Vellum/internal/httpapi/static_test.go
mithral 051169a18a Documentation, and make a fresh clone actually work
Docs
- docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six
  enforced layers, the data model, the scanner, the job queue, direct-play
  playback, and the three protocol surfaces over one application.
- docs/decisions/: seven ADRs with the alternatives that were rejected —
  SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular
  monolith, the one item-access chokepoint, the table-backed queue, and
  fixed-width timestamps.
- README: the layout section was stale (it predated catalog, home, scanner,
  providers, engine, probe, playback, apitoken, arch and the dto packages) and
  now lists what exists, plus links to the new docs.

Two real bugs, both found by verifying the docs instead of trusting them

1. The CLI could not run on a fresh install. `Config.Validate` created the data
   and config directories, but the CLI never called it, so `vellum user add` —
   the recovery path when you are locked out — failed with "unable to open
   database file" until the server had booted at least once. Split the directory
   creation into `Config.EnsureDirs`, call it from the CLI, and keep the media
   root check out of it: an admin command must not fail because a library is
   unmounted. The CLI tests now point DBPath inside DataDir, exactly as
   LoadConfig derives it, so they exercise the fresh-install path; removing
   EnsureDirs fails three of them.

2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile
   when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that
   was never created. Added it, so a clone builds and the server logs
   "web UI not embedded; run the frontend build to include it" — the intended
   behaviour for someone who only wants the API.

Also: the httpapi tests depended on a built frontend and FAILED rather than
skipping, so the suite was red on any fresh clone. findAsset and readAsset now
skip with a message naming the missing step. They still RUN when a bundle is
present (verified: zero skips here), so they have not been quietly disabled.

Verified from a simulated clone of tracked files only: go build exit 0, 25
packages pass, the server boots and applies all 12 migrations, and the CLI
creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
2026-10-12 00:18:11 -04:00

343 lines
10 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 == "" {
// A fresh clone has no built frontend: web/dist holds only .gitkeep, so
// the Go embed compiles and the server logs "web UI not embedded". The
// gzip behaviour cannot be exercised without a real asset, so skip
// rather than fail — the suite must pass on a clone that has not run
// `pnpm build`.
t.Skipf("no embedded asset ending in %q; run `pnpm build` in web/ to exercise this test", 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 {
// Same reason as findAsset: without `pnpm build` the only thing in dist
// is .gitkeep, so a test that reads a real asset cannot run. Skip so the
// suite is green on a fresh clone.
t.Skipf("embedded %s not present; run `pnpm build` in web/ to exercise this test", name)
}
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)
}
}
}