From c29e4b9c5387ca044f0849941730d66e3f50bcbb Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 25 Jun 2026 20:42:35 +0200 Subject: [PATCH] test(cli): journey for group-script inheritance, CoW, isolation + apply binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4-lite C5. Two `pic` journeys: * `group_script_is_inherited_with_cow_and_isolation` — a group endpoint `shared` is invoked by name from an app under the group; the app's own `shared` then shadows it (CoW); an app outside the group cannot reach it. * `manifest_binds_route_to_inherited_group_script_idempotently` — a manifest that declares no `greet` script binds a route to the inherited group `greet`; plan shows a route create (no script create), apply succeeds, and re-plan is a clean no-op. Adds a `ScriptGuard` (deletes a script by id on drop) since a group-owned script blocks its group's deletion (ON DELETE RESTRICT) — it must drop before the GroupGuard. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/picloud-cli/tests/cli.rs | 1 + crates/picloud-cli/tests/common/cleanup.rs | 30 +++ crates/picloud-cli/tests/group_scripts.rs | 236 +++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 crates/picloud-cli/tests/group_scripts.rs diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 3d0d930..2129c12 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -23,6 +23,7 @@ mod dead_letters; mod email_queue; mod enabled; mod env_overlay; +mod group_scripts; mod group_secrets; mod groups; mod init; diff --git a/crates/picloud-cli/tests/common/cleanup.rs b/crates/picloud-cli/tests/common/cleanup.rs index a6c8822..e80c37e 100644 --- a/crates/picloud-cli/tests/common/cleanup.rs +++ b/crates/picloud-cli/tests/common/cleanup.rs @@ -38,6 +38,36 @@ impl Drop for AppGuard { } } +/// Deletes a script by id on drop (best-effort). Phase 4: a group-owned +/// script blocks its group's deletion (`ON DELETE RESTRICT`), so register the +/// `ScriptGuard` *after* the owning `GroupGuard` — the script drops (deletes) +/// first, leaving the group removable. +pub struct ScriptGuard { + url: String, + token: String, + id: String, +} + +impl ScriptGuard { + pub fn new(url: &str, token: &str, id: &str) -> Self { + Self { + url: url.to_string(), + token: token.to_string(), + id: id.to_string(), + } + } +} + +impl Drop for ScriptGuard { + fn drop(&mut self) { + let client = reqwest::blocking::Client::new(); + let _ = client + .delete(format!("{}/api/v1/admin/scripts/{}", self.url, self.id)) + .bearer_auth(&self.token) + .send(); + } +} + pub struct UserGuard { url: String, token: String, diff --git a/crates/picloud-cli/tests/group_scripts.rs b/crates/picloud-cli/tests/group_scripts.rs new file mode 100644 index 0000000..97eae5a --- /dev/null +++ b/crates/picloud-cli/tests/group_scripts.rs @@ -0,0 +1,236 @@ +//! Phase 4-lite group-owned scripts, end to end via `pic`: +//! * a group owns an endpoint script; an app *under* the group inherits it, +//! resolving by name down the chain (`invoke`), +//! * an app's own script of the same name shadows the inherited one (CoW), +//! * an app NOT under the group cannot resolve it (isolation), +//! * a manifest binds a route to the inherited script declaratively, and +//! re-applying is an idempotent no-op. + +use std::fs; + +use tempfile::TempDir; + +use crate::common; +use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard}; + +fn manifest_dir() -> TempDir { + let dir = TempDir::new().expect("tempdir"); + fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir"); + dir +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_script_is_inherited_with_cow_and_isolation() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("gs-grp"); + let child = common::unique_slug("gs-child"); + let orphan = common::unique_slug("gs-orphan"); + + // Group with an endpoint script `shared` returning a marker. + // GroupGuard first so it drops LAST — after the app + group script below. + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + let dir = manifest_dir(); + fs::write(dir.path().join("scripts/shared.rhai"), "\"from-group\"").unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/shared.rhai")) + .args(["--group", &group, "--name", "shared"]) + .assert() + .success(); + // The group script blocks group deletion (ON DELETE RESTRICT) — guard it + // so it drops before the GroupGuard. + let gscript_id = group_script_id(&env, &group); + let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id); + + // An app under the group inherits `shared`. Its `caller` script invokes it. + let _child = AppGuard::new(&env.url, &env.token, &child); + common::pic_as(&env) + .args(["apps", "create", &child, "--group", &group]) + .assert() + .success(); + fs::write( + dir.path().join("scripts/caller.rhai"), + "invoke(\"shared\", #{})", + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/caller.rhai")) + .args(["--app", &child, "--name", "caller"]) + .assert() + .success(); + let caller_id = app_script_id(&env, &child, "caller"); + + // Inheritance: caller resolves `shared` down its chain → the group's value. + assert_eq!( + invoke_body(&env, &caller_id), + serde_json::json!("from-group"), + "inherited group script should resolve by name" + ); + + // CoW: the app defines its OWN `shared`; the same caller now sees it. + fs::write(dir.path().join("scripts/own.rhai"), "\"from-app\"").unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/own.rhai")) + .args(["--app", &child, "--name", "shared"]) + .assert() + .success(); + assert_eq!( + invoke_body(&env, &caller_id), + serde_json::json!("from-app"), + "an app's own script must shadow the inherited group one (CoW)" + ); + + // Isolation: an app NOT under the group cannot resolve `shared`. + let _orphan = AppGuard::new(&env.url, &env.token, &orphan); + common::pic_as(&env) + .args(["apps", "create", &orphan]) + .assert() + .success(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/caller.rhai")) + .args(["--app", &orphan, "--name", "caller"]) + .assert() + .success(); + let orphan_caller = app_script_id(&env, &orphan, "caller"); + // `shared` is not in the orphan's chain → invoke fails (the script errors). + let out = common::pic_as(&env) + .args(["scripts", "invoke", &orphan_caller]) + .output() + .expect("invoke"); + assert!( + !out.status.success(), + "an app outside the group must NOT reach the group's `shared`" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn manifest_binds_route_to_inherited_group_script_idempotently() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("gsr-grp"); + let child = common::unique_slug("gsr-child"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + let dir = manifest_dir(); + fs::write(dir.path().join("scripts/greet.rhai"), "\"hi\"").unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/greet.rhai")) + .args(["--group", &group, "--name", "greet"]) + .assert() + .success(); + let gscript_id = group_script_id(&env, &group); + let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id); + + let _child = AppGuard::new(&env.url, &env.token, &child); + common::pic_as(&env) + .args(["apps", "create", &child, "--group", &group]) + .assert() + .success(); + + // Manifest declares NO `greet` script — only a route binding to it. The + // apply engine must resolve `greet` to the inherited group endpoint. + let manifest = format!( + "[app]\nslug = \"{child}\"\nname = \"GSR\"\n\n\ + [[routes]]\nscript = \"greet\"\nmethod = \"GET\"\n\ + host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n" + ); + let manifest_path = dir.path().join("picloud.toml"); + fs::write(&manifest_path, &manifest).unwrap(); + + // Plan: a route create that binds to `greet`, and NO script create. + let plan = String::from_utf8( + common::pic_as(&env) + .args(["plan", "--file"]) + .arg(&manifest_path) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + plan.contains("greet") && plan.contains("route"), + "plan should bind a route to the inherited `greet`:\n{plan}" + ); + + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&manifest_path) + .assert() + .success(); + + // Re-plan is a clean no-op — the diff resolved the group-bound route's id + // back to `greet`, so it is not a perpetual rebind. + let replan = String::from_utf8( + common::pic_as(&env) + .args(["plan", "--file"]) + .arg(&manifest_path) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + !replan.contains("create") && !replan.contains("update"), + "re-plan after binding an inherited route must be a no-op:\n{replan}" + ); +} + +/// First script id from `pic scripts ls --group `. +fn group_script_id(env: &common::TestEnv, group: &str) -> String { + let ls = common::pic_as(env) + .args(["scripts", "ls", "--group", group]) + .output() + .expect("scripts ls --group"); + common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap()) + .expect("group should have one script") +} + +/// Id of the named script in an app (`pic scripts ls --app `). +fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String { + let ls = common::pic_as(env) + .args(["scripts", "ls", "--app", app]) + .output() + .expect("scripts ls --app"); + let table = String::from_utf8(ls.stdout).unwrap(); + // Rows are `id\tapp_slug\tname\tversion\tupdated_at`; pick the wanted name. + table + .lines() + .map(common::cells) + .find(|c| c.get(2) == Some(&name)) + .and_then(|c| c.first().map(|s| (*s).to_string())) + .unwrap_or_else(|| panic!("script `{name}` not found in app `{app}`:\n{table}")) +} + +fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value { + let out = common::pic_as(env) + .args(["scripts", "invoke", id]) + .output() + .expect("scripts invoke"); + assert!( + out.status.success(), + "invoke failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).expect("invoke body is JSON") +}