test/docs(apply): §6 M1 group-create journey + design note
`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) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ mod email_queue;
|
|||||||
mod enabled;
|
mod enabled;
|
||||||
mod env_overlay;
|
mod env_overlay;
|
||||||
mod extension_points;
|
mod extension_points;
|
||||||
|
mod group_create;
|
||||||
mod group_modules;
|
mod group_modules;
|
||||||
mod group_routes;
|
mod group_routes;
|
||||||
mod group_scripts;
|
mod group_scripts;
|
||||||
|
|||||||
193
crates/picloud-cli/tests/group_create.rs
Normal file
193
crates/picloud-cli/tests/group_create.rs
Normal file
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
(`--adopt-server-structure` / `--force-local-structure`); declarative group create/reparent (lifting "groups
|
||||||
pre-exist"); per-env approval gating (`[project.environments]`).
|
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
|
## 8. Diagrams
|
||||||
|
|||||||
Reference in New Issue
Block a user