Vellum/internal/httpapi/static_test.go
mithral 09474b0e08 Wave 3: search depth, transfer size, and the Home screen's tests
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.
2026-10-11 13:46:18 -04:00

335 lines
9.9 KiB
Go

package httpapi
import (
"bufio"
"bytes"
"compress/gzip"
"io"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mithral/vellum/web"
)
// findAsset returns the path (relative to the embedded dist root, no leading
// slash) of the first embedded file ending in suffix. Assets are content-hashed
// by the build, so tests must discover them rather than hardcode a name.
func findAsset(t *testing.T, suffix string) string {
t.Helper()
sub, err := fs.Sub(web.Dist, "dist")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
var found string
err = fs.WalkDir(sub, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if found == "" && strings.HasSuffix(p, suffix) {
found = p
}
return nil
})
if err != nil {
t.Fatalf("walk dist: %v", err)
}
if found == "" {
t.Fatalf("no embedded asset ending in %q", suffix)
}
return found
}
func readAsset(t *testing.T, name string) []byte {
t.Helper()
sub, err := fs.Sub(web.Dist, "dist")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
b, err := fs.ReadFile(sub, name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return b
}
func gunzip(t *testing.T, body []byte) []byte {
t.Helper()
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("gzip.NewReader: %v", err)
}
defer zr.Close()
out, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("read gzip body: %v", err)
}
return out
}
// TestStaticGzipCompressesJS proves a gzip-capable request for a .js asset is
// compressed, carries no stale Content-Length, and round-trips to the original.
func TestStaticGzipCompressesJS(t *testing.T) {
srv := newTestServer(t)
name := findAsset(t, ".js")
want := readAsset(t, name)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+name, nil)
req.Header.Set("Accept-Encoding", "gzip")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") {
t.Errorf("Vary = %q, want it to mention Accept-Encoding", got)
}
// The handler's Content-Length describes the uncompressed body; it must be
// gone once the length changes.
if got := rec.Header().Get("Content-Length"); got != "" {
t.Errorf("Content-Length = %q, want empty on a compressed response", got)
}
if got := gunzip(t, rec.Body.Bytes()); !bytes.Equal(got, want) {
t.Errorf("decompressed body does not match original %s (%d vs %d bytes)", name, len(got), len(want))
}
if rec.Body.Len() >= len(want) {
t.Errorf("compressed body %d bytes is not smaller than original %d", rec.Body.Len(), len(want))
}
}
// TestStaticIdentityWithoutAcceptEncoding proves a request with no
// Accept-Encoding is served byte-for-byte, uncompressed.
func TestStaticIdentityWithoutAcceptEncoding(t *testing.T) {
srv := newTestServer(t)
name := findAsset(t, ".js")
want := readAsset(t, name)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+name, nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty for an identity request", got)
}
if !bytes.Equal(rec.Body.Bytes(), want) {
t.Errorf("identity body does not match original %s", name)
}
}
// TestStaticRangeNotCompressed proves a Range request stays identity: seeking
// depends on the original byte offsets, which gzip would invalidate.
func TestStaticRangeNotCompressed(t *testing.T) {
srv := newTestServer(t)
name := findAsset(t, ".js")
want := readAsset(t, name)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+name, nil)
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set("Range", "bytes=0-99")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty on a Range response", got)
}
if !bytes.Equal(rec.Body.Bytes(), want[:100]) {
t.Errorf("range body does not match the first 100 bytes of %s", name)
}
}
// TestStaticPNGNotCompressed proves binary assets are passed through even when
// the client accepts gzip; re-compressing them wastes CPU.
func TestStaticPNGNotCompressed(t *testing.T) {
srv := newTestServer(t)
name := findAsset(t, ".png")
want := readAsset(t, name)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/"+name, nil)
req.Header.Set("Accept-Encoding", "gzip")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "image/png") {
t.Fatalf("Content-Type = %q, want image/png", got)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty for a png", got)
}
if !bytes.Equal(rec.Body.Bytes(), want) {
t.Errorf("png body does not match original %s", name)
}
}
// TestStaticIndexCompressedAndNoCache proves the SPA shell still says no-cache
// and is now compressed for gzip clients.
func TestStaticIndexCompressedAndNoCache(t *testing.T) {
srv := newTestServer(t)
want := readAsset(t, "index.html")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept-Encoding", "gzip")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
t.Errorf("Cache-Control = %q, want no-cache", got)
}
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := gunzip(t, rec.Body.Bytes()); !bytes.Equal(got, want) {
t.Errorf("decompressed index does not match original")
}
}
// ── Wrapper interface forwarding ───────────────────────────────────────
//
// The wrapper sits between the static handler and statusRecorder, so it must
// keep the same interfaces statusRecorder forwards.
func TestGzipResponseWriterForwardsFlush(t *testing.T) {
rec := &flushRecorder{ResponseRecorder: httptest.NewRecorder()}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Accept-Encoding", "gzip")
gz := newGzipResponseWriter(rec, req)
gz.Header().Set("Content-Type", "text/html; charset=utf-8")
gz.WriteHeader(http.StatusOK)
_, _ = gz.Write([]byte("<html>"))
gz.Flush()
_ = gz.Close()
if !rec.flushed {
t.Error("Flush was not forwarded to the underlying writer")
}
if got := gunzip(t, rec.Body.Bytes()); string(got) != "<html>" {
t.Errorf("flushed body = %q, want <html>", got)
}
}
type hijackRecorder struct {
*httptest.ResponseRecorder
hijacked bool
}
func (h *hijackRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h.hijacked = true
return nil, nil, nil
}
func TestGzipResponseWriterForwardsHijack(t *testing.T) {
rec := &hijackRecorder{ResponseRecorder: httptest.NewRecorder()}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
gz := newGzipResponseWriter(rec, req)
if _, _, err := gz.Hijack(); err != nil {
t.Fatalf("Hijack: %v", err)
}
if !rec.hijacked {
t.Error("Hijack was not forwarded to the underlying writer")
}
}
func TestGzipResponseWriterForwardsReadFrom(t *testing.T) {
rec := &readFromRecorder{ResponseRecorder: httptest.NewRecorder()}
req := httptest.NewRequest(http.MethodGet, "/x", nil) // no Accept-Encoding
gz := newGzipResponseWriter(rec, req)
n, err := gz.ReadFrom(strings.NewReader("payload"))
if err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if !rec.readFrom {
t.Error("ReadFrom was not forwarded to the underlying writer")
}
if n != int64(len("payload")) || rec.Body.String() != "payload" {
t.Errorf("ReadFrom wrote %d bytes %q, want payload", n, rec.Body.String())
}
}
func TestGzipResponseWriterReadFromCompresses(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Accept-Encoding", "gzip")
gz := newGzipResponseWriter(rec, req)
gz.Header().Set("Content-Type", "application/javascript")
n, err := gz.ReadFrom(strings.NewReader("payload"))
if err != nil {
t.Fatalf("ReadFrom: %v", err)
}
if n != int64(len("payload")) {
t.Errorf("ReadFrom returned %d, want %d", n, len("payload"))
}
_ = gz.Close()
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := gunzip(t, rec.Body.Bytes()); string(got) != "payload" {
t.Errorf("decompressed body = %q, want payload", got)
}
}
// TestCompressibleType and TestAcceptsGzip pin the content-type allow list and
// the Accept-Encoding parsing, including the q=0 refusal.
func TestCompressibleType(t *testing.T) {
compressible := []string{
"text/html", "text/html; charset=utf-8",
"text/css", "application/javascript",
"text/javascript; charset=utf-8",
"application/json", "image/svg+xml",
"application/manifest+json",
}
for _, ct := range compressible {
if !compressibleType(ct) {
t.Errorf("compressibleType(%q) = false, want true", ct)
}
}
skip := []string{
"image/png", "image/jpeg", "font/woff2",
"application/wasm", "application/octet-stream", "",
}
for _, ct := range skip {
if compressibleType(ct) {
t.Errorf("compressibleType(%q) = true, want false", ct)
}
}
}
func TestAcceptsGzip(t *testing.T) {
yes := []string{"gzip", "gzip, deflate", "br, gzip;q=0.5", "GZIP"}
for _, h := range yes {
if !acceptsGzip(h) {
t.Errorf("acceptsGzip(%q) = false, want true", h)
}
}
no := []string{"", "deflate", "br", "gzip;q=0"}
for _, h := range no {
if acceptsGzip(h) {
t.Errorf("acceptsGzip(%q) = true, want false", h)
}
}
}