mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-14 01:10:54 +03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9245851126 | ||
|
|
39b7f6678c | ||
|
|
e45d9e970d | ||
|
|
8ead0be8cd | ||
|
|
9f28503d6c | ||
|
|
26e05947fe | ||
|
|
348192b9d7 | ||
|
|
b483e2e92f |
34
.github/workflows/unit-tests.yml
vendored
Normal file
34
.github/workflows/unit-tests.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Unit Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: 'backend/go.mod'
|
||||||
|
cache-dependency-path: 'backend/go.sum'
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
go get ./...
|
||||||
|
- name: Run backend unit tests
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
go test -v ./... | tee /tmp/TestResults.log
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: backend-unit-tests
|
||||||
|
path: /tmp/TestResults.log
|
||||||
|
retention-days: 15
|
||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,3 +1,15 @@
|
|||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.0...v) (2025-03-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* API keys not working if sqlite is used ([8ead0be](https://github.com/pocket-id/pocket-id/commit/8ead0be8cd0cfb542fe488b7251cfd5274975ae1))
|
||||||
|
* caching for own profile picture ([e45d9e9](https://github.com/pocket-id/pocket-id/commit/e45d9e970d327a5120ff9fb0c8d42df8af69bb38))
|
||||||
|
* email logo icon displaying too big ([#336](https://github.com/pocket-id/pocket-id/issues/336)) ([b483e2e](https://github.com/pocket-id/pocket-id/commit/b483e2e92fdb528e7de026350a727d6970227426))
|
||||||
|
* emails are considered as medium spam by rspamd ([#337](https://github.com/pocket-id/pocket-id/issues/337)) ([39b7f66](https://github.com/pocket-id/pocket-id/commit/39b7f6678c98cadcdc3abfbcb447d8eb0daa9eb0))
|
||||||
|
* Fixes and performance improvements in utils package ([#331](https://github.com/pocket-id/pocket-id/issues/331)) ([348192b](https://github.com/pocket-id/pocket-id/commit/348192b9d7e2698add97810f8fba53d13d0df018))
|
||||||
|
* remove custom claim key restrictions ([9f28503](https://github.com/pocket-id/pocket-id/commit/9f28503d6c73d3521d1309bee055704a0507e9b5))
|
||||||
|
|
||||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.39.0...v) (2025-03-13)
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.39.0...v) (2025-03-13)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
|
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
|
||||||
|
|
||||||
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
|
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
|
||||||
group.GET("/users/me/profile-picture.png", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserProfilePictureHandler)
|
|
||||||
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
|
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
|
||||||
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
|
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
|
||||||
|
|
||||||
@@ -249,24 +249,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
c.Header("Cache-Control", "public, max-age=300")
|
||||||
}
|
|
||||||
|
|
||||||
// getCurrentUserProfilePictureHandler godoc
|
|
||||||
// @Summary Get current user's profile picture
|
|
||||||
// @Description Retrieve the currently authenticated user's profile picture
|
|
||||||
// @Tags Users
|
|
||||||
// @Produce image/png
|
|
||||||
// @Success 200 {file} binary "PNG image"
|
|
||||||
// @Router /users/me/profile-picture.png [get]
|
|
||||||
func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) {
|
|
||||||
userID := c.GetString("userID")
|
|
||||||
|
|
||||||
picture, size, err := uc.userService.GetProfilePicture(userID)
|
|
||||||
if err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ type CustomClaimDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CustomClaimCreateDto struct {
|
type CustomClaimCreateDto struct {
|
||||||
Key string `json:"key" binding:"required,claimKey"`
|
Key string `json:"key" binding:"required"`
|
||||||
Value string `json:"value" binding:"required"`
|
Value string `json:"value" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,22 +16,10 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
|
|
||||||
// The string can only contain letters and numbers
|
|
||||||
regex := "^[A-Za-z0-9]*$"
|
|
||||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
|
||||||
return matched
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
log.Fatalf("Failed to register custom validation: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
|
||||||
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
|
|
||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) {
|
|||||||
hashedKey := utils.CreateSha256Hash(apiKey)
|
hashedKey := utils.CreateSha256Hash(apiKey)
|
||||||
|
|
||||||
if err := s.db.Preload("User").Where("key = ? AND expires_at > ?",
|
if err := s.db.Preload("User").Where("key = ? AND expires_at > ?",
|
||||||
hashedKey, time.Now()).Preload("User").First(&key).Error; err != nil {
|
hashedKey, datatype.DateTime(time.Now())).Preload("User").First(&key).Error; err != nil {
|
||||||
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return model.User{}, &common.InvalidAPIKeyError{}
|
return model.User{}, &common.InvalidAPIKeyError{}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
ttemplate "text/template"
|
ttemplate "text/template"
|
||||||
"time"
|
"time"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
@@ -84,6 +86,29 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
|
|||||||
c.AddHeaderRaw("Content-Type",
|
c.AddHeaderRaw("Content-Type",
|
||||||
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
|
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
c.AddHeader("MIME-Version", "1.0")
|
||||||
|
c.AddHeader("Date", time.Now().Format(time.RFC1123Z))
|
||||||
|
|
||||||
|
// to create a message-id, we need the FQDN of the sending server, but that may be a docker hostname or localhost
|
||||||
|
// so we use the domain of the from address instead (the same as Thunderbird does)
|
||||||
|
// if the address does not have an @ (which would be unusual), we use hostname
|
||||||
|
|
||||||
|
from_address := srv.appConfigService.DbConfig.SmtpFrom.Value
|
||||||
|
domain := ""
|
||||||
|
if strings.Contains(from_address, "@") {
|
||||||
|
domain = strings.Split(from_address, "@")[1]
|
||||||
|
} else {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
// can that happen? we just give up
|
||||||
|
return fmt.Errorf("failed to get own hostname: %w", err)
|
||||||
|
} else {
|
||||||
|
domain = hostname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.AddHeader("Message-ID", "<" + uuid.New().String() + "@" + domain + ">")
|
||||||
|
|
||||||
c.Body(body)
|
c.Body(body)
|
||||||
|
|
||||||
// Connect to the SMTP server
|
// Connect to the SMTP server
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ func genAddressHeader(name string, addresses []Address, maxLength int) string {
|
|||||||
} else {
|
} else {
|
||||||
email = fmt.Sprintf("<%s>", addr.Email)
|
email = fmt.Sprintf("<%s>", addr.Email)
|
||||||
}
|
}
|
||||||
writeHeaderQ(hl, addr.Name)
|
if isPrintableASCII(addr.Name) {
|
||||||
|
writeHeaderAtom(hl, addr.Name)
|
||||||
|
} else {
|
||||||
|
writeHeaderQ(hl, addr.Name)
|
||||||
|
}
|
||||||
writeHeaderAtom(hl, " ")
|
writeHeaderAtom(hl, " ")
|
||||||
writeHeaderAtom(hl, email)
|
writeHeaderAtom(hl, email)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetFileExtension(filename string) string {
|
func GetFileExtension(filename string) string {
|
||||||
splitted := strings.Split(filename, ".")
|
ext := filepath.Ext(filename)
|
||||||
return splitted[len(splitted)-1]
|
if len(ext) > 0 && ext[0] == '.' {
|
||||||
|
return ext[1:]
|
||||||
|
}
|
||||||
|
return filename
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImageMimeType(ext string) string {
|
func GetImageMimeType(ext string) string {
|
||||||
|
|||||||
73
backend/internal/utils/file_util_test.go
Normal file
73
backend/internal/utils/file_util_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetFileExtension(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Simple file with extension",
|
||||||
|
filename: "document.pdf",
|
||||||
|
want: "pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File with path",
|
||||||
|
filename: "/path/to/document.txt",
|
||||||
|
want: "txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File with path (Windows style)",
|
||||||
|
filename: "C:\\path\\to\\document.jpg",
|
||||||
|
want: "jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple extensions",
|
||||||
|
filename: "archive.tar.gz",
|
||||||
|
want: "gz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hidden file with extension",
|
||||||
|
filename: ".config.json",
|
||||||
|
want: "json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Filename with dots",
|
||||||
|
filename: "version.1.2.3.txt",
|
||||||
|
want: "txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File with uppercase extension",
|
||||||
|
filename: "image.JPG",
|
||||||
|
want: "JPG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File without extension",
|
||||||
|
filename: "README",
|
||||||
|
want: "README",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hidden file without extension",
|
||||||
|
filename: ".gitignore",
|
||||||
|
want: "gitignore",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty filename",
|
||||||
|
filename: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := GetFileExtension(tt.filename)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("GetFileExtension(%q) = %q, want %q", tt.filename, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, err
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = imaging.Encode(&buf, img, imaging.PNG)
|
err = imaging.Encode(&buf, img, imaging.PNG)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encode image: %v", err)
|
return nil, fmt.Errorf("failed to encode image: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &buf, nil
|
return &buf, nil
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,23 +14,41 @@ import (
|
|||||||
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
||||||
func GenerateRandomAlphanumericString(length int) (string, error) {
|
func GenerateRandomAlphanumericString(length int) (string, error) {
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
const charsetLength = int64(len(charset))
|
|
||||||
|
|
||||||
if length <= 0 {
|
if length <= 0 {
|
||||||
return "", fmt.Errorf("length must be a positive integer")
|
return "", errors.New("length must be a positive integer")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]byte, length)
|
// The algorithm below is adapted from https://stackoverflow.com/a/35615565
|
||||||
|
const (
|
||||||
|
letterIdxBits = 6 // 6 bits to represent a letter index
|
||||||
|
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
|
||||||
|
)
|
||||||
|
|
||||||
for i := range result {
|
result := strings.Builder{}
|
||||||
num, err := rand.Int(rand.Reader, big.NewInt(charsetLength))
|
result.Grow(length)
|
||||||
if err != nil {
|
// Because we discard a bunch of bytes, we read more in the buffer to minimize the changes of performing additional IO
|
||||||
return "", err
|
bufferSize := int(float64(length) * 1.3)
|
||||||
|
randomBytes := make([]byte, bufferSize)
|
||||||
|
for i, j := 0, 0; i < length; j++ {
|
||||||
|
// Fill the buffer if needed
|
||||||
|
if j%bufferSize == 0 {
|
||||||
|
_, err := io.ReadFull(rand.Reader, randomBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard bytes that are outside of the range
|
||||||
|
// This allows making sure that we maintain uniform distribution
|
||||||
|
idx := int(randomBytes[j%length] & letterIdxMask)
|
||||||
|
if idx < len(charset) {
|
||||||
|
result.WriteByte(charset[idx])
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
result[i] = charset[num.Int64()]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(result), nil
|
return result.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHostnameFromURL(rawURL string) string {
|
func GetHostnameFromURL(rawURL string) string {
|
||||||
@@ -45,30 +64,40 @@ func StringPointer(s string) *string {
|
|||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
func CapitalizeFirstLetter(s string) string {
|
func CapitalizeFirstLetter(str string) string {
|
||||||
if s == "" {
|
if str == "" {
|
||||||
return s
|
return ""
|
||||||
}
|
}
|
||||||
runes := []rune(s)
|
|
||||||
runes[0] = unicode.ToUpper(runes[0])
|
result := strings.Builder{}
|
||||||
return string(runes)
|
result.Grow(len(str))
|
||||||
|
for i, r := range str {
|
||||||
|
if i == 0 {
|
||||||
|
result.WriteRune(unicode.ToUpper(r))
|
||||||
|
} else {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func CamelCaseToSnakeCase(s string) string {
|
func CamelCaseToSnakeCase(str string) string {
|
||||||
var result []rune
|
result := strings.Builder{}
|
||||||
for i, r := range s {
|
result.Grow(int(float32(len(str)) * 1.1))
|
||||||
|
for i, r := range str {
|
||||||
if unicode.IsUpper(r) && i > 0 {
|
if unicode.IsUpper(r) && i > 0 {
|
||||||
result = append(result, '_')
|
result.WriteByte('_')
|
||||||
}
|
}
|
||||||
result = append(result, unicode.ToLower(r))
|
result.WriteRune(unicode.ToLower(r))
|
||||||
}
|
}
|
||||||
return string(result)
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||||
|
|
||||||
func CamelCaseToScreamingSnakeCase(s string) string {
|
func CamelCaseToScreamingSnakeCase(s string) string {
|
||||||
// Insert underscores before uppercase letters (except the first one)
|
// Insert underscores before uppercase letters (except the first one)
|
||||||
re := regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`)
|
||||||
snake := re.ReplaceAllString(s, `${1}_${2}`)
|
|
||||||
|
|
||||||
// Convert to uppercase
|
// Convert to uppercase
|
||||||
return strings.ToUpper(snake)
|
return strings.ToUpper(snake)
|
||||||
|
|||||||
105
backend/internal/utils/string_util_test.go
Normal file
105
backend/internal/utils/string_util_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateRandomAlphanumericString(t *testing.T) {
|
||||||
|
t.Run("valid length returns correct string", func(t *testing.T) {
|
||||||
|
const length = 10
|
||||||
|
str, err := GenerateRandomAlphanumericString(length)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(str) != length {
|
||||||
|
t.Errorf("Expected length %d, got %d", length, len(str))
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, err := regexp.MatchString(`^[a-zA-Z0-9]+$`, str)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Regex match failed: %v", err)
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
t.Errorf("String contains non-alphanumeric characters: %s", str)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero length returns error", func(t *testing.T) {
|
||||||
|
_, err := GenerateRandomAlphanumericString(0)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for zero length, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("negative length returns error", func(t *testing.T) {
|
||||||
|
_, err := GenerateRandomAlphanumericString(-1)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for negative length, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generates different strings", func(t *testing.T) {
|
||||||
|
str1, _ := GenerateRandomAlphanumericString(10)
|
||||||
|
str2, _ := GenerateRandomAlphanumericString(10)
|
||||||
|
if str1 == str2 {
|
||||||
|
t.Error("Generated strings should be different")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapitalizeFirstLetter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"empty string", "", ""},
|
||||||
|
{"lowercase first letter", "hello", "Hello"},
|
||||||
|
{"already capitalized", "Hello", "Hello"},
|
||||||
|
{"single lowercase letter", "h", "H"},
|
||||||
|
{"single uppercase letter", "H", "H"},
|
||||||
|
{"starts with number", "123abc", "123abc"},
|
||||||
|
{"unicode character", "étoile", "Étoile"},
|
||||||
|
{"special character", "_test", "_test"},
|
||||||
|
{"multi-word", "hello world", "Hello world"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := CapitalizeFirstLetter(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("CapitalizeFirstLetter(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCamelCaseToSnakeCase(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"empty string", "", ""},
|
||||||
|
{"simple camelCase", "camelCase", "camel_case"},
|
||||||
|
{"PascalCase", "PascalCase", "pascal_case"},
|
||||||
|
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"},
|
||||||
|
{"consecutive uppercase", "HTTPRequest", "h_t_t_p_request"},
|
||||||
|
{"single lowercase word", "word", "word"},
|
||||||
|
{"single uppercase word", "WORD", "w_o_r_d"},
|
||||||
|
{"with numbers", "camel123Case", "camel123_case"},
|
||||||
|
{"with numbers in middle", "model2Name", "model2_name"},
|
||||||
|
{"mixed case", "iPhone6sPlus", "i_phone6s_plus"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := CamelCaseToSnakeCase(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("CamelCaseToSnakeCase(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{{ define "base" }}
|
{{ define "base" }}
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
|
||||||
<h1>{{ .AppName }}</h1>
|
<h1>{{ .AppName }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="warning">Warning</div>
|
<div class="warning">Warning</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{ define "base" }}
|
{{ define "base" }}
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
|
||||||
<h1>{{ .AppName }}</h1>
|
<h1>{{ .AppName }}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.40.0",
|
"version": "0.40.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
|
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
|
||||||
let selectedIndex = $state(-1);
|
let selectedIndex = $state(-1);
|
||||||
let keyError: string | undefined = $state();
|
|
||||||
|
|
||||||
let isInputFocused = $state(false);
|
let isInputFocused = $state(false);
|
||||||
|
|
||||||
@@ -26,13 +25,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOnInput() {
|
function handleOnInput() {
|
||||||
if (value.length > 0 && !/^[A-Za-z0-9]*$/.test(value)) {
|
|
||||||
keyError = 'Only alphanumeric characters are allowed';
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
keyError = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredSuggestions = suggestions
|
filteredSuggestions = suggestions
|
||||||
.filter((s) => s.includes(value.toLowerCase()))
|
.filter((s) => s.includes(value.toLowerCase()))
|
||||||
.slice(0, suggestionLimit);
|
.slice(0, suggestionLimit);
|
||||||
@@ -83,9 +75,6 @@
|
|||||||
onfocus={() => (isInputFocused = true)}
|
onfocus={() => (isInputFocused = true)}
|
||||||
onblur={() => (isInputFocused = false)}
|
onblur={() => (isInputFocused = false)}
|
||||||
/>
|
/>
|
||||||
{#if keyError}
|
|
||||||
<p class="mt-1 text-sm text-red-500">{keyError}</p>
|
|
||||||
{/if}
|
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
disableFocusTrap
|
disableFocusTrap
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Content class="pt-6">
|
<Card.Content class="pt-6">
|
||||||
<ProfilePictureSettings
|
<ProfilePictureSettings
|
||||||
userId="me"
|
userId={account.id}
|
||||||
isLdapUser={!!account.ldapId}
|
isLdapUser={!!account.ldapId}
|
||||||
callback={updateProfilePicture}
|
callback={updateProfilePicture}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user