//! §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" ); }