//! §3 M3 — per-env approval gating (`[project.environments]`). //! //! A `[project.environments]` block marks some envs confirm-required. Applying //! to such an env with `pic apply --env ` is refused unless it is explicitly //! `--approve `d — and a blanket `--yes` does NOT cover it (§4.2, "CI must //! opt in per environment"). An unlisted or `confirm = false` env applies //! freely. The gate is client-side, so a refused apply never reaches the server. use std::fs; use std::path::Path; use tempfile::TempDir; use crate::common; use crate::common::cleanup::GroupGuard; /// A repo whose `[project]` gates `production` (confirm-required) but not /// `staging`, managing one pre-existing `[group]`. Minimal per-env overlay files /// exist so `--env` can load them. fn gated_repo(dir: &Path, project: &str, group: &str) { fs::write( dir.join("picloud.toml"), format!( "[project]\nslug = \"{project}\"\n\n\ [project.environments]\nproduction = {{ confirm = true }}\n\ staging = {{ confirm = false }}\n\n\ [group]\nslug = \"{group}\"\nname = \"Env Gated\"\n" ), ) .unwrap(); fs::write( dir.join("picloud.production.toml"), "# production overlay\n", ) .unwrap(); fs::write(dir.join("picloud.staging.toml"), "# staging overlay\n").unwrap(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn confirm_required_env_needs_explicit_approve() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("ea-grp"); let project = common::unique_slug("ea-proj"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let dir = TempDir::new().unwrap(); gated_repo(dir.path(), &project, &group); let manifest = dir.path().join("picloud.toml"); let apply = |args: &[&str]| -> std::process::Output { let mut c = common::pic_as(&env); c.args(["apply", "--file"]).arg(&manifest).args(args); c.output().expect("apply") }; // production is confirm-required → a bare `--env production` is refused, // and the message names `--approve`. (Client-side: never hits the server.) let out = apply(&["--env", "production"]); assert!(!out.status.success(), "production must require approval"); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("approve") && err.contains("production"), "the refusal must point at --approve production:\n{err}" ); // A blanket `--yes` does NOT cover a gated env. assert!( !apply(&["--env", "production", "--yes"]).status.success(), "--yes must not bypass a confirm-required env" ); // `--approve production` lets it through. assert!( apply(&["--env", "production", "--approve", "production"]) .status .success(), "an explicit --approve production must apply" ); // staging is `confirm = false` → applies freely, no approval needed. assert!( apply(&["--env", "staging"]).status.success(), "an un-gated env must apply without --approve" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn an_env_listed_with_an_empty_policy_is_a_hard_error() { // `production = {}` (no `confirm`) must FAIL to load, not silently parse as // un-gated — you can't half-declare a gate. `confirm` is a required field. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("eae-grp"); let project = common::unique_slug("eae-proj"); let _g = GroupGuard::new(&env.url, &env.token, &group); let dir = TempDir::new().unwrap(); let manifest = dir.path().join("picloud.toml"); fs::write( &manifest, format!( "[project]\nslug = \"{project}\"\n\n\ [project.environments]\nproduction = {{}}\n\n\ [group]\nslug = \"{group}\"\nname = \"Empty Policy\"\n" ), ) .unwrap(); fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap(); let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&manifest) .args(["--env", "production"]) .output() .expect("apply"); assert!( !out.status.success(), "an env with an empty policy must be a load error, not a silent no-gate" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("confirm"), "the error must name the missing `confirm` field:\n{err}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn leaf_file_apply_honors_the_root_env_gate() { // `pic apply --file ` where the leaf carries no `[project]` still // honors the gate declared in the repo root (found by walking up). let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let base = common::unique_slug("eal-base"); let leaf = common::unique_slug("eal-leaf"); let project = common::unique_slug("eal-proj"); let _b = GroupGuard::new(&env.url, &env.token, &base); let _l = GroupGuard::new(&env.url, &env.token, &leaf); common::pic_as(&env) .args(["groups", "create", &base]) .assert() .success(); common::pic_as(&env) .args(["groups", "create", &leaf, "--parent", &base]) .assert() .success(); // Root manifest carries the gating `[project]`; the leaf (in a subdir) has // no `[project]` of its own and is applied on its own with `--file`. let dir = TempDir::new().unwrap(); fs::write( dir.path().join("picloud.toml"), format!( "[project]\nslug = \"{project}\"\n\n\ [project.environments]\nproduction = {{ confirm = true }}\n\n\ [group]\nslug = \"{base}\"\nname = \"Base\"\n" ), ) .unwrap(); let sub = dir.path().join("sub"); fs::create_dir_all(&sub).unwrap(); let leaf_manifest = sub.join("picloud.toml"); fs::write( &leaf_manifest, format!("[group]\nslug = \"{leaf}\"\nname = \"Leaf\"\n"), ) .unwrap(); fs::write(sub.join("picloud.production.toml"), "# overlay\n").unwrap(); // Without --approve: refused, because the gate is discovered up-tree. let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&leaf_manifest) .args(["--env", "production"]) .output() .expect("apply"); assert!( !out.status.success(), "a leaf --file apply must honor the root's env gate" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("approve") && err.contains("production"), "the refusal must point at --approve production:\n{err}" ); // With --approve production: passes the gate and applies (leaf pre-exists). common::pic_as(&env) .args(["apply", "--file"]) .arg(&leaf_manifest) .args(["--env", "production", "--approve", "production"]) .assert() .success(); }