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" "gopkg.in/yaml.v3" ) // historyTestSetup wires a fresh root with the embedded defaults // (which declare the records: rules for mdl/rsk/ssr) plus a // permissive ACL for *@example.com so the test cohort can write // anywhere under archive/. // // Returns (cfg, do) where do invokes ServeFileAPI directly — we // bypass the dispatch tree because the system-under-test is the // serveFilePut path, not the entire HTTP stack. func historyTestSetup(t *testing.T) (config.Config, func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder) { t.Helper() root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 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, body []byte, headers map[string]string) *httptest.ResponseRecorder { var req *http.Request if body != nil { req = httptest.NewRequest(method, target, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/yaml") } else { req = httptest.NewRequest(method, target, nil) } for k, v := range headers { req.Header.Set(k, v) } ctx := context.WithValue(req.Context(), EmailKey, email) ctx = context.WithValue(ctx, ElevatedKey, true) req = req.WithContext(ctx) rec := httptest.NewRecorder() ServeFileAPI(cfg, rec, req) return rec } return cfg, do } // TestRecordPut_CreateStampsAuditFields verifies that a PUT to a // fresh mdl row inserts created_*, updated_*, revision=1 server-side, // and that the response body echoes the stamped YAML. func TestRecordPut_CreateStampsAuditFields(t *testing.T) { cfg, do := historyTestSetup(t) // Build a body with the right components for the embedded // mdl rule's filename_format. body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n") url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml" rec := do(http.MethodPut, url, "alice@example.com", body, nil) if rec.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } // Response body should be the stamped YAML. out := map[string]any{} if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("parse response body: %v", err) } if out["created_by"] != "alice@example.com" { t.Errorf("created_by=%v want alice@example.com", out["created_by"]) } if out["updated_by"] != "alice@example.com" { t.Errorf("updated_by=%v want alice@example.com", out["updated_by"]) } if out["revision"] != 1 { t.Errorf("revision=%v want 1", out["revision"]) } if out["created_at"] == "" || out["updated_at"] == "" { t.Errorf("created_at/updated_at empty: %+v", out) } if _, hasPrev := out["previous_sha"]; hasPrev { t.Errorf("previous_sha should be absent on create: %+v", out) } // On-disk file matches the response body. abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml") disk, err := os.ReadFile(abs) if err != nil { t.Fatalf("read disk: %v", err) } if !bytes.Equal(disk, rec.Body.Bytes()) { t.Errorf("response body != disk bytes\nresponse=%s\ndisk=%s", rec.Body.String(), disk) } // No history dir yet (create only). histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history") if _, err := os.Stat(histDir); !os.IsNotExist(err) { t.Errorf(".history/ should not exist after create-only; got err=%v", err) } } // TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior verifies // that the second write captures the first into .history//, // chains previous_sha, and increments revision. func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) { cfg, do := historyTestSetup(t) url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml" body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n") rec := do(http.MethodPut, url, "alice@example.com", body1, nil) if rec.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) } firstEtag := strings.Trim(rec.Result().Header.Get("ETag"), `"`) body2 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V2\n") rec = do(http.MethodPut, url, "bob@example.com", body2, map[string]string{ "If-Match": `"` + firstEtag + `"`, }) if rec.Code != http.StatusOK { t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String()) } out := map[string]any{} if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("parse update response: %v", err) } if out["created_by"] != "alice@example.com" { t.Errorf("created_by should be preserved as alice: %v", out["created_by"]) } if out["updated_by"] != "bob@example.com" { t.Errorf("updated_by should be bob: %v", out["updated_by"]) } if out["revision"] != 2 { t.Errorf("revision=%v want 2", out["revision"]) } if out["previous_sha"] == "" || out["previous_sha"] == nil { t.Errorf("previous_sha should be non-empty on update: %+v", out) } // .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes). histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history", "ACM-PRJ-EL-SPC-0001") ents, err := os.ReadDir(histDir) if err != nil { t.Fatalf("read history dir: %v", err) } if len(ents) != 1 { t.Fatalf("history entries=%d want 1", len(ents)) } // The archived file's title is V1 (the prior version). prior, err := os.ReadFile(filepath.Join(histDir, ents[0].Name())) if err != nil { t.Fatal(err) } if !bytes.Contains(prior, []byte("title: V1")) { t.Errorf("archived prior version missing title=V1; got %s", prior) } } // TestRecordPut_ConflictPreservesHistory ensures a 412 doesn't // write anything — no history entry, no overwrite. func TestRecordPut_ConflictPreservesHistory(t *testing.T) { cfg, do := historyTestSetup(t) url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml" body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n") if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated { t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) } body2 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V2\n") rec := do(http.MethodPut, url, "bob@example.com", body2, map[string]string{ "If-Match": `"deadbeefdeadbeefdeadbeefdeadbeef"`, // wrong }) if rec.Code != http.StatusPreconditionFailed { t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String()) } histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".history") if _, err := os.Stat(histDir); !os.IsNotExist(err) { t.Errorf("history dir should not exist after 412 conflict; got err=%v", err) } } // TestRecordPut_ClientAuditFieldsStripped: client tries to forge // audit fields → server silently strips and overwrites them. func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) { _, do := historyTestSetup(t) url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml" body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Forged\n" + "created_by: eve@evil.com\nupdated_by: eve@evil.com\nrevision: 999\n") rec := do(http.MethodPut, url, "alice@example.com", body, nil) if rec.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } out := map[string]any{} if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } if out["created_by"] != "alice@example.com" { t.Errorf("client-forged created_by leaked through: %v", out["created_by"]) } if out["revision"] != 1 { t.Errorf("client-forged revision leaked through: %v", out["revision"]) } } // TestRecordPut_FilenameMismatch: body fields compose to a different // tracking number than the URL → 422 with a "/" path error. func TestRecordPut_FilenameMismatch(t *testing.T) { _, do := historyTestSetup(t) // URL claims sequence=0002 but body says 0001 → mismatch. url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0002.yaml" body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n") rec := do(http.MethodPut, url, "alice@example.com", body, nil) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d body=%s", rec.Code, rec.Body.String()) } } // TestAugmentSchema_OriginatorReadOnlyAndPrefilled verifies the form // renderer marks the folder-bound originator read-only and pre-fills // it with the party-folder name resolved from the cascade at the // per-party mdl/ directory. func TestAugmentSchema_OriginatorReadOnlyAndPrefilled(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil { t.Fatal(err) } zddc.InvalidateCache(root) gateDir := filepath.Join(root, "Project", "archive", "ACM", "mdl") if err := os.MkdirAll(gateDir, 0o755); err != nil { t.Fatal(err) } chain, err := zddc.EffectivePolicy(root, gateDir) if err != nil { t.Fatal(err) } var spec FormSpec if err := yaml.Unmarshal(DefaultMdlFormYAML(), &spec); err != nil { t.Fatal(err) } augmentSchemaFromCascade(spec.Schema, chain, gateDir) orig := spec.Schema.Properties["originator"] if orig == nil { t.Fatal("originator property missing from default mdl schema") } if !orig.ReadOnly { t.Errorf("originator.ReadOnly = false, want true (folder-bound)") } if orig.Default != "ACM" { t.Errorf("originator.Default = %v, want ACM (party-folder name)", orig.Default) } } // TestRecordPut_OriginatorBoundToPartyFolder: the mdl rule's // folder_fields binds originator to the party-folder name. A client // value is overwritten silently (folder is the sole source of truth), // and a URL whose filename uses a different originator 422s on the // filename-composition check. func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) { cfg, do := historyTestSetup(t) // Body claims originator=WRONG; the party folder is ACM. The URL // filename correctly uses the folder name, so the server overwrites // the body field and the write succeeds. url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml" body := []byte("originator: WRONG\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n") rec := do(http.MethodPut, url, "alice@example.com", body, nil) if rec.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml") disk, err := os.ReadFile(abs) if err != nil { t.Fatalf("read disk: %v", err) } out := map[string]any{} if err := yaml.Unmarshal(disk, &out); err != nil { t.Fatal(err) } if out["originator"] != "ACM" { t.Errorf("originator=%v want ACM (party-folder name overrides body)", out["originator"]) } // A URL whose filename uses a different originator than the folder // can't be composed to match — 422 filename mismatch. badURL := "/Project/archive/ACM/mdl/WRONG-PRJ-EL-SPC-0002.yaml" badBody := []byte("originator: WRONG\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0002'\ntitle: X\n") rec = do(http.MethodPut, badURL, "alice@example.com", badBody, nil) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422 for wrong-originator filename, got %d body=%s", rec.Code, rec.Body.String()) } } // TestRecordPut_LockedFieldRejected: rsk rule locks type=RSK; a // client submitting type=SPC for an rsk row gets 422 with // path=/type. func TestRecordPut_LockedFieldRejected(t *testing.T) { _, do := historyTestSetup(t) url := "/Project/archive/ACM/rsk/ACM-PRJ-EL-RSK-0001-001.yaml" // Client tries type=SPC even though rsk/ locks type=RSK. body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\nrow: '001'\ntitle: X\n") rec := do(http.MethodPut, url, "alice@example.com", body, nil) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d body=%s", rec.Code, rec.Body.String()) } var errs struct { Errors []struct { Path string `json:"path"` Message string `json:"message"` } `json:"errors"` } _ = json.Unmarshal(rec.Body.Bytes(), &errs) found := false for _, e := range errs.Errors { if e.Path == "/type" { found = true } } if !found { t.Errorf("expected /type lock error; got %v", errs.Errors) } } // TestRecordPut_SSRHistoryAtPartyLevel: writing to an SSR row's // canonical archive//ssr.yaml puts history at // archive//.history/ssr/, NOT at archive/.history//. func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) { cfg, do := historyTestSetup(t) // We bypass the SSR create handler and just PUT directly to the // canonical path the SSR rewrites would land on. abs := filepath.Join(cfg.Root, "Project", "archive", "0330C1") if err := os.MkdirAll(abs, 0o755); err != nil { t.Fatal(err) } // The plain file API uses the bytes as-is; ssr.yaml's records: // rule will trigger audit stamping but no filename composition // (no filename_format on the SSR records: entry). url := "/Project/archive/0330C1/ssr.yaml" body := []byte("kind: SSR\nvendorType: subcontractor\ncontractNo: PO-001\nscopeSummary: Concrete\n") if rec := do(http.MethodPut, url, "alice@example.com", body, nil); rec.Code != http.StatusCreated { t.Fatalf("first put status=%d body=%s", rec.Code, rec.Body.String()) } // Read back current ETag so we can update with If-Match. getRec := do(http.MethodGet, url, "alice@example.com", nil, nil) _ = getRec // ETag rebuilt from disk by fileETag inside serveFilePut rec := do(http.MethodPut, url, "bob@example.com", []byte("kind: SSR\nvendorType: supplier\ncontractNo: PO-002\nscopeSummary: Pipe\n"), nil) if rec.Code != http.StatusOK { t.Fatalf("second put status=%d body=%s", rec.Code, rec.Body.String()) } // History at archive/0330C1/.history/ssr/, NOT at archive/.history/. wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".history", "ssr") if _, err := os.Stat(wanted); err != nil { t.Fatalf("expected history at %s; err=%v", wanted, err) } bad := filepath.Join(cfg.Root, "Project", "archive", ".history") if _, err := os.Stat(bad); !os.IsNotExist(err) { t.Errorf("history must NOT live at %s; err=%v", bad, err) } } // TestRollupCreate_AssignsRowAndComposesFilename: posting an rsk // row via the rollup create endpoint causes the server to compute // the filename from the body components AND auto-assign the next // row number within the table-scope group. func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) { cfg, _ := historyTestSetup(t) // Materialize the party folder (rollup create requires it). partyAbs := filepath.Join(cfg.Root, "Project", "archive", "0330C1") if err := os.MkdirAll(partyAbs, 0o755); err != nil { t.Fatal(err) } // First row: table-tracking components + the routing party field. // originator is omitted — the server derives it from the party // folder (0330C1) via folder_fields. Server should pick row=001. body1 := `{"party":"0330C1","project":"PRJ","discipline":"EL","sequence":"0001","title":"Schedule slip"}` rec := doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body1) if rec.Code != http.StatusCreated { t.Fatalf("first rsk create status=%d body=%s", rec.Code, rec.Body.String()) } loc := rec.Result().Header.Get("Location") if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0001-001.yaml") { t.Errorf("first row location=%q want ...0330C1-PRJ-EL-RSK-0001-001.yaml", loc) } // Second row in the same table: row=002. body2 := `{"party":"0330C1","project":"PRJ","discipline":"EL","sequence":"0001","title":"Cost overrun"}` rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body2) if rec.Code != http.StatusCreated { t.Fatalf("second rsk create status=%d body=%s", rec.Code, rec.Body.String()) } loc = rec.Result().Header.Get("Location") if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0001-002.yaml") { t.Errorf("second row location=%q want ...0330C1-PRJ-EL-RSK-0001-002.yaml", loc) } // Different table-scope (sequence=0002) restarts at row=001. body3 := `{"party":"0330C1","project":"PRJ","discipline":"EL","sequence":"0002","title":"Risk in second register"}` rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body3) if rec.Code != http.StatusCreated { t.Fatalf("third rsk create status=%d body=%s", rec.Code, rec.Body.String()) } loc = rec.Result().Header.Get("Location") if !strings.Contains(loc, "0330C1-PRJ-EL-RSK-0002-001.yaml") { t.Errorf("third row (new scope) location=%q want ...0330C1-PRJ-EL-RSK-0002-001.yaml", loc) } // All three files contain audit fields (proves WriteWithHistory ran). rskDir := filepath.Join(partyAbs, "rsk") ents, err := os.ReadDir(rskDir) if err != nil { t.Fatal(err) } yamlCount := 0 for _, e := range ents { if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { continue } yamlCount++ data, err := os.ReadFile(filepath.Join(rskDir, e.Name())) if err != nil { t.Fatal(err) } if !bytes.Contains(data, []byte("created_by: alice@example.com")) { t.Errorf("%s missing created_by stamp: %s", e.Name(), data) } } if yamlCount != 3 { t.Errorf("expected 3 rsk row files; got %d", yamlCount) } } // doForm is a small helper that dispatches a form POST through // RecognizeFormRequest → ServeForm (the rollup/SSR create entry // point). Lets the history tests share the same harness without // pulling in the full ssrTestSetup helper. func doForm(t *testing.T, cfg config.Config, method, target, email, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") ctx := context.WithValue(req.Context(), EmailKey, email) ctx = context.WithValue(ctx, ElevatedKey, true) req = req.WithContext(ctx) rec := httptest.NewRecorder() formReq := RecognizeFormRequest(cfg.Root, method, target) if formReq == nil { t.Fatalf("RecognizeFormRequest returned nil for %s %s", method, target) } ServeForm(cfg, formReq, rec, req) return rec }