setupTestServer built the settings registry (and wired it into
systemSettingsHandler) but never called authHandler.SetSettings, unlike
main.go:112. The auth handler therefore ran with a nil registry and
defaultTimezone() took its nil-fallback branch, returning "UTC" for every
Register/AdminCreateUser in tests regardless of the system default_timezone
setting — so the 740213f0 inheritance pin in TestAdminCreateUser could never
pass and failed every full-suite run.
Also replace the TestAdminCreateUser "stable-ish" uniquifier (MaxConns is
pinned to 1 by the harness, so it produced the same user every run and
depended on the next run's cleanup sweep) with a real uuid suffix.
318 lines
12 KiB
Go
318 lines
12 KiB
Go
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() {
|
|
// Real unique suffix: MaxConns is forced to 1 by the harness, so the old
|
|
// "stable-ish" uniquifier produced the same user every run and only
|
|
// survived on the setup-time cleanup sweep of the NEXT run.
|
|
unique := fmt.Sprintf("admincreated-%s", uuid.New().String()[:8])
|
|
|
|
// 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))
|
|
}
|