Add GET /<path>/.zddc?effective=1 returning JSON with the composed
ZddcFile across the full cascade plus a per-level source list. The
.zddc file itself still serves only what's defined at that level
(YAML, the source of truth); the new query is inspection-only
(JSON, never written back). The virtual .zddc body's header
comment already pointed at this URL — now it's live.
Wire shape:
{ url_path: "/Project-1/archive/Acme/working/",
merged: { …ZddcFile JSON, composed view… },
sources: [ { level: -1, url: "<embedded>",
contributed: ["roles", "available_tools", "paths"] },
{ level: 0, url: "/.zddc",
contributed: ["acl", "admins"] },
{ level: 4, url: "/Project-1/archive/Acme/working/.zddc",
contributed: ["default_tool", "auto_own", …] } ] }
New zddc.EffectiveZddc(chain) walks chain.Embedded then
chain.Levels[VisibleStart..leaf] through mergeOverlay, and folds the
cross-level Roles union (via the existing lookupRoleMembers,
matching the runtime ACL evaluator's semantics). Returns
([]SourceEntry) listing each contributing level with its non-zero
top-level fields. The handler maps SourceEntry.Level to a directory
URL: -1 → "<embedded>"; 0..n → "/<seg/seg/.../>.zddc".
ACL gate is the same as the YAML view (read on the directory).
X-ZDDC-Source: virtual:effective so clients can distinguish.
Four tests cover the contract:
- BasicCompose: alice's root grant + project_team baseline from
embedded + the project's title all surface in merged; sources
include -1 (embedded), 0 (root), 1 (project).
- InheritFence: top-level inherit:false on /Closed/.zddc drops
every ancestor including the embedded baseline from sources.
- RoleMemberUnion: document_controller declared at root and
project unions members in merged.roles (matches the runtime
cross-level union the ACL evaluator performs).
- existing virtual-body tests still pass — they hit the YAML path,
not the JSON branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
376 lines
14 KiB
Go
376 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
func TestIsZddcFileRequest(t *testing.T) {
|
|
cases := []struct {
|
|
url string
|
|
want bool
|
|
}{
|
|
{"/.zddc", true},
|
|
{"/.zddc/", true},
|
|
{"/Project/.zddc", true},
|
|
{"/Project/archive/PartyA/mdl/.zddc", true},
|
|
{"/.zddc.html", false}, // editor leaf, handled separately
|
|
{"/Project/.zddc.html", false},
|
|
{"/Project/.zddc/", true},
|
|
{"/Project/", false},
|
|
{"/", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := IsZddcFileRequest(tc.url); got != tc.want {
|
|
t.Errorf("IsZddcFileRequest(%q) = %v, want %v", tc.url, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeZddcFile_ExistingFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"title: root\nacl:\n permissions:\n \"*\": rwcda\n")
|
|
subDir := filepath.Join(root, "Project")
|
|
if err := os.Mkdir(subDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustWrite(t, filepath.Join(subDir, ".zddc"),
|
|
"title: project-level\n")
|
|
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/.zddc", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddcFile(cfg, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "title: project-level") {
|
|
t.Errorf("body missing on-disk content: %q", rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("X-ZDDC-Source"); !strings.HasPrefix(got, "file:") {
|
|
t.Errorf("X-ZDDC-Source = %q, want file:* prefix", got)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/yaml") {
|
|
t.Errorf("Content-Type = %q, want application/yaml*", got)
|
|
}
|
|
}
|
|
|
|
// TestServeZddcFile_VirtualDefault — when no .zddc is on disk at the
|
|
// requested directory, the body is the cascade's leaf-level ZddcFile
|
|
// marshalled as YAML, prefixed by a header comment explaining what
|
|
// the file is and pointing at ?effective=1 for the composed view.
|
|
//
|
|
// At /Project/.zddc with no on-disk file, the leaf is the embedded
|
|
// defaults' paths.* contribution — i.e. the project-scoped baseline
|
|
// (project_team: r, observer: r, document_controller: rw) plus the
|
|
// canonical paths: tree (archive, working, staging, reviewing, …).
|
|
// Asserts a few load-bearing markers; the full content is the
|
|
// `defaults.zddc.yaml` source-of-truth, which lives under
|
|
// zddc/internal/zddc and is parsed at every cascade walk.
|
|
func TestServeZddcFile_VirtualDefault(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Directory exists but has no .zddc.
|
|
subDir := filepath.Join(root, "Project")
|
|
if err := os.Mkdir(subDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/.zddc", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddcFile(cfg, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("X-ZDDC-Source"); got != "virtual:zddc" {
|
|
t.Errorf("X-ZDDC-Source = %q, want virtual:zddc", got)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Virtual .zddc") {
|
|
t.Errorf("body missing virtual header comment: %q", body)
|
|
}
|
|
if !strings.Contains(body, "?effective=1") {
|
|
t.Errorf("body missing pointer to the composed-view query: %q", body)
|
|
}
|
|
// The embedded defaults declare project_team: r and
|
|
// observer: r at paths.*. Confirm both surface so the user
|
|
// sees the project-scoped baseline.
|
|
if !strings.Contains(body, "project_team: r") {
|
|
t.Errorf("body missing project_team grant from embedded defaults: %q", body)
|
|
}
|
|
if !strings.Contains(body, "observer: r") {
|
|
t.Errorf("body missing observer grant from embedded defaults: %q", body)
|
|
}
|
|
// The paths: subtree below should include archive (the only
|
|
// physical project-root child) and the virtual aggregators.
|
|
if !strings.Contains(body, "archive:") {
|
|
t.Errorf("body missing archive subtree: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestServeZddcFile_VirtualEmpty — at a directory the embedded
|
|
// defaults' paths: tree does NOT cover, the body collapses to the
|
|
// header comment + an empty-document placeholder ({}). The user
|
|
// sees "no rules declared at this exact level".
|
|
func TestServeZddcFile_VirtualEmpty(t *testing.T) {
|
|
root := t.TempDir()
|
|
// /Project/random-subfolder/ is not declared in the embedded
|
|
// defaults' paths tree (paths.* matches the project name, but
|
|
// no child path matches "random-subfolder").
|
|
deep := filepath.Join(root, "Project", "random-subfolder")
|
|
if err := os.MkdirAll(deep, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/random-subfolder/.zddc", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddcFile(cfg, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Virtual .zddc") {
|
|
t.Errorf("body missing virtual header: %q", body)
|
|
}
|
|
if !strings.Contains(body, "{}") {
|
|
t.Errorf("undeclared-level body should end in {}: %q", body)
|
|
}
|
|
if !strings.Contains(body, "inherited from ancestors") {
|
|
t.Errorf("undeclared-level body should explain inheritance: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestServeZddcFile_Effective_BasicCompose — ?effective=1 returns the
|
|
// merged composed view across embedded baseline + on-disk levels.
|
|
// Body is JSON with the merged ZddcFile and per-level source list.
|
|
func TestServeZddcFile_Effective_BasicCompose(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"alice@example.com\": rwcda\n")
|
|
proj := filepath.Join(root, "Project")
|
|
if err := os.Mkdir(proj, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustWrite(t, filepath.Join(proj, ".zddc"),
|
|
"title: My Project\n")
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/.zddc?effective=1", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddcFile(cfg, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
|
|
t.Errorf("Content-Type = %q, want application/json", got)
|
|
}
|
|
if got := rec.Header().Get("X-ZDDC-Source"); got != "virtual:effective" {
|
|
t.Errorf("X-ZDDC-Source = %q, want virtual:effective", got)
|
|
}
|
|
|
|
var view effectiveZddcView
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
|
t.Fatalf("decode: %v (body: %s)", err, rec.Body.String())
|
|
}
|
|
if view.URLPath != "/Project" {
|
|
t.Errorf("url_path = %q, want /Project", view.URLPath)
|
|
}
|
|
// Merged should carry alice's grant (from root) AND the title
|
|
// from /Project, AND the project_team grant from the embedded
|
|
// defaults' paths.* contribution.
|
|
if view.Merged.ACL.Permissions["alice@example.com"] != "rwcda" {
|
|
t.Errorf("merged.acl.permissions missing alice's grant: %+v", view.Merged.ACL.Permissions)
|
|
}
|
|
if view.Merged.ACL.Permissions["project_team"] != "r" {
|
|
t.Errorf("merged.acl.permissions missing project_team (from embedded defaults paths.*): %+v", view.Merged.ACL.Permissions)
|
|
}
|
|
if view.Merged.Title != "My Project" {
|
|
t.Errorf("merged.title = %q, want My Project (from /Project/.zddc)", view.Merged.Title)
|
|
}
|
|
// Sources should include the embedded baseline (level -1) and
|
|
// the two on-disk levels.
|
|
var levels []int
|
|
for _, s := range view.Sources {
|
|
levels = append(levels, s.Level)
|
|
}
|
|
wantLevels := map[int]bool{-1: true, 0: true, 1: true}
|
|
for _, l := range levels {
|
|
delete(wantLevels, l)
|
|
}
|
|
if len(wantLevels) > 0 {
|
|
t.Errorf("missing source levels %v in %v", wantLevels, levels)
|
|
}
|
|
// Per-level URLs are populated.
|
|
for _, s := range view.Sources {
|
|
if s.Level == -1 && s.URL != "<embedded>" {
|
|
t.Errorf("embedded source url = %q, want <embedded>", s.URL)
|
|
}
|
|
if s.Level == 0 && s.URL != "/.zddc" {
|
|
t.Errorf("root source url = %q, want /.zddc", s.URL)
|
|
}
|
|
if s.Level == 1 && s.URL != "/Project/.zddc" {
|
|
t.Errorf("project source url = %q, want /Project/.zddc", s.URL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestServeZddcFile_Effective_InheritFence — inherit:false at a level
|
|
// drops every ancestor (including the embedded baseline) from the
|
|
// composed view. Only the fence-and-below contribute.
|
|
func TestServeZddcFile_Effective_InheritFence(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"alice@example.com\": rwcda\n")
|
|
proj := filepath.Join(root, "Closed")
|
|
if err := os.Mkdir(proj, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// inherit:false on Closed/.zddc — root + embedded both drop
|
|
// out of the visible chain.
|
|
// Top-level inherit:false drops EVERY ancestor including the
|
|
// embedded baseline. (ACL.inherit:false would only fence ACL
|
|
// evaluation — roles, paths-tree, and embedded baseline still
|
|
// cascade through, which is a separate test.)
|
|
mustWrite(t, filepath.Join(proj, ".zddc"),
|
|
"inherit: false\n"+
|
|
"acl:\n inherit: false\n permissions:\n \"bob@example.com\": rwcda\n")
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
// Bob has the only grant inside the fence; alice's root grant
|
|
// is hidden by inherit:false so she'd 404 on the read gate.
|
|
req := httptest.NewRequest(http.MethodGet, "/Closed/.zddc?effective=1", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "bob@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddcFile(cfg, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var view effectiveZddcView
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
// Alice's root grant must be invisible behind the fence.
|
|
if _, ok := view.Merged.ACL.Permissions["alice@example.com"]; ok {
|
|
t.Errorf("alice's root grant should be hidden by fence; got %+v", view.Merged.ACL.Permissions)
|
|
}
|
|
// Bob's grant at Closed/ is visible.
|
|
if view.Merged.ACL.Permissions["bob@example.com"] != "rwcda" {
|
|
t.Errorf("bob's fence-level grant missing: %+v", view.Merged.ACL.Permissions)
|
|
}
|
|
// Embedded baseline (level -1) must not appear in sources — the
|
|
// fence zeroed it.
|
|
for _, s := range view.Sources {
|
|
if s.Level == -1 {
|
|
t.Errorf("embedded baseline leaked past inherit:false fence: %+v", s)
|
|
}
|
|
if s.Level == 0 {
|
|
t.Errorf("root /.zddc leaked past inherit:false fence: %+v", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestServeZddcFile_Effective_RoleMemberUnion — roles defined at
|
|
// multiple levels show the union of members (the runtime ACL
|
|
// evaluator uses lookupRoleMembers' union, and the composed view
|
|
// must match).
|
|
func TestServeZddcFile_Effective_RoleMemberUnion(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"alice@example.com\": r\n"+
|
|
"roles:\n document_controller:\n members:\n - root-dc@example.com\n")
|
|
proj := filepath.Join(root, "Project")
|
|
if err := os.Mkdir(proj, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustWrite(t, filepath.Join(proj, ".zddc"),
|
|
"roles:\n document_controller:\n members:\n - project-dc@example.com\n")
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/.zddc?effective=1", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddcFile(cfg, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var view effectiveZddcView
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
dc, ok := view.Merged.Roles["document_controller"]
|
|
if !ok {
|
|
t.Fatalf("merged.roles missing document_controller: %+v", view.Merged.Roles)
|
|
}
|
|
wantMembers := map[string]bool{
|
|
"root-dc@example.com": true,
|
|
"project-dc@example.com": true,
|
|
}
|
|
for _, m := range dc.Members {
|
|
delete(wantMembers, m)
|
|
}
|
|
if len(wantMembers) > 0 {
|
|
t.Errorf("document_controller members missing %v; got %v", wantMembers, dc.Members)
|
|
}
|
|
}
|
|
|
|
// TestServeZddcFile_VirtualPerPartyWorking — a deeper path declared
|
|
// by the embedded defaults (archive/<party>/working/) shows its own
|
|
// rich subtree: default_tool, available_tools, auto_own, etc.
|
|
func TestServeZddcFile_VirtualPerPartyWorking(t *testing.T) {
|
|
root := t.TempDir()
|
|
deep := filepath.Join(root, "Project", "archive", "Acme", "working")
|
|
if err := os.MkdirAll(deep, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Project/archive/Acme/working/.zddc", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddcFile(cfg, rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
"default_tool: browse", // working/ default_tool
|
|
"auto_own: true", // working/ creator owns subdirs
|
|
"drop_target: true", // upload zone
|
|
"classifier", // available_tools includes classifier
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("body missing %q at archive/<party>/working/: %s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|