ZDDC/zddc/internal/handler/archivehandler_test.go
ZDDC f56eb7d0f9 fix(zddc-server): per-revision .archive entries + global index with ACL filter
The .archive virtual directory now emits both <tracking>.html (highest
base rev) and <tracking>_<rev>.html (each specific base rev) so HTML
documents can deep-link to a known revision and have it resolve to the
first chronologically received copy. Modifier files (<rev>+C1 etc.) stay
reachable via the resolver but aren't surfaced in the listing.

.archive at any folder depth serves the same global index — the depth
exists so offline HTML can use ../.archive/<tracking>.html and let the
browser resolve it before the request reaches the server. The earlier
attempt at scoping listings to the contextPath subtree was wrong; gating
is purely by ACL: contextPath gates the listing endpoint, and each
entry's resolved file gets its own per-target ACL check (404 on denial,
not 403, so cross-subtree existence isn't disclosed).

Adds the first tests for the previously untested archive package, plus
end-to-end ACL coverage for the handler (cascade direction, default-deny
once any .zddc exists, anonymous denied under allow:[\"*@…\"], stable
Location across contextPaths).

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

375 lines
13 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// archiveTestRoot lays down a two-project tree so listings exercise scope and
// ACL cascading. ACLs are written per-test in the helper that calls this.
//
// <root>/
// ProjectA/
// 2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf
// 2025-01-01_T1 (IFR) - Title/100_A (IFC) - Title.pdf
// 2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf
// ProjectB/
// 2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf
func archiveTestRoot(t *testing.T) (string, *archive.Index) {
t.Helper()
root := t.TempDir()
mk := func(rel string) {
path := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
mk("ProjectA/2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf")
mk("ProjectA/2025-01-01_T1 (IFR) - Title/100_A (IFC) - Title.pdf")
mk("ProjectA/2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf")
mk("ProjectB/2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
return root, idx
}
// writeZddc writes a .zddc YAML at <root>/<rel>/.zddc and clears the
// per-directory policy cache so a previous test's permissive .zddc doesn't
// bleed into this one.
func writeZddc(t *testing.T, root, rel, body string) {
t.Helper()
dir := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(dir)
}
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 {
t.Helper()
urlPath := contextPath
if !strings.HasSuffix(urlPath, "/") {
urlPath += "/"
}
urlPath += ".archive/" + 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)
return rec
}
func decodeListing(t *testing.T, body []byte) []listing.FileInfo {
t.Helper()
var out []listing.FileInfo
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, body)
}
return out
}
func names(entries []listing.FileInfo) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name)
}
return out
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
// .archive at any depth serves the SAME global index (modulo ACL). Only the
// URL prefix on the entries differs, so relative ../.archive/ links resolve
// to a working server endpoint no matter which folder the source page sits
// in.
func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
`)
cfg := archiveCfg(root)
const email = "alice@example.com"
cases := []struct {
name string
contextPath string
urlPrefix string
}{
{"root", "/", "/.archive/"},
{"project depth", "/ProjectA", "/ProjectA/.archive/"},
{"unrelated project depth", "/ProjectB", "/ProjectB/.archive/"},
}
wantNames := []string{"100.html", "100_A.html", "100_~A.html", "200.html", "200_0.html"}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := callArchive(t, cfg, idx, email, c.contextPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
got := decodeListing(t, rec.Body.Bytes())
gotNames := names(got)
for _, want := range wantNames {
if !contains(gotNames, want) {
t.Errorf("missing %q at %s; got %v", want, c.contextPath, gotNames)
}
}
for _, e := range got {
if !strings.HasPrefix(e.URL, c.urlPrefix) {
t.Errorf("entry %q URL = %q, want %s prefix", e.Name, e.URL, c.urlPrefix)
}
}
})
}
}
// 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) {
root, idx := archiveTestRoot(t)
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", "")
if rec.Code != http.StatusForbidden {
t.Errorf("denied caller got status %d, want 403; body = %s", rec.Code, rec.Body.String())
}
rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
if rec.Code != http.StatusOK {
t.Errorf("allowed caller got 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 sees no entries from it — even when querying /.archive/ at the
// root where they ARE allowed. 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:["*"]).
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
`)
writeZddc(t, root, "ProjectB", `acl:
deny: ["alice@example.com"]
`)
cfg := archiveCfg(root)
rec := callArchive(t, cfg, idx, "alice@example.com", "/", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
gotNames := names(decodeListing(t, rec.Body.Bytes()))
for _, want := range []string{"100.html", "100_A.html", "100_~A.html"} {
if !contains(gotNames, want) {
t.Errorf("alice missing accessible entry %q; got %v", want, gotNames)
}
}
for _, hidden := range []string{"200.html", "200_0.html"} {
if contains(gotNames, hidden) {
t.Errorf("alice should not see ACL-blocked entry %q; got %v", hidden, gotNames)
}
}
rec = callArchive(t, cfg, idx, "bob@example.com", "/", "")
gotNames = names(decodeListing(t, rec.Body.Bytes()))
if !contains(gotNames, "200.html") {
t.Errorf("bob should see ProjectB entry 200.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.
func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
`)
writeZddc(t, root, "ProjectB", `acl:
deny: ["alice@example.com"]
`)
cfg := archiveCfg(root)
rec := callArchive(t, cfg, idx, "alice@example.com", "/", "200.html")
if rec.Code != http.StatusNotFound {
t.Errorf("alice → 200.html: status %d, want 404 (target ACL-denied)", rec.Code)
}
for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} {
rec := callArchive(t, cfg, idx, "alice@example.com", "/", fn)
if rec.Code != http.StatusFound {
t.Errorf("alice → %s: status %d, want 302; body = %s", fn, rec.Code, rec.Body.String())
}
}
rec = callArchive(t, cfg, idx, "bob@example.com", "/", "200.html")
if rec.Code != http.StatusFound {
t.Errorf("bob → 200.html: status %d, want 302", 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 of cascade must be exercised so
// future refactors of the per-target ACL helper can't silently break one.
func TestServeArchive_CascadeDirectionsBothEnforced(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 the leaf, mallory stays out
// everywhere, bob stays in everywhere.
writeZddc(t, root, ".", `acl:
allow: ["bob@example.com"]
`)
writeZddc(t, root, "ProjectA", `acl:
allow: ["alice@example.com"]
`)
cfg := archiveCfg(root)
cases := []struct {
email string
filename string
wantStatus int
why string
}{
{"bob@example.com", "100.html", http.StatusFound, "bob allowed at root → reaches ProjectA target"},
{"bob@example.com", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"},
{"alice@example.com", "100.html", http.StatusFound, "alice rescued by ProjectA allow"},
{"alice@example.com", "200.html", http.StatusNotFound, "alice not in ProjectB chain → 404"},
// mallory is denied EVERYWHERE — including the /ProjectA contextPath
// — so she never reaches per-target evaluation; the contextPath
// gate returns 403. (404 leak-prevention only kicks in once the
// contextPath itself is reachable.)
{"mallory@example.com", "100.html", http.StatusForbidden, "mallory blocked at contextPath"},
}
for _, c := range cases {
t.Run(c.email+"_"+c.filename, func(t *testing.T) {
// Use ProjectA as contextPath: alice is rescued there (so she
// passes the gate and we get to per-target ACL on the ProjectB
// resolve), and bob+mallory's behavior is governed by the root
// rules.
rec := callArchive(t, cfg, idx, c.email, "/ProjectA", c.filename)
if rec.Code != c.wantStatus {
t.Errorf("%s → %s: status %d, want %d (%s)", c.email, c.filename, rec.Code, c.wantStatus, c.why)
}
})
}
}
// Resolved redirect Location header must be the absolute path to the actual
// file under cfg.Root, regardless of which contextPath the caller used to
// reach .archive. So /ProjectA/.archive/100.html and /.archive/100.html
// both 302 to the same file.
func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*"]
`)
cfg := archiveCfg(root)
wantLocPrefix := "/ProjectA/2025-01-01_T1 (IFR) - Title/100_A"
for _, ctx := range []string{"/", "/ProjectA", "/ProjectB"} {
rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
if rec.Code != http.StatusFound {
t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String())
continue
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, wantLocPrefix) {
t.Errorf("ctx=%s Location=%q, want prefix %q", ctx, loc, wantLocPrefix)
}
}
}
// 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.
rec := callArchive(t, cfg, idx, "alice@example.com", "/", "")
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 at root → 403 even for the listing.
rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "")
if rec.Code != http.StatusForbidden {
t.Errorf("charlie listing: status %d, want 403", rec.Code)
}
// Direct resolve also denied (404 to avoid leak).
rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "100.html")
// contextPath ACL fires first: at root, charlie is denied → 403.
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.
func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl:
allow: ["*@example.com"]
`)
cfg := archiveCfg(root)
rec := callArchive(t, cfg, idx, "", "/", "")
if rec.Code != http.StatusForbidden {
t.Errorf("anonymous listing: status %d, want 403", rec.Code)
}
}