May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:
ssr/mdl/rsk tables rollups across parties with a
synthesised $party source-party column
working/staging/ browse folder-nav listings of parties with
reviewing non-empty content in the slot; per-party
URLs 302-redirect to archive/<party>/<slot>/
Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.
Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.
document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
322 lines
12 KiB
Go
322 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// planReviewSetup writes a tree shaped like a real ZDDC project with
|
|
// `archive/Acme/received/Acme-0042/` populated and an admin grant for
|
|
// alice@example.com. Returns the cfg, a do() helper that POSTs Plan
|
|
// Review requests, and the root path.
|
|
func planReviewSetup(t *testing.T) (config.Config, func(target, email string, body []byte) *httptest.ResponseRecorder, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
|
|
// Root .zddc grants alice subtree-admin everywhere AND sets the
|
|
// document_controller role so the cascade's reviewing/+staging/
|
|
// admin grants resolve to her. The role membership also confers
|
|
// `c` authority on received/ via the WORM list in the defaults,
|
|
// which Plan Review's pre-flight requires.
|
|
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/received/Acme-0042"} {
|
|
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", d, err)
|
|
}
|
|
}
|
|
// Seed a ZDDC-parseable file so the title derives correctly.
|
|
mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Foundation.pdf"),
|
|
"%PDF-")
|
|
zddc.InvalidateCache(root)
|
|
|
|
cfg := config.Config{
|
|
Root: root,
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
MaxWriteBytes: 64 * 1024,
|
|
}
|
|
|
|
do := func(target, email string, body []byte) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
|
req.Header.Set(headerOp, opPlanReview)
|
|
req.Header.Set("Content-Type", "application/yaml")
|
|
ctx := context.WithValue(req.Context(), EmailKey, email)
|
|
ctx = context.WithValue(ctx, ElevatedKey, true)
|
|
req = req.WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
ServeFileAPI(cfg, rec, req)
|
|
return rec
|
|
}
|
|
return cfg, do, root
|
|
}
|
|
|
|
func mustWriteHelper(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir parent of %s: %v", path, err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func planReviewBody() string {
|
|
return strings.Join([]string{
|
|
"review_lead: bob@vendor.com",
|
|
"approver: carol@example.com",
|
|
"plan_review_complete_date: 2026-05-30",
|
|
"plan_response_date: 2026-06-15",
|
|
}, "\n") + "\n"
|
|
}
|
|
|
|
// TestPlanReview_FreshConvergence runs Plan Review against a tree with
|
|
// no existing workflow folders. Expects both reviewing/ and staging/
|
|
// to be created, each with a .zddc declaring received_path +
|
|
// planned_date, and the response to confirm both were created.
|
|
func TestPlanReview_FreshConvergence(t *testing.T) {
|
|
cfg, do, root := planReviewSetup(t)
|
|
|
|
rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
|
[]byte(planReviewBody()))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp planReviewResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
|
|
}
|
|
if resp.Tracking != "Acme-0042" {
|
|
t.Errorf("Tracking=%q, want Acme-0042", resp.Tracking)
|
|
}
|
|
if !resp.Reviewing.Created || !resp.Reviewing.ZddcWritten {
|
|
t.Errorf("Reviewing not fully converged: %+v", resp.Reviewing)
|
|
}
|
|
if !resp.Staging.Created || !resp.Staging.ZddcWritten {
|
|
t.Errorf("Staging not fully converged: %+v", resp.Staging)
|
|
}
|
|
|
|
// Workflow folders: should carry received_path + ACL only.
|
|
for _, side := range []struct {
|
|
path string
|
|
wantDate string
|
|
actor string
|
|
}{
|
|
{resp.Reviewing.Path, "2026-05-30", "bob@vendor.com"},
|
|
{resp.Staging.Path, "2026-06-15", "carol@example.com"},
|
|
} {
|
|
abs := filepath.Join(root, filepath.FromSlash(strings.Trim(side.path, "/")))
|
|
base := filepath.Base(abs)
|
|
if !strings.HasPrefix(base, side.wantDate) {
|
|
t.Errorf("folder %q does not start with date %q", base, side.wantDate)
|
|
}
|
|
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
|
|
if err != nil {
|
|
t.Fatalf("parse %s/.zddc: %v", abs, err)
|
|
}
|
|
if zf.ReceivedPath != "archive/Acme/received/Acme-0042" {
|
|
t.Errorf("%s: received_path=%q", abs, zf.ReceivedPath)
|
|
}
|
|
// Workflow .zddc must NOT carry planned dates — those live in
|
|
// the canonical received/.zddc and are sealed.
|
|
if zf.PlannedReviewDate != "" || zf.PlannedResponseDate != "" {
|
|
t.Errorf("%s: workflow .zddc must not carry planned dates", abs)
|
|
}
|
|
if v, ok := zf.ACL.Permissions[side.actor]; !ok || v != "rwcda" {
|
|
t.Errorf("%s: ACL[%s]=%q, want rwcda", abs, side.actor, v)
|
|
}
|
|
}
|
|
|
|
// Canonical received/.zddc: planned dates are sealed here.
|
|
zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc"))
|
|
if err != nil {
|
|
t.Fatalf("parse received .zddc: %v", err)
|
|
}
|
|
if zfRecv.PlannedReviewDate != "2026-05-30" {
|
|
t.Errorf("received planned_review_date=%q", zfRecv.PlannedReviewDate)
|
|
}
|
|
if zfRecv.PlannedResponseDate != "2026-06-15" {
|
|
t.Errorf("received planned_response_date=%q", zfRecv.PlannedResponseDate)
|
|
}
|
|
// Constrained schema: no ACL, no admins, no roles, no received_path.
|
|
if len(zfRecv.ACL.Permissions) != 0 || len(zfRecv.Admins) != 0 ||
|
|
len(zfRecv.Roles) != 0 || zfRecv.ReceivedPath != "" {
|
|
t.Errorf("received .zddc has unexpected content: acl=%v admins=%v roles=%v rp=%q",
|
|
zfRecv.ACL.Permissions, zfRecv.Admins, zfRecv.Roles, zfRecv.ReceivedPath)
|
|
}
|
|
if resp.Title != "Foundation" {
|
|
t.Errorf("Title=%q, want Foundation (from received file)", resp.Title)
|
|
}
|
|
_ = cfg
|
|
}
|
|
|
|
// TestPlanReview_Idempotent runs Plan Review twice with the same body;
|
|
// the second run is a no-op (created=false everywhere) and folder/.zddc
|
|
// state is unchanged.
|
|
func TestPlanReview_Idempotent(t *testing.T) {
|
|
_, do, root := planReviewSetup(t)
|
|
|
|
first := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
|
[]byte(planReviewBody()))
|
|
if first.Code != http.StatusOK {
|
|
t.Fatalf("first status=%d; body=%s", first.Code, first.Body.String())
|
|
}
|
|
var firstResp planReviewResponse
|
|
if err := json.Unmarshal(first.Body.Bytes(), &firstResp); err != nil {
|
|
t.Fatalf("decode first: %v", err)
|
|
}
|
|
|
|
second := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
|
[]byte(planReviewBody()))
|
|
if second.Code != http.StatusOK {
|
|
t.Fatalf("second status=%d; body=%s", second.Code, second.Body.String())
|
|
}
|
|
var secondResp planReviewResponse
|
|
if err := json.Unmarshal(second.Body.Bytes(), &secondResp); err != nil {
|
|
t.Fatalf("decode second: %v", err)
|
|
}
|
|
|
|
if secondResp.Reviewing.Created || secondResp.Staging.Created {
|
|
t.Errorf("second run created=true: %+v", secondResp)
|
|
}
|
|
if firstResp.Reviewing.Path != secondResp.Reviewing.Path {
|
|
t.Errorf("reviewing path drifted: %q vs %q",
|
|
firstResp.Reviewing.Path, secondResp.Reviewing.Path)
|
|
}
|
|
if firstResp.Staging.Path != secondResp.Staging.Path {
|
|
t.Errorf("staging path drifted: %q vs %q",
|
|
firstResp.Staging.Path, secondResp.Staging.Path)
|
|
}
|
|
|
|
// Confirm no duplicate folders snuck in.
|
|
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
|
entries, err := os.ReadDir(reviewingRoot)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", reviewingRoot, err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Errorf("reviewing/ has %d entries, want 1", len(entries))
|
|
}
|
|
}
|
|
|
|
// TestPlanReview_ReceivedZddcIsWriteOnce — re-running Plan Review with
|
|
// different planned dates leaves received/.zddc alone (sealed at first
|
|
// run). Workflow folder ACLs can still be re-converged on subsequent
|
|
// runs.
|
|
func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
|
|
_, do, root := planReviewSetup(t)
|
|
|
|
if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
|
[]byte(planReviewBody())); rec.Code != http.StatusOK {
|
|
t.Fatalf("first POST status=%d; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Second run with a different review_lead AND a different planned
|
|
// date. The workflow .zddc should reflect the new actor, but the
|
|
// canonical received/.zddc must keep its original dates.
|
|
updated := strings.Join([]string{
|
|
"review_lead: dave@vendor.com",
|
|
"approver: carol@example.com",
|
|
"plan_review_complete_date: 2099-01-01", // attempted but should be ignored
|
|
"plan_response_date: 2099-01-15",
|
|
}, "\n") + "\n"
|
|
if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
|
[]byte(updated)); rec.Code != http.StatusOK {
|
|
t.Fatalf("second POST status=%d; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// received/.zddc unchanged.
|
|
zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc"))
|
|
if err != nil {
|
|
t.Fatalf("parse received: %v", err)
|
|
}
|
|
if zfRecv.PlannedReviewDate != "2026-05-30" || zfRecv.PlannedResponseDate != "2026-06-15" {
|
|
t.Errorf("received dates drifted: review=%q response=%q",
|
|
zfRecv.PlannedReviewDate, zfRecv.PlannedResponseDate)
|
|
}
|
|
|
|
// reviewing/.zddc reflects the new review_lead.
|
|
reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
|
|
entries, err := os.ReadDir(reviewingRoot)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", reviewingRoot, err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("expected 1 reviewing folder, got %d", len(entries))
|
|
}
|
|
zf, err := zddc.ParseFile(filepath.Join(reviewingRoot, entries[0].Name(), ".zddc"))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if _, ok := zf.ACL.Permissions["dave@vendor.com"]; !ok {
|
|
t.Errorf("reviewing ACL did not switch to dave: %v", zf.ACL.Permissions)
|
|
}
|
|
}
|
|
|
|
// TestPlanReview_Forbidden — a user without admin authority on the
|
|
// workflow roots gets 403 and no folders are created.
|
|
func TestPlanReview_Forbidden(t *testing.T) {
|
|
_, do, root := planReviewSetup(t)
|
|
|
|
rec := do("/Project-1/archive/Acme/received/Acme-0042/", "stranger@vendor.com",
|
|
[]byte(planReviewBody()))
|
|
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")
|
|
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
|
|
// guarantees no state change.
|
|
entries, _ := os.ReadDir(reviewingRoot)
|
|
if len(entries) > 0 {
|
|
t.Errorf("reviewing/ created despite 403: %d entries", len(entries))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCommentResolvedName — counter scope is per-target, plain target
|
|
// gets +C1, subsequent targets get sequential +C2/+C3.
|
|
func TestCommentResolvedName(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
resolved, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf")
|
|
if err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
if resolved != "Acme-0042_A+C1 (RFI) - Foundation.pdf" {
|
|
t.Errorf("first=%q, want +C1", resolved)
|
|
}
|
|
|
|
// Seed a +C1 file; next should be +C2.
|
|
if err := os.WriteFile(filepath.Join(root, resolved), []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
resolved2, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf")
|
|
if err != nil {
|
|
t.Fatalf("second: %v", err)
|
|
}
|
|
if resolved2 != "Acme-0042_A+C2 (RFI) - Foundation.pdf" {
|
|
t.Errorf("second=%q, want +C2", resolved2)
|
|
}
|
|
|
|
// Different target → independent counter at +C1.
|
|
resolvedB, err := zddc.CommentResolvedName(root, "Acme-0042_B (RFI) - Foundation-Spec.pdf")
|
|
if err != nil {
|
|
t.Fatalf("B: %v", err)
|
|
}
|
|
if resolvedB != "Acme-0042_B+C1 (RFI) - Foundation-Spec.pdf" {
|
|
t.Errorf("B=%q, want +C1", resolvedB)
|
|
}
|
|
}
|