Files
PiCloud/crates/picloud-cli/tests/groups.rs
MechaCat02 eea1d8984e feat(cli): pic groups (tree/create/rename/reparent/rm/members)
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>
2026-06-24 20:15:33 +02:00

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