//! §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 CLI gates client-side (fast fail) AND the SERVER re-derives the //! policy from the request and enforces it — a confirm-required apply is refused //! (409) unless approved, and an approved override additionally requires ADMIN on //! every declared node (a step up from editor write caps) and is audited. This //! closes the patched/older-CLI bypass (a client that still declares the project //! but skips the local check); a request that omits the policy is inherently //! ungated (the policy is applier-supplied, not persisted server state). use std::fs; use std::path::Path; use tempfile::TempDir; use crate::common; use crate::common::cleanup::GroupGuard; use crate::common::member; /// 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(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn server_enforces_the_gate_against_a_non_stock_client() { // The client-side gate is a convenience; a patched CLI or a raw request can // skip it. The server re-derives the policy from the wire and enforces it: // a raw tree apply to a confirm-required env WITHOUT the env in // `approved_envs` is refused (409), regardless of the client. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("eas-grp"); let project = common::unique_slug("eas-proj"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let http = reqwest::blocking::Client::new(); let body = |approved: serde_json::Value| { serde_json::json!({ "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] }, "project": { "slug": project, "environments": { "production": { "confirm": true } } }, "env": "production", "approved_envs": approved, "prune": false, }) }; // No approval on the wire → 409 ApprovalRequired (server-enforced, the CLI's // client-side gate never ran). let resp = http .post(format!("{}/api/v1/admin/tree/apply", env.url)) .bearer_auth(&env.token) .json(&body(serde_json::json!([]))) .send() .expect("tree apply"); assert_eq!( resp.status().as_u16(), 409, "a raw gated apply without approval must be refused server-side (got {}: {})", resp.status(), resp.text().unwrap_or_default() ); // Approved on the wire + an admin token (holds GroupAdmin) → succeeds. let ok = http .post(format!("{}/api/v1/admin/tree/apply", env.url)) .bearer_auth(&env.token) .json(&body(serde_json::json!(["production"]))) .send() .expect("tree apply"); assert!( ok.status().is_success(), "an approved gated apply by an admin must succeed (got {}: {})", ok.status(), ok.text().unwrap_or_default() ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn approving_a_gated_apply_requires_admin() { // §4.2: overriding a gate is ADMIN authority — a step up from the editor // write caps an ordinary apply needs. A group `editor` who CAN write the // group still cannot approve a gated apply of it. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("eaa-grp"); let project = common::unique_slug("eaa-proj"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); // A member granted `editor` (write, not admin) on the group. let m = member::member_user(fx, &common::unique_username("eaa")); common::pic_as(&env) .args([ "groups", "members", "add", &group, &m.id, "--role", "editor", ]) .assert() .success(); let member_env = common::custom_env(&fx.url, &m.token); common::seed_credentials(&member_env, &m.username); // A gated repo managing that group (with a var, so the editor's write cap is // exercised — proving the admin gate is ABOVE it). Applied by the editor. 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 = \"{group}\"\nname = \"Gated\"\n\n\ [vars]\nregion = \"eu\"\n" ), ) .unwrap(); fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap(); let manifest = dir.path().join("picloud.toml"); let out = common::pic_as(&member_env) .args(["apply", "--file"]) .arg(&manifest) .args(["--env", "production", "--approve", "production"]) .output() .expect("apply"); assert!( !out.status.success(), "a non-admin editor must not be able to approve a gated apply" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("forbidden") || err.contains("403"), "the approval denial should be an authz error:\n{err}" ); }