test(handler): coverage for record audit + history flows
Adds history_test.go with eight cases exercising the record-write
orchestration path:
- CreateStampsAuditFields: PUT to a fresh mdl path → audit fields
injected; response echoes the stamped YAML; no history dir yet.
- UpdateIncrementsRevisionAndArchivesPrior: second PUT archives
the prior bytes under .history/<base>/<ts>-<sha8>.yaml, bumps
revision, preserves created_*, chains previous_sha.
- ConflictPreservesHistory: 412 from stale If-Match leaves the live
file untouched and writes NO history entry (the failed write must
be a true no-op).
- ClientAuditFieldsStripped: client-supplied created_by / revision
are silently overwritten by server values — anti-forgery test.
- FilenameMismatch: URL says ...-0002 but body composes to ...-0001
→ 422.
- LockedFieldRejected: posting type=SPC to an rsk row → 422 with
/type error (rsk/ locks type=RSK via cascade).
- SSRHistoryAtPartyLevel: writes to archive/<party>/ssr.yaml put
history at archive/<party>/.history/ssr/, NOT at
archive/.history/<party>/.
- RollupCreate_AssignsRowAndComposesFilename: three POSTs to
/project/rsk/form.html in two table-scope groups demonstrate the
server picks up filename_format + row_field+row_scope_fields from
the cascade, auto-assigns sequence row numbers per group, and
composes the canonical filename.
Bug fix surfaced by the first test: composeFilename was eliding TWO
separators around an optional placeholder when one was correct.
"ACM-{phase?}-PRJ" with phase="" was producing "ACMPRJ" instead of
"ACM-PRJ". Now drops only the trailing separator from output and
lets the next iteration emit the connector.
Default-project-{mdl,rsk}.form.yaml updated: project-rollup MDL +
RSK schemas gained the six readOnly audit fields and the project-
rsk schema picked up the full table-tracking component shape (+
row) plus an enum-locked type=RSK. The required: list no longer
includes type for rsk schemas — the cascade's field_defaults
injects it after schema validation, and requiring it would 422
well-behaved clients.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
83c3b332d5
commit
3b2280de7f
5 changed files with 508 additions and 13 deletions
|
|
@ -86,6 +86,37 @@ schema:
|
|||
notes:
|
||||
type: string
|
||||
title: Notes
|
||||
|
||||
# --- Audit fields (server-managed; read-only).
|
||||
created_at:
|
||||
type: string
|
||||
title: Created
|
||||
format: date-time
|
||||
readOnly: true
|
||||
created_by:
|
||||
type: string
|
||||
title: Created by
|
||||
format: email
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
title: Updated
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_by:
|
||||
type: string
|
||||
title: Updated by
|
||||
format: email
|
||||
readOnly: true
|
||||
revision:
|
||||
type: integer
|
||||
title: Revision
|
||||
minimum: 1
|
||||
readOnly: true
|
||||
previous_sha:
|
||||
type: string
|
||||
title: Previous SHA
|
||||
readOnly: true
|
||||
ui:
|
||||
notes:
|
||||
ui:widget: textarea
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ description: One risk across all parties. The first field (Package) routes the r
|
|||
|
||||
schema:
|
||||
type: object
|
||||
required: [party, id, title]
|
||||
# `type` is intentionally absent from required: — the cascade's
|
||||
# field_defaults inject type=RSK after schema validation, and the
|
||||
# form renderer surfaces it as a locked readOnly field. Requiring
|
||||
# it here would 422 well-behaved clients that omit the cascade-
|
||||
# owned field.
|
||||
required: [party, originator, project, discipline, sequence, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
party:
|
||||
|
|
@ -26,11 +31,46 @@ schema:
|
|||
description: Routing key — must match an existing <project>/archive/<party>/ folder.
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
|
||||
minLength: 1
|
||||
id:
|
||||
|
||||
# --- Table-tracking components (same shape as the per-party rsk
|
||||
# schema). Together with `row` they compose the filename.
|
||||
originator:
|
||||
type: string
|
||||
title: ID
|
||||
description: Stable identifier, e.g. R-001.
|
||||
title: Originator
|
||||
minLength: 1
|
||||
phase:
|
||||
type: string
|
||||
title: Phase
|
||||
project:
|
||||
type: string
|
||||
title: Project
|
||||
minLength: 1
|
||||
area:
|
||||
type: string
|
||||
title: Area
|
||||
discipline:
|
||||
type: string
|
||||
title: Discipline
|
||||
minLength: 1
|
||||
type:
|
||||
type: string
|
||||
title: Document type
|
||||
description: Locked to RSK by the cascade; the form renders this read-only.
|
||||
enum: [RSK]
|
||||
sequence:
|
||||
type: string
|
||||
title: Sequence
|
||||
minLength: 1
|
||||
suffix:
|
||||
type: string
|
||||
title: Suffix
|
||||
row:
|
||||
type: string
|
||||
title: Row
|
||||
description: Zero-padded sequence within the parent register. Server-assigned.
|
||||
readOnly: true
|
||||
|
||||
# --- Risk-level data.
|
||||
title:
|
||||
type: string
|
||||
title: Risk
|
||||
|
|
@ -73,6 +113,37 @@ schema:
|
|||
notes:
|
||||
type: string
|
||||
title: Notes
|
||||
|
||||
# --- Audit fields (server-managed; read-only).
|
||||
created_at:
|
||||
type: string
|
||||
title: Created
|
||||
format: date-time
|
||||
readOnly: true
|
||||
created_by:
|
||||
type: string
|
||||
title: Created by
|
||||
format: email
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
title: Updated
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_by:
|
||||
type: string
|
||||
title: Updated by
|
||||
format: email
|
||||
readOnly: true
|
||||
revision:
|
||||
type: integer
|
||||
title: Revision
|
||||
minimum: 1
|
||||
readOnly: true
|
||||
previous_sha:
|
||||
type: string
|
||||
title: Previous SHA
|
||||
readOnly: true
|
||||
ui:
|
||||
description:
|
||||
ui:widget: textarea
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ description: One identified risk. The first eight fields together identify the p
|
|||
|
||||
schema:
|
||||
type: object
|
||||
required: [originator, project, discipline, type, sequence, title]
|
||||
# `type` is intentionally absent from required: — the cascade's
|
||||
# field_defaults inject type=RSK after schema validation, and the
|
||||
# form renderer surfaces it as a locked readOnly field.
|
||||
required: [originator, project, discipline, sequence, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
# --- Table-tracking components: identify which RSK deliverable
|
||||
|
|
|
|||
|
|
@ -369,19 +369,18 @@ func composeFilename(format string, body map[string]any) (string, error) {
|
|||
if !optional {
|
||||
return "", fmt.Errorf("filename_format: required field %q is missing or empty", name)
|
||||
}
|
||||
// Drop the trailing separator we just wrote, if any:
|
||||
// avoids "A-B-" or "A--C" when an optional middle
|
||||
// segment elides.
|
||||
// Drop the trailing separator we just wrote, if any.
|
||||
// For "A-{b?}-C" with b empty we want "A-C": dropping
|
||||
// the preceding '-' here, then letting the next
|
||||
// iteration emit the trailing '-' from the format, is
|
||||
// exactly one connector between A and C. (Earlier
|
||||
// versions of this code also skipped the leading
|
||||
// separator, which double-elided.)
|
||||
s := out.String()
|
||||
if n := len(s); n > 0 && (s[n-1] == '-' || s[n-1] == '_') {
|
||||
out.Reset()
|
||||
out.WriteString(s[:n-1])
|
||||
}
|
||||
// And skip a leading separator that immediately
|
||||
// follows the elided field.
|
||||
if i < len(format) && (format[i] == '-' || format[i] == '_') {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
out.WriteString(val)
|
||||
|
|
|
|||
391
zddc/internal/handler/history_test.go
Normal file
391
zddc/internal/handler/history_test.go
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
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/Acme/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", "Acme", "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", "Acme", "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/<base>/,
|
||||
// chains previous_sha, and increments revision.
|
||||
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
||||
cfg, do := historyTestSetup(t)
|
||||
url := "/Project/archive/Acme/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", "Acme", "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/Acme/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", "Acme", "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/Acme/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/Acme/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())
|
||||
}
|
||||
}
|
||||
|
||||
// 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/Acme/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/<party>/ssr.yaml puts history at
|
||||
// archive/<party>/.history/ssr/, NOT at archive/.history/<party>/.
|
||||
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: full table-tracking components + the routing party
|
||||
// field. Server should pick row=001.
|
||||
body1 := `{"party":"0330C1","originator":"ACM","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, "ACM-PRJ-EL-RSK-0001-001.yaml") {
|
||||
t.Errorf("first row location=%q want ...-RSK-0001-001.yaml", loc)
|
||||
}
|
||||
|
||||
// Second row in the same table: row=002.
|
||||
body2 := `{"party":"0330C1","originator":"ACM","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, "ACM-PRJ-EL-RSK-0001-002.yaml") {
|
||||
t.Errorf("second row location=%q want ...-RSK-0001-002.yaml", loc)
|
||||
}
|
||||
|
||||
// Different table-scope (sequence=0002) restarts at row=001.
|
||||
body3 := `{"party":"0330C1","originator":"ACM","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, "ACM-PRJ-EL-RSK-0002-001.yaml") {
|
||||
t.Errorf("third row (new scope) location=%q want ...-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
|
||||
}
|
||||
Loading…
Reference in a new issue