ZDDC/zddc/cmd/zddc-server/main_test.go
ZDDC e2c4700d32 refactor(zddc-server): demote routing-shape redirects from 301 to 302
301 Moved Permanently is cached by browsers effectively forever — when
we changed /<project> no-slash from "redirect to slash form" to
"serve project landing" earlier today, anyone who had visited the URL
under the prior behavior got stuck on the cached 301 indefinitely. No
server-side fix is possible after the fact; only a manual cache clear
in each user's browser releases the binding.

Demote every routing-shape redirect to 302 Found, which browsers do
not cache by default. Five sites:

  - handler/directory.go: no-trailing-slash → slash on directory URLs
  - main.go (4 sites):
      .archive/ canonicalization (deep /<project>/<sub>/.../.archive/
        path collapses to /<project>/.archive/)
      reviewing/<tracking> no-slash → slash
      reviewing/ default-app fallback to slash form
      generic IsDir + no-slash + no-default-tool fallback

301 → 302 trades "permanent semantics in the protocol" for "we can
change our mind later without trapping users on old behavior." For
these routes — all of which are convention-driven shapes the server
owns — the latter is what we want.

Test updates: five httptest assertions switch from
http.StatusMovedPermanently → http.StatusFound, plus five comment
strings ("301" → "302").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:37:02 -05:00

855 lines
30 KiB
Go

package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
// rejects requests whose URL contains a dot-prefixed segment (other than
// the recognized virtual prefixes .archive and /.profile handled separately).
//
// The guard exists so the in-image dev-shell can keep persistent state
// (settings, source clones, Go module cache) under /srv/.devshell on the
// same Azure Files PVC as served data without ever exposing those files
// via direct HTTP fetch.
func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
root := t.TempDir()
// Realistic shape: a project dir, a hidden top-level dir, and a hidden
// sibling of a normal file inside the project.
mustMkdir(t, filepath.Join(root, "Project-A"))
mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok")
mustMkdir(t, filepath.Join(root, ".devshell"))
mustMkdir(t, filepath.Join(root, ".devshell", "coder"))
mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "secret")
mustMkdir(t, filepath.Join(root, "Project-A", ".internal"))
mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
path string
wantStatus int
}{
// Hidden top-level dir — every shape blocked.
{"hidden top dir", "/.devshell/", http.StatusNotFound},
{"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound},
// Hidden segment under a real project dir — also blocked.
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
// Sanity: recognized virtual prefixes are NOT blocked. .archive falls
// through to its own handler (which 404s on missing tracking number);
// .profile is handled by ServeProfile and the page itself is public.
// /.admin no longer exists — it is hard-cut and falls through to the
// dot-prefix guard, which 404s.
{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler
{".profile not blocked by guard", "/.profile/", http.StatusOK}, // public page renders for anonymous
{".admin hard-cut → dot-prefix guard", "/.admin/whoami", http.StatusNotFound},
// Normal files unaffected.
{"plain file", "/Project-A/doc.txt", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("path=%q status=%d want=%d body=%q",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
// TestDispatchAppsResolution drives the full apps fetch+cache flow through
// dispatch() with a fake upstream. Confirms that:
// - GET / serves the landing app from the apps subsystem
// - GET /archive.html serves the archive app via fetch+cache
// - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
// - direct URL access to /_zddc/... is rejected
func TestDispatchAppsResolution(t *testing.T) {
root := t.TempDir()
body := []byte("<!doctype html>archive content")
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
sig := ed25519.Sign(priv, body)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Same body for every artifact; same signature for every .sig
// (since the body is identical across the five tools in this
// fixture). Real deployments publish a distinct .sig per
// artifact; the test only cares that the verify gate passes.
if strings.HasSuffix(r.URL.Path, ".sig") {
_, _ = w.Write(sig)
return
}
w.Header().Set("ETag", `"v1"`)
_, _ = w.Write(body)
}))
defer upstream.Close()
upstreamURL, _ := url.Parse(upstream.URL)
upstreamHost := upstreamURL.Host
if i := strings.Index(upstreamHost, ":"); i >= 0 {
upstreamHost = upstreamHost[:i]
}
_ = upstreamHost // referenced below
// Seed root .zddc with subdir-cascade Apps entries pointing at the
// fake upstream. Allow all email patterns (anonymous) so the test
// doesn't have to set up email headers.
zf := zddc.ZddcFile{
ACL: zddc.ACLRules{Allow: []string{"*"}},
Apps: map[string]string{
"archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html",
"classifier": upstream.URL + "/classifier_stable.html",
"mdedit": upstream.URL + "/mdedit_stable.html",
"landing": upstream.URL + "/landing_stable.html",
},
}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Create folder convention dirs so classifier/mdedit/transmittal
// availability rules pass for the test paths used below.
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// Override the production embedded public key with the test fixture's
// pubkey so signature verification of upstream.Sign'd bodies succeeds.
appsSrv.Fetcher.VerifyKey = pub
// GET /archive.html → fetched from upstream (archive is available everywhere)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(body) {
t.Errorf("first /archive.html: body mismatch")
}
// GET /archive.html again → cache hit (no new upstream fetch)
rec2 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
if rec2.Code != http.StatusOK {
t.Errorf("second /archive.html: status=%d", rec2.Code)
}
// GET / → landing
rec3 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
if rec3.Code != http.StatusOK {
t.Errorf("GET /: status=%d", rec3.Code)
}
// Direct URL access to /_app/ → 404
rec4 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
if rec4.Code != http.StatusNotFound {
t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code)
}
// Folder availability rules: classifier should NOT be served at root
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
// /Project-A/Working/.
rec5 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
if rec5.Code != http.StatusNotFound {
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
}
rec6 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
if rec6.Code != http.StatusOK {
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
}
}
// silence "imported and not used" if apps not referenced elsewhere — keep
// import even when we trim test cases later.
var _ = apps.DefaultUpstream
// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
// to the file API rather than to the read pipeline.
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*@example.com\"\n deny: []\n")
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1 << 20,
}
ring := handler.NewLogRing(10)
withEmail := func(req *http.Request, email string) *http.Request {
// dispatch reads email from context (ACLMiddleware would normally
// set it), so set it directly here.
return req.WithContext(handler.WithEmail(req.Context(), email))
}
// PUT a new file via dispatch.
body := []byte("note body")
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
}
// GET it back.
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
}
// MOVE it.
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com")
req.Header.Set("X-ZDDC-Op", "move")
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
}
// DELETE it.
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
}
// Reserved segment guard still applies to writes.
req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code)
}
}
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 302'd
// to the canonical /<project>/.archive/... so all tracking-number references
// converge on a single stable URL per (project, tracking) regardless of the
// folder a relative "../.archive/..." link was resolved from.
func TestDispatchArchiveRedirect(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n")
mustMkdir(t, filepath.Join(root, "ProjectA", "Working"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
path string
query string
wantStatus int
wantLoc string
}{
{
"deep two segments",
"/ProjectA/Working/.archive/100.html",
"",
http.StatusFound,
"/ProjectA/.archive/100.html",
},
{
"deep three segments",
"/ProjectA/sub/sub2/.archive/100.html",
"",
http.StatusFound,
"/ProjectA/.archive/100.html",
},
{
"deep with trailing slash (listing)",
"/ProjectA/Working/.archive/",
"",
http.StatusFound,
"/ProjectA/.archive/",
},
{
"deep with query string preserved",
"/ProjectA/Working/.archive/100.html",
"v=42",
http.StatusFound,
"/ProjectA/.archive/100.html?v=42",
},
{
"already canonical (no redirect)",
"/ProjectA/.archive/100.html",
"",
// 100.html doesn't resolve in this index (no transmittal
// folders), so the handler 404s rather than redirecting.
http.StatusNotFound,
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rawURL := tc.path
if tc.query != "" {
rawURL += "?" + tc.query
}
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantLoc != "" {
if got := rec.Header().Get("Location"); got != tc.wantLoc {
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
}
}
})
}
}
func TestDispatchSlashRouting(t *testing.T) {
// Convention: <dir>/ → browse (directory view); <dir> → the canonical
// default tool for the directory (mdedit under working/, transmittal
// under staging/, archive under archive/, tables under
// archive/<party>/mdl/). Without a default app, no-slash falls
// through to the trailing-slash redirect (302).
//
// Exception: a directory that is the rows-dir of a registered table
// (declared via parent .zddc tables:) — including the default-MDL
// fallback at archive/<party>/mdl/ — redirects the trailing-slash
// form too, bouncing to <parent>/<name>.table.html. Bare folder
// listings here would just be a row-of-yaml-files preview that the
// table view subsumes.
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcda\n")
for _, sub := range []string{
"Project/working",
"Project/staging",
"Project/archive",
"Project/archive/Acme",
"Project/archive/Acme/incoming",
"Project/archive/Acme/mdl",
"Project/scratch",
} {
mustMkdir(t, filepath.Join(root, sub))
}
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
cases := []struct {
name string
path string
wantStatus int
wantNoRedirect bool
wantLoc string // checked when wantStatus is a redirect
}{
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true, ""},
{"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
{"archive no-slash → archive", "/Project/archive", http.StatusOK, true, ""},
{"archive slash → browse", "/Project/archive/", http.StatusOK, true, ""},
{"archive/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true, ""},
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true, ""},
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true, ""},
// Trailing-slash form on a tables rows-dir bounces to the canonical
// .table.html URL so users land on the table view rather than a
// browse listing of the row-yaml files.
{"archive/<party>/mdl slash → 302 in-dir table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl/table.html"},
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
{"non-canonical no-slash → 302 to slash", "/Project/scratch", http.StatusFound, false, ""},
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
// Project root no-slash → synthetic landing page (handler.ServeProjectLanding).
{"project root no-slash → landing", "/Project", http.StatusOK, true, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
t.Errorf("path=%q unexpected redirect to %q",
tc.path, rec.Header().Get("Location"))
}
if tc.wantLoc != "" {
if got := rec.Header().Get("Location"); got != tc.wantLoc {
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
}
}
})
}
}
// Canonical project-root folders (archive/working/staging/reviewing)
// that don't yet exist on disk must render as 200 + empty listing
// rather than 404, so the stage-strip nav lands on a usable view on a
// fresh project. Mirror of fs.ListDirectory's read-side fallback at
// the dispatcher level — without this, os.Stat 404s before
// ServeDirectory ever runs.
func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcda\n")
// Project exists, but NO subdirs (no archive/, working/, staging/,
// reviewing/). Fresh-project state.
mustMkdir(t, filepath.Join(root, "Project"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// JSON request → 200 + JSON array (not null, not 404).
// Virtual user-home injection at <project>/working/ depends on a
// context-bound email; this test calls dispatch directly without
// the ACL middleware that sets that context, so email is "" here
// and working/ also returns []. virtualUserHomeEntry's email
// branch is covered separately by tests in internal/fs/tree_test.go.
for _, stage := range []string{"archive", "working", "staging", "reviewing"} {
t.Run("json/"+stage, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := strings.TrimSpace(rec.Body.String())
if body != "[]" {
t.Errorf("%s/ body=%q, want %q", stage, body, "[]")
}
})
}
// No-trailing-slash form on a canonical folder → default app
// (mdedit for working/, transmittal for staging/, archive for
// archive/). Mirror of the existing "no-slash → default app"
// behavior at the IsDir branch, extended to cover the case where
// the folder doesn't exist on disk yet.
noSlashDefaultApp := []struct {
stage string
expect string // substring that should appear in the response body
}{
{"working", "ZDDC Markdown"},
{"staging", "ZDDC Transmittal"},
{"archive", "ZDDC Archive"},
// reviewing/ also routes to mdedit; the polyfill follows the
// virtual aggregator's listing into canonical archive/+staging
// paths from there.
{"reviewing", "ZDDC Markdown"},
}
for _, tc := range noSlashDefaultApp {
t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+tc.stage, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), tc.expect) {
t.Errorf("%s/ body missing %q", tc.stage, tc.expect)
}
})
}
// Trailing-slash form on a canonical folder serves the browse
// app for HTML requests — same convention as the existing IsDir
// branch. The slash-vs-no-slash distinction is the user's signal:
// "show me the directory contents" vs "open the default tool".
for _, stage := range []string{"working", "staging", "archive", "reviewing"} {
t.Run("slash/"+stage+" → browse", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
req.Header.Set("Accept", "text/html")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "ZDDC Browse") {
t.Errorf("%s/ HTML response missing 'ZDDC Browse'", stage)
}
})
}
// Non-canonical missing folder still 404s (the fallback is
// scoped to the four canonical names, not a blanket "missing →
// empty" rule).
t.Run("non-canonical missing → 404", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/random-folder/", nil)
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
})
}
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
// API's write path, so a write to an archive URL never silently mutates
// anything (and so a 302 redirect can never silently downgrade a write
// to a GET on the canonical URL).
func TestDispatchArchiveMethodGate(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n")
mustMkdir(t, filepath.Join(root, "ProjectA"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1 << 20,
}
ring := handler.NewLogRing(10)
for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
for _, path := range []string{
"/ProjectA/.archive/100.html",
"/ProjectA/Working/.archive/100.html",
} {
t.Run(method+" "+path, func(t *testing.T) {
req := httptest.NewRequest(method, path, strings.NewReader("body"))
req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String())
}
if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "GET") {
t.Errorf("%s %s: Allow=%q, want to contain GET", method, path, allow)
}
})
}
}
}
// TestDispatchCaseInsensitiveURL: mixed-case URLs resolve to the on-disk
// canonical case, with the lowercase variant winning when both case
// variants exist as siblings on disk.
func TestDispatchCaseInsensitiveURL(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n")
mustMkdir(t, filepath.Join(root, "project-a", "working"))
mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note")
// Sibling Mixed-Case dir present too. Lowercase must win on the
// case-insensitive resolution; the Mixed-Case dir's contents must
// not bleed through under any URL casing.
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
mustWrite(t, filepath.Join(root, "Project-A", "Working", "note.md"), "MIXEDCASE note")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
url string
}{
{"all lowercase", "/project-a/working/note.md"},
{"mixed case top", "/Project-A/working/note.md"},
{"mixed case nested", "/PROJECT-A/Working/Note.md"},
{"all uppercase", "/PROJECT-A/WORKING/NOTE.MD"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "lowercase note" {
t.Errorf("body=%q want %q (lowercase variant must win)",
got, "lowercase note")
}
})
}
}
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
}
func mustWrite(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper
// behavior we wire in main(): responses above MinSize get gzip-encoded
// when the client advertises Accept-Encoding: gzip; small responses
// pass through uncompressed; HEAD requests still set Vary correctly.
//
// We construct the wrapper the same way main() does (1024 byte minsize)
// and exercise it against a tiny test handler — full end-to-end is
// covered by the live curl smoke test in CI / dev verification.
func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
// Re-create the wrapper config from main.go so this test stays in
// sync with the real wiring.
wrapper, err := newGzipWrapper()
if err != nil {
t.Fatalf("newGzipWrapper: %v", err)
}
largeBody := strings.Repeat("ZDDC ", 4000) // ~20 KB, well over MinSize
smallBody := "ok"
handler := wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.URL.Path == "/large" {
_, _ = w.Write([]byte(largeBody))
} else {
_, _ = w.Write([]byte(smallBody))
}
}))
srv := httptest.NewServer(handler)
defer srv.Close()
t.Run("large body with Accept-Encoding gzip → compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
req.Header.Set("Accept-Encoding", "gzip")
// Disable transparent decompression so we can read the raw bytes
// and confirm the wire format.
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Errorf("Content-Encoding = %q, want gzip", got)
}
if got := resp.Header.Get("Vary"); !strings.Contains(strings.ToLower(got), "accept-encoding") {
t.Errorf("Vary = %q, want to contain Accept-Encoding", got)
}
})
t.Run("small body → not compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/small", nil)
req.Header.Set("Accept-Encoding", "gzip")
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got == "gzip" {
t.Errorf("Content-Encoding = gzip; small response should not be compressed")
}
})
t.Run("no Accept-Encoding → not compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q; client without Accept-Encoding should get plain", got)
}
})
}
// TestDispatchZddcEditorAtPath verifies the per-directory <dir>/.zddc.html
// virtual URL is recognised by the dispatcher and routed to the editor
// handler (carved out from the dot-prefix guard). Permission gate is
// hasAnyAdminScope; non-admins get 404.
func TestDispatchZddcEditorAtPath(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"admins:\n - root@example.com\n")
mustMkdir(t, filepath.Join(root, "Project", "working"))
mustWrite(t, filepath.Join(root, "Project", ".zddc"),
"title: Demo Project\n")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
path string
email string
wantStatus int
wantSubstr string
}{
{
"root admin opens project editor",
"/Project/.zddc.html", "root@example.com",
http.StatusOK, "Demo Project",
},
{
"root admin opens working/ editor (no .zddc on disk yet)",
"/Project/working/.zddc.html", "root@example.com",
http.StatusOK, ".zddc editor",
},
{
"root admin opens deployment-root editor",
"/.zddc.html", "root@example.com",
http.StatusOK, ".zddc editor",
},
{
"non-admin gets 404",
"/Project/.zddc.html", "stranger@example.com",
http.StatusNotFound, "",
},
{
"anonymous gets 404",
"/Project/.zddc.html", "",
http.StatusNotFound, "",
},
{
"missing directory gets 404",
"/Project/no-such-dir/.zddc.html", "root@example.com",
http.StatusNotFound, "",
},
{
"deeper than leaf rejected",
"/Project/.zddc.html/extra", "root@example.com",
http.StatusNotFound, "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) {
t.Errorf("path=%q body missing %q", tc.path, tc.wantSubstr)
}
})
}
}