package handler import ( "bytes" "context" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) const sampleTableSpec = `title: Master Deliverables List description: Sample MDL. columns: - field: id title: ID width: 6em - field: title title: Deliverable - field: status title: Status enum: [pending, submitted, accepted] defaults: sort: - { field: id, dir: asc } ` const sampleRowFormSpec = `title: Deliverable schema: type: object required: [id, title] additionalProperties: false properties: id: type: string title: type: string status: type: string enum: [pending, submitted, accepted] ` // tableTestSetup writes a directory tree under a temp root with the // in-dir layout: // // /Working/MDL/table.yaml → spec // /Working/MDL/form.yaml → row schema // /Working/MDL/.yaml → row data (one per entry in rows) // // Optional extra .zddc files at relative paths can be supplied via zddcFiles. // Returns (config, do) where do dispatches a request through ServeTable via // the same recognize → serve path the production catch-all uses. // // Note: under the client-side rendering architecture the handler does not // parse the spec or list row files — the rows/spec on disk are written // only because the ACL cascade may evaluate paths under them. func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]string) (config.Config, func(method, target, email string) *httptest.ResponseRecorder) { t.Helper() root := t.TempDir() mdlDir := filepath.Join(root, "Working", "MDL") if err := os.MkdirAll(mdlDir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte(sampleTableSpec), 0o644); err != nil { t.Fatalf("write spec: %v", err) } if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil { t.Fatalf("write form spec: %v", err) } for name, body := range rows { if err := os.WriteFile(filepath.Join(mdlDir, name), []byte(body), 0o644); err != nil { t.Fatalf("write row %s: %v", name, err) } } if _, ok := zddcFiles["Working"]; !ok { if zddcFiles == nil { zddcFiles = make(map[string]string) } zddcFiles["Working"] = `acl: permissions: "*@example.com": rwcd ` } for rel, body := range zddcFiles { 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 string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, target, bytes.NewReader(nil)) ctx := context.WithValue(req.Context(), EmailKey, email) ctx = context.WithValue(ctx, ElevatedKey, true) req = req.WithContext(ctx) rec := httptest.NewRecorder() tableReq := RecognizeTableRequest(cfg.Root, method, target) if tableReq == nil { rec.WriteHeader(http.StatusNotFound) return rec } ServeTable(cfg, tableReq, rec, req) return rec } return cfg, do } func TestRecognizeTableRequest(t *testing.T) { root := t.TempDir() mdlDir := filepath.Join(root, "Working", "MDL") if err := os.MkdirAll(mdlDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte(sampleTableSpec), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil { t.Fatal(err) } zddc.InvalidateCache(mdlDir) cases := []struct { method, url string wantNil bool wantSpec string wantName string }{ {"GET", "/Working/MDL/table.html", false, "Working/MDL/table.yaml", "MDL"}, // Same URL but POST → tables are read-only at the URL level. {"POST", "/Working/MDL/table.html", true, "", ""}, {"PUT", "/Working/MDL/table.html", true, "", ""}, {"DELETE", "/Working/MDL/table.html", true, "", ""}, // No table.yaml in this dir → not a table request. {"GET", "/Working/Other/table.html", true, "", ""}, // No table.yaml anywhere → not a table request. {"GET", "/Other/MDL/table.html", true, "", ""}, // Random .html → falls through. {"GET", "/index.html", true, "", ""}, // /form.html in the same dir is form territory, not a table. {"GET", "/Working/MDL/form.html", true, "", ""}, // Path traversal attempt. {"GET", "/../etc/passwd/table.html", true, "", ""}, } for _, tc := range cases { t.Run(tc.method+" "+tc.url, func(t *testing.T) { got := RecognizeTableRequest(root, tc.method, tc.url) if tc.wantNil { if got != nil { t.Errorf("got %+v, want nil", got) } return } if got == nil { t.Fatalf("got nil, want a TableRequest") } if got.Name != tc.wantName { t.Errorf("Name = %q want %q", got.Name, tc.wantName) } wantSpec := filepath.Join(root, tc.wantSpec) if got.SpecPath != wantSpec { t.Errorf("SpecPath = %q want %q", got.SpecPath, wantSpec) } }) } } // TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the // embedded tables.html bytes verbatim, with the empty inline context // placeholder intact (so the client knows to walk the directory). func TestServeTable_ServesEmbeddedHTML(t *testing.T) { rows := map[string]string{ "D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n", } _, do := tableTestSetup(t, rows, nil) rec := do(http.MethodGet, "/Working/MDL/table.html", "casey@example.com") if rec.Code != http.StatusOK { t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) } if ct := rec.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("Content-Type = %q want text/html…", ct) } body := rec.Body.String() if !strings.Contains(body, `{}`) { t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk") } } func TestServeTable_ACLForbidden(t *testing.T) { zddcs := map[string]string{ "Working": `acl: permissions: "root@example.com": rwcd tables: MDL: ./MDL.table.yaml `, } _, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs) rec := do(http.MethodGet, "/Working/MDL/table.html", "stranger@example.com") if rec.Code != http.StatusForbidden { t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) } } // --- default MDL spec fallback --------------------------------------------- // archivePartyTestSetup builds a minimal Project/archive// tree // with no operator-supplied tables: declaration. RecognizeTableRequest // should still fire for "mdl" thanks to the default-spec fallback. func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(method, target, email string) *httptest.ResponseRecorder) { t.Helper() root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil { t.Fatal(err) } partyDir := filepath.Join(root, "Project", "archive", "Acme") if err := os.MkdirAll(partyDir, 0o755); err != nil { t.Fatal(err) } if partyZddcExtras != "" { if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), []byte(partyZddcExtras), 0o644); err != nil { t.Fatal(err) } } zddc.InvalidateCache(root) cfg := config.Config{ Root: root, EmailHeader: "X-Auth-Request-Email", } do := func(method, target, email string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, target, bytes.NewReader(nil)) ctx := context.WithValue(req.Context(), EmailKey, email) ctx = context.WithValue(ctx, ElevatedKey, true) req = req.WithContext(ctx) tr := RecognizeTableRequest(cfg.Root, method, target) rec := httptest.NewRecorder() if tr == nil { rec.WriteHeader(http.StatusNotFound) return rec } ServeTable(cfg, tr, rec, req) return rec } return root, do } func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) { _, do := archivePartyTestSetup(t, "") rec := do(http.MethodGet, "/Project/archive/Acme/mdl/table.html", "alice@example.com") if rec.Code != http.StatusOK { t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "/archive//. A // request at a deeper path (e.g. archive/Acme/mdl/sub/) or a // non-archive path should return nil (no recognition). _, do := archivePartyTestSetup(t, "") rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl/table.html", "alice@example.com") if rec.Code != http.StatusNotFound { t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code) } rec = do(http.MethodGet, "/Project/working/mdl/table.html", "alice@example.com") if rec.Code != http.StatusNotFound { t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code) } } func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) { root := t.TempDir() // archive/Acme/ exists but no mdl/table.yaml on disk. if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil { t.Fatal(err) } bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml") if !ok { t.Fatalf("expected fallback to fire") } if !strings.Contains(string(bts), "Master Deliverables List") { t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))]) } bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/form.yaml") if !ok { t.Fatalf("expected form fallback to fire") } if !strings.Contains(string(bts), "Deliverable") { t.Errorf("default form spec missing expected title") } } func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) { root := t.TempDir() mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl") if err := os.MkdirAll(mdlDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil { t.Fatal(err) } if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok { t.Errorf("operator file should win over embedded fallback") } } func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) { root := t.TempDir() cases := []string{ "/Project/working/mdl/table.yaml", "/Project/archive/mdl/table.yaml", // depth 3 — no party segment "/Project/archive/Acme/sub/mdl/table.yaml", } for _, p := range cases { if _, ok := IsDefaultMdlSpec(root, p); ok { t.Errorf("path %q should NOT trigger default fallback", p) } } }