Files
bookhoard/cmd/server/tests/series_integration_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

465 lines
15 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type SeriesIntegrationTestSuite struct {
suite.Suite
setup *TestServerSetup
token string
libraryID string
}
func (s *SeriesIntegrationTestSuite) SetupSuite() {
s.setup = setupTestServer(s.T())
s.token = s.setup.Token
s.libraryID = createTestLibraryWithFolder(s.T(), s.setup.Server, s.token, "Test Series Library", false)
}
func (s *SeriesIntegrationTestSuite) TearDownSuite() {
s.setup.Close()
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
// Ground truth (2026-10-13 visibility hardening): library_id stays
// optional, but the unscoped aggregate now covers only the requester's
// VISIBLE libraries — hidden libraries contribute nothing. Series are
// grouped per library (same name in two libraries = two entries), so
// every row carries a library_id.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Contains(s.T(), body, "series", "response carries the series array")
assert.Contains(s.T(), body, "total")
if rows, ok := body["series"].([]interface{}); ok {
for _, r := range rows {
row, ok := r.(map[string]interface{})
require.True(s.T(), ok)
assert.NotEmpty(s.T(), row["library_id"], "series rows carry their library scope")
}
}
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id=not-a-uuid", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
var body map[string]string
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "invalid library_id", body["error"])
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_EmptyLibrary() {
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
series, ok := body["series"].([]interface{})
require.True(s.T(), ok, "series should be an array")
assert.Empty(s.T(), series, "empty library should have no series")
total, ok := body["total"].(float64)
require.True(s.T(), ok, "total should be a number")
assert.Equal(s.T(), float64(0), total)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_Unauthorized() {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_PaginationParams() {
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=5&offset=0", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
limit, ok := body["limit"].(float64)
require.True(s.T(), ok)
assert.Equal(s.T(), float64(5), limit)
offset, ok := body["offset"].(float64)
require.True(s.T(), ok)
assert.Equal(s.T(), float64(0), offset)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=999", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
limit, ok := body["limit"].(float64)
require.True(s.T(), ok)
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_LibraryIDRequired() {
// Ground truth (2026-10-13 visibility hardening): /api/series/books is
// per-library now — library_id is REQUIRED, validated, and must be
// visible to the requester. The former name-only contract let any
// authenticated account pull series books across ALL libraries
// (including hidden ones), and merged same-name series across
// libraries (manga adaptations / LN splits).
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
var body map[string]string
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "library_id required", body["error"])
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
url := fmt.Sprintf("%s/api/series/books?library_id=%s", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
var body map[string]string
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "name required", body["error"])
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
// library_id is now validated: an unparseable uuid is a 400.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() {
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=Nonexistent+Series", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
books, ok := body["books"].([]interface{})
require.True(s.T(), ok, "books should be an array")
assert.Empty(s.T(), books, "nonexistent series should return empty books array")
assert.Equal(s.T(), "Nonexistent Series", body["name"])
total, ok := body["total"].(float64)
require.True(s.T(), ok)
assert.Equal(s.T(), float64(0), total)
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_Unauthorized() {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id="+s.libraryID+"&name=Test", nil)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_SpecialCharactersInName() {
seriesName := "Series: Book & Other (Vol. 1)"
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=%s", s.setup.Server.URL, s.libraryID, url.QueryEscape(seriesName))
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() {
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Contains(s.T(), body, "series", "response should contain 'series' key")
assert.Contains(s.T(), body, "total", "response should contain 'total' key")
assert.Contains(s.T(), body, "limit", "response should contain 'limit' key")
assert.Contains(s.T(), body, "offset", "response should contain 'offset' key")
_, ok := body["series"].([]interface{})
assert.True(s.T(), ok, "'series' should be an array")
}
func (s *SeriesIntegrationTestSuite) setLibraryVisibility(token, libraryID string, visible bool) *http.Response {
payload, _ := json.Marshal(map[string]interface{}{
"library_id": libraryID,
"is_visible": visible,
})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/libraries/visibility", 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)
return resp
}
// TestGetSeries_HiddenLibraryScopedOut pins the family-lens contract: a
// library hidden from a user contributes NOTHING to that user's series
// views, and naming it explicitly is a 403, not a silent pass — across
// /api/series, /api/series/books, and /api/media-items.
func (s *SeriesIntegrationTestSuite) TestGetSeries_HiddenLibraryScopedOut() {
regular := s.setup.RegularToken
// Baseline: the regular user can see the suite library.
resp := s.setLibraryVisibility(regular, s.libraryID, true)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Hide it from the regular user.
resp = s.setLibraryVisibility(regular, s.libraryID, false)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
defer func() {
r := s.setLibraryVisibility(regular, s.libraryID, true)
_ = r.Body.Close()
}()
client := &http.Client{}
// Explicit hidden library → 403.
for _, path := range []string{
"/api/series?library_id=" + s.libraryID,
"/api/series/books?library_id=" + s.libraryID + "&name=Test",
"/api/media-items?library_id=" + s.libraryID,
} {
req, _ := http.NewRequest("GET", s.setup.Server.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+regular)
r, err := client.Do(req)
require.NoError(s.T(), err)
body, _ := io.ReadAll(r.Body)
r.Body.Close()
require.Equalf(s.T(), http.StatusForbidden, r.StatusCode, "%s must be 403 for a hidden library (body: %s)", path, body)
}
// Unscoped aggregate: the hidden library's series never appear.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
req.Header.Set("Authorization", "Bearer "+regular)
r, err := client.Do(req)
require.NoError(s.T(), err)
defer r.Body.Close()
require.Equal(s.T(), http.StatusOK, r.StatusCode)
var body map[string]interface{}
require.NoError(s.T(), json.NewDecoder(r.Body).Decode(&body))
if rows, ok := body["series"].([]interface{}); ok {
for _, row := range rows {
m, ok := row.(map[string]interface{})
require.True(s.T(), ok)
assert.NotEqual(s.T(), s.libraryID, m["library_id"], "hidden library must not appear in the aggregate")
}
}
// The admin still sees the library (default-allow; visibility rows are
// per-user).
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
r, err = client.Do(req)
require.NoError(s.T(), err)
defer r.Body.Close()
assert.Equal(s.T(), http.StatusOK, r.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() {
reqBody := map[string]interface{}{
"collection_name": "Continue Series",
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(s.T(), err)
assert.Contains(s.T(), response, "message")
}
func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(s.T(), err)
sections, ok := response["sections"].([]interface{})
require.True(s.T(), ok, "sections should be an array")
require.Len(s.T(), sections, 5, "Should have 5 system collections")
sectionIDs := make(map[string]bool)
for _, sec := range sections {
section := sec.(map[string]interface{})
sectionIDs[section["id"].(string)] = true
}
// Ground truth: the /sections contract keys a section's identity on
// the collection NAME — BuildSections sets ID = CollectionName, and
// the web/app reorder + hidden-collection preferences persist those
// same strings (data-collection-id). The kebab "query_type"
// ("continue-series" etc.) is the STORAGE key for system collections
// and is not exposed on this endpoint.
assert.Contains(s.T(), sectionIDs, "Continue Series", "dashboard should include Continue Series")
assert.Contains(s.T(), sectionIDs, "Continue Reading")
assert.Contains(s.T(), sectionIDs, "Recently Added")
assert.Contains(s.T(), sectionIDs, "Recently Read")
assert.Contains(s.T(), sectionIDs, "Not Started")
}
func TestSeriesIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(SeriesIntegrationTestSuite))
}