From dcc387c345c617e10699202f7c4a07746c126b36 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 7 Jul 2026 20:07:12 +0200 Subject: [PATCH] =?UTF-8?q?test/docs(apply):=20=C2=A76=20M1=20group-create?= =?UTF-8?q?=20journey=20+=20design=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/group_create.rs`: a nested `[group]` tree with no server groups yet → `pic apply --dir` creates both (parent from directory nesting), claims them for the `[project]`, and re-applies as a no-op; a member with only editor (no group-admin) is refused and leaves nothing behind. Design doc §6 records M1 shipped (parent-by-nesting, Phase-0 create-in-tx, claim, RBAC + attach ceiling) with reparent/divergence still deferred to M2. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/picloud-cli/tests/cli.rs | 1 + crates/picloud-cli/tests/group_create.rs | 193 +++++++++++++++++++++++ docs/design/groups-and-project-tool.md | 17 ++ 3 files changed, 211 insertions(+) create mode 100644 crates/picloud-cli/tests/group_create.rs diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index e5fb18b..5e55536 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -26,6 +26,7 @@ mod email_queue; mod enabled; mod env_overlay; mod extension_points; +mod group_create; mod group_modules; mod group_routes; mod group_scripts; diff --git a/crates/picloud-cli/tests/group_create.rs b/crates/picloud-cli/tests/group_create.rs new file mode 100644 index 0000000..bbc4991 --- /dev/null +++ b/crates/picloud-cli/tests/group_create.rs @@ -0,0 +1,193 @@ +//! §6 M1 — declarative group CREATE, end to end via `pic apply --dir`. +//! +//! A repo declares its group subtree by directory nesting: a `[group]` node's +//! parent is the nearest ancestor directory that also holds a `[group]`, and the +//! topmost group binds to the repo's `[project] parent_group` (attach point). +//! The tree apply creates the missing groups (parents-first) in one transaction, +//! claims them for the project, and is a no-op on re-apply. Creating a group is +//! capability-gated (a member without group-admin is refused). + +use std::fs; +use std::path::Path; + +use tempfile::TempDir; + +use crate::common; +use crate::common::cleanup::GroupGuard; + +/// A two-level repo on disk: root `[group] {top}` (claimed by `{project}`, +/// attached under `{attach}`) with a nested `sub/` `[group] {sub}`. +fn nested_repo(dir: &Path, project: &str, attach: &str, top: &str, sub: &str) { + fs::write( + dir.join("picloud.toml"), + format!( + "[project]\nslug = \"{project}\"\nparent_group = \"{attach}\"\n\n\ + [group]\nslug = \"{top}\"\nname = \"Top\"\n" + ), + ) + .unwrap(); + let subdir = dir.join("sub"); + fs::create_dir_all(&subdir).unwrap(); + fs::write( + subdir.join("picloud.toml"), + format!("[group]\nslug = \"{sub}\"\nname = \"Sub\"\n"), + ) + .unwrap(); +} + +/// The `parent` (index 2) and `owner` (index 3) cells for `group` in +/// `pic groups ls` — columns: slug, name, parent, owner, created_at. +fn parent_and_owner(env: &common::TestEnv, group: &str) -> (String, String) { + let ls = common::pic_as(env) + .args(["groups", "ls"]) + .output() + .expect("groups ls"); + let table = String::from_utf8(ls.stdout).unwrap(); + let row = table + .lines() + .map(common::cells) + .find(|c| c.first() == Some(&group)) + .unwrap_or_else(|| panic!("group `{group}` not in groups ls:\n{table}")); + ( + row.get(2).copied().unwrap_or_default().to_string(), + row.get(3).copied().unwrap_or_default().to_string(), + ) +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn tree_apply_creates_nested_groups_under_the_attach_point() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let base = common::unique_slug("gc-base"); + let top = common::unique_slug("gc-top"); + let sub = common::unique_slug("gc-sub"); + let project = common::unique_slug("gc-proj"); + + // The attach point pre-exists; `top` and `sub` do NOT — the tree creates them. + let _b = GroupGuard::new(&env.url, &env.token, &base); + common::pic_as(&env) + .args(["groups", "create", &base]) + .assert() + .success(); + // Teardown order (reverse of declaration): sub → top → base. + let _gtop = GroupGuard::new(&env.url, &env.token, &top); + let _gsub = GroupGuard::new(&env.url, &env.token, &sub); + + let dir = TempDir::new().unwrap(); + nested_repo(dir.path(), &project, &base, &top, &sub); + + // Plan previews both group CREATES (and, with a [project], a claim). + let out = common::pic_as(&env) + .args(["plan", "--dir"]) + .arg(dir.path()) + .output() + .unwrap(); + let plan = String::from_utf8(out.stdout).unwrap(); + let plan_err = String::from_utf8_lossy(&out.stderr); + assert!( + plan.contains(&top) && plan.contains(&sub), + "plan should mention both to-create groups:\nSTDOUT:\n{plan}\nSTDERR:\n{plan_err}" + ); + + // Apply creates `top` under `base`, then `sub` under `top`, in one tx. + common::pic_as(&env) + .args(["apply", "--dir"]) + .arg(dir.path()) + .assert() + .success(); + + let (top_parent, top_owner) = parent_and_owner(&env, &top); + assert_eq!(top_parent, base, "top's parent is the attach point"); + assert_eq!( + top_owner, project, + "a created group is claimed by the project" + ); + let (sub_parent, sub_owner) = parent_and_owner(&env, &sub); + assert_eq!( + sub_parent, top, + "sub's parent is its same-tree ancestor group" + ); + assert_eq!( + sub_owner, project, + "the nested created group is also claimed" + ); + + // Re-apply is a clean no-op (both groups already exist, parents match). + let replan = String::from_utf8( + common::pic_as(&env) + .args(["plan", "--dir"]) + .arg(dir.path()) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + !replan.contains("create") && !replan.contains("update"), + "re-plan of an already-created tree must be a no-op:\n{replan}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn creating_a_group_requires_group_admin_on_the_parent() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let base = common::unique_slug("gcm-base"); + let top = common::unique_slug("gcm-top"); + let project = common::unique_slug("gcm-proj"); + + let _b = GroupGuard::new(&env.url, &env.token, &base); + common::pic_as(&env) + .args(["groups", "create", &base]) + .assert() + .success(); + + // A member with only EDITOR on `base` may reconcile content but must not be + // able to CREATE a subgroup under it (that needs group-admin). + let mem = common::member::member_user(fx, &common::unique_slug("gcm-mem")); + common::member::grant_group_membership(fx, &base, &mem.id, "editor"); + let menv = common::custom_env(&env.url, &mem.token); + common::seed_credentials(&menv, &mem.username); + + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("picloud.toml"), + format!( + "[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\ + [group]\nslug = \"{top}\"\nname = \"Top\"\n" + ), + ) + .unwrap(); + + let out = common::pic_as(&menv) + .args(["apply", "--dir"]) + .arg(dir.path()) + .output() + .expect("member apply"); + assert!( + !out.status.success(), + "an editor without group-admin must not create a group" + ); + + // The group was NOT created (the tx rolled back). + let ls = String::from_utf8( + common::pic_as(&env) + .args(["groups", "ls"]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + !ls.lines() + .map(common::cells) + .any(|c| c.first() == Some(&top.as_str())), + "a denied create must leave no group behind:\n{ls}" + ); +} diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 1ef720b..a047bdb 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -796,6 +796,23 @@ ownership track (§7 M1–M3) is complete. Deferred (separate later work):** §6 (`--adopt-server-structure` / `--force-local-structure`); declarative group create/reparent (lifting "groups pre-exist"); per-env approval gating (`[project.environments]`). +**§6 group-tree M1 shipped — declarative group CREATE.** A `[group]` node's PARENT is now inferred from +directory nesting (the nearest ancestor directory holding a `[group]`; the topmost group binds to the repo's +`[project] parent_group` attach point, else the instance root). `discover::build_tree` emits `parent` (plus the +group's `name`/`description`) per group node, sorted parents-first by directory depth; `TreeNode` gained those +fields (`#[serde(default)]`, so a pre-§6 CLI still reconciles existing groups). Server-side, `apply_tree` runs a +**Phase 0** (`create_missing_groups_tx`) that resolves-or-creates every group node IN the apply tx +(parents-first, a same-tree parent resolved before its child via `read_group_id_by_slug_tx`), under the coarse +`GROUP_STRUCTURAL_LOCK_KEY` (taken only when it creates), enforcing the **attach-point ceiling** and **RBAC** +(`InstanceCreateGroup` at the root / `GroupAdmin(parent)` for a subgroup) per create; the group is then CLAIMED +in Phase A alongside existing groups (a fresh group is `decide_group_claim` → `Claim`). `prepare_tree` gained a +caller-supplied `resolved_ids` map (existing + just-created) and returns a `to_create` list the plan path +previews (empty current → a full-create plan; ownership → `claim`) — a to-create group's token part is identical +to its post-create part, so plan/apply tokens match across a create. `authz_tree` skips a to-create group (its +authorization is the service-side create gate). Reparent of an EXISTING diverged group + the detect-and-refuse +flags are M2. Pinned by `tests/group_create.rs`. **Still deferred:** §6 structural-divergence detection + M2 +reparent; per-env approval gating. + --- ## 8. Diagrams