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.
70 lines
2.2 KiB
Go
70 lines
2.2 KiB
Go
package media
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
)
|
|
|
|
// getItem drives the per-item detail handler directly. PathValue is normally
|
|
// filled in by the ServeMux; the test sets it explicitly.
|
|
func getItem(t *testing.T, h *Handlers, u auth.User, id string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/items/"+id, nil)
|
|
req.SetPathValue("id", id)
|
|
rec := httptest.NewRecorder()
|
|
h.handleItem(rec, req, u)
|
|
return rec
|
|
}
|
|
|
|
// TestHandleItemScoping pins the per-item access chokepoint: an item in a
|
|
// library the caller cannot read must be indistinguishable from a missing one.
|
|
func TestHandleItemScoping(t *testing.T) {
|
|
f := setupHome(t)
|
|
h := mediaHandlers(t, f.db)
|
|
|
|
// userA may read libA, so a1 resolves.
|
|
rec := getItem(t, h, auth.User{ID: f.userA}, itoa(f.a1))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("accessible item status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Item struct {
|
|
ID int64 `json:"id"`
|
|
LibraryID int64 `json:"library_id"`
|
|
Kind string `json:"kind"`
|
|
} `json:"item"`
|
|
Authors []string `json:"authors"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode item: %v", err)
|
|
}
|
|
if body.Item.ID != f.a1 || body.Item.LibraryID != f.libA || body.Item.Kind != "ebooks" {
|
|
t.Errorf("item = %+v, want id %d in library %d", body.Item, f.a1, f.libA)
|
|
}
|
|
if len(body.Authors) != 1 || body.Authors[0] != "Ada Author" {
|
|
t.Errorf("authors = %v, want [Ada Author]", body.Authors)
|
|
}
|
|
|
|
// b1 is in libB, which userA cannot read: 404, not 403, so existence is
|
|
// not disclosed.
|
|
rec = getItem(t, h, auth.User{ID: f.userA}, itoa(f.b1))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("inaccessible item status = %d, want 404", rec.Code)
|
|
}
|
|
|
|
// A user with no grants gets the same 404.
|
|
rec = getItem(t, h, auth.User{ID: f.userNone}, itoa(f.a1))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("no-grant item status = %d, want 404", rec.Code)
|
|
}
|
|
|
|
// A non-numeric id is a 400, not a panic.
|
|
rec = getItem(t, h, auth.User{ID: f.userA}, "abc")
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("invalid id status = %d, want 400", rec.Code)
|
|
}
|
|
}
|