package server import ( "bytes" "encoding/json" "fmt" "io" "log/slog" "mime" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "slices" "strings" "testing" "testing/fstest" "time" "uncensored-send/internal/auth" "uncensored-send/internal/config" "uncensored-send/internal/store" ) // clock is the fixed instant tests start from; s.now is swapped so expiry can // be exercised without sleeping. var clock = time.Date(2026, 9, 12, 10, 0, 0, 0, time.UTC) type harness struct { *Server ts *httptest.Server dir string now time.Time token string // a token allowing vanity names admin string } func newHarness(t *testing.T, tweak func(*config.Config)) *harness { t.Helper() dir := t.TempDir() cfg := config.Config{} fs := config.NewSet("test", "UNCENSORED_SEND_TEST_") cfg.Register(fs) if err := fs.Parse(nil); err != nil { t.Fatal(err) } cfg.DataDir = dir cfg.MaxSize = 1 << 20 cfg.MaxExpiry = 72 * time.Hour cfg.DefaultExpiry = 72 * time.Hour cfg.MinFreeBytes = 0 cfg.UploadRate = 100000 cfg.UploadBurst = 100000 if tweak != nil { tweak(&cfg) } if err := cfg.Normalise(); err != nil { t.Fatal(err) } st, err := store.Open(cfg.DataDir) if err != nil { t.Fatal(err) } tokens, err := auth.Load(cfg.TokensPath) if err != nil { t.Fatal(err) } h := &harness{dir: dir, now: clock} for _, spec := range []struct { name string admin bool dst *string }{{"friend", false, &h.token}, {"boss", true, &h.admin}} { secret, err := store.NewSecret() if err != nil { t.Fatal(err) } if err := tokens.Add(&auth.Token{ Name: spec.name, Hash: auth.HashSecret(secret), AllowVanity: true, Admin: spec.admin, }); err != nil { t.Fatal(err) } *spec.dst = secret } log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv, err := New(&cfg, st, tokens, log) if err != nil { t.Fatal(err) } srv.now = func() time.Time { return h.now } h.Server = srv h.ts = httptest.NewServer(srv) t.Cleanup(h.ts.Close) return h } // upload posts a raw body, the way curl does. func (h *harness) upload(t *testing.T, body []byte, headers map[string]string) *http.Response { t.Helper() return h.uploadReader(t, bytes.NewReader(body), headers) } func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]string) *http.Response { t.Helper() req, err := http.NewRequest("POST", h.ts.URL+"/upload", body) if err != nil { t.Fatal(err) } req.Header.Set("Accept", "application/json") for k, v := range headers { req.Header.Set(k, v) } resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } return resp } // session builds the cookie a browser would be holding for this token. The // value is sealed, so a test cannot simply write the token into it. func (h *harness) session(t *testing.T, token string) *http.Cookie { t.Helper() sealed, err := h.sessions.seal(token) if err != nil { t.Fatal(err) } return &http.Cookie{Name: tokenCookie, Value: sealed} } func decode[T any](t *testing.T, resp *http.Response) T { t.Helper() defer resp.Body.Close() var v T if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { t.Fatalf("decoding %s response: %v", resp.Status, err) } return v } func TestRoundTrip(t *testing.T) { h := newHarness(t, nil) payload := bytes.Repeat([]byte("godot"), 4096) resp := h.upload(t, payload, map[string]string{ "Content-Disposition": `attachment; filename="MyGame.zip"`, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("upload status = %s", resp.Status) } res := decode[uploadResult](t, resp) if res.Filename != "MyGame.zip" { t.Errorf("filename = %q, want MyGame.zip", res.Filename) } if res.Size != int64(len(payload)) { t.Errorf("size = %d, want %d", res.Size, len(payload)) } if res.DeleteToken == "" { t.Error("no delete token returned") } get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID) if err != nil { t.Fatal(err) } defer get.Body.Close() got, _ := io.ReadAll(get.Body) if !bytes.Equal(got, payload) { t.Errorf("downloaded %d bytes, want %d", len(got), len(payload)) } // An uploaded file must never come back as something a browser will run. if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" { t.Errorf("Content-Type = %q", ct) } if get.Header.Get("X-Content-Type-Options") != "nosniff" { t.Error("missing nosniff") } if !strings.Contains(get.Header.Get("Content-Security-Policy"), "sandbox") { t.Errorf("CSP = %q", get.Header.Get("Content-Security-Policy")) } disp, params, err := mime.ParseMediaType(get.Header.Get("Content-Disposition")) if err != nil || disp != "attachment" || params["filename"] != "MyGame.zip" { t.Errorf("Content-Disposition = %q (%v)", get.Header.Get("Content-Disposition"), err) } } func TestHTMLUploadIsServedInert(t *testing.T) { h := newHarness(t, nil) resp := h.upload(t, []byte(""), map[string]string{ "Content-Disposition": `attachment; filename="evil.html"`, }) res := decode[uploadResult](t, resp) get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID) if err != nil { t.Fatal(err) } defer get.Body.Close() if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" { t.Errorf("HTML served as %q; it must never be text/html", ct) } if !strings.HasPrefix(get.Header.Get("Content-Disposition"), "attachment") { t.Error("HTML was not served as an attachment") } } func TestRangeRequest(t *testing.T) { h := newHarness(t, nil) payload := bytes.Repeat([]byte("abcdefgh"), 1024) res := decode[uploadResult](t, h.upload(t, payload, nil)) req, _ := http.NewRequest("GET", h.ts.URL+"/d/"+res.ID, nil) req.Header.Set("Range", "bytes=0-1023") get, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer get.Body.Close() if get.StatusCode != http.StatusPartialContent { t.Fatalf("status = %s, want 206", get.Status) } body, _ := io.ReadAll(get.Body) if len(body) != 1024 || !bytes.Equal(body, payload[:1024]) { t.Errorf("got %d bytes, want the first 1024", len(body)) } if cr := get.Header.Get("Content-Range"); cr != fmt.Sprintf("bytes 0-1023/%d", len(payload)) { t.Errorf("Content-Range = %q", cr) } } // An oversized body must be refused on bytes actually written, never on a // declared length. This sends a chunked body, so there is no Content-Length to // consult even if the code wanted to. func TestOversizeChunkedUploadIsRefused(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.MaxSize = 4096 }) // A plain io.Reader (not a *bytes.Buffer) makes the client use chunked // encoding with no declared length. body := io.LimitReader(zeroes{}, 1<<20) resp := h.uploadReader(t, struct{ io.Reader }{body}, nil) defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("status = %s, want 413", resp.Status) } if n := h.store.Count(); n != 0 { t.Errorf("%d objects stored after a refused upload", n) } assertNoDebris(t, h.dir) } // A body one byte over the cap is refused; exactly at the cap is accepted. func TestSizeLimitBoundary(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.MaxSize = 1000 }) resp := h.upload(t, bytes.Repeat([]byte("x"), 1000), nil) if resp.StatusCode != http.StatusCreated { t.Fatalf("exactly at the limit: status = %s, want 201", resp.Status) } resp.Body.Close() resp = h.upload(t, bytes.Repeat([]byte("x"), 1001), nil) defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("one byte over: status = %s, want 413", resp.Status) } } type zeroes struct{} func (zeroes) Read(p []byte) (int, error) { return len(p), nil } func TestAnonymousCannotClaimVanity(t *testing.T) { h := newHarness(t, nil) resp := h.upload(t, []byte("hi"), map[string]string{"Vanity": "my-file"}) defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("status = %s, want 403", resp.Status) } if h.store.Exists("my-file") { t.Error("the name was claimed despite the refusal") } } func TestVanityCollision(t *testing.T) { h := newHarness(t, nil) hdr := map[string]string{ "Vanity": "my-file", "Authorization": "Bearer " + h.token, } resp := h.upload(t, []byte("first"), hdr) if resp.StatusCode != http.StatusCreated { t.Fatalf("first upload: status = %s", resp.Status) } resp.Body.Close() resp = h.upload(t, []byte("second"), hdr) defer resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("second upload: status = %s, want 409", resp.Status) } // The first object must be untouched. get, err := h.ts.Client().Get(h.ts.URL + "/d/my-file") if err != nil { t.Fatal(err) } defer get.Body.Close() body, _ := io.ReadAll(get.Body) if string(body) != "first" { t.Errorf("content = %q, want %q", body, "first") } } func TestUnknownTokenIsRejected(t *testing.T) { h := newHarness(t, nil) resp := h.upload(t, []byte("hi"), map[string]string{"Authorization": "Bearer nope"}) defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %s, want 401", resp.Status) } } // Expiry is enforced on read, not only by the sweeper, which never runs here. func TestExpiryIsCheckedOnRead(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("ephemeral"), map[string]string{ "Expiry": "1h", })) status := func(when time.Duration) int { h.now = clock.Add(when) get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID) if err != nil { t.Fatal(err) } get.Body.Close() return get.StatusCode } if got := status(59 * time.Minute); got != http.StatusOK { t.Fatalf("before expiry: status = %d, want 200", got) } if got := status(61 * time.Minute); got != http.StatusNotFound { t.Fatalf("after expiry: status = %d, want 404", got) } // The read path also reclaims the space. if _, err := os.Stat(filepath.Join(h.dir, "objects", res.ID)); !os.IsNotExist(err) { t.Error("expired object was not removed on read") } } func TestExpiryBeyondLimitIsRefused(t *testing.T) { h := newHarness(t, nil) // max 72h for anonymous for _, req := range []string{"30d", "never"} { resp := h.upload(t, []byte("hi"), map[string]string{"Expiry": req}) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expiry: %s => status %s, want 400", req, resp.Status) } resp.Body.Close() } } func TestTokenMayOutliveTheAnonymousLimit(t *testing.T) { h := newHarness(t, nil) forever := "never" if err := h.tokens.Remove("friend"); err != nil { t.Fatal(err) } secret, _ := store.NewSecret() if err := h.tokens.Add(&auth.Token{ Name: "friend", Hash: auth.HashSecret(secret), MaxExpiry: &forever, DefaultExpiry: &forever, AllowVanity: true, }); err != nil { t.Fatal(err) } res := decode[uploadResult](t, h.upload(t, []byte("keep me"), map[string]string{ "Authorization": "Bearer " + secret, "Expiry": "never", })) if res.Expires != "" { t.Errorf("expires = %q, want empty (never)", res.Expires) } } func TestPathTraversalIsRejected(t *testing.T) { h := newHarness(t, nil) // As a requested vanity name. for _, name := range []string{"../etc/passwd", "..", ".", "/absolute", "a/b", `a\b`, "ok..name"} { resp := h.upload(t, []byte("x"), map[string]string{ "Vanity": name, "Authorization": "Bearer " + h.token, }) if resp.StatusCode == http.StatusCreated { t.Errorf("vanity %q was accepted", name) } resp.Body.Close() } // As a download path. Some of these are normalised away into a redirect to // the index, which is harmless; what matters is that no stored bytes are // ever served, so the check is for an object response rather than a status. for _, path := range []string{ "/d/..%2f..%2fetc%2fpasswd", "/d/.", "/d/..", "/d/%2e%2e", "/d/../tokens.json", "/d/%2e%2e%2ftokens.json", "/d/objects", } { get, err := h.ts.Client().Get(h.ts.URL + path) if err != nil { continue // the client itself may refuse to send it, which is fine } get.Body.Close() if get.Header.Get("Content-Disposition") != "" { t.Errorf("GET %s served an object", path) } } } // A vanity name has only the spelling rules to satisfy. Names that look like // routes or like the site's own assets are ordinary names, because an id is // reachable only under /d/ and /i/ and never collides with anything of ours. func TestVanityNamesThatLookLikeRoutesAreOrdinary(t *testing.T) { h := newHarness(t, nil) for _, name := range []string{"favicon.png", "admin", "login", "upload", "static", "robots.txt", "tokens.json"} { resp := h.formUpload(t, map[string]string{"vanity": name, "token": h.token}, "f.txt", "body of "+name) body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Errorf("vanity %q => %s: %s", name, resp.Status, strings.TrimSpace(string(body))) continue } // And it is genuinely reachable at the name that was asked for. got := h.get(t, "/d/"+name, "") content, _ := io.ReadAll(got.Body) got.Body.Close() if got.StatusCode != http.StatusOK { t.Errorf("GET /d/%s => %s, want 200", name, got.Status) } if string(content) != "body of "+name { t.Errorf("GET /d/%s served %q, not the file that was uploaded", name, content) } } } // The spelling rules themselves still stand: they are what keeps a name from // becoming a path element it should not be. func TestMalformedVanityNamesAreRefused(t *testing.T) { h := newHarness(t, nil) for _, name := range []string{"d", "i", "no spaces allowed", "-leading-dash", "..", "trailing.", "a/b"} { resp := h.upload(t, []byte("x"), map[string]string{ "Vanity": name, "Authorization": "Bearer " + h.token, }) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("vanity %q => %s, want 400", name, resp.Status) } } } func TestDeleteRequiresTheRightToken(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("delete me"), nil)) del := func(token string) int { form := strings.NewReader("token=" + token) req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } if got := del("wrong-token"); got != http.StatusForbidden { t.Errorf("wrong token => %d, want 403", got) } if got := del(h.token); got != http.StatusForbidden { t.Errorf("a non-owning, non-admin token => %d, want 403", got) } if got := del(res.DeleteToken); got != http.StatusOK { t.Errorf("correct token => %d, want 200", got) } // The token is single-use because the object it names is gone. if got := del(res.DeleteToken); got != http.StatusNotFound { t.Errorf("reused token => %d, want 404", got) } assertNoDebris(t, h.dir) } func TestAdminMayDeleteAnything(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil)) req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", nil) req.Header.Set("Authorization", "Bearer "+h.admin) req.Header.Set("Accept", "application/json") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("admin delete => %s, want 200", resp.Status) } } func TestMultipartUpload(t *testing.T) { h := newHarness(t, nil) var body bytes.Buffer mw := multipart.NewWriter(&body) // Order matters: the server needs these before the file part arrives. mw.WriteField("token", h.token) mw.WriteField("expiry", "2h") mw.WriteField("vanity", "from-the-form") fw, err := mw.CreateFormFile("file", "notes (draft).txt") if err != nil { t.Fatal(err) } fw.Write([]byte("hello from a browser")) mw.Close() req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Accept", "application/json") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %s", resp.Status) } res := decode[uploadResult](t, resp) if res.ID != "from-the-form" { t.Errorf("id = %q, want from-the-form", res.ID) } if res.Filename != "notes (draft).txt" { t.Errorf("filename = %q", res.Filename) } } // A form post with no Accept: application/json gets the HTML success page, so // the no-JS path works. func TestFormPostRendersHTML(t *testing.T) { h := newHarness(t, nil) var body bytes.Buffer mw := multipart.NewWriter(&body) fw, _ := mw.CreateFormFile("file", "thing.bin") fw.Write([]byte("data")) mw.Close() req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Accept", "text/html,application/xhtml+xml") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("Content-Type = %q, want HTML", ct) } page, _ := io.ReadAll(resp.Body) if !strings.Contains(string(page), "Delete token") { t.Error("the success page does not show the delete token") } // The script asks for this same page and writes it into the document it is // running in, so a fragment would leave the browser with a half a page. if !strings.HasPrefix(string(page), "") || !strings.Contains(string(page), "") { t.Error("the success page is not a whole document") } if !strings.Contains(string(page), "static/app.js") { t.Error("the success page does not load the script, so its copy buttons stay dead") } } func TestBasePathMounting(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.BasePath = "/send" }) get, err := h.ts.Client().Get(h.ts.URL + "/send/") if err != nil { t.Fatal(err) } defer get.Body.Close() if get.StatusCode != http.StatusOK { t.Fatalf("GET /send/ => %s", get.Status) } page, _ := io.ReadAll(get.Body) if !strings.Contains(string(page), `href="/send/static/style.css"`) { t.Error("page links do not carry the base path") } // The bare prefix redirects to the slashed form. noRedirect := *h.ts.Client() noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } resp, err := noRedirect.Get(h.ts.URL + "/send") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusMovedPermanently { t.Errorf("GET /send => %s, want 301", resp.Status) } } // An upload that dies mid-flight must leave nothing visible behind, and the // sweeper must eventually reclaim the directory. func TestAbandonedUploadIsInvisibleAndSwept(t *testing.T) { h := newHarness(t, nil) up, err := h.store.Reserve("half-done") if err != nil { t.Fatal(err) } up.Write([]byte("partial")) // Deliberately no Commit and no Abort: this is what a killed process leaves. get, err := h.ts.Client().Get(h.ts.URL + "/d/half-done") if err != nil { t.Fatal(err) } get.Body.Close() if get.StatusCode != http.StatusNotFound { t.Errorf("an uncommitted object was visible: %s", get.Status) } dir := filepath.Join(h.dir, "objects", "half-done") old := clock.Add(-48 * time.Hour) if err := os.Chtimes(dir, old, old); err != nil { t.Fatal(err) } h.store.Sweep(clock) if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Error("abandoned upload directory was not swept") } } func TestQuotaRefusesUploads(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.MaxTotalBytes = 100 }) resp := h.upload(t, bytes.Repeat([]byte("x"), 80), nil) if resp.StatusCode != http.StatusCreated { t.Fatalf("first upload => %s", resp.Status) } resp.Body.Close() // Only 20 bytes of quota remain, so this is truncated to the remainder and // refused rather than allowed to overshoot. resp = h.upload(t, bytes.Repeat([]byte("x"), 80), nil) defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("over quota => %s, want 413", resp.Status) } } func TestRateLimit(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.UploadRate = 1 c.UploadBurst = 2 }) var last *http.Response for range 3 { if last != nil { last.Body.Close() } last = h.upload(t, []byte("x"), nil) } defer last.Body.Close() if last.StatusCode != http.StatusTooManyRequests { t.Fatalf("third upload => %s, want 429", last.Status) } } // assertNoDebris checks that no object directory was left behind. func assertNoDebris(t *testing.T, dir string) { t.Helper() entries, err := os.ReadDir(filepath.Join(dir, "objects")) if err != nil { t.Fatal(err) } for _, e := range entries { t.Errorf("leftover object directory: %s", e.Name()) } } // --- remembered tokens --------------------------------------------------- // Logging in is what stores a token; uploading never touches the cookie. func TestLoginStoresTheToken(t *testing.T) { h := newHarness(t, nil) resp := h.postForm(t, "/login", url.Values{"token": {h.token}, "persist": {"1"}}, nil) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status = %s, want 303", resp.Status) } if loc := resp.Header.Get("Location"); loc != "/" { t.Errorf("Location = %q, want /", loc) } cookie := findCookie(resp, tokenCookie) if cookie == nil { t.Fatal("logging in set no cookie") } // The whole point of sealing it: the credential is not sitting in the // browser's cookie jar for anyone glancing at a developer console. if strings.Contains(cookie.Value, h.token) { t.Error("the cookie carries the token in the clear") } if got := h.sessions.open(cookie.Value); got != h.token { t.Errorf("the cookie does not open to the token (got %q)", got) } if !cookie.HttpOnly { t.Error("the session cookie is readable by scripts") } if cookie.SameSite != http.SameSiteStrictMode { t.Error("the session cookie is not SameSite=Strict, so it is CSRF-exposed") } if cookie.MaxAge <= 0 { t.Error("'stay logged in' did not persist the cookie") } // The session alone is now enough to claim a vanity name. req, _ := http.NewRequest("POST", h.ts.URL+"/upload", strings.NewReader("two")) req.Header.Set("Accept", "application/json") req.Header.Set("Vanity", "session-upload") req.AddCookie(cookie) up, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } if up.StatusCode != http.StatusCreated { t.Fatalf("upload with only the session: status = %s", up.Status) } if res := decode[uploadResult](t, up); res.ID != "session-upload" { t.Errorf("id = %q, want session-upload", res.ID) } } // Without "stay logged in" the cookie must die with the browser. func TestLoginWithoutPersistIsASessionCookie(t *testing.T) { h := newHarness(t, nil) resp := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil) resp.Body.Close() c := findCookie(resp, tokenCookie) if c == nil { t.Fatal("no cookie was set") } if c.MaxAge != 0 || !c.Expires.IsZero() { t.Errorf("cookie carries a lifetime (MaxAge=%d), want a session cookie", c.MaxAge) } } func TestLoginRejectsAnUnknownToken(t *testing.T) { h := newHarness(t, nil) resp := h.postForm(t, "/login", url.Values{"token": {"not-a-token"}}, nil) raw, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %s, want 401", resp.Status) } if findCookie(resp, tokenCookie) != nil { t.Error("a rejected login still set a cookie") } if !strings.Contains(string(raw), "not recognised") { t.Error("the login page does not say what went wrong") } } // Guessing a token at the login form is throttled; a correct one is not. func TestFailedLoginsAreThrottled(t *testing.T) { h := newHarness(t, nil) h.authLimiter = newLimiter(1, 3) var last *http.Response for range 5 { if last != nil { last.Body.Close() } last = h.postForm(t, "/login", url.Values{"token": {"guess"}}, nil) } if last.StatusCode != http.StatusTooManyRequests { t.Fatalf("repeated guesses => %s, want 429", last.Status) } last.Body.Close() resp := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("a correct token was throttled: %s", resp.Status) } } // The post-login destination is an allowlisted page name, never a URL, so it // cannot be turned into an open redirect. func TestLoginRedirectIsAllowlisted(t *testing.T) { h := newHarness(t, nil) for _, c := range []struct{ next, want string }{ {"files", "/files"}, {"", "/"}, {"https://evil.example.com", "/"}, {"//evil.example.com", "/"}, {"../../etc", "/"}, } { resp := h.postForm(t, "/login", url.Values{"token": {h.admin}, "next": {c.next}}, nil) resp.Body.Close() if loc := resp.Header.Get("Location"); loc != c.want { t.Errorf("next=%q => Location %q, want %q", c.next, loc, c.want) } } } func TestLogoutEndsTheSession(t *testing.T) { h := newHarness(t, nil) resp := h.postForm(t, "/logout", url.Values{}, h.session(t, h.token)) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status = %s, want 303", resp.Status) } c := findCookie(resp, tokenCookie) if c == nil || c.MaxAge >= 0 || c.Value != "" { t.Fatalf("the session cookie was not cleared: %v", c) } } // Uploading must never change the session, in either direction. func TestUploadNeverTouchesTheSession(t *testing.T) { h := newHarness(t, nil) resp := h.formUpload(t, map[string]string{"token": h.token}, "a.bin", "one") resp.Body.Close() if c := findCookie(resp, tokenCookie); c != nil { t.Errorf("an upload with a one-off token set a session cookie: %v", c) } resp = h.formUploadWith(t, h.session(t, h.token), map[string]string{}, "b.bin", "two") resp.Body.Close() if c := findCookie(resp, tokenCookie); c != nil { t.Errorf("an upload cleared the session: %v", c) } } // A one-off token on the form wins over the logged-in session. func TestExplicitTokenBeatsTheCookie(t *testing.T) { h := newHarness(t, nil) cookie := h.session(t, h.token) resp := h.formUploadWith(t, cookie, map[string]string{"token": h.admin}, "b.bin", "y") defer resp.Body.Close() res := decode[uploadResult](t, resp) m, err := h.store.Get(res.ID, h.now) if err != nil { t.Fatal(err) } if m.Owner != "boss" { t.Errorf("owner = %q, want boss: the session shadowed the one-off token", m.Owner) } } // The cookie is sealed with a key this server holds, so one from anywhere else // is not a session. This is also the upgrade path: every cookie written in the // old plain-text format arrives here. func TestCookieFromAnotherKeyIsNotASession(t *testing.T) { h := newHarness(t, nil) // A cookie holding the raw token, exactly as the previous format wrote it. plain := &http.Cookie{Name: tokenCookie, Value: h.token} req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req.AddCookie(plain) resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } page, _ := io.ReadAll(resp.Body) resp.Body.Close() if strings.Contains(string(page), ">friend<") { t.Error("a plain-text cookie was accepted as a session") } if c := findCookie(resp, tokenCookie); c == nil || c.MaxAge >= 0 { t.Error("the unusable cookie was not cleared, so the browser keeps sending it") } // Nor does it work anywhere the cookie is a credential. res := h.formUploadWith(t, plain, map[string]string{"vanity": "should-not-work"}, "f.txt", "x") defer res.Body.Close() if res.StatusCode != http.StatusForbidden { t.Errorf("upload with a plain-text cookie = %s, want 403", res.Status) } // A cookie sealed by a different server is just as dead. other, err := newSealer(filepath.Join(t.TempDir(), "session.key")) if err != nil { t.Fatal(err) } sealed, err := other.seal(h.token) if err != nil { t.Fatal(err) } if got := h.sessions.open(sealed); got != "" { t.Errorf("a cookie from another key opened to %q", got) } } // The key outlives the process: a restart must not log everyone out. func TestSessionKeyIsReusedAcrossRestarts(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.key") first, err := newSealer(path) if err != nil { t.Fatal(err) } sealed, err := first.seal("a-token") if err != nil { t.Fatal(err) } second, err := newSealer(path) if err != nil { t.Fatal(err) } if got := second.open(sealed); got != "a-token" { t.Errorf("after a restart the cookie opened to %q, want the token back", got) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Errorf("session key mode = %o, want 600: it is credential material", perm) } // A key that is present but unusable must stop the server rather than be // replaced, which would silently log out every session. if err := os.WriteFile(path, []byte("not a key"), 0o600); err != nil { t.Fatal(err) } if _, err := newSealer(path); err == nil { t.Error("a corrupt session key was accepted") } } // Two seals of the same token must differ, or the cookie becomes a stable // fingerprint of which token a visitor holds. func TestSealingIsNotDeterministic(t *testing.T) { s, err := newSealer(filepath.Join(t.TempDir(), "session.key")) if err != nil { t.Fatal(err) } first, err := s.seal("a-token") if err != nil { t.Fatal(err) } second, err := s.seal("a-token") if err != nil { t.Fatal(err) } if first == second { t.Error("sealing the same token twice gave the same cookie") } for _, c := range []string{first, second} { if got := s.open(c); got != "a-token" { t.Errorf("cookie opened to %q, want the token", got) } } } // A session whose token has since been revoked must not wedge the page. func TestStaleCookieIsDropped(t *testing.T) { h := newHarness(t, nil) req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: "a-token-that-was-revoked"}) resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %s, want the page to still render", resp.Status) } if c := findCookie(resp, tokenCookie); c == nil || c.MaxAge >= 0 { t.Error("a stale cookie was not dropped") } page, _ := io.ReadAll(resp.Body) if !strings.Contains(string(page), "no longer valid") { t.Error("the page does not explain that the session ended") } if !strings.Contains(string(page), "anonymous") { t.Error("the page claims an identity it could not resolve") } } // The session is resolved server-side, so every page agrees about who you are // even though the cookie is unreadable by script. func TestIndexShowsWhoIsLoggedIn(t *testing.T) { h := newHarness(t, nil) req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req.AddCookie(h.session(t, h.token)) resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() page, _ := io.ReadAll(resp.Body) if !strings.Contains(string(page), ">friend<") { t.Error("the page does not show who is logged in") } if strings.Contains(string(page), h.token) { t.Error("the page echoes the token back into the HTML") } } // The per-object delete token must still work when a cookie is also present. func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) { h := newHarness(t, nil) // Uploaded anonymously, so the logged-in token owns nothing here. res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) form := strings.NewReader("token=" + res.DeleteToken) req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") req.AddCookie(h.session(t, h.token)) resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %s, want 200: the cookie shadowed the delete token", resp.Status) } } func findCookie(resp *http.Response, name string) *http.Cookie { for _, c := range resp.Cookies() { if c.Name == name { return c } } return nil } // formUpload posts the multipart form the browser would, with fields ordered // ahead of the file part. func (h *harness) formUpload(t *testing.T, fields map[string]string, filename, content string) *http.Response { t.Helper() return h.formUploadWith(t, nil, fields, filename, content) } func (h *harness) formUploadWith(t *testing.T, cookie *http.Cookie, fields map[string]string, filename, content string) *http.Response { t.Helper() var body bytes.Buffer mw := multipart.NewWriter(&body) for k, v := range fields { mw.WriteField(k, v) } fw, err := mw.CreateFormFile("file", filename) if err != nil { t.Fatal(err) } fw.Write([]byte(content)) mw.Close() req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Accept", "application/json") if cookie != nil { req.AddCookie(cookie) } resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } return resp } // --- favicon -------------------------------------------------------------- // No icon is committed, so the selection logic is exercised against stand-in // filesystems: a build that has one is a build nobody can write a test for. func TestFaviconSelection(t *testing.T) { for _, c := range []struct { name string files []string want string }{ {"nothing shipped", nil, ""}, {"a png", []string{"favicon.png"}, "favicon.png"}, {"an ico", []string{"favicon.ico"}, "favicon.ico"}, {"both, png wins", []string{"favicon.ico", "favicon.png"}, "favicon.png"}, {"something else entirely", []string{"logo.png"}, ""}, } { fsys := fstest.MapFS{} for _, f := range c.files { fsys[f] = &fstest.MapFile{Data: []byte("x")} } if got := faviconFor(fsys); got != c.want { t.Errorf("%s: faviconFor = %q, want %q", c.name, got, c.want) } } } // With no icon in the build, the markup must not promise one: a link to a // missing file costs every visitor a 404 on every page. func TestNoFaviconMeansNoLink(t *testing.T) { h := newHarness(t, nil) if h.favicon != "" { t.Skipf("this build embeds %q, so the empty case cannot be checked here", h.favicon) } resp := h.get(t, "/", "") page, _ := io.ReadAll(resp.Body) resp.Body.Close() if strings.Contains(string(page), `rel="icon"`) { t.Error("the page links an icon that this build does not carry") } resp = h.get(t, "/favicon.ico", "") resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("GET /favicon.ico = %s, want 404 when no icon is embedded", resp.Status) } } // --- the footer ------------------------------------------------------------ // The upload page says where the source is; no other page does, and an // instance that would rather not say so drops it there too. func TestSourceLinkIsOptional(t *testing.T) { body := func(h *harness, path string) string { t.Helper() resp := h.get(t, path, "") raw, _ := io.ReadAll(resp.Body) resp.Body.Close() return string(raw) } shown := newHarness(t, nil) if !strings.Contains(body(shown, "/"), ``) { t.Error("the upload page does not link its source") } for _, path := range []string{"/login", "/files"} { if strings.Contains(body(shown, path), "git.uncensored.hu") { t.Errorf("GET %s carries the source link, which belongs on the upload page alone", path) } } forked := newHarness(t, func(c *config.Config) { c.SourceURL = "https://example.org/me/fork" }) if !strings.Contains(body(forked, "/"), `href="https://example.org/me/fork"`) { t.Error("a fork's own source URL is not used") } quiet := newHarness(t, func(c *config.Config) { c.SourceURL = "" }) if page := body(quiet, "/"); strings.Contains(page, ">Source<") || strings.Contains(page, "git.uncensored.hu") { t.Error("the upload page still names a source with the link switched off") } } // The footer is the one link that leaves this origin, so the header that keeps // it from naming this instance to the far end has to stay put. func TestOutboundRequestsCarryNoReferrer(t *testing.T) { h := newHarness(t, nil) resp := h.get(t, "/", "") resp.Body.Close() if got := resp.Header.Get("Referrer-Policy"); got != "no-referrer" { t.Errorf("Referrer-Policy = %q, want no-referrer", got) } } // --- content security policy --------------------------------------------- // The page's own behaviour and its CSP have to agree, and nothing in a Go test // or a curl invocation enforces CSP, only a browser does. This reads the // script that is actually shipped, works out which fetch directives the page // needs, and checks the policy grants them. // // It exists because omitting connect-src once made the browser block every // upload while every server-side test still passed. func TestAppCSPAllowsWhatThePageDoes(t *testing.T) { h := newHarness(t, nil) resp, err := h.ts.Client().Get(h.ts.URL + "/static/app.js") if err != nil { t.Fatal(err) } defer resp.Body.Close() script, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } // Which directive each capability the script might use depends on. Every // fetch directive falls back to default-src when unlisted, and default-src // here is 'none', so anything the script does must be granted explicitly. needs := []struct { directive string used bool because string }{ {"connect-src", bytes.Contains(script, []byte("XMLHttpRequest")) || bytes.Contains(script, []byte("fetch(")), "the page makes XHR or fetch calls"}, {"script-src", true, "the page loads an external script"}, {"style-src", true, "the page loads an external stylesheet"}, {"form-action", true, "the page posts a form"}, } page, err := h.ts.Client().Get(h.ts.URL + "/") if err != nil { t.Fatal(err) } page.Body.Close() csp := page.Header.Get("Content-Security-Policy") if csp == "" { t.Fatal("the upload page carries no Content-Security-Policy") } directives := map[string]string{} for _, d := range strings.Split(csp, ";") { name, value, _ := strings.Cut(strings.TrimSpace(d), " ") directives[strings.ToLower(name)] = strings.TrimSpace(value) } if directives["default-src"] != "'none'" { t.Errorf("default-src = %q, want 'none': the checks below assume it denies by default", directives["default-src"]) } for _, n := range needs { if !n.used { continue } value, ok := directives[n.directive] if !ok { t.Errorf("CSP has no %s, but %s; the browser will fall back to default-src and block it", n.directive, n.because) continue } if !strings.Contains(value, "'self'") { t.Errorf("CSP %s = %q, which does not allow this origin, but %s", n.directive, value, n.because) } } } // The download policy is the opposite case: it must stay maximally restrictive, // since it governs bytes a stranger uploaded. func TestDownloadCSPStaysInert(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte(""), nil)) get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID) if err != nil { t.Fatal(err) } get.Body.Close() csp := get.Header.Get("Content-Security-Policy") if !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "sandbox") { t.Errorf("download CSP = %q, want default-src 'none' and sandbox", csp) } for _, forbidden := range []string{"connect-src", "script-src 'self'", "'unsafe-inline'"} { if strings.Contains(csp, forbidden) { t.Errorf("download CSP contains %q; uploaded bytes must be granted nothing", forbidden) } } } // The result page is the only place a link to the info page is ever offered, // so losing it strands that page with no way to discover it. func TestResultPageOffersBothLinksAndAWayBack(t *testing.T) { h := newHarness(t, nil) var body bytes.Buffer mw := multipart.NewWriter(&body) fw, _ := mw.CreateFormFile("file", "thing.bin") fw.Write([]byte("data")) mw.Close() req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Accept", "text/html") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) page := string(raw) id := h.store.List()[0].ID for _, want := range []struct{ what, fragment string }{ {"the info page link", "/i/" + id}, {"the direct download link", "/d/" + id}, {"a way to upload another file", `href="/">Upload another file`}, {"the script that enables the copy buttons", "static/app.js"}, } { if !strings.Contains(page, want.fragment) { t.Errorf("the result page is missing %s (%q)", want.what, want.fragment) } } // Copy buttons ship hidden, so a reader without JavaScript never sees a // button that does nothing. if strings.Count(page, `class="copy"`) != strings.Count(page, `hidden>Copy<`) { t.Error("a copy button is not hidden by default") } } // The JSON reply has to carry the same two links, since the script builds the // result card from it alone. func TestUploadJSONCarriesBothLinks(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) if res.URL == "" || !strings.Contains(res.URL, "/d/"+res.ID) { t.Errorf("url = %q, want the direct download", res.URL) } if res.InfoURL == "" || !strings.Contains(res.InfoURL, "/i/"+res.ID) { t.Errorf("info_url = %q, want the info page", res.InfoURL) } // Both must actually resolve. for _, u := range []string{res.URL, res.InfoURL} { get, err := h.ts.Client().Get(u) if err != nil { t.Fatal(err) } get.Body.Close() if get.StatusCode != http.StatusOK { t.Errorf("GET %s => %s", u, get.Status) } } } // --- administration ------------------------------------------------------ func (h *harness) get(t *testing.T, path, token string) *http.Response { t.Helper() req, _ := http.NewRequest("GET", h.ts.URL+path, nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } return resp } // The listing is open to anyone with a token; what changes is its contents. // Only a credential that does not resolve is turned away. func TestFilesPageAccess(t *testing.T) { h := newHarness(t, nil) for _, c := range []struct { who string token string want int }{ {"anonymous", "", http.StatusOK}, {"an unknown token", "not-a-token", http.StatusUnauthorized}, {"a plain token", h.token, http.StatusOK}, {"an admin token", h.admin, http.StatusOK}, } { resp := h.get(t, "/files", c.token) resp.Body.Close() if resp.StatusCode != c.want { t.Errorf("GET /files as %s => %s, want %d", c.who, resp.Status, c.want) } } } // The whole point of the page for a token holder: their own uploads come back, // and nobody else's do. Without this there is no way to find a file again once // the link has been lost. func TestFilesPageListsOnlyYourOwnUploads(t *testing.T) { h := newHarness(t, nil) h.upload(t, []byte("mine"), map[string]string{ "Authorization": "Bearer " + h.token, "Content-Disposition": `attachment; filename="mine.bin"`}).Body.Close() h.upload(t, []byte("theirs"), map[string]string{ "Authorization": "Bearer " + h.admin, "Content-Disposition": `attachment; filename="theirs.bin"`}).Body.Close() h.upload(t, []byte("nobodys"), map[string]string{ "Content-Disposition": `attachment; filename="nobodys.bin"`}).Body.Close() resp := h.get(t, "/files", h.token) raw, _ := io.ReadAll(resp.Body) resp.Body.Close() page := string(raw) if !strings.Contains(page, "mine.bin") { t.Error("a token holder cannot see their own upload") } for _, hidden := range []string{"theirs.bin", "nobodys.bin"} { if strings.Contains(page, hidden) { t.Errorf("the listing shows %q, which belongs to someone else", hidden) } } // The server's own state is an admin's business, not a guest's. for _, secret := range []string{"Free disk", "Tokens", "boss"} { if strings.Contains(page, secret) { t.Errorf("a plain token's listing exposes %q", secret) } } // An owner column would be a column of one repeated name. if strings.Contains(page, `data-label="Owner"`) { t.Error("the listing carries an owner column for a reader who owns everything in it") } // The admin, by contrast, sees all three. resp = h.get(t, "/files", h.admin) raw, _ = io.ReadAll(resp.Body) resp.Body.Close() for _, want := range []string{"mine.bin", "theirs.bin", "nobodys.bin"} { if !strings.Contains(string(raw), want) { t.Errorf("the admin listing is missing %q", want) } } } // Anonymous uploads are recorded against nobody, so the page says so rather // than pretending to be empty or refusing outright. func TestFilesPageAsksAnonymousVisitorsToLogIn(t *testing.T) { h := newHarness(t, nil) h.upload(t, []byte("nobodys"), map[string]string{ "Content-Disposition": `attachment; filename="nobodys.bin"`}).Body.Close() resp := h.get(t, "/files", "") raw, _ := io.ReadAll(resp.Body) resp.Body.Close() page := string(raw) if resp.StatusCode != http.StatusOK { t.Errorf("status = %s, want 200: the page exists, it just needs a token", resp.Status) } if !strings.Contains(page, `href="/login?next=files`) { t.Error("the page does not offer a way to log in and come back") } if strings.Contains(page, "nobodys.bin") { t.Error("an anonymous visitor is shown files they cannot be known to own") } } // A token holder may delete what they uploaded, and lands back on the listing. func TestOwnerDeletesFromTheListing(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("mine"), map[string]string{ "Authorization": "Bearer " + h.token, "Content-Disposition": `attachment; filename="mine.bin"`})) client := *h.ts.Client() client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", strings.NewReader("from=files")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "text/html") req.AddCookie(h.session(t, h.token)) resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status = %s, want 303", resp.Status) } if loc := resp.Header.Get("Location"); loc != "/files" { t.Errorf("Location = %q, want /files", loc) } if _, err := h.store.Get(res.ID, h.now); err == nil { t.Error("the owner's own file was not deleted") } } // The cookie is the credential a browser actually uses for this page. func TestFilesPageAcceptsTheSession(t *testing.T) { h := newHarness(t, nil) req, _ := http.NewRequest("GET", h.ts.URL+"/files", nil) req.AddCookie(h.session(t, h.admin)) resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("GET /files with an admin cookie => %s", resp.Status) } } func TestAdminSeesEveryFileAndNoExpiredOnes(t *testing.T) { h := newHarness(t, nil) // One anonymous, one owned, one that will have expired by the time the // page is rendered. h.upload(t, []byte("anon"), map[string]string{ "Content-Disposition": `attachment; filename="anonymous.bin"`}).Body.Close() h.upload(t, []byte("owned"), map[string]string{ "Authorization": "Bearer " + h.token, "Vanity": "friends-file", "Content-Disposition": `attachment; filename="owned.bin"`}).Body.Close() h.upload(t, []byte("gone"), map[string]string{ "Expiry": "1h", "Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close() h.now = clock.Add(2 * time.Hour) resp := h.get(t, "/files", h.admin) defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) page := string(raw) for _, want := range []string{"anonymous.bin", "owned.bin", "friends-file", "friend"} { if !strings.Contains(page, want) { t.Errorf("the admin listing does not list %q", want) } } if strings.Contains(page, "expired.bin") { t.Error("the admin listing shows an expired file as though it were still stored") } // Token names and limits are shown; nothing secret is. if !strings.Contains(page, "boss") { t.Error("the token table does not list the tokens") } for _, secret := range []string{h.admin, h.token} { if strings.Contains(page, secret) { t.Error("the listing echoes a token secret") } if strings.Contains(page, auth.HashSecret(secret)) { t.Error("the admin page exposes a token hash") } } } func TestFilesSortIsRestrictedToKnownColumns(t *testing.T) { h := newHarness(t, nil) h.upload(t, []byte("x"), nil).Body.Close() for _, sort := range []string{"size", "created", "expires", "name", "owner", "", "../../etc", "nonsense"} { resp := h.get(t, "/files?sort="+url.QueryEscape(sort), h.admin) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("sort=%q => %s", sort, resp.Status) } } } // Deleting from the table returns to the table rather than to a dead end. func TestAdminDeleteReturnsToTheListing(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil)) client := *h.ts.Client() client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } form := strings.NewReader("from=files") req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "text/html") req.AddCookie(h.session(t, h.admin)) resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status = %s, want 303", resp.Status) } if loc := resp.Header.Get("Location"); loc != "/files" { t.Errorf("Location = %q, want /files", loc) } if _, err := h.store.Get(res.ID, h.now); err == nil { t.Error("the file was not deleted") } } // A non-admin must not be able to delete someone else's file from that form. func TestAdminDeleteStillRequiresAdmin(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("not yours"), nil)) form := strings.NewReader("from=files") req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") req.AddCookie(h.session(t, h.token)) resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("status = %s, want 403", resp.Status) } } // The header link is the only way to discover the page, so it has to appear // for everyone who has a listing to see - which is anyone logged in, not just // an admin - and for nobody who does not. func TestFilesLinkIsShownToEveryoneLoggedIn(t *testing.T) { h := newHarness(t, nil) for _, c := range []struct { who string token string want bool }{ {"anonymous", "", false}, {"a plain token", h.token, true}, {"an admin token", h.admin, true}, } { req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) if c.token != "" { req.AddCookie(h.session(t, c.token)) } resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } raw, _ := io.ReadAll(resp.Body) resp.Body.Close() if got := strings.Contains(string(raw), `href="/files"`); got != c.want { t.Errorf("files link shown to %s = %v, want %v", c.who, got, c.want) } } } // Mixing the two request shapes, a multipart body with the headers the raw // shape uses, must not silently discard the options. Being handed a UUID when // you asked for a name is worse than being told no. func TestMultipartHonoursTheHeaderForm(t *testing.T) { h := newHarness(t, nil) var body bytes.Buffer mw := multipart.NewWriter(&body) fw, _ := mw.CreateFormFile("file", "build.zip") fw.Write([]byte("payload")) mw.Close() req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+h.token) req.Header.Set("Vanity", "friends-build") req.Header.Set("Expiry", "1h") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %s", resp.Status) } res := decode[uploadResult](t, resp) if res.ID != "friends-build" { t.Errorf("id = %q, want friends-build: the Vanity header was ignored", res.ID) } if want := clock.Add(time.Hour).UTC().Format(time.RFC3339); res.Expires != want { t.Errorf("expires = %q, want %q: the Expiry header was ignored", res.Expires, want) } } // A form field still wins, so the browser's own controls stay authoritative. func TestFormFieldsOverrideTheHeaders(t *testing.T) { h := newHarness(t, nil) var body bytes.Buffer mw := multipart.NewWriter(&body) mw.WriteField("vanity", "from-the-form") mw.WriteField("expiry", "") fw, _ := mw.CreateFormFile("file", "build.zip") fw.Write([]byte("payload")) mw.Close() req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+h.token) req.Header.Set("Vanity", "from-the-header") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } res := decode[uploadResult](t, resp) if res.ID != "from-the-form" { t.Errorf("id = %q, want the form field to win", res.ID) } } // --- deleting from the info page ----------------------------------------- // postForm submits a form the way a browser would, without following the // redirect: where these posts send you, and what they set on the way, is // usually the thing under test. func (h *harness) postForm(t *testing.T, path string, form url.Values, cookie *http.Cookie) *http.Response { t.Helper() req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "text/html") if cookie != nil { req.AddCookie(cookie) } client := *h.ts.Client() client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } resp, err := client.Do(req) if err != nil { t.Fatal(err) } return resp } // The delete token is shown once and then has to be usable somewhere. The info // page is the link an uploader would have kept, so the form lives there. // detailsIsOpen reports whether the page's first
is rendered open, // by reading the attribute rather than matching the whole tag: the element // carries classes too, and styling it must not be able to fail this. func detailsIsOpen(page string) bool { start := strings.Index(page, "") if end < 0 { return false } return strings.Contains(page[start:start+end], " open") } func TestInfoPageAcceptsTheDeleteToken(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) info := h.get(t, "/i/"+res.ID, "") raw, _ := io.ReadAll(info.Body) info.Body.Close() page := string(raw) if !strings.Contains(page, "Remove this file") { t.Error("the info page offers no way to use a delete token") } if !strings.Contains(page, `name="token"`) { t.Error("the info page has no field for the delete token") } if strings.Contains(page, res.DeleteToken) { t.Fatal("the info page leaks the delete token to anyone holding the link") } resp := h.postForm(t, "/d/"+res.ID+"/delete", url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("deleting with the right token => %s", resp.Status) } if _, err := h.store.Get(res.ID, h.now); err == nil { t.Error("the file was not deleted") } } // A mistyped token must land back on the file's page with the reason, not on a // generic error page that has thrown the form away. func TestWrongDeleteTokenReturnsToTheInfoPage(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{ "Content-Disposition": `attachment; filename="keepme.bin"`})) resp := h.postForm(t, "/d/"+res.ID+"/delete", url.Values{"from": {"info"}, "token": {"wrong"}}, nil) raw, _ := io.ReadAll(resp.Body) resp.Body.Close() page := string(raw) if resp.StatusCode != http.StatusForbidden { t.Errorf("status = %s, want 403", resp.Status) } if !strings.Contains(page, "keepme.bin") { t.Error("the response is not the file's own page") } if !strings.Contains(page, "not correct") { t.Error("the page does not say what went wrong") } if !detailsIsOpen(page) { t.Error("the delete section is collapsed, hiding the error") } if _, err := h.store.Get(res.ID, h.now); err != nil { t.Error("the file was deleted despite a wrong token") } } // Someone whose own token already authorises removal gets a button, not a // field asking for a token they do not have. func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{ "Authorization": "Bearer " + h.token})) for _, c := range []struct { who string token string expectBtn bool }{ {"the owner", h.token, true}, {"an admin", h.admin, true}, {"a stranger", "", false}, } { req, _ := http.NewRequest("GET", h.ts.URL+"/i/"+res.ID, nil) if c.token != "" { req.AddCookie(h.session(t, c.token)) } resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } raw, _ := io.ReadAll(resp.Body) resp.Body.Close() got := strings.Contains(string(raw), "Your token can remove this file") if got != c.expectBtn { t.Errorf("direct delete button shown to %s = %v, want %v", c.who, got, c.expectBtn) } } // And that button actually works with no token field at all. resp := h.postForm(t, "/d/"+res.ID+"/delete", url.Values{"from": {"info"}}, h.session(t, h.token)) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("owner delete => %s", resp.Status) } } // Guessing is throttled, but only the guessing: a correct token is never // delayed by someone else's failed attempts. func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) { h := newHarness(t, nil) h.authLimiter = newLimiter(1, 3) res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) var last *http.Response for range 5 { if last != nil { last.Body.Close() } last = h.postForm(t, "/d/"+res.ID+"/delete", url.Values{"from": {"info"}, "token": {"guess"}}, nil) } if last.StatusCode != http.StatusTooManyRequests { t.Fatalf("repeated guesses => %s, want 429", last.Status) } last.Body.Close() // The real token still works, having consumed nothing from the bucket. resp := h.postForm(t, "/d/"+res.ID+"/delete", url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("the correct token was throttled: %s", resp.Status) } } // --- cross-site posts ---------------------------------------------------- // A login form needs no cookie to submit, so SameSite does not cover it: a // hostile page could otherwise sign a visitor into an account it controls and // collect whatever they upload next. Browsers label their own requests, and // those labels are checked on every state-changing route. func TestCrossOriginPostsAreRejected(t *testing.T) { h := newHarness(t, nil) paths := []string{"/login", "/logout", "/upload", "/d/anything/delete"} hostile := []map[string]string{ {"Origin": "https://evil.example.com"}, {"Sec-Fetch-Site": "cross-site"}, {"Sec-Fetch-Site": "same-site"}, } for _, path := range paths { for _, headers := range hostile { req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader("token=x")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") for k, v := range headers { req.Header.Set(k, v) } resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("POST %s with %v => %s, want 403", path, headers, resp.Status) } } } } // The page's own posts, and API clients that label nothing, must still work. func TestSameOriginAndUnlabelledPostsAreAccepted(t *testing.T) { h := newHarness(t, nil) for _, headers := range []map[string]string{ {}, // curl and friends {"Sec-Fetch-Site": "same-origin"}, // the page itself {"Sec-Fetch-Site": "none"}, // typed into the bar {"Origin": "http://" + strings.TrimPrefix(h.ts.URL, "http://")}, // older browser } { req, _ := http.NewRequest("POST", h.ts.URL+"/login", strings.NewReader(url.Values{"token": {h.token}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") for k, v := range headers { req.Header.Set(k, v) } resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("POST /login with %v => %s, want 200", headers, resp.Status) } } } // The header is the only navigation there is, so it has to tell the truth // about the session on every page. func TestHeaderReflectsTheSession(t *testing.T) { h := newHarness(t, nil) for _, c := range []struct { who string token string present []string absent []string }{ {"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/files"`}}, {"a plain token", h.token, []string{`action="/logout"`, ">friend<", `href="/files"`}, []string{`href="/login"`}}, {"an admin token", h.admin, []string{`action="/logout"`, `href="/files"`, ">boss<"}, []string{`href="/login"`}}, } { for _, path := range []string{"/", "/login"} { req, _ := http.NewRequest("GET", h.ts.URL+path, nil) if c.token != "" { req.AddCookie(h.session(t, c.token)) } resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } raw, _ := io.ReadAll(resp.Body) resp.Body.Close() page := string(raw) for _, want := range c.present { if !strings.Contains(page, want) { t.Errorf("GET %s as %s: missing %q", path, c.who, want) } } for _, unwanted := range c.absent { if strings.Contains(page, unwanted) { t.Errorf("GET %s as %s: unexpectedly offers %q", path, c.who, unwanted) } } } } } // The upload form keeps a one-off token field, so a quick upload under another // token does not require logging in and out. func TestUploadPageKeepsTheOneOffTokenField(t *testing.T) { h := newHarness(t, nil) resp := h.get(t, "/", "") raw, _ := io.ReadAll(resp.Body) resp.Body.Close() page := string(raw) if !strings.Contains(page, `name="token"`) { t.Error("the upload form has no one-off token field") } if !strings.Contains(page, "does not log you in") { t.Error("the form does not explain that the field is one-off") } // The limits are rendered, not fetched, so no script is needed to show them. if !strings.Contains(page, `data-max-size="1048576"`) { t.Error("the form does not carry the server-rendered size limit") } } // The server reads the multipart body as a stream and stops at the file part, // so anything the upload depends on has to be in the markup ahead of it. The // form is laid out to look otherwise, which is exactly why this is pinned: a // tidy-up that moves the drop zone back up in the markup would silently strip // the expiry, the vanity name and the one-off token from every upload. func TestUploadFormSendsTheFileLast(t *testing.T) { h := newHarness(t, nil) resp := h.get(t, "/", "") raw, _ := io.ReadAll(resp.Body) resp.Body.Close() page := string(raw) file := strings.Index(page, `name="file"`) if file < 0 { t.Fatal("the upload form has no file field") } for _, field := range []string{"expiry", "vanity", "token"} { at := strings.Index(page, `name="`+field+`"`) if at < 0 { t.Errorf("the upload form has no %s field", field) continue } if at > file { t.Errorf("the %s field follows the file part, where the server can no longer read it", field) } } } // Anonymous visitors may type a vanity name: the token that permits it can be // supplied in the same form, for this upload only. The rule itself is the // server's to enforce, not the markup's. func TestVanityFieldIsAlwaysUsable(t *testing.T) { h := newHarness(t, nil) resp := h.get(t, "/", "") raw, _ := io.ReadAll(resp.Body) resp.Body.Close() form := string(raw) if i := strings.Index(form, `name="vanity"`); i < 0 { t.Fatal("the upload form has no vanity name field") } else if j := strings.Index(form[i:], ">"); strings.Contains(form[i:i+j], "disabled") { t.Error("the vanity name field is disabled, so a one-off token cannot be used with it") } // Enabled in the page, still refused on the wire without a token. res := h.formUpload(t, map[string]string{"vanity": "anonymous-pick"}, "f.txt", "hello") defer res.Body.Close() if res.StatusCode != http.StatusForbidden { t.Errorf("anonymous vanity upload = %s, want 403", res.Status) } // And accepted when the form carries a token that allows it. res2 := h.formUpload(t, map[string]string{"vanity": "chosen-name", "token": h.token}, "f.txt", "hello") defer res2.Body.Close() if res2.StatusCode != http.StatusCreated { t.Errorf("one-off token vanity upload = %s, want 201", res2.Status) } } // The share page's expiry line is rounded to the minute, so it must not offer // a seconds tail it is not counting - nor, when the remainder rounds away to // nothing, claim the file is kept forever. func TestDescribeExpiryReadsAsWritten(t *testing.T) { at := func(d time.Duration) *time.Time { when := clock.Add(d) return &when } for _, c := range []struct { name string in *time.Time want string }{ {"no deadline", nil, "never"}, {"already gone", at(-time.Hour), "expired"}, {"about to go", at(20 * time.Second), "(in under a minute)"}, {"a minute off", at(time.Minute), "(in 1m)"}, {"an hour and a half", at(90 * time.Minute), "(in 1h30m)"}, {"three days", at(72 * time.Hour), "(in 3d)"}, } { if got := describeExpiry(c.in, clock); !strings.Contains(got, c.want) { t.Errorf("%s: describeExpiry = %q, want it to contain %q", c.name, got, c.want) } } } // A chosen passphrase has to work everywhere a generated token does: at the // login form, on an upload, and as a session. func TestChosenPassphraseWorksEndToEnd(t *testing.T) { h := newHarness(t, nil) const passphrase = "godot-friends-2026" tok, err := auth.NewChosen("memorable", passphrase) if err != nil { t.Fatal(err) } tok.AllowVanity = true if err := h.tokens.Add(tok); err != nil { t.Fatal(err) } resp := h.postForm(t, "/login", url.Values{"token": {passphrase}, "persist": {"1"}}, nil) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("login with a passphrase => %s", resp.Status) } cookie := findCookie(resp, tokenCookie) if cookie == nil { t.Fatal("no session was started") } req, _ := http.NewRequest("POST", h.ts.URL+"/upload", strings.NewReader("x")) req.Header.Set("Accept", "application/json") req.Header.Set("Vanity", "chosen-upload") req.AddCookie(cookie) up, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } if up.StatusCode != http.StatusCreated { t.Fatalf("upload with a passphrase session => %s", up.Status) } if res := decode[uploadResult](t, up); res.ID != "chosen-upload" { t.Errorf("id = %q, want chosen-upload", res.ID) } } // Deriving a passphrase is expensive by design, which makes an unverified // credential an amplifier unless the work is charged for. Junk must not be // able to buy unlimited derivations. func TestUnverifiedCredentialsCannotForceUnlimitedDerivations(t *testing.T) { h := newHarness(t, nil) tok, err := auth.NewChosen("memorable", "a-chosen-passphrase") if err != nil { t.Fatal(err) } if err := h.tokens.Add(tok); err != nil { t.Fatal(err) } h.authLimiter = newLimiter(1, 3) // Distinct junk on every request, so the memo never answers. for i := range 6 { req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: fmt.Sprintf("junk-%d", i)}) resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() // The page still renders; it just renders as anonymous. if resp.StatusCode != http.StatusOK { t.Fatalf("page %d => %s", i, resp.Status) } } if h.tokens.Resolved("junk-5") { t.Error("a derivation ran past the budget") } } // The memo means a live session pays the derivation once, not per request. func TestPassphraseSessionsAreMemoised(t *testing.T) { h := newHarness(t, nil) const passphrase = "a-chosen-passphrase" tok, err := auth.NewChosen("memorable", passphrase) if err != nil { t.Fatal(err) } if err := h.tokens.Add(tok); err != nil { t.Fatal(err) } if h.tokens.Resolved(passphrase) { t.Fatal("resolved before anything verified it") } resp := h.get(t, "/", "") resp.Body.Close() req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req.AddCookie(h.session(t, passphrase)) first, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } first.Body.Close() if !h.tokens.Resolved(passphrase) { t.Error("the session was not memoised, so every request would derive again") } } // Rotating a secret has to end the sessions that were using it, or rotation // would not actually revoke anything. func TestRotationEndsLiveSessions(t *testing.T) { h := newHarness(t, nil) // A logged-in browser, and a page render proving the session works. session := h.session(t, h.token) req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req.AddCookie(session) resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } raw, _ := io.ReadAll(resp.Body) resp.Body.Close() if !strings.Contains(string(raw), ">friend<") { t.Fatal("setup: the session is not logged in") } var replacement string if err := h.tokens.Update("friend", func(tok *auth.Token) error { s, err := tok.SetGenerated() replacement = s return err }); err != nil { t.Fatal(err) } // The old cookie is now just a string. req, _ = http.NewRequest("GET", h.ts.URL+"/", nil) req.AddCookie(session) resp, err = h.ts.Client().Do(req) if err != nil { t.Fatal(err) } raw, _ = io.ReadAll(resp.Body) resp.Body.Close() if strings.Contains(string(raw), ">friend<") { t.Error("a rotated-away secret still authenticates a session") } if !strings.Contains(string(raw), "no longer valid") { t.Error("the page does not explain that the session ended") } // Uploading with the old secret is refused; the new one works. old := h.upload(t, []byte("x"), map[string]string{"Authorization": "Bearer " + h.token}) old.Body.Close() if old.StatusCode != http.StatusUnauthorized { t.Errorf("upload with the old secret => %s, want 401", old.Status) } fresh := h.upload(t, []byte("x"), map[string]string{ "Authorization": "Bearer " + replacement, "Vanity": "after-rotation", }) if fresh.StatusCode != http.StatusCreated { t.Fatalf("upload with the rotated secret => %s", fresh.Status) } if res := decode[uploadResult](t, fresh); res.ID != "after-rotation" { t.Errorf("id = %q: the rotated token lost its vanity permission", res.ID) } } // Sorting has to compare the underlying values, not their rendered form: two // uploads in the same minute render identically but are not equal. func TestFilesSortOrdersByValue(t *testing.T) { h := newHarness(t, nil) // Three files, distinct in every sortable dimension. type spec struct { name string size int expiry string } for i, s := range []spec{ {"big", 300, "3h"}, {"small", 10, "1h"}, {"medium", 100, "2h"}, } { h.now = clock.Add(time.Duration(i) * time.Second) // same minute, distinct instants resp := h.upload(t, bytes.Repeat([]byte("x"), s.size), map[string]string{ "Authorization": "Bearer " + h.token, "Vanity": s.name, "Expiry": s.expiry, "Content-Disposition": `attachment; filename="` + s.name + `.bin"`, }) resp.Body.Close() } h.now = clock order := func(sortBy string) []string { resp := h.get(t, "/files?sort="+sortBy, h.admin) raw, _ := io.ReadAll(resp.Body) resp.Body.Close() var ids []string for _, m := range regexp.MustCompile(`class="id mono">([a-z]+)`).FindAllStringSubmatch(string(raw), -1) { ids = append(ids, m[1]) } return ids } for _, c := range []struct { sortBy string want []string }{ {"size", []string{"big", "medium", "small"}}, // largest first {"expires", []string{"small", "medium", "big"}}, // soonest first {"created", []string{"medium", "small", "big"}}, // newest first {"name", []string{"big", "medium", "small"}}, // by filename } { got := order(c.sortBy) if !slices.Equal(got, c.want) { t.Errorf("sort=%s gave %v, want %v", c.sortBy, got, c.want) } } } // The listing is a table, and a table needs more room than a form. It also has // to stop being a table on a narrow screen rather than grow a scrollbar. func TestFilesPageIsLaidOutForATable(t *testing.T) { h := newHarness(t, nil) h.upload(t, []byte("x"), nil).Body.Close() resp := h.get(t, "/files", h.admin) raw, _ := io.ReadAll(resp.Body) resp.Body.Close() page := string(raw) if !strings.Contains(page, `class="wide"`) { t.Error("the listing renders at the narrow reading measure meant for forms") } // Every cell needs its label for the stacked layout, where the header row // is hidden. for _, label := range []string{"Size", "Owner", "Uploaded", "Expires"} { if !strings.Contains(page, `data-label="`+label+`"`) { t.Errorf("cells carry no %q label, so the stacked layout loses its headings", label) } } css := h.get(t, "/static/style.css", "") cssRaw, _ := io.ReadAll(css.Body) css.Body.Close() style := string(cssRaw) if !strings.Contains(style, "body.wide") { t.Error("no wide layout is defined") } if !strings.Contains(style, "@media (max-width: 46rem)") { t.Error("no narrow-screen rule, so the table will scroll sideways on a phone") } if strings.Contains(style, "overflow-x: auto") && strings.Contains(page, "tablewrap") { t.Error("the listing still relies on a horizontal scroll container") } }