Files
PiCloud/crates/picloud-cli/tests/env_approval.rs
MechaCat02 3f272daf04 fix(cli): make the per-env approval gate fail closed
Review #2 (MEDIUM). The M3 `[project.environments]` approval gate silently
failed open in three shapes:

- `EnvPolicy.confirm` was `#[serde(default)]`, so `production = {}` parsed as
  un-gated. `confirm` is now a REQUIRED field — an env listed with an empty
  policy is a load error (`missing field 'confirm'`), not a silent no-gate.
- `pic apply --file <leaf>` where the leaf carried no `[project]` skipped the
  gate even when the repo root gated the env. `run` now discovers the governing
  `[project]` by walking up from the manifest to the nearest ancestor
  `picloud.toml` that declares one (`find_governing_project`, loaded without the
  env overlay — only the block matters).
- A `[project].environments` in a non-root manifest under `--dir` was dropped
  with a generic "ignored" note; `build_tree` now warns explicitly that its
  approval gating is not enforced.

Pinned by `tests/env_approval.rs` (empty-policy load error; leaf `--file` apply
honoring the root gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:48:09 +02:00

207 lines
7.2 KiB
Rust

//! §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 <e>` is refused unless it is explicitly
//! `--approve <e>`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 <leaf>` 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();
}