feat: per-party RSK + project-level SSR/MDL/RSK rollup tables
Adds the risk register as a sibling of MDL under archive/<party>/, and
three project-level virtual aggregations at <project>/{ssr,mdl,rsk}:
- SSR aggregates archive/<party>/ssr.yaml; "+ Add row" materializes a
new party folder (mkdir + auto-own .zddc + ssr.yaml). Renames go
through X-ZDDC-Op: ssr-rename, which os.Rename's the party
directory so every row inside follows. Party name doubles as the
folder name (no opaque IDs) and is path-derived on read.
- MDL/RSK rollups list every deliverable / every risk across all
parties with a derived `party` column; "+ Add row" is suppressed
because party affiliation is ambiguous in the aggregate view.
All four virtual roots are declared `virtual: true` in
defaults.zddc.yaml. Spec/form bytes come from six new embedded
defaults (default-rsk.*, default-ssr.*, default-project-{mdl,rsk}.*)
served via a generalized IsDefaultSpec/IsDefaultSpecAbs that replaces
the MDL-only recognizer. Listing synthesis lives in fs/tree.go;
ACL on each synthetic row evaluates against the canonical
archive/<party>/ chain so non-owners see rows read-only. PUT/DELETE
through virtual URLs rewrite to canonical paths in fileapi.go via
sibling-shape blocks that don't touch the ACL gate. SSR row DELETE
returns 405 (delete the party folder via the archive view).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
351dc63cb4
commit
73e34bed5e
20 changed files with 1540 additions and 118 deletions
|
|
@ -111,6 +111,11 @@
|
||||||
description: spec.description,
|
description: spec.description,
|
||||||
columns: spec.columns,
|
columns: spec.columns,
|
||||||
defaults: spec.defaults,
|
defaults: spec.defaults,
|
||||||
|
// addable defaults to true; tables can opt out with
|
||||||
|
// `addable: false` (used by project-rollup MDL/RSK where the
|
||||||
|
// party affiliation of a new row is ambiguous — add at the
|
||||||
|
// per-party path instead).
|
||||||
|
addable: spec.addable !== false,
|
||||||
rowSchema: rowSchema,
|
rowSchema: rowSchema,
|
||||||
rows: rows
|
rows: rows
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,11 @@
|
||||||
if (addRowBtn) {
|
if (addRowBtn) {
|
||||||
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||||
if (onHttp && hasCols) {
|
// ctx.addable === false suppresses the affordance entirely.
|
||||||
|
// Used by project-rollup tables where the row's party
|
||||||
|
// affiliation is ambiguous (add at the per-party path).
|
||||||
|
const allowAdd = ctx.addable !== false;
|
||||||
|
if (onHttp && hasCols && allowAdd) {
|
||||||
addRowBtn.hidden = false;
|
addRowBtn.hidden = false;
|
||||||
addRowBtn.removeAttribute('href');
|
addRowBtn.removeAttribute('href');
|
||||||
addRowBtn.setAttribute('role', 'button');
|
addRowBtn.setAttribute('role', 'button');
|
||||||
|
|
|
||||||
|
|
@ -1002,13 +1002,20 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
info, err := os.Stat(absPath)
|
info, err := os.Stat(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
// Default MDL spec fallback: archive/<party>/mdl.table.yaml
|
// Default-spec fallback for the embedded table.yaml / form.yaml
|
||||||
// and archive/<party>/mdl.form.yaml are served from embedded
|
// files served when no operator file exists on disk:
|
||||||
// bytes when no operator file exists on disk. The table app
|
//
|
||||||
// fetches these client-side; the fallback lets a fresh
|
// <project>/archive/<party>/{mdl,rsk}/{table,form}.yaml
|
||||||
// project work out of the box.
|
// <project>/archive/<party>/ssr.form.yaml
|
||||||
|
// <project>/{ssr,mdl,rsk}/{table,form}.yaml
|
||||||
|
//
|
||||||
|
// The table app fetches these client-side; the fallback lets
|
||||||
|
// a fresh project work out of the box. ACL gates against the
|
||||||
|
// chain at the request directory; for project-level virtual
|
||||||
|
// specs that chain is the project's, and for per-party paths
|
||||||
|
// it's the party's archive folder.
|
||||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
|
if bytes, ok := handler.IsDefaultSpec(cfg.Root, urlPath); ok {
|
||||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
||||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
|
@ -1016,7 +1023,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("X-ZDDC-Source", "default-mdl-spec")
|
w.Header().Set("X-ZDDC-Source", "default-spec")
|
||||||
if r.Method == http.MethodHead {
|
if r.Method == http.MethodHead {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1024,6 +1031,22 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Virtual project-level table views (SSR / MDL rollup / RSK
|
||||||
|
// rollup). The virtual row URL doesn't exist on disk; the
|
||||||
|
// underlying canonical file lives in <project>/archive/<party>/.
|
||||||
|
// ACL evaluates against the canonical party-archive path so
|
||||||
|
// non-owners see the row read-only and party owners can edit.
|
||||||
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
|
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind.IsRowKind() {
|
||||||
|
chain, _ := zddc.EffectivePolicy(cfg.Root, vv.PartyArchive)
|
||||||
|
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.ServeVirtualViewRow(w, r, vv)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// File doesn't exist at this path. Before falling through to
|
// File doesn't exist at this path. Before falling through to
|
||||||
// app-HTML routing or 404, check the two virtual-file-extension
|
// app-HTML routing or 404, check the two virtual-file-extension
|
||||||
// shapes that ZDDC exposes through the listing convention:
|
// shapes that ZDDC exposes through the listing convention:
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,68 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
// to real ones.
|
// to real ones.
|
||||||
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
|
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
|
||||||
|
|
||||||
|
// Project-level virtual table views: SSR aggregates one row per
|
||||||
|
// party folder under archive/; MDL/RSK rollups aggregate every
|
||||||
|
// row from each party's mdl/ or rsk/. The listing surfaces
|
||||||
|
// synthetic row entries (Writable bit per the canonical
|
||||||
|
// archive/<party>/ chain) plus synthetic table.yaml/form.yaml
|
||||||
|
// entries so the tables tool's client-side walkServer finds the
|
||||||
|
// spec without a 404 round-trip. Spec bytes are served by the
|
||||||
|
// main.go IsDefaultSpec fallback; row reads go through
|
||||||
|
// handler.ServeVirtualViewRow which path-injects name/party.
|
||||||
|
if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() {
|
||||||
|
partyChains := make(map[string]zddc.PolicyChain)
|
||||||
|
chainFor := func(partyAbs string) zddc.PolicyChain {
|
||||||
|
if c, ok := partyChains[partyAbs]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c, _ := zddc.EffectivePolicy(fsRoot, partyAbs)
|
||||||
|
partyChains[partyAbs] = c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
appendVirtualRow := func(syntheticName, partyAbs string) {
|
||||||
|
rowURL := baseURL + url.PathEscape(syntheticName)
|
||||||
|
chain := chainFor(partyAbs)
|
||||||
|
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, rowURL); !allowed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
partyActiveAdmin := elevated && userEmail != "" &&
|
||||||
|
zddc.IsAdminForChain(chain, userEmail)
|
||||||
|
writable := partyActiveAdmin
|
||||||
|
if !writable {
|
||||||
|
allowed, _ := policy.AllowActionFromChainP(ctx, decider, chain, principal, rowURL, policy.ActionWrite)
|
||||||
|
writable = allowed
|
||||||
|
}
|
||||||
|
result = append(result, listing.FileInfo{
|
||||||
|
Name: syntheticName,
|
||||||
|
URL: rowURL,
|
||||||
|
IsDir: false,
|
||||||
|
Virtual: true,
|
||||||
|
Writable: writable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch vv.Slot {
|
||||||
|
case "ssr":
|
||||||
|
parties, _ := zddc.ListSSRParties(fsRoot, vv.ProjectAbs)
|
||||||
|
for _, party := range parties {
|
||||||
|
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
|
||||||
|
appendVirtualRow(party+".yaml", partyAbs)
|
||||||
|
}
|
||||||
|
case "mdl", "rsk":
|
||||||
|
rows, _ := zddc.ListRollupRows(fsRoot, vv.ProjectAbs, vv.Slot)
|
||||||
|
for _, row := range rows {
|
||||||
|
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
|
||||||
|
appendVirtualRow(row.SyntheticName, partyAbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result,
|
||||||
|
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true},
|
||||||
|
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Workflow folder: append a virtual `received/` entry whose backing
|
// Workflow folder: append a virtual `received/` entry whose backing
|
||||||
// is .zddc.received_path. The entry's URL stays under the workflow
|
// is .zddc.received_path. The entry's URL stays under the workflow
|
||||||
// folder (baseURL + "received/") so a click navigates "into" the
|
// folder (baseURL + "received/") so a click navigates "into" the
|
||||||
|
|
|
||||||
69
zddc/internal/handler/default-project-mdl.table.yaml
Normal file
69
zddc/internal/handler/default-project-mdl.table.yaml
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Default project-rollup Master Deliverables List spec, served by
|
||||||
|
# zddc-server when no operator-supplied table.yaml exists at
|
||||||
|
# <project>/mdl/.
|
||||||
|
#
|
||||||
|
# This view aggregates every deliverable row from every party under
|
||||||
|
# <project>/archive/. Each synthetic row is backed by the real file
|
||||||
|
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `party`
|
||||||
|
# column is derived from the row's source folder (path-injected by
|
||||||
|
# the server, not stored in the YAML).
|
||||||
|
#
|
||||||
|
# + Add row is suppressed in this view because the party affiliation
|
||||||
|
# would be ambiguous — add deliverables at the per-party path
|
||||||
|
# (<project>/archive/<party>/mdl/) and they'll appear here on next
|
||||||
|
# load.
|
||||||
|
|
||||||
|
title: Project Deliverables (all parties)
|
||||||
|
description: Every deliverable across all parties under archive/. Click a row to edit; add rows at the per-party MDL view.
|
||||||
|
|
||||||
|
addable: false
|
||||||
|
|
||||||
|
columns:
|
||||||
|
- field: party
|
||||||
|
title: Package
|
||||||
|
width: 7em
|
||||||
|
- field: originator
|
||||||
|
title: Originator
|
||||||
|
width: 8em
|
||||||
|
- field: phase
|
||||||
|
title: Phase
|
||||||
|
width: 5em
|
||||||
|
- field: project
|
||||||
|
title: Project
|
||||||
|
width: 8em
|
||||||
|
- field: area
|
||||||
|
title: Area
|
||||||
|
width: 5em
|
||||||
|
- field: discipline
|
||||||
|
title: Disc.
|
||||||
|
width: 5em
|
||||||
|
- field: type
|
||||||
|
title: Type
|
||||||
|
width: 6em
|
||||||
|
- field: sequence
|
||||||
|
title: Seq.
|
||||||
|
width: 5em
|
||||||
|
- field: suffix
|
||||||
|
title: Suffix
|
||||||
|
width: 5em
|
||||||
|
- field: title
|
||||||
|
title: Deliverable
|
||||||
|
- field: plannedRevision
|
||||||
|
title: Rev.
|
||||||
|
width: 5em
|
||||||
|
- field: plannedDate
|
||||||
|
title: Planned
|
||||||
|
format: date
|
||||||
|
width: 8em
|
||||||
|
- field: status
|
||||||
|
title: Status
|
||||||
|
width: 6em
|
||||||
|
enum: [DFT, IFR, IFA, IFC, AFC, AB]
|
||||||
|
- field: owner
|
||||||
|
title: Owner
|
||||||
|
width: 12em
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
sort:
|
||||||
|
- { field: party, dir: asc }
|
||||||
|
- { field: plannedDate, dir: asc }
|
||||||
56
zddc/internal/handler/default-project-rsk.table.yaml
Normal file
56
zddc/internal/handler/default-project-rsk.table.yaml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Default project-rollup Risk Register spec, served by zddc-server
|
||||||
|
# when no operator-supplied table.yaml exists at <project>/rsk/.
|
||||||
|
#
|
||||||
|
# This view aggregates every risk row from every party under
|
||||||
|
# <project>/archive/. Each synthetic row is backed by the real file
|
||||||
|
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `party`
|
||||||
|
# column is derived from the row's source folder (path-injected by
|
||||||
|
# the server, not stored in the YAML).
|
||||||
|
#
|
||||||
|
# + Add row is suppressed in this view because the party affiliation
|
||||||
|
# would be ambiguous — add risks at the per-party path
|
||||||
|
# (<project>/archive/<party>/rsk/) and they'll appear here on next
|
||||||
|
# load.
|
||||||
|
|
||||||
|
title: Project Risk Register (all parties)
|
||||||
|
description: Every risk across all parties under archive/. Click a row to edit; add rows at the per-party RSK view.
|
||||||
|
|
||||||
|
addable: false
|
||||||
|
|
||||||
|
columns:
|
||||||
|
- field: party
|
||||||
|
title: Package
|
||||||
|
width: 7em
|
||||||
|
- field: id
|
||||||
|
title: ID
|
||||||
|
width: 6em
|
||||||
|
- field: title
|
||||||
|
title: Risk
|
||||||
|
- field: category
|
||||||
|
title: Category
|
||||||
|
width: 10em
|
||||||
|
- field: likelihood
|
||||||
|
title: L
|
||||||
|
width: 4em
|
||||||
|
- field: impact
|
||||||
|
title: I
|
||||||
|
width: 4em
|
||||||
|
- field: severity
|
||||||
|
title: Sev
|
||||||
|
width: 5em
|
||||||
|
- field: owner
|
||||||
|
title: Owner
|
||||||
|
width: 12em
|
||||||
|
- field: status
|
||||||
|
title: Status
|
||||||
|
width: 9em
|
||||||
|
enum: [open, mitigated, accepted, closed]
|
||||||
|
- field: dueDate
|
||||||
|
title: Due
|
||||||
|
format: date
|
||||||
|
width: 8em
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
sort:
|
||||||
|
- { field: severity, dir: desc }
|
||||||
|
- { field: party, dir: asc }
|
||||||
83
zddc/internal/handler/default-rsk.form.yaml
Normal file
83
zddc/internal/handler/default-rsk.form.yaml
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# Default row schema for a Risk Register entry, served by
|
||||||
|
# zddc-server when no operator-supplied form.yaml exists at
|
||||||
|
# archive/<party>/rsk/.
|
||||||
|
#
|
||||||
|
# Likelihood and impact use the standard 1-5 ordinal scales;
|
||||||
|
# severity is also 1-25 (typically L*I) and stored on each row so
|
||||||
|
# operators can override it when the simple product doesn't capture
|
||||||
|
# the actual risk profile.
|
||||||
|
#
|
||||||
|
# To customize: drop your own form.yaml into archive/<party>/rsk/
|
||||||
|
# (the same directory as table.yaml). Tighten constraints with
|
||||||
|
# `enum:`, `pattern:`, etc. Add fields and they'll appear in the
|
||||||
|
# row-edit form; add a matching column to table.yaml to surface
|
||||||
|
# the field in the table view too.
|
||||||
|
|
||||||
|
title: Risk
|
||||||
|
description: One identified risk. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure.
|
||||||
|
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id, title]
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
title: ID
|
||||||
|
description: Stable identifier, e.g. R-001.
|
||||||
|
minLength: 1
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
title: Risk
|
||||||
|
minLength: 1
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
title: Category
|
||||||
|
description: Free-form grouping (schedule, cost, technical, regulatory, ...).
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
title: Description
|
||||||
|
likelihood:
|
||||||
|
type: integer
|
||||||
|
title: Likelihood
|
||||||
|
description: 1 (rare) to 5 (almost certain).
|
||||||
|
minimum: 1
|
||||||
|
maximum: 5
|
||||||
|
impact:
|
||||||
|
type: integer
|
||||||
|
title: Impact
|
||||||
|
description: 1 (negligible) to 5 (catastrophic).
|
||||||
|
minimum: 1
|
||||||
|
maximum: 5
|
||||||
|
severity:
|
||||||
|
type: integer
|
||||||
|
title: Severity
|
||||||
|
description: Residual risk score. Typically likelihood * impact (1-25), but operators can override.
|
||||||
|
minimum: 1
|
||||||
|
maximum: 25
|
||||||
|
mitigation:
|
||||||
|
type: string
|
||||||
|
title: Mitigation
|
||||||
|
description: Plan for reducing this risk's likelihood or impact.
|
||||||
|
owner:
|
||||||
|
type: string
|
||||||
|
title: Owner
|
||||||
|
description: Email or party name responsible for tracking this risk.
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
title: Status
|
||||||
|
enum: [open, mitigated, accepted, closed]
|
||||||
|
dueDate:
|
||||||
|
type: string
|
||||||
|
title: Due date
|
||||||
|
format: date
|
||||||
|
notes:
|
||||||
|
type: string
|
||||||
|
title: Notes
|
||||||
|
ui:
|
||||||
|
description:
|
||||||
|
ui:widget: textarea
|
||||||
|
mitigation:
|
||||||
|
ui:widget: textarea
|
||||||
|
notes:
|
||||||
|
ui:widget: textarea
|
||||||
51
zddc/internal/handler/default-rsk.table.yaml
Normal file
51
zddc/internal/handler/default-rsk.table.yaml
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Default Risk Register spec, served by zddc-server when no
|
||||||
|
# operator-supplied table.yaml exists at archive/<party>/rsk/.
|
||||||
|
#
|
||||||
|
# Columns cover the standard risk-register fields: identifier, title,
|
||||||
|
# category, likelihood / impact / severity scores, owner, status, and
|
||||||
|
# due date. Severity is stored on each row (1-25, typically L*I) so
|
||||||
|
# operators can override it when the simple product doesn't capture
|
||||||
|
# the actual risk profile.
|
||||||
|
#
|
||||||
|
# To customize: drop your own table.yaml + form.yaml into the same
|
||||||
|
# directory (archive/<party>/rsk/). The whole directory IS the table —
|
||||||
|
# spec, row-edit form, and rows are siblings. Override examples mirror
|
||||||
|
# the MDL table.yaml customization patterns.
|
||||||
|
|
||||||
|
title: Risk Register
|
||||||
|
description: Risks tracked for this party. Severity is the residual risk score; sort defaults to severity descending.
|
||||||
|
|
||||||
|
columns:
|
||||||
|
- field: id
|
||||||
|
title: ID
|
||||||
|
width: 6em
|
||||||
|
- field: title
|
||||||
|
title: Risk
|
||||||
|
- field: category
|
||||||
|
title: Category
|
||||||
|
width: 10em
|
||||||
|
- field: likelihood
|
||||||
|
title: L
|
||||||
|
width: 4em
|
||||||
|
- field: impact
|
||||||
|
title: I
|
||||||
|
width: 4em
|
||||||
|
- field: severity
|
||||||
|
title: Sev
|
||||||
|
width: 5em
|
||||||
|
- field: owner
|
||||||
|
title: Owner
|
||||||
|
width: 12em
|
||||||
|
- field: status
|
||||||
|
title: Status
|
||||||
|
width: 9em
|
||||||
|
enum: [open, mitigated, accepted, closed]
|
||||||
|
- field: dueDate
|
||||||
|
title: Due
|
||||||
|
format: date
|
||||||
|
width: 8em
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
sort:
|
||||||
|
- { field: severity, dir: desc }
|
||||||
|
- { field: dueDate, dir: asc }
|
||||||
76
zddc/internal/handler/default-ssr.form.yaml
Normal file
76
zddc/internal/handler/default-ssr.form.yaml
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Default row schema for a Supplier / Subcontractor Status Report
|
||||||
|
# entry, served by zddc-server when no operator-supplied form.yaml
|
||||||
|
# exists at <project>/archive/<party>/ssr.form.yaml.
|
||||||
|
#
|
||||||
|
# The `name` field doubles as the party folder name (the row's
|
||||||
|
# stable identifier). It's required on create (+ Add row materializes
|
||||||
|
# <project>/archive/<name>/) but is stripped from the YAML on save —
|
||||||
|
# the folder name IS the identity, so storing it inside the file too
|
||||||
|
# would just be a denormalization. On read the dispatcher injects
|
||||||
|
# name back into the row data so this form (and the SSR table)
|
||||||
|
# can display it.
|
||||||
|
#
|
||||||
|
# Pattern excludes leading `.` and `_` to avoid colliding with
|
||||||
|
# fileapi.go's dot/underscore-prefix guards on file paths.
|
||||||
|
#
|
||||||
|
# To customize: drop your own form.yaml into
|
||||||
|
# <project>/archive/<party>/ (sibling to the party's ssr.yaml).
|
||||||
|
|
||||||
|
title: Supplier / Subcontractor Status
|
||||||
|
description: One party's status report. The party name doubles as the archive folder name and is required when creating a new row.
|
||||||
|
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name, vendorType, contractNo, scopeSummary]
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
title: Party (folder name)
|
||||||
|
description: Becomes <project>/archive/<name>/. Typical naming = MasterFormat 4-digit code + C|P + sequence digit (e.g. 0330C1).
|
||||||
|
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
|
||||||
|
minLength: 1
|
||||||
|
vendorType:
|
||||||
|
type: string
|
||||||
|
title: Vendor type
|
||||||
|
enum: [subcontractor, supplier, consultant, vendor, other]
|
||||||
|
contractNo:
|
||||||
|
type: string
|
||||||
|
title: Contract / PO number
|
||||||
|
scopeSummary:
|
||||||
|
type: string
|
||||||
|
title: Scope summary
|
||||||
|
contractValue:
|
||||||
|
type: number
|
||||||
|
title: Contract value
|
||||||
|
awardDate:
|
||||||
|
type: string
|
||||||
|
title: Award date
|
||||||
|
format: date
|
||||||
|
kickoffDate:
|
||||||
|
type: string
|
||||||
|
title: Kickoff date
|
||||||
|
format: date
|
||||||
|
scheduleStatus:
|
||||||
|
type: string
|
||||||
|
title: Schedule status
|
||||||
|
enum: [on-track, at-risk, behind, completed, on-hold]
|
||||||
|
deliverablesStatus:
|
||||||
|
type: string
|
||||||
|
title: Deliverables status
|
||||||
|
enum: [on-track, at-risk, behind, completed]
|
||||||
|
paymentStatus:
|
||||||
|
type: string
|
||||||
|
title: Payment status
|
||||||
|
enum: [current, overdue, hold, complete]
|
||||||
|
ownerContact:
|
||||||
|
type: string
|
||||||
|
title: Owner contact (email)
|
||||||
|
notes:
|
||||||
|
type: string
|
||||||
|
title: Notes
|
||||||
|
ui:
|
||||||
|
scopeSummary:
|
||||||
|
ui:widget: textarea
|
||||||
|
notes:
|
||||||
|
ui:widget: textarea
|
||||||
62
zddc/internal/handler/default-ssr.table.yaml
Normal file
62
zddc/internal/handler/default-ssr.table.yaml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Default Supplier / Subcontractor Status Report spec, served by
|
||||||
|
# zddc-server when no operator-supplied table.yaml exists at
|
||||||
|
# <project>/ssr/.
|
||||||
|
#
|
||||||
|
# The SSR is a project-level aggregation: one row per party folder
|
||||||
|
# under <project>/archive/, each row backed by
|
||||||
|
# <project>/archive/<party>/ssr.yaml. The synthetic `name` column
|
||||||
|
# shows the party folder name (which is the row's stable identifier);
|
||||||
|
# typical naming encodes a MasterFormat 4-digit code plus C|P plus
|
||||||
|
# a sequence digit (e.g. 0330C1, 0440P2).
|
||||||
|
#
|
||||||
|
# To customize: drop your own table.yaml + form.yaml at
|
||||||
|
# <project>/ssr/table.yaml + form.yaml (the cascade declares
|
||||||
|
# <project>/ssr/ as virtual, but the spec files themselves can be
|
||||||
|
# real overrides). Add columns or tighten enums as your project's
|
||||||
|
# subcontract reporting requires.
|
||||||
|
|
||||||
|
title: Supplier / Subcontractor Status
|
||||||
|
description: One row per party folder under archive/. Click + Add row to create a new party (folder + metadata).
|
||||||
|
|
||||||
|
columns:
|
||||||
|
- field: name
|
||||||
|
title: Party
|
||||||
|
width: 8em
|
||||||
|
- field: vendorType
|
||||||
|
title: Type
|
||||||
|
width: 9em
|
||||||
|
- field: contractNo
|
||||||
|
title: Contract
|
||||||
|
width: 10em
|
||||||
|
- field: scopeSummary
|
||||||
|
title: Scope
|
||||||
|
- field: contractValue
|
||||||
|
title: Value
|
||||||
|
width: 10em
|
||||||
|
- field: awardDate
|
||||||
|
title: Award
|
||||||
|
format: date
|
||||||
|
width: 8em
|
||||||
|
- field: kickoffDate
|
||||||
|
title: Kickoff
|
||||||
|
format: date
|
||||||
|
width: 8em
|
||||||
|
- field: scheduleStatus
|
||||||
|
title: Schedule
|
||||||
|
width: 9em
|
||||||
|
enum: [on-track, at-risk, behind, completed, on-hold]
|
||||||
|
- field: deliverablesStatus
|
||||||
|
title: Deliv.
|
||||||
|
width: 9em
|
||||||
|
enum: [on-track, at-risk, behind, completed]
|
||||||
|
- field: paymentStatus
|
||||||
|
title: Pmt.
|
||||||
|
width: 8em
|
||||||
|
enum: [current, overdue, hold, complete]
|
||||||
|
- field: ownerContact
|
||||||
|
title: Owner contact
|
||||||
|
width: 14em
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
sort:
|
||||||
|
- { field: name, dir: asc }
|
||||||
|
|
@ -50,6 +50,9 @@ const (
|
||||||
|
|
||||||
opMove = "move"
|
opMove = "move"
|
||||||
opMkdir = "mkdir"
|
opMkdir = "mkdir"
|
||||||
|
// opSSRRename / opPlanReview / opAcceptTransmittal are declared
|
||||||
|
// alongside their handler files. Listed in the dispatch switch
|
||||||
|
// below so they're discoverable from a single place.
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsWriteMethod reports whether this method is handled by the file API.
|
// IsWriteMethod reports whether this method is handled by the file API.
|
||||||
|
|
@ -297,6 +300,22 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Virtual project-level table views — SSR / MDL rollup / RSK
|
||||||
|
// rollup. The PUT URL lives in <project>/{ssr,mdl,rsk}/...; the
|
||||||
|
// underlying bytes belong inside <project>/archive/<party>/. We
|
||||||
|
// rewrite abs + cleanURL to the canonical path so the rest of
|
||||||
|
// this function (ACL gate, ETag, audit, conversion-cache purge)
|
||||||
|
// operates on the real file location.
|
||||||
|
//
|
||||||
|
// SSR row PUTs land at archive/<party>/ssr.yaml; MDL/RSK rollup
|
||||||
|
// row PUTs land at archive/<party>/<slot>/<file>.yaml. Same
|
||||||
|
// shape as the virtual-received rewrite below.
|
||||||
|
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
|
||||||
|
abs = vv.CanonicalAbs
|
||||||
|
cleanURL = vv.CanonicalURL
|
||||||
|
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
|
||||||
|
}
|
||||||
|
|
||||||
// Virtual received/ rewrite. When the PUT targets a file under the
|
// Virtual received/ rewrite. When the PUT targets a file under the
|
||||||
// synthetic <workflow>/received/<file> URL, the canonical record is
|
// synthetic <workflow>/received/<file> URL, the canonical record is
|
||||||
// WORM — we can't write there. Convention: treat the drop as a
|
// WORM — we can't write there. Convention: treat the drop as a
|
||||||
|
|
@ -424,6 +443,21 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Virtual project-level table views. SSR row deletes are refused
|
||||||
|
// (would orphan the party folder and its mdl/rsk contents) — use
|
||||||
|
// the archive view to delete a party. MDL/RSK rollup row deletes
|
||||||
|
// pass through to the canonical archive/<party>/<slot>/<file>.yaml
|
||||||
|
// path with the normal ACL gate.
|
||||||
|
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
|
||||||
|
if vv.Kind == zddc.VirtualViewSSRRow {
|
||||||
|
http.Error(w, "Method Not Allowed — delete the party folder via the archive view, not the SSR table", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs = vv.CanonicalAbs
|
||||||
|
cleanURL = vv.CanonicalURL
|
||||||
|
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
|
||||||
|
}
|
||||||
|
|
||||||
info, err := os.Stat(abs)
|
info, err := os.Stat(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
|
@ -473,6 +507,8 @@ func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
servePlanReview(cfg, w, r)
|
servePlanReview(cfg, w, r)
|
||||||
case opAcceptTransmittal:
|
case opAcceptTransmittal:
|
||||||
serveAcceptTransmittal(cfg, w, r)
|
serveAcceptTransmittal(cfg, w, r)
|
||||||
|
case opSSRRename:
|
||||||
|
serveSSRRename(cfg, w, r)
|
||||||
case "":
|
case "":
|
||||||
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
|
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -77,16 +77,21 @@ type formContext struct {
|
||||||
|
|
||||||
// FormRequest describes a recognized form-system request.
|
// FormRequest describes a recognized form-system request.
|
||||||
type FormRequest struct {
|
type FormRequest struct {
|
||||||
// Kind is one of: "render-empty", "create", "render-edit", "update".
|
// Kind is one of: "render-empty", "create", "render-edit", "update",
|
||||||
|
// or "create-via-ssr" (the special SSR create flow which materializes
|
||||||
|
// a new party folder + ssr.yaml).
|
||||||
Kind string
|
Kind string
|
||||||
// SpecPath is the absolute filesystem path to the <name>.form.yaml.
|
// SpecPath is the absolute filesystem path to the <name>.form.yaml.
|
||||||
SpecPath string
|
SpecPath string
|
||||||
// DataPath is the absolute filesystem path to the data .yaml; empty for
|
// DataPath is the absolute filesystem path to the data .yaml; empty for
|
||||||
// render-empty / create.
|
// render-empty / create / create-via-ssr.
|
||||||
DataPath string
|
DataPath string
|
||||||
// SubmitURL is the URL the form should POST back to (the server-injected
|
// SubmitURL is the URL the form should POST back to (the server-injected
|
||||||
// "submit to my own URL" value).
|
// "submit to my own URL" value).
|
||||||
SubmitURL string
|
SubmitURL string
|
||||||
|
// Project carries the project name for create-via-ssr requests. Empty
|
||||||
|
// for all other kinds.
|
||||||
|
Project string
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecognizeFormRequest classifies r as a form-system request, or returns nil
|
// RecognizeFormRequest classifies r as a form-system request, or returns nil
|
||||||
|
|
@ -103,17 +108,38 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
||||||
if !strings.HasSuffix(urlPath, ".html") {
|
if !strings.HasSuffix(urlPath, ".html") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSR create: /<project>/ssr/form.html maps to the special create
|
||||||
|
// path that materializes a new party folder (mkdir archive/<name>/)
|
||||||
|
// AND writes archive/<name>/ssr.yaml. Recognized before the generic
|
||||||
|
// form.html branch so it doesn't get misrouted as an in-dir create.
|
||||||
|
if project, ok := zddc.IsSSRCreateURL(urlPath); ok {
|
||||||
|
kind := "render-empty"
|
||||||
|
if method == http.MethodPost {
|
||||||
|
kind = "create-via-ssr"
|
||||||
|
}
|
||||||
|
// SpecPath is the embedded default SSR form schema; the loader
|
||||||
|
// falls back to embedded bytes via IsDefaultSpecAbs. The path
|
||||||
|
// itself is the virtual <project>/ssr/form.yaml location.
|
||||||
|
specAbs := filepath.Join(fsRoot, project, "ssr", "form.yaml")
|
||||||
|
return &FormRequest{
|
||||||
|
Kind: kind,
|
||||||
|
SpecPath: specAbs,
|
||||||
|
SubmitURL: urlPath,
|
||||||
|
Project: project,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
underlying := strings.TrimSuffix(urlPath, ".html")
|
underlying := strings.TrimSuffix(urlPath, ".html")
|
||||||
|
|
||||||
// specEligible accepts a spec path that exists on disk OR matches
|
// specEligible accepts a spec path that exists on disk OR matches
|
||||||
// the default-MDL virtual-fallback shape at archive/<party>/mdl/.
|
// any of the default-spec virtual-fallback shapes (per-party
|
||||||
// Without this, the default-MDL row form would 404 on a fresh
|
// mdl/rsk, per-party SSR schema, project-level virtual specs).
|
||||||
// archive even though the table view renders.
|
|
||||||
specEligible := func(specAbs string) bool {
|
specEligible := func(specAbs string) bool {
|
||||||
if fileExists(specAbs) {
|
if fileExists(specAbs) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if _, ok := IsDefaultMdlSpecAbs(fsRoot, specAbs); ok {
|
if _, ok := IsDefaultSpecAbs(fsRoot, specAbs); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -154,7 +180,36 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
||||||
|
|
||||||
if strings.HasSuffix(underlying, ".yaml") {
|
if strings.HasSuffix(underlying, ".yaml") {
|
||||||
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
|
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
|
||||||
// SAME directory as the row file (<dir>/form.yaml).
|
// SAME directory as the row file (<dir>/form.yaml) UNLESS the
|
||||||
|
// URL maps to one of the project-level virtual views, in which
|
||||||
|
// case the canonical SpecPath / DataPath are inside the per-
|
||||||
|
// party archive folder. ResolveVirtualView handles the rewrite;
|
||||||
|
// SubmitURL stays as the virtual URL so the form POSTs back to
|
||||||
|
// the same endpoint (which re-resolves to the same canonical
|
||||||
|
// paths on the second pass).
|
||||||
|
if vv := zddc.ResolveVirtualView(fsRoot, underlying); vv.Resolved && vv.Kind.IsRowKind() {
|
||||||
|
var specPath string
|
||||||
|
switch vv.Kind {
|
||||||
|
case zddc.VirtualViewSSRRow:
|
||||||
|
specPath = vv.SchemaAbs
|
||||||
|
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
|
||||||
|
specPath = filepath.Join(vv.PartyArchive, vv.Slot, "form.yaml")
|
||||||
|
}
|
||||||
|
if !specEligible(specPath) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
kind := "render-edit"
|
||||||
|
if method == http.MethodPost {
|
||||||
|
kind = "update"
|
||||||
|
}
|
||||||
|
return &FormRequest{
|
||||||
|
Kind: kind,
|
||||||
|
SpecPath: specPath,
|
||||||
|
DataPath: vv.CanonicalAbs,
|
||||||
|
SubmitURL: urlPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
|
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
|
||||||
dataAbs := filepath.Join(fsRoot, dataRel)
|
dataAbs := filepath.Join(fsRoot, dataRel)
|
||||||
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
|
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
|
||||||
|
|
@ -192,6 +247,8 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
|
||||||
serveFormCreate(cfg, req, w, r)
|
serveFormCreate(cfg, req, w, r)
|
||||||
case "update":
|
case "update":
|
||||||
serveFormUpdate(cfg, req, w, r)
|
serveFormUpdate(cfg, req, w, r)
|
||||||
|
case "create-via-ssr":
|
||||||
|
serveFormCreateSSR(cfg, req, w, r)
|
||||||
default:
|
default:
|
||||||
http.Error(w, "unknown form request kind", http.StatusInternalServerError)
|
http.Error(w, "unknown form request kind", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
@ -417,13 +474,13 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
|
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Default-MDL virtual fallback: when the operator hasn't placed
|
// Default-spec virtual fallback: when no operator file exists at
|
||||||
// an mdl.form.yaml under archive/<party>/, serve the embedded
|
// path, serve the embedded default if path matches one of the
|
||||||
// default. Mirrors the static-handler fallback for direct YAML
|
// recognized virtual fallback shapes (per-party mdl/rsk, per-
|
||||||
// fetches so the form recognizer and the loader agree on what
|
// party SSR schema, project-level virtual specs). Mirrors the
|
||||||
// "this spec exists" means.
|
// static-handler fallback for direct YAML fetches.
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
if bytes, ok := IsDefaultMdlSpecAbs(fsRoot, path); ok {
|
if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
|
||||||
data = bytes
|
data = bytes
|
||||||
} else {
|
} else {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
287
zddc/internal/handler/ssrhandler.go
Normal file
287
zddc/internal/handler/ssrhandler.go
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
// Package handler — ssrhandler.go: Supplier/Subcontractor Status Report
|
||||||
|
// lifecycle endpoints that don't fit the generic form / file API shapes.
|
||||||
|
//
|
||||||
|
// Two endpoints live here:
|
||||||
|
//
|
||||||
|
// POST /<project>/ssr/form.html → "+ Add row" / SSR create
|
||||||
|
// POST /<project>/ssr/<old>.yaml with X-ZDDC-Op: ssr-rename
|
||||||
|
// X-ZDDC-Destination: /<project>/ssr/<new>.yaml
|
||||||
|
//
|
||||||
|
// Both target the project-level SSR aggregator, which is a virtual view
|
||||||
|
// (see zddc/virtualviews.go). Internally they materialize / rename real
|
||||||
|
// party folders under <project>/archive/.
|
||||||
|
//
|
||||||
|
// The generic file API only does file-level moves; renaming a party
|
||||||
|
// folder means renaming a directory and bringing every row inside with
|
||||||
|
// it. Rather than weaken serveFileMove's IsDir-source guard (which keeps
|
||||||
|
// callers from accidentally moving entire subtrees), this file
|
||||||
|
// implements ssr-rename as a tightly-scoped op that only fires for the
|
||||||
|
// SSR virtual URL shape.
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// opSSRRename is the X-ZDDC-Op value that fires serveSSRRename. Dispatched
|
||||||
|
// from serveFilePost in fileapi.go.
|
||||||
|
const opSSRRename = "ssr-rename"
|
||||||
|
|
||||||
|
// serveFormCreateSSR materializes a new party folder under
|
||||||
|
// <project>/archive/<name>/ and writes archive/<name>/ssr.yaml from the
|
||||||
|
// submitted form body. The `name` field doubles as the party folder
|
||||||
|
// name and is stripped from the YAML before write (path-derived).
|
||||||
|
//
|
||||||
|
// ACL gate: ActionCreate at <project>/archive/<name>/ — typically
|
||||||
|
// satisfied by document_controller's rwc grant on archive/ in the
|
||||||
|
// project-level cascade, OR by a deployment that grants `c` to a wider
|
||||||
|
// audience.
|
||||||
|
//
|
||||||
|
// Auto-own .zddc is seeded for the new party folder via
|
||||||
|
// WriteAutoOwnZddc, the same machinery the generic mkdir uses.
|
||||||
|
// Authenticated email is required (401 otherwise) so the auto-own
|
||||||
|
// grant always names a real principal.
|
||||||
|
func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
|
||||||
|
email := EmailFromContext(r)
|
||||||
|
if email == "" {
|
||||||
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Project == "" {
|
||||||
|
http.Error(w, "internal: SSR create missing project", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := decodeRequestData(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spec, err := loadFormSpec(cfg.Root, req.SpecPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
|
||||||
|
writeValidationErrors(w, errs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataMap, ok := data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nameRaw, _ := dataMap["name"].(string)
|
||||||
|
name := strings.TrimSpace(nameRaw)
|
||||||
|
if !zddc.ValidPartyName(name) {
|
||||||
|
writeValidationErrors(w, []jsonschema.Error{{
|
||||||
|
Path: "/name",
|
||||||
|
Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`,
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name)
|
||||||
|
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
partyURL := "/" + req.Project + "/archive/" + name + "/"
|
||||||
|
rowURL := "/" + req.Project + "/ssr/" + name + ".yaml"
|
||||||
|
|
||||||
|
// ACL gate: create at <project>/archive/<name>/. authorizeAction walks
|
||||||
|
// up to the closest existing ancestor for the chain — typically
|
||||||
|
// <project>/archive/, where document_controller carries rwc per the
|
||||||
|
// project-level cascade.
|
||||||
|
if !authorizeAction(cfg, w, r, partyAbs, partyURL, policy.ActionCreate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse to clobber an existing party folder. The SSR view shows
|
||||||
|
// any folder under archive/*/; if one with this name exists, the
|
||||||
|
// user should edit that row instead of creating a duplicate.
|
||||||
|
if info, err := os.Stat(partyAbs); err == nil {
|
||||||
|
if info.IsDir() {
|
||||||
|
http.Error(w, "Conflict — a party folder with that name already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Conflict — a file exists at this path", http.StatusConflict)
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Materialize canonical ancestors (<project>/archive/) with auto-own
|
||||||
|
// seeding before creating the party folder itself. Mirrors what the
|
||||||
|
// generic file-API mkdir does at fileapi.go:629-634.
|
||||||
|
yamlAbs := filepath.Join(partyAbs, "ssr.yaml")
|
||||||
|
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil {
|
||||||
|
slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(partyAbs, 0o755); err != nil {
|
||||||
|
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Auto-own .zddc on the new party folder. archive/*/ is declared
|
||||||
|
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
|
||||||
|
// fires here exactly as it would for a manual mkdir.
|
||||||
|
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
|
||||||
|
var werr error
|
||||||
|
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
|
||||||
|
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email)
|
||||||
|
} else {
|
||||||
|
werr = zddc.WriteAutoOwnZddc(partyAbs, email)
|
||||||
|
}
|
||||||
|
if werr != nil {
|
||||||
|
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the path-derived `name` field — it's the folder name, not
|
||||||
|
// row data. The dispatcher re-injects it on read.
|
||||||
|
delete(dataMap, "name")
|
||||||
|
|
||||||
|
yamlBytes, err := yaml.Marshal(dataMap)
|
||||||
|
if err != nil {
|
||||||
|
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err)
|
||||||
|
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil {
|
||||||
|
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err)
|
||||||
|
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Location", rowURL)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("X-ZDDC-Source", "ssr-create")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL)))
|
||||||
|
auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveSSRRename renames a party folder by rewriting an SSR row URL.
|
||||||
|
//
|
||||||
|
// Wire-form: POST /<project>/ssr/<old>.yaml
|
||||||
|
//
|
||||||
|
// X-ZDDC-Op: ssr-rename
|
||||||
|
// X-ZDDC-Destination: /<project>/ssr/<new>.yaml
|
||||||
|
//
|
||||||
|
// Both source and destination must resolve as SSR-row virtual URLs in
|
||||||
|
// the same project. The destination party name must be a valid party
|
||||||
|
// folder name, must differ from the source, and must not already exist.
|
||||||
|
//
|
||||||
|
// ACL: ActionWrite at <project>/archive/<old>/ AND ActionCreate at
|
||||||
|
// <project>/archive/<new>/. Both gates evaluate against their canonical
|
||||||
|
// archive paths so non-owners can't rename someone else's party.
|
||||||
|
//
|
||||||
|
// On success: os.Rename moves archive/<old>/ → archive/<new>/ (an
|
||||||
|
// atomic directory rename on the same filesystem); every row inside
|
||||||
|
// follows. No denormalized `party:` field rewrite is needed — the
|
||||||
|
// MDL/RSK schemas don't carry one.
|
||||||
|
func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
|
email := EmailFromContext(r)
|
||||||
|
if email == "" {
|
||||||
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path)
|
||||||
|
if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow {
|
||||||
|
http.Error(w, "Bad Request — ssr-rename source must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dstHeader := r.Header.Get(headerDestination)
|
||||||
|
if dstHeader == "" {
|
||||||
|
http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dec, err := url.PathUnescape(dstHeader); err == nil {
|
||||||
|
dstHeader = dec
|
||||||
|
}
|
||||||
|
dst := zddc.ResolveVirtualView(cfg.Root, dstHeader)
|
||||||
|
if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow {
|
||||||
|
http.Error(w, "Bad Request — ssr-rename destination must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dst.Project != src.Project {
|
||||||
|
http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dst.Party == src.Party {
|
||||||
|
http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source party folder must exist.
|
||||||
|
srcArchive := src.PartyArchive
|
||||||
|
if info, err := os.Stat(srcArchive); err != nil || !info.IsDir() {
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Destination must not exist.
|
||||||
|
dstArchive := dst.PartyArchive
|
||||||
|
if _, err := os.Stat(dstArchive); err == nil {
|
||||||
|
http.Error(w, "Conflict — destination party folder already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACL: write on src archive, create on dst archive. URLs include
|
||||||
|
// the trailing slash convention used elsewhere for directory ops.
|
||||||
|
srcArchiveURL := "/" + src.Project + "/archive/" + src.Party + "/"
|
||||||
|
dstArchiveURL := "/" + dst.Project + "/archive/" + dst.Party + "/"
|
||||||
|
if !authorizeAction(cfg, w, r, srcArchive, srcArchiveURL, policy.ActionWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional If-Match against the source ssr.yaml etag.
|
||||||
|
if !checkIfMatch(w, r, src.CanonicalAbs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(srcArchive, dstArchive); err != nil {
|
||||||
|
auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newURL := "/" + dst.Project + "/ssr/" + dst.Party + ".yaml"
|
||||||
|
w.Header().Set("Location", newURL)
|
||||||
|
w.Header().Set("X-ZDDC-Destination", newURL)
|
||||||
|
w.Header().Set("X-ZDDC-Source", "ssr-rename")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp, _ := json.Marshal(map[string]string{"location": newURL})
|
||||||
|
_, _ = w.Write(resp)
|
||||||
|
auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil)
|
||||||
|
}
|
||||||
214
zddc/internal/handler/ssrhandler_test.go
Normal file
214
zddc/internal/handler/ssrhandler_test.go
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ssrTestSetup builds a fresh project root with permissive top-level
|
||||||
|
// ACL that lets *@example.com create + write anywhere under archive/.
|
||||||
|
// Returns (cfg, do) where do dispatches a request through the same
|
||||||
|
// recognize→serve path the production catch-all uses.
|
||||||
|
func ssrTestSetup(t *testing.T) (config.Config, func(method, target, email, body string, headers map[string]string) *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
// Project root: grant the test cohort rwc at the project level so
|
||||||
|
// they can create archive/<party>/ folders.
|
||||||
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||||
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||||
|
|
||||||
|
do := func(method, target, email, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
var req *http.Request
|
||||||
|
if body != "" {
|
||||||
|
req = httptest.NewRequest(method, target, bytes.NewReader([]byte(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, target, nil)
|
||||||
|
}
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||||
|
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// SSR create flows through RecognizeFormRequest → ServeForm →
|
||||||
|
// create-via-ssr case. Rename flows through ServeFileAPI's POST
|
||||||
|
// dispatch (ssr-rename op).
|
||||||
|
if method == http.MethodPost && strings.Contains(target, "/ssr/") &&
|
||||||
|
strings.HasSuffix(target, ".yaml") {
|
||||||
|
ServeFileAPI(cfg, rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
formReq := RecognizeFormRequest(cfg.Root, method, target)
|
||||||
|
if formReq != nil {
|
||||||
|
ServeForm(cfg, formReq, rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
rec.WriteHeader(http.StatusNotFound)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
return cfg, do
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRCreate_HappyPath(t *testing.T) {
|
||||||
|
cfg, do := ssrTestSetup(t)
|
||||||
|
|
||||||
|
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"Concrete works"}`
|
||||||
|
rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
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"))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
yaml := string(yamlBytes)
|
||||||
|
if !strings.Contains(yaml, "contractNo: PO-001") {
|
||||||
|
t.Errorf("ssr.yaml 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRCreate_AnonymousRejected(t *testing.T) {
|
||||||
|
_, do := ssrTestSetup(t)
|
||||||
|
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
|
||||||
|
rec := do(http.MethodPost, "/Project/ssr/form.html", "", body, nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status=%d want 401; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRCreate_InvalidName(t *testing.T) {
|
||||||
|
_, do := ssrTestSetup(t)
|
||||||
|
cases := []string{
|
||||||
|
`{"name":".hidden","vendorType":"subcontractor","contractNo":"x","scopeSummary":"x"}`,
|
||||||
|
`{"name":"with space","vendorType":"subcontractor","contractNo":"x","scopeSummary":"x"}`,
|
||||||
|
}
|
||||||
|
for _, body := range cases {
|
||||||
|
rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil)
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity && rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("body=%s status=%d want 422 or 400", body, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRCreate_DuplicateName(t *testing.T) {
|
||||||
|
cfg, do := ssrTestSetup(t)
|
||||||
|
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
|
||||||
|
rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("first create failed: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project", "archive", "0330C1"))
|
||||||
|
rec = do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Errorf("duplicate create: status=%d want 409", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRRename_HappyPath(t *testing.T) {
|
||||||
|
cfg, do := ssrTestSetup(t)
|
||||||
|
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
|
||||||
|
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,
|
||||||
|
"X-ZDDC-Destination": "/Project/ssr/0330C2.yaml",
|
||||||
|
})
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRRename_CrossProjectRejected(t *testing.T) {
|
||||||
|
cfg, do := ssrTestSetup(t)
|
||||||
|
body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
|
||||||
|
if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("setup create failed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project"))
|
||||||
|
rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "",
|
||||||
|
map[string]string{
|
||||||
|
"X-ZDDC-Op": opSSRRename,
|
||||||
|
"X-ZDDC-Destination": "/OtherProject/ssr/0330C1.yaml",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("cross-project rename: status=%d want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRRename_DestinationExists(t *testing.T) {
|
||||||
|
cfg, do := ssrTestSetup(t)
|
||||||
|
bodyA := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}`
|
||||||
|
bodyB := `{"name":"0330C2","vendorType":"subcontractor","contractNo":"PO-002","scopeSummary":"y"}`
|
||||||
|
for _, b := range []string{bodyA, bodyB} {
|
||||||
|
if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", b, nil); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("setup create failed: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(filepath.Join(cfg.Root, "Project"))
|
||||||
|
rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "",
|
||||||
|
map[string]string{
|
||||||
|
"X-ZDDC-Op": opSSRRename,
|
||||||
|
"X-ZDDC-Destination": "/Project/ssr/0330C2.yaml",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Errorf("rename to existing: status=%d want 409", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,56 +47,78 @@ var embeddedDefaultMdlTable []byte
|
||||||
//go:embed default-mdl.form.yaml
|
//go:embed default-mdl.form.yaml
|
||||||
var embeddedDefaultMdlForm []byte
|
var embeddedDefaultMdlForm []byte
|
||||||
|
|
||||||
|
//go:embed default-rsk.table.yaml
|
||||||
|
var embeddedDefaultRskTable []byte
|
||||||
|
|
||||||
|
//go:embed default-rsk.form.yaml
|
||||||
|
var embeddedDefaultRskForm []byte
|
||||||
|
|
||||||
|
//go:embed default-ssr.table.yaml
|
||||||
|
var embeddedDefaultSsrTable []byte
|
||||||
|
|
||||||
|
//go:embed default-ssr.form.yaml
|
||||||
|
var embeddedDefaultSsrForm []byte
|
||||||
|
|
||||||
|
//go:embed default-project-mdl.table.yaml
|
||||||
|
var embeddedDefaultProjectMdlTable []byte
|
||||||
|
|
||||||
|
//go:embed default-project-rsk.table.yaml
|
||||||
|
var embeddedDefaultProjectRskTable []byte
|
||||||
|
|
||||||
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
|
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
|
||||||
// Used by the static-file handler to serve the default spec at
|
// Used by callers that need the canonical spec without going through
|
||||||
// archive/<party>/mdl.table.yaml when no operator file exists on disk.
|
// the URL-recognition path.
|
||||||
func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
|
func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
|
||||||
|
|
||||||
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
|
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
|
||||||
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
|
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
|
||||||
|
|
||||||
// IsDefaultMdlSpec reports whether urlPath is one of the default-MDL
|
// DefaultRskTableYAML returns the embedded default rsk.table.yaml bytes.
|
||||||
// virtual files served when no operator file exists on disk:
|
func DefaultRskTableYAML() []byte { return embeddedDefaultRskTable }
|
||||||
|
|
||||||
|
// DefaultRskFormYAML returns the embedded default rsk.form.yaml bytes.
|
||||||
|
func DefaultRskFormYAML() []byte { return embeddedDefaultRskForm }
|
||||||
|
|
||||||
|
// DefaultSsrTableYAML returns the embedded default ssr.table.yaml bytes.
|
||||||
|
func DefaultSsrTableYAML() []byte { return embeddedDefaultSsrTable }
|
||||||
|
|
||||||
|
// DefaultSsrFormYAML returns the embedded default ssr.form.yaml bytes.
|
||||||
|
func DefaultSsrFormYAML() []byte { return embeddedDefaultSsrForm }
|
||||||
|
|
||||||
|
// DefaultProjectMdlTableYAML returns the embedded project-rollup
|
||||||
|
// mdl.table.yaml bytes.
|
||||||
|
func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable }
|
||||||
|
|
||||||
|
// DefaultProjectRskTableYAML returns the embedded project-rollup
|
||||||
|
// rsk.table.yaml bytes.
|
||||||
|
func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable }
|
||||||
|
|
||||||
|
// IsDefaultSpec reports whether urlPath is one of the embedded
|
||||||
|
// default-spec virtual files served when no operator file exists on
|
||||||
|
// disk. Recognized URL shapes:
|
||||||
//
|
//
|
||||||
// <project>/archive/<party>/mdl/table.yaml
|
// <project>/archive/<party>/mdl/{table.yaml, form.yaml}
|
||||||
// <project>/archive/<party>/mdl/form.yaml
|
// <project>/archive/<party>/rsk/{table.yaml, form.yaml}
|
||||||
|
// <project>/archive/<party>/ssr.form.yaml
|
||||||
|
// <project>/ssr/{table.yaml, form.yaml}
|
||||||
|
// <project>/mdl/{table.yaml, form.yaml}
|
||||||
|
// <project>/rsk/{table.yaml, form.yaml}
|
||||||
//
|
//
|
||||||
// The MDL files live INSIDE the rows-dir along with row YAMLs so the
|
// Returns embedded bytes + true when the fallback should fire; nil +
|
||||||
// whole directory is self-contained — copying mdl/ moves the spec,
|
// false when an operator file exists at that path or the URL is not
|
||||||
// the form, and all rows together. Returns embedded bytes + true when
|
// eligible. Operator files always win.
|
||||||
// the fallback should fire; nil + false when an operator-supplied
|
func IsDefaultSpec(fsRoot, urlPath string) ([]byte, bool) {
|
||||||
// file exists or the path is not eligible.
|
|
||||||
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
|
|
||||||
base := strings.ToLower(filepath.Base(urlPath))
|
|
||||||
var bytes []byte
|
|
||||||
switch base {
|
|
||||||
case "table.yaml":
|
|
||||||
bytes = embeddedDefaultMdlTable
|
|
||||||
case "form.yaml":
|
|
||||||
bytes = embeddedDefaultMdlForm
|
|
||||||
default:
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if !isAtArchivePartyMdlLevel(fsRoot, urlPath) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
// Operator file wins if it exists on disk.
|
|
||||||
rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/")
|
rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/")
|
||||||
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
||||||
if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot {
|
if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if fileExists(abs) {
|
return IsDefaultSpecAbs(fsRoot, abs)
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return bytes, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDefaultMdlSpecAbs is the abs-path-keyed variant of IsDefaultMdlSpec.
|
// IsDefaultSpecAbs is the abs-path-keyed variant of IsDefaultSpec.
|
||||||
// Used by handlers that hold a filesystem path rather than a URL.
|
// Used by handlers that hold a filesystem path rather than a URL.
|
||||||
// Returns the embedded default bytes + true when absPath is the
|
func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
|
||||||
// virtual archive/<party>/{mdl.table.yaml, mdl.form.yaml} fallback.
|
|
||||||
func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) {
|
|
||||||
if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot {
|
if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
@ -104,8 +126,87 @@ func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
urlPath := "/" + filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
return IsDefaultMdlSpec(fsRoot, urlPath)
|
if rel == "" || rel == "." || strings.HasPrefix(rel, "../") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
bytes := classifyDefaultSpec(rel)
|
||||||
|
if bytes == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// Operator file wins if it exists on disk.
|
||||||
|
if fileExists(absPath) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return bytes, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyDefaultSpec maps a slash-form path (relative to fsRoot) to
|
||||||
|
// the matching embedded default-spec bytes, or nil if the path does
|
||||||
|
// not name one of the recognized virtual fallback files.
|
||||||
|
func classifyDefaultSpec(rel string) []byte {
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
switch len(parts) {
|
||||||
|
case 5:
|
||||||
|
// <project>/archive/<party>/<slot>/<file>
|
||||||
|
if !strings.EqualFold(parts[1], "archive") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slot := strings.ToLower(parts[3])
|
||||||
|
file := strings.ToLower(parts[4])
|
||||||
|
switch slot {
|
||||||
|
case "mdl":
|
||||||
|
switch file {
|
||||||
|
case "table.yaml":
|
||||||
|
return embeddedDefaultMdlTable
|
||||||
|
case "form.yaml":
|
||||||
|
return embeddedDefaultMdlForm
|
||||||
|
}
|
||||||
|
case "rsk":
|
||||||
|
switch file {
|
||||||
|
case "table.yaml":
|
||||||
|
return embeddedDefaultRskTable
|
||||||
|
case "form.yaml":
|
||||||
|
return embeddedDefaultRskForm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
// <project>/archive/<party>/<file> — only ssr.form.yaml is virtual.
|
||||||
|
if !strings.EqualFold(parts[1], "archive") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(parts[3], "ssr.form.yaml") {
|
||||||
|
return embeddedDefaultSsrForm
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
// <project>/<slot>/<file> — project-level virtual specs.
|
||||||
|
slot := strings.ToLower(parts[1])
|
||||||
|
file := strings.ToLower(parts[2])
|
||||||
|
switch slot {
|
||||||
|
case "ssr":
|
||||||
|
switch file {
|
||||||
|
case "table.yaml":
|
||||||
|
return embeddedDefaultSsrTable
|
||||||
|
case "form.yaml":
|
||||||
|
return embeddedDefaultSsrForm
|
||||||
|
}
|
||||||
|
case "mdl":
|
||||||
|
switch file {
|
||||||
|
case "table.yaml":
|
||||||
|
return embeddedDefaultProjectMdlTable
|
||||||
|
case "form.yaml":
|
||||||
|
return embeddedDefaultMdlForm
|
||||||
|
}
|
||||||
|
case "rsk":
|
||||||
|
switch file {
|
||||||
|
case "table.yaml":
|
||||||
|
return embeddedDefaultProjectRskTable
|
||||||
|
case "form.yaml":
|
||||||
|
return embeddedDefaultRskForm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAtArchivePartyLevel reports whether urlPath refers to a file
|
// isAtArchivePartyLevel reports whether urlPath refers to a file
|
||||||
|
|
@ -114,33 +215,19 @@ func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) {
|
||||||
func isAtArchivePartyLevel(fsRoot, urlPath string) bool {
|
func isAtArchivePartyLevel(fsRoot, urlPath string) bool {
|
||||||
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
|
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
|
||||||
parts := strings.Split(rel, "/")
|
parts := strings.Split(rel, "/")
|
||||||
// <project>/archive/<party>/<file> = 4 segments
|
|
||||||
if len(parts) != 4 {
|
if len(parts) != 4 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.EqualFold(parts[1], "archive")
|
return strings.EqualFold(parts[1], "archive")
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAtArchivePartyMdlLevel reports whether urlPath refers to a file
|
|
||||||
// directly under <project>/archive/<party>/mdl/ (depth-4 directory).
|
|
||||||
// Used by the default-MDL fallback after the spec/form moved INSIDE
|
|
||||||
// the rows-dir for self-containment.
|
|
||||||
func isAtArchivePartyMdlLevel(fsRoot, urlPath string) bool {
|
|
||||||
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
// <project>/archive/<party>/mdl/<file> = 5 segments
|
|
||||||
if len(parts) != 5 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableRequest describes a recognized table-system request.
|
// TableRequest describes a recognized table-system request.
|
||||||
type TableRequest struct {
|
type TableRequest struct {
|
||||||
// Name is the table's URL stem (the key declared in .zddc tables).
|
// Name is the table's URL stem (the key declared in .zddc tables).
|
||||||
Name string
|
Name string
|
||||||
// SpecPath is the absolute filesystem path to the *.table.yaml.
|
// SpecPath is the absolute filesystem path to the *.table.yaml.
|
||||||
// Validated to exist at recognition time.
|
// May reference a virtual path when the spec is served from
|
||||||
|
// embedded defaults.
|
||||||
SpecPath string
|
SpecPath string
|
||||||
// Dir is the absolute path to the request directory (where the
|
// Dir is the absolute path to the request directory (where the
|
||||||
// .zddc declared the table).
|
// .zddc declared the table).
|
||||||
|
|
@ -149,15 +236,14 @@ type TableRequest struct {
|
||||||
|
|
||||||
// tableRowsRedirect reports the canonical /<dir>/table.html URL to
|
// tableRowsRedirect reports the canonical /<dir>/table.html URL to
|
||||||
// redirect to when (urlPath) names a directory that contains a
|
// redirect to when (urlPath) names a directory that contains a
|
||||||
// table.yaml (or matches the default-MDL fallback). Returns "" when
|
// table.yaml (or matches one of the default-spec fallbacks). Returns
|
||||||
// no redirect should fire.
|
// "" when no redirect should fire.
|
||||||
//
|
//
|
||||||
// Recognition reuses RecognizeTableRequest by synthesizing the
|
// Recognition reuses RecognizeTableRequest by synthesizing the
|
||||||
// equivalent <urlPath>table.html and asking the recognizer whether
|
// equivalent <urlPath>table.html and asking the recognizer whether
|
||||||
// it's a real (or default-MDL) table. Single source of truth for
|
// it's a real (or default-spec) table. Single source of truth for
|
||||||
// validation.
|
// validation.
|
||||||
func tableRowsRedirect(fsRoot, urlPath string) string {
|
func tableRowsRedirect(fsRoot, urlPath string) string {
|
||||||
// urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/".
|
|
||||||
if urlPath == "" || urlPath == "/" {
|
if urlPath == "" || urlPath == "/" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -169,11 +255,11 @@ func tableRowsRedirect(fsRoot, urlPath string) string {
|
||||||
if tr == nil {
|
if tr == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// Default-MDL case (no on-disk table.yaml): follow the slash/no-
|
// Default-spec case (no on-disk table.yaml): follow the slash/no-
|
||||||
// slash convention — slash form serves browse, no-slash serves
|
// slash convention — slash form serves browse, no-slash serves
|
||||||
// tables (handled by the dispatcher). Redirecting here would
|
// tables (handled by the dispatcher). Redirecting here would
|
||||||
// override the convention and force the user into the table view
|
// override the convention and force the user into the table view
|
||||||
// from any /<party>/mdl/ click.
|
// from any /<party>/{mdl,rsk}/ click.
|
||||||
if !fileExists(tr.SpecPath) {
|
if !fileExists(tr.SpecPath) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -183,15 +269,13 @@ func tableRowsRedirect(fsRoot, urlPath string) string {
|
||||||
// RecognizeTableRequest classifies r as a table-system request, or
|
// RecognizeTableRequest classifies r as a table-system request, or
|
||||||
// returns nil if it falls through to other handlers. Discovery is
|
// returns nil if it falls through to other handlers. Discovery is
|
||||||
// presence-based and self-contained: a /<dir>/table.html URL fires
|
// presence-based and self-contained: a /<dir>/table.html URL fires
|
||||||
// when <dir>/table.yaml exists on disk, or when the default-MDL
|
// when <dir>/table.yaml exists on disk, or when one of the default-
|
||||||
// fallback at archive/<party>/mdl/ applies.
|
// spec fallbacks applies (per-party mdl/rsk under archive/<party>/,
|
||||||
|
// or project-level ssr/mdl/rsk virtual aggregations).
|
||||||
//
|
//
|
||||||
// The spec, the row-edit form, and all rows live together in <dir>.
|
// The table's "name" is the directory's basename for on-disk and
|
||||||
// Copying <dir> elsewhere copies everything needed to re-host the
|
// per-party-virtual tables (e.g. "mdl"); for project-level virtual
|
||||||
// table — that's the whole point of the in-dir layout.
|
// tables it's the slot name ("ssr", "mdl", "rsk").
|
||||||
//
|
|
||||||
// The table's "name" is the directory's basename (so the URL
|
|
||||||
// /<parent>/mdl/table.html names the "mdl" table, with rows in mdl/).
|
|
||||||
//
|
//
|
||||||
// Methods other than GET return nil — the table is read-only at the
|
// Methods other than GET return nil — the table is read-only at the
|
||||||
// URL level. Writes go through the file API directly.
|
// URL level. Writes go through the file API directly.
|
||||||
|
|
@ -203,12 +287,10 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip /table.html — what remains is the rows-dir.
|
|
||||||
rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html")
|
rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html")
|
||||||
rel = strings.TrimSuffix(rel, "table.html") // handles "/table.html" at root → ""
|
rel = strings.TrimSuffix(rel, "table.html")
|
||||||
rel = strings.Trim(rel, "/")
|
rel = strings.Trim(rel, "/")
|
||||||
if rel == "" {
|
if rel == "" {
|
||||||
// /table.html at root has no rows-dir to name.
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
||||||
|
|
@ -224,34 +306,49 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
||||||
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
|
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default-MDL virtual-spec fallback at archive/<party>/mdl/. The
|
// Default-spec fallbacks — the rows-dir itself may not exist on
|
||||||
// spec bytes come from IsDefaultMdlSpec via the static-file
|
// disk yet (fully virtual). The static-file dispatcher serves the
|
||||||
// dispatcher when no on-disk file exists at that path; the rows-dir
|
// embedded spec bytes from IsDefaultSpecAbs when the client
|
||||||
// itself doesn't need to exist either (fully virtual archive).
|
// fetches <dir>/table.yaml client-side.
|
||||||
if isAtArchivePartyMdlDir(fsRoot, dirAbs) {
|
if slot, ok := classifyVirtualTableDir(fsRoot, dirAbs); ok {
|
||||||
return &TableRequest{Name: "mdl", SpecPath: specAbs, Dir: dirAbs}
|
return &TableRequest{Name: slot, SpecPath: specAbs, Dir: dirAbs}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAtArchivePartyMdlDir reports whether dirAbs is exactly
|
// classifyVirtualTableDir reports whether dirAbs is one of the
|
||||||
// <fsRoot>/<project>/archive/<party>/mdl. Used by the default-MDL
|
// virtual-spec table dirs and returns its slot name ("mdl", "rsk",
|
||||||
// fallback to recognize the virtual rows-dir whether or not it
|
// or "ssr"). Recognizes both per-party slots
|
||||||
// exists on disk.
|
// (<project>/archive/<party>/{mdl,rsk}) and project-level slots
|
||||||
func isAtArchivePartyMdlDir(fsRoot, dirAbs string) bool {
|
// (<project>/{ssr,mdl,rsk}).
|
||||||
|
func classifyVirtualTableDir(fsRoot, dirAbs string) (string, bool) {
|
||||||
rel, err := filepath.Rel(fsRoot, dirAbs)
|
rel, err := filepath.Rel(fsRoot, dirAbs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return "", false
|
||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
|
if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
|
||||||
return false
|
return "", false
|
||||||
}
|
}
|
||||||
parts := strings.Split(rel, "/")
|
parts := strings.Split(rel, "/")
|
||||||
if len(parts) != 4 {
|
switch len(parts) {
|
||||||
return false
|
case 2:
|
||||||
|
// <project>/<slot>
|
||||||
|
slot := strings.ToLower(parts[1])
|
||||||
|
if slot == "ssr" || slot == "mdl" || slot == "rsk" {
|
||||||
|
return slot, true
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
// <project>/archive/<party>/<slot>
|
||||||
|
if !strings.EqualFold(parts[1], "archive") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
slot := strings.ToLower(parts[3])
|
||||||
|
if slot == "mdl" || slot == "rsk" {
|
||||||
|
return slot, true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl")
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNotExistError reports whether err indicates a missing file. Local
|
// isNotExistError reports whether err indicates a missing file. Local
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,11 @@ func TestRecognizeTableRequest(t *testing.T) {
|
||||||
{"DELETE", "/Working/MDL/table.html", true, "", ""},
|
{"DELETE", "/Working/MDL/table.html", true, "", ""},
|
||||||
// No table.yaml in this dir → not a table request.
|
// No table.yaml in this dir → not a table request.
|
||||||
{"GET", "/Working/Other/table.html", true, "", ""},
|
{"GET", "/Working/Other/table.html", true, "", ""},
|
||||||
// No table.yaml anywhere → not a table request.
|
// /<project>/mdl/ now resolves as the project-level virtual MDL
|
||||||
{"GET", "/Other/MDL/table.html", true, "", ""},
|
// rollup (independent of any on-disk file). Recognized as the
|
||||||
|
// virtual table named "mdl"; the spec bytes are served from
|
||||||
|
// embedded defaults via IsDefaultSpec on the client fetch.
|
||||||
|
{"GET", "/Other/mdl/table.html", false, "Other/mdl/table.yaml", "mdl"},
|
||||||
// Random .html → falls through.
|
// Random .html → falls through.
|
||||||
{"GET", "/index.html", true, "", ""},
|
{"GET", "/index.html", true, "", ""},
|
||||||
// /form.html in the same dir is form territory, not a table.
|
// /form.html in the same dir is form territory, not a table.
|
||||||
|
|
@ -295,14 +298,14 @@ func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// archive/Acme/ exists but no mdl/table.yaml on disk.
|
// archive/Acme/ exists but no mdl/table.yaml on disk.
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml")
|
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected fallback to fire")
|
t.Fatalf("expected fallback to fire")
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +313,7 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
||||||
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
||||||
}
|
}
|
||||||
|
|
||||||
bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/form.yaml")
|
bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/mdl/form.yaml")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected form fallback to fire")
|
t.Fatalf("expected form fallback to fire")
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +322,7 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
|
func TestIsDefaultSpec_MDL_OperatorFileWins(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
|
mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
|
||||||
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||||
|
|
@ -328,12 +331,12 @@ func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
|
||||||
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok {
|
if _, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok {
|
||||||
t.Errorf("operator file should win over embedded fallback")
|
t.Errorf("operator file should win over embedded fallback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
|
func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"/Project/working/mdl/table.yaml",
|
"/Project/working/mdl/table.yaml",
|
||||||
|
|
@ -341,9 +344,100 @@ func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
|
||||||
"/Project/archive/Acme/sub/mdl/table.yaml",
|
"/Project/archive/Acme/sub/mdl/table.yaml",
|
||||||
}
|
}
|
||||||
for _, p := range cases {
|
for _, p := range cases {
|
||||||
if _, ok := IsDefaultMdlSpec(root, p); ok {
|
if _, ok := IsDefaultSpec(root, p); ok {
|
||||||
t.Errorf("path %q should NOT trigger default fallback", p)
|
t.Errorf("path %q should NOT trigger default fallback", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- RSK + SSR + project-rollup default-spec recognition --------------------
|
||||||
|
|
||||||
|
func TestRecognizeTableRequest_DefaultRskAtArchiveParty(t *testing.T) {
|
||||||
|
_, do := archivePartyTestSetup(t, "")
|
||||||
|
rec := do(http.MethodGet, "/Project/archive/Acme/rsk/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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecognizeTableRequest_ProjectVirtualTables(t *testing.T) {
|
||||||
|
_, do := archivePartyTestSetup(t, "")
|
||||||
|
for _, slot := range []string{"ssr", "mdl", "rsk"} {
|
||||||
|
rec := do(http.MethodGet, "/Project/"+slot+"/table.html", "alice@example.com")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("project-level virtual table %q: want 200, got %d", slot, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDefaultSpec_RSK_ServesEmbeddedYAML(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected RSK form fallback to fire")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(bts), "Risk") {
|
||||||
|
t.Error("default RSK form spec missing expected title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDefaultSpec_SSR_PerParty(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
// archive/<party>/ssr.form.yaml — per-party SSR schema.
|
||||||
|
bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/ssr.form.yaml")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected per-party SSR schema fallback to fire")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(bts), "Supplier") {
|
||||||
|
t.Errorf("per-party SSR schema missing expected header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDefaultSpec_ProjectLevel(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
url string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{"/Project/ssr/table.yaml", "Supplier"},
|
||||||
|
{"/Project/ssr/form.yaml", "Supplier"},
|
||||||
|
{"/Project/mdl/table.yaml", "Project Deliverables"},
|
||||||
|
{"/Project/mdl/form.yaml", "Deliverable"},
|
||||||
|
{"/Project/rsk/table.yaml", "Project Risk Register"},
|
||||||
|
{"/Project/rsk/form.yaml", "Risk"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
bts, ok := IsDefaultSpec(root, tc.url)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: expected fallback to fire", tc.url)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(bts), tc.contains) {
|
||||||
|
t.Errorf("%s: body missing %q; got %q…", tc.url, tc.contains, string(bts)[:min(120, len(bts))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDefaultSpec_ProjectLevel_OperatorOverrides(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Project", "ssr"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "Project", "ssr", "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := IsDefaultSpec(root, "/Project/ssr/table.yaml"); ok {
|
||||||
|
t.Errorf("operator file should win at /Project/ssr/table.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1511,7 +1511,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 22:38:21 · 85e6eb1-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 02:25:26 · da4754b-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -3548,6 +3548,11 @@ body.is-elevated::after {
|
||||||
description: spec.description,
|
description: spec.description,
|
||||||
columns: spec.columns,
|
columns: spec.columns,
|
||||||
defaults: spec.defaults,
|
defaults: spec.defaults,
|
||||||
|
// addable defaults to true; tables can opt out with
|
||||||
|
// `addable: false` (used by project-rollup MDL/RSK where the
|
||||||
|
// party affiliation of a new row is ambiguous — add at the
|
||||||
|
// per-party path instead).
|
||||||
|
addable: spec.addable !== false,
|
||||||
rowSchema: rowSchema,
|
rowSchema: rowSchema,
|
||||||
rows: rows
|
rows: rows
|
||||||
};
|
};
|
||||||
|
|
@ -6231,7 +6236,11 @@ body.is-elevated::after {
|
||||||
if (addRowBtn) {
|
if (addRowBtn) {
|
||||||
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||||
if (onHttp && hasCols) {
|
// ctx.addable === false suppresses the affordance entirely.
|
||||||
|
// Used by project-rollup tables where the row's party
|
||||||
|
// affiliation is ambiguous (add at the per-party path).
|
||||||
|
const allowAdd = ctx.addable !== false;
|
||||||
|
if (onHttp && hasCols && allowAdd) {
|
||||||
addRowBtn.hidden = false;
|
addRowBtn.hidden = false;
|
||||||
addRowBtn.removeAttribute('href');
|
addRowBtn.removeAttribute('href');
|
||||||
addRowBtn.setAttribute('role', 'button');
|
addRowBtn.setAttribute('role', 'button');
|
||||||
|
|
|
||||||
101
zddc/internal/handler/virtualviewhandler.go
Normal file
101
zddc/internal/handler/virtualviewhandler.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// Package handler — virtualviewhandler.go: GET dispatch for SSR row +
|
||||||
|
// MDL/RSK rollup row URLs.
|
||||||
|
//
|
||||||
|
// These URLs live in the project-level virtual folders (<project>/ssr,
|
||||||
|
// <project>/mdl, <project>/rsk) and rewrite to canonical files inside
|
||||||
|
// <project>/archive/<party>/. The bytes returned to the client are
|
||||||
|
// augmented with a single path-derived field that the canonical file
|
||||||
|
// doesn't carry:
|
||||||
|
//
|
||||||
|
// - SSR rows get `name: <party>` so the table renderer has a column
|
||||||
|
// to sort on and the form edit pre-fills the party name.
|
||||||
|
// - MDL / RSK rollup rows get `party: <party>` so the rollup table
|
||||||
|
// can show which package each row came from.
|
||||||
|
//
|
||||||
|
// Both fields are stripped before write-back (SSR via serveFormCreateSSR
|
||||||
|
// strip; MDL/RSK rollup writes go through the generic serveFormUpdate,
|
||||||
|
// where the path-derived `party:` is rejected by `additionalProperties:
|
||||||
|
// false` in the underlying schema — so the client must strip it on
|
||||||
|
// submit, which the tables/form JS already does for path-derived
|
||||||
|
// fields).
|
||||||
|
//
|
||||||
|
// Listings: see fs/tree.go.
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeVirtualViewRow serves a GET (or HEAD) for one of the virtual
|
||||||
|
// row URLs. Caller is expected to have already evaluated ACL against
|
||||||
|
// vv.PartyArchive's chain.
|
||||||
|
//
|
||||||
|
// For SSR rows: returns the canonical archive/<party>/ssr.yaml bytes
|
||||||
|
// with `name: <party>` injected. If no canonical file exists yet,
|
||||||
|
// returns `name: <party>\n` (an otherwise-empty row) — the SSR view
|
||||||
|
// shows every party folder whether or not metadata has been written.
|
||||||
|
//
|
||||||
|
// For MDL / RSK rollup rows: returns the canonical bytes with
|
||||||
|
// `party: <party>` injected. If the canonical file doesn't exist
|
||||||
|
// (shouldn't happen — the listing only surfaces real files) returns
|
||||||
|
// 404.
|
||||||
|
func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.VirtualViewResolution) {
|
||||||
|
if !vv.Resolved || !vv.Kind.IsRowKind() {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(vv.CanonicalAbs)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// File doesn't exist yet.
|
||||||
|
if vv.Kind != zddc.VirtualViewSSRRow {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]any
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := yaml.Unmarshal(raw, &data); err != nil {
|
||||||
|
http.Error(w, "parse canonical yaml: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
data = make(map[string]any)
|
||||||
|
}
|
||||||
|
switch vv.Kind {
|
||||||
|
case zddc.VirtualViewSSRRow:
|
||||||
|
data["name"] = vv.Party
|
||||||
|
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
|
||||||
|
data["party"] = vv.Party
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "marshal virtual row: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-ZDDC-Source", "virtual-view-row")
|
||||||
|
w.Header().Set("X-ZDDC-Resolved-Path", vv.CanonicalURL)
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(out)
|
||||||
|
}
|
||||||
|
|
@ -153,6 +153,13 @@ paths:
|
||||||
# tables tool serves it from the embedded default
|
# tables tool serves it from the embedded default
|
||||||
# spec even when the on-disk folder doesn't exist.
|
# spec even when the on-disk folder doesn't exist.
|
||||||
virtual: true
|
virtual: true
|
||||||
|
rsk:
|
||||||
|
default_tool: tables
|
||||||
|
available_tools: [tables]
|
||||||
|
# Risk register — same virtual-by-convention pattern
|
||||||
|
# as mdl/. Embedded default-rsk spec backs it when no
|
||||||
|
# operator override is on disk.
|
||||||
|
virtual: true
|
||||||
incoming:
|
incoming:
|
||||||
# incoming/ is the COUNTERPARTY's drop zone. The flow:
|
# incoming/ is the COUNTERPARTY's drop zone. The flow:
|
||||||
# 1. the other party's document controller uploads
|
# 1. the other party's document controller uploads
|
||||||
|
|
@ -254,3 +261,29 @@ paths:
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: true
|
drop_target: true
|
||||||
admins: [document_controller]
|
admins: [document_controller]
|
||||||
|
# Project-level aggregation tables. All three are virtual: the
|
||||||
|
# folder doesn't exist on disk; the server synthesizes listings
|
||||||
|
# by walking archive/*/ at request time. ACL on each synthetic
|
||||||
|
# row is evaluated against the canonical archive/<party>/ path,
|
||||||
|
# so party owners can edit their own rows and non-owners see
|
||||||
|
# them read-only.
|
||||||
|
ssr:
|
||||||
|
default_tool: tables
|
||||||
|
available_tools: [tables]
|
||||||
|
# SSR aggregates one row per party folder; the row's backing
|
||||||
|
# file is archive/<party>/ssr.yaml. + Add row in this view
|
||||||
|
# creates a new party folder.
|
||||||
|
virtual: true
|
||||||
|
mdl:
|
||||||
|
default_tool: tables
|
||||||
|
available_tools: [tables]
|
||||||
|
# Project-rollup of every archive/<party>/mdl/ row. Read +
|
||||||
|
# edit; + Add row is disabled because party affiliation is
|
||||||
|
# ambiguous here (add at the per-party path instead).
|
||||||
|
virtual: true
|
||||||
|
rsk:
|
||||||
|
default_tool: tables
|
||||||
|
available_tools: [tables]
|
||||||
|
# Project-rollup of every archive/<party>/rsk/ row. Same
|
||||||
|
# semantics as the mdl rollup.
|
||||||
|
virtual: true
|
||||||
|
|
|
||||||
|
|
@ -184,13 +184,16 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project
|
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project
|
||||||
// root, the four canonical children should be enumerated.
|
// root, the canonical children should be enumerated: the four
|
||||||
|
// physical folders (archive, working, staging, reviewing) plus the
|
||||||
|
// three project-level virtual aggregator slots (ssr, mdl, rsk).
|
||||||
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
|
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
|
||||||
want := map[string]bool{
|
want := map[string]bool{
|
||||||
"archive": true, "working": true, "staging": true, "reviewing": true,
|
"archive": true, "working": true, "staging": true, "reviewing": true,
|
||||||
|
"ssr": true, "mdl": true, "rsk": true,
|
||||||
}
|
}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
|
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue