Findings from a 3-lens end-to-end review (correctness, security, tests).
Security review came back clean (cross-repo authz sound, isolation intact,
M5 gate sound modulo the documented manifest-trust boundary). No behavior
changes here — only accuracy + coverage:
- apply_service Phase B2: correct the descendant-expansion comment. The chain
is COMMITTED ancestry (ancestors() reads the pool), so a reparent in the same
apply takes effect next apply — same "one more apply" shape the in-tree path
and group-create reparenting already have. Authz stays sound: the gate and the
expansion consume the SAME chain (checked == written).
- doc §4.5: document the two known "one more apply / no data risk" limitations
— reparent-in-same-apply template resolution, and the `{env}` source split
(declared apps use --env; cross-repo descendants use apps.environment).
- approval journey: give the app real (`[vars]`) content so the editor member's
AppVarsWrite is actually exercised — the admin-gate test now proves approval
is ABOVE editor-write, not just "any non-admin is refused". (Vars cascade with
the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown.)
- templates journey: assert descendant expansions are idempotent (stable row
ids on re-apply — no churn), matching the in-tree guarantee.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
563 lines
21 KiB
Rust
563 lines
21 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"
|
|
);
|
|
}
|
|
|
|
/// M6 (§4.5): a `{var:NAME}` placeholder resolves against a var set in the SAME
|
|
/// apply (in-transaction), so a var and a template referencing it converge in
|
|
/// one `pic apply`. Before M6 the var (written earlier in the tx) was invisible
|
|
/// to expansion — the route would only pick it up on a *second* apply.
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn route_template_var_resolves_in_same_apply() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let group = common::unique_slug("tplv-g");
|
|
let a = common::unique_slug("tplv-a");
|
|
|
|
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);
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &a, "--group", &group])
|
|
.assert()
|
|
.success();
|
|
|
|
// The group sets `region` AND declares a template referencing it — one
|
|
// manifest, one apply.
|
|
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""#).unwrap();
|
|
fs::write(
|
|
dir.path().join("picloud.toml"),
|
|
format!(
|
|
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
|
[vars]\nregion = \"eu\"\n\n\
|
|
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
|
[[route_templates]]\nname = \"geo\"\nscript = \"shared\"\n\
|
|
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/tv/{{app_slug}}/{{var:region}}\"\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
fs::create_dir_all(dir.path().join(&a)).unwrap();
|
|
fs::write(
|
|
dir.path().join(&a).join("picloud.toml"),
|
|
format!("[app]\nslug = \"{a}\"\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));
|
|
|
|
// The expanded route resolved `{var:region}` → `eu` in THIS apply.
|
|
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
|
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
|
let want = format!("/tv/{a}/eu");
|
|
let rows = pg
|
|
.query(
|
|
"SELECT path FROM routes WHERE from_template IS NOT NULL AND path = $1",
|
|
&[&want],
|
|
)
|
|
.expect("query");
|
|
assert_eq!(
|
|
rows.len(),
|
|
1,
|
|
"`{{var:region}}` must resolve to `eu` in the same apply (expected path {want})"
|
|
);
|
|
}
|
|
|
|
/// Template-expanded routes (`from_template IS NOT NULL`) whose path is one of
|
|
/// `paths`, as `path` strings — for asserting which descendant apps received an
|
|
/// expansion regardless of which apply declared them.
|
|
fn expansion_paths(paths: &[String]) -> Vec<String> {
|
|
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
|
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
|
let rows = pg
|
|
.query(
|
|
"SELECT path FROM routes \
|
|
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
|
&[&paths],
|
|
)
|
|
.expect("query expansion paths");
|
|
rows.iter().map(|r| r.get(0)).collect()
|
|
}
|
|
|
|
/// `(path, id)` of template-expanded routes among `paths` — for asserting an
|
|
/// expansion is STABLE (same row id) across a re-apply, i.e. not churned.
|
|
fn expansion_rows_for(paths: &[String]) -> Vec<(String, String)> {
|
|
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
|
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
|
let rows = pg
|
|
.query(
|
|
"SELECT path, id::text FROM routes \
|
|
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
|
&[&paths],
|
|
)
|
|
.expect("query expansion rows");
|
|
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
|
}
|
|
|
|
/// M7 (§4.5): a route template fans out to EVERY descendant app in the DB
|
|
/// subtree, not only the app nodes present in the apply. An app created under
|
|
/// the group out-of-band (absent from the manifest) still receives the
|
|
/// expansion on the next apply, and removing the template reaps it there too.
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn route_template_reaches_out_of_tree_descendant() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let group = common::unique_slug("xrepo-g");
|
|
let sg = common::unique_slug("xrepo-sg"); // subgroup (depth-2)
|
|
let a = common::unique_slug("xrepo-a");
|
|
let c = common::unique_slug("xrepo-c"); // out-of-tree descendant (depth-1)
|
|
let d = common::unique_slug("xrepo-d"); // out-of-tree descendant (depth-2)
|
|
|
|
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);
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &a, "--group", &group])
|
|
.assert()
|
|
.success();
|
|
|
|
// Apply a tree of just (group ← app a) with an `/x/{app_slug}` template.
|
|
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""#).unwrap();
|
|
let group_toml = |with_template: bool| {
|
|
let tmpl = if with_template {
|
|
"\n[[route_templates]]\nname = \"x\"\nscript = \"shared\"\n\
|
|
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x/{app_slug}\"\n"
|
|
} else {
|
|
""
|
|
};
|
|
format!(
|
|
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
|
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n{tmpl}"
|
|
)
|
|
};
|
|
fs::write(dir.path().join("picloud.toml"), group_toml(true)).unwrap();
|
|
fs::create_dir_all(dir.path().join(&a)).unwrap();
|
|
fs::write(
|
|
dir.path().join(&a).join("picloud.toml"),
|
|
format!("[app]\nslug = \"{a}\"\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));
|
|
assert_eq!(
|
|
expansion_paths(&[format!("/x/{a}")]).len(),
|
|
1,
|
|
"in-tree app a gets its expansion"
|
|
);
|
|
|
|
// Create app c (direct child) and a nested subgroup ← app d (depth-2),
|
|
// all OUTSIDE the manifest tree, to exercise the recursive descendant CTE.
|
|
let _ac = AppGuard::new(&env.url, &env.token, &c);
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &c, "--group", &group])
|
|
.assert()
|
|
.success();
|
|
let _sg = GroupGuard::new(&env.url, &env.token, &sg);
|
|
common::pic_as(&env)
|
|
.args(["groups", "create", &sg, "--parent", &group])
|
|
.assert()
|
|
.success();
|
|
let _ad = AppGuard::new(&env.url, &env.token, &d);
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &d, "--group", &sg])
|
|
.assert()
|
|
.success();
|
|
|
|
// Re-apply the same tree (still only group + a). M7: c (depth-1) AND d
|
|
// (depth-2, under the subgroup) both receive the expansion. `--force`
|
|
// waives the bound token (the out-of-band creates bumped structure version).
|
|
common::pic_as(&env)
|
|
.args(["apply", "--dir"])
|
|
.arg(dir.path())
|
|
.arg("--force")
|
|
.assert()
|
|
.success();
|
|
let want = [format!("/x/{c}"), format!("/x/{d}")];
|
|
assert_eq!(
|
|
expansion_paths(&want),
|
|
want.to_vec(),
|
|
"out-of-tree descendants at depth 1 AND 2 receive expansions"
|
|
);
|
|
|
|
// Idempotent re-apply: descendants must not churn (same row ids — provenance
|
|
// by `from_template`, not delete+recreate), same as the in-tree path.
|
|
let before = expansion_rows_for(&want);
|
|
common::pic_as(&env)
|
|
.args(["apply", "--dir"])
|
|
.arg(dir.path())
|
|
.arg("--force")
|
|
.assert()
|
|
.success();
|
|
assert_eq!(
|
|
before,
|
|
expansion_rows_for(&want),
|
|
"re-apply must not churn descendant expansions (stable ids)"
|
|
);
|
|
|
|
// Remove the template + prune: every descendant's expansion is reaped,
|
|
// in-tree or not, at any depth.
|
|
fs::write(dir.path().join("picloud.toml"), group_toml(false)).unwrap();
|
|
common::pic_as(&env)
|
|
.args(["apply", "--dir"])
|
|
.arg(dir.path())
|
|
.args(["--prune", "--yes", "--force"])
|
|
.assert()
|
|
.success();
|
|
assert!(
|
|
expansion_paths(&[format!("/x/{a}"), format!("/x/{c}"), format!("/x/{d}")]).is_empty(),
|
|
"removing the template reaps expansions on ALL descendants, any depth"
|
|
);
|
|
}
|
|
|
|
#[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")
|
|
}
|