Design correction discovered by the auth chicken-and-egg: the decorator-level refusal was re-created a convention gate (plan B thinking). ScopedDB now NEVER refuses — it declares the GUC when the context carries an identity and declares nothing otherwise; the ENGINE is the fail-loud layer (policies raise on governed tables, ungoverned tables like auth lookups simply work). rls.WithInternal removed as a dead concept. - search service's raw DBTX path now receives the ScopedDB itself (main.go + harness) — a bare pool bypassed the GUC. - database/schema.go: migration tx declares a synthetic identity (schema.sql's dedup DO block SELECTs media_items; with policies live, boot 2 raised 'unrecognized configuration parameter' — A11). - harness: TestServerSetup.DB is a raw-pool SEED handle (write policies permissive, other seeded tables ungoverned); the server under test keeps the scoped wiring. 33 CreateMediaItem sites use seedCtx() — a seed identity with no visibility rows — because RETURNING requires SELECT visibility of the new row. - invariant test: regex UUID-scraping replaced with a structured JSON decode of the sections payload. INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied, confirmed by the tool, and subsequently vanished from disk while an 'opencode serve' process (PID 84551) is alive on the machine. This commit pins the tree against further loss; implementation is PAUSED pending the user killing that process.
306 lines
12 KiB
Go
306 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(seedCtx(), 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) 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(seedCtx(), 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))
|
|
}
|