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" ) const sampleFormSpec = `title: Daily Safety Check-In schema: type: object required: [date, location] additionalProperties: false properties: date: type: string format: date location: type: string enum: [Site A, Site B] severity: type: integer minimum: 1 maximum: 5 notes: type: string ui: notes: ui:widget: textarea ` // formTestSetup writes a directory tree under a temp root including a // safety.form.yaml at /Working/safety.form.yaml plus optional .zddc files. // Returns (config, do) where do dispatches a request through ServeForm via // the same recognize → serve path the production catch-all uses. func formTestSetup(t *testing.T, zddcFiles map[string]string) (config.Config, func(method, target, email, body string) *httptest.ResponseRecorder) { t.Helper() root := t.TempDir() // Always seed the form spec at /Working/safety.form.yaml. working := filepath.Join(root, "Working") if err := os.MkdirAll(working, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } specPath := filepath.Join(working, "safety.form.yaml") if err := os.WriteFile(specPath, []byte(sampleFormSpec), 0o644); err != nil { t.Fatalf("write spec: %v", err) } 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, body string) *httptest.ResponseRecorder { var req *http.Request if body != "" { req = httptest.NewRequest(method, target, bytes.NewReader([]byte(body))) 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() formReq := RecognizeFormRequest(cfg.Root, method, target) if formReq == nil { rec.WriteHeader(http.StatusNotFound) return rec } ServeForm(cfg, formReq, rec, req) return rec } return cfg, do } func TestRecognizeFormRequest(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "Working", "safety"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "Working", "safety.form.yaml"), []byte("schema:\n type: object\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "Working", "safety", "2026-05-01-casey.yaml"), []byte("date: 2026-05-01\n"), 0o644); err != nil { t.Fatal(err) } cases := []struct { method, url string wantKind string // "" means expect nil wantSpec string wantData string }{ {"GET", "/Working/safety.form.html", "render-empty", "Working/safety.form.yaml", ""}, {"POST", "/Working/safety.form.html", "create", "Working/safety.form.yaml", ""}, {"GET", "/Working/safety/2026-05-01-casey.yaml.html", "render-edit", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"}, {"POST", "/Working/safety/2026-05-01-casey.yaml.html", "update", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"}, // No spec → not a form request. {"GET", "/Working/missing.form.html", "", "", ""}, // Bare .yaml (not .yaml.html) → not a form request, falls through to static. {"GET", "/Working/safety/2026-05-01-casey.yaml", "", "", ""}, // Random .html → falls through. {"GET", "/index.html", "", "", ""}, // Wrong method. {"DELETE", "/Working/safety.form.html", "", "", ""}, // Path traversal attempt. {"GET", "/../etc/passwd.form.html", "", "", ""}, } for _, tc := range cases { t.Run(tc.method+" "+tc.url, func(t *testing.T) { got := RecognizeFormRequest(root, tc.method, tc.url) if tc.wantKind == "" { if got != nil { t.Errorf("got %+v, want nil", got) } return } if got == nil { t.Fatalf("got nil, want kind=%q", tc.wantKind) } if got.Kind != tc.wantKind { t.Errorf("Kind = %q want %q", got.Kind, tc.wantKind) } wantSpec := filepath.Join(root, tc.wantSpec) if got.SpecPath != wantSpec { t.Errorf("SpecPath = %q want %q", got.SpecPath, wantSpec) } if tc.wantData != "" { wantData := filepath.Join(root, tc.wantData) if got.DataPath != wantData { t.Errorf("DataPath = %q want %q", got.DataPath, wantData) } } else if got.DataPath != "" { t.Errorf("DataPath = %q want empty", got.DataPath) } }) } } func TestRenderEmptyForm(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["*@example.com"] `, }) rec := do(http.MethodGet, "/Working/safety.form.html", "casey@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The placeholder should be replaced with real context content. if !strings.Contains(body, ``) { t.Fatal("placeholder {} was not replaced") } // Title from the form spec should land in the rendered context. if !strings.Contains(body, "Daily Safety Check-In") { t.Errorf("expected title in body, got first 500 chars:\n%s", body[:min(500, len(body))]) } } func TestRenderEmptyForm_ACLDeny(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["root@example.com"] `, }) rec := do(http.MethodGet, "/Working/safety.form.html", "stranger@example.com", "") if rec.Code != http.StatusForbidden { t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) } } func TestCreateSubmission_Valid(t *testing.T) { cfg, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["*@example.com"] `, }) body := `{"date":"2026-05-01","location":"Site A","severity":3,"notes":"all clear"}` rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) if rec.Code != http.StatusCreated { t.Fatalf("status = %d want 201; body = %s", rec.Code, rec.Body.String()) } loc := rec.Header().Get("Location") if loc == "" { t.Fatal("Location header missing") } // Filename uses the server's UTC date (not the user-entered date), so just // check the path prefix and email-sanitized component. if !strings.HasPrefix(loc, "/Working/safety/") || !strings.Contains(loc, "casey-at-example-com") { t.Errorf("Location = %q; expected /Working/safety/...casey-at-example-com...", loc) } // File should exist on disk with the submitted values reflected. abs := filepath.Join(cfg.Root, filepath.FromSlash(strings.TrimPrefix(loc, "/"))) yamlBytes, err := os.ReadFile(abs) if err != nil { t.Fatalf("read submission: %v", err) } yamlStr := string(yamlBytes) if !strings.Contains(yamlStr, "2026-05-01") { t.Errorf("submission YAML missing user-entered date: %s", yamlStr) } if !strings.Contains(yamlStr, "Site A") { t.Errorf("submission YAML missing location: %s", yamlStr) } if !strings.Contains(yamlStr, "all clear") { t.Errorf("submission YAML missing notes: %s", yamlStr) } } func TestCreateSubmission_Invalid_Returns422(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["*@example.com"] `, }) // Missing required `location`, severity out of range. body := `{"date":"2026-05-01","severity":99}` rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("status = %d want 422; body = %s", rec.Code, rec.Body.String()) } var resp struct { Errors []struct { Path string `json:"path"` Message string `json:"message"` } `json:"errors"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v; body = %s", err, rec.Body.String()) } if len(resp.Errors) < 2 { t.Errorf("expected at least 2 errors, got %d: %+v", len(resp.Errors), resp.Errors) } gotPaths := map[string]bool{} for _, e := range resp.Errors { gotPaths[e.Path] = true } if !gotPaths["/location"] { t.Errorf("expected error at /location, got paths %v", gotPaths) } if !gotPaths["/severity"] { t.Errorf("expected error at /severity, got paths %v", gotPaths) } } func TestCreateSubmission_ACLDeny(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["root@example.com"] `, }) body := `{"date":"2026-05-01","location":"Site A"}` rec := do(http.MethodPost, "/Working/safety.form.html", "stranger@example.com", body) if rec.Code != http.StatusForbidden { t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) } } func TestCreateSubmission_NoAuth_Returns401(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["*"] `, }) body := `{"date":"2026-05-01","location":"Site A"}` rec := do(http.MethodPost, "/Working/safety.form.html", "", body) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d want 401; body = %s", rec.Code, rec.Body.String()) } } func TestCreateSubmission_FilenameCollision(t *testing.T) { cfg, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["*@example.com"] `, }) body := `{"date":"2026-05-01","location":"Site A"}` first := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) if first.Code != http.StatusCreated { t.Fatalf("first submit: status = %d; body = %s", first.Code, first.Body.String()) } second := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) if second.Code != http.StatusCreated { t.Fatalf("second submit: status = %d; body = %s", second.Code, second.Body.String()) } loc1 := first.Header().Get("Location") loc2 := second.Header().Get("Location") if loc1 == loc2 { t.Errorf("collision suffix not applied: both submissions at %q", loc1) } if !strings.Contains(loc2, "-2.yaml") { t.Errorf("second submission Location = %q; expected -2.yaml suffix", loc2) } // Both files exist on disk. for _, l := range []string{loc1, loc2} { abs := filepath.Join(cfg.Root, filepath.FromSlash(strings.TrimPrefix(l, "/"))) if _, err := os.Stat(abs); err != nil { t.Errorf("expected submission at %s: %v", abs, err) } } } func TestRenderEdit_LoadsSubmission(t *testing.T) { cfg, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["*@example.com"] `, }) // Pre-populate a submission file. subDir := filepath.Join(cfg.Root, "Working", "safety") if err := os.MkdirAll(subDir, 0o755); err != nil { t.Fatal(err) } subPath := filepath.Join(subDir, "2026-05-01-jamie-at-example-com.yaml") if err := os.WriteFile(subPath, []byte("date: 2026-05-01\nlocation: Site B\nseverity: 4\n"), 0o644); err != nil { t.Fatal(err) } rec := do(http.MethodGet, "/Working/safety/2026-05-01-jamie-at-example-com.yaml.html", "jamie@example.com", "") if rec.Code != http.StatusOK { t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The form-context JSON should now contain the loaded data. if !strings.Contains(body, `"location":"Site B"`) { t.Errorf("expected loaded location in form context; first 500 chars:\n%s", body[:min(500, len(body))]) } } func TestUpdateSubmission_OverwritesFile(t *testing.T) { cfg, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["*@example.com"] `, }) subDir := filepath.Join(cfg.Root, "Working", "safety") if err := os.MkdirAll(subDir, 0o755); err != nil { t.Fatal(err) } subPath := filepath.Join(subDir, "2026-05-01-jamie-at-example-com.yaml") if err := os.WriteFile(subPath, []byte("date: 2026-05-01\nlocation: Site A\n"), 0o644); err != nil { t.Fatal(err) } body := `{"date":"2026-05-01","location":"Site B","severity":2}` rec := do(http.MethodPost, "/Working/safety/2026-05-01-jamie-at-example-com.yaml.html", "jamie@example.com", body) if rec.Code != http.StatusOK { t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) } updated, err := os.ReadFile(subPath) if err != nil { t.Fatalf("read updated: %v", err) } if !strings.Contains(string(updated), "Site B") { t.Errorf("update did not change location; got: %s", string(updated)) } if !strings.Contains(string(updated), "severity: 2") { t.Errorf("update did not include severity; got: %s", string(updated)) } } func TestUpdateSubmission_NotFound(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: allow: ["*@example.com"] `, }) body := `{"date":"2026-05-01","location":"Site A"}` rec := do(http.MethodPost, "/Working/safety/missing.yaml.html", "jamie@example.com", body) if rec.Code != http.StatusNotFound { t.Errorf("status = %d want 404; body = %s", rec.Code, rec.Body.String()) } } func TestSanitizeEmail(t *testing.T) { cases := map[string]string{ "casey@proton.me": "casey-at-proton-me", "first.last@example.com": "first-last-at-example-com", "casey+tag@example.io": "caseytag-at-example-io", "": "anonymous", "../etc/passwd@evil.com": "etcpasswd-at-evil-com", } for in, want := range cases { got := sanitizeEmail(in) if got != want { t.Errorf("sanitizeEmail(%q) = %q want %q", in, got, want) } } } func TestPickAvailableFilename_Collision(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "a.yaml"), []byte("x"), 0o644); err != nil { t.Fatal(err) } path, name, ok := pickAvailableFilename(dir, "a", ".yaml") if !ok { t.Fatal("ok=false on first collision step") } if name != "a-2.yaml" { t.Errorf("name = %q want a-2.yaml", name) } if filepath.Base(path) != "a-2.yaml" { t.Errorf("path basename = %q want a-2.yaml", filepath.Base(path)) } } func TestInjectFormContext_PlaceholderReplaced(t *testing.T) { template := []byte(``) out, err := injectFormContext(template, formContext{ Title: "X", SubmitURL: "/x", }) if err != nil { t.Fatalf("inject: %v", err) } s := string(out) if strings.Contains(s, `"application/json">{}`) { t.Error("placeholder still present") } if !strings.Contains(s, `"title":"X"`) { t.Errorf("missing title in injected JSON; got: %s", s) } } func TestInjectFormContext_EscapesScriptCloseInValue(t *testing.T) { // A schema description containing "" must not break out of the // inline JSON. encoding/json's default escapes `<` → `<`, so the // rendered output should still contain exactly one (the actual // closing tag) regardless of what the user-controlled value held. template := []byte(``) ctx := formContext{ Title: `legit `, SubmitURL: "/x", } out, err := injectFormContext(template, ctx) if err != nil { t.Fatalf("inject: %v", err) } s := string(out) if n := strings.Count(s, ""); n != 1 { t.Errorf("expected exactly 1 closing tag, got %d:\n%s", n, s) } // The user-controlled value should be present in escaped form. if !strings.Contains(s, ``) { t.Errorf("expected escaped \\u003c/script\\u003e in output:\n%s", s) } } func min(a, b int) int { if a < b { return a } return b }