collections + collection_items join the RLS-governed tables, same doctrine as the media_items family lens (2026-10-29). Policies route through two PURE predicate functions (app_can_read_collection / app_can_write_collection) that never query collections — no policy recursion under FORCE RLS — so a future sharing model is one EXISTS branch inside the functions, not a schema re-touch. Reads: owner or admin; writes: owner, admin escape for SYSTEM collections only. Why: the write path had no app-level ownership gate on Update/Delete/ AddBooks — any authenticated user could mutate another user's collection by id. The DB is now the enforcement of record; existing app-level gates (match-mode, reorder, view-settings) stay as defense-in-depth. Behavior changes (contract_pins 2026-10-31): - cross-user GET detail: 200 -> 404 (row invisible) - cross-user PUT update: silent success -> 404 (ErrNoRows mapping; service wrap fixed %v -> %w so errors.Is sees pgx.ErrNoRows) - cross-user POST books: silent success -> 404 - cross-user DELETE: 204, now a true DB no-op - anonymous sees nothing; admin persona unchanged Bootstrap: registration seeds system collections BEFORE the JWT exists — CreateDefaultCollectionsForUser declares the new user's identity (rls.WithUser), the same fix mirrored in the test harness (createDefaultCollectionsForUser owner-scoped; collection sweeps run under the owner's identity). Tests: new TestCollectionsRLS pins the cross-user matrix end-to-end (404 detail/update/add/view-settings, 204 no-op delete, admin escape, owner positive control, registration seeding under RLS). Harness fixture writes to non-system collections now run as the row's OWNER (scopeExecAs/ownerSeedCtx) — the admin write-escape covers system rows only, and bare-ctx pool reads of collections see nothing. Filter/playlist/audiobook/scan-state fixtures converted. Full container suite green (159s).
199 lines
8.0 KiB
Go
199 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"bookhoard/internal/database"
|
|
)
|
|
|
|
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
|
|
// an ordering construct must (a) land the real series on the item, (b)
|
|
// auto-create a curated playlist collection for the ordering construct with
|
|
// provenance + position, (c) survive a rescan without duplicates, (d)
|
|
// support owner-only reorder, and (e) filter finished books for Up Next /
|
|
// hide_finished with a completion marker when everything is read.
|
|
func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
t.Cleanup(func() { _ = setup.Close() })
|
|
adminID, _ := moveTestAdminID(t, setup)
|
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Playlist %d", time.Now().UnixNano()))
|
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
|
|
|
// The user's exact Witcher shape: real series + reading-order construct.
|
|
itemDir := filepath.Join(folder, "Tag Author", "The Tagged Album")
|
|
fixtureAudio(t, "tagged.mp3", filepath.Join(itemDir, "tagged.mp3"))
|
|
sidecar := `{
|
|
"title": "The Tagged Album",
|
|
"authors": ["Tag Author"],
|
|
"series": ["The Tagged Saga #2", "The Tagged Saga (Reading Order) #7"]
|
|
}`
|
|
require.NoError(t, os.WriteFile(filepath.Join(itemDir, "metadata.json"), []byte(sidecar), 0644))
|
|
|
|
scanTestFolder(t, setup, adminID, folder)
|
|
|
|
// (a) The item carries the REAL series only; the reading-order entry
|
|
// never touches the series columns.
|
|
var series string
|
|
var seriesNum float64
|
|
var foundPath string
|
|
err := setup.DBPool.QueryRow(context.Background(),
|
|
`SELECT series, series_number, file_path FROM media_items WHERE title = 'The Tagged Album'`,
|
|
).Scan(&series, &seriesNum, &foundPath)
|
|
if err != nil {
|
|
var paths []string
|
|
rows, qerr := setup.DBPool.Query(context.Background(),
|
|
`SELECT file_path FROM media_items WHERE library_id = $1`, libID)
|
|
if qerr == nil {
|
|
for rows.Next() {
|
|
var p string
|
|
_ = rows.Scan(&p)
|
|
paths = append(paths, p)
|
|
}
|
|
rows.Close()
|
|
}
|
|
t.Fatalf("item not found by title (paths in library: %v): %v", paths, err)
|
|
}
|
|
require.Equal(t, "The Tagged Saga", series)
|
|
require.Equal(t, 2.0, seriesNum)
|
|
require.NotEmpty(t, foundPath)
|
|
|
|
// (b) The playlist collection exists with provenance, dashboard pin,
|
|
// and the item at reading position 7. Owner-scoped read: collections
|
|
// RLS (2026-10-31) hides the row from the pool session's SeedIdentity.
|
|
adminUUIDStr := adminID.String()
|
|
var collID uuid.UUID
|
|
var collName, collDesc string
|
|
var showOnDashboard bool
|
|
var viewSettingsRaw []byte
|
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
|
`SELECT id, name, description, show_on_dashboard, view_settings
|
|
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
|
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
|
|
require.True(t, showOnDashboard, "playlists are dashboard-eligible by design")
|
|
require.Contains(t, string(viewSettingsRaw), "imported_from_series")
|
|
require.Contains(t, string(viewSettingsRaw), "show_up_next")
|
|
require.Contains(t, collDesc, "safe to edit or delete")
|
|
_ = adminUUIDStr
|
|
|
|
var position *int32
|
|
var itemCount int
|
|
// Owner-scoped reads: collections RLS (2026-10-31) — items ride the
|
|
// parent's readability, so the pool session's SeedIdentity sees none.
|
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
|
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
|
|
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
|
|
require.Equal(t, 1, itemCount)
|
|
require.NotNil(t, position)
|
|
require.Equal(t, int32(7), *position)
|
|
_ = adminUUIDStr
|
|
|
|
// (c) Rescan: no duplicate collection, no duplicate membership.
|
|
scanTestFolder(t, setup, adminID, folder)
|
|
var collectionCount, membershipCount int
|
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
|
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
|
adminID).Scan(&collectionCount))
|
|
require.Equal(t, 1, collectionCount)
|
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
|
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
|
|
require.Equal(t, 1, membershipCount)
|
|
|
|
// (d) Owner-only reorder: positions become 1..n; a non-owner gets 403.
|
|
var itemID uuid.UUID
|
|
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
|
`SELECT id FROM media_items WHERE title = 'The Tagged Album'`).Scan(&itemID))
|
|
|
|
reorderURL := fmt.Sprintf("%s/api/collections/%s/items/order", setup.Server.URL, collID.String())
|
|
body, _ := json.Marshal(map[string][]string{"item_ids": {itemID.String()}})
|
|
req, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body))
|
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
_ = resp.Body.Close()
|
|
|
|
regularToken := setup.RegularToken
|
|
req2, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body))
|
|
req2.Header.Set("Authorization", "Bearer "+regularToken)
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
resp2, err := http.DefaultClient.Do(req2)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusForbidden, resp2.StatusCode)
|
|
_ = resp2.Body.Close()
|
|
|
|
// (e) hide_finished + Up Next + completion marker.
|
|
// Flip the collection's flag on, mark the single item finished for the
|
|
// admin, and the dashboard section becomes Complete with no items.
|
|
// Owner-or-admin UPDATE policy: run as ADMIN (collections RLS,
|
|
// 2026-10-31).
|
|
adminScopeExec(t, setup, adminCtxValue(t, setup),
|
|
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
|
|
require.NoError(t, err)
|
|
_, err = setup.DBPool.Exec(context.Background(),
|
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
|
|
VALUES ($1, $2, 1.0, NOW()) ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 1.0`,
|
|
itemID, adminID)
|
|
require.NoError(t, err)
|
|
|
|
sectionsURL := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", setup.Server.URL, libID.String())
|
|
req3, _ := http.NewRequest("GET", sectionsURL, nil)
|
|
req3.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
resp3, err := http.DefaultClient.Do(req3)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp3.Body.Close() }()
|
|
require.Equal(t, http.StatusOK, resp3.StatusCode)
|
|
|
|
var payload struct {
|
|
Sections []struct {
|
|
CollectionID string `json:"collection_id"`
|
|
Complete bool `json:"complete"`
|
|
IsPlaylist bool `json:"is_playlist"`
|
|
Items []map[string]any `json:"items"`
|
|
} `json:"sections"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(resp3.Body).Decode(&payload))
|
|
|
|
var found bool
|
|
for _, s := range payload.Sections {
|
|
if s.CollectionID == collID.String() {
|
|
found = true
|
|
if !s.Complete {
|
|
b, _ := json.Marshal(s)
|
|
t.Fatalf("section found but not complete: %s", b)
|
|
}
|
|
require.True(t, s.IsPlaylist)
|
|
require.Empty(t, s.Items, "finished items must not show under hide_finished")
|
|
}
|
|
}
|
|
if !found {
|
|
b, _ := json.Marshal(payload.Sections)
|
|
t.Fatalf("playlist section missing; sections = %s", b)
|
|
}
|
|
require.True(t, found, "the playlist section must appear on the owner's dashboard")
|
|
|
|
// Up Next on the collection detail: the finished queue is empty.
|
|
detailURL := fmt.Sprintf("%s/api/collections/%s", setup.Server.URL, collID.String())
|
|
req4, _ := http.NewRequest("GET", detailURL, nil)
|
|
req4.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
resp4, err := http.DefaultClient.Do(req4)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp4.Body.Close() }()
|
|
var detail struct {
|
|
UpNext []map[string]any `json:"up_next"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(resp4.Body).Decode(&detail))
|
|
require.Empty(t, detail.UpNext, "nothing is up next once everything is finished")
|
|
}
|