From bee36c2ee90bf0755003cebb5466175387b74538 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 3 Jun 2026 12:15:56 -0500 Subject: [PATCH] test(handler,cmd): update suites for flat-peer layout Repoint handler + dispatch tests to the top-level peer layout: register parties via ssr/.yaml where party_source gates writes; move workspace paths out from under archive (incoming/working/staging/reviewing + mdl/rsk are top-level, archive//{received,issued} stay WORM); rewrite SSR create (writes ssr/.yaml, no archive folder) + SSR rename (registry-only); accept-transmittal source incoming//; plan-review scaffolds top-level reviewing/staging; tablehandler classifyVirtualTableDir recognizes // (depth-3) for per-party mdl/rsk tables. Full Go suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/cmd/zddc-server/main_test.go | 32 ++--- zddc/internal/handler/accepthandler_test.go | 28 ++-- zddc/internal/handler/auth_invariants_test.go | 23 ++-- zddc/internal/handler/fileapi_test.go | 126 +++++++++--------- zddc/internal/handler/formhandler_test.go | 18 +-- zddc/internal/handler/history_test.go | 92 +++++++------ zddc/internal/handler/planreview_test.go | 6 +- zddc/internal/handler/ssrhandler_test.go | 52 +++----- zddc/internal/handler/tablehandler.go | 11 +- zddc/internal/handler/tablehandler_test.go | 27 ++-- zddc/internal/handler/wormbypass_test.go | 8 ++ zddc/internal/handler/zddcfile_test.go | 21 +-- 12 files changed, 220 insertions(+), 224 deletions(-) diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index acb55ed..94d7603 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -145,7 +145,7 @@ func TestDispatchAppsResolution(t *testing.T) { } // Create folder convention dirs so classifier/browse/transmittal // 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) 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) } 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 { - 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() mustWrite(t, filepath.Join(root, ".zddc"), "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) if err != nil { @@ -300,7 +303,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { // archive//working/ — the project-level working/ aggregator is // virtual (see TestFileAPI_MkdirInAggregatorRejected). 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() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusCreated { @@ -308,7 +311,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // 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() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusOK || rec.Body.String() != string(body) { @@ -316,9 +319,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // 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-Destination", "/Project-A/archive/Acme/working/renamed.md") + req.Header.Set("X-ZDDC-Destination", "/Project-A/working/Acme/renamed.md") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusOK { @@ -326,7 +329,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // 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() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusNoContent { @@ -670,17 +673,16 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) { }) } - // No-trailing-slash form on a canonical folder → default app. - // Under the reshape, the project-root staging/reviewing/working - // URLs are folder-nav virtuals served by browse (the per-party - // transmittal default lives at archive//staging/). archive/ - // is still the archive tool. + // No-trailing-slash form on a canonical peer → its default app. + // In the flat-peer layout these are physical peers: working/reviewing + // default to browse, staging to transmittal, archive to the archive + // tool. noSlashDefaultApp := []struct { stage string expect string // substring that should appear in the response body }{ {"working", "ZDDC Browse"}, - {"staging", "ZDDC Browse"}, + {"staging", "ZDDC Transmittal"}, {"archive", "ZDDC Archive"}, {"reviewing", "ZDDC Browse"}, } diff --git a/zddc/internal/handler/accepthandler_test.go b/zddc/internal/handler/accepthandler_test.go index 75a4b94..ed868c9 100644 --- a/zddc/internal/handler/accepthandler_test.go +++ b/zddc/internal/handler/accepthandler_test.go @@ -25,13 +25,15 @@ func acceptSetup(t *testing.T) (config.Config, func(target, email string, body [ mustWriteHelper(t, filepath.Join(root, ".zddc"), "admins:\n - 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 { 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. - 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) - Cover Letter.pdf"), "%PDF-") 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. func TestAccept_FreshAcceptance(t *testing.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) if rec.Code != http.StatusOK { 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) } // 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") } } @@ -98,8 +100,8 @@ func TestAccept_FreshAcceptance(t *testing.T) { func TestAccept_NonConformingFilename(t *testing.T) { _, do, root := acceptSetup(t) // 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") - rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil) + mustWriteHelper(t, filepath.Join(root, "Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/random-notes.txt"), "oops") + rec := do("/Project-1/incoming/Acme/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil) if rec.Code != http.StatusConflict { 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()) } // 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) } } @@ -117,11 +119,11 @@ func TestAccept_NonConformingFilename(t *testing.T) { // outer shape but the folder grammar fails). func TestAccept_NonConformingFolderName(t *testing.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 { 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 { 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", "", }, "\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 { 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. func TestAccept_Merge(t *testing.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 { 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 // 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 { t.Fatal(err) } 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 { t.Fatalf("second accept status=%d, want 200; body=%s", rec.Code, rec.Body.String()) } diff --git a/zddc/internal/handler/auth_invariants_test.go b/zddc/internal/handler/auth_invariants_test.go index 7197824..7f3651c 100644 --- a/zddc/internal/handler/auth_invariants_test.go +++ b/zddc/internal/handler/auth_invariants_test.go @@ -28,12 +28,12 @@ import ( // // - admin@example.com — root super-admin // - alice@example.com — subtree admin of Project-1/archive/Acme/working -// (via per-dir .zddc admins:) — used to test -// subtree scope +// (via per-dir .zddc admins:) — used to test +// subtree scope // - 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 -// the project per defaults) +// the project per defaults) // // Plus one file each in working/, issued/, received/ so we can exercise // 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/. mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/working/.zddc"), @@ -294,12 +299,12 @@ func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) { // Targets: // - /.zddc — root file (root admins: govern) // - /Project-1/.zddc — project file (no on-disk .zddc; -// write must materialise it; root -// admins still govern via cascade) +// write must materialise it; root +// admins still govern via cascade) // - /Project-1/archive/Acme/working/.zddc — subtree file; alice administers -// this subtree via its own admins: -// list (so alice's write doesn't -// require root-admin authority). +// this subtree via its own admins: +// list (so alice's write doesn't +// require root-admin authority). // // Expected status: 200 or 201 on success; 403 on denial; 404 only when // resolveTargetPath rejects the path (e.g. empty email gets 403 from diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index 07c11af..71bbb8e 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -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 { t.Fatalf("mkdir %s: %v", d, err) } + // Auto-register any party implied by a pre-created peer path + // (///… or /archive//…) so + // the party_source gate doesn't 409 before the test's real + // assertion. party_source: ssr → registry entry ssr/.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 { full := filepath.Join(root, rel) @@ -357,15 +366,15 @@ func TestFileAPI_MkdirCreates(t *testing.T) { // Project-root mkdir is restricted to archive/ + system names // after the layout reshape; test mkdir at a depth where the // guard doesn't fire (under archive//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", }) if rec.Code != http.StatusCreated { 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 { t.Fatalf("stat: %v", err) } @@ -375,8 +384,8 @@ func TestFileAPI_MkdirCreates(t *testing.T) { } func TestFileAPI_MkdirIdempotent(t *testing.T) { - _, do, _ := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming/exists"}, nil) - rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/exists/", "alice@example.com", nil, map[string]string{ + _, do, _ := fileAPITestSetup(t, []string{"Proj/incoming/Acme/exists"}, nil) + rec := do(http.MethodPost, "/Proj/incoming/Acme/exists/", "alice@example.com", nil, map[string]string{ "X-ZDDC-Op": "mkdir", }) if rec.Code != http.StatusOK { @@ -385,9 +394,8 @@ func TestFileAPI_MkdirIdempotent(t *testing.T) { } // TestFileAPI_MkdirProjectRootGuard — direct mkdir at // -// is restricted: archive/ and system names (_/.-prefix) are allowed, -// any other name (including the six virtual aggregator names) is -// rejected with 409. +// is restricted to the canonical peers + system names (_/.-prefix); any +// other name is rejected with 409. func TestFileAPI_MkdirProjectRootGuard(t *testing.T) { _, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil) // Reject ad-hoc name. @@ -397,22 +405,15 @@ func TestFileAPI_MkdirProjectRootGuard(t *testing.T) { if rec.Code != http.StatusConflict { t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String()) } - // Reject each virtual aggregator name. - for _, name := range []string{"ssr", "mdl", "rsk", "working", "staging", "reviewing"} { + // Allow each canonical peer name. + 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{ "X-ZDDC-Op": "mkdir", }) - if rec.Code != http.StatusConflict { - t.Fatalf("%s: want 409, got %d: %s", name, rec.Code, rec.Body.String()) + if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { + 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 // rejects them as reserved path segments with 404 — see fileapi.go // resolveTargetPath); the mkdir guard would also allow them, so the @@ -477,7 +478,7 @@ func TestFileAPI_AnonymousDenied(t *testing.T) { // roles defined at root. // // The project is "Project-X"; the counterparty is "Acme". URLs target -// paths like /Project-X/archive/Acme/incoming/. +// paths like /Project-X/incoming/Acme/. // // 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) { @@ -503,21 +504,28 @@ acl: t.Fatalf("root .zddc: %v", err) } - // Project + per-party canonical layout. - partyDir := filepath.Join(root, "Project-X", "archive", "Acme") - for _, sub := range []string{"incoming", "issued", "received"} { - if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil { - t.Fatalf("mkdir party/%s: %v", sub, err) - } + // Register the party (party_source: ssr) — its existence gates the peers. + if err := os.MkdirAll(filepath.Join(root, "Project-X", "ssr"), 0o755); err != nil { + t.Fatalf("mkdir ssr: %v", err) } - partyZ := []byte(`acl: - permissions: - vendor_acme: rwcd - _doc_controller: rwcda - _company: "" -`) - if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil { - t.Fatalf("party .zddc: %v", err) + if err := os.WriteFile(filepath.Join(root, "Project-X", "ssr", "Acme.yaml"), []byte("kind: SSR\n"), 0o644); err != nil { + t.Fatalf("register party: %v", err) + } + // The counterparty's inbound drop zone is a top-level peer now; grant + // the vendor rwcd there (the DC would set this on incoming//.zddc). + incomingDir := filepath.Join(root, "Project-X", "incoming", "Acme") + if err := os.MkdirAll(incomingDir, 0o755); err != nil { + 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//. + 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) @@ -554,12 +562,12 @@ func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) { _, do, _ := rolePermissionsTestSetup(t) // 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 { t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String()) } // 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 { 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 // 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", }) if rec.Code != http.StatusCreated { 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) if err != nil { 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 // otherwise wouldn't have ACL admin rights. 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 { 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 -// archive//staging// and working at archive// -// working//, the project-level pairing no longer maps cleanly. -// Tests for the removed behaviour have been deleted.) - -// 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// -// / succeeds — which is what browse's party picker targets. -func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) { +// party_source gating: creating a party folder under a workspace peer +// 409s until the party is registered (ssr/.yaml exists), then +// succeeds. +func TestFileAPI_PartySourceGate(t *testing.T) { _, do, root := fileAPITestSetup(t, nil, nil) - for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} { - rec := do(http.MethodPost, "/Proj/"+slot+"/foo/", "alice@example.com", nil, map[string]string{ + // Unregistered party → 409 under each party_source peer. + 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", }) if rec.Code != http.StatusConflict { - t.Errorf("%s: mkdir in aggregator: want 409, got %d: %s", slot, 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) + t.Errorf("%s: mkdir for unregistered party: want 409, got %d: %s", peer, rec.Code, rec.Body.String()) } } - // The party-scoped path the picker resolves to works. - rec := do(http.MethodPost, "/Proj/archive/Acme/working/drafts/", "alice@example.com", nil, map[string]string{ + // Register the party (ssr/ has no party_source — a plain create). + 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", }) 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 { - t.Errorf("party-scoped folder not created: %v", err) + if _, err := os.Stat(filepath.Join(root, "Proj", "working", "Acme", "drafts")); err != nil { + t.Errorf("workspace folder not created: %v", err) } } diff --git a/zddc/internal/handler/formhandler_test.go b/zddc/internal/handler/formhandler_test.go index c382689..f496b40 100644 --- a/zddc/internal/handler/formhandler_test.go +++ b/zddc/internal/handler/formhandler_test.go @@ -173,26 +173,26 @@ func TestRecognizeFormRequest_DefaultMdlAtArchiveParty(t *testing.T) { // Empty form / create at archive//mdl/form.html — no spec // 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" { 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) } // 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" { t.Fatalf("POST mdl/form.html: got %+v want create", got) } - // Re-edit (.yaml.html) at archive//mdl/ — same default - // spec applies. The data file itself must exist on disk; the spec - // is the embedded default in the same directory. - mustMkdir(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl")) - mustWrite(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl", "row-001.yaml"), "trackingNumber: TR-001\n") - got = RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/row-001.yaml.html") + // Re-edit (.yaml.html) at mdl// — same default spec + // applies. The data file itself must exist on disk; the spec is the + // embedded default in the same directory. + mustMkdir(t, filepath.Join(root, "Project", "mdl", "PartyA")) + mustWrite(t, filepath.Join(root, "Project", "mdl", "PartyA", "row-001.yaml"), "trackingNumber: TR-001\n") + got = RecognizeFormRequest(root, "GET", "/Project/mdl/PartyA/row-001.yaml.html") if got == nil || got.Kind != "render-edit" { t.Fatalf("GET row-001.yaml.html: got %+v want render-edit", got) } diff --git a/zddc/internal/handler/history_test.go b/zddc/internal/handler/history_test.go index dde9313..585464c 100644 --- a/zddc/internal/handler/history_test.go +++ b/zddc/internal/handler/history_test.go @@ -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 { t.Fatal(err) } + // Register the party the mdl/rsk tests use (party_source: ssr). The + // SSR-history test registers its own party (0330C1) by creating the + // ssr row, so it's intentionally left out here. + { + f := filepath.Join(root, "Project", "ssr", "ACM.yaml") + if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f, []byte("kind: SSR\n"), 0o644); err != nil { + t.Fatal(err) + } + } zddc.InvalidateCache(root) cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} @@ -64,7 +76,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) { // Build a body with the right components for the embedded // mdl rule's filename_format. body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n") - url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml" + url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml" rec := do(http.MethodPut, url, "alice@example.com", body, nil) if rec.Code != http.StatusCreated { @@ -93,7 +105,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) { } // 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) if err != nil { t.Fatalf("read disk: %v", err) @@ -103,7 +115,7 @@ func TestRecordPut_CreateStampsAuditFields(t *testing.T) { } // 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) { 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. func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.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") 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). - 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) if err != nil { t.Fatalf("read history dir: %v", err) @@ -171,7 +183,7 @@ func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) { // write anything — no history entry, no overwrite. func TestRecordPut_ConflictPreservesHistory(t *testing.T) { cfg, do := historyTestSetup(t) - url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml" + url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml" body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n") if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated { @@ -186,7 +198,7 @@ func TestRecordPut_ConflictPreservesHistory(t *testing.T) { 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) { 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. func TestRecordPut_ClientAuditFieldsStripped(t *testing.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" + "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) { _, do := historyTestSetup(t) // 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") rec := do(http.MethodPut, url, "alice@example.com", body, nil) if rec.Code != http.StatusUnprocessableEntity { @@ -240,7 +252,7 @@ func TestAugmentSchema_OriginatorReadOnlyAndPrefilled(t *testing.T) { t.Fatal(err) } 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 { t.Fatal(err) } @@ -276,13 +288,13 @@ func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) { // Body claims originator=WRONG; the party folder is ACM. The URL // filename correctly uses the folder name, so the server overwrites // the body field and the write succeeds. - url := "/Project/archive/ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml" + url := "/Project/mdl/ACM/ACM-PRJ-EL-SPC-0001.yaml" body := []byte("originator: WRONG\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n") rec := do(http.MethodPut, url, "alice@example.com", body, nil) if rec.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } - abs := filepath.Join(cfg.Root, "Project", "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) if err != nil { 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 // 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") rec = do(http.MethodPut, badURL, "alice@example.com", badBody, nil) if rec.Code != http.StatusUnprocessableEntity { @@ -310,7 +322,7 @@ func TestRecordPut_OriginatorBoundToPartyFolder(t *testing.T) { // path=/type. func TestRecordPut_LockedFieldRejected(t *testing.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. 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) @@ -335,21 +347,13 @@ func TestRecordPut_LockedFieldRejected(t *testing.T) { } } -// TestRecordPut_SSRHistoryAtPartyLevel: writing to an SSR row's -// canonical archive//ssr.yaml puts history at -// archive//.history/ssr/, NOT at archive/.history//. -func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) { +// TestRecordPut_SSRHistory: writing to an SSR registry row +// (ssr/.yaml) puts record-history under ssr/.zddc.d/history//. +func TestRecordPut_SSRHistory(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" + // PUT directly to the registry row (ssr/ has no party_source, so this + // IS the party-registration write). + url := "/Project/ssr/0330C1.yaml" body := []byte("kind: SSR\nvendorType: subcontractor\ncontractNo: PO-001\nscopeSummary: Concrete\n") if rec := do(http.MethodPut, url, "alice@example.com", body, nil); rec.Code != http.StatusCreated { t.Fatalf("first put status=%d body=%s", rec.Code, rec.Body.String()) @@ -364,15 +368,11 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) { 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", ".zddc.d", "history", "ssr") + // Record-history lives at ssr/.zddc.d/history/0330C1/ (the row's own dir). + wanted := filepath.Join(cfg.Root, "Project", "ssr", ".zddc.d", "history", "0330C1") if _, err := os.Stat(wanted); err != nil { t.Fatalf("expected history at %s; err=%v", wanted, err) } - 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 @@ -381,11 +381,15 @@ func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) { // 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 { + // Register the party (rollup create requires it via party_source: ssr). + regF := filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml") + if err := os.MkdirAll(filepath.Dir(regF), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(regF, []byte("kind: SSR\n"), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(cfg.Root) // First row: table-tracking components + the routing party field. // originator is omitted — the server derives it from the party @@ -423,7 +427,7 @@ func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) { } // 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) if err != nil { t.Fatal(err) @@ -461,7 +465,7 @@ func TestInDirCreate_RecordComposesAndStampsAudit(t *testing.T) { cfg, _ := historyTestSetup(t) // originator is omitted on purpose — it's folder-bound to ACM. body := `{"project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"Switchgear spec"}` - rec := doForm(t, cfg, "POST", "/Project/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 { 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") { 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) if err != nil { 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). func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.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") 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()) @@ -501,11 +505,11 @@ func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) { // Same components, new title → revision bumps to 2 (proves the form // update went through WriteWithHistory, not a plain WriteAtomic). upd := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0001","title":"V2"}` - rec := doForm(t, cfg, "POST", "/Project/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 { 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{} if err := yaml.Unmarshal(disk, &out); err != nil { t.Fatal(err) @@ -520,7 +524,7 @@ func TestInDirUpdate_RecordStampsAuditAndRejectsRename(t *testing.T) { // Editing a tracking-number component in place → 422 (composed name // would differ from the file's name). rename := `{"originator":"ACM","project":"PRJ","discipline":"EL","type":"SPC","sequence":"0099","title":"V3"}` - rec = doForm(t, cfg, "POST", "/Project/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 { t.Fatalf("expected 422 for in-place component edit, got %d body=%s", rec.Code, rec.Body.String()) } diff --git a/zddc/internal/handler/planreview_test.go b/zddc/internal/handler/planreview_test.go index 0ae91e8..96fc1ef 100644 --- a/zddc/internal/handler/planreview_test.go +++ b/zddc/internal/handler/planreview_test.go @@ -205,7 +205,7 @@ func TestPlanReview_Idempotent(t *testing.T) { } // 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) if err != nil { t.Fatalf("read %s: %v", reviewingRoot, err) @@ -252,7 +252,7 @@ func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) { } // 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) if err != nil { t.Fatalf("read %s: %v", reviewingRoot, err) @@ -279,7 +279,7 @@ func TestPlanReview_Forbidden(t *testing.T) { if rec.Code != http.StatusForbidden { 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 { // reviewing/ should not have been materialised. The mkdir // happens AFTER the ACL check in the handler, so refusal diff --git a/zddc/internal/handler/ssrhandler_test.go b/zddc/internal/handler/ssrhandler_test.go index 88039a7..40ecb48 100644 --- a/zddc/internal/handler/ssrhandler_test.go +++ b/zddc/internal/handler/ssrhandler_test.go @@ -76,30 +76,22 @@ func TestSSRCreate_HappyPath(t *testing.T) { if loc := rec.Result().Header.Get("Location"); loc != "/Project/ssr/0330C1.yaml" { t.Errorf("Location=%q want /Project/ssr/0330C1.yaml", loc) } - // archive/0330C1/ exists. - partyDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1") - if info, err := os.Stat(partyDir); err != nil || !info.IsDir() { - t.Fatalf("party folder not created: err=%v", err) - } - // .zddc auto-own grant. - zf, err := os.ReadFile(filepath.Join(partyDir, ".zddc")) + // Registration writes the registry row at ssr/.yaml and does + // NOT create an archive party folder (that appears on first filing). + rowAbs := filepath.Join(cfg.Root, "Project", "ssr", "0330C1.yaml") + yamlBytes, err := os.ReadFile(rowAbs) if err != nil { - t.Fatalf("read auto-own .zddc: %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) + t.Fatalf("read ssr/0330C1.yaml: %v", err) } yaml := string(yamlBytes) 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") { - 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//; 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 { 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", "", map[string]string{ "X-ZDDC-Op": opSSRRename, @@ -164,15 +146,13 @@ func TestSSRRename_HappyPath(t *testing.T) { if rec.Code != http.StatusOK { 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) { - t.Error("source party folder still exists after rename") + // Registry-only rename: the row moves to the new name; folders under + // 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 { - t.Errorf("destination party folder 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) + if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "ssr", "0330C2.yaml")); err != nil { + t.Errorf("destination registry row not created: %v", err) } } diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index 7129420..51c6afb 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -341,17 +341,14 @@ func classifyVirtualTableDir(fsRoot, dirAbs string) (string, bool) { parts := strings.Split(rel, "/") switch len(parts) { case 2: - // / + // / — aggregate ssr/mdl/rsk table. slot := strings.ToLower(parts[1]) if slot == "ssr" || slot == "mdl" || slot == "rsk" { return slot, true } - case 4: - // /archive// - if !strings.EqualFold(parts[1], "archive") { - return "", false - } - slot := strings.ToLower(parts[3]) + case 3: + // // — per-party mdl/rsk table. + slot := strings.ToLower(parts[1]) if slot == "mdl" || slot == "rsk" { return slot, true } diff --git a/zddc/internal/handler/tablehandler_test.go b/zddc/internal/handler/tablehandler_test.go index 0c49685..24afa84 100644 --- a/zddc/internal/handler/tablehandler_test.go +++ b/zddc/internal/handler/tablehandler_test.go @@ -272,7 +272,7 @@ func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(m func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.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 { 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) } - bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml") + bts, ok := IsDefaultSpec(root, "/Project/mdl/Acme/table.yaml") if !ok { 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))]) } - bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/mdl/form.yaml") + bts, ok = IsDefaultSpec(root, "/Project/mdl/Acme/form.yaml") if !ok { 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) { 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 { t.Fatal(err) } if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil { 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") } } @@ -340,7 +340,7 @@ func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) { root := t.TempDir() cases := []string{ "/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", } for _, p := range cases { @@ -354,7 +354,7 @@ func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) { func TestRecognizeTableRequest_DefaultRskAtArchiveParty(t *testing.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 { 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 { t.Fatal(err) } - bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/rsk/table.yaml") + bts, ok := IsDefaultSpec(root, "/Project/rsk/Acme/table.yaml") if !ok { t.Fatalf("expected RSK table fallback to fire") } if !strings.Contains(string(bts), "Risk Register") { 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 { 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) { root := t.TempDir() - // archive//ssr.form.yaml — per-party SSR schema. - bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/ssr.form.yaml") + // ssr/ is the flat registry; its form spec is /Project/ssr/form.yaml. + bts, ok := IsDefaultSpec(root, "/Project/ssr/form.yaml") 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") { - 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") } } - diff --git a/zddc/internal/handler/wormbypass_test.go b/zddc/internal/handler/wormbypass_test.go index 5d81886..ef5c205 100644 --- a/zddc/internal/handler/wormbypass_test.go +++ b/zddc/internal/handler/wormbypass_test.go @@ -34,6 +34,14 @@ func TestPutToIssuedAsUnelevatedNonAdminUserDenied(t *testing.T) { " document_controller:\n members: [alice@example.com, bob@example.com]\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. 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 { diff --git a/zddc/internal/handler/zddcfile_test.go b/zddc/internal/handler/zddcfile_test.go index 0179266..ced8ca9 100644 --- a/zddc/internal/handler/zddcfile_test.go +++ b/zddc/internal/handler/zddcfile_test.go @@ -341,19 +341,14 @@ func TestServeZddcFile_Effective_RoleMemberUnion(t *testing.T) { } } -// TestServeZddcFile_VirtualPerPartyWorking — a deeper path declared -// by the embedded defaults (archive//working/) shows its own -// rich subtree: default_tool, available_tools, auto_own, etc. -func TestServeZddcFile_VirtualPerPartyWorking(t *testing.T) { +// TestServeZddcFile_VirtualWorkingPeer — the working/ peer declared by +// the embedded defaults shows its rich config in the synthesized virtual +// .zddc: default_tool, available_tools (classifier), party_source, history. +func TestServeZddcFile_VirtualWorkingPeer(t *testing.T) { 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"} - 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")) rec := httptest.NewRecorder() ServeZddcFile(cfg, rec, req) @@ -364,13 +359,11 @@ func TestServeZddcFile_VirtualPerPartyWorking(t *testing.T) { body := rec.Body.String() for _, want := range []string{ "default_tool: browse", // working/ default_tool - "auto_own: true", // working/ creator owns subdirs - "drop_target: true", // upload zone + "party_source: ssr", // party gating "classifier", // available_tools includes classifier } { if !strings.Contains(body, want) { - t.Errorf("body missing %q at archive//working/: %s", want, body) + t.Errorf("body missing %q at working/ peer: %s", want, body) } } } -