ZDDC/zddc/cmd/zddc-server/main_test.go
ZDDC a0f9fca95d feat(archive): canonicalize deep .archive URLs + permissions follow the file
The .archive virtual prefix is now project-scoped at exactly one URL
depth: any /<project>/<sub>/.../.archive/... gets a 301 to the
canonical /<project>/.archive/.... The dispatcher does this before
calling the handler; query strings are preserved (the browser handles
the fragment automatically). .archive is also GET/HEAD-only — anything
else returns 405 with Allow: GET, HEAD, ahead of the file API.

Why: offline-built HTML files reference siblings as
"../.archive/<tracking>.html" from arbitrary depths. All of those refs
should converge on a single stable URL per (project, tracking) so
external links and bookmarks don't fork by entry point.

Permissions now follow the resolved file, not .archive itself.
.archive is a virtual surface — it has no on-disk directory and no
.zddc of its own, so gating it as if it did is wrong. Two gates only:

  - Resolve: only the per-target file's ACL chain decides. A user
    explicitly allowed at one transmittal folder but denied at the
    project root can still fetch tracking numbers that resolve there.
    Per-target denial returns 404 (not 403) so existence doesn't leak.

  - Listing: filter entries by per-target ACL. If the project bucket
    has zero indexed entries → 404 (unknown / empty project, indistinguishable
    from a probe). If the bucket is non-empty but the caller can read
    no entries → 403 (existence-leak guard: don't confirm an inaccessible
    project's archive exists). Otherwise → 200 with the filtered subset.

The listing endpoint is now content-negotiated like ServeDirectory:
Accept: text/html serves the embedded `browse` SPA bytes (with the
embedded ETag and X-ZDDC-Source: embedded:browse); Accept:
application/json returns the JSON entry array (with content-hash ETag
and 304 short-circuit). Vary: Accept set on both. The browse SPA's
auto-detect path-fetch then renders the archive entries as a sortable,
filterable flat list at /<project>/.archive/.

ServeArchive's signature is now (cfg, idx, w, r, project, filename) —
the dispatcher hands the normalized project string in directly, so
projectFromContextPath is gone. Old behavior was to derive project
from contextPath inside the handler; with the upstream redirect that's
redundant and the handler's preconditions are simpler.

Tests: archivehandler_test.go rewritten around the new semantics;
added per-target-only resolve, project-root-deny + per-target-allow
rescue, listing 403/404 distinction, JSON/HTML content-negotiation,
and conditional GET. main_test.go gains TestDispatchArchiveRedirect
(deep paths, query preservation, already-canonical no-op) and
TestDispatchArchiveMethodGate (PUT/POST/DELETE → 405).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 06:28:07 -05:00

510 lines
18 KiB
Go

package main
import (
"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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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)
}
}
})
}
}
// 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, 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)
}
})
}
}
}
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)
}
})
}