ZDDC/zddc/internal/handler/planreview_test.go
ZDDC 690d185dc2 feat: reviewing/ lifecycle — Plan Review endpoint, virtual received window, browse context-menu workflows
Two layers shipped together since the second builds on the first.

LAYER 1 — reviewing/ + Plan Review scaffolding

- reviewing/ is now a real folder under each project, populated by the
  Plan Review composite endpoint. The old reviewing/ virtual aggregator
  handler is retired.
- POST /<project>/archive/<party>/received/<tracking>/ with X-ZDDC-Op:
  plan-review scaffolds physical workflow folders under reviewing_root
  and staging_root, each carrying .zddc.received_path pointing back at
  the canonical submittal. Idempotent re-runs match by received_path
  and re-converge the ACL.
- Virtual received window: when listing or writing under
  <workflow>/received/, the server resolves through the canonical
  archive/<party>/received/<tracking>/ via the workflow's
  .zddc.received_path. Writes get rewritten to
  <workflow>/<base>+C<n><suffix> so review comments land in the
  workflow folder and never touch the WORM archive.
- Cascade defaults declare on_plan_review per project so the
  reviewing_root and staging_root are configurable.

LAYER 2 — browse context-menu workflows

- Accept Transmittal: right-click a transmittal folder in
  archive/<party>/incoming/ → validates ZDDC folder + filename
  conformance, atomic-renames the folder to
  archive/<party>/received/<tracking>/ (WORM zone), and optionally
  chains into Plan Review in the same composite request. Re-acceptance
  with a different revision merges file-by-file; WORM forbids
  overwrite of an existing filename.
- Stage / Unstage: right-click files in working/<…>/ → "Stage to…"
  with picker of existing staging transmittal folders + inline
  "New transmittal folder…" create; right-click files in
  staging/<…>/ → "Unstage to working/" defaulting to the user's
  working/<email>/ home. Reuses the file-API move primitive.
- Create Transmittal folder: right-click the staging/ pane → prompts
  for a ZDDC-conforming folder name with live validation; mkdir,
  then navigate to the new folder URL where the transmittal tool
  serves the editor.
- Supporting infrastructure: new CanonicalFolderAt cascade lookup +
  X-ZDDC-Canonical-Folder response header so the browse SPA can
  scope-gate menu items without re-implementing the cascade
  client-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:08:04 -05:00

321 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", "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", "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())
}
if _, err := os.Stat(filepath.Join(root, "Project-1", "reviewing")); 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(filepath.Join(root, "Project-1", "reviewing"))
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)
}
}