Files
PiCloud/crates/picloud-cli/tests/templates.rs
MechaCat02 4d2eed4e81 feat(hierarchies): trigger templates — per-app fan-out (M4b)
Completes §4.5 templates (M4a shipped routes). A group declares a trigger once;
the tree apply fans it out into one concrete per-app_id trigger on each
descendant app, reusing the M4a expansion engine over the trigger insert path.

- Migration 0054: `trigger_templates` table (group-owned; the whole
  BundleTrigger wire object stored as `spec` JSONB, placeholders unresolved) +
  `triggers.from_template` provenance column + index.
- `template_repo.rs`: trigger-template CRUD + chain-load.
- Manifest: `[group]` `[[trigger_templates]]` (flat: name, kind, script, + kind
  params); build_bundle emits them; rejected on an app node.
- Apply: group nodes reconcile trigger-template rows; tree Phase B expands each
  chain trigger template into a concrete trigger per descendant app —
  placeholders resolved in every spec string leaf, then the typed trigger is
  rebuilt and inserted through the shared `insert_bundle_trigger_tx` (email
  secrets resolved + re-sealed per recipient app). Idempotent via from_template
  (one trigger per template per app; semantic-identity compare → no-op /
  delete+recreate / reap). Collision with a hand-declared trigger or between
  templates is a hard error. Each resolved trigger is re-validated with the
  per-kind shape check (cron/queue/etc.) so a {var:}-injected bad
  schedule/timeout can't slip in.
- Authz: declaring → GroupScriptsWrite; receiving → AppManageTriggers, plus
  AppSecretsRead for email-trigger recipients (parity with hand-declared email).
- Plan/token/report extended for trigger templates; blast radius covers both.
- Tests: tests/templates.rs trigger fan-out (kv + {app_slug}, stable-ids,
  prune-reap); resolve_placeholders_in_json unit test; schema golden reblessed.

Reviewed (no CRITICAL; isolation + per-app email-secret sealing verified
sound). Closed a HIGH validation-parity gap (re-validate resolved triggers) and
a MEDIUM authz gap (AppSecretsRead for email expansions). v1.2 Hierarchies
template work (M4) complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:38:04 +02:00

332 lines
12 KiB
Rust

//! M4a route templates (§4.5) via `pic apply --dir`:
//! * a group declares ONE route template (`/t/{app_slug}`) bound to its
//! inherited script; the apply fans it out into a concrete route on every
//! descendant app, with `{app_slug}` resolved per app,
//! * re-apply is idempotent (no duplicate expansions — provenance),
//! * removing the template + `--prune` reaps the expansions,
//! * an expansion colliding with a hand-declared route is a hard error,
//! * `pic plan --dir` reports the blast radius.
use std::fs;
use postgres::{Client as PgClient, NoTls};
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
/// Template-expanded routes (`from_template IS NOT NULL`) for the two app slugs
/// under test, as `(path, id)` — read straight from Postgres since the route
/// admin endpoint only lists app-owned-script routes (a group script isn't
/// addressable there). Scoped to `/t/<a>` and `/t/<b>` so parallel tests don't
/// interfere.
fn expansion_rows(a: &str, b: &str) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = [format!("/t/{a}"), format!("/t/{b}")];
let rows = pg
.query(
"SELECT path, id::text FROM routes \
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
&[&&want[..]],
)
.expect("query expansions");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
/// A tree: a root group declaring a `shared` endpoint + a `greet` route template
/// `/t/{app_slug}`, and `extra_app_toml` appended to app `a`'s manifest. Apps
/// must pre-exist under the group (created in the test before applying).
fn tree_dir(group: &str, a: &str, b: &str, template: &str, extra_app_a: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(
dir.path().join("scripts/shared.rhai"),
r#""hi from template""#,
)
.unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{template}"
),
)
.unwrap();
for (slug, extra) in [(a, extra_app_a), (b, "")] {
fs::create_dir_all(dir.path().join(slug)).unwrap();
fs::write(
dir.path().join(slug).join("picloud.toml"),
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n{extra}"),
)
.unwrap();
}
dir
}
const TEMPLATE: &str = "[[route_templates]]\nname = \"greet\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/t/{app_slug}\"\n";
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_fans_out_per_app_idempotently_then_prunes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tpl-g");
let a = common::unique_slug("tpl-a");
let b = common::unique_slug("tpl-b");
// group ← (app a, app b). Guards drop apps before the group (RESTRICT).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// --- Apply: the template fans out to both apps. ---
let dir = tree_dir(&group, &a, &b, TEMPLATE, "");
// Plan reports the blast radius (both descendant apps).
let plan = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.expect("plan --dir");
let plan_err = String::from_utf8_lossy(&plan.stderr);
assert!(
plan_err.contains("blast radius") && plan_err.contains("2 app(s)"),
"plan should report the 2-app blast radius:\n{plan_err}"
);
// --- Apply: the template fans out to both apps, one route each. ---
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
// The group script must drop before its group at teardown (RESTRICT).
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
let first = expansion_rows(&a, &b);
let paths: Vec<&str> = first.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec![format!("/t/{a}").as_str(), format!("/t/{b}").as_str()],
"each app gets its own `{{app_slug}}`-resolved route"
);
// --- Idempotent re-apply: same rows, same ids (no churn — provenance). ---
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let second = expansion_rows(&a, &b);
assert_eq!(
first, second,
"re-apply must not churn expansions (stable ids)"
);
// --- Remove the template + --prune: expansions are reaped. Rewrite the
// SAME repo's group manifest (a fresh dir would be a different project and
// conflict on the owned group, §7). ---
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes"])
.assert()
.success();
assert!(
expansion_rows(&a, &b).is_empty(),
"removing the template must reap its expansions"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn expansion_colliding_with_hand_declared_route_is_rejected() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tplc-g");
let a = common::unique_slug("tplc-a");
let b = common::unique_slug("tplc-b");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// App `a` hand-declares the EXACT route the template would expand to (the
// template path resolves to `/t/<a>` for app a). That collision is a hard
// error — neither side silently wins.
let collide = format!(
"\n[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\n\
path_kind = \"exact\"\npath = \"/t/{a}\"\n"
);
let dir = tree_dir(&group, &a, &b, TEMPLATE, &collide);
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("apply --dir");
assert!(
!out.status.success(),
"an expansion colliding with a hand-declared route must be rejected"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("template") && (err.contains("hand") || err.contains("declare")),
"error should name the template/hand-declared collision:\n{err}"
);
}
/// Template-expanded kv triggers for the two apps, as `(collection_glob, id)`,
/// read straight from Postgres (a group-script trigger isn't listable via the
/// app trigger API). Scoped to the test's two slugs.
fn trigger_rows(a: &str, b: &str) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = [format!("{a}-data"), format!("{b}-data")];
let rows = pg
.query(
"SELECT d.collection_glob, t.id::text FROM triggers t \
JOIN kv_trigger_details d ON d.trigger_id = t.id \
WHERE t.from_template IS NOT NULL AND d.collection_glob = ANY($1) \
ORDER BY d.collection_glob",
&[&&want[..]],
)
.expect("query trigger expansions");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
const TRIGGER_TEMPLATE: &str = "[[trigger_templates]]\nname = \"ev\"\nkind = \"kv\"\n\
script = \"shared\"\ncollection_glob = \"{app_slug}-data\"\n\
ops = [\"insert\"]\n";
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn trigger_template_fans_out_per_app_idempotently_then_prunes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("ttpl-g");
let a = common::unique_slug("ttpl-a");
let b = common::unique_slug("ttpl-b");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// group manifest: a `shared` script + a kv trigger template using {app_slug}.
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), r#""x""#).unwrap();
let write_group = |with_tmpl: bool| {
let tmpl = if with_tmpl { TRIGGER_TEMPLATE } else { "" };
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"TT\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{tmpl}"
),
)
.unwrap();
};
write_group(true);
for slug in [&a, &b] {
fs::create_dir_all(dir.path().join(slug)).unwrap();
fs::write(
dir.path().join(slug).join("picloud.toml"),
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n"),
)
.unwrap();
}
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
let first = trigger_rows(&a, &b);
assert_eq!(
first.iter().map(|(g, _)| g.as_str()).collect::<Vec<_>>(),
vec![format!("{a}-data").as_str(), format!("{b}-data").as_str()],
"each app gets its own {{app_slug}}-resolved kv trigger"
);
// Idempotent re-apply: same rows, same ids.
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
assert_eq!(
trigger_rows(&a, &b),
first,
"re-apply must not churn triggers"
);
// Remove the template + --prune: trigger expansions reaped.
write_group(false);
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes"])
.assert()
.success();
assert!(
trigger_rows(&a, &b).is_empty(),
"removing the trigger template must reap its expansions"
);
}
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")
}