package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "testing" "time" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) // OwnershipAndAdminTestSuite pins the 2026-10-13 hardening: per-user // annotation mutations are ownership-scoped (no cross-user delete/update by // bare row id), and admins can create users even with self-service // registration disabled. type OwnershipAndAdminTestSuite struct { suite.Suite setup *TestServerSetup token string itemID string libraryID string absFile string } func (s *OwnershipAndAdminTestSuite) SetupSuite() { s.setup = setupTestServer(s.T()) s.token = s.setup.Token var libID string s.itemID, libID, s.absFile = s.createItemViaDB(true) s.libraryID = libID // createTestMediaItemID's t.Cleanup path can't be used at suite level // (it fires after TearDownSuite has closed the server), so the item is // created directly through the DB handle; suite cleanup relies on the // standard "@tests.bookhoard.internal / test-named rows" passes of the // next run's setupTestServer. } // createItemViaDB makes a throwaway library + media item directly through // the test DB handle — no HTTP, no t.Cleanup entanglement. // With withFolder=true the library gets a REAL temp folder row and the // file is created on disk inside it, so deletes exercise real file // removal; the folder path is returned alongside the item id. func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, libraryID, absFile string) { ctx := s.setup.QueueCtx db := s.setup.DB libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks") require.NoError(s.T(), err, "should find ebooks library type") admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal") require.NoError(s.T(), err, "dev admin user should exist") library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{ Name: fmt.Sprintf("Ownership Suite Test Library %d", time.Now().UnixNano()), Description: pgtype.Text{String: "annotation ownership suite", Valid: true}, LibraryTypeID: libraryTypeRow.ID, CreatedByAdminID: admin.ID, }) require.NoError(s.T(), err) relPath := fmt.Sprintf("ownership-suite-%d.epub", time.Now().UnixNano()) absFile = "" if withFolder { dir, dirErr := os.MkdirTemp("", "ownership-suite-*") require.NoError(s.T(), dirErr) _, ferr := db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{ LibraryID: library.ID, FolderPath: dir, }) require.NoError(s.T(), ferr) absFile = filepath.Join(dir, relPath) if werr := os.WriteFile(absFile, []byte("restored book"), 0o644); werr != nil { require.NoError(s.T(), werr) } } item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: library.ID, Title: "Ownership Suite Item", Authors: []string{"Suite Author"}, FilePath: relPath, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }) require.NoError(s.T(), err) return uuid.UUID(item.ID.Bytes).String(), uuid.UUID(library.ID.Bytes).String(), absFile } func (s *OwnershipAndAdminTestSuite) TearDownSuite() { s.setup.Close() } func (s *OwnershipAndAdminTestSuite) getItem(itemID string) map[string]interface{} { req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/media-items/"+itemID, nil) req.Header.Set("Authorization", "Bearer "+s.token) resp, err := (&http.Client{}).Do(req) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode) var body map[string]interface{} require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body)) return body } func (s *OwnershipAndAdminTestSuite) createHighlight(token string) map[string]interface{} { payload, _ := json.Marshal(map[string]interface{}{ "selection_text": "owned annotation text", "start_position": "p1", "end_position": "p1", "color": "#ffff00", }) req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := (&http.Client{}).Do(req) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "highlight create should succeed") var body map[string]interface{} require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body)) return body } func (s *OwnershipAndAdminTestSuite) highlightExists(token, highlightID string) bool { req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := (&http.Client{}).Do(req) require.NoError(s.T(), err) defer resp.Body.Close() var body []map[string]interface{} require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body)) for _, h := range body { if h["id"] == highlightID { return true } } return false } // TestHighlightDelete_CrossUserNoOp pins the ownership predicate: user B's // DELETE of user A's highlight by bare id must not remove A's row. func (s *OwnershipAndAdminTestSuite) TestHighlightDelete_CrossUserNoOp() { regular := s.setup.RegularToken created := s.createHighlight(regular) highlightID, ok := created["id"].(string) require.True(s.T(), ok, "create response carries the highlight id") // The ADMIN (a different user) tries to delete it by id. req, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights/"+highlightID, nil) req.Header.Set("Authorization", "Bearer "+s.token) resp, err := (&http.Client{}).Do(req) require.NoError(s.T(), err) io.Copy(io.Discard, resp.Body) resp.Body.Close() assert.True(s.T(), s.highlightExists(regular, highlightID), "another user's delete-by-id must not remove the owner's highlight") // The owner deletes their own highlight — gone. req, _ = http.NewRequest("DELETE", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights/"+highlightID, nil) req.Header.Set("Authorization", "Bearer "+regular) resp, err = (&http.Client{}).Do(req) require.NoError(s.T(), err) io.Copy(io.Discard, resp.Body) resp.Body.Close() assert.False(s.T(), s.highlightExists(regular, highlightID), "owner delete removes the row") } // TestAdminCreateUser pins the admin counterpart to registration: admins // create accounts even with self-service registration disabled, regular // users cannot, and duplicates conflict. func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() { suffix := fmt.Sprintf("%d", s.setup.DBPool.Config().MaxConns) // stable-ish uniquifier unique := fmt.Sprintf("admincreated%s", suffix) // Regular user token → 403 (AdminMiddleware). payload, _ := json.Marshal(map[string]interface{}{ "email": unique + "@tests.bookhoard.internal", "username": unique, "password": "Test@Pass123!", }) req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.setup.RegularToken) resp, err := (&http.Client{}).Do(req) require.NoError(s.T(), err) io.Copy(io.Discard, resp.Body) resp.Body.Close() require.Equal(s.T(), http.StatusForbidden, resp.StatusCode) // Admin → 201, and the account is immediately usable (login works) // even though self-service registration is off for new servers. req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.token) resp, err = (&http.Client{}).Do(req) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusCreated, resp.StatusCode) var created map[string]interface{} require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&created)) 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") req.Header.Set("Authorization", "Bearer "+s.token) resp, err = (&http.Client{}).Do(req) require.NoError(s.T(), err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusConflict, resp.StatusCode) // The created user can log in. loginPayload, _ := json.Marshal(map[string]interface{}{ "login": unique + "@tests.bookhoard.internal", "password": "Test@Pass123!", }) req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/login", bytes.NewBuffer(loginPayload)) req.Header.Set("Content-Type", "application/json") resp, err = (&http.Client{}).Do(req) require.NoError(s.T(), err) defer resp.Body.Close() assert.Equal(s.T(), http.StatusOK, resp.StatusCode) } // TestDelete_StorageDownRefused pins the NAS guard: deleting a media item // while its library storage is unreachable is REFUSED (row + annotations // survive) instead of cascading annotations for a file that will re-import // bare on the next scan. func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() { ctx := s.setup.QueueCtx db := s.setup.DB libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks") require.NoError(s.T(), err) admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal") require.NoError(s.T(), err) library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{ Name: fmt.Sprintf("Ownership Suite Test Offline %d", time.Now().UnixNano()), Description: pgtype.Text{String: "storage-down suite", Valid: true}, LibraryTypeID: libraryTypeRow.ID, CreatedByAdminID: admin.ID, }) require.NoError(s.T(), err) // Folder row pointing at a mount that does not exist. _, err = db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{ LibraryID: library.ID, FolderPath: "/nonexistent-mount-xyz", }) require.NoError(s.T(), err) item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: library.ID, Title: "Offline Storage Item", FilePath: "offline.epub", MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }) require.NoError(s.T(), err) itemID := uuid.UUID(item.ID.Bytes).String() payload, _ := json.Marshal(map[string]interface{}{"media_item_ids": []string{itemID}}) req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/bulk-delete", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.token) resp, err := (&http.Client{}).Do(req) require.NoError(s.T(), err) defer resp.Body.Close() require.Equal(s.T(), http.StatusOK, resp.StatusCode, "bulk endpoint answers 200 with per-item errors") var body map[string]interface{} require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body)) results := body["results"].([]interface{}) require.Len(s.T(), results, 1) first := results[0].(map[string]interface{}) assert.Contains(s.T(), first["error"], "storage unreachable") // The row survived the refused delete. check, err := db.GetMediaItem(ctx, item.ID) require.NoError(s.T(), err) assert.Equal(s.T(), "Offline Storage Item", check.Title) } func TestOwnershipAndAdminTestSuite(t *testing.T) { suite.Run(t, new(OwnershipAndAdminTestSuite)) }