package handler import ( "bytes" "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" ) // zddcTestSetup writes a tree of .zddc files and returns the root and a // helper that builds requests with an injected user email. files keys // are paths relative to root; the empty string is the root itself. Each // path is created as a directory; if the value is non-empty it is // written as that directory's .zddc. func zddcTestSetup(t *testing.T, files map[string]string) (cfg config.Config, do func(method, target, email, body string) *httptest.ResponseRecorder) { t.Helper() root := t.TempDir() for rel, body := range files { dir := filepath.Join(root, rel) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) } zddc.InvalidateCache(dir) if body == "" { continue } if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil { t.Fatalf("write .zddc: %v", err) } } cfg = config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} do = func(method, target, email, body string) *httptest.ResponseRecorder { var rdr *bytes.Reader if body != "" { rdr = bytes.NewReader([]byte(body)) } var req *http.Request if rdr != nil { req = httptest.NewRequest(method, target, rdr) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, target, nil) } ctx := context.WithValue(req.Context(), EmailKey, email) req = req.WithContext(ctx) rec := httptest.NewRecorder() ServeZddc(cfg, rec, req) return rec } return cfg, do } func TestServeZddcAuthGate(t *testing.T) { // root admin = root@example.com; subtree admin alice@example.com on /projects. cfg, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "projects": "admins:\n - alice@example.com\n", "projects/x": "", }) cases := []struct { name string method string target string email string wantStatus int }{ {"anon GET root", http.MethodGet, "/.profile/zddc?path=/", "", http.StatusNotFound}, {"non-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "mallory@example.com", http.StatusNotFound}, {"super-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "root@example.com", http.StatusOK}, {"subtree-admin GET root (read-only)", http.MethodGet, "/.profile/zddc?path=/", "alice@example.com", http.StatusOK}, {"subtree-admin GET own grant file (read-only)", http.MethodGet, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusOK}, {"subtree-admin GET deeper", http.MethodGet, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK}, {"subtree-admin POST own grant file (forbidden)", http.MethodPost, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusForbidden}, {"subtree-admin POST deeper (allowed)", http.MethodPost, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK}, {"super-admin POST root", http.MethodPost, "/.profile/zddc?path=/", "root@example.com", http.StatusOK}, {"non-admin POST anywhere", http.MethodPost, "/.profile/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound}, {"DELETE root rejected", http.MethodDelete, "/.profile/zddc?path=/", "root@example.com", http.StatusBadRequest}, {"super-admin DELETE leaf", http.MethodDelete, "/.profile/zddc?path=/projects/x", "root@example.com", http.StatusNoContent}, } _ = cfg for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { body := "" if tc.method == http.MethodPost { if tc.target == "/.profile/zddc?path=/" { // Root POST: writer must remain in admins list. body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}` } else { body = `{"title":"x","acl":{"allow":["*@example.com"],"deny":[]},"admins":[]}` } } rec := do(tc.method, tc.target, tc.email, body) if rec.Code != tc.wantStatus { t.Errorf("status = %d, want %d; body=%s", rec.Code, tc.wantStatus, rec.Body.String()) } }) } } func TestServeZddcGetReturnsChain(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\nacl:\n allow: [\"*@example.com\"]\n", "projects": "title: All Projects\n", "projects/sub": "title: Substation\n", }) rec := do(http.MethodGet, "/.profile/zddc?path=/projects/sub", "root@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } var resp zddcGetResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.Path != "/projects/sub" { t.Errorf("path = %q, want /projects/sub", resp.Path) } if !resp.CanEdit { t.Errorf("CanEdit = false; root admin should edit anywhere") } if !resp.Exists { t.Errorf("Exists = false but file was written") } if len(resp.EffectiveChain) != 3 { t.Fatalf("chain length = %d, want 3 (root, projects, projects/sub)", len(resp.EffectiveChain)) } if resp.EffectiveChain[0].Dir != "/" { t.Errorf("chain[0].Dir = %q, want /", resp.EffectiveChain[0].Dir) } if resp.EffectiveChain[1].Dir != "/projects" { t.Errorf("chain[1].Dir = %q, want /projects", resp.EffectiveChain[1].Dir) } if resp.EffectiveChain[2].Title != "Substation" { t.Errorf("chain[2].Title = %q, want Substation", resp.EffectiveChain[2].Title) } } func TestServeZddcPostValidatesGlob(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "projects": "", }) body := `{"title":"x","acl":{"allow":["alice@@bad","good@example.com"],"deny":[]},"admins":[]}` rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) } var we writeError if err := json.Unmarshal(rec.Body.Bytes(), &we); err != nil { t.Fatalf("decode err body: %v", err) } if len(we.Errors) == 0 || we.Errors[0].Field != "acl.allow[0]" { t.Errorf("expected acl.allow[0] error, got %+v", we.Errors) } } func TestServeZddcRootSelfDemotionRejected(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n - bob@example.com\n", }) // root tries to remove themselves, leaving only bob. body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["bob@example.com"]}` rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (self-demotion rejected); body=%s", rec.Code, rec.Body.String()) } } func TestServeZddcRootKeepingSelfAccepted(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", }) // root adds bob alongside themselves — fine. body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com","bob@example.com"]}` rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } } func TestServeZddcWriteRoundTrip(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "projects": "", }) body := `{"title":"Engineering","acl":{"allow":["*@varasys.io"],"deny":[]},"admins":["alice@varasys.io"]}` rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body) if rec.Code != http.StatusOK { t.Fatalf("write status = %d body=%s", rec.Code, rec.Body.String()) } rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("get status = %d body=%s", rec.Code, rec.Body.String()) } var resp zddcGetResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.File.Title != "Engineering" { t.Errorf("title round-trip = %q, want Engineering", resp.File.Title) } if len(resp.File.Admins) != 1 || resp.File.Admins[0] != "alice@varasys.io" { t.Errorf("admins round-trip = %v, want [alice@varasys.io]", resp.File.Admins) } } func TestServeZddcWriteAppsRoundTrip(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "projects": "", }) body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{` + `"default":"https://zddc.varasys.io/releases:stable",` + `"classifier":":beta",` + `"archive":"https://my.local.stuff/releases"}}` rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body) if rec.Code != http.StatusOK { t.Fatalf("write status=%d body=%s", rec.Code, rec.Body.String()) } rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String()) } var resp zddcGetResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if got := resp.File.Apps["default"]; got != "https://zddc.varasys.io/releases:stable" { t.Errorf("default round-trip = %q", got) } if got := resp.File.Apps["classifier"]; got != ":beta" { t.Errorf("classifier round-trip = %q", got) } if got := resp.File.Apps["archive"]; got != "https://my.local.stuff/releases" { t.Errorf("archive round-trip = %q", got) } } func TestServeZddcWriteAppsRejectsBadSpec(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "projects": "", }) body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{"archive":"this is garbage"}}` rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d (want 400)", rec.Code) } if !strings.Contains(rec.Body.String(), `"apps.archive"`) { t.Errorf("expected per-field error for apps.archive; got %s", rec.Body.String()) } } func TestServeZddcEditorRendersAppsSection(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "projects": "apps:\n default: \":beta\"\n classifier: \"v0.0.4\"\n", }) rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ "Apps (tool HTML sources)", `data-apps-key="default"`, `data-apps-key="archive"`, `data-apps-key="classifier"`, `data-apps-key="browse"`, `data-apps-key="transmittal"`, `data-apps-key="landing"`, `value=":beta"`, `value="v0.0.4"`, "classifier_v0.0.4.html", // preview reflects the cascaded resolution } { if !strings.Contains(body, want) { t.Errorf("editor body missing %q", want) } } } func TestServeZddcTreeFiltersByVisibility(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "alpha": "admins:\n - alice@example.com\n", "alpha/x": "title: alpha-x\n", "beta": "admins:\n - bob@example.com\n", }) // alice sees alpha (her grant) and alpha/x (descendant), but not beta. rec := do(http.MethodGet, "/.profile/zddc/tree", "alice@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var entries []treeEntry if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil { t.Fatalf("decode: %v", err) } seen := map[string]bool{} for _, e := range entries { seen[e.Path] = true } if !seen["/alpha"] || !seen["/alpha/x"] { t.Errorf("alice should see /alpha and /alpha/x; got %v", seen) } if seen["/beta"] { t.Errorf("alice should NOT see /beta; got %v", seen) } } func TestServeZddcEditorRenders(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "projects": "title: Engineering\n", }) rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Engineering") { t.Errorf("editor should pre-fill title; body did not contain 'Engineering'") } if !strings.Contains(body, "/.profile/zddc?path=") { t.Errorf("editor should reference API URL; body lacks /.profile/zddc?path=") } if !strings.Contains(body, "Subtree admins of /projects") { t.Errorf("editor should label admins section as subtree (not bootstrap) for non-root file") } } func TestServeZddcEditorReadOnlyForNonEditor(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", "projects": "admins:\n - alice@example.com\n", }) // alice viewing her own grant file: read-only. rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "alice@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Read-only") { t.Errorf("editor should show Read-only banner for non-editor; body lacks it") } } func TestServeZddcRejectsReservedPathSegments(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n", }) for _, p := range []string{"/.foo", "/_bar", "/projects/.evil"} { rec := do(http.MethodGet, "/.profile/zddc?path="+p, "root@example.com", "") if rec.Code != http.StatusNotFound { t.Errorf("path=%q expected 404, got %d", p, rec.Code) } } } func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) { // Confirm that putting /.profile/zddc/* under the broader gate did not // regress the super-admin gate on /.profile/whoami etc. root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil { t.Fatalf("write .zddc: %v", err) } zddc.InvalidateCache(root) cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} req := httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) rec := httptest.NewRecorder() ServeProfile(cfg, nil, nil, rec, req) if rec.Code != http.StatusNotFound { t.Errorf("non-admin /.profile/whoami got %d, want 404", rec.Code) } req = httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com")) rec = httptest.NewRecorder() ServeProfile(cfg, nil, nil, rec, req) if rec.Code != http.StatusOK { t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String()) } } func TestServeZddcAssetsCustomCSS(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil { t.Fatalf("write .zddc: %v", err) } if err := os.WriteFile(filepath.Join(root, ".admin.css"), []byte("body { color: red; }"), 0o644); err != nil { t.Fatalf("write .admin.css: %v", err) } zddc.InvalidateCache(root) cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com")) rec := httptest.NewRecorder() ServeZddc(cfg, rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") { t.Errorf("Content-Type = %q, want text/css...", ct) } if !strings.Contains(rec.Body.String(), "color: red") { t.Errorf("body does not contain custom CSS") } } func TestServeZddcAssetsAbsentReturns404(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil { t.Fatalf("write .zddc: %v", err) } zddc.InvalidateCache(root) cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com")) rec := httptest.NewRecorder() ServeZddc(cfg, rec, req) if rec.Code != http.StatusNotFound { t.Errorf("status=%d, want 404", rec.Code) } }