diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 802ddaf..e373b49 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -2,6 +2,7 @@ package main import ( "archive/zip" + "bytes" "context" "crypto/ed25519" "crypto/rand" @@ -288,6 +289,99 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } } +// TestDispatchZddcWriteRouting pins the dispatcher's .zddc routing: +// GET/HEAD lands on ServeZddcFile (which serves the YAML view or the +// virtual placeholder), and PUT/DELETE/POST falls through past the +// dot-prefix guard into ServeFileAPI. Before the .zddc-leaf carve-out, +// PUT/DELETE 405'd at ServeZddcFile (or 404'd at the dot-prefix guard) +// and the YAML editor's save flow had no live path. +func TestDispatchZddcWriteRouting(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "admins:\n - admin@example.com\nacl:\n permissions:\n \"*@example.com\": r\n") + mustMkdir(t, filepath.Join(root, "Project-A")) + + 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) + + withAuth := func(req *http.Request, email string, elevated bool) *http.Request { + ctx := handler.WithEmail(req.Context(), email) + ctx = handler.WithElevation(ctx, elevated) + return req.WithContext(ctx) + } + + // GET routes to ServeZddcFile — serves YAML bytes for an authorised reader. + req := withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true) + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, nil, rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /.zddc: want 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Errorf("GET /.zddc Content-Type = %q, want application/yaml*", ct) + } + + // PUT must route to ServeFileAPI (not 405 from ServeZddcFile). + body := []byte("admins:\n - admin@example.com\n - extra@example.com\n") + req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader(body)), "admin@example.com", true) + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, nil, rec, req) + if rec.Code != http.StatusOK && rec.Code != http.StatusCreated { + t.Fatalf("PUT /.zddc: want 200/201, got %d body=%s", rec.Code, rec.Body.String()) + } + + // Read back via GET to confirm the write landed. + req = withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true) + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, nil, rec, req) + if !strings.Contains(rec.Body.String(), "extra@example.com") { + t.Errorf("GET after PUT: body missing PUT bytes; got %q", rec.Body.String()) + } + + // Project-level .zddc that doesn't exist yet — PUT creates it. + req = withAuth(httptest.NewRequest(http.MethodPut, "/Project-A/.zddc", bytes.NewReader([]byte("title: A\n"))), "admin@example.com", true) + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, nil, rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("PUT /Project-A/.zddc: want 201, got %d body=%s", rec.Code, rec.Body.String()) + } + + // DELETE removes a .zddc. + req = withAuth(httptest.NewRequest(http.MethodDelete, "/Project-A/.zddc", nil), "admin@example.com", true) + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, nil, rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("DELETE /Project-A/.zddc: want 204, got %d body=%s", rec.Code, rec.Body.String()) + } + + // Non-admin elevated still 403 on PUT — the carve-out only opens + // the path past the segment guard; the decider gates ActionAdmin. + req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader([]byte("title: probe\n"))), "stranger@example.com", true) + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, nil, rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("PUT /.zddc by stranger: want 403, got %d body=%s", rec.Code, rec.Body.String()) + } + + // Intermediate .zddc.d segments stay reserved — only the LEAF .zddc + // is carved through. A PUT to /.zddc.d/foo must 404 at the guard. + req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true) + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, nil, rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("PUT /.zddc.d/something: want 404 (reserved segment), got %d", rec.Code) + } +} + // TestDispatchArchiveRedirect: any ///.../.archive/... is 302'd // to the canonical //.archive/... so all tracking-number references // converge on a single stable URL per (project, tracking) regardless of the diff --git a/zddc/internal/handler/auth_invariants_test.go b/zddc/internal/handler/auth_invariants_test.go index 5c6497b..0968f69 100644 --- a/zddc/internal/handler/auth_invariants_test.go +++ b/zddc/internal/handler/auth_invariants_test.go @@ -282,6 +282,178 @@ func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) { } } +// ── Invariant 10 — .zddc write matrix at root / project / subtree ───────── + +// TestInvariant_ZddcPutMatrix exercises every (principal × elevation × +// target) combination for PUT to a .zddc file. The decider's +// IsActiveAdmin short-circuit is the single bypass; this matrix locks +// down that it only fires for an Elevated principal who is named in +// the admins: list of some level on the target's chain. +// +// Targets: +// - /.zddc — root file (root admins: govern) +// - /Project-1/.zddc — project file (no on-disk .zddc; +// write must materialise it; root +// admins still govern via cascade) +// - /Project-1/working/.zddc — subtree file; alice administers +// this subtree via its own admins: +// list (so alice's write doesn't +// require root-admin authority). +// +// Expected status: 200 or 201 on success; 403 on denial; 404 only when +// resolveTargetPath rejects the path (e.g. empty email gets 403 from +// the decider, not 404). +func TestInvariant_ZddcPutMatrix(t *testing.T) { + type principal struct { + email string + elevated bool + } + rootAdminElevated := principal{"admin@example.com", true} + rootAdminUnelevated := principal{"admin@example.com", false} + subtreeAdminElevated := principal{"alice@example.com", true} + subtreeAdminUnelevated := principal{"alice@example.com", false} + nonAdmin := principal{"eve@example.com", true} + anon := principal{"", true} + + const ( + ok = http.StatusOK + den = http.StatusForbidden + ) + cases := []struct { + name string + target string + who principal + want int + }{ + // Root .zddc + {"root admin elevated → root .zddc", "/.zddc", rootAdminElevated, ok}, + {"root admin un-elevated → root .zddc", "/.zddc", rootAdminUnelevated, den}, + {"subtree admin elevated → root .zddc", "/.zddc", subtreeAdminElevated, den}, + {"subtree admin un-elevated → root .zddc", "/.zddc", subtreeAdminUnelevated, den}, + {"non-admin → root .zddc", "/.zddc", nonAdmin, den}, + {"anonymous → root .zddc", "/.zddc", anon, den}, + + // Project .zddc (no on-disk file yet — PUT creates it) + {"root admin elevated → project .zddc", "/Project-1/.zddc", rootAdminElevated, http.StatusCreated}, + {"root admin un-elevated → project .zddc", "/Project-1/.zddc", rootAdminUnelevated, den}, + {"subtree admin elevated (out-of-scope) → project .zddc", "/Project-1/.zddc", subtreeAdminElevated, den}, + {"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den}, + + // Subtree .zddc (alice administers this subtree) + {"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, ok}, + {"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, den}, + {"subtree admin elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, ok}, + {"subtree admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, den}, + {"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, den}, + {"anonymous → subtree .zddc", "/Project-1/working/.zddc", anon, den}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, _ := invariantsFixture(t) + body := []byte("title: matrix probe\n") + rec := doReq(cfg, http.MethodPut, tc.target, tc.who.email, tc.who.elevated, body, "") + if tc.want == den { + if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized { + t.Fatalf("want denial, got %d body=%s", rec.Code, dumpBody(rec)) + } + } else if rec.Code != tc.want { + t.Fatalf("want %d, got %d body=%s", tc.want, rec.Code, dumpBody(rec)) + } + }) + } +} + +// TestInvariant_ZddcDeleteMatrix mirrors ZddcPutMatrix for DELETE. The +// project-level .zddc target is dropped (no on-disk file → 404 lives +// outside the auth surface). The cases that remain pin: only an +// elevated admin with authority over the .zddc's directory can drop +// the file. +func TestInvariant_ZddcDeleteMatrix(t *testing.T) { + type principal struct { + email string + elevated bool + } + rootAdminElevated := principal{"admin@example.com", true} + rootAdminUnelevated := principal{"admin@example.com", false} + subtreeAdminElevated := principal{"alice@example.com", true} + subtreeAdminUnelevated := principal{"alice@example.com", false} + nonAdmin := principal{"eve@example.com", true} + + cases := []struct { + name string + target string + who principal + want int + }{ + {"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, http.StatusNoContent}, + {"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, http.StatusForbidden}, + {"subtree admin elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, http.StatusNoContent}, + {"subtree admin un-elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden}, + {"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, http.StatusForbidden}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, _ := invariantsFixture(t) + rec := doReq(cfg, http.MethodDelete, tc.target, tc.who.email, tc.who.elevated, nil, "") + if rec.Code != tc.want { + t.Fatalf("want %d, got %d body=%s", tc.want, rec.Code, dumpBody(rec)) + } + }) + } +} + +// ── Invariant 11 — anti-bypass: un-elevated admin gets nothing extra ────── + +// TestInvariant_UnelevatedAdminNoSilentBypass is the anti-test for the +// elevation gate. For every (admin-flavour × action) tuple, an +// un-elevated admin must behave exactly like a non-admin: they may +// only do what an explicit ACL grant permits. The fixture's admin and +// alice both have NO baseline ACL grant outside their admin scope, so +// every action below MUST 403 — any pass indicates a bypass leak. +func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) { + cfg, _ := invariantsFixture(t) + type op struct { + method string + path string + body []byte + op string + } + probes := []op{ + // .zddc writes (ActionAdmin) + {http.MethodPut, "/.zddc", []byte("title: x\n"), ""}, + {http.MethodPut, "/Project-1/working/.zddc", []byte("title: x\n"), ""}, + {http.MethodDelete, "/Project-1/working/.zddc", nil, ""}, + // WORM writes (ActionWrite / ActionCreate stripped) + {http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""}, + {http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""}, + {http.MethodDelete, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", nil, ""}, + // Regular write into someone else's working/ home (no ACL grant) + {http.MethodPut, "/Project-1/working/eve@example.com/draft.md", []byte("# steal\n"), ""}, + } + + admins := []struct { + name string + email string + }{ + {"root super-admin", "admin@example.com"}, + {"subtree admin (alice)", "alice@example.com"}, + } + + for _, a := range admins { + for _, p := range probes { + t.Run(a.name+" "+p.method+" "+p.path, func(t *testing.T) { + rec := doReq(cfg, p.method, p.path, a.email, false, p.body, p.op) + if rec.Code != http.StatusForbidden { + t.Fatalf("BYPASS LEAK: %s un-elevated reached %s %s with status %d body=%s", + a.email, p.method, p.path, rec.Code, dumpBody(rec)) + } + }) + } + } +} + // ── Invariant 9 — Profile admin endpoints 404 (not 403) for non-admins ──── func TestInvariant_ProfileAdminEndpointsHideFromNonAdmins(t *testing.T) { diff --git a/zddc/internal/handler/middleware_test.go b/zddc/internal/handler/middleware_test.go index a295795..0474507 100644 --- a/zddc/internal/handler/middleware_test.go +++ b/zddc/internal/handler/middleware_test.go @@ -2,13 +2,17 @@ package handler import ( "bytes" + "encoding/json" "log/slog" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // TestAccessLogReadsEmailFromACLContext is a regression test for a bug where @@ -136,3 +140,113 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) { t.Errorf("audit log missing status code; got: %s", out) } } + +// TestAccessLog_ChainAdminLevelAttribution pins the audit-log forensic +// invariant: every request record carries `chain_admin_level` matching +// the .zddc admins: level that conferred admin authority on this +// request, or -1 when no admin authority applies. Forensics use this to +// distinguish a root-admin write from a subtree-admin write from a +// non-admin write — three operationally distinct events that used to +// be conflated under a single `is_admin` boolean. +// +// Truth table the middleware must emit: +// +// (elevated, in admins at level N) → chain_admin_level=N, active_admin=true +// (elevated, in admins at no level) → chain_admin_level=-1, active_admin=false +// (not elevated, in admins) → chain_admin_level=-1, active_admin=false +// (anonymous, elevation flag ignored) → chain_admin_level=-1, active_admin=false +func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) { + // Fixture: root admin at level 0; subtree admin at level 1 (Project-1). + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("admins:\n - root@example.com\n"), 0o644); err != nil { + t.Fatalf("write root .zddc: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "Project-1"), 0o755); err != nil { + t.Fatalf("mkdir Project-1: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "Project-1", ".zddc"), + []byte("admins:\n - alice@example.com\n"), 0o644); err != nil { + t.Fatalf("write subtree .zddc: %v", err) + } + zddc.InvalidateCache(root) + zddc.InvalidateCache(filepath.Join(root, "Project-1")) + + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + type record struct { + Email string `json:"email"` + Elevated bool `json:"elevated"` + ActiveAdmin bool `json:"active_admin"` + ChainAdminLevel int `json:"chain_admin_level"` + Path string `json:"path"` + } + parse := func(t *testing.T, buf *bytes.Buffer) record { + t.Helper() + var rec record + if err := json.Unmarshal(buf.Bytes(), &rec); err != nil { + t.Fatalf("audit log not valid JSON: %v; raw=%s", err, buf.String()) + } + return rec + } + + cases := []struct { + name string + email string + elevate bool + path string + wantLevel int + wantActive bool + }{ + {"root admin elevated probing root → level 0", "root@example.com", true, "/", 0, true}, + {"root admin elevated probing project → level 0 (walks down chain)", "root@example.com", true, "/Project-1/", 0, true}, + {"subtree admin elevated probing own subtree → level 1", "alice@example.com", true, "/Project-1/", 1, true}, + {"subtree admin elevated probing root → -1 (out of scope)", "alice@example.com", true, "/", -1, false}, + {"root admin un-elevated → -1 (no live authority)", "root@example.com", false, "/", -1, false}, + {"non-admin elevated → -1 (elevation alone confers nothing)", "stranger@example.com", true, "/", -1, false}, + {"anonymous → -1", "", false, "/", -1, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + auditLogger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop)) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + if tc.email != "" { + req.Header.Set("X-Auth-Request-Email", tc.email) + } + if tc.elevate { + req.AddCookie(&http.Cookie{Name: "zddc-elevate", Value: "1"}) + } + chain.ServeHTTP(httptest.NewRecorder(), req) + + rec := parse(t, &buf) + if rec.ChainAdminLevel != tc.wantLevel { + t.Errorf("chain_admin_level = %d, want %d", rec.ChainAdminLevel, tc.wantLevel) + } + if rec.ActiveAdmin != tc.wantActive { + t.Errorf("active_admin = %v, want %v", rec.ActiveAdmin, tc.wantActive) + } + // active_admin is the projection of chain_admin_level — these + // two fields must agree on every record. Asserted explicitly + // so a future refactor that drops the chain_admin_level field + // (or recomputes active_admin from a different source) trips + // this test before the forensic invariant rots. + if rec.ActiveAdmin != (rec.ChainAdminLevel >= 0) { + t.Errorf("active_admin must equal (chain_admin_level >= 0); got active=%v level=%d", + rec.ActiveAdmin, rec.ChainAdminLevel) + } + // Elevation flag must round-trip independently — distinguishes + // "tried to elevate, no authority" (elevated=true, active=false) + // from "didn't elevate" (elevated=false, active=false). + if rec.Elevated != tc.elevate { + t.Errorf("elevated = %v, want %v", rec.Elevated, tc.elevate) + } + }) + } +} diff --git a/zddc/internal/policy/principal_test.go b/zddc/internal/policy/principal_test.go new file mode 100644 index 0000000..f39c65b --- /dev/null +++ b/zddc/internal/policy/principal_test.go @@ -0,0 +1,280 @@ +package policy + +import ( + "context" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// TestAllowActionFromChainP_TruthTable pins the principal-aware decider +// across the full {elevated × admin-at-level-N × action} cross-product. +// This is the single bypass site that consolidates every former +// scattered IsAdmin/IsSubtreeAdmin/CanEditZddc check in handler code, +// so its semantics must be locked in by an exhaustive table. +// +// Invariants pinned: +// +// 1. Admin bypass requires BOTH (Email in admins:) AND Elevated. +// - In admins + elevated → bypass (any action returns true) +// - In admins + un-elevated → no bypass (falls through to ACL) +// - Not in admins + elevated → no bypass +// - Empty email + elevated → no bypass (gate() rejects empty) +// +// 2. Bypass is action-agnostic: ActionRead, ActionWrite, ActionCreate, +// ActionDelete, ActionAdmin all behave the same way under bypass. +// +// 3. Admin authority at ANY level on the chain confers bypass +// (root admin gets bypass even on deep paths; subtree admin +// declared at level N gets bypass for level ≥ N). +// +// 4. With no bypass, the cascade ACL governs: +// - rwcd grant → ActionRead/Write/Create/Delete succeed, ActionAdmin denied +// - no grant + has_any_file → all actions denied +// - empty chain → all actions allowed (public default) +func TestAllowActionFromChainP_TruthTable(t *testing.T) { + // Chain shape used throughout: root admins:[root@example.com] + + // level 1 admins:[sub@example.com] + level 1 ACL allowing + // staff@example.com rwcd. + chain := zddc.PolicyChain{ + HasAnyFile: true, + Levels: []zddc.ZddcFile{ + {Admins: []string{"root@example.com"}}, + { + Admins: []string{"sub@example.com"}, + ACL: zddc.ACLRules{Permissions: map[string]string{ + "staff@example.com": "rwcd", + }}, + }, + }, + } + + type want struct { + read, write, create, deleteV, adminV bool + } + allActions := want{true, true, true, true, true} + noAdmin := want{true, true, true, true, false} // staff has rwcd but no `a` + + cases := []struct { + name string + email string + elevated bool + want want + }{ + // ─── BYPASS PATH ──────────────────────────────────────────── + { + name: "root admin elevated → bypass on every action", + email: "root@example.com", + elevated: true, + want: allActions, + }, + { + name: "subtree admin elevated → bypass on every action", + email: "sub@example.com", + elevated: true, + want: allActions, + }, + + // ─── ELEVATION GATE ───────────────────────────────────────── + // An admin who hasn't elevated MUST be treated as a normal + // user. They don't carry any baseline ACL grant in this + // fixture, so every action is denied. + { + name: "root admin NOT elevated → no bypass, no ACL grant → all denied", + email: "root@example.com", + elevated: false, + want: want{}, + }, + { + name: "subtree admin NOT elevated → no bypass, no ACL grant → all denied", + email: "sub@example.com", + elevated: false, + want: want{}, + }, + + // ─── NON-ADMIN PATHS ──────────────────────────────────────── + { + name: "non-admin with rwcd grant → ACL governs, admin denied", + email: "staff@example.com", + elevated: false, + want: noAdmin, + }, + { + name: "non-admin elevated → elevation alone confers nothing", + email: "staff@example.com", + elevated: true, + want: noAdmin, + }, + { + name: "stranger denied across the board", + email: "rando@example.com", + elevated: false, + want: want{}, + }, + { + name: "stranger elevated still denied", + email: "rando@example.com", + elevated: true, + want: want{}, + }, + + // ─── ANONYMOUS / DEGENERATE ───────────────────────────────── + { + name: "empty email + elevated → gate rejects, no bypass", + email: "", + elevated: true, + want: want{}, + }, + { + name: "empty email + not elevated → denied", + email: "", + elevated: false, + want: want{}, + }, + } + + d := &InternalDecider{} + ctx := context.Background() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := zddc.Principal{Email: tc.email, Elevated: tc.elevated} + check := func(action string, want bool) { + t.Helper() + got, err := AllowActionFromChainP(ctx, d, chain, p, "/sub/file", action) + if err != nil { + t.Fatalf("%s: unexpected error: %v", action, err) + } + if got != want { + t.Errorf("%s: got %v, want %v", action, got, want) + } + } + check(ActionRead, tc.want.read) + check(ActionWrite, tc.want.write) + check(ActionCreate, tc.want.create) + check(ActionDelete, tc.want.deleteV) + check(ActionAdmin, tc.want.adminV) + }) + } +} + +// TestAllowActionFromChainP_AdminScopeDepth: admin authority at the +// root level cascades to every depth; subtree admin authority declared +// at level N applies only when level N is on the queried chain. The +// decider doesn't synthesise admin authority — it derives it from +// IsAdminForChain, which walks the chain it was given. +func TestAllowActionFromChainP_AdminScopeDepth(t *testing.T) { + rootOnly := zddc.PolicyChain{ + HasAnyFile: true, + Levels: []zddc.ZddcFile{ + {Admins: []string{"root@example.com"}}, + }, + } + rootPlusProject := zddc.PolicyChain{ + HasAnyFile: true, + Levels: []zddc.ZddcFile{ + {Admins: []string{"root@example.com"}}, + {Admins: []string{"alice@example.com"}}, + }, + } + siblingChain := zddc.PolicyChain{ + HasAnyFile: true, + Levels: []zddc.ZddcFile{ + {Admins: []string{"root@example.com"}}, + // Sibling project — alice is NOT in this chain's admins. + {Admins: []string{"bob@example.com"}}, + }, + } + + d := &InternalDecider{} + ctx := context.Background() + + cases := []struct { + name string + chain zddc.PolicyChain + email string + path string + wantPut bool + }{ + { + name: "root admin reaches a root-only path", + chain: rootOnly, + email: "root@example.com", + path: "/file", + wantPut: true, + }, + { + name: "root admin reaches a deep path", + chain: rootPlusProject, + email: "root@example.com", + path: "/Project-A/file", + wantPut: true, + }, + { + name: "subtree admin reaches their own subtree", + chain: rootPlusProject, + email: "alice@example.com", + path: "/Project-A/file", + wantPut: true, + }, + { + name: "subtree admin does NOT reach a sibling subtree", + chain: siblingChain, + email: "alice@example.com", + path: "/Project-B/file", + wantPut: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := zddc.Principal{Email: tc.email, Elevated: true} + got, _ := AllowActionFromChainP(ctx, d, tc.chain, p, tc.path, ActionWrite) + if got != tc.wantPut { + t.Errorf("AllowActionFromChainP write: got %v, want %v", got, tc.wantPut) + } + }) + } +} + +// TestAllowActionFromChainP_BypassWinsOverWorm: an elevated admin's +// bypass fires before WORM evaluation, so a mis-filed document under +// received/ or issued/ can still be corrected. This is the explicit +// human escape hatch documented in the policy package comment. +func TestAllowActionFromChainP_BypassWinsOverWorm(t *testing.T) { + trueP := true + chain := zddc.PolicyChain{ + HasAnyFile: true, + Levels: []zddc.ZddcFile{ + {Admins: []string{"root@example.com"}}, + { + // WORM zone (received/issued style). Without admin bypass, + // every write would be stripped. + Worm: []string{"_doc_controller"}, + ACL: zddc.ACLRules{Inherit: &trueP}, + }, + }, + } + d := &InternalDecider{} + ctx := context.Background() + + p := zddc.Principal{Email: "root@example.com", Elevated: true} + for _, action := range []string{ActionRead, ActionWrite, ActionCreate, ActionDelete, ActionAdmin} { + t.Run("elevated admin in WORM zone — "+action, func(t *testing.T) { + got, _ := AllowActionFromChainP(ctx, d, chain, p, "/received/x", action) + if !got { + t.Errorf("elevated admin %s denied inside WORM zone", action) + } + }) + } + + // Negative control: same principal un-elevated must NOT bypass WORM. + pUn := zddc.Principal{Email: "root@example.com", Elevated: false} + for _, action := range []string{ActionWrite, ActionDelete, ActionAdmin} { + t.Run("un-elevated admin in WORM zone — "+action, func(t *testing.T) { + got, _ := AllowActionFromChainP(ctx, d, chain, pUn, "/received/x", action) + if got { + t.Errorf("un-elevated admin %s allowed inside WORM zone (bypass leaked)", action) + } + }) + } +}