From a0f9fca95d2119059452ade905557e09b8a172e1 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 7 May 2026 06:28:07 -0500 Subject: [PATCH] feat(archive): canonicalize deep .archive URLs + permissions follow the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .archive virtual prefix is now project-scoped at exactly one URL depth: any ///.../.archive/... gets a 301 to the canonical //.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/.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 //.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) --- zddc/cmd/zddc-server/main.go | 57 ++- zddc/cmd/zddc-server/main_test.go | 132 ++++++ zddc/internal/handler/archivehandler.go | 197 +++++--- zddc/internal/handler/archivehandler_test.go | 465 +++++++++---------- 4 files changed, 512 insertions(+), 339 deletions(-) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 8232b2f..3af56a7 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -476,18 +476,57 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } - // Check for .archive segment in the path + // Check for .archive segment in the path. .archive is project-scoped + // and addressed at exactly one depth — //.archive/... — even + // though offline-built HTML files reference siblings via + // "../.archive/.html" from arbitrary depths. Any deeper form + // (///.../.archive/...) gets a 301 to the project-rooted + // canonical so anchored links and bookmarks normalize to a single + // stable URL per tracking number. The redirect target preserves the + // path tail after .archive/ verbatim and the query string; browsers + // preserve the fragment automatically across redirects. + // + // .archive is read-only: only GET/HEAD reach the handler. Anything + // else (PUT/POST/DELETE) returns 405 here, before the file API would + // otherwise see the request. This avoids the 302→GET silent-method- + // downgrade trap and makes the contract explicit. for i, seg := range segments { - if seg == cfg.IndexPath { - // contextPath is everything before .archive - contextPath := "/" + strings.Join(segments[:i], "/") - var filename string - if i+1 < len(segments) { - filename = strings.Join(segments[i+1:], "/") - } - handler.ServeArchive(cfg, idx, w, r, contextPath, filename) + if seg != cfg.IndexPath { + continue + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } + // segments[0] is the project; segments[i] is .archive. i==0 + // means /.archive/... at the very root, with no project to + // scope by — 404 (a tracking-number reference must be project- + // rooted; cross-project tracking-number collisions otherwise + // silently pick a winner). + if i == 0 { + http.NotFound(w, r) + return + } + project := segments[0] + var filename string + if i+1 < len(segments) { + filename = strings.Join(segments[i+1:], "/") + } + // Canonicalize anything below //.archive/. Building + // the target by hand (rather than re-encoding) keeps any + // already-encoded characters in the original URL.RawPath + // trailing segments intact for the browser to follow. + if i > 1 { + target := "/" + project + "/" + cfg.IndexPath + "/" + filename + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusMovedPermanently) + return + } + handler.ServeArchive(cfg, idx, w, r, project, filename) + return } // Tables-system intercept: *.table.html is a virtual URL that the diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index a03138a..1998032 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -285,6 +285,138 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } } +// TestDispatchArchiveRedirect: any ///.../.archive/... is 301'd +// to the canonical //.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 { diff --git a/zddc/internal/handler/archivehandler.go b/zddc/internal/handler/archivehandler.go index 2b77f6e..1bd5576 100644 --- a/zddc/internal/handler/archivehandler.go +++ b/zddc/internal/handler/archivehandler.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "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/listing" @@ -14,50 +15,45 @@ import ( "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) -// ServeArchive handles requests under a .archive virtual path segment. +// ServeArchive handles requests under a project's .archive virtual path. // -// .archive is exposed at every folder depth so HTML produced for offline use -// can reference sibling tracking numbers via "../.archive/.html". -// In a browser the relative link is resolved before the request reaches the -// server, so the contextPath the request arrives under is significant: its -// FIRST segment is the project, and the .archive listing/resolver is scoped -// to that project's bucket. This avoids cross-project collisions when the -// same tracking number is issued under multiple projects. +// The dispatcher canonicalizes every .archive request to //.archive/... +// before reaching here (any deeper //sub/.../archive/... gets a 301 +// to the project-rooted form), so this handler only ever sees one shape: +// project = first URL segment, filename = whatever follows .archive/. // -// contextPath: the URL path leading up to (but not including) .archive -// - first segment selects the project bucket -// - used to gate the listing endpoint via cascading .zddc ACL -// - used as the URL prefix for the entries returned in the listing -// - empty (root /.archive/) returns 404 — refs must be project-rooted +// Permissions follow the FILE, not .archive itself. .archive is a virtual +// surface — it has no on-disk directory and no .zddc of its own. Two gates +// only: // -// filename: the part after .archive/ (empty for directory listing) -func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) { - email := EmailFromContext(r) - decider := DeciderFromContext(r) - ctx := r.Context() - - project := projectFromContextPath(contextPath) +// 1. Listing: returned entries are filtered by the per-target file's ACL +// chain. If the project bucket is empty (or doesn't exist in the index) +// the response is 404; if the user can read NO entries in a non-empty +// bucket the response is 403, so existence of an inaccessible project's +// archive does not leak. +// +// 2. Resolve: only the per-target file's ACL gates access. A user with +// no project-root permission but an explicit allow on one transmittal +// folder can fetch that file's tracking-number URL; conversely, a user +// with broad project access but a narrower deny on a specific subtree +// gets 404 (not 403) on its tracking numbers — existence must not leak. +// +// Listings serve the embedded `browse` SPA on Accept: text/html and the +// JSON entry array on Accept: application/json — same content negotiation +// as ServeDirectory, so the SPA's auto-detect path-fetch works at .archive +// URLs identically to real directories. +func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, filename string) { if project == "" { http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. //.archive/)", http.StatusNotFound) return } - // ACL gate on the context directory: callers who can't reach the - // directory hosting this .archive shouldn't be able to query it either. - dirPath := strings.TrimPrefix(contextPath, "/") - dirPath = strings.TrimSuffix(dirPath, "/") - absDir := filepath.Join(cfg.Root, filepath.FromSlash(dirPath)) - chain, err := zddc.EffectivePolicy(cfg.Root, absDir) - if err != nil { - slog.Warn("ACL policy error", "path", absDir, "err", err) - } - if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, contextPath); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } + email := EmailFromContext(r) + decider := DeciderFromContext(r) + ctx := r.Context() if filename == "" { - serveArchiveListing(cfg, idx, w, r, contextPath, project, email) + serveArchiveListing(cfg, idx, w, r, project, email, decider) return } @@ -67,11 +63,11 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, return } - // Per-target ACL: the resolved file may live in a subtree the caller - // can't reach even though they could reach the contextPath. 404 (not - // 403) so the tracking number's mere existence isn't disclosed. + // Per-target ACL is the only gate. 404 (not 403) so the tracking + // number's mere existence isn't disclosed to a caller who can't + // actually read the resolved file. fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target))) - chain, err = zddc.EffectivePolicy(cfg.Root, fileDir) + chain, err := zddc.EffectivePolicy(cfg.Root, fileDir) if err != nil { slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err) } @@ -80,50 +76,40 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, return } - // Serve the resolved file in place — DO NOT redirect. The .archive/ - // URL is meant to be a stable forward-able link (people share - // `.archive/.html#section` and expect that to keep tracking - // the latest revision). A redirect would expose the specific - // transmittal-folder URL, and any anchor/hash bookmarked from the - // browser bar would pin to that snapshot instead of "the latest." + // Serve in place — DO NOT redirect to the resolved file's real path. + // People share .archive/.html#section URLs and expect the + // link to keep tracking the latest revision; redirecting would pin + // the bookmark to a specific transmittal-folder snapshot. The + // canonicalization redirect (///.archive/X → //.archive/X) + // happens upstream in the dispatcher and is a different thing — it + // only collapses the .archive prefix, not the resolved bytes. // - // Cache-Control no-cache forces a conditional revalidation each - // load — http.ServeFile sets Last-Modified/ETag from the on-disk - // file, so when the resolver picks a newer target the ETag changes - // and the browser refetches. + // Cache-Control: no-cache forces conditional revalidation each load — + // http.ServeFile sets Last-Modified/ETag from the on-disk file, so + // when the resolver picks a newer target the ETag changes and the + // browser refetches. absFile := filepath.Join(cfg.Root, filepath.FromSlash(target)) w.Header().Set("Cache-Control", "no-cache") http.ServeFile(w, r, absFile) } -// projectFromContextPath returns the first non-empty segment of the -// contextPath, which is the project bucket key for archive lookups. Returns -// "" for "/" or "" (root .archive — has no project). -func projectFromContextPath(contextPath string) string { - cleaned := strings.Trim(contextPath, "/") - if cleaned == "" { - return "" - } - if i := strings.IndexByte(cleaned, '/'); i >= 0 { - return cleaned[:i] - } - return cleaned -} - -func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, project, email string) { - decider := DeciderFromContext(r) +func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, email string, decider policy.Decider) { ctx := r.Context() allEntries := idx.AllEntries(project) - archiveBase := contextPath - if !strings.HasSuffix(archiveBase, "/") { - archiveBase += "/" + if len(allEntries) == 0 { + // Project bucket missing or empty. 404 with no body distinction + // from "unknown project" — a caller probing for project names + // gets the same shape whether or not the project exists. + http.Error(w, "Not Found", http.StatusNotFound) + return } - archiveBase += cfg.IndexPath + "/" + + archiveBase := "/" + project + "/" + cfg.IndexPath + "/" // ACL chains are folder-keyed and the listing typically hits the same - // few directories repeatedly (one per transmittal folder), so cache the - // allow/deny decision per directory rather than re-walking .zddc files - // for every entry. + // few directories repeatedly (one per transmittal folder), so cache + // the allow/deny decision per directory rather than re-walking .zddc + // files for every entry. aclCache := make(map[string]bool) allowed := func(targetPath string) bool { fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath))) @@ -140,7 +126,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW return v } - var result []listing.FileInfo + result := make([]listing.FileInfo, 0, len(allEntries)) for _, e := range allEntries { if !allowed(e.TargetPath) { continue @@ -152,9 +138,68 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW }) } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-cache") - if err := json.NewEncoder(w).Encode(result); err != nil { - slog.Error("encoding archive listing", "err", err) + // Existence-leak guard: if the user can read no entries in a + // non-empty bucket, 403 — never confirm the project's archive + // exists to a caller with no permissions in it. + if len(result) == 0 { + http.Error(w, "Forbidden", http.StatusForbidden) + return } + + // Vary: Accept is critical because the same URL serves either the + // JSON listing or the embedded browse SPA depending on Accept; + // without it, browsers/CDNs may serve one Accept's body for the + // other Accept value and break the SPA's JSON auto-fetch. + w.Header().Set("Vary", "Accept") + + if strings.Contains(r.Header.Get("Accept"), "application/json") { + body, err := json.Marshal(result) + if err != nil { + slog.Error("encoding archive listing", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + etag := `"` + listingETag(body) + `"` + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate") + if match := r.Header.Get("If-None-Match"); match != "" && match == etag { + w.WriteHeader(http.StatusNotModified) + return + } + _, _ = w.Write(body) + return + } + + // HTML: serve the embedded `browse` SPA. The SPA auto-detects the + // server-mode listing by re-fetching this same URL with + // Accept: application/json — that path lands in the JSON branch + // above and renders the archive entries as a sortable, filterable + // flat list. + body := apps.EmbeddedBytes("browse") + if len(body) == 0 { + // Bootstrap state: a fresh build hasn't populated browse.html + // into the embed yet. Fall through to JSON for clients that + // will still parse it. + jsonBody, err := json.Marshal(result) + if err != nil { + slog.Error("encoding archive listing (no-embed fallback)", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(jsonBody) + return + } + etag := `"` + apps.EmbeddedETag("browse") + `"` + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-ZDDC-Source", "embedded:browse") + if match := r.Header.Get("If-None-Match"); match != "" && match == etag { + w.WriteHeader(http.StatusNotModified) + return + } + _, _ = w.Write(body) } diff --git a/zddc/internal/handler/archivehandler_test.go b/zddc/internal/handler/archivehandler_test.go index 5762924..b846f14 100644 --- a/zddc/internal/handler/archivehandler_test.go +++ b/zddc/internal/handler/archivehandler_test.go @@ -11,6 +11,7 @@ import ( "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/listing" @@ -76,38 +77,46 @@ func archiveCfg(root string) config.Config { return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", IndexPath: ".archive"} } -func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, contextPath, filename string) *httptest.ResponseRecorder { +// callArchive drives ServeArchive directly with (project, filename). The +// dispatcher is responsible for canonicalizing deeper /// +// .archive/... paths to this shape (see TestDispatchArchiveRedirect in +// the cmd package). Tests that want a specific Accept header set it on +// the recorder request before calling. +func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, project, filename string) *httptest.ResponseRecorder { t.Helper() - // Build a syntactically valid URL by escaping each segment of the - // contextPath and filename. The handler receives the decoded - // contextPath/filename arguments directly (as the dispatcher would have - // decoded them); the URL itself just needs to parse for httptest. - urlPath := encodePath(contextPath) + "/" + cfg.IndexPath + urlPath := "/" + if project != "" { + urlPath = "/" + url.PathEscape(project) + "/" + cfg.IndexPath + "/" + } if filename != "" { - urlPath += "/" + url.PathEscape(filename) - } else { - urlPath += "/" + urlPath += url.PathEscape(filename) } req := httptest.NewRequest(http.MethodGet, urlPath, nil) req = req.WithContext(context.WithValue(req.Context(), EmailKey, email)) rec := httptest.NewRecorder() - ServeArchive(cfg, idx, rec, req, contextPath, filename) + ServeArchive(cfg, idx, rec, req, project, filename) return rec } -// encodePath URL-escapes each non-empty slash-separated segment of p so -// special characters like spaces and parens don't break NewRequest's URL -// parser. A leading slash is preserved; an empty input becomes "/". -func encodePath(p string) string { - trimmed := strings.Trim(p, "/") - if trimmed == "" { - return "" +// callArchiveAccept is callArchive plus a custom Accept header — used to +// drive the listing's content-negotiation branches. +func callArchiveAccept(t *testing.T, cfg config.Config, idx *archive.Index, email, project, filename, accept string) *httptest.ResponseRecorder { + t.Helper() + urlPath := "/" + if project != "" { + urlPath = "/" + url.PathEscape(project) + "/" + cfg.IndexPath + "/" } - parts := strings.Split(trimmed, "/") - for i, s := range parts { - parts[i] = url.PathEscape(s) + if filename != "" { + urlPath += url.PathEscape(filename) } - return "/" + strings.Join(parts, "/") + req := httptest.NewRequest(http.MethodGet, urlPath, nil) + if accept != "" { + req.Header.Set("Accept", accept) + } + req = req.WithContext(context.WithValue(req.Context(), EmailKey, email)) + rec := httptest.NewRecorder() + ServeArchive(cfg, idx, rec, req, project, filename) + return rec } func decodeListing(t *testing.T, body []byte) []listing.FileInfo { @@ -136,36 +145,49 @@ func contains(xs []string, x string) bool { return false } -// /.archive/ at the very root has no project segment to scope by, so it's a -// hard 404 — even for an admin. Stable references must include the project -// directory; otherwise cross-project tracking-number collisions would silently -// pick a winner. -func TestServeArchive_RootHasNoProjectScope404(t *testing.T) { +// Empty project (no first segment) is rejected at the handler. The +// dispatcher already 404s /.archive/ before reaching here, but the handler +// keeps a defense-in-depth guard so a future direct caller can't bypass. +func TestServeArchive_EmptyProject404(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: allow: ["*"] `) cfg := archiveCfg(root) - for _, ctx := range []string{"/", ""} { - t.Run("ctx="+ctx, func(t *testing.T) { - rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "") - if rec.Code != http.StatusNotFound { - t.Errorf("listing at root: status %d, want 404; body = %s", rec.Code, rec.Body.String()) - } - rec = callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html") - if rec.Code != http.StatusNotFound { - t.Errorf("resolve at root: status %d, want 404", rec.Code) - } - }) + rec := callArchive(t, cfg, idx, "alice@example.com", "", "") + if rec.Code != http.StatusNotFound { + t.Errorf("listing with empty project: status %d, want 404", rec.Code) + } + rec = callArchive(t, cfg, idx, "alice@example.com", "", "100.html") + if rec.Code != http.StatusNotFound { + t.Errorf("resolve with empty project: status %d, want 404", rec.Code) } } -// .archive listings are scoped to the contextPath's first segment (the -// project). Each project sees only its own tracking numbers; cross-project -// entries are invisible. Subdirectory contextPaths still resolve to the -// top-level project's bucket — a request from /ProjectA/sub/sub/.archive/ -// shows ProjectA's entries with that deeper URL prefix. +// Unknown / empty project bucket returns 404 (not 403) — a probe for +// project names gets the same shape whether or not the project exists. +func TestServeArchive_UnknownProject404(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["*"] +`) + cfg := archiveCfg(root) + + rec := callArchive(t, cfg, idx, "alice@example.com", "NoSuchProject", "") + if rec.Code != http.StatusNotFound { + t.Errorf("listing for unknown project: status %d, want 404; body=%s", rec.Code, rec.Body.String()) + } + rec = callArchive(t, cfg, idx, "alice@example.com", "NoSuchProject", "100.html") + if rec.Code != http.StatusNotFound { + t.Errorf("resolve in unknown project: status %d, want 404", rec.Code) + } +} + +// Listing scoping: each project's bucket surfaces only its own entries, +// and entry URLs are always project-rooted (//.archive/...) — +// independent of any deeper request path the caller might have started +// from (the dispatcher canonicalizes those before reaching the handler). func TestServeArchive_ListingScopedToProject(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: @@ -175,29 +197,22 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) { const email = "alice@example.com" cases := []struct { - name string - contextPath string - urlPrefix string - wantNames []string - denyNames []string + name string + project string + urlPrefix string + wantNames []string + denyNames []string }{ { - "ProjectA top level", - "/ProjectA", + "ProjectA", + "ProjectA", "/ProjectA/.archive/", []string{"100.html", "100_A.html", "100_~A.html"}, []string{"200.html", "200_0.html"}, }, { - "ProjectA deeper subpath", - "/ProjectA/2025-01-01_T1 (IFR) - Title", - "/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/", - []string{"100.html", "100_A.html", "100_~A.html"}, - []string{"200.html", "200_0.html"}, - }, - { - "ProjectB top level", - "/ProjectB", + "ProjectB", + "ProjectB", "/ProjectB/.archive/", []string{"200.html", "200_0.html"}, []string{"100.html", "100_A.html", "100_~A.html"}, @@ -206,7 +221,7 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - rec := callArchive(t, cfg, idx, email, c.contextPath, "") + rec := callArchiveAccept(t, cfg, idx, email, c.project, "", "application/json") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) } @@ -214,12 +229,12 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) { gotNames := names(got) for _, want := range c.wantNames { if !contains(gotNames, want) { - t.Errorf("missing %q at %s; got %v", want, c.contextPath, gotNames) + t.Errorf("missing %q in %s; got %v", want, c.project, gotNames) } } for _, deny := range c.denyNames { if contains(gotNames, deny) { - t.Errorf("unexpected cross-project entry %q at %s; got %v", deny, c.contextPath, gotNames) + t.Errorf("unexpected cross-project entry %q in %s; got %v", deny, c.project, gotNames) } } for _, e := range got { @@ -231,36 +246,34 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) { } } -// Listing endpoint is gated by the contextPath ACL: callers who can't reach -// the directory the .archive virtually sits in get 403 (the directory is -// known to exist; just not accessible). -func TestServeArchive_ListingDeniedByContextPathACL(t *testing.T) { +// Listing existence-leak guard: a user who can read no entries in a +// non-empty project bucket gets 403, NOT 200 with an empty list. The +// project must not confirm its existence to a caller with no permissions. +func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) { root, idx := archiveTestRoot(t) + // Default-deny: only alice listed at any level. mallory is in no + // allow list anywhere → every per-target check returns deny → the + // filtered listing is empty → 403. writeZddc(t, root, ".", `acl: allow: ["alice@example.com"] -`) - writeZddc(t, root, "ProjectA", `acl: - deny: ["mallory@example.com"] - allow: ["alice@example.com"] `) cfg := archiveCfg(root) - rec := callArchive(t, cfg, idx, "mallory@example.com", "/ProjectA", "") + rec := callArchiveAccept(t, cfg, idx, "mallory@example.com", "ProjectA", "", "application/json") if rec.Code != http.StatusForbidden { - t.Errorf("denied caller got status %d, want 403; body = %s", rec.Code, rec.Body.String()) + t.Errorf("mallory listing: status %d, want 403; body=%s", rec.Code, rec.Body.String()) } - rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "") + rec = callArchiveAccept(t, cfg, idx, "alice@example.com", "ProjectA", "", "application/json") if rec.Code != http.StatusOK { - t.Errorf("allowed caller got status %d, want 200; body = %s", rec.Code, rec.Body.String()) + t.Errorf("alice listing: status %d, want 200; body=%s", rec.Code, rec.Body.String()) } } -// Listing entries are filtered per-target by ACL: a caller denied at a -// subtree's transmittal directory sees no entries whose target lives there. -// Excluding a user from a subdir requires an explicit deny there (the -// cascade is "first explicit match wins, bottom-up", so a child allow list -// doesn't narrow a parent's allow:["*"]). +// Listing entries are filtered per-target by ACL: a caller denied at one +// transmittal subtree but allowed at others sees the unblocked entries +// (200 with the subset), not 403, because they have SOME read access +// in the project. func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: @@ -268,14 +281,13 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { `) // Deny alice on the transmittal folder where 100_~A+C1 lives, so her // listing of /ProjectA/.archive/ drops that entry — but other ProjectA - // entries stay visible. (A blanket /ProjectA deny would 403 the - // listing entirely; that's covered by the previous test.) + // entries stay visible. writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl: deny: ["alice@example.com"] `) cfg := archiveCfg(root) - rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "") + rec := callArchiveAccept(t, cfg, idx, "alice@example.com", "ProjectA", "", "application/json") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) } @@ -286,138 +298,66 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { t.Errorf("alice missing accessible entry %q; got %v", want, gotNames) } } - - // Bob has no per-target denials in either project. - rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "") - if rec.Code != http.StatusOK { - t.Fatalf("bob ProjectB listing: status %d, want 200", rec.Code) - } - gotNames = names(decodeListing(t, rec.Body.Bytes())) - if !contains(gotNames, "200.html") { - t.Errorf("bob should see ProjectB entry 200.html; got %v", gotNames) + // 100_~A+C1.html maps to a denied target — must not appear. + if contains(gotNames, "100_~A+C1.html") { + t.Errorf("alice unexpectedly saw denied entry 100_~A+C1.html; got %v", gotNames) } } -// Direct redirect requests for a tracking number whose target the caller -// can't read return 404 (not 403, not 302) — the file's existence must not -// leak across the ACL boundary. Cross-project tracking-number requests also -// 404 because each project's bucket is separate. -func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) { +// Resolve: only the per-target ACL gates access. A caller denied on the +// resolved file's directory gets 404 (not 403) — never confirm the +// tracking number's existence. +func TestServeArchive_ResolvePerTargetACLOnly(t *testing.T) { root, idx := archiveTestRoot(t) + // Both alice and mallory are root-allowed, but a deny on the + // transmittal folder kicks mallory out at the per-target chain + // ("first explicit match wins, bottom-up"). writeZddc(t, root, ".", `acl: - allow: ["*"] + allow: ["alice@example.com", "mallory@example.com"] `) - writeZddc(t, root, "ProjectB", `acl: - deny: ["alice@example.com"] + writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl: + deny: ["mallory@example.com"] `) cfg := archiveCfg(root) - // 200 doesn't even live in ProjectA, so the resolver itself returns 404 - // regardless of ACL — project scoping comes first. - rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "200.html") - if rec.Code != http.StatusNotFound { - t.Errorf("alice → /ProjectA/.archive/200.html: status %d, want 404 (cross-project)", rec.Code) - } - - // Alice in /ProjectA can resolve all of ProjectA's entries. - for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} { - rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", fn) - if rec.Code != http.StatusOK { - t.Errorf("alice → /ProjectA/.archive/%s: status %d, want 200; body = %s", fn, rec.Code, rec.Body.String()) - } - } - - // Alice attempting ProjectB directly is denied at the contextPath ACL. - rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectB", "200.html") - if rec.Code != http.StatusForbidden { - t.Errorf("alice → /ProjectB/.archive/200.html: status %d, want 403 (denied at contextPath)", rec.Code) - } - - // Bob has no denies — he can pull 200.html from /ProjectB. - rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "200.html") + // alice can resolve. + rec := callArchive(t, cfg, idx, "alice@example.com", "ProjectA", "100.html") if rec.Code != http.StatusOK { - t.Errorf("bob → /ProjectB/.archive/200.html: status %d, want 200", rec.Code) + t.Errorf("alice resolve: status %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + // mallory is denied at the file's directory → 404 (existence-leak guard). + rec = callArchive(t, cfg, idx, "mallory@example.com", "ProjectA", "100.html") + if rec.Code != http.StatusNotFound { + t.Errorf("mallory resolve: status %d, want 404 (per-target deny); body=%s", rec.Code, rec.Body.String()) } } -// Cascade direction sanity check: a denial at the subtree wins over an -// allow at the parent, AND a target-level allow can rescue a user the -// parent didn't mention. Both directions must be exercised so future -// refactors of the per-target ACL helper can't silently break one. -func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) { +// Resolve is decoupled from project-root ACL: a user explicitly allowed +// at one transmittal folder but denied at the project root (and not in +// any other allow list) can still fetch tracking numbers that resolve +// to that folder. .archive/ is a virtual surface — the file's own ACL +// chain decides. +func TestServeArchive_ResolveBypassesProjectRootDenyWhenPerTargetAllows(t *testing.T) { root, idx := archiveTestRoot(t) - // Root: deny default — only bob is on the list. ProjectA: explicitly - // allow alice. So alice is rescued at ProjectA, mallory stays out - // everywhere, bob stays in everywhere. Per-target ACL on resolved files - // doesn't kick in here — both projects allow bob via the root rule. + // Project root denies bob, but the transmittal folder under it + // allows him. The cascade is "first explicit match wins, bottom-up" + // — so the per-target chain at the file's directory hits the local + // allow first. writeZddc(t, root, ".", `acl: - allow: ["bob@example.com"] -`) - writeZddc(t, root, "ProjectA", `acl: allow: ["alice@example.com"] `) - cfg := archiveCfg(root) - - cases := []struct { - email string - contextPath string - filename string - wantStatus int - why string - }{ - {"bob@example.com", "/ProjectA", "100.html", http.StatusOK, "bob allowed at root → reaches ProjectA target"}, - {"bob@example.com", "/ProjectB", "200.html", http.StatusOK, "bob allowed at root → reaches ProjectB target"}, - {"alice@example.com", "/ProjectA", "100.html", http.StatusOK, "alice rescued by ProjectA allow"}, - {"alice@example.com", "/ProjectB", "200.html", http.StatusForbidden, "alice not in ProjectB chain → 403 at contextPath"}, - // mallory denied everywhere; the contextPath gate fires first. - {"mallory@example.com", "/ProjectA", "100.html", http.StatusForbidden, "mallory blocked at contextPath"}, - } - for _, c := range cases { - t.Run(c.email+"_"+c.contextPath+"_"+c.filename, func(t *testing.T) { - rec := callArchive(t, cfg, idx, c.email, c.contextPath, c.filename) - if rec.Code != c.wantStatus { - t.Errorf("%s @ %s → %s: status %d, want %d (%s)", c.email, c.contextPath, c.filename, rec.Code, c.wantStatus, c.why) - } - }) - } -} - -// .archive serves the resolved file in place — the URL never changes. -// From any depth within the same project the resolver picks the same -// target file, so the bytes returned to the caller must be identical -// across context paths (the per-revision file URL is intentionally -// hidden so external links remain stable). -func TestServeArchive_ServedBytesStableAcrossDepthWithinProject(t *testing.T) { - root, idx := archiveTestRoot(t) - writeZddc(t, root, ".", `acl: - allow: ["*"] + writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl: + allow: ["bob@example.com"] `) cfg := archiveCfg(root) - wantBodyPrefix := "ProjectA/2025-01-01_T1 (IFR) - Title/100_A" - var firstBody string - for i, ctx := range []string{ - "/ProjectA", - "/ProjectA/2025-01-01_T1 (IFR) - Title", - "/ProjectA/2025-02-01_T2 (RTN) - Comments", - } { - rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html") - if rec.Code != http.StatusOK { - t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String()) - continue - } - if loc := rec.Header().Get("Location"); loc != "" { - t.Errorf("ctx=%s unexpected Location=%q (.archive must serve in place)", ctx, loc) - } - body := rec.Body.String() - if !strings.HasPrefix(body, wantBodyPrefix) { - t.Errorf("ctx=%s body=%q, want prefix %q", ctx, body, wantBodyPrefix) - } - if i == 0 { - firstBody = body - } else if body != firstBody { - t.Errorf("ctx=%s body differs from first contextPath (resolver should pick the same target regardless of depth)", ctx) - } + rec := callArchive(t, cfg, idx, "bob@example.com", "ProjectA", "100.html") + if rec.Code != http.StatusOK { + t.Errorf("bob resolve: status %d, want 200 (per-target allow rescues him); body=%s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "" { + t.Errorf("unexpected Location=%q (.archive must serve in place)", loc) } } @@ -447,7 +387,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) { cfg := archiveCfg(root) const email = "alice@example.com" - recA := callArchive(t, cfg, idx, email, "/ProjectA", "123.html") + recA := callArchive(t, cfg, idx, email, "ProjectA", "123.html") if recA.Code != http.StatusOK { t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String()) } @@ -456,7 +396,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) { t.Errorf("ProjectA body=%q, want a ProjectA/ file's content", bodyA) } - recB := callArchive(t, cfg, idx, email, "/ProjectB", "123.html") + recB := callArchive(t, cfg, idx, email, "ProjectB", "123.html") if recB.Code != http.StatusOK { t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String()) } @@ -479,59 +419,26 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) { } // Listing each project shows only its own. - for _, c := range []struct{ ctx, mustHave, mustNot string }{ - {"/ProjectA", "ProjectA", "ProjectB"}, - {"/ProjectB", "ProjectB", "ProjectA"}, + for _, c := range []struct{ project, mustHave string }{ + {"ProjectA", "ProjectA"}, + {"ProjectB", "ProjectB"}, } { - rec := callArchive(t, cfg, idx, email, c.ctx, "") + rec := callArchiveAccept(t, cfg, idx, email, c.project, "", "application/json") if rec.Code != http.StatusOK { - t.Fatalf("listing %s: status %d", c.ctx, rec.Code) + t.Fatalf("listing %s: status %d", c.project, rec.Code) } got := decodeListing(t, rec.Body.Bytes()) for _, e := range got { if !strings.Contains(e.URL, "/"+c.mustHave+"/") { - t.Errorf("ctx=%s entry URL %q lacks /%s/ segment", c.ctx, e.URL, c.mustHave) + t.Errorf("project=%s entry URL %q lacks /%s/ segment", c.project, e.URL, c.mustHave) } } } } -// Default-deny: as soon as ANY .zddc exists in the chain, an unmatched -// caller is denied. Verify this applies to listing entries too — a target -// in a directory with a restrictive .zddc is not surfaced to outsiders even -// though the file exists. -func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) { - root, idx := archiveTestRoot(t) - // Root .zddc allows alice only. No "*" — so anyone else is default-denied. - writeZddc(t, root, ".", `acl: - allow: ["alice@example.com"] -`) - cfg := archiveCfg(root) - - // alice sees everything she's allowed to in ProjectA. - rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "") - if rec.Code != http.StatusOK { - t.Fatalf("alice listing: status %d, want 200", rec.Code) - } - if len(decodeListing(t, rec.Body.Bytes())) == 0 { - t.Errorf("alice listing was empty, want entries") - } - - // Charlie isn't on any list → default-deny → 403 even for the listing. - rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "") - if rec.Code != http.StatusForbidden { - t.Errorf("charlie listing: status %d, want 403", rec.Code) - } - - // Direct resolve: contextPath ACL fires first → 403. - rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "100.html") - if rec.Code != http.StatusForbidden { - t.Errorf("charlie resolve: status %d, want 403 (denied at contextPath)", rec.Code) - } -} - // Empty email never matches — even an `allow: ["*"]` policy denies it, -// which is the existing zddc package contract. .archive must honor it. +// per the existing zddc package contract. .archive must honor it: the +// listing 403s (empty filtered set) and resolves return 404. func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: @@ -539,30 +446,80 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) { `) cfg := archiveCfg(root) - rec := callArchive(t, cfg, idx, "", "/ProjectA", "") + rec := callArchiveAccept(t, cfg, idx, "", "ProjectA", "", "application/json") if rec.Code != http.StatusForbidden { t.Errorf("anonymous listing: status %d, want 403", rec.Code) } + + rec = callArchive(t, cfg, idx, "", "ProjectA", "100.html") + if rec.Code != http.StatusNotFound { + t.Errorf("anonymous resolve: status %d, want 404", rec.Code) + } } -// projectFromContextPath is the canonical place to derive the project key -// from the .archive contextPath. Pin the edge cases. -func TestProjectFromContextPath(t *testing.T) { - cases := []struct { - ctx string - want string - }{ - {"/ProjectA", "ProjectA"}, - {"/ProjectA/", "ProjectA"}, - {"/ProjectA/sub/sub", "ProjectA"}, - {"/", ""}, - {"", ""}, - {"ProjectA/sub", "ProjectA"}, +// Listing content negotiation: Accept: application/json returns the +// JSON entry array; Accept: text/html returns the embedded `browse` SPA +// bytes (tested by content-type and the embedded ETag header). +// The same URL must serve both, with Vary: Accept set. +func TestServeArchive_ListingContentNegotiation(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["*"] +`) + cfg := archiveCfg(root) + const email = "alice@example.com" + + // JSON branch. + recJSON := callArchiveAccept(t, cfg, idx, email, "ProjectA", "", "application/json") + if recJSON.Code != http.StatusOK { + t.Fatalf("JSON listing: status %d, want 200; body=%s", recJSON.Code, recJSON.Body.String()) } - for _, c := range cases { - got := projectFromContextPath(c.ctx) - if got != c.want { - t.Errorf("projectFromContextPath(%q) = %q, want %q", c.ctx, got, c.want) + if ct := recJSON.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("JSON listing content-type=%q, want application/json", ct) + } + if vary := recJSON.Header().Get("Vary"); !strings.Contains(vary, "Accept") { + t.Errorf("JSON listing missing Vary: Accept (got %q)", vary) + } + _ = decodeListing(t, recJSON.Body.Bytes()) + + // HTML branch — falls back to JSON only if the embedded slot is + // empty, which won't be the case in a normal test run (the embed is + // populated at compile time). Verify either branch is sane. + recHTML := callArchiveAccept(t, cfg, idx, email, "ProjectA", "", "text/html") + if recHTML.Code != http.StatusOK { + t.Fatalf("HTML listing: status %d, want 200; body=%s", recHTML.Code, recHTML.Body.String()) + } + ct := recHTML.Header().Get("Content-Type") + switch { + case strings.Contains(ct, "text/html"): + // Normal path: embedded browse bytes were served. + if etag := recHTML.Header().Get("ETag"); etag == "" || etag != `"`+apps.EmbeddedETag("browse")+`"` { + t.Errorf("HTML listing ETag=%q, want %q", etag, `"`+apps.EmbeddedETag("browse")+`"`) + } + if src := recHTML.Header().Get("X-ZDDC-Source"); src != "embedded:browse" { + t.Errorf("HTML listing X-ZDDC-Source=%q, want embedded:browse", src) + } + case strings.Contains(ct, "application/json"): + // Bootstrap path: embedded slot empty (e.g. fresh build before + // browse.html has been populated). JSON fallback is acceptable + // — confirm it parses as a listing. + _ = decodeListing(t, recHTML.Body.Bytes()) + default: + t.Errorf("HTML listing unexpected content-type=%q", ct) + } + + // Conditional GET: re-fetching with If-None-Match for the JSON ETag + // short-circuits to 304. + etagJSON := recJSON.Header().Get("ETag") + if etagJSON != "" { + req := httptest.NewRequest(http.MethodGet, "/ProjectA/.archive/", nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("If-None-Match", etagJSON) + req = req.WithContext(context.WithValue(req.Context(), EmailKey, email)) + rec304 := httptest.NewRecorder() + ServeArchive(cfg, idx, rec304, req, "ProjectA", "") + if rec304.Code != http.StatusNotModified { + t.Errorf("conditional JSON GET: status %d, want 304", rec304.Code) } } }