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 != "" { t.Errorf("embedded source url = %q, want ", 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//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//working/: %s", want, body) } } }