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>
This commit is contained in:
ZDDC 2026-05-07 06:28:07 -05:00
parent 8cf1aa1002
commit a0f9fca95d
4 changed files with 512 additions and 339 deletions

View file

@ -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 — /<project>/.archive/... — even
// though offline-built HTML files reference siblings via
// "../.archive/<tracking>.html" from arbitrary depths. Any deeper form
// (/<project>/<sub>/.../.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], "/")
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:], "/")
}
handler.ServeArchive(cfg, idx, w, r, contextPath, filename)
// Canonicalize anything below /<project>/.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

View file

@ -285,6 +285,138 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
}
}
// 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 {

View file

@ -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/<tracking>.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 /<project>/.archive/...
// before reaching here (any deeper /<project>/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. /<project>/.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/<tracking>.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/<tracking>.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 (/<project>/<sub>/.archive/X → /<project>/.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
})
}
// 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")
if err := json.NewEncoder(w).Encode(result); err != nil {
slog.Error("encoding archive listing", "err", err)
_, _ = 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)
}

View file

@ -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 /<project>/<sub>/
// .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, "")
rec := callArchive(t, cfg, idx, "alice@example.com", "", "")
if rec.Code != http.StatusNotFound {
t.Errorf("listing at root: status %d, want 404; body = %s", rec.Code, rec.Body.String())
t.Errorf("listing with empty project: status %d, want 404", rec.Code)
}
rec = callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
rec = callArchive(t, cfg, idx, "alice@example.com", "", "100.html")
if rec.Code != http.StatusNotFound {
t.Errorf("resolve at root: status %d, want 404", rec.Code)
}
})
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 (/<project>/.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:
@ -176,28 +198,21 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
cases := []struct {
name string
contextPath 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")
// alice can resolve.
rec := callArchive(t, cfg, idx, "alice@example.com", "ProjectA", "100.html")
if rec.Code != http.StatusOK {
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("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())
t.Errorf("mallory resolve: status %d, want 404 (per-target deny); body=%s", 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")
if rec.Code != http.StatusOK {
t.Errorf("bob → /ProjectB/.archive/200.html: status %d, want 200", rec.Code)
}
}
// 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")
rec := callArchive(t, cfg, idx, "bob@example.com", "ProjectA", "100.html")
if rec.Code != http.StatusOK {
t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String())
continue
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("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)
}
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)
}
}
}