diff --git a/frontend/tests/vitest/common/config.test.js b/frontend/tests/vitest/common/config.test.js index 6f588d21a..8657796ec 100644 --- a/frontend/tests/vitest/common/config.test.js +++ b/frontend/tests/vitest/common/config.test.js @@ -165,6 +165,35 @@ describe("common/config", () => { expect(config.getIcon()).toBe("/i/pro-1/_theme/logo.svg"); }); + it("getIcon resolves a built-in appIcon name against the static uri", () => { + const config = new Config(new StorageShim(), { siteTitle: "Foo", baseUri: "/i/pro-1", appIcon: "bloom" }); + expect(config.getIcon()).toBe("/i/pro-1/static/icons/bloom.svg"); + }); + + it("getIcon resolves each selectable app-icon variant to its static svg", () => { + ["crisp", "mint", "bold", "bloom", "flower", "ring", "shutter"].forEach((name) => { + const config = new Config(new StorageShim(), { siteTitle: "Foo", appIcon: name }); + expect(config.getIcon()).toBe(`/static/icons/${name}.svg`); + }); + }); + + it("getIcon falls back to the logo for an unknown or empty app-icon name", () => { + expect(new Config(new StorageShim(), { siteTitle: "Foo", appIcon: "does-not-exist" }).getIcon()).toBe("/static/icons/logo.svg"); + expect(new Config(new StorageShim(), { siteTitle: "Foo" }).getIcon()).toBe("/static/icons/logo.svg"); + }); + + it("getIcon prefers a theme-provided icon over the appIcon setting", () => { + const config = new Config(new StorageShim(), { + siteTitle: "Foo", + baseUri: "/i/pro-1", + appIcon: "bloom", + settings: { ui: { theme: "branded-icon" } }, + }); + themes.Set("branded-icon", { name: "branded-icon", title: "Branded", colors: {}, variables: { icon: "logo.svg" } }); + config.setTheme("branded-icon"); + expect(config.getIcon()).toBe("/i/pro-1/_theme/logo.svg"); + }); + it("should store values", () => { const storage = new StorageShim(); const values = { siteTitle: "Foo", country: "Germany", city: "Hamburg" }; diff --git a/internal/api/api_auth_test.go b/internal/api/api_auth_test.go index 6965fb419..c15cadfba 100644 --- a/internal/api/api_auth_test.go +++ b/internal/api/api_auth_test.go @@ -142,6 +142,37 @@ func TestAuthAny_AppPasswordsDisabled(t *testing.T) { assert.Equal(t, http.StatusForbidden, s2.HttpStatus()) }) } + + // Negative control: the gate keys on IsApplication(), so a non-application + // session (here a client-credentials grant) must keep authorizing whether or + // not app passwords are disabled, proving the flag does not over-reject. + t.Run("NonApplicationSessionUnaffected", func(t *testing.T) { + sess, err := entity.AddClientSession("alice-client-cred", conf.SessionMaxAge(), "*", authn.GrantClientCredentials, nil) + require.NoError(t, err) + require.False(t, sess.IsApplication()) + token := sess.AuthToken() + + authPhotos := func() *entity.Session { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil) + header.SetAuthorization(req, token) + req.RemoteAddr = "10.9.8.7:4321" + c.Request = req + return AuthAny(c, acl.ResourcePhotos, acl.Permissions{acl.ActionView}) + } + + conf.Settings().Features.AppPasswords = true + s := authPhotos() + require.NotNil(t, s) + assert.Equal(t, http.StatusOK, s.HttpStatus()) + + conf.Settings().Features.AppPasswords = false + s2 := authPhotos() + require.NotNil(t, s2) + assert.Equal(t, http.StatusOK, s2.HttpStatus(), "disabling app passwords must not reject non-application sessions") + }) } func TestAuthAny_AppPasswordWebLoginDisabled(t *testing.T) { diff --git a/internal/api/folders_search_test.go b/internal/api/folders_search_test.go index 38a1ef43f..43cec9667 100644 --- a/internal/api/folders_search_test.go +++ b/internal/api/folders_search_test.go @@ -2,6 +2,8 @@ package api import ( "encoding/json" + "net/http" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -94,6 +96,32 @@ func TestGetFoldersOriginals(t *testing.T) { }) } +func TestSearchFolders_Headers(t *testing.T) { + app, router, conf := NewApiTest() + _ = conf.CreateDirectories() + SearchFoldersOriginals(router) + + // Request uncached results so the count headers are always written + // (a cache hit returns early before the headers are added). + r := PerformRequest(app, "GET", "/api/v1/folders/originals?uncached=true") + assert.Equal(t, http.StatusOK, r.Code) + result := r.Result() + + xFiles, err := strconv.Atoi(result.Header.Get("X-Files")) + assert.NoError(t, err, "X-Files header should be an integer") + xFolders, err := strconv.Atoi(result.Header.Get("X-Folders")) + assert.NoError(t, err, "X-Folders header should be an integer") + xCount, err := strconv.Atoi(result.Header.Get("X-Count")) + assert.NoError(t, err, "X-Count header should be an integer") + + // X-Count is the combined number of files and folders returned. + assert.Equal(t, xFiles+xFolders, xCount) + + // An unpaginated request reports the form's zero-value count and offset. + assert.Equal(t, "0", result.Header.Get("X-Limit")) + assert.Equal(t, "0", result.Header.Get("X-Offset")) +} + func TestGetFoldersImport(t *testing.T) { t.Run("Flat", func(t *testing.T) { app, router, conf := NewApiTest() diff --git a/internal/entity/photo_test.go b/internal/entity/photo_test.go index 49c827a8c..70953598d 100644 --- a/internal/entity/photo_test.go +++ b/internal/entity/photo_test.go @@ -2,8 +2,10 @@ package entity import ( "fmt" + "strings" "testing" "time" + "unicode/utf8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1645,10 +1647,21 @@ func TestPhoto_ArchiveRestore(t *testing.T) { } func TestPhoto_SetCameraSerial(t *testing.T) { - m := &Photo{} - assert.Empty(t, m.CameraSerial) - m.SetCameraSerial("abcCamera") - assert.Equal(t, "abcCamera", m.CameraSerial) + t.Run("Success", func(t *testing.T) { + m := &Photo{} + assert.Empty(t, m.CameraSerial) + m.SetCameraSerial("abcCamera") + assert.Equal(t, "abcCamera", m.CameraSerial) + }) + t.Run("MultiByteClippedOnRuneBoundary", func(t *testing.T) { + // camera_serial is VARBINARY(160), so a long multi-byte value must be byte-clipped + // without splitting a rune, keeping the stored value within budget and valid UTF-8. + m := &Photo{} + m.SetCameraSerial(strings.Repeat("δΈ–", 100)) // 100 runes x 3 bytes = 300 bytes. + assert.NotEmpty(t, m.CameraSerial) + assert.LessOrEqual(t, len(m.CameraSerial), txt.ClipDefault) + assert.True(t, utf8.ValidString(m.CameraSerial)) + }) } func TestPhoto_MapKey(t *testing.T) { diff --git a/internal/form/lens_test.go b/internal/form/lens_test.go index a7b091eaa..681398640 100644 --- a/internal/form/lens_test.go +++ b/internal/form/lens_test.go @@ -27,3 +27,18 @@ func TestNewLens(t *testing.T) { assert.Equal(t, "New Model", result.LensModel) }) } + +func TestLens_Validate(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + frm := &Lens{LensMake: "Canon", LensModel: "EF 50mm f/1.8"} + assert.NoError(t, frm.Validate()) + }) + t.Run("EmptyMake", func(t *testing.T) { + frm := &Lens{LensMake: "", LensModel: "EF 50mm f/1.8"} + assert.Error(t, frm.Validate()) + }) + t.Run("EmptyModel", func(t *testing.T) { + frm := &Lens{LensMake: "Canon", LensModel: ""} + assert.Error(t, frm.Validate()) + }) +}