diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 30234cf..70467c4 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -24,6 +24,7 @@ mod config; mod dead_letters; mod email_queue; mod enabled; +mod env_approval; mod env_overlay; mod extension_points; mod group_create; @@ -44,13 +45,13 @@ mod roles; mod routes; mod scripts; mod sealed; -mod structural_divergence; mod secrets; mod shared_queues; mod shared_topics; mod shared_triggers; mod staleness; mod stateful_templates; +mod structural_divergence; mod suppress; mod tree; mod triggers; diff --git a/crates/picloud-cli/tests/env_approval.rs b/crates/picloud-cli/tests/env_approval.rs new file mode 100644 index 0000000..c140b75 --- /dev/null +++ b/crates/picloud-cli/tests/env_approval.rs @@ -0,0 +1,88 @@ +//! §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" + ); +} diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 76c56d3..292268c 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -824,8 +824,19 @@ reshapes the tree): `Refuse` → `ApplyError::StructuralDivergence` (422, naming divergence (`divergence_preview` → a `structure` row: `diverged`, server + manifest parents). CLI: `--force-local-structure` / `--adopt-server-structure` (mutually exclusive) → the `structure_mode` wire field; the 422 surfaces verbatim. A concurrent `pic groups reparent` between plan and apply still trips `StateMoved` (the tree -token folds `structure_version`). Pinned by `tests/structural_divergence.rs`. **Still deferred:** per-env approval -gating (`[project.environments]`). +token folds `structure_version`). Pinned by `tests/structural_divergence.rs`. + +**§3 M3 shipped — per-env approval gating (`[project.environments]`).** A `[project.environments]` block on the +root `[project]` maps an env name to `{ confirm = bool }` (`ManifestProject.environments`, `#[serde(skip_serializing)]` +— purely CLI-side). `pic apply --env ` is REFUSED (client-side, before any request) when `` is confirm-required +unless it is explicitly `--approve `d; a blanket `--yes` does NOT cover a gated env (§4.2 "CI must opt in per +environment"); an unlisted / `confirm = false` env applies freely. `require_env_approval` gates both the single +(`run`) and tree (`run_tree`) apply paths; `--approve ` is a repeatable flag. Value overlays +(`picloud..toml`) already merge client-side, so this milestone is the confirm-policy gate only. Pinned by +`tests/env_approval.rs`. **Deferred (documented, not built):** server-side audited approval-override capability +(§4.2 → `authz::can`); env-as-app mapping + declarative server-side `@E` config scoping. + +**§6/§3 group-tree Tier 1 (M1 create · M2 divergence/reparent · M3 per-env approval) is COMPLETE.** ---