Files
PiCloud/crates/picloud-cli/tests/common/cleanup.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

95 lines
2.4 KiB
Rust

//! RAII guards that delete server-side resources on `Drop`.
//!
//! Each guard owns the minimum it needs to issue a single DELETE: the
//! base URL, an admin bearer token, and the resource identifier.
//! Failures are swallowed because Drop runs during teardown — a panic
//! here would just mask the real failure that the test was reporting.
pub struct AppGuard {
url: String,
token: String,
slug: String,
}
impl AppGuard {
pub fn new(url: &str, token: &str, slug: &str) -> Self {
Self {
url: url.to_string(),
token: token.to_string(),
slug: slug.to_string(),
}
}
pub fn slug(&self) -> &str {
&self.slug
}
}
impl Drop for AppGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!(
"{}/api/v1/admin/apps/{}?force=true",
self.url, self.slug
))
.bearer_auth(&self.token)
.send();
}
}
pub struct UserGuard {
url: String,
token: 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 {
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
Self {
url: url.to_string(),
token: token.to_string(),
user_id: user_id.to_string(),
}
}
}
impl Drop for UserGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!("{}/api/v1/admin/admins/{}", self.url, self.user_id))
.bearer_auth(&self.token)
.send();
}
}