//! Phase-2 groups, end to end via `pic`: tree CRUD, delete=RESTRICT, //! reparent cycle rejection, and the headline invariant — a `group_admin` //! on an ancestor group can act on an app it is NOT a direct member of //! (inherited membership), and loses that access the instant the group //! grant is revoked. use predicates::prelude::*; use crate::common; use crate::common::cleanup::{AppGuard, GroupGuard}; use crate::common::member; #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn group_tree_create_show_and_delete_restrict() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let acme = common::unique_slug("g-acme"); let team = common::unique_slug("g-team"); let app = common::unique_slug("g-app"); // Root-level group, then a subgroup under it. let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); common::pic_as(&env) .args(["groups", "create", &acme]) .assert() .success(); let _g_team = GroupGuard::new(&env.url, &env.token, &team); common::pic_as(&env) .args(["groups", "create", &team, "--parent", &acme]) .assert() .success(); // An app under the subgroup. let _app = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app, "--group", &team]) .assert() .success(); // `groups show team` lists the app. let out = String::from_utf8( common::pic_as(&env) .args(["groups", "show", &team]) .output() .unwrap() .stdout, ) .unwrap(); assert!( out.contains(&app), "group detail should list its app:\n{out}" ); // delete=RESTRICT: acme has a subgroup → refused. common::pic_as(&env) .args(["groups", "rm", &acme]) .assert() .failure() .stderr(predicate::str::contains("409").or(predicate::str::contains("subgroup"))); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn reparent_into_own_descendant_is_rejected() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let parent = common::unique_slug("g-cyc-p"); let child = common::unique_slug("g-cyc-c"); let _g_parent = GroupGuard::new(&env.url, &env.token, &parent); common::pic_as(&env) .args(["groups", "create", &parent]) .assert() .success(); let _g_child = GroupGuard::new(&env.url, &env.token, &child); common::pic_as(&env) .args(["groups", "create", &child, "--parent", &parent]) .assert() .success(); // Moving the parent under its own child would form a cycle → refused. common::pic_as(&env) .args(["groups", "reparent", &parent, "--to", &child]) .assert() .failure() .stderr(predicate::str::contains("409").or(predicate::str::contains("descendant"))); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn inherited_group_admin_can_deploy_then_revoke() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let acme = common::unique_slug("g-inh"); let app = common::unique_slug("g-inh-app"); let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); common::pic_as(&env) .args(["groups", "create", &acme]) .assert() .success(); let _app = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app, "--group", &acme]) .assert() .success(); // A fresh Member with NO app membership. let m = member::member_user(fx, &common::unique_username("inh")); let member_env = common::custom_env(&fx.url, &m.token); common::seed_credentials(&member_env, &m.username); let fixture = common::fixture_path("hello.rhai"); // Baseline: without any grant, deploy is forbidden. common::pic_as(&member_env) .args([ "scripts", "deploy", fixture.to_str().unwrap(), "--app", &app, ]) .assert() .failure() .stderr(predicate::str::contains("HTTP 403")); // Grant group_admin on the ANCESTOR group (no app_members row). common::pic_as(&env) .args([ "groups", "members", "add", &acme, &m.id, "--role", "app_admin", ]) .assert() .success(); // Inherited: the member can now deploy to the app it never joined. // (Deploy prints a KvBlock — assert on the script name + create action, // not a prose string.) common::pic_as(&member_env) .args([ "scripts", "deploy", fixture.to_str().unwrap(), "--app", &app, ]) .assert() .success() .stdout(predicate::str::contains("hello").and(predicate::str::contains("created"))); // Revoke the group grant → access drops immediately (no cache lag). common::pic_as(&env) .args(["groups", "members", "rm", &acme, &m.id]) .assert() .success(); common::pic_as(&member_env) .args([ "scripts", "deploy", fixture.to_str().unwrap(), "--app", &app, ]) .assert() .failure() .stderr(predicate::str::contains("HTTP 403")); }