//! M5: `pic apply` creates email + queue triggers. The email trigger's //! inbound secret is referenced by name (pushed via `pic secret set`) and //! resolved + re-sealed server-side — never written into the manifest. use std::fs; use tempfile::TempDir; use crate::common; use crate::common::cleanup::AppGuard; #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn apply_email_and_queue_triggers() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("m5"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); // The email trigger references this secret by name; push its value // out-of-band first. common::pic_as(&env) .args(["secrets", "set", "--app", &slug, "email-hmac"]) .write_stdin("super-secret-hmac") .assert() .success(); let dir = TempDir::new().unwrap(); fs::create_dir_all(dir.path().join("scripts")).unwrap(); fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap(); let manifest = format!( "[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\ [secrets]\nnames = [\"email-hmac\"]\n\n\ [[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\ [[triggers.queue]]\nscript = \"handler\"\nqueue_name = \"jobs\"\n\n\ [[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n" ); let manifest_path = dir.path().join("picloud.toml"); fs::write(&manifest_path, &manifest).unwrap(); let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&manifest_path) .output() .expect("apply"); assert!( out.status.success(), "apply failed: {}", String::from_utf8_lossy(&out.stderr) ); // Both triggers exist. let s = String::from_utf8( common::pic_as(&env) .args(["triggers", "ls", "--app", &slug]) .output() .unwrap() .stdout, ) .unwrap(); assert!( s.lines().any(|l| l.contains("queue")), "queue trigger missing:\n{s}" ); assert!( s.lines().any(|l| l.contains("email")), "email trigger missing:\n{s}" ); // Re-apply is a no-op (both triggers match by identity). let r = String::from_utf8( common::pic_as(&env) .args(["apply", "--file"]) .arg(&manifest_path) .output() .unwrap() .stdout, ) .unwrap(); assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn prune_refuses_to_orphan_email_trigger() { // `pull` can't represent email triggers, so a manifest that omits the // script owning one would, under `--prune`, cascade-delete the trigger // (and its sealed secret) when the script is dropped. Apply must refuse. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("m5-orphan"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); common::pic_as(&env) .args(["secrets", "set", "--app", &slug, "email-hmac"]) .write_stdin("super-secret-hmac") .assert() .success(); let dir = TempDir::new().unwrap(); fs::create_dir_all(dir.path().join("scripts")).unwrap(); fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap(); let manifest_path = dir.path().join("picloud.toml"); // v1: a script with an email trigger. let v1 = format!( "[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\ [secrets]\nnames = [\"email-hmac\"]\n\n\ [[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\ [[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n" ); fs::write(&manifest_path, &v1).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&manifest_path) .assert() .success(); // v2: drop the script (and, implicitly, its un-representable email // trigger). A prune apply must REFUSE rather than cascade-destroy it. let v2 = format!("[app]\nslug = \"{slug}\"\nname = \"M5\"\n"); fs::write(&manifest_path, &v2).unwrap(); let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&manifest_path) .arg("--prune") .output() .expect("apply --prune"); assert!( !out.status.success(), "prune must refuse to orphan an email trigger" ); // The script and its email trigger both survive the refused apply. let scripts = String::from_utf8( common::pic_as(&env) .args(["scripts", "ls", "--app", &slug]) .output() .unwrap() .stdout, ) .unwrap(); assert!( scripts.contains("handler"), "script must survive:\n{scripts}" ); let triggers = String::from_utf8( common::pic_as(&env) .args(["triggers", "ls", "--app", &slug]) .output() .unwrap() .stdout, ) .unwrap(); assert!( triggers.lines().any(|l| l.contains("email")), "email trigger must survive:\n{triggers}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn apply_email_unset_secret_fails() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("m5-nosecret"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); let dir = TempDir::new().unwrap(); fs::create_dir_all(dir.path().join("scripts")).unwrap(); fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap(); let manifest = format!( "[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\ [[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\ [[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"never-set\"\n" ); let manifest_path = dir.path().join("picloud.toml"); fs::write(&manifest_path, &manifest).unwrap(); // The referenced secret was never set → apply must fail atomically. let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&manifest_path) .output() .expect("apply"); assert!( !out.status.success(), "apply must fail when an email secret is unset" ); // Atomic: neither the script nor the email trigger was created. let s = String::from_utf8( common::pic_as(&env) .args(["scripts", "ls", "--app", &slug]) .output() .unwrap() .stdout, ) .unwrap(); assert!( !s.contains("handler"), "failed apply must leave nothing behind:\n{s}" ); }