test(handler,cmd): update suites for flat-peer layout
Repoint handler + dispatch tests to the top-level peer layout: register
parties via ssr/<party>.yaml where party_source gates writes; move
workspace paths out from under archive (incoming/working/staging/reviewing
+ mdl/rsk are top-level, archive/<party>/{received,issued} stay WORM);
rewrite SSR create (writes ssr/<party>.yaml, no archive folder) + SSR
rename (registry-only); accept-transmittal source incoming/<party>/<txn>;
plan-review scaffolds top-level reviewing/staging; tablehandler
classifyVirtualTableDir recognizes <project>/<peer>/<party> (depth-3) for
per-party mdl/rsk tables. Full Go suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
150da9d186
commit
bee36c2ee9
12 changed files with 220 additions and 224 deletions
|
|
@ -145,7 +145,7 @@ func TestDispatchAppsResolution(t *testing.T) {
|
||||||
}
|
}
|
||||||
// Create folder convention dirs so classifier/browse/transmittal
|
// Create folder convention dirs so classifier/browse/transmittal
|
||||||
// availability rules pass for the test paths used below.
|
// availability rules pass for the test paths used below.
|
||||||
mustMkdir(t, filepath.Join(root, "Project-A", "archive", "Acme", "working"))
|
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
|
||||||
|
|
||||||
idx, err := archive.BuildIndex(root)
|
idx, err := archive.BuildIndex(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -208,9 +208,9 @@ func TestDispatchAppsResolution(t *testing.T) {
|
||||||
t.Errorf("/classifier.html at root: status=%d, want 404 (not in per-party working/staging/incoming)", rec5.Code)
|
t.Errorf("/classifier.html at root: status=%d, want 404 (not in per-party working/staging/incoming)", rec5.Code)
|
||||||
}
|
}
|
||||||
rec6 := httptest.NewRecorder()
|
rec6 := httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/Working/classifier.html", nil))
|
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/classifier.html", nil))
|
||||||
if rec6.Code != http.StatusOK {
|
if rec6.Code != http.StatusOK {
|
||||||
t.Errorf("/Project-A/archive/Acme/Working/classifier.html: status=%d, want 200", rec6.Code)
|
t.Errorf("/Project-A/working/Acme/classifier.html: status=%d, want 200", rec6.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +276,10 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||||
"acl:\n permissions:\n \"*@example.com\": rwcd\n")
|
"acl:\n permissions:\n \"*@example.com\": rwcd\n")
|
||||||
mustMkdir(t, filepath.Join(root, "Project-A", "archive", "Acme", "working"))
|
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
|
||||||
|
// Register the party (party_source: ssr).
|
||||||
|
mustMkdir(t, filepath.Join(root, "Project-A", "ssr"))
|
||||||
|
mustWrite(t, filepath.Join(root, "Project-A", "ssr", "Acme.yaml"), "kind: SSR\n")
|
||||||
|
|
||||||
idx, err := archive.BuildIndex(root)
|
idx, err := archive.BuildIndex(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -300,7 +303,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
// archive/<party>/working/ — the project-level working/ aggregator is
|
// archive/<party>/working/ — the project-level working/ aggregator is
|
||||||
// virtual (see TestFileAPI_MkdirInAggregatorRejected).
|
// virtual (see TestFileAPI_MkdirInAggregatorRejected).
|
||||||
body := []byte("note body")
|
body := []byte("note body")
|
||||||
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/archive/Acme/working/note.md", strings.NewReader(string(body))), "alice@example.com")
|
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/working/Acme/note.md", strings.NewReader(string(body))), "alice@example.com")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
|
|
@ -308,7 +311,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET it back.
|
// GET it back.
|
||||||
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/working/note.md", nil), "alice@example.com")
|
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
||||||
|
|
@ -316,9 +319,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MOVE it.
|
// MOVE it.
|
||||||
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/archive/Acme/working/note.md", nil), "alice@example.com")
|
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
|
||||||
req.Header.Set("X-ZDDC-Op", "move")
|
req.Header.Set("X-ZDDC-Op", "move")
|
||||||
req.Header.Set("X-ZDDC-Destination", "/Project-A/archive/Acme/working/renamed.md")
|
req.Header.Set("X-ZDDC-Destination", "/Project-A/working/Acme/renamed.md")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|
@ -326,7 +329,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE it.
|
// DELETE it.
|
||||||
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/archive/Acme/working/renamed.md", nil), "alice@example.com")
|
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/working/Acme/renamed.md", nil), "alice@example.com")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusNoContent {
|
if rec.Code != http.StatusNoContent {
|
||||||
|
|
@ -670,17 +673,16 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// No-trailing-slash form on a canonical folder → default app.
|
// No-trailing-slash form on a canonical peer → its default app.
|
||||||
// Under the reshape, the project-root staging/reviewing/working
|
// In the flat-peer layout these are physical peers: working/reviewing
|
||||||
// URLs are folder-nav virtuals served by browse (the per-party
|
// default to browse, staging to transmittal, archive to the archive
|
||||||
// transmittal default lives at archive/<party>/staging/). archive/
|
// tool.
|
||||||
// is still the archive tool.
|
|
||||||
noSlashDefaultApp := []struct {
|
noSlashDefaultApp := []struct {
|
||||||
stage string
|
stage string
|
||||||
expect string // substring that should appear in the response body
|
expect string // substring that should appear in the response body
|
||||||
}{
|
}{
|
||||||
{"working", "ZDDC Browse"},
|
{"working", "ZDDC Browse"},
|
||||||
{"staging", "ZDDC Browse"},
|
{"staging", "ZDDC Transmittal"},
|
||||||
{"archive", "ZDDC Archive"},
|
{"archive", "ZDDC Archive"},
|
||||||
{"reviewing", "ZDDC Browse"},
|
{"reviewing", "ZDDC Browse"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,15 @@ func acceptSetup(t *testing.T) (config.Config, func(target, email string, body [
|
||||||
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||||
"admins:\n - alice@example.com\n"+
|
"admins:\n - alice@example.com\n"+
|
||||||
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
||||||
for _, d := range []string{"Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation"} {
|
for _, d := range []string{"Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation"} {
|
||||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||||
t.Fatalf("mkdir %s: %v", d, err)
|
t.Fatalf("mkdir %s: %v", d, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Register the party (party_source: ssr) so filing isn't 409'd.
|
||||||
|
mustWriteHelper(t, filepath.Join(root, "Project-1/ssr/Acme.yaml"), "kind: SSR\n")
|
||||||
// Seed two conforming files inside the transmittal folder.
|
// Seed two conforming files inside the transmittal folder.
|
||||||
transmittalDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")
|
transmittalDir := filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation")
|
||||||
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Foundation.pdf"), "%PDF-")
|
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Foundation.pdf"), "%PDF-")
|
||||||
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Cover Letter.pdf"), "%PDF-")
|
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Cover Letter.pdf"), "%PDF-")
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
@ -64,7 +66,7 @@ func acceptSetup(t *testing.T) (config.Config, func(target, email string, body [
|
||||||
// from incoming/ to received/, renamed to tracking-only.
|
// from incoming/ to received/, renamed to tracking-only.
|
||||||
func TestAccept_FreshAcceptance(t *testing.T) {
|
func TestAccept_FreshAcceptance(t *testing.T) {
|
||||||
_, do, root := acceptSetup(t)
|
_, do, root := acceptSetup(t)
|
||||||
target := "/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/"
|
target := "/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/"
|
||||||
rec := do(target, "alice@example.com", nil)
|
rec := do(target, "alice@example.com", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -87,7 +89,7 @@ func TestAccept_FreshAcceptance(t *testing.T) {
|
||||||
t.Errorf("primary file not moved into received/: %v", err)
|
t.Errorf("primary file not moved into received/: %v", err)
|
||||||
}
|
}
|
||||||
// Source should no longer exist.
|
// Source should no longer exist.
|
||||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation")); !os.IsNotExist(err) {
|
||||||
t.Errorf("source folder still present after rename")
|
t.Errorf("source folder still present after rename")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,8 +100,8 @@ func TestAccept_FreshAcceptance(t *testing.T) {
|
||||||
func TestAccept_NonConformingFilename(t *testing.T) {
|
func TestAccept_NonConformingFilename(t *testing.T) {
|
||||||
_, do, root := acceptSetup(t)
|
_, do, root := acceptSetup(t)
|
||||||
// Drop a bad file alongside the good ones.
|
// Drop a bad file alongside the good ones.
|
||||||
mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/random-notes.txt"), "oops")
|
mustWriteHelper(t, filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/random-notes.txt"), "oops")
|
||||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
rec := do("/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusConflict {
|
||||||
t.Fatalf("status=%d, want 409; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, want 409; body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +109,7 @@ func TestAccept_NonConformingFilename(t *testing.T) {
|
||||||
t.Errorf("error body should name the violating file; got %s", rec.Body.String())
|
t.Errorf("error body should name the violating file; got %s", rec.Body.String())
|
||||||
}
|
}
|
||||||
// Source untouched.
|
// Source untouched.
|
||||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); err != nil {
|
if _, err := os.Stat(filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation")); err != nil {
|
||||||
t.Errorf("source folder removed despite rejection: %v", err)
|
t.Errorf("source folder removed despite rejection: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,11 +119,11 @@ func TestAccept_NonConformingFilename(t *testing.T) {
|
||||||
// outer shape but the folder grammar fails).
|
// outer shape but the folder grammar fails).
|
||||||
func TestAccept_NonConformingFolderName(t *testing.T) {
|
func TestAccept_NonConformingFolderName(t *testing.T) {
|
||||||
_, do, root := acceptSetup(t)
|
_, do, root := acceptSetup(t)
|
||||||
badDir := filepath.Join(root, "Project-1/archive/Acme/incoming/bad-folder-name")
|
badDir := filepath.Join(root, "Project-1/incoming/Acme/bad-folder-name")
|
||||||
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
rec := do("/Project-1/archive/Acme/incoming/bad-folder-name/", "alice@example.com", nil)
|
rec := do("/Project-1/incoming/Acme/bad-folder-name/", "alice@example.com", nil)
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +141,7 @@ func TestAccept_PlanReviewChain(t *testing.T) {
|
||||||
"plan_response_date: 2026-06-15",
|
"plan_response_date: 2026-06-15",
|
||||||
"",
|
"",
|
||||||
}, "\n"))
|
}, "\n"))
|
||||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", body)
|
rec := do("/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", body)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -164,18 +166,18 @@ func TestAccept_PlanReviewChain(t *testing.T) {
|
||||||
// folder. Re-using a filename is rejected by WORM.
|
// folder. Re-using a filename is rejected by WORM.
|
||||||
func TestAccept_Merge(t *testing.T) {
|
func TestAccept_Merge(t *testing.T) {
|
||||||
_, do, root := acceptSetup(t)
|
_, do, root := acceptSetup(t)
|
||||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
rec := do("/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("first accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("first accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Build a second transmittal folder with the same tracking but a
|
// Build a second transmittal folder with the same tracking but a
|
||||||
// distinct rev so the filenames don't collide.
|
// distinct rev so the filenames don't collide.
|
||||||
secondDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup")
|
secondDir := filepath.Join(root, "Project-1/incoming/Acme/2026-06-01_Acme-0042 (RFI) - Followup")
|
||||||
if err := os.MkdirAll(secondDir, 0o755); err != nil {
|
if err := os.MkdirAll(secondDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mustWriteHelper(t, filepath.Join(secondDir, "Acme-0042_B (RFI) - Foundation.pdf"), "%PDF-")
|
mustWriteHelper(t, filepath.Join(secondDir, "Acme-0042_B (RFI) - Foundation.pdf"), "%PDF-")
|
||||||
rec = do("/Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup/", "alice@example.com", nil)
|
rec = do("/Project-1/incoming/Acme/2026-06-01_Acme-0042 (RFI) - Followup/", "alice@example.com", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("second accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("second accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,12 @@ import (
|
||||||
//
|
//
|
||||||
// - admin@example.com — root super-admin
|
// - admin@example.com — root super-admin
|
||||||
// - alice@example.com — subtree admin of Project-1/archive/Acme/working
|
// - alice@example.com — subtree admin of Project-1/archive/Acme/working
|
||||||
// (via per-dir .zddc admins:) — used to test
|
// (via per-dir .zddc admins:) — used to test
|
||||||
// subtree scope
|
// subtree scope
|
||||||
// - bob@example.com — document_controller role member (gets WORM cr
|
// - bob@example.com — document_controller role member (gets WORM cr
|
||||||
// on received/ + issued/ via cascade defaults)
|
// on received/ + issued/ via cascade defaults)
|
||||||
// - eve@example.com — non-admin, project_team only (read-only across
|
// - eve@example.com — non-admin, project_team only (read-only across
|
||||||
// the project per defaults)
|
// the project per defaults)
|
||||||
//
|
//
|
||||||
// Plus one file each in working/, issued/, received/ so we can exercise
|
// Plus one file each in working/, issued/, received/ so we can exercise
|
||||||
// reads + writes across the cascade.
|
// reads + writes across the cascade.
|
||||||
|
|
@ -57,6 +57,11 @@ func invariantsFixture(t *testing.T) (config.Config, string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register the party (party_source: ssr) so writes under the
|
||||||
|
// party_source peers aren't rejected before the WORM/admin checks
|
||||||
|
// these invariants actually exercise.
|
||||||
|
mustWriteHelper(t, filepath.Join(root, "Project-1/ssr/Acme.yaml"), "kind: SSR\n")
|
||||||
|
|
||||||
// Subtree-admin grant: alice administers Project-1/archive/Acme/working/.
|
// Subtree-admin grant: alice administers Project-1/archive/Acme/working/.
|
||||||
mustWriteHelper(t,
|
mustWriteHelper(t,
|
||||||
filepath.Join(root, "Project-1/archive/Acme/working/.zddc"),
|
filepath.Join(root, "Project-1/archive/Acme/working/.zddc"),
|
||||||
|
|
@ -294,12 +299,12 @@ func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
|
||||||
// Targets:
|
// Targets:
|
||||||
// - /.zddc — root file (root admins: govern)
|
// - /.zddc — root file (root admins: govern)
|
||||||
// - /Project-1/.zddc — project file (no on-disk .zddc;
|
// - /Project-1/.zddc — project file (no on-disk .zddc;
|
||||||
// write must materialise it; root
|
// write must materialise it; root
|
||||||
// admins still govern via cascade)
|
// admins still govern via cascade)
|
||||||
// - /Project-1/archive/Acme/working/.zddc — subtree file; alice administers
|
// - /Project-1/archive/Acme/working/.zddc — subtree file; alice administers
|
||||||
// this subtree via its own admins:
|
// this subtree via its own admins:
|
||||||
// list (so alice's write doesn't
|
// list (so alice's write doesn't
|
||||||
// require root-admin authority).
|
// require root-admin authority).
|
||||||
//
|
//
|
||||||
// Expected status: 200 or 201 on success; 403 on denial; 404 only when
|
// Expected status: 200 or 201 on success; 403 on denial; 404 only when
|
||||||
// resolveTargetPath rejects the path (e.g. empty email gets 403 from
|
// resolveTargetPath rejects the path (e.g. empty email gets 403 from
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,15 @@ func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg
|
||||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||||
t.Fatalf("mkdir %s: %v", d, err)
|
t.Fatalf("mkdir %s: %v", d, err)
|
||||||
}
|
}
|
||||||
|
// Auto-register any party implied by a pre-created peer path
|
||||||
|
// (<project>/<peer>/<party>/… or <project>/archive/<party>/…) so
|
||||||
|
// the party_source gate doesn't 409 before the test's real
|
||||||
|
// assertion. party_source: ssr → registry entry ssr/<party>.yaml.
|
||||||
|
if segs := strings.Split(filepath.ToSlash(d), "/"); len(segs) >= 3 {
|
||||||
|
ssrFile := filepath.Join(root, segs[0], "ssr", segs[2]+".yaml")
|
||||||
|
_ = os.MkdirAll(filepath.Dir(ssrFile), 0o755)
|
||||||
|
_ = os.WriteFile(ssrFile, []byte("kind: SSR\n"), 0o644)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for rel, body := range seed {
|
for rel, body := range seed {
|
||||||
full := filepath.Join(root, rel)
|
full := filepath.Join(root, rel)
|
||||||
|
|
@ -357,15 +366,15 @@ func TestFileAPI_MkdirCreates(t *testing.T) {
|
||||||
// Project-root mkdir is restricted to archive/ + system names
|
// Project-root mkdir is restricted to archive/ + system names
|
||||||
// after the layout reshape; test mkdir at a depth where the
|
// after the layout reshape; test mkdir at a depth where the
|
||||||
// guard doesn't fire (under archive/<party>/incoming/).
|
// guard doesn't fire (under archive/<party>/incoming/).
|
||||||
_, do, root := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming"}, nil)
|
_, do, root := fileAPITestSetup(t, []string{"Proj/incoming/Acme"}, nil)
|
||||||
|
|
||||||
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/newfolder/", "alice@example.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Proj/incoming/Acme/newfolder/", "alice@example.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
info, err := os.Stat(filepath.Join(root, "Proj/archive/Acme/incoming/newfolder"))
|
info, err := os.Stat(filepath.Join(root, "Proj/incoming/Acme/newfolder"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stat: %v", err)
|
t.Fatalf("stat: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -375,8 +384,8 @@ func TestFileAPI_MkdirCreates(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
||||||
_, do, _ := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming/exists"}, nil)
|
_, do, _ := fileAPITestSetup(t, []string{"Proj/incoming/Acme/exists"}, nil)
|
||||||
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/exists/", "alice@example.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Proj/incoming/Acme/exists/", "alice@example.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|
@ -385,9 +394,8 @@ func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFileAPI_MkdirProjectRootGuard — direct mkdir at <project>/<name>/
|
// TestFileAPI_MkdirProjectRootGuard — direct mkdir at <project>/<name>/
|
||||||
// is restricted: archive/ and system names (_/.-prefix) are allowed,
|
// is restricted to the canonical peers + system names (_/.-prefix); any
|
||||||
// any other name (including the six virtual aggregator names) is
|
// other name is rejected with 409.
|
||||||
// rejected with 409.
|
|
||||||
func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
|
func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
|
||||||
_, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil)
|
_, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil)
|
||||||
// Reject ad-hoc name.
|
// Reject ad-hoc name.
|
||||||
|
|
@ -397,22 +405,15 @@ func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusConflict {
|
||||||
t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Reject each virtual aggregator name.
|
// Allow each canonical peer name.
|
||||||
for _, name := range []string{"ssr", "mdl", "rsk", "working", "staging", "reviewing"} {
|
for _, name := range []string{"archive", "ssr", "mdl", "rsk", "working", "staging", "reviewing", "incoming"} {
|
||||||
rec := do(http.MethodPost, "/Proj/"+name+"/", "alice@example.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Proj/"+name+"/", "alice@example.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||||
t.Fatalf("%s: want 409, got %d: %s", name, rec.Code, rec.Body.String())
|
t.Fatalf("%s: want 201/200 (canonical peer), got %d: %s", name, rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Allow archive/.
|
|
||||||
rec = do(http.MethodPost, "/Proj/archive/", "alice@example.com", nil, map[string]string{
|
|
||||||
"X-ZDDC-Op": "mkdir",
|
|
||||||
})
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("want 201 for /Proj/archive/, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
// `_`/`.`-prefixed system names are caught earlier (resolveTargetPath
|
// `_`/`.`-prefixed system names are caught earlier (resolveTargetPath
|
||||||
// rejects them as reserved path segments with 404 — see fileapi.go
|
// rejects them as reserved path segments with 404 — see fileapi.go
|
||||||
// resolveTargetPath); the mkdir guard would also allow them, so the
|
// resolveTargetPath); the mkdir guard would also allow them, so the
|
||||||
|
|
@ -477,7 +478,7 @@ func TestFileAPI_AnonymousDenied(t *testing.T) {
|
||||||
// roles defined at root.
|
// roles defined at root.
|
||||||
//
|
//
|
||||||
// The project is "Project-X"; the counterparty is "Acme". URLs target
|
// The project is "Project-X"; the counterparty is "Acme". URLs target
|
||||||
// paths like /Project-X/archive/Acme/incoming/<file>.
|
// paths like /Project-X/incoming/Acme/<file>.
|
||||||
//
|
//
|
||||||
// Returns the same do() helper as fileAPITestSetup.
|
// Returns the same do() helper as fileAPITestSetup.
|
||||||
func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
|
func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
|
||||||
|
|
@ -503,21 +504,28 @@ acl:
|
||||||
t.Fatalf("root .zddc: %v", err)
|
t.Fatalf("root .zddc: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project + per-party canonical layout.
|
// Register the party (party_source: ssr) — its existence gates the peers.
|
||||||
partyDir := filepath.Join(root, "Project-X", "archive", "Acme")
|
if err := os.MkdirAll(filepath.Join(root, "Project-X", "ssr"), 0o755); err != nil {
|
||||||
for _, sub := range []string{"incoming", "issued", "received"} {
|
t.Fatalf("mkdir ssr: %v", err)
|
||||||
if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil {
|
|
||||||
t.Fatalf("mkdir party/%s: %v", sub, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
partyZ := []byte(`acl:
|
if err := os.WriteFile(filepath.Join(root, "Project-X", "ssr", "Acme.yaml"), []byte("kind: SSR\n"), 0o644); err != nil {
|
||||||
permissions:
|
t.Fatalf("register party: %v", err)
|
||||||
vendor_acme: rwcd
|
}
|
||||||
_doc_controller: rwcda
|
// The counterparty's inbound drop zone is a top-level peer now; grant
|
||||||
_company: ""
|
// the vendor rwcd there (the DC would set this on incoming/<party>/.zddc).
|
||||||
`)
|
incomingDir := filepath.Join(root, "Project-X", "incoming", "Acme")
|
||||||
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil {
|
if err := os.MkdirAll(incomingDir, 0o755); err != nil {
|
||||||
t.Fatalf("party .zddc: %v", err)
|
t.Fatalf("mkdir incoming/Acme: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(incomingDir, ".zddc"),
|
||||||
|
[]byte("acl:\n permissions:\n vendor_acme: rwcd\n _doc_controller: rwcda\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("incoming .zddc: %v", err)
|
||||||
|
}
|
||||||
|
// The committed record (WORM) stays under archive/<party>/.
|
||||||
|
for _, sub := range []string{"issued", "received"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Project-X", "archive", "Acme", sub), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir archive/Acme/%s: %v", sub, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
@ -554,12 +562,12 @@ func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
|
||||||
_, do, _ := rolePermissionsTestSetup(t)
|
_, do, _ := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Vendor PUTs into their incoming → 201.
|
// Vendor PUTs into their incoming → 201.
|
||||||
rec := do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
rec := do(http.MethodPut, "/Project-X/incoming/Acme/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Vendor overwrites the same file → 200 (rwcd has w).
|
// Vendor overwrites the same file → 200 (rwcd has w).
|
||||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
rec = do(http.MethodPut, "/Project-X/incoming/Acme/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code)
|
t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
@ -658,14 +666,14 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
||||||
|
|
||||||
// Vendor creates a folder under their incoming. Server should
|
// Vendor creates a folder under their incoming. Server should
|
||||||
// auto-write a .zddc granting them rwcda on the new subtree.
|
// auto-write a .zddc granting them rwcda on the new subtree.
|
||||||
rec := do(http.MethodPost, "/Project-X/archive/Acme/incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Project-X/incoming/Acme/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
autoZ := filepath.Join(root, "Project-X/archive/Acme/incoming/2026-05-15-issue/.zddc")
|
autoZ := filepath.Join(root, "Project-X/incoming/Acme/2026-05-15-issue/.zddc")
|
||||||
data, err := os.ReadFile(autoZ)
|
data, err := os.ReadFile(autoZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("auto .zddc not written: %v", err)
|
t.Fatalf("auto .zddc not written: %v", err)
|
||||||
|
|
@ -684,7 +692,7 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
||||||
// now PUT a brand-new file inside their owned folder where they
|
// now PUT a brand-new file inside their owned folder where they
|
||||||
// otherwise wouldn't have ACL admin rights.
|
// otherwise wouldn't have ACL admin rights.
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
|
rec = do(http.MethodPut, "/Project-X/incoming/Acme/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -716,39 +724,37 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// (The pre-reshape staging↔working mirror was retired: with staging at
|
// party_source gating: creating a party folder under a workspace peer
|
||||||
// archive/<party>/staging/<batch>/ and working at archive/<party>/
|
// 409s until the party is registered (ssr/<party>.yaml exists), then
|
||||||
// working/<email>/, the project-level pairing no longer maps cleanly.
|
// succeeds.
|
||||||
// Tests for the removed behaviour have been deleted.)
|
func TestFileAPI_PartySourceGate(t *testing.T) {
|
||||||
|
|
||||||
// Mkdir INSIDE a project-level virtual aggregator is 409'd with a
|
|
||||||
// pointer at the party-scoped path, instead of silently materialising an
|
|
||||||
// unreachable shadow. The same folder created under archive/<party>/
|
|
||||||
// <slot>/ succeeds — which is what browse's party picker targets.
|
|
||||||
func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) {
|
|
||||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||||
|
|
||||||
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
// Unregistered party → 409 under each party_source peer.
|
||||||
rec := do(http.MethodPost, "/Proj/"+slot+"/foo/", "alice@example.com", nil, map[string]string{
|
for _, peer := range []string{"working", "staging", "reviewing", "incoming", "mdl", "rsk"} {
|
||||||
|
rec := do(http.MethodPost, "/Proj/"+peer+"/Acme/", "alice@example.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusConflict {
|
||||||
t.Errorf("%s: mkdir in aggregator: want 409, got %d: %s", slot, rec.Code, rec.Body.String())
|
t.Errorf("%s: mkdir for unregistered party: want 409, got %d: %s", peer, rec.Code, rec.Body.String())
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("%s: aggregator slot must not be materialised; got err=%v", slot, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The party-scoped path the picker resolves to works.
|
// Register the party (ssr/ has no party_source — a plain create).
|
||||||
rec := do(http.MethodPost, "/Proj/archive/Acme/working/drafts/", "alice@example.com", nil, map[string]string{
|
rec := do(http.MethodPut, "/Proj/ssr/Acme.yaml", "alice@example.com", []byte("kind: SSR\n"), nil)
|
||||||
|
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("register party via ssr/: want 201/200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the workspace folder can be created.
|
||||||
|
rec = do(http.MethodPost, "/Proj/working/Acme/drafts/", "alice@example.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("party-scoped mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("registered-party mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "Acme", "working", "drafts")); err != nil {
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "Acme", "drafts")); err != nil {
|
||||||
t.Errorf("party-scoped folder not created: %v", err)
|
t.Errorf("workspace folder not created: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,26 +173,26 @@ func TestRecognizeFormRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
||||||
|
|
||||||
// Empty form / create at archive/<party>/mdl/form.html — no spec
|
// Empty form / create at archive/<party>/mdl/form.html — no spec
|
||||||
// on disk, no mdl/ dir on disk, default-MDL fallback applies.
|
// on disk, no mdl/ dir on disk, default-MDL fallback applies.
|
||||||
got := RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/form.html")
|
got := RecognizeFormRequest(root, "GET", "/Project/mdl/PartyA/form.html")
|
||||||
if got == nil || got.Kind != "render-empty" {
|
if got == nil || got.Kind != "render-empty" {
|
||||||
t.Fatalf("GET mdl/form.html: got %+v want render-empty via default-MDL fallback", got)
|
t.Fatalf("GET mdl/form.html: got %+v want render-empty via default-MDL fallback", got)
|
||||||
}
|
}
|
||||||
if got.SpecPath != filepath.Join(root, "Project", "archive", "PartyA", "mdl", "form.yaml") {
|
if got.SpecPath != filepath.Join(root, "Project", "mdl", "PartyA", "form.yaml") {
|
||||||
t.Errorf("SpecPath = %q", got.SpecPath)
|
t.Errorf("SpecPath = %q", got.SpecPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST → create.
|
// POST → create.
|
||||||
got = RecognizeFormRequest(root, "POST", "/Project/archive/PartyA/mdl/form.html")
|
got = RecognizeFormRequest(root, "POST", "/Project/mdl/PartyA/form.html")
|
||||||
if got == nil || got.Kind != "create" {
|
if got == nil || got.Kind != "create" {
|
||||||
t.Fatalf("POST mdl/form.html: got %+v want create", got)
|
t.Fatalf("POST mdl/form.html: got %+v want create", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-edit (<id>.yaml.html) at archive/<party>/mdl/ — same default
|
// Re-edit (<id>.yaml.html) at mdl/<party>/ — same default spec
|
||||||
// spec applies. The data file itself must exist on disk; the spec
|
// applies. The data file itself must exist on disk; the spec is the
|
||||||
// is the embedded default in the same directory.
|
// embedded default in the same directory.
|
||||||
mustMkdir(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl"))
|
mustMkdir(t, filepath.Join(root, "Project", "mdl", "PartyA"))
|
||||||
mustWrite(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl", "row-001.yaml"), "trackingNumber: TR-001\n")
|
mustWrite(t, filepath.Join(root, "Project", "mdl", "PartyA", "row-001.yaml"), "trackingNumber: TR-001\n")
|
||||||
got = RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/row-001.yaml.html")
|
got = RecognizeFormRequest(root, "GET", "/Project/mdl/PartyA/row-001.yaml.html")
|
||||||
if got == nil || got.Kind != "render-edit" {
|
if got == nil || got.Kind != "render-edit" {
|
||||||
t.Fatalf("GET row-001.yaml.html: got %+v want render-edit", got)
|
t.Fatalf("GET row-001.yaml.html: got %+v want render-edit", got)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,18 @@ func historyTestSetup(t *testing.T) (config.Config, func(method, target, email s
|
||||||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
zddc.InvalidateCache(root)
|
||||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||||
|
|
||||||
|
|
@ -64,7 +76,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
||||||
// Build a body with the right components for the embedded
|
// Build a body with the right components for the embedded
|
||||||
// mdl rule's filename_format.
|
// mdl rule's filename_format.
|
||||||
body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n")
|
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"
|
url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml"
|
||||||
|
|
||||||
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
|
|
@ -93,7 +105,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// On-disk file matches the response body.
|
// On-disk file matches the response body.
|
||||||
abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
|
abs := filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||||
disk, err := os.ReadFile(abs)
|
disk, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read disk: %v", err)
|
t.Fatalf("read disk: %v", err)
|
||||||
|
|
@ -103,7 +115,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// No history dir yet (create only).
|
// No history dir yet (create only).
|
||||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history")
|
histDir := filepath.Join(cfg.Root, "Project", "mdl", "ACM", ".zddc.d", "history")
|
||||||
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
||||||
t.Errorf(".history/ should not exist after create-only; got err=%v", err)
|
t.Errorf(".history/ should not exist after create-only; got err=%v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +126,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) {
|
||||||
// chains previous_sha, and increments revision.
|
// chains previous_sha, and increments revision.
|
||||||
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
||||||
cfg, do := historyTestSetup(t)
|
cfg, do := historyTestSetup(t)
|
||||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
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")
|
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)
|
rec := do(http.MethodPut, url, "alice@example.com", body1, nil)
|
||||||
|
|
@ -149,7 +161,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes).
|
// .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes).
|
||||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history", "ACM-PRJ-EL-SPC-0001")
|
histDir := filepath.Join(cfg.Root, "Project", "mdl", "ACM", ".zddc.d", "history", "ACM-PRJ-EL-SPC-0001")
|
||||||
ents, err := os.ReadDir(histDir)
|
ents, err := os.ReadDir(histDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read history dir: %v", err)
|
t.Fatalf("read history dir: %v", err)
|
||||||
|
|
@ -171,7 +183,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) {
|
||||||
// write anything — no history entry, no overwrite.
|
// write anything — no history entry, no overwrite.
|
||||||
func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
|
func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
|
||||||
cfg, do := historyTestSetup(t)
|
cfg, do := historyTestSetup(t)
|
||||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
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")
|
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 {
|
if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated {
|
||||||
|
|
@ -186,7 +198,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
|
||||||
t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
histDir := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", ".zddc.d", "history")
|
histDir := filepath.Join(cfg.Root, "Project", "mdl", "ACM", ".zddc.d", "history")
|
||||||
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
if _, err := os.Stat(histDir); !os.IsNotExist(err) {
|
||||||
t.Errorf("history dir should not exist after 412 conflict; got err=%v", err)
|
t.Errorf("history dir should not exist after 412 conflict; got err=%v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +208,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) {
|
||||||
// audit fields → server silently strips and overwrites them.
|
// audit fields → server silently strips and overwrites them.
|
||||||
func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
|
func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
|
||||||
_, do := historyTestSetup(t)
|
_, do := historyTestSetup(t)
|
||||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
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" +
|
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")
|
"created_by: eve@evil.com\nupdated_by: eve@evil.com\nrevision: 999\n")
|
||||||
|
|
@ -221,7 +233,7 @@ func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) {
|
||||||
func TestRecordPut_FilenameMismatch(t *testing.T) {
|
func TestRecordPut_FilenameMismatch(t *testing.T) {
|
||||||
_, do := historyTestSetup(t)
|
_, do := historyTestSetup(t)
|
||||||
// URL claims sequence=0002 but body says 0001 → mismatch.
|
// URL claims sequence=0002 but body says 0001 → mismatch.
|
||||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0002.yaml"
|
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")
|
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)
|
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
|
@ -240,7 +252,7 @@ func TestAugmentSchema_OriginatorReadOnlyAndPrefilled(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
gateDir := filepath.Join(root, "Project", "archive", "ACM", "mdl")
|
gateDir := filepath.Join(root, "Project", "mdl", "ACM")
|
||||||
if err := os.MkdirAll(gateDir, 0o755); err != nil {
|
if err := os.MkdirAll(gateDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -276,13 +288,13 @@ func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) {
|
||||||
// Body claims originator=WRONG; the party folder is ACM. The URL
|
// Body claims originator=WRONG; the party folder is ACM. The URL
|
||||||
// filename correctly uses the folder name, so the server overwrites
|
// filename correctly uses the folder name, so the server overwrites
|
||||||
// the body field and the write succeeds.
|
// the body field and the write succeeds.
|
||||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
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")
|
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)
|
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
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")
|
abs := filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||||
disk, err := os.ReadFile(abs)
|
disk, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read disk: %v", err)
|
t.Fatalf("read disk: %v", err)
|
||||||
|
|
@ -297,7 +309,7 @@ func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) {
|
||||||
|
|
||||||
// A URL whose filename uses a different originator than the folder
|
// A URL whose filename uses a different originator than the folder
|
||||||
// can't be composed to match — 422 filename mismatch.
|
// can't be composed to match — 422 filename mismatch.
|
||||||
badURL := "/Project/archive/ACM/mdl/WRONG-PRJ-EL-SPC-0002.yaml"
|
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")
|
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)
|
rec = do(http.MethodPut, badURL, "alice@example.com", badBody, nil)
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
|
@ -310,7 +322,7 @@ func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) {
|
||||||
// path=/type.
|
// path=/type.
|
||||||
func TestRecordPut_LockedFieldRejected(t *testing.T) {
|
func TestRecordPut_LockedFieldRejected(t *testing.T) {
|
||||||
_, do := historyTestSetup(t)
|
_, do := historyTestSetup(t)
|
||||||
url := "/Project/archive/ACM/rsk/ACM-PRJ-EL-RSK-0001-001.yaml"
|
url := "/Project/rsk/ACM/ACM-PRJ-EL-RSK-0001-001.yaml"
|
||||||
// Client tries type=SPC even though rsk/ locks type=RSK.
|
// 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")
|
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)
|
rec := do(http.MethodPut, url, "alice@example.com", body, nil)
|
||||||
|
|
@ -335,21 +347,13 @@ func TestRecordPut_LockedFieldRejected(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRecordPut_SSRHistoryAtPartyLevel: writing to an SSR row's
|
// TestRecordPut_SSRHistory: writing to an SSR registry row
|
||||||
// canonical archive/<party>/ssr.yaml puts history at
|
// (ssr/<party>.yaml) puts record-history under ssr/.zddc.d/history/<party>/.
|
||||||
// archive/<party>/.history/ssr/, NOT at archive/.history/<party>/.
|
func TestRecordPut_SSRHistory(t *testing.T) {
|
||||||
func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
|
|
||||||
cfg, do := historyTestSetup(t)
|
cfg, do := historyTestSetup(t)
|
||||||
// We bypass the SSR create handler and just PUT directly to the
|
// PUT directly to the registry row (ssr/ has no party_source, so this
|
||||||
// canonical path the SSR rewrites would land on.
|
// IS the party-registration write).
|
||||||
abs := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
|
url := "/Project/ssr/0330C1.yaml"
|
||||||
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")
|
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 {
|
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())
|
t.Fatalf("first put status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -364,15 +368,11 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
|
||||||
t.Fatalf("second put status=%d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("second put status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// History at archive/0330C1/.history/ssr/, NOT at archive/.history/.
|
// Record-history lives at ssr/.zddc.d/history/0330C1/ (the row's own dir).
|
||||||
wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".zddc.d", "history", "ssr")
|
wanted := filepath.Join(cfg.Root, "Project", "ssr", ".zddc.d", "history", "0330C1")
|
||||||
if _, err := os.Stat(wanted); err != nil {
|
if _, err := os.Stat(wanted); err != nil {
|
||||||
t.Fatalf("expected history at %s; err=%v", wanted, err)
|
t.Fatalf("expected history at %s; err=%v", wanted, err)
|
||||||
}
|
}
|
||||||
bad := filepath.Join(cfg.Root, "Project", "archive", ".zddc.d", "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
|
// TestRollupCreate_AssignsRowAndComposesFilename: posting an rsk
|
||||||
|
|
@ -381,11 +381,15 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) {
|
||||||
// row number within the table-scope group.
|
// row number within the table-scope group.
|
||||||
func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
|
func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
|
||||||
cfg, _ := historyTestSetup(t)
|
cfg, _ := historyTestSetup(t)
|
||||||
// Materialize the party folder (rollup create requires it).
|
// Register the party (rollup create requires it via party_source: ssr).
|
||||||
partyAbs := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
|
regF := filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml")
|
||||||
if err := os.MkdirAll(partyAbs, 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(regF), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
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.
|
// First row: table-tracking components + the routing party field.
|
||||||
// originator is omitted — the server derives it from the party
|
// originator is omitted — the server derives it from the party
|
||||||
|
|
@ -423,7 +427,7 @@ func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// All three files contain audit fields (proves WriteWithHistory ran).
|
// All three files contain audit fields (proves WriteWithHistory ran).
|
||||||
rskDir := filepath.Join(partyAbs, "rsk")
|
rskDir := filepath.Join(cfg.Root, "Project", "rsk", "0330C1")
|
||||||
ents, err := os.ReadDir(rskDir)
|
ents, err := os.ReadDir(rskDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -461,7 +465,7 @@ func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) {
|
||||||
cfg, _ := historyTestSetup(t)
|
cfg, _ := historyTestSetup(t)
|
||||||
// originator is omitted on purpose — it's folder-bound to ACM.
|
// originator is omitted on purpose — it's folder-bound to ACM.
|
||||||
body := `{"project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"Switchgear spec"}`
|
body := `{"project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"Switchgear spec"}`
|
||||||
rec := doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/form.html", "alice@example.com", body)
|
rec := doForm(t, cfg, "POST", "/Project/mdl/ACM/form.html", "alice@example.com", body)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -469,7 +473,7 @@ func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) {
|
||||||
if !strings.Contains(loc, "ACM-PRJ-EL-SPC-0001.yaml") {
|
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)
|
t.Errorf("location=%q want composed ACM-PRJ-EL-SPC-0001.yaml (not a date+email name)", loc)
|
||||||
}
|
}
|
||||||
abs := filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml")
|
abs := filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml")
|
||||||
disk, err := os.ReadFile(abs)
|
disk, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read disk: %v", err)
|
t.Fatalf("read disk: %v", err)
|
||||||
|
|
@ -492,7 +496,7 @@ func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) {
|
||||||
// in-place tracking-number change (identity is the filename).
|
// in-place tracking-number change (identity is the filename).
|
||||||
func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
||||||
cfg, do := historyTestSetup(t)
|
cfg, do := historyTestSetup(t)
|
||||||
url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml"
|
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")
|
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 {
|
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())
|
t.Fatalf("seed status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -501,11 +505,11 @@ func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
||||||
// Same components, new title → revision bumps to 2 (proves the form
|
// Same components, new title → revision bumps to 2 (proves the form
|
||||||
// update went through WriteWithHistory, not a plain WriteAtomic).
|
// update went through WriteWithHistory, not a plain WriteAtomic).
|
||||||
upd := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"V2"}`
|
upd := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"V2"}`
|
||||||
rec := doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", upd)
|
rec := doForm(t, cfg, "POST", "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", upd)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
disk, _ := os.ReadFile(filepath.Join(cfg.Root, "Project", "archive", "ACM", "mdl", "ACM-PRJ-EL-SPC-0001.yaml"))
|
disk, _ := os.ReadFile(filepath.Join(cfg.Root, "Project", "mdl", "ACM", "ACM-PRJ-EL-SPC-0001.yaml"))
|
||||||
out := map[string]any{}
|
out := map[string]any{}
|
||||||
if err := yaml.Unmarshal(disk, &out); err != nil {
|
if err := yaml.Unmarshal(disk, &out); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -520,7 +524,7 @@ func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) {
|
||||||
// Editing a tracking-number component in place → 422 (composed name
|
// Editing a tracking-number component in place → 422 (composed name
|
||||||
// would differ from the file's name).
|
// would differ from the file's name).
|
||||||
rename := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0099","title":"V3"}`
|
rename := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0099","title":"V3"}`
|
||||||
rec = doForm(t, cfg, "POST", "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", rename)
|
rec = doForm(t, cfg, "POST", "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml.html", "bob@example.com", rename)
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("expected 422 for in-place component edit, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 422 for in-place component edit, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ func TestPlanReview_Idempotent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm no duplicate folders snuck in.
|
// Confirm no duplicate folders snuck in.
|
||||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
reviewingRoot := filepath.Join(root, "Project-1", "reviewing", "Acme")
|
||||||
entries, err := os.ReadDir(reviewingRoot)
|
entries, err := os.ReadDir(reviewingRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||||
|
|
@ -252,7 +252,7 @@ func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// reviewing/.zddc reflects the new review_lead.
|
// reviewing/.zddc reflects the new review_lead.
|
||||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
reviewingRoot := filepath.Join(root, "Project-1", "reviewing", "Acme")
|
||||||
entries, err := os.ReadDir(reviewingRoot)
|
entries, err := os.ReadDir(reviewingRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||||
|
|
@ -279,7 +279,7 @@ func TestPlanReview_Forbidden(t *testing.T) {
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
reviewingRoot := filepath.Join(root, "Project-1", "reviewing", "Acme")
|
||||||
if _, err := os.Stat(reviewingRoot); err == nil {
|
if _, err := os.Stat(reviewingRoot); err == nil {
|
||||||
// reviewing/ should not have been materialised. The mkdir
|
// reviewing/ should not have been materialised. The mkdir
|
||||||
// happens AFTER the ACL check in the handler, so refusal
|
// happens AFTER the ACL check in the handler, so refusal
|
||||||
|
|
|
||||||
|
|
@ -76,30 +76,22 @@ func TestSSRCreate_HappyPath(t *testing.T) {
|
||||||
if loc := rec.Result().Header.Get("Location"); loc != "/Project/ssr/0330C1.yaml" {
|
if loc := rec.Result().Header.Get("Location"); loc != "/Project/ssr/0330C1.yaml" {
|
||||||
t.Errorf("Location=%q want /Project/ssr/0330C1.yaml", loc)
|
t.Errorf("Location=%q want /Project/ssr/0330C1.yaml", loc)
|
||||||
}
|
}
|
||||||
// archive/0330C1/ exists.
|
// Registration writes the registry row at ssr/<party>.yaml and does
|
||||||
partyDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1")
|
// NOT create an archive party folder (that appears on first filing).
|
||||||
if info, err := os.Stat(partyDir); err != nil || !info.IsDir() {
|
rowAbs := filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml")
|
||||||
t.Fatalf("party folder not created: err=%v", err)
|
yamlBytes, err := os.ReadFile(rowAbs)
|
||||||
}
|
|
||||||
// .zddc auto-own grant.
|
|
||||||
zf, err := os.ReadFile(filepath.Join(partyDir, ".zddc"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read auto-own .zddc: %v", err)
|
t.Fatalf("read ssr/0330C1.yaml: %v", err)
|
||||||
}
|
|
||||||
if !strings.Contains(string(zf), "casey@example.com") {
|
|
||||||
t.Errorf("auto-own .zddc missing creator email; got %s", string(zf))
|
|
||||||
}
|
|
||||||
// ssr.yaml exists and contains the submitted fields but NOT `name`.
|
|
||||||
yamlBytes, err := os.ReadFile(filepath.Join(partyDir, "ssr.yaml"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read ssr.yaml: %v", err)
|
|
||||||
}
|
}
|
||||||
yaml := string(yamlBytes)
|
yaml := string(yamlBytes)
|
||||||
if !strings.Contains(yaml, "contractNo: PO-001") {
|
if !strings.Contains(yaml, "contractNo: PO-001") {
|
||||||
t.Errorf("ssr.yaml missing contractNo; got %s", yaml)
|
t.Errorf("ssr row missing contractNo; got %s", yaml)
|
||||||
}
|
}
|
||||||
if strings.Contains(yaml, "name: 0330C1") {
|
if strings.Contains(yaml, "name: 0330C1") {
|
||||||
t.Errorf("ssr.yaml should not carry path-derived `name` field; got %s", yaml)
|
t.Errorf("ssr row should not carry path-derived `name` field; got %s", yaml)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C1")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("registration must not create archive/<party>/; got err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,16 +138,6 @@ func TestSSRRename_HappyPath(t *testing.T) {
|
||||||
if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil); rec.Code != http.StatusCreated {
|
if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil); rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("setup create failed: %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("setup create failed: %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Drop an MDL row inside the party folder; it should survive the rename.
|
|
||||||
mdlDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1", "mdl")
|
|
||||||
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(mdlDir, "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project", "archive", "0330C1"))
|
|
||||||
|
|
||||||
rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "",
|
rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"X-ZDDC-Op": opSSRRename,
|
"X-ZDDC-Op": opSSRRename,
|
||||||
|
|
@ -164,15 +146,13 @@ func TestSSRRename_HappyPath(t *testing.T) {
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("rename failed: %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("rename failed: %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C1")); !os.IsNotExist(err) {
|
// Registry-only rename: the row moves to the new name; folders under
|
||||||
t.Error("source party folder still exists after rename")
|
// the other peers are intentionally left untouched.
|
||||||
|
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml")); !os.IsNotExist(err) {
|
||||||
|
t.Error("source registry row still exists after rename")
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C2")); err != nil {
|
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "ssr", "0330C2.yaml")); err != nil {
|
||||||
t.Errorf("destination party folder not created: %v", err)
|
t.Errorf("destination registry row not created: %v", err)
|
||||||
}
|
|
||||||
// MDL row followed the directory rename.
|
|
||||||
if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C2", "mdl", "D-001.yaml")); err != nil {
|
|
||||||
t.Errorf("MDL row did not survive rename: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,17 +341,14 @@ func classifyVirtualTableDir(fsRoot, dirAbs string) (string, bool) {
|
||||||
parts := strings.Split(rel, "/")
|
parts := strings.Split(rel, "/")
|
||||||
switch len(parts) {
|
switch len(parts) {
|
||||||
case 2:
|
case 2:
|
||||||
// <project>/<slot>
|
// <project>/<peer> — aggregate ssr/mdl/rsk table.
|
||||||
slot := strings.ToLower(parts[1])
|
slot := strings.ToLower(parts[1])
|
||||||
if slot == "ssr" || slot == "mdl" || slot == "rsk" {
|
if slot == "ssr" || slot == "mdl" || slot == "rsk" {
|
||||||
return slot, true
|
return slot, true
|
||||||
}
|
}
|
||||||
case 4:
|
case 3:
|
||||||
// <project>/archive/<party>/<slot>
|
// <project>/<peer>/<party> — per-party mdl/rsk table.
|
||||||
if !strings.EqualFold(parts[1], "archive") {
|
slot := strings.ToLower(parts[1])
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
slot := strings.ToLower(parts[3])
|
|
||||||
if slot == "mdl" || slot == "rsk" {
|
if slot == "mdl" || slot == "rsk" {
|
||||||
return slot, true
|
return slot, true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,7 @@ func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(m
|
||||||
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
||||||
_, do := archivePartyTestSetup(t, "")
|
_, do := archivePartyTestSetup(t, "")
|
||||||
|
|
||||||
rec := do(http.MethodGet, "/Project/archive/Acme/mdl/table.html", "alice@example.com")
|
rec := do(http.MethodGet, "/Project/mdl/Acme/table.html", "alice@example.com")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +305,7 @@ func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml")
|
bts, ok := IsDefaultSpec(root, "/Project/mdl/Acme/table.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected fallback to fire")
|
t.Fatalf("expected fallback to fire")
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +313,7 @@ func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) {
|
||||||
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
||||||
}
|
}
|
||||||
|
|
||||||
bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/mdl/form.yaml")
|
bts, ok = IsDefaultSpec(root, "/Project/mdl/Acme/form.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected form fallback to fire")
|
t.Fatalf("expected form fallback to fire")
|
||||||
}
|
}
|
||||||
|
|
@ -324,14 +324,14 @@ func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) {
|
||||||
|
|
||||||
func TestIsDefaultSpec_MDL_OperatorFileWins(t *testing.T) {
|
func TestIsDefaultSpec_MDL_OperatorFileWins(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
|
mdlDir := filepath.Join(root, "Project", "mdl", "Acme")
|
||||||
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok {
|
if _, ok := IsDefaultSpec(root, "/Project/mdl/Acme/table.yaml"); ok {
|
||||||
t.Errorf("operator file should win over embedded fallback")
|
t.Errorf("operator file should win over embedded fallback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -340,7 +340,7 @@ func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"/Project/working/mdl/table.yaml",
|
"/Project/working/mdl/table.yaml",
|
||||||
"/Project/archive/mdl/table.yaml", // depth 3 — no party segment
|
"/Project/archive/mdl/table.yaml", // depth 3 — no party segment
|
||||||
"/Project/archive/Acme/sub/mdl/table.yaml",
|
"/Project/archive/Acme/sub/mdl/table.yaml",
|
||||||
}
|
}
|
||||||
for _, p := range cases {
|
for _, p := range cases {
|
||||||
|
|
@ -354,7 +354,7 @@ func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) {
|
||||||
|
|
||||||
func TestRecognizeTableRequest_DefaultRskAtArchiveParty(t *testing.T) {
|
func TestRecognizeTableRequest_DefaultRskAtArchiveParty(t *testing.T) {
|
||||||
_, do := archivePartyTestSetup(t, "")
|
_, do := archivePartyTestSetup(t, "")
|
||||||
rec := do(http.MethodGet, "/Project/archive/Acme/rsk/table.html", "alice@example.com")
|
rec := do(http.MethodGet, "/Project/rsk/Acme/table.html", "alice@example.com")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("default rsk recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("default rsk recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -375,14 +375,14 @@ func TestIsDefaultSpec_RSK_ServesEmbeddedYAML(t *testing.T) {
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/rsk/table.yaml")
|
bts, ok := IsDefaultSpec(root, "/Project/rsk/Acme/table.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected RSK table fallback to fire")
|
t.Fatalf("expected RSK table fallback to fire")
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(bts), "Risk Register") {
|
if !strings.Contains(string(bts), "Risk Register") {
|
||||||
t.Errorf("default RSK table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
t.Errorf("default RSK table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
||||||
}
|
}
|
||||||
bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/rsk/form.yaml")
|
bts, ok = IsDefaultSpec(root, "/Project/rsk/Acme/form.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected RSK form fallback to fire")
|
t.Fatalf("expected RSK form fallback to fire")
|
||||||
}
|
}
|
||||||
|
|
@ -393,13 +393,13 @@ func TestIsDefaultSpec_RSK_ServesEmbeddedYAML(t *testing.T) {
|
||||||
|
|
||||||
func TestIsDefaultSpec_SSR_PerParty(t *testing.T) {
|
func TestIsDefaultSpec_SSR_PerParty(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// archive/<party>/ssr.form.yaml — per-party SSR schema.
|
// ssr/ is the flat registry; its form spec is /Project/ssr/form.yaml.
|
||||||
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/ssr.form.yaml")
|
bts, ok := IsDefaultSpec(root, "/Project/ssr/form.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected per-party SSR schema fallback to fire")
|
t.Fatalf("expected SSR schema fallback to fire")
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(bts), "Supplier") {
|
if !strings.Contains(string(bts), "Supplier") {
|
||||||
t.Errorf("per-party SSR schema missing expected header")
|
t.Errorf("SSR schema missing expected header")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,4 +440,3 @@ func TestIsDefaultSpec_ProjectLevel_OperatorOverrides(t *testing.T) {
|
||||||
t.Errorf("operator file should win at /Project/ssr/table.yaml")
|
t.Errorf("operator file should win at /Project/ssr/table.yaml")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ func TestPutToIssuedAsUnelevatedNonAdminUserDenied(t *testing.T) {
|
||||||
" document_controller:\n members: [alice@example.com, bob@example.com]\n"+
|
" document_controller:\n members: [alice@example.com, bob@example.com]\n"+
|
||||||
" project_team:\n members: [\"*@example.com\", \"*@bitnest.cc\"]\n")
|
" project_team:\n members: [\"*@example.com\", \"*@bitnest.cc\"]\n")
|
||||||
|
|
||||||
|
// Register the party (party_source: ssr) so the write reaches the WORM
|
||||||
|
// check this test exercises rather than the registration gate.
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Project-1/ssr"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir ssr: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "Project-1/ssr/PartyA.yaml"), []byte("kind: SSR\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("register party: %v", err)
|
||||||
|
}
|
||||||
// Materialise the exact path shape from the bitnest log entry.
|
// Materialise the exact path shape from the bitnest log entry.
|
||||||
issuedDir := filepath.Join(root, "Project-1/archive/PartyA/issued/2025-09-21_A-FAC2-PM-DRW-0377 (RSB) - Test")
|
issuedDir := filepath.Join(root, "Project-1/archive/PartyA/issued/2025-09-21_A-FAC2-PM-DRW-0377 (RSB) - Test")
|
||||||
if err := os.MkdirAll(issuedDir, 0o755); err != nil {
|
if err := os.MkdirAll(issuedDir, 0o755); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -341,19 +341,14 @@ func TestServeZddcFile_Effective_RoleMemberUnion(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServeZddcFile_VirtualPerPartyWorking — a deeper path declared
|
// TestServeZddcFile_VirtualWorkingPeer — the working/ peer declared by
|
||||||
// by the embedded defaults (archive/<party>/working/) shows its own
|
// the embedded defaults shows its rich config in the synthesized virtual
|
||||||
// rich subtree: default_tool, available_tools, auto_own, etc.
|
// .zddc: default_tool, available_tools (classifier), party_source, history.
|
||||||
func TestServeZddcFile_VirtualPerPartyWorking(t *testing.T) {
|
func TestServeZddcFile_VirtualWorkingPeer(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
deep := filepath.Join(root, "Project", "archive", "Acme", "working")
|
|
||||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zddc.InvalidateCache(root)
|
|
||||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/Project/archive/Acme/working/.zddc", nil)
|
req := httptest.NewRequest(http.MethodGet, "/Project/working/.zddc", nil)
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeZddcFile(cfg, rec, req)
|
ServeZddcFile(cfg, rec, req)
|
||||||
|
|
@ -364,13 +359,11 @@ func TestServeZddcFile_VirtualPerPartyWorking(t *testing.T) {
|
||||||
body := rec.Body.String()
|
body := rec.Body.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"default_tool: browse", // working/ default_tool
|
"default_tool: browse", // working/ default_tool
|
||||||
"auto_own: true", // working/ creator owns subdirs
|
"party_source: ssr", // party gating
|
||||||
"drop_target: true", // upload zone
|
|
||||||
"classifier", // available_tools includes classifier
|
"classifier", // available_tools includes classifier
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("body missing %q at archive/<party>/working/: %s", want, body)
|
t.Errorf("body missing %q at working/ peer: %s", want, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue