feat: project creation gated by cascade ActionCreate, not hardcoded admin
The /.profile/projects endpoint previously refused anyone without hasAnyAdminScope. Now it runs the standard decider with ActionCreate on the parent directory — super-admins still pass via the IsActiveAdmin bypass branch, and anyone the root .zddc grants `c` to (e.g. `*@example.com: c`) can self-service a project without needing an existing admin grant. Other changes in this commit: - The new project's .zddc is seeded with the creator's email in admins: when the request body doesn't supply one — they become subtree admin of their own project at birth. .zddc edits in deeper subfolders flow through their authority; strict-ancestor rule still prevents them from editing /<project>/.zddc itself. - AccessView gains can_create_project, computed by the same decider call the endpoint uses — UI and server agree on visibility with no daylight. - Profile page splits the subtree-admin template from the create- project template so the latter mounts on can_create_project, independent of has_any_admin_scope. Non-admin grantees see the form; admins keep seeing both. - Lock-in tests cover the five interesting cases: cascade-granted user succeeds and becomes subtree admin; stranger gets 404; elevated super-admin auto-defaults admins; explicit admins list wins over the default; duplicate-name 409. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fd4f03afc3
commit
b80b11c99f
5 changed files with 218 additions and 34 deletions
|
|
@ -148,6 +148,12 @@ type AccessView struct {
|
||||||
IsSuperAdmin bool `json:"is_super_admin"`
|
IsSuperAdmin bool `json:"is_super_admin"`
|
||||||
HasAnyAdminScope bool `json:"has_any_admin_scope"`
|
HasAnyAdminScope bool `json:"has_any_admin_scope"`
|
||||||
CanElevate bool `json:"can_elevate"`
|
CanElevate bool `json:"can_elevate"`
|
||||||
|
// CanCreateProject is true when the caller is authorized to mkdir a
|
||||||
|
// new top-level project — either via the root .zddc granting `c` to
|
||||||
|
// their email/role, or via super-admin authority (elevated). Drives
|
||||||
|
// the visibility of the profile page's "+ New project" form so the
|
||||||
|
// UI doesn't dangle an affordance the server would 404.
|
||||||
|
CanCreateProject bool `json:"can_create_project"`
|
||||||
Projects []ProjectInfo `json:"projects"`
|
Projects []ProjectInfo `json:"projects"`
|
||||||
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
||||||
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
|
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
|
||||||
|
|
@ -173,6 +179,13 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con
|
||||||
// Drives the header elevation toggle's visibility — an un-
|
// Drives the header elevation toggle's visibility — an un-
|
||||||
// elevated admin still needs to see the toggle they'd flip.
|
// elevated admin still needs to see the toggle they'd flip.
|
||||||
view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email)
|
view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email)
|
||||||
|
// CanCreateProject mirrors the gate in serveProfileProjectsCreate —
|
||||||
|
// same decider call, same authority, no daylight between the UI
|
||||||
|
// affordance and the endpoint.
|
||||||
|
if rootChain, perr := zddc.EffectivePolicy(cfg.Root, cfg.Root); perr == nil {
|
||||||
|
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
|
||||||
|
view.CanCreateProject = allowed
|
||||||
|
}
|
||||||
for _, t := range view.AdminSubtrees {
|
for _, t := range view.AdminSubtrees {
|
||||||
if t.CanEdit {
|
if t.CanEdit {
|
||||||
view.EditableParentChoices = append(view.EditableParentChoices, t)
|
view.EditableParentChoices = append(view.EditableParentChoices, t)
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="subtree-admin-slot"></div>
|
<div id="subtree-admin-slot"></div>
|
||||||
|
<div id="create-project-slot"></div>
|
||||||
|
|
||||||
<template id="tmpl-subtree-admin">
|
<template id="tmpl-subtree-admin">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|
@ -193,10 +194,12 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
<p class="help">Open the form-based editor for any subtree you administer.</p>
|
<p class="help">Open the form-based editor for any subtree you administer.</p>
|
||||||
<div id="editable-list"></div>
|
<div id="editable-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tmpl-create-project">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Create new project folder</h2>
|
<h2>Create new project folder</h2>
|
||||||
<p class="help">Creates a directory under the chosen parent. If you fill in any of title / allow / deny / admins, a starter <code>.zddc</code> is also written; otherwise the directory is empty and inherits ACL from its ancestors.</p>
|
<p class="help">Creates a directory under the chosen parent. Your email is added to admins automatically so you administer the new project; you can also fill title / ACL / additional admins below.</p>
|
||||||
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
|
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
|
||||||
<form id="cp-form" autocomplete="off">
|
<form id="cp-form" autocomplete="off">
|
||||||
<label>Parent
|
<label>Parent
|
||||||
|
|
@ -215,7 +218,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Deny (optional)</h3>
|
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Deny (optional)</h3>
|
||||||
<div class="list" data-field="acl.deny"></div>
|
<div class="list" data-field="acl.deny"></div>
|
||||||
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
|
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
|
||||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Admins (optional)</h3>
|
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Additional admins (optional)</h3>
|
||||||
<div class="list" data-field="admins"></div>
|
<div class="list" data-field="admins"></div>
|
||||||
<button type="button" class="add" data-target="admins">+ Add admin</button>
|
<button type="button" class="add" data-target="admins">+ Add admin</button>
|
||||||
<div style="margin-top: 1rem;">
|
<div style="margin-top: 1rem;">
|
||||||
|
|
@ -250,6 +253,11 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
(function() {
|
(function() {
|
||||||
var prefix = {{ .ProfilePathPrefix }};
|
var prefix = {{ .ProfilePathPrefix }};
|
||||||
var isSuper = {{ .IsSuperAdmin }};
|
var isSuper = {{ .IsSuperAdmin }};
|
||||||
|
// canCreateProject is hydrated from /.profile/access (the JSON
|
||||||
|
// refresh) — server-rendered HTML only knows IsSuperAdmin. Default
|
||||||
|
// to isSuper so the UI doesn't flicker between paint and fetch for
|
||||||
|
// super-admins; the JSON view overrides for non-admin grantees.
|
||||||
|
var canCreateProject = isSuper;
|
||||||
|
|
||||||
function escText(s) {
|
function escText(s) {
|
||||||
var d = document.createElement("div");
|
var d = document.createElement("div");
|
||||||
|
|
@ -422,7 +430,11 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
var sel = document.getElementById("cp-parent");
|
var sel = document.getElementById("cp-parent");
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
sel.innerHTML = "";
|
sel.innerHTML = "";
|
||||||
if (isSuper) {
|
// Root is offered whenever the caller can create projects there —
|
||||||
|
// super-admin (full bypass) or cascade-granted "c" at the root.
|
||||||
|
// The server's can_create_project flag means both, since it runs
|
||||||
|
// the same decider gate the endpoint uses.
|
||||||
|
if (isSuper || canCreateProject) {
|
||||||
var optRoot = document.createElement("option");
|
var optRoot = document.createElement("option");
|
||||||
optRoot.value = "/"; optRoot.textContent = "/ (root)";
|
optRoot.value = "/"; optRoot.textContent = "/ (root)";
|
||||||
sel.appendChild(optRoot);
|
sel.appendChild(optRoot);
|
||||||
|
|
@ -506,15 +518,28 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
}
|
}
|
||||||
|
|
||||||
function instantiateAdminScaffold(view) {
|
function instantiateAdminScaffold(view) {
|
||||||
if (!view.has_any_admin_scope) return;
|
if (view.has_any_admin_scope) {
|
||||||
var tmpl = document.getElementById("tmpl-subtree-admin");
|
var tmpl = document.getElementById("tmpl-subtree-admin");
|
||||||
if (!tmpl) return;
|
if (tmpl) {
|
||||||
var slot = document.getElementById("subtree-admin-slot");
|
var slot = document.getElementById("subtree-admin-slot");
|
||||||
slot.appendChild(tmpl.content.cloneNode(true));
|
slot.appendChild(tmpl.content.cloneNode(true));
|
||||||
renderEditableList(view.editable_parent_choices, view.has_any_admin_scope);
|
renderEditableList(view.editable_parent_choices, view.has_any_admin_scope);
|
||||||
populateParentChoices(view.admin_subtrees);
|
}
|
||||||
|
}
|
||||||
|
// Create-project mounts independently on the can_create_project
|
||||||
|
// gate — non-admins who hold "c" at root via cascade grant get the
|
||||||
|
// form too. Parent-selector seeds from admin_subtrees when those
|
||||||
|
// exist, otherwise just root.
|
||||||
|
if (view.can_create_project) {
|
||||||
|
var cpTmpl = document.getElementById("tmpl-create-project");
|
||||||
|
if (cpTmpl) {
|
||||||
|
var cpSlot = document.getElementById("create-project-slot");
|
||||||
|
cpSlot.appendChild(cpTmpl.content.cloneNode(true));
|
||||||
|
populateParentChoices(view.editable_parent_choices || []);
|
||||||
wireCreateProjectForm();
|
wireCreateProjectForm();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetch(prefix + "/access", { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
fetch(prefix + "/access", { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
||||||
.then(function(r) { return r.ok ? r.json() : null; })
|
.then(function(r) { return r.ok ? r.json() : null; })
|
||||||
|
|
@ -523,6 +548,9 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
document.getElementById("projects-loading").textContent = "Could not load access view.";
|
document.getElementById("projects-loading").textContent = "Could not load access view.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Hydrate the server-computed flag so populateParentChoices and
|
||||||
|
// any visibility check after this point sees the live value.
|
||||||
|
canCreateProject = !!view.can_create_project;
|
||||||
renderProjects(view.projects);
|
renderProjects(view.projects);
|
||||||
renderAdminSubtrees(view.admin_subtrees);
|
renderAdminSubtrees(view.admin_subtrees);
|
||||||
instantiateAdminScaffold(view);
|
instantiateAdminScaffold(view);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -38,20 +39,21 @@ type projectCreateResponse struct {
|
||||||
|
|
||||||
// serveProfileProjectsCreate handles POST /.profile/projects.
|
// serveProfileProjectsCreate handles POST /.profile/projects.
|
||||||
//
|
//
|
||||||
// Authorization is delegated to CanEditZddc on the prospective new
|
// Authorization is the cascade's ActionCreate verb at the prospective
|
||||||
// directory: the caller must have authority that would let them write a
|
// parent directory — the same `c` permission used everywhere else.
|
||||||
// .zddc at that location (super-admin via root admins, or a strict-ancestor
|
// Super-admins pass via the decider's IsActiveAdmin bypass; explicitly
|
||||||
// admin grant). Non-authorized callers receive 404 to keep this endpoint's
|
// granted principals (e.g., `*@example.com: c` on the root .zddc) pass
|
||||||
// existence hidden alongside the rest of the admin surface.
|
// via the normal ACL path. Non-authorized callers receive 404 to keep
|
||||||
|
// this endpoint's existence hidden alongside the rest of /.profile.
|
||||||
|
//
|
||||||
|
// The new project's .zddc is seeded with the creator's email in
|
||||||
|
// `admins:` (unless the request body supplied an explicit list). That
|
||||||
|
// makes them subtree admin of their own project from birth — they can
|
||||||
|
// manage deeper .zddc files, define roles, set per-stage ACLs. The
|
||||||
|
// strict-ancestor rule still applies to /<project>/.zddc itself; that
|
||||||
|
// stays editable only by ancestor admins (root super-admins).
|
||||||
func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
// Admin gate first so non-admins (AND un-elevated admins) see 404
|
|
||||||
// regardless of method, matching the rest of /.profile/'s existence-
|
|
||||||
// leakage policy.
|
|
||||||
p := PrincipalFromContext(r)
|
p := PrincipalFromContext(r)
|
||||||
if !hasAnyAdminScope(cfg.Root, p) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
w.Header().Set("Allow", "POST")
|
w.Header().Set("Allow", "POST")
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
|
|
@ -81,27 +83,47 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newDir := filepath.Join(parentAbs, req.Name)
|
// Cascade-driven authorization: ActionCreate at the parent directory.
|
||||||
if !zddc.CanEditZddc(cfg.Root, newDir, p) {
|
// 404 (not 403) on deny to match the rest of /.profile/'s existence-
|
||||||
|
// hiding policy.
|
||||||
|
parentChain, err := zddc.EffectivePolicy(cfg.Root, parentAbs)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parentRel, _ := filepath.Rel(cfg.Root, parentAbs)
|
||||||
|
parentURL := "/"
|
||||||
|
if parentRel != "." && parentRel != "" {
|
||||||
|
parentURL = "/" + filepath.ToSlash(parentRel) + "/"
|
||||||
|
}
|
||||||
|
allowed, _ := policy.AllowActionFromChainP(r.Context(), DeciderFromContext(r), parentChain, p, parentURL, policy.ActionCreate)
|
||||||
|
if !allowed {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newDir := filepath.Join(parentAbs, req.Name)
|
||||||
if _, err := os.Stat(newDir); err == nil {
|
if _, err := os.Stat(newDir); err == nil {
|
||||||
http.Error(w, "Conflict: directory already exists", http.StatusConflict)
|
http.Error(w, "Conflict: directory already exists", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the body supplies any .zddc fields, validate them BEFORE we mkdir
|
// Always seed a starter .zddc — the creator becomes subtree admin of
|
||||||
// so a validation failure leaves no on-disk trace.
|
// their new project. Caller can also pass title / ACL / extra
|
||||||
wantsZddc := req.Title != "" || (req.ACL != nil && (len(req.ACL.Allow) > 0 || len(req.ACL.Deny) > 0)) || len(req.Admins) > 0
|
// admins on top.
|
||||||
|
admins := req.Admins
|
||||||
|
if len(admins) == 0 && p.Email != "" {
|
||||||
|
admins = []string{p.Email}
|
||||||
|
}
|
||||||
var zf zddc.ZddcFile
|
var zf zddc.ZddcFile
|
||||||
if wantsZddc {
|
|
||||||
zf.Title = req.Title
|
zf.Title = req.Title
|
||||||
if req.ACL != nil {
|
if req.ACL != nil {
|
||||||
zf.ACL = *req.ACL
|
zf.ACL = *req.ACL
|
||||||
}
|
}
|
||||||
zf.Admins = req.Admins
|
zf.Admins = admins
|
||||||
|
wantsZddc := len(zf.Admins) > 0 || zf.Title != "" ||
|
||||||
|
(req.ACL != nil && (len(req.ACL.Allow) > 0 || len(req.ACL.Deny) > 0 || len(req.ACL.Permissions) > 0))
|
||||||
|
if wantsZddc {
|
||||||
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
|
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
|
||||||
121
zddc/internal/handler/profileprojects_test.go
Normal file
121
zddc/internal/handler/profileprojects_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// projectCreateFixture lays out a deployment whose root .zddc grants
|
||||||
|
// `c` (create) to `*@example.com` so non-admin members can spin up
|
||||||
|
// projects, with `admin@example.com` as the super-admin escape hatch.
|
||||||
|
func projectCreateFixture(t *testing.T) (config.Config, string) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||||
|
"admins:\n - admin@example.com\n"+
|
||||||
|
"acl:\n permissions:\n \"*@example.com\": c\n")
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 64 * 1024}, root
|
||||||
|
}
|
||||||
|
|
||||||
|
func doProjectCreate(cfg config.Config, email string, elevated bool, body []byte) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||||
|
ctx = context.WithValue(ctx, ElevatedKey, elevated)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
serveProfileProjectsCreate(cfg, rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admin user holds `c` at root via cascade — succeeds, becomes
|
||||||
|
// subtree admin of the new project.
|
||||||
|
func TestProjectCreate_CascadeGrantedUserBecomesSubtreeAdmin(t *testing.T) {
|
||||||
|
cfg, root := projectCreateFixture(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"parent": "/", "name": "Project-1", "title": "My project"})
|
||||||
|
rec := doProjectCreate(cfg, "alice@example.com", false, body)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
// Verify the new project .zddc names alice as admin.
|
||||||
|
zf, err := zddc.ParseFile(filepath.Join(root, "Project-1", ".zddc"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read new .zddc: %v", err)
|
||||||
|
}
|
||||||
|
if len(zf.Admins) != 1 || zf.Admins[0] != "alice@example.com" {
|
||||||
|
t.Errorf("Admins=%v, want [alice@example.com]", zf.Admins)
|
||||||
|
}
|
||||||
|
if zf.Title != "My project" {
|
||||||
|
t.Errorf("Title=%q, want %q", zf.Title, "My project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User with no grant at root — 404 (existence-hidden).
|
||||||
|
func TestProjectCreate_UnauthenticatedUserDenied(t *testing.T) {
|
||||||
|
cfg, _ := projectCreateFixture(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"parent": "/", "name": "ShouldFail"})
|
||||||
|
rec := doProjectCreate(cfg, "stranger@other.org", false, body)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status=%d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Super-admin elevated — succeeds via decider's IsActiveAdmin bypass,
|
||||||
|
// not via explicit ACL grant. Admins default to creator email.
|
||||||
|
func TestProjectCreate_ElevatedSuperAdminWithDefaultAdmins(t *testing.T) {
|
||||||
|
cfg, root := projectCreateFixture(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"parent": "/", "name": "AdminProj"})
|
||||||
|
rec := doProjectCreate(cfg, "admin@example.com", true, body)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
zf, _ := zddc.ParseFile(filepath.Join(root, "AdminProj", ".zddc"))
|
||||||
|
if len(zf.Admins) != 1 || zf.Admins[0] != "admin@example.com" {
|
||||||
|
t.Errorf("default Admins=%v, want [admin@example.com]", zf.Admins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller passes explicit admins list — used instead of auto-defaulting.
|
||||||
|
func TestProjectCreate_ExplicitAdminsListWins(t *testing.T) {
|
||||||
|
cfg, root := projectCreateFixture(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"parent": "/",
|
||||||
|
"name": "TeamProj",
|
||||||
|
"admins": []string{"alice@example.com", "bob@example.com"},
|
||||||
|
})
|
||||||
|
rec := doProjectCreate(cfg, "alice@example.com", false, body)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
zf, _ := zddc.ParseFile(filepath.Join(root, "TeamProj", ".zddc"))
|
||||||
|
if len(zf.Admins) != 2 {
|
||||||
|
t.Errorf("Admins=%v, want 2 entries", zf.Admins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-creating an existing project name fails 409 — duplicate detection
|
||||||
|
// runs before the admin seed write.
|
||||||
|
func TestProjectCreate_DuplicateNameRejected(t *testing.T) {
|
||||||
|
cfg, root := projectCreateFixture(t)
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Existing"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{"parent": "/", "name": "Existing"})
|
||||||
|
rec := doProjectCreate(cfg, "alice@example.com", false, body)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Errorf("status=%d, want 409", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1559,7 +1559,7 @@ body.is-elevated {
|
||||||
</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 14:54:41 · 63fc433-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 15:25:14 · fd4f03a-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue