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.
465 lines
15 KiB
Go
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))
|
|
}
|