feat(fileapi): mirror staging transmittal folders into working/
When a folder is created under <project>/staging/ whose name parses as a ZDDC transmittal folder (YYYY-MM-DD_<tracking> (<status>) - <title>) and whose tracking number contains -TRN- or -SUB-, also create the same- named folder under <project>/working/ as a drafting space for staff. The mirror is one-way and one-shot: created at staging-mkdir time only. Renames and deletions of either side are not propagated. The transmittal client orchestrates cleanup at issue time (move files to archive/<recipient>/issued/, then delete both staging and working siblings) — the server stays out of that decision. -MDL- tracking deliberately skips the mirror; MDL deliverables live in archive/<party>/mdl/ rows, not via the working↔staging pairing. Implementation: mirrorStagingToWorking() in fileapi.go, called after a successful serveFileMkdir. EnsureCanonicalAncestors handles working/'s own auto-own .zddc; the mirror folder gets its own creator-grant on top. Six new tests cover -TRN-/-SUB- mirror, -MDL- skip, non-transmittal name skip, deep-path skip, and idempotency over a pre-existing sibling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a79cfd2f88
commit
55abce3448
2 changed files with 212 additions and 0 deletions
|
|
@ -581,11 +581,79 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Staging↔working mirror: when a folder created under staging/ matches
|
||||
// the ZDDC transmittal-folder grammar AND its tracking number contains
|
||||
// -SUB- or -TRN-, also create the same-named folder under working/ as
|
||||
// a drafting space for staff. The mirror is one-way and one-shot —
|
||||
// renames or deletions of either side are not propagated.
|
||||
if email != "" {
|
||||
mirrorStagingToWorking(cfg, abs, email)
|
||||
}
|
||||
|
||||
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
|
||||
}
|
||||
|
||||
// mirrorStagingToWorking creates a paired drafting folder under working/
|
||||
// when newAbs is a transmittal-named folder under <project>/staging/. Best
|
||||
// effort — failures are logged but do not affect the staging mkdir result.
|
||||
//
|
||||
// Eligibility:
|
||||
// - newAbs's parent is exactly <project>/staging/ (case-fold)
|
||||
// - filepath.Base(newAbs) parses as a transmittal folder
|
||||
// (YYYY-MM-DD_<tracking> (<status>) - <title>)
|
||||
// - tracking contains -SUB- or -TRN- (case-fold)
|
||||
//
|
||||
// Side effects on success:
|
||||
// - <project>/working/ created if missing, with auto-own .zddc seeded
|
||||
// (via EnsureCanonicalAncestors)
|
||||
// - <project>/working/<sameName>/ created if missing, with its own
|
||||
// auto-own .zddc (it's a child of the working/ canonical folder)
|
||||
func mirrorStagingToWorking(cfg config.Config, newAbs, email string) {
|
||||
rel, err := filepath.Rel(cfg.Root, newAbs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) != 3 {
|
||||
// Mirror only fires for direct children of staging/. Deeper paths
|
||||
// (staging/<name>/sub/) are user-managed.
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(parts[1], "staging") {
|
||||
return
|
||||
}
|
||||
|
||||
name := parts[2]
|
||||
_, tracking, _, _, ok := zddc.ParseTransmittalFolder(name)
|
||||
if !ok || !zddc.IsTrnOrSubTracking(tracking) {
|
||||
return
|
||||
}
|
||||
|
||||
mirrorPath := filepath.Join(cfg.Root, parts[0], "working", name)
|
||||
// Idempotent: skip if the working sibling already exists.
|
||||
if info, err := os.Stat(mirrorPath); err == nil && info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
// EnsureCanonicalAncestors creates working/ (with its own auto-own .zddc)
|
||||
// if missing; we then MkdirAll the mirror folder itself and seed its
|
||||
// auto-own grant.
|
||||
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, mirrorPath, email, 0o755); err != nil {
|
||||
slog.Warn("mirror: ensure ancestors", "path", mirrorPath, "err", err)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(mirrorPath, 0o755); err != nil {
|
||||
slog.Warn("mirror: mkdir", "path", mirrorPath, "err", err)
|
||||
return
|
||||
}
|
||||
if err := zddc.WriteAutoOwnZddc(mirrorPath, email); err != nil {
|
||||
slog.Warn("mirror: auto-own .zddc", "path", mirrorPath, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// auditFile emits a structured log line for each file API operation.
|
||||
// AccessLogMiddleware already logs every request — this adds an
|
||||
// op-tagged line so audit consumers can filter by operation without
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -663,3 +664,146 @@ func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) {
|
|||
t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- staging↔working mirror -------------------------------------------------
|
||||
|
||||
// stagingMirrorURL builds a URL-safe target path for a transmittal folder
|
||||
// name with spaces and parens, mirroring how a real client would encode it.
|
||||
func stagingMirrorURL(project, folder string) string {
|
||||
return "/" + project + "/staging/" + url.PathEscape(folder) + "/"
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_TRN(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - Foundation Plans"
|
||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("staging mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Staging side exists with auto-own.
|
||||
stagingDir := filepath.Join(root, "Proj/staging", folder)
|
||||
if info, err := os.Stat(stagingDir); err != nil || !info.IsDir() {
|
||||
t.Fatalf("staging folder not created: err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stagingDir, ".zddc")); err != nil {
|
||||
t.Errorf("staging auto-own .zddc missing: %v", err)
|
||||
}
|
||||
|
||||
// Working mirror exists with auto-own.
|
||||
workingDir := filepath.Join(root, "Proj/working", folder)
|
||||
if info, err := os.Stat(workingDir); err != nil || !info.IsDir() {
|
||||
t.Fatalf("working mirror not created: err=%v", err)
|
||||
}
|
||||
mirrorZ, err := os.ReadFile(filepath.Join(workingDir, ".zddc"))
|
||||
if err != nil {
|
||||
t.Fatalf("working mirror auto-own .zddc missing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(mirrorZ), "alice@example.com: rwcda") {
|
||||
t.Errorf("mirror .zddc missing creator grant: %s", mirrorZ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_SUB(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
folder := "2026-07-01_vendor-EM-SUB-0017 (RSA) - Review Notes"
|
||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); err != nil {
|
||||
t.Errorf("SUB-tracked folder should mirror; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_NonTransmittalNameSkipped(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
rec := do(http.MethodPost, "/Proj/staging/scratch/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
||||
}
|
||||
// staging/scratch/ exists.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/staging/scratch")); err != nil {
|
||||
t.Fatalf("staging/scratch not created: %v", err)
|
||||
}
|
||||
// No working/ sibling — name doesn't parse as transmittal.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/working/scratch")); !os.IsNotExist(err) {
|
||||
t.Errorf("non-transmittal name must NOT mirror; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_MdlTrackingSkipped(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
folder := "2026-06-15_proj-EM-MDL-0001 (IFR) - Master Deliverables List"
|
||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
||||
}
|
||||
// MDL deliverables are tracked in archive/<party>/mdl/, not via the
|
||||
// working↔staging pairing — no mirror.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
|
||||
t.Errorf("-MDL- tracking must NOT mirror; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_DeepPathSkipped(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
// mkdir of staging/<name>/sub/ (depth 4) — only depth-3 (immediate
|
||||
// child of staging/) qualifies for mirroring.
|
||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - x"
|
||||
if err := os.MkdirAll(filepath.Join(root, "Proj/staging", folder), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := do(http.MethodPost, "/Proj/staging/"+url.PathEscape(folder)+"/sub/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("deep mkdir: got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The transmittal folder did not get a mirror retroactively because
|
||||
// the mirror only fires on depth-3 mkdirs.
|
||||
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
|
||||
t.Errorf("deep mkdir should not retroactively mirror; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StagingMirror_Idempotent(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
// Pre-create the working sibling with a sentinel file so we can detect
|
||||
// if the mirror code blew it away.
|
||||
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - existing"
|
||||
mirrorDir := filepath.Join(root, "Proj/working", folder)
|
||||
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinel := filepath.Join(mirrorDir, "preexisting.md")
|
||||
if err := os.WriteFile(sentinel, []byte("user content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "mkdir",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("mkdir: want 201, got %d", rec.Code)
|
||||
}
|
||||
// Sentinel still exists — mirror was idempotent (no-op when sibling
|
||||
// already present).
|
||||
if _, err := os.Stat(sentinel); err != nil {
|
||||
t.Errorf("idempotency: pre-existing content gone: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue