From da83222ea1c6ee4e90ed23c36e6dd76782c09bfd Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 2 Jul 2026 20:29:23 +0200 Subject: [PATCH] test(stateful-templates): group email template journey + load group secrets (M5.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI journey: set a group secret, apply a group [[triggers.email]] template referencing it, create an app under the group → `pic triggers ls --app` shows a materialized email trigger; re-apply is a NoOp. Required loading a group's own set-secret names into CurrentState (was hardcoded empty) so the apply plan-time email-secret check resolves the ref against the GROUP store. Informational only — the secrets diff is never executed on apply (secrets are set out-of-band via `pic secret set`); the state token now folds in group secrets, matching apps. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/apply_service.rs | 21 +++-- .../picloud-cli/tests/stateful_templates.rs | 94 ++++++++++++++++++- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 476def7..9316554 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -2593,7 +2593,8 @@ impl ApplyService { .list_for_app(app_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))?, - self.all_secret_names(app_id).await?, + self.all_secret_names(crate::secrets_service::SecretOwner::App(app_id)) + .await?, VarOwner::App(app_id), ), ApplyOwner::Group(group_id) => ( @@ -2611,7 +2612,12 @@ impl ApplyService { .list_for_group(group_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))?, - Vec::new(), + // §4.5 M5.5: a group's own set secrets — the plan check for an + // email TEMPLATE resolves `inbound_secret_ref` against the + // GROUP store, and the state token folds them in. Informational + // in the secrets diff only (apply never writes secrets). + self.all_secret_names(crate::secrets_service::SecretOwner::Group(group_id)) + .await?, VarOwner::Group(group_id), ), }; @@ -2658,17 +2664,16 @@ impl ApplyService { }) } - async fn all_secret_names(&self, app_id: AppId) -> Result, ApplyError> { + async fn all_secret_names( + &self, + owner: crate::secrets_service::SecretOwner, + ) -> Result, ApplyError> { let mut names = Vec::new(); let mut cursor: Option = None; loop { let page = self .secrets - .list_names( - crate::secrets_service::SecretOwner::App(app_id), - cursor.as_deref(), - 200, - ) + .list_names(owner, cursor.as_deref(), 200) .await .map_err(|e| ApplyError::Backend(e.to_string()))?; names.extend(page.names); diff --git a/crates/picloud-cli/tests/stateful_templates.rs b/crates/picloud-cli/tests/stateful_templates.rs index ba40261..3b85d47 100644 --- a/crates/picloud-cli/tests/stateful_templates.rs +++ b/crates/picloud-cli/tests/stateful_templates.rs @@ -33,8 +33,8 @@ fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String { .unwrap_or_else(|| panic!("group script `{name}` not found:\n{table}")) } -/// Count `cron` rows in `pic triggers ls --app`. -fn app_cron_count(env: &common::TestEnv, app: &str) -> usize { +/// Count rows of `kind` in `pic triggers ls --app`. +fn app_trigger_count(env: &common::TestEnv, app: &str, kind: &str) -> usize { let out = common::pic_as(env) .args(["triggers", "ls", "--app", app]) .output() @@ -43,10 +43,15 @@ fn app_cron_count(env: &common::TestEnv, app: &str) -> usize { .unwrap() .lines() .map(common::cells) - .filter(|c| c.contains(&"cron")) + .filter(|c| c.contains(&kind)) .count() } +/// Count `cron` rows in `pic triggers ls --app`. +fn app_cron_count(env: &common::TestEnv, app: &str) -> usize { + app_trigger_count(env, app, "cron") +} + #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn group_cron_template_materializes_for_descendant_apps() { @@ -118,3 +123,86 @@ fn group_cron_template_materializes_for_descendant_apps() { "re-apply must not duplicate the materialized copy" ); } + +/// §4.5 M5.5 — a group EMAIL template materializes for a descendant app. The +/// template resolves its `inbound_secret_ref` against the GROUP's own secret +/// store once at apply (shared-group-secret model); creating an app under the +/// group materializes a per-app email trigger, visible in `pic triggers ls +/// --app`. Re-applying the group is a NoOp (no duplicate copy). +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_email_template_materializes_for_descendant_apps() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("mat-em-grp"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + // A group secret the email template references, resolved at apply. + common::pic_as(&env) + .args(["secrets", "set", "--group", &group, "mail-sig"]) + .write_stdin("s3cret-hmac") + .assert() + .success(); + + // Group handler + an email TEMPLATE binding it. + let dir = manifest_dir(); + fs::write( + dir.path().join("scripts/inbound.rhai"), + r#"log::info("inbound"); "ok""#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/inbound.rhai")) + .args(["--group", &group, "--name", "inbound"]) + .assert() + .success(); + let _gs = ScriptGuard::new( + &env.url, + &env.token, + &group_script_id(&env, &group, "inbound"), + ); + let gmanifest = format!( + "[group]\nslug = \"{group}\"\nname = \"MatEmG\"\n\n\ + [[triggers.email]]\nscript = \"inbound\"\ninbound_secret_ref = \"mail-sig\"\n" + ); + let gpath = dir.path().join("group.toml"); + fs::write(&gpath, &gmanifest).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + + // An app created under the group materializes a per-app email copy. + let app = common::unique_slug("mat-em-app"); + let _a = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &group]) + .assert() + .success(); + assert_eq!( + app_trigger_count(&env, &app, "email"), + 1, + "a descendant app must have a materialized email trigger" + ); + + // Re-apply of the group is a NoOp for materialization (no duplicate copy). + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + assert_eq!( + app_trigger_count(&env, &app, "email"), + 1, + "re-apply must not duplicate the materialized email copy" + ); +}