Adds the `pic groups` command family over /api/v1/admin/groups: ls/tree, create (--parent), show (path + subgroups + apps), rename (name/description; slug frozen), reparent (--to), rm (--recursive, leaf-first; never auto-deletes apps), and members add/set/rm/ls. Also `pic apps create --group <slug>` to place an app under a group. End-to-end journey tests (tests/groups.rs, DB-gated) cover tree CRUD, delete=RESTRICT (409 on a non-empty group), reparent cycle rejection, and the headline invariant: a group_admin on an ancestor group can deploy to an app it is NOT a direct member of (inherited membership), and loses that access immediately on revoke — all green against a live server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
5.5 KiB
Rust
180 lines
5.5 KiB
Rust
//! 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"));
|
|
}
|