Files
pad/internal/server/middleware_compress_test.go
T
xarmian 95046c9b3b feat(server): gzip what the client accepts, except streams, upgrades, ranges and secrets (TASK-2225) (#1934)
* feat(server): gzip what the client accepts, except streams, upgrades, ranges and credential responses (TASK-2225)

Audit C18: the Go server ignored Accept-Encoding entirely, so the items
index (2.1 MB on a large workspace, 6.8x smaller gzipped), every JS chunk
and every API poll went out uncompressed. Invisible on a LAN; most of a
page's load time on a self-hosted WAN link. Pad Cloud already gets Brotli
from Cloudflare, so this is the self-host win.

CompressResponses wraps chi's Compress(5) (negotiation, Vary:
Accept-Encoding, and compressible types only: text, JSON, JS, CSS, SVG;
never images, archives or the export bundle) and skips:
- long-lived streams: /api/v1/events* (SSE) and /mcp (streamable HTTP);
- the collab WebSocket and any Upgrade request;
- Range requests (a 206's Content-Range names identity offsets);
- credential responses (/api/v1/auth/*, /oauth/*, */tokens): the BREACH
  shape, and small enough that skipping costs nothing.

Measured on a throwaway instance: the largest JS chunk 1,456,216 ->
452,518 bytes (3.2x), index.html 6,444 -> 1,354.

Tests: gzip for an accepting client (Vary set, body decodes) and identity
otherwise; every skip; images untouched; an SSE stream still delivers a
flushed event while open; wired into the real router (/api/v1/health).
Mutants: a skip list that never skips, the middleware unwired: each
killed. e2e on the built binary: the SSE and collab specs (14 pass).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): compress reads only, so no minted secret is ever compressed (TASK-2225, codex r1)

Every secret the API mints comes back from a write; write responses are small.
The account export's flushes still deliver (chi's writer flushes gzip then the
connection), now pinned by a test.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): a workspace claim code is never compressed (TASK-2225, codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): a HEAD keeps its Content-Length: compress GETs only (TASK-2225, codex r3)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): the members list is never compressed: it can carry a legacy invitation code (TASK-2225, codex r4)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): a share view's signed attachment references are never compressed (TASK-2225, codex r5 sweep)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(server): a census of every GET route, each classified compress or skip (TASK-2225, lead condition)

The secret-bearing GETs compressSkips leaves alone are a deny list, and a
deny list rots: the next GET route that returns a secret would be
compressed silently. testdata/compress_get_routes.txt holds every GET
route the router serves (149, 18 skipped) with its classification;
TestCompressRouteCensus walks the router with chi.Walk and fails when a
route is missing from the census, when the census names a route the router
no longer serves, or when compressSkips disagrees with it. A new GET route
therefore fails CI until someone decides whether its response can carry a
secret and regenerates the file (PAD_UPDATE_COMPRESS_ROUTES=1), making the
decision a reviewable diff. Checked: a dropped census line fails naming the
route; dropping the claim-code skip fails naming the disagreement.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): leave attachment bytes uncompressed (TASK-2225)

CI on #1934 failed TestTask3401c_AStalledClientDoesNotOutliveTheDeadline
in both Go jobs: the client got EOF before any headers. chi's compressor
holds the headers until its first flush, and a 24 MiB file of repeated
text compresses ~1000:1, so on a slower runner the 300ms route write
deadline expired before a byte reached the wire. Attachment downloads are
users' files streamed under that deadline; they now go out as written.
The attachment list at /attachments still compresses.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(server): state the deadline rule beside the attachment skip (TASK-2225)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 03:16:26 -04:00

225 lines
7.6 KiB
Go

package server
import (
"bufio"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TASK-2225: responses are gzipped for a client that accepts it, except
// streams, upgrades, ranges and responses that carry credentials.
var bigJSON = `{"items":[` + strings.Repeat(`{"id":"00000000-0000-0000-0000-000000000000","title":"a row"},`, 200) + `{}]}`
func jsonHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, bigJSON)
})
}
func get(t *testing.T, h http.Handler, path string, hdr map[string]string) *http.Response {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Result()
}
func TestCompressGzipsJSONForAClientThatAcceptsIt(t *testing.T) {
h := CompressResponses(jsonHandler())
resp := get(t, h, "/api/v1/workspaces/w/items-index", map[string]string{"Accept-Encoding": "gzip"})
if resp.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("Content-Encoding %q; want gzip", resp.Header.Get("Content-Encoding"))
}
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
t.Errorf("Vary %q; a cache must key on Accept-Encoding", resp.Header.Get("Vary"))
}
zr, err := gzip.NewReader(resp.Body)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(zr)
if string(body) != bigJSON {
t.Fatal("the gzipped body does not decode to what the handler wrote")
}
plain := get(t, h, "/api/v1/workspaces/w/items-index", nil)
if plain.Header.Get("Content-Encoding") != "" {
t.Errorf("compressed for a client that did not ask: %q", plain.Header.Get("Content-Encoding"))
}
}
func TestCompressLeavesSkippedResponsesAlone(t *testing.T) {
h := CompressResponses(jsonHandler())
for _, c := range []struct {
path string
hdr map[string]string
}{
{"/api/v1/events", nil},
{"/api/v1/events/stream", nil},
{"/mcp", nil},
{"/api/v1/collab/item-1", map[string]string{"Upgrade": "websocket"}},
{"/api/v1/workspaces/w/items/x", map[string]string{"Upgrade": "websocket"}},
{"/api/v1/workspaces/w/attachments/a", map[string]string{"Range": "bytes=0-99"}},
// An attachment's bytes, without a Range too: a large compressible file
// spent its whole route deadline in the compressor before its headers
// went out (CI on #1934).
{"/api/v1/workspaces/w/attachments/a", nil},
{"/api/app/v1/workspaces/w/attachments/a/content", nil},
{"/api/v1/auth/login", nil},
{"/api/v1/auth/tokens", nil},
{"/api/v1/workspaces/w/tokens", nil},
{"/oauth/token", nil},
{"/api/v1/workspaces/w/claim-code", nil},
{"/api/v1/workspaces/w/members", nil},
{"/api/v1/s/sharetoken", nil},
} {
hdr := map[string]string{"Accept-Encoding": "gzip"}
for k, v := range c.hdr {
hdr[k] = v
}
resp := get(t, h, c.path, hdr)
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
t.Errorf("%s %v: compressed (%s); it must go out as written", c.path, c.hdr, ce)
}
}
}
// The attachment skip is the per-file routes, not the list beside them.
func TestCompressStillCompressesTheAttachmentList(t *testing.T) {
h := CompressResponses(jsonHandler())
resp := get(t, h, "/api/v1/workspaces/w/attachments", map[string]string{"Accept-Encoding": "gzip"})
if ce := resp.Header.Get("Content-Encoding"); ce != "gzip" {
t.Errorf("the attachment list was not compressed (Content-Encoding %q)", ce)
}
}
func TestCompressSkipsTypesNotWorthCompressing(t *testing.T) {
h := CompressResponses(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte(strings.Repeat("\x89PNG", 500)))
}))
if ce := get(t, h, "/api/v1/workspaces/w/attachments/a", map[string]string{"Accept-Encoding": "gzip"}).Header.Get("Content-Encoding"); ce != "" {
t.Errorf("an image was compressed (%s)", ce)
}
}
// An SSE event reaches the client when the handler flushes it, not when the
// stream ends: a compressor holding it in its window would starve the client.
func TestCompressDoesNotBufferAnSSEStream(t *testing.T) {
release := make(chan struct{})
srv := httptest.NewServer(CompressResponses(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: first\n\n")
w.(http.Flusher).Flush()
<-release
})))
defer srv.Close()
defer close(release)
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/events/stream", nil)
req.Header.Set("Accept-Encoding", "gzip")
tr := &http.Transport{DisableCompression: true}
resp, err := (&http.Client{Transport: tr, Timeout: 5 * time.Second}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
t.Fatalf("the SSE stream was compressed (%s)", ce)
}
line := make(chan string, 1)
go func() {
l, _ := bufio.NewReader(resp.Body).ReadString('\n')
line <- l
}()
select {
case l := <-line:
if l != "data: first\n" {
t.Fatalf("first line %q", l)
}
case <-time.After(2 * time.Second):
t.Fatal("the flushed event never arrived while the stream was open")
}
}
func TestCompressIsWiredIntoTheServer(t *testing.T) {
srv := testServer(t)
req := httptest.NewRequest("GET", "/api/v1/health", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("GET /api/v1/health with Accept-Encoding: gzip answered Content-Encoding %q", rec.Header().Get("Content-Encoding"))
}
}
// A write's response is never compressed: every secret the API mints comes
// back from one (codex r1). Nor a HEAD's: its Content-Length is the answer
// (codex r3).
func TestCompressLeavesWriteResponsesAlone(t *testing.T) {
h := CompressResponses(jsonHandler())
for _, m := range []string{"POST", "PUT", "PATCH", "DELETE", "HEAD"} {
req := httptest.NewRequest(m, "/api/v1/workspaces/w/members/invite", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if ce := rec.Header().Get("Content-Encoding"); ce != "" {
t.Errorf("%s: compressed (%s)", m, ce)
}
}
}
// A compressed response that flushes (the account export flushes after each
// workspace) still delivers each flushed part while the handler runs: chi's
// writer flushes the gzip stream and then the connection.
func TestCompressedStreamStillDeliversFlushes(t *testing.T) {
release := make(chan struct{})
srv := httptest.NewServer(CompressResponses(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"first":true}`+"\n")
w.(http.Flusher).Flush()
<-release
})))
defer srv.Close()
defer close(release)
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/me/export", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}, Timeout: 5 * time.Second}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("Content-Encoding %q; want gzip", resp.Header.Get("Content-Encoding"))
}
line := make(chan string, 1)
go func() {
zr, err := gzip.NewReader(resp.Body)
if err != nil {
line <- "error: " + err.Error()
return
}
l, _ := bufio.NewReader(zr).ReadString('\n')
line <- l
}()
select {
case l := <-line:
if l != `{"first":true}`+"\n" {
t.Fatalf("first line %q", l)
}
case <-time.After(2 * time.Second):
t.Fatal("the flushed part never arrived while the response was open")
}
}