diff --git a/cmd/server/tests/device_test.go b/cmd/server/tests/device_test.go index efddc754..481c630b 100644 --- a/cmd/server/tests/device_test.go +++ b/cmd/server/tests/device_test.go @@ -524,6 +524,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) { FirstName: pgtype.Text{String: "Different", Valid: true}, LastName: pgtype.Text{String: "User", Valid: true}, Role: "user", + Timezone: pgtype.Text{String: "UTC", Valid: true}, }) assert.NoError(t, err) diff --git a/cmd/server/tests/ownership_and_admin_test.go b/cmd/server/tests/ownership_and_admin_test.go index 266b5291..5693947f 100644 --- a/cmd/server/tests/ownership_and_admin_test.go +++ b/cmd/server/tests/ownership_and_admin_test.go @@ -190,6 +190,21 @@ func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() { assert.Equal(s.T(), unique, created["username"]) assert.Equal(s.T(), "user", created["role"]) + // New users inherit the system-wide default timezone (this was the + // point of the setting; before 2026-10-13 every new user got UTC + // regardless of it). + expectedTZ := "UTC" + var settingTZ string + err = s.setup.DBPool.QueryRow(s.setup.QueueCtx, + "SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'").Scan(&settingTZ) + if err == nil { + if _, locErr := time.LoadLocation(settingTZ); locErr == nil && settingTZ != "" { + expectedTZ = settingTZ + } + } + assert.Equal(s.T(), expectedTZ, created["timezone"], + "created user must carry the system default timezone") + // Duplicate username → 409. req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") diff --git a/cmd/server/tests/sync_integration_test.go b/cmd/server/tests/sync_integration_test.go index 9dc58196..9b49c8a0 100644 --- a/cmd/server/tests/sync_integration_test.go +++ b/cmd/server/tests/sync_integration_test.go @@ -55,6 +55,7 @@ func createSyncTestUser(t *testing.T, db *database.Queries) pgtype.UUID { FirstName: pgtype.Text{String: "Test", Valid: true}, LastName: pgtype.Text{String: "Sync", Valid: true}, Role: "user", + Timezone: pgtype.Text{String: "UTC", Valid: true}, }) require.NoError(t, err) diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 2848cc15..f0b41af1 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -246,6 +246,7 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData { FirstName: pgtype.Text{String: "Regular", Valid: true}, LastName: pgtype.Text{String: "User", Valid: true}, Role: "user", + Timezone: pgtype.Text{String: "UTC", Valid: true}, }) require.NoError(t, err, "Should create regular test user") @@ -696,6 +697,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { FirstName: pgtype.Text{String: "Regular", Valid: true}, LastName: pgtype.Text{String: "User", Valid: true}, Role: "user", + Timezone: pgtype.Text{String: "UTC", Valid: true}, }) require.NoError(t, err, "Failed to create regular test user") diff --git a/cmd/server/tests/user_test.go b/cmd/server/tests/user_test.go index 00e14835..6639711a 100644 --- a/cmd/server/tests/user_test.go +++ b/cmd/server/tests/user_test.go @@ -8,22 +8,65 @@ import ( "net/http/httptest" "testing" + "bookhoard/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" ) +// ensureSingleAdmin makes the "last admin" precondition deterministic in +// the shared test DB: only the preserved testuser admin remains. Other +// suites may have promoted or renamed users; the last-admin semantics +// under test require a true singleton. +func ensureSingleAdmin(t *testing.T, setup *TestServerSetup) { + ctx := context.Background() + _, err := setup.DBPool.Exec(ctx, + "DELETE FROM users WHERE role = 'admin' AND email != 'testuser@tests.bookhoard.internal'") + require.NoError(t, err, "Failed to prune extra admins") + + if _, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); err != nil { + passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" + _, cerr := setup.DB.CreateUser(ctx, database.CreateUserParams{ + Email: "testuser@tests.bookhoard.internal", + Username: "testuser", + PasswordHash: passwordHash, + Role: "admin", + Timezone: pgtype.Text{String: "UTC", Valid: true}, + }) + require.NoError(t, cerr, "Failed to restore the shared admin") + } + + users, _ := setup.DB.ListUsers(ctx) + adminCount := 0 + for _, u := range users { + if u.Role == "admin" { + adminCount++ + } + } + require.Equal(t, 1, adminCount, "precondition: exactly one admin") +} + func TestUpdateProfileSelfEdit(t *testing.T) { t.Run("PUT /api/auth/profile - Update all fields successfully", func(t *testing.T) { setup := setupTestServer(t) - _ = createTestUserOnce(t, setup.DB) - token := setup.Token + // Self-edit a DEDICATED user, never the shared admin: renaming + // testuser here leaked a second admin row into the shared DB (the + // renamed email escapes the harness's preserve-list cleanup) and + // broke the last-admin tests downstream. + selfUser := createRegularUserOnce(t, setup.DB) + token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password) + // Unique rename target: a fixed name collides with stale rows from + // earlier runs of this very test (409 username conflict). + suffix := uuid.New().String()[:8] + newUsername := "updateduser-" + suffix updateData := map[string]interface{}{ - "username": "updateduser", - "email": "updated@tests.bookhoard.internal", + "username": newUsername, + "email": "updated-" + suffix + "@tests.bookhoard.internal", "first_name": "Updated", "last_name": "Name", "theme": "dracula", @@ -39,9 +82,9 @@ func TestUpdateProfileSelfEdit(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code) - user, err := setup.DB.GetUserByUsername(context.Background(), "updateduser") + user, err := setup.DB.GetUserByUsername(context.Background(), newUsername) require.NoError(t, err) - assert.Equal(t, "updated@tests.bookhoard.internal", user.Email) + assert.Equal(t, "updated-"+suffix+"@tests.bookhoard.internal", user.Email) assert.Equal(t, "Updated", user.FirstName.String) assert.Equal(t, "Name", user.LastName.String) assert.Equal(t, "dracula", user.Theme.String) @@ -215,6 +258,10 @@ func TestUpdateProfileAdminMode(t *testing.T) { t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) { setup := setupTestServer(t) + // Deterministic singleton-admin precondition (shared DB may carry + // promoted/renamed admin rows from other suites). + ensureSingleAdmin(t, setup) + // Delete the regular user so we have only 1 admin (testuser) regularUser := createRegularUserOnce(t, setup.DB) err := setup.DB.DeleteUser(context.Background(), uuidToPGType(regularUser.ID)) @@ -435,6 +482,7 @@ func TestDeleteUserConsolidated(t *testing.T) { t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) { setup := setupTestServer(t) + ensureSingleAdmin(t, setup) _ = createTestUserOnce(t, setup.DB) token := setup.Token @@ -471,6 +519,7 @@ func TestDeleteUserConsolidated(t *testing.T) { t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) { setup := setupTestServer(t) + ensureSingleAdmin(t, setup) _ = createTestUserOnce(t, setup.DB) token := setup.Token diff --git a/cmd/server/tests/websocket_test.go b/cmd/server/tests/websocket_test.go index 8094f84b..2b6d1eae 100644 --- a/cmd/server/tests/websocket_test.go +++ b/cmd/server/tests/websocket_test.go @@ -240,9 +240,11 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) { // Use pre-created admin user (setup.Token) and regular user (setup.RegularToken) // Both users already created by setupTestServer() - // Create a collection for admin user + // Create a collection for admin user. Unique name: the shared DB keeps + // non-"test"-named collections across runs, and UNIQUE(user_id, name) + // turns a fixed name into a 409 on the second run. collectionReq := map[string]interface{}{ - "name": "Admin Collection", + "name": "Admin Collection " + uuid.New().String()[:8], "description": "Test collection for WebSocket test", } collectionBody, _ := json.Marshal(collectionReq) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 262c67ac..cbe71880 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2003,9 +2003,9 @@ func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBook } const CreateUser = `-- name: CreateUser :one -INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, email, username, theme, first_name, last_name, role, created_at, updated_at +INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role, timezone) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, email, username, theme, first_name, last_name, role, timezone, created_at, updated_at ` type CreateUserParams struct { @@ -2016,6 +2016,7 @@ type CreateUserParams struct { LastName pgtype.Text `db:"last_name" json:"last_name"` Theme pgtype.Text `db:"theme" json:"theme"` Role string `db:"role" json:"role"` + Timezone pgtype.Text `db:"timezone" json:"timezone"` } type CreateUserRow struct { @@ -2026,6 +2027,7 @@ type CreateUserRow struct { FirstName pgtype.Text `db:"first_name" json:"first_name"` LastName pgtype.Text `db:"last_name" json:"last_name"` Role string `db:"role" json:"role"` + Timezone pgtype.Text `db:"timezone" json:"timezone"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } @@ -2039,6 +2041,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU arg.LastName, arg.Theme, arg.Role, + arg.Timezone, ) var i CreateUserRow err := row.Scan( @@ -2049,6 +2052,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU &i.FirstName, &i.LastName, &i.Role, + &i.Timezone, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index ff0190a0..eec78ccc 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -1,7 +1,7 @@ -- name: CreateUser :one -INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, email, username, theme, first_name, last_name, role, created_at, updated_at; +INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role, timezone) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, email, username, theme, first_name, last_name, role, timezone, created_at, updated_at; -- name: GetUserByEmail :one SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1; diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 6ce7a1eb..e97bdec8 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -39,6 +39,23 @@ var SessionDurationSec = int(DefaultSessionDuration.Seconds()) // SetSettings wires the tunable settings registry (optional). func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s } +// defaultTimezone returns the system-wide default timezone for new users. +// Falls back to UTC when no registry is wired, the setting is empty, or +// the value is not a valid IANA location. +func (h *AuthHandler) defaultTimezone() string { + if h.settings == nil { + return "UTC" + } + tz := h.settings.DefaultTimezone() + if tz == "" { + return "UTC" + } + if _, err := time.LoadLocation(tz); err != nil { + return "UTC" + } + return tz +} + // sessionDuration returns the active session duration from the registry. func (h *AuthHandler) sessionDuration() time.Duration { if h.settings != nil { @@ -263,6 +280,7 @@ func (h *AuthHandler) Register(c *echo.Context) error { LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""}, Theme: pgtype.Text{String: "tokyo-night", Valid: true}, Role: userRole, + Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true}, }) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { @@ -735,6 +753,7 @@ func (h *AuthHandler) AdminCreateUser(c *echo.Context) error { LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""}, Theme: pgtype.Text{String: "tokyo-night", Valid: true}, Role: role, + Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -756,6 +775,7 @@ func (h *AuthHandler) AdminCreateUser(c *echo.Context) error { "email": user.Email, "username": user.Username, "role": user.Role, + "timezone": user.Timezone, "created_at": user.CreatedAt, }) }