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. rowSchema: ./MDL.form.yaml rows: ./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: // // /Working/.zddc → declares tables: { MDL: ./MDL.table.yaml } // /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() working := filepath.Join(root, "Working") if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil { t.Fatalf("write spec: %v", err) } if err := os.WriteFile(filepath.Join(working, "MDL.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(working, "MDL", 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 tables: MDL: ./MDL.table.yaml ` } 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) 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() working := filepath.Join(root, "Working") if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(working, ".zddc"), []byte(`tables: MDL: ./MDL.table.yaml `), 0o644); err != nil { t.Fatal(err) } zddc.InvalidateCache(working) 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, "", ""}, // Not declared in .zddc → not a table request. {"GET", "/Working/Other.table.html", true, "", ""}, // No .zddc at the dir → not a table request. {"GET", "/Other/MDL.table.html", true, "", ""}, // Random .html → falls through. {"GET", "/index.html", true, "", ""}, // .form.html (form territory) → falls through to form handler. {"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()) } }