Files
bookhoard/cmd/server/tests/ownership_and_admin_test.go
T
John O'Keefe 431fec05ad feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation
The family-lens hardening: library_visibility was only enforced where
file bytes flowed (download/reader/OPDS) — metadata surfaces leaked
hidden libraries to any authenticated account, and registration was
open by default with no admin-side user creation.

- Series are grouped PER LIBRARY now (same name in two libraries =
  two entries: manga adaptations / LN splits no longer merge);
  /api/series aggregates only the requester's VISIBLE libraries and
  /api/series/books REQUIRES a visible library_id (breaking contract;
  web + app clients updated in the same change).
- /api/media-items, item detail, and the reader content endpoints
  (chapters/panels/outline/THUMBNAILS — page images leaked unguarded
  next to a gated GetPage) all enforce the visible set; resolveLibrary
  no longer honors a hand-typed library_id outside the visible set.
- Annotation/note/bookmark mutations (update/delete/tombstone, sync
  LWW paths incl. conflict resolution) carry ownership predicates:
  another account's bare row id is a 404, not a silent delete.
- Admin user creation: POST /api/auth/users (AdminMiddleware) + a
  Create User card in the admin Users UI — accounts stay creatable
  with self-service registration OFF, which is now the schema default
  (fresh installs; the first-admin bootstrap exception is unchanged).
- Tests: series contract re-pinned (per-library, visibility 403s),
  new ownership + admin-create integration suites, OPDS all=1 test
  now passes an explicit page size (shared-DB growth made the fresh
  books fall off page 1), device-cap helper switched to the admin
  endpoint.
2026-10-13 12:03:01 -04:00

218 lines
8.1 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"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
}
func (s *OwnershipAndAdminTestSuite) SetupSuite() {
s.setup = setupTestServer(s.T())
s.token = s.setup.Token
s.itemID = s.createItemViaDB()
// 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.
func (s *OwnershipAndAdminTestSuite) createItemViaDB() 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 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)
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Ownership Suite Item",
Author: pgtype.Text{String: "Suite Author", Valid: true},
FilePath: fmt.Sprintf("/tmp/ownership-suite-%d.epub", time.Now().UnixNano()),
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(s.T(), err)
return uuid.UUID(item.ID.Bytes).String()
}
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"])
// 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)
}
func TestOwnershipAndAdminTestSuite(t *testing.T) {
suite.Run(t, new(OwnershipAndAdminTestSuite))
}