Files
PiCloud/crates/picloud-cli/tests/approval.rs
MechaCat02 aa2831da90 docs+test(hierarchies): end-to-end review follow-ups (M5/M6/M7)
Findings from a 3-lens end-to-end review (correctness, security, tests).
Security review came back clean (cross-repo authz sound, isolation intact,
M5 gate sound modulo the documented manifest-trust boundary). No behavior
changes here — only accuracy + coverage:

- apply_service Phase B2: correct the descendant-expansion comment. The chain
  is COMMITTED ancestry (ancestors() reads the pool), so a reparent in the same
  apply takes effect next apply — same "one more apply" shape the in-tree path
  and group-create reparenting already have. Authz stays sound: the gate and the
  expansion consume the SAME chain (checked == written).
- doc §4.5: document the two known "one more apply / no data risk" limitations
  — reparent-in-same-apply template resolution, and the `{env}` source split
  (declared apps use --env; cross-repo descendants use apps.environment).
- approval journey: give the app real (`[vars]`) content so the editor member's
  AppVarsWrite is actually exercised — the admin-gate test now proves approval
  is ABOVE editor-write, not just "any non-admin is refused". (Vars cascade with
  the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown.)
- templates journey: assert descendant expansions are idempotent (stable row
  ids on re-apply — no churn), matching the in-tree guarantee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:57:21 +02:00

170 lines
6.3 KiB
Rust

//! 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 <env>` (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 <e>` 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}"
);
}