URLs are now case-insensitive against the on-disk casing under ZDDC_ROOT, with a lowercase-wins tiebreak when sibling case variants exist. File and folder names preserve case on disk — the change is a pure URL→FS-name mapping; nothing renames anything. internal/fs/resolve.go ResolveCanonical walks segments left-to-right under fsRoot. Per segment: try lowercase first (canonical / cheap lstat fast-path), then exact-case, then readdir+CI scan with the all-lowercase variant winning the tiebreak. Walk stops at the first segment that doesn't exist on disk so virtual prefixes (.archive, .profile, .tokens, .auth) and 404 paths flow through with their tail preserved verbatim. Path-escape safety check on the resolved abs path matches the existing safeJoin pattern. Wired in at the top of cmd/zddc-server/main.go dispatch(), which rewrites r.URL.Path before any handler runs. Downstream handlers (plus their existing safeJoin calls and the cascade walker) pick up canonical case automatically — no per-handler changes. The ACL cascade benefits from this for free since EffectivePolicy is keyed by the now-canonical absolute path. internal/handler/middleware.go AccessLogMiddleware snapshots the as-typed URL path before the rewrite. The audit log's `path` field records what the client actually sent; a `resolved_path` field is added only when canonicalization changed it. Operators reading the log can see both the raw request and what was served. Lowercase as the project-wide canonical convention is already honoured by the auto-created folders in internal/zddc/ensure.go (working/, staging/, archive/<party>/incoming/) and the server's own state dirs (_app/, .zddc.d/tokens/, .zddc.d/outbox/, .zddc.d/logs/). Operators who drop a Mixed-Case-Folder/ on disk keep that casing — the resolver finds it via the readdir tier. Performance: the lowercase-first lstat is one syscall on the hot path. Only mismatches (mixed-case URL where on-disk is also mixed-case) pay the readdir+EqualFold scan, and Linux page-caches small-dir readdirs aggressively. Apache mod_speling uses the same "try then fallback" pattern. Tests: - internal/fs/resolve_test.go — 9 unit tests: exact-case, mixed-case-URL-with-lowercase-on-disk, mixed-case-URL-with- mixed-case-on-disk, both-cases-exist-lowercase-wins, nonexistent segment preserves remainder, file-segment terminates walk, escape rejection, trailing-slash normalization, root. - cmd/zddc-server/main_test.go TestDispatchCaseInsensitiveURL — end-to-end through the dispatcher with sibling Archive/ and archive/ on disk; all four URL casings of the same path serve the lowercase variant's content (proves the tiebreak fires through every layer). - Full Go suite green. Docs: AGENTS.md gains a "URL handling" subsection in the zddc-server section; ARCHITECTURE.md security-model table gains a "URL canonicalization" row. Out of scope (separate decisions, can revisit if needed): - ACL glob CI-matching. If .zddc rules use mixed-case URL globs, they won't match the canonical lowercase URL. Workable today by writing rules in lowercase. Touches a different package. - Redirect-to-canonical (303). Server serves under whichever case the client used; canonicalization is internal. Could 301 to canonical for SEO/bookmark hygiene as a follow-up. - Client-mode (proxy/cache). Only master mode is wired so far. Cache-handler CI lives in internal/cache/cache.go cachePathFor and is a separate code path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
739 lines
26 KiB
Go
739 lines
26 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 301'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.StatusMovedPermanently,
|
|
"/ProjectA/.archive/100.html",
|
|
},
|
|
{
|
|
"deep three segments",
|
|
"/ProjectA/sub/sub2/.archive/100.html",
|
|
"",
|
|
http.StatusMovedPermanently,
|
|
"/ProjectA/.archive/100.html",
|
|
},
|
|
{
|
|
"deep with trailing slash (listing)",
|
|
"/ProjectA/Working/.archive/",
|
|
"",
|
|
http.StatusMovedPermanently,
|
|
"/ProjectA/.archive/",
|
|
},
|
|
{
|
|
"deep with query string preserved",
|
|
"/ProjectA/Working/.archive/100.html",
|
|
"v=42",
|
|
http.StatusMovedPermanently,
|
|
"/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 legacy 301-to-trailing-slash redirect.
|
|
//
|
|
// 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 .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 → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""},
|
|
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
|
|
{"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false, ""},
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|