//! 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`, //! * 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. fn project_dir(app: &str) -> TempDir { let dir = TempDir::new().expect("tempdir"); // A `[vars]` entry so the app bundle has write-requiring content: an `editor` // member must hold (and exercise) AppVarsWrite to pass authz_tree — which // makes the admin-gate test prove the approval gate is ABOVE editor-write, // not merely "any non-admin is refused". (Vars cascade-delete with the app, // unlike scripts which are ON DELETE RESTRICT, so AppGuard teardown is clean.) 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 silent // bypass): the admin-gated approval is a `--dir` feature. --- 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 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}" ); }