//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`: //! * a root manifest `[project]` block marks an environment confirm-required, //! * applying to that env WITHOUT `--approve` is refused (a blanket `--yes` //! does not cover it) — refused non-interactively at the CLI, //! * `--approve ` (as an admin) lets it through, //! * a non-gated environment applies with plain `--yes`, //! * single-node `apply --file` to a gated env is refused (no silent bypass), //! * approving a gated apply needs ADMIN authority on the node — a non-admin //! editor with `--approve` is refused server-side (403). use std::fs; use std::path::Path; use tempfile::TempDir; use crate::common; use crate::common::cleanup::{AppGuard, GroupGuard}; use crate::common::member; /// A single-app project dir whose root manifest declares a `[project]` policy: /// `production` is confirm-required, `staging` is not. A `[vars]` entry gives /// the app write-requiring content so an `editor` member's AppVarsWrite is /// exercised (proving the admin gate is ABOVE editor-write). Vars cascade with /// the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown. fn project_dir(app: &str) -> TempDir { let dir = TempDir::new().expect("tempdir"); fs::write( dir.path().join("picloud.toml"), format!( "[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\ [[project.environments]]\nname = \"production\"\nconfirm = true\n\n\ [[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\ [vars]\nregion = \"eu\"\n" ), ) .unwrap(); // `--env ` requires the overlay file to exist; empty overlays keep the // base slug (same app across envs — we're testing the gate, not env routing). fs::write(dir.path().join("picloud.production.toml"), "").unwrap(); fs::write(dir.path().join("picloud.staging.toml"), "").unwrap(); dir } fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output { let mut cmd = common::pic_as(env); cmd.args(["apply", "--dir"]).arg(dir).args(extra); cmd.output().expect("apply --dir") } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn confirm_required_env_needs_explicit_approval() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("appr-g"); let app = common::unique_slug("appr-a"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let _a = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app, "--group", &group]) .assert() .success(); let dir = project_dir(&app); // --- production is confirm-required: --yes alone is refused. --- let out = apply(&env, dir.path(), &["--env", "production", "--yes"]); assert!( !out.status.success(), "production apply without --approve must be refused" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("approve"), "refusal should mention --approve:\n{err}" ); // --- with --approve production, it applies. --- let ok = apply( &env, dir.path(), &["--env", "production", "--approve", "production"], ); assert!( ok.status.success(), "approved production apply should succeed: {}", String::from_utf8_lossy(&ok.stderr) ); // --- staging is NOT gated: plain --yes applies. --- let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]); assert!( ok2.status.success(), "non-gated staging apply should succeed: {}", String::from_utf8_lossy(&ok2.stderr) ); // --- single-node `apply --file` to a gated env is refused (no bypass). --- let single = common::pic_as(&env) .args(["apply", "--file"]) .arg(dir.path().join("picloud.toml")) .args(["--env", "production", "--yes"]) .output() .expect("apply --file"); assert!( !single.status.success(), "single-node apply to a confirm-required env must be refused" ); let serr = String::from_utf8_lossy(&single.stderr).to_lowercase(); assert!( serr.contains("confirm-required") || serr.contains("--dir"), "single-node refusal should point at --dir:\n{serr}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn approving_a_gated_apply_requires_admin() { // §4.2: approving a confirm-required env is admin-gated — a second gate on // top of the editor-level write caps an ordinary apply needs. An editor who // CAN write the app (its `[vars]`) still cannot approve a gated apply. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("appr2-g"); let app = common::unique_slug("appr2-a"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let _a = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app, "--group", &group]) .assert() .success(); // A member with `editor` (write) on the app — enough for an ordinary apply, // not enough to approve a gated environment. let m = member::member_user(fx, &common::unique_username("appr")); member::grant_membership(fx, &app, &m.id, "editor"); let member_env = common::custom_env(&fx.url, &m.token); common::seed_credentials(&member_env, &m.username); let dir = project_dir(&app); let out = apply( &member_env, dir.path(), &["--env", "production", "--approve", "production"], ); 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"), "approval denial should be an authz error:\n{err}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn gated_group_node_admin_gate_is_not_bypassable_by_uuid_slug() { // Regression (§4.2): `enforce_env_approval` must resolve a group node by // UUID-or-slug and FAIL CLOSED — a bare `get_by_slug` skipped the GroupAdmin // gate when the node addressed the group by its UUID (which the apply still // resolves), letting a group EDITOR apply to a confirm-required env without // admin. We drive the raw `/tree/apply` wire directly (the CLI only ever // sends slugs) with the group node's UUID as its slug. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("appr3-g"); let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); // Resolve the group's UUID. let http = reqwest::blocking::Client::new(); let detail: serde_json::Value = http .get(format!("{}/api/v1/admin/groups/{}", env.url, group)) .bearer_auth(&env.token) .send() .expect("get group") .json() .expect("group json"); let group_uuid = detail["id"].as_str().expect("group id").to_string(); // A group `editor` — write caps, but NOT GroupAdmin. let m = member::member_user(fx, &common::unique_username("appr3")); common::pic_as(&env) .args([ "groups", "members", "add", &group, &m.id, "--role", "editor", ]) .assert() .success(); // A gated bundle whose single group node is addressed by UUID. A `vars` // entry makes the node non-empty (and exercises the editor's write cap). let body = serde_json::json!({ "bundle": { "nodes": [{ "kind": "group", "slug": group_uuid, "bundle": { "vars": { "region": "eu" } } }], "project": { "environments": [{ "name": "production", "confirm": true }] } }, "prune": false, "env": "production", "approved_envs": ["production"], "allow_takeover": false, }); let resp = http .post(format!("{}/api/v1/admin/tree/apply", env.url)) .bearer_auth(&m.token) .json(&body) .send() .expect("tree apply"); assert_eq!( resp.status().as_u16(), 403, "a group editor must be 403'd approving a gated apply even when the node \ is addressed by UUID (got {}: {})", resp.status(), resp.text().unwrap_or_default() ); }