Authors are searchable (internal/search, migrations/0009) - The FTS index covered only title and path, so typing an artist or author found nothing even though people/item_people already held the credits. - Rather than a second index, the names are denormalised into a real media_items.authors column, so the existing external-content FTS and its triggers keep doing the work; item_people/people triggers recompute it, and the media_items UPDATE trigger re-indexes automatically. - Verified: searching "daft punk" now returns Random Access Memories, and so does the partial "punk". - The agent found and worked around a real FTS5 external-content bug: on an UPGRADE the backfill UPDATE fires the delete trigger for rowids not yet in the fresh index, raising "database disk image is malformed". A rebuild before the backfill fixes it. This would have broken existing databases while a fresh install looked fine. gzip for the SPA (internal/httpapi/static.go) - 456 KB of JS and 31 KB of CSS were served uncompressed on every cold load. - Scoped to the embedded SPA assets only, never API or media: compressing a Range response would break seeking, and wrapping every response would undo Wave 2's Flusher/ReaderFrom forwarding. - Compresses only when the client accepts gzip, the response is a compressible type, there is no Range header, and nothing already set Content-Encoding. Sets Vary: Accept-Encoding and drops the now-wrong Content-Length. - Verified by sha256 round-trip (identity vs gzip-decompressed are identical), and by confirming a Range request still returns 206 with no Content-Encoding. JS 351,835 -> 108,794 bytes (69% smaller); CSS 80% smaller. epub.js is code-split (Reader.svelte) - It was a static import, so ~350 KB was downloaded by everyone on first load even though most never open an ebook. Now loaded on demand. - Main bundle 458 KB -> 112 KB (gzipped 149 KB -> 39.5 KB). - Verified at runtime, which pnpm check cannot: only the main bundle loads initially, the epubjs chunk is fetched when a book is opened, and the reader still renders. Home screen tests (internal/media) - Coverage 23.4% -> 50.2%. (The 17.9% quoted in the brief was a stale reading; the agent measured the real baseline by moving its own files aside.) - Covers the five shelves, counts, Explore's invariants (visibility, no duplicates, every kind represented, varies across calls, wrap-around), the list offset/limit/total contract, MostViewed ordering, and — most importantly — grant scoping, which is the security chokepoint. Fixed a bug the tests found: Suggestions seeded from revoked libraries - topEngagement selected the user's most recent progress row with no library filter, so progress from a library whose grant had been revoked still steered the home screen — a visible item was labelled "More books" based on content the user could no longer read. Returned items were always correctly scoped, so this was an information-flow gap rather than a content leak. - Now scoped to visible libraries, and the test that pinned the old behaviour flipped to assert the correct reason. gofmt/vet clean, 14 packages pass, media tests run 3x for flakiness.
250 lines
7.5 KiB
Go
250 lines
7.5 KiB
Go
package media
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
)
|
|
|
|
// listResponse mirrors the JSON contract of handleListItems.
|
|
type listResponse struct {
|
|
Items []itemRow `json:"items"`
|
|
Total int `json:"total"`
|
|
Offset int `json:"offset"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
func listItems(t *testing.T, h *Handlers, u auth.User, query string) (int, listResponse) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/items"+query, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.handleListItems(rec, req, u)
|
|
var resp listResponse
|
|
if rec.Code == http.StatusOK {
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode list: %v (body %s)", err, rec.Body.String())
|
|
}
|
|
}
|
|
return rec.Code, resp
|
|
}
|
|
|
|
type listFixture struct {
|
|
db *sql.DB
|
|
libA int64
|
|
libB int64
|
|
userA int64
|
|
userNone int64
|
|
e1 int64 // libA ebooks, sort 01
|
|
e2 int64 // libA ebooks, sort 02
|
|
e3 int64 // libA ebooks, sort 03
|
|
c1 int64 // libA comics, sort 04
|
|
m1 int64 // libA ebooks, missing
|
|
b1 int64 // libB audiobooks
|
|
}
|
|
|
|
func setupList(t *testing.T) listFixture {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
f := listFixture{db: db}
|
|
|
|
f.libA = newLibrary(t, db, "List A", "ebooks", "/list/a")
|
|
f.libB = newLibrary(t, db, "List B", "audiobooks", "/list/b")
|
|
f.userA = newUser(t, db, "list-a")
|
|
f.userNone = newUser(t, db, "list-none")
|
|
grantRead(t, db, f.userA, f.libA)
|
|
|
|
const at = "2026-01-01T00:00:00.000Z"
|
|
f.e1 = newItem(t, db, f.libA, "ebooks", "e1.epub", "Echo", "01 echo", false, at)
|
|
f.e2 = newItem(t, db, f.libA, "ebooks", "e2.epub", "Delta", "02 delta", false, at)
|
|
f.e3 = newItem(t, db, f.libA, "ebooks", "e3.epub", "Charlie", "03 charlie", false, at)
|
|
f.c1 = newItem(t, db, f.libA, "comics", "c1.cbz", "Bravo", "04 bravo", false, at)
|
|
f.m1 = newItem(t, db, f.libA, "ebooks", "m1.epub", "Missing", "05 missing", true, at)
|
|
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "Alpha", "06 alpha", false, at)
|
|
return f
|
|
}
|
|
|
|
// TestListItemsTotalIsNotThePage pins the recently added offset/limit/total
|
|
// contract: total counts every matching row, independent of the page window.
|
|
func TestListItemsTotalIsNotThePage(t *testing.T) {
|
|
f := setupList(t)
|
|
h := mediaHandlers(t, f.db)
|
|
|
|
code, resp := listItems(t, h, auth.User{ID: f.userA}, "?limit=2")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", code)
|
|
}
|
|
if resp.Total != 4 {
|
|
t.Errorf("total = %d, want 4 (all matching present rows, not the page)", resp.Total)
|
|
}
|
|
if len(resp.Items) != 2 {
|
|
t.Errorf("page has %d items, want 2", len(resp.Items))
|
|
}
|
|
if resp.Limit != 2 || resp.Offset != 0 {
|
|
t.Errorf("limit/offset = %d/%d, want 2/0", resp.Limit, resp.Offset)
|
|
}
|
|
for _, it := range resp.Items {
|
|
if it.ID == f.m1 {
|
|
t.Error("a missing item was listed")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListItemsPaging(t *testing.T) {
|
|
f := setupList(t)
|
|
h := mediaHandlers(t, f.db)
|
|
u := auth.User{ID: f.userA}
|
|
|
|
_, p1 := listItems(t, h, u, "?limit=2&offset=0")
|
|
_, p2 := listItems(t, h, u, "?limit=2&offset=2")
|
|
if len(p1.Items) != 2 || len(p2.Items) != 2 {
|
|
t.Fatalf("page sizes = %d/%d, want 2/2", len(p1.Items), len(p2.Items))
|
|
}
|
|
if p1.Offset != 0 || p2.Offset != 2 {
|
|
t.Errorf("offsets = %d/%d, want 0/2", p1.Offset, p2.Offset)
|
|
}
|
|
|
|
// Pages must not overlap, and must order by sort_title.
|
|
if p1.Items[0].ID != f.e1 || p1.Items[1].ID != f.e2 {
|
|
t.Errorf("page 1 = %v, want [%d %d]", idsFromRows(p1.Items), f.e1, f.e2)
|
|
}
|
|
if p2.Items[0].ID != f.e3 || p2.Items[1].ID != f.c1 {
|
|
t.Errorf("page 2 = %v, want [%d %d]", idsFromRows(p2.Items), f.e3, f.c1)
|
|
}
|
|
seen := map[int64]bool{}
|
|
for _, it := range append(append([]itemRow{}, p1.Items...), p2.Items...) {
|
|
if seen[it.ID] {
|
|
t.Errorf("item %d appears on both pages", it.ID)
|
|
}
|
|
seen[it.ID] = true
|
|
}
|
|
|
|
// A page past the end is empty but still reports the true total.
|
|
_, beyond := listItems(t, h, u, "?limit=2&offset=99")
|
|
if len(beyond.Items) != 0 || beyond.Total != 4 {
|
|
t.Errorf("beyond page: items=%d total=%d, want 0/4", len(beyond.Items), beyond.Total)
|
|
}
|
|
}
|
|
|
|
func TestListItemsFiltersCompose(t *testing.T) {
|
|
f := setupList(t)
|
|
h := mediaHandlers(t, f.db)
|
|
u := auth.User{ID: f.userA}
|
|
|
|
_, ebooks := listItems(t, h, u, "?kind=ebooks")
|
|
if ebooks.Total != 3 {
|
|
t.Errorf("kind=ebooks total = %d, want 3", ebooks.Total)
|
|
}
|
|
for _, it := range ebooks.Items {
|
|
if it.Kind != "ebooks" {
|
|
t.Errorf("kind filter returned a %q item", it.Kind)
|
|
}
|
|
}
|
|
|
|
_, libA := listItems(t, h, u, "?library_id="+itoa(f.libA))
|
|
if libA.Total != 4 {
|
|
t.Errorf("library_id=%d total = %d, want 4", f.libA, libA.Total)
|
|
}
|
|
|
|
// kind and library_id compose (both must hold).
|
|
_, both := listItems(t, h, u, "?kind=ebooks&library_id="+itoa(f.libA))
|
|
if both.Total != 3 {
|
|
t.Errorf("kind+library total = %d, want 3", both.Total)
|
|
}
|
|
for _, it := range both.Items {
|
|
if it.Kind != "ebooks" || it.LibraryID != f.libA {
|
|
t.Errorf("composed filter returned %+v", it)
|
|
}
|
|
}
|
|
|
|
// A kind with no items is an empty page, not an error.
|
|
code, none := listItems(t, h, u, "?kind=movies")
|
|
if code != http.StatusOK || none.Total != 0 || len(none.Items) != 0 {
|
|
t.Errorf("unknown kind: status=%d total=%d items=%d", code, none.Total, len(none.Items))
|
|
}
|
|
}
|
|
|
|
func TestListItemsScoping(t *testing.T) {
|
|
f := setupList(t)
|
|
h := mediaHandlers(t, f.db)
|
|
|
|
// A user with no grants sees nothing.
|
|
_, none := listItems(t, h, auth.User{ID: f.userNone}, "?limit=100")
|
|
if none.Total != 0 || len(none.Items) != 0 {
|
|
t.Errorf("no-grant user saw total=%d items=%d", none.Total, len(none.Items))
|
|
}
|
|
|
|
// Asking explicitly for a library the user cannot read must not leak it.
|
|
code, leak := listItems(t, h, auth.User{ID: f.userA}, "?library_id="+itoa(f.libB)+"&limit=100")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", code)
|
|
}
|
|
if leak.Total != 0 || len(leak.Items) != 0 {
|
|
t.Errorf("user A read library %d: total=%d items=%d", f.libB, leak.Total, len(leak.Items))
|
|
}
|
|
|
|
// The unfiltered list contains only the readable library.
|
|
_, all := listItems(t, h, auth.User{ID: f.userA}, "?limit=100")
|
|
for _, it := range all.Items {
|
|
if it.LibraryID != f.libA {
|
|
t.Errorf("item %d leaked from library %d", it.ID, it.LibraryID)
|
|
}
|
|
}
|
|
if containsRowID(all.Items, f.b1) {
|
|
t.Error("user A's list contained another user's item")
|
|
}
|
|
}
|
|
|
|
func TestListItemsInvalidLibraryID(t *testing.T) {
|
|
f := setupList(t)
|
|
h := mediaHandlers(t, f.db)
|
|
for _, q := range []string{"?library_id=abc", "?library_id=-1", "?library_id=0"} {
|
|
code, _ := listItems(t, h, auth.User{ID: f.userA}, q)
|
|
if code != http.StatusBadRequest {
|
|
t.Errorf("%s status = %d, want 400", q, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListItemsDefaultsAndCaps(t *testing.T) {
|
|
f := setupList(t)
|
|
h := mediaHandlers(t, f.db)
|
|
u := auth.User{ID: f.userA}
|
|
|
|
_, def := listItems(t, h, u, "")
|
|
if def.Limit != 300 || def.Offset != 0 {
|
|
t.Errorf("defaults = limit %d offset %d, want 300/0", def.Limit, def.Offset)
|
|
}
|
|
|
|
// Out-of-range limit and negative offset fall back to the defaults.
|
|
for _, q := range []string{"?limit=0", "?limit=5000", "?limit=-1"} {
|
|
_, r := listItems(t, h, u, q)
|
|
if r.Limit != 300 {
|
|
t.Errorf("%s limit = %d, want 300", q, r.Limit)
|
|
}
|
|
}
|
|
_, neg := listItems(t, h, u, "?offset=-5")
|
|
if neg.Offset != 0 {
|
|
t.Errorf("negative offset = %d, want 0", neg.Offset)
|
|
}
|
|
}
|
|
|
|
func idsFromRows(rows []itemRow) []int64 {
|
|
out := make([]int64, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = r.ID
|
|
}
|
|
return out
|
|
}
|
|
|
|
func containsRowID(rows []itemRow, id int64) bool {
|
|
for _, r := range rows {
|
|
if r.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|