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 root-admin AND adds her to the // document_controller role. The root-admin status + elevated // principal (set on the request below) is what carries her past // Plan Review's ActionAdmin checks — DCs are no longer subtree- // admin by default; their party-level `a` verb comes from the // auto-own .zddc that ensure.go writes when they mkdir // archive// (carrying auto_own_roles: [document_controller] // from the defaults). This fixture uses root-admin to keep the // test self-contained without scaffolding a party folder; the // non-root-admin DC path is covered by the standardroles tests. 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) } }