//! §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}" ); }