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>
This commit is contained in:
MechaCat02
2026-06-24 20:15:33 +02:00
parent a432091191
commit eea1d8984e
8 changed files with 796 additions and 2 deletions

View File

@@ -12,7 +12,8 @@ use chrono::{DateTime, Utc};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use picloud_shared::{ use picloud_shared::{
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog, AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox, Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind,
ScriptSandbox,
}; };
use reqwest::{header, Method, RequestBuilder, StatusCode}; use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -149,6 +150,144 @@ impl Client {
decode_status(resp).await decode_status(resp).await
} }
// --- Groups (Phase 2) -------------------------------------------------
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
/// client-side from `parent_id`).
pub async fn groups_list(&self) -> Result<Vec<Group>> {
let resp = self
.request(Method::GET, "/api/v1/admin/groups")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
let ident = seg(ident);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/groups/{ident}"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/groups`
pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result<Group> {
let resp = self
.request(Method::POST, "/api/v1/admin/groups")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only
/// (the slug is frozen).
pub async fn groups_rename(
&self,
ident: &str,
name: Option<&str>,
description: Option<&str>,
) -> Result<Group> {
let ident = seg(ident);
let body = serde_json::json!({ "name": name, "description": description });
let resp = self
.request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}"))
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a
/// slug/id, or `None` to move to root.
pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result<Group> {
let ident = seg(ident);
let body = serde_json::json!({ "parent": parent });
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/groups/{ident}/reparent"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty.
pub async fn groups_delete(&self, ident: &str) -> Result<()> {
let ident = seg(ident);
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}"))
.send()
.await?;
decode_status(resp).await
}
pub async fn group_members_list(&self, group: &str) -> Result<Vec<AppMemberDto>> {
let group = seg(group);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/groups/{group}/members"),
)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_grant(
&self,
group: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let group = seg(group);
let body = serde_json::json!({ "user_id": user_id, "role": role });
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/groups/{group}/members"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_set_role(
&self,
group: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let (group, user_id) = (seg(group), seg(user_id));
let body = serde_json::json!({ "role": role });
let resp = self
.request(
Method::PATCH,
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> {
let (group, user_id) = (seg(group), seg(user_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
)
.send()
.await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the /// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
/// owning app (stricter than the edit endpoints, by design). /// owning app (stricter than the edit endpoints, by design).
pub async fn scripts_delete(&self, id: &str) -> Result<()> { pub async fn scripts_delete(&self, id: &str) -> Result<()> {
@@ -1049,6 +1188,31 @@ pub struct CreateAppBody<'a> {
pub name: &'a str, pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>, pub description: Option<&'a str>,
/// Parent group (slug or id); omit for the instance root.
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<&'a str>,
}
#[derive(Debug, Serialize)]
pub struct CreateGroupBody<'a> {
pub slug: &'a str,
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
/// Parent group (slug or id); omit for a root-level group.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<&'a str>,
}
/// `GET /groups/{id}` response — the group plus its breadcrumb path and
/// direct children.
#[derive(Debug, Deserialize)]
pub struct GroupDetailDto {
#[serde(flatten)]
pub group: Group,
pub path: Vec<Group>,
pub subgroups: Vec<Group>,
pub apps: Vec<App>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]

View File

@@ -31,6 +31,7 @@ pub async fn create(
slug: &str, slug: &str,
name: Option<&str>, name: Option<&str>,
description: Option<&str>, description: Option<&str>,
group: Option<&str>,
mode: OutputMode, mode: OutputMode,
) -> Result<()> { ) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
@@ -39,6 +40,7 @@ pub async fn create(
slug, slug,
name: name.unwrap_or(slug), name: name.unwrap_or(slug),
description, description,
group,
}; };
let app = client.apps_create(&body).await?; let app = client.apps_create(&body).await?;
// Emit the created object so `--output json` callers can capture the // Emit the created object so `--output json` callers can capture the

View File

@@ -0,0 +1,257 @@
//! `pic groups` — manage the org-tree groups (Phase 2).
//!
//! Wraps `/api/v1/admin/groups*`. Structural mutations are gated
//! server-side: create/reparent/delete need group-admin (reparent at both
//! source and destination parent); the slug is frozen at creation.
use std::collections::BTreeMap;
use anyhow::Result;
use picloud_shared::{AppRole, Group};
use crate::client::{Client, CreateGroupBody};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let groups = client.groups_list().await?;
let mut table = Table::new(["slug", "name", "parent", "created_at"]);
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect();
for g in &groups {
let parent = g
.parent_id
.and_then(|p| by_id.get(&p).cloned())
.unwrap_or_else(|| "-".into());
table.row([
g.slug.clone(),
g.name.clone(),
parent,
g.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}
/// `pic groups tree` — render the hierarchy as an indented tree (text
/// mode); falls back to the flat list for `--output json`.
pub async fn tree(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let groups = client.groups_list().await?;
if matches!(mode, OutputMode::Json) {
// Machine consumers get the flat list; the shape carries parent_id.
println!("{}", serde_json::to_string_pretty(&groups)?);
return Ok(());
}
// children-by-parent, then DFS from the roots.
let mut children: BTreeMap<Option<_>, Vec<&Group>> = BTreeMap::new();
for g in &groups {
children.entry(g.parent_id).or_default().push(g);
}
for kids in children.values_mut() {
kids.sort_by(|a, b| a.name.cmp(&b.name));
}
print_subtree(&children, None, 0);
Ok(())
}
fn print_subtree(
children: &BTreeMap<Option<picloud_shared::GroupId>, Vec<&Group>>,
parent: Option<picloud_shared::GroupId>,
depth: usize,
) {
let Some(kids) = children.get(&parent) else {
return;
};
for g in kids {
println!("{}{} ({})", " ".repeat(depth), g.name, g.slug);
print_subtree(children, Some(g.id), depth + 1);
}
}
pub async fn create(
slug: &str,
name: Option<&str>,
description: Option<&str>,
parent: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let body = CreateGroupBody {
slug,
name: name.unwrap_or(slug),
description,
parent,
};
let group = client.groups_create(&body).await?;
print_group(&group, mode);
Ok(())
}
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let detail = client.groups_get(ident).await?;
let path = detail
.path
.iter()
.map(|g| g.slug.as_str())
.collect::<Vec<_>>()
.join(" / ");
let mut block = KvBlock::new();
block
.field("id", detail.group.id.to_string())
.field("slug", detail.group.slug.clone())
.field("name", detail.group.name.clone())
.field("path", if path.is_empty() { "-".into() } else { path })
.field(
"subgroups",
detail
.subgroups
.iter()
.map(|g| g.slug.as_str())
.collect::<Vec<_>>()
.join(", "),
)
.field(
"apps",
detail
.apps
.iter()
.map(|a| a.slug.as_str())
.collect::<Vec<_>>()
.join(", "),
);
block.print(mode);
Ok(())
}
pub async fn rename(
ident: &str,
name: Option<&str>,
description: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let group = client.groups_rename(ident, name, description).await?;
print_group(&group, mode);
Ok(())
}
pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let group = client.groups_reparent(ident, to).await?;
print_group(&group, mode);
Ok(())
}
/// `pic groups rm <slug>`. The server enforces delete=RESTRICT (409 on a
/// non-empty group); `--recursive` expands the delete into ordered,
/// leaf-first child deletions (groups + apps) the operator opted into.
pub async fn rm(ident: &str, recursive: bool) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
if !recursive {
client.groups_delete(ident).await?;
println!("Deleted group {ident}");
return Ok(());
}
// Recursive: delete the subtree leaf-first so each DB delete sees an
// empty node (the FK stays RESTRICT — we never cascade implicitly).
let detail = client.groups_get(ident).await?;
if let Some(app) = detail.apps.first() {
anyhow::bail!(
"group {ident} contains app {:?}; move or delete apps before a recursive group delete \
(apps are never auto-deleted)",
app.slug
);
}
for sub in &detail.subgroups {
Box::pin(rm(&sub.slug, true)).await?;
}
client.groups_delete(ident).await?;
println!("Deleted group {ident}");
Ok(())
}
// --- members ---------------------------------------------------------------
pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let members = client.group_members_list(group).await?;
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
for m in members {
table.row([
m.user_id.to_string(),
m.username,
m.role.as_str().to_string(),
format!("{:?}", m.instance_role).to_lowercase(),
m.is_active.to_string(),
]);
}
table.print(mode);
Ok(())
}
pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.group_members_grant(group, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.group_members_set_role(group, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn members_rm(group: &str, user_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.group_members_remove(group, user_id).await?;
println!("Removed {user_id} from {group}");
Ok(())
}
fn print_group(g: &Group, mode: OutputMode) {
let mut block = KvBlock::new();
block
.field("id", g.id.to_string())
.field("slug", g.slug.clone())
.field("name", g.name.clone())
.field(
"parent_id",
g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()),
)
.field("created_at", g.created_at.to_rfc3339());
block.print(mode);
}
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
let mut block = KvBlock::new();
block
.field("user_id", m.user_id.to_string())
.field("username", m.username.clone())
.field("role", m.role.as_str().to_string());
block.print(mode);
}
fn parse_role(role: &str) -> Result<AppRole> {
AppRole::from_db_str(role)
.ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer"))
}

View File

@@ -6,6 +6,7 @@ pub mod apps_domains;
pub mod config; pub mod config;
pub mod dead_letters; pub mod dead_letters;
pub mod files; pub mod files;
pub mod groups;
pub mod init; pub mod init;
pub mod kv; pub mod kv;
pub mod login; pub mod login;

View File

@@ -51,6 +51,12 @@ enum Cmd {
cmd: AppsCmd, cmd: AppsCmd,
}, },
/// Group (org-tree) management.
Groups {
#[command(subcommand)]
cmd: GroupsCmd,
},
/// Script management. /// Script management.
Scripts { Scripts {
#[command(subcommand)] #[command(subcommand)]
@@ -401,6 +407,9 @@ enum AppsCmd {
name: Option<String>, name: Option<String>,
#[arg(long)] #[arg(long)]
description: Option<String>, description: Option<String>,
/// Parent group (slug or id). Defaults to the instance root.
#[arg(long)]
group: Option<String>,
}, },
/// Show a single app, including the caller's role in it. /// Show a single app, including the caller's role in it.
@@ -423,6 +432,75 @@ enum AppsCmd {
}, },
} }
#[derive(Subcommand)]
enum GroupsCmd {
/// List all groups (flat).
Ls,
/// Render the group hierarchy as an indented tree.
Tree,
/// Create a new group. Omit `--parent` for a root-level group.
Create {
slug: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
/// Parent group (slug or id).
#[arg(long)]
parent: Option<String>,
},
/// Show a group with its path, subgroups, and apps.
Show { ident: String },
/// Rename a group (name/description only — the slug is frozen).
Rename {
ident: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Move a group under a new parent (`--to` slug/id, or omit for root).
Reparent {
ident: String,
#[arg(long)]
to: Option<String>,
},
/// Delete a group. Refused (409) if non-empty unless `--recursive`,
/// which deletes child groups leaf-first (apps are never auto-deleted).
Rm {
ident: String,
#[arg(long)]
recursive: bool,
},
/// Manage a group's members (inherited down the tree).
Members {
#[command(subcommand)]
cmd: GroupMembersCmd,
},
}
#[derive(Subcommand)]
enum GroupMembersCmd {
/// List a group's members.
Ls { group: String },
/// Grant a member a role on the group.
Add {
group: String,
user_id: String,
#[arg(long, default_value = "viewer")]
role: String,
},
/// Change a member's role.
Set {
group: String,
user_id: String,
#[arg(long)]
role: String,
},
/// Remove a member from the group.
Rm { group: String, user_id: String },
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum DomainsCmd { enum DomainsCmd {
/// List the app's domain claims. /// List the app's domain claims.
@@ -1176,8 +1254,18 @@ async fn main() -> ExitCode {
slug, slug,
name, name,
description, description,
group,
}, },
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref(), mode).await, } => {
cmds::apps::create(
&slug,
name.as_deref(),
description.as_deref(),
group.as_deref(),
mode,
)
.await
}
Cmd::Apps { Cmd::Apps {
cmd: AppsCmd::Show { ident }, cmd: AppsCmd::Show { ident },
} => cmds::apps::show(&ident, mode).await, } => cmds::apps::show(&ident, mode).await,
@@ -1201,6 +1289,79 @@ async fn main() -> ExitCode {
cmd: DomainsCmd::Rm { app, domain_id }, cmd: DomainsCmd::Rm { app, domain_id },
}, },
} => cmds::apps_domains::rm(&app, &domain_id).await, } => cmds::apps_domains::rm(&app, &domain_id).await,
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
Cmd::Groups {
cmd: GroupsCmd::Tree,
} => cmds::groups::tree(mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Create {
slug,
name,
description,
parent,
},
} => {
cmds::groups::create(
&slug,
name.as_deref(),
description.as_deref(),
parent.as_deref(),
mode,
)
.await
}
Cmd::Groups {
cmd: GroupsCmd::Show { ident },
} => cmds::groups::show(&ident, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Rename {
ident,
name,
description,
},
} => cmds::groups::rename(&ident, name.as_deref(), description.as_deref(), mode).await,
Cmd::Groups {
cmd: GroupsCmd::Reparent { ident, to },
} => cmds::groups::reparent(&ident, to.as_deref(), mode).await,
Cmd::Groups {
cmd: GroupsCmd::Rm { ident, recursive },
} => cmds::groups::rm(&ident, recursive).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd: GroupMembersCmd::Ls { group },
},
} => cmds::groups::members_ls(&group, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd:
GroupMembersCmd::Add {
group,
user_id,
role,
},
},
} => cmds::groups::members_add(&group, &user_id, &role, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd:
GroupMembersCmd::Set {
group,
user_id,
role,
},
},
} => cmds::groups::members_set(&group, &user_id, &role, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd: GroupMembersCmd::Rm { group, user_id },
},
} => cmds::groups::members_rm(&group, &user_id).await,
Cmd::Scripts { Cmd::Scripts {
cmd: ScriptsCmd::Ls { app }, cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref(), mode).await, } => cmds::scripts::ls(app.as_deref(), mode).await,

View File

@@ -23,6 +23,7 @@ mod dead_letters;
mod email_queue; mod email_queue;
mod enabled; mod enabled;
mod env_overlay; mod env_overlay;
mod groups;
mod init; mod init;
mod invoke; mod invoke;
mod logs; mod logs;

View File

@@ -44,6 +44,35 @@ pub struct UserGuard {
user_id: String, user_id: String,
} }
/// Deletes a group on drop (best-effort). The group must be empty by then
/// — register an `AppGuard`/child `GroupGuard` *after* this one so the
/// child drops (deletes) first, leaving an empty node here.
pub struct GroupGuard {
url: String,
token: String,
slug: String,
}
impl GroupGuard {
pub fn new(url: &str, token: &str, slug: &str) -> Self {
Self {
url: url.to_string(),
token: token.to_string(),
slug: slug.to_string(),
}
}
}
impl Drop for GroupGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!("{}/api/v1/admin/groups/{}", self.url, self.slug))
.bearer_auth(&self.token)
.send();
}
}
impl UserGuard { impl UserGuard {
pub fn new(url: &str, token: &str, user_id: &str) -> Self { pub fn new(url: &str, token: &str, user_id: &str) -> Self {
Self { Self {

View File

@@ -0,0 +1,179 @@
//! 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"));
}