Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:
* fix(archive): nested-party + folder-type cascade
transmittalIsUnderVisibleParty short-circuited on the first matched
party segment, only checking the immediately-next segment for a
folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
any-segment party match. Eight new Playwright cases pin the contract
in tests/archive-cascade.spec.js.
* refactor(zddc-server): scope .archive index by project
archive.Index now buckets by top-level segment
(.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
take a project parameter; handler extracts it from contextPath's first
segment. /.archive/ at root returns 404 — stable refs must be
project-rooted. Within-project (tracking, rev) collisions emit a WARN
with both paths. Cross-project tracking-number duplicates no longer
collide.
* perf(zddc-server): lazy-load expensive bits of the profile page
serveProfilePage now ships a minimal shell: Email, EmailHeader,
IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
editable scaffolds populate client-side via /.profile/access. Subtree-
admin scaffolds live in <template id="tmpl-subtree-admin">; pure
non-admins receive no live admin form. ScanZddcFiles now memoized,
invalidated on .zddc events by the watcher and writer helpers.
* feat: lockstep release + redesigned releases page
sh build.sh --release [version|alpha|beta] is the canonical lockstep
cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
version. zddc-server binaries now committed under website/releases/
with the same cascade chain as HTML tools (no more Codeberg release-
asset publication). zddc/release.sh deprecated (kept as a guard);
shared/publish-codeberg-release.sh removed.
Releases page redesigned as an action-first install guide: hero +
version dropdown that rewires every download link, channel chips for
always-visible alpha/beta access (state-aware labels: "tracks stable"
vs "active dev"), Path A (zddc-server with platform auto-detect from
UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.
Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
resolves at the end of every build. Bootstrap-friendly: zddc-server
artifact checks skip until the first lockstep cut anchors the chain.
Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
547 lines
19 KiB
Go
547 lines
19 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"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 project
|
|
// scoping, ACL cascading, and the per-project bucket boundary. 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()
|
|
// 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
|
|
if filename != "" {
|
|
urlPath += "/" + url.PathEscape(filename)
|
|
} else {
|
|
urlPath += "/"
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
parts := strings.Split(trimmed, "/")
|
|
for i, s := range parts {
|
|
parts[i] = url.PathEscape(s)
|
|
}
|
|
return "/" + strings.Join(parts, "/")
|
|
}
|
|
|
|
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 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) {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// .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.
|
|
func TestServeArchive_ListingScopedToProject(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
|
|
wantNames []string
|
|
denyNames []string
|
|
}{
|
|
{
|
|
"ProjectA top level",
|
|
"/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/.archive/",
|
|
[]string{"200.html", "200_0.html"},
|
|
[]string{"100.html", "100_A.html", "100_~A.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 c.wantNames {
|
|
if !contains(gotNames, want) {
|
|
t.Errorf("missing %q at %s; got %v", want, c.contextPath, 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)
|
|
}
|
|
}
|
|
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'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:["*"]).
|
|
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
|
root, idx := archiveTestRoot(t)
|
|
writeZddc(t, root, ".", `acl:
|
|
allow: ["*"]
|
|
`)
|
|
// 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.)
|
|
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", "")
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
root, idx := archiveTestRoot(t)
|
|
writeZddc(t, root, ".", `acl:
|
|
allow: ["*"]
|
|
`)
|
|
writeZddc(t, root, "ProjectB", `acl:
|
|
deny: ["alice@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.StatusFound {
|
|
t.Errorf("alice → /ProjectA/.archive/%s: status %d, want 302; 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")
|
|
if rec.Code != http.StatusFound {
|
|
t.Errorf("bob → /ProjectB/.archive/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 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 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.
|
|
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.StatusFound, "bob allowed at root → reaches ProjectA target"},
|
|
{"bob@example.com", "/ProjectB", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"},
|
|
{"alice@example.com", "/ProjectA", "100.html", http.StatusFound, "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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Resolved redirect Location header is the absolute path to the actual file
|
|
// under cfg.Root. From any depth within the same project, the resolver
|
|
// returns the same target — `/ProjectA/.archive/100.html` and
|
|
// `/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/100.html` 302 to the same
|
|
// file because both look up project ProjectA.
|
|
func TestServeArchive_ResolveLocationStableAcrossDepthWithinProject(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",
|
|
"/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.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cross-project: same tracking number issued under two projects. Each
|
|
// project's .archive/ resolves to its own copy, never the other's.
|
|
func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
|
|
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/123_A (IFR) - Title.pdf")
|
|
mk("ProjectB/2025-06-01_T9 (IFR) - Other Title/123_A (IFR) - Other Title.pdf")
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
|
|
writeZddc(t, root, ".", `acl:
|
|
allow: ["*"]
|
|
`)
|
|
cfg := archiveCfg(root)
|
|
const email = "alice@example.com"
|
|
|
|
recA := callArchive(t, cfg, idx, email, "/ProjectA", "123.html")
|
|
if recA.Code != http.StatusFound {
|
|
t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String())
|
|
}
|
|
locA := recA.Header().Get("Location")
|
|
if !strings.HasPrefix(locA, "/ProjectA/") {
|
|
t.Errorf("ProjectA Location=%q, want /ProjectA/ prefix", locA)
|
|
}
|
|
|
|
recB := callArchive(t, cfg, idx, email, "/ProjectB", "123.html")
|
|
if recB.Code != http.StatusFound {
|
|
t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String())
|
|
}
|
|
locB := recB.Header().Get("Location")
|
|
if !strings.HasPrefix(locB, "/ProjectB/") {
|
|
t.Errorf("ProjectB Location=%q, want /ProjectB/ prefix", locB)
|
|
}
|
|
|
|
if locA == locB {
|
|
t.Errorf("cross-project leak: same Location for both projects: %q", locA)
|
|
}
|
|
|
|
// Listing each project shows only its own.
|
|
for _, c := range []struct{ ctx, mustHave, mustNot string }{
|
|
{"/ProjectA", "ProjectA", "ProjectB"},
|
|
{"/ProjectB", "ProjectB", "ProjectA"},
|
|
} {
|
|
rec := callArchive(t, cfg, idx, email, c.ctx, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("listing %s: status %d", c.ctx, 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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, "", "/ProjectA", "")
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("anonymous listing: status %d, want 403", 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"},
|
|
}
|
|
for _, c := range cases {
|
|
got := projectFromContextPath(c.ctx)
|
|
if got != c.want {
|
|
t.Errorf("projectFromContextPath(%q) = %q, want %q", c.ctx, got, c.want)
|
|
}
|
|
}
|
|
}
|