547 lines
22 KiB
Go
547 lines
22 KiB
Go
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)
|
|
}
|
|
// Register the party the mdl/rsk tests use (party_source: ssr). The
|
|
// SSR-history test registers its own party (0330C1) by creating the
|
|
// ssr row, so it's intentionally left out here.
|
|
{
|
|
f := filepath.Join(root, "Project", "ssr", "ACM.yaml")
|
|
if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(f, []byte("kind: SSR\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/mdl/ACM/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", "mdl", "ACM", "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", "mdl", "ACM", ".zddc.d", "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/<base>/,
|
|
// chains previous_sha, and increments revision.
|
|
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
|
cfg, do := historyTestSetup(t)
|
|
url := "/Project/mdl/ACM/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", "mdl", "ACM", ".zddc.d", "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/mdl/ACM/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", "mdl", "ACM", ".zddc.d", "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/mdl/ACM/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/mdl/ACM/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", "mdl", "ACM")
|
|
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/mdl/ACM/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", "mdl", "ACM", "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/mdl/ACM/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/rsk/ACM/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_SSRHistory: writing to an SSR registry row
|
|
// (ssr/<party>.yaml) puts record-history under ssr/.zddc.d/history/<party>/.
|
|
func TestRecordPut_SSRHistory(t *testing.T) {
|
|
cfg, do := historyTestSetup(t)
|
|
// PUT directly to the registry row (ssr/ has no party_source, so this
|
|
// IS the party-registration write).
|
|
url := "/Project/ssr/0330C1.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())
|
|
}
|
|
|
|
// Record-history lives at ssr/.zddc.d/history/0330C1/ (the row's own dir).
|
|
wanted := filepath.Join(cfg.Root, "Project", "ssr", ".zddc.d", "history", "0330C1")
|
|
if _, err := os.Stat(wanted); err != nil {
|
|
t.Fatalf("expected history at %s; err=%v", wanted, 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)
|
|
// Register the party (rollup create requires it via party_source: ssr).
|
|
regF := filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml")
|
|
if err := os.MkdirAll(filepath.Dir(regF), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(regF, []byte("kind: SSR\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(cfg.Root)
|
|
|
|
// 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(cfg.Root, "Project", "rsk", "0330C1")
|
|
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.
|
|
// TestInDirCreate_RecordComposesAndStampsAudit: "+ Add row" on a
|
|
// per-party mdl table posts to the in-dir form.html create endpoint
|
|
// (serveFormCreate). Convergence requires it to compose the
|
|
// tracking-number filename, fold in the folder-bound originator, and
|
|
// stamp audit fields — i.e. behave like the rollup / PUT, NOT drop a
|
|
// date+email submission file.
|
|
func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) {
|
|
cfg, _ := historyTestSetup(t)
|
|
// originator is omitted on purpose — it's folder-bound to ACM.
|
|
body := `{"project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"Switchgear spec"}`
|
|
rec := doForm(t, cfg, "POST", "/Project/mdl/ACM/form.html", "alice@example.com", body)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
loc := rec.Result().Header.Get("Location")
|
|
if !strings.Contains(loc, "ACM-PRJ-EL-SPC-0001.yaml") {
|
|
t.Errorf("location=%q want composed ACM-PRJ-EL-SPC-0001.yaml (not a date+email name)", loc)
|
|
}
|
|
abs := filepath.Join(cfg.Root, "Project", "mdl", "ACM", "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 (folder-bound)", out["originator"])
|
|
}
|
|
if out["created_by"] != "alice@example.com" || out["revision"] != 1 {
|
|
t.Errorf("audit not stamped on in-dir create: %+v", out)
|
|
}
|
|
}
|
|
|
|
// TestInDirUpdate_RecordStampsAuditAndRejectsRename: the in-dir
|
|
// form.html update endpoint (serveFormUpdate) must route records
|
|
// through WriteWithHistory — incrementing revision and refusing an
|
|
// in-place tracking-number change (identity is the filename).
|
|
func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
|
cfg, do := historyTestSetup(t)
|
|
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml"
|
|
seed := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n")
|
|
if rec := do(http.MethodPut, url, "alice@example.com", seed, nil); rec.Code != http.StatusCreated {
|
|
t.Fatalf("seed status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Same components, new title → revision bumps to 2 (proves the form
|
|
// update went through WriteWithHistory, not a plain WriteAtomic).
|
|
upd := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"V2"}`
|
|
rec := doForm(t, cfg, "POST", "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", upd)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
disk, _ := os.ReadFile(filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml"))
|
|
out := map[string]any{}
|
|
if err := yaml.Unmarshal(disk, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out["revision"] != 2 {
|
|
t.Errorf("revision=%v want 2 (form update must route through WriteWithHistory)", out["revision"])
|
|
}
|
|
if out["updated_by"] != "bob@example.com" {
|
|
t.Errorf("updated_by=%v want bob", out["updated_by"])
|
|
}
|
|
|
|
// Editing a tracking-number component in place → 422 (composed name
|
|
// would differ from the file's name).
|
|
rename := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0099","title":"V3"}`
|
|
rec = doForm(t, cfg, "POST", "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", rename)
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("expected 422 for in-place component edit, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|