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.
335 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|