Files
PiCloud/crates/picloud-cli/tests/staleness.rs
MechaCat02 be5df06a48 feat(project-tool): bound-plan staleness check (content fingerprint)
`pic plan` now records a fingerprint of the live state it diffed against;
`pic apply` replays it and the server refuses (HTTP 409) if the app
changed underneath the reviewed plan — the §4.2 "apply exactly what you
reviewed" guarantee, in its content-addressed form (no migration, no
changes to interactive write paths).

Server (manager-core):
- `state_token(CurrentState)`: deterministic FNV-1a fingerprint over what
  the diff keys on — script name+version (version bumps on any edit),
  route identity+binding/attrs, trigger membership+enabled, secret names.
  Order-independent; a collision can only yield a false "unchanged", never
  a false refusal.
- `plan` returns it (flattened onto the plan JSON, so the wire stays
  additive); `apply` takes an optional `expected_token` and, under the
  apply lock before any write, returns `StateMoved` (409) on mismatch.

CLI:
- `.picloud/` link state (`linkstate`): `pic plan` writes the token scoped
  to the app slug; `pic apply` replays it, then clears it on success (the
  token is single-use — the next apply re-plans). `--force` skips the
  check; apply with no recorded plan still works standalone (today's
  behavior). `.picloud/` is already gitignored by `pic init`.

The tree-structure version (the other half of §4.2's counter) stays a
deliberate no-op until groups exist — it only guards reparent/structural
moves, which don't exist single-app.

Tested: state_token unit test (stable/order-independent/sensitive) + a
staleness journey (plan → out-of-band deploy → apply refuses → --force
applies); manager-core lib 363 + cli bins 31 + 13 journeys green; clippy
-D warnings clean.

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

83 lines
2.7 KiB
Rust

//! Bound-plan staleness: `pic plan` records a state token under `.picloud/`,
//! and a later `pic apply` refuses (without `--force`) if the app changed
//! out-of-band since the plan was reviewed.
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apply_refuses_when_state_moved_since_plan() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("stale");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hello.rhai"), "let x = 1; x").unwrap();
let manifest_path = dir.path().join("picloud.toml");
let manifest = format!(
"[app]\nslug = \"{slug}\"\nname = \"Stale\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
);
fs::write(&manifest_path, &manifest).unwrap();
// Establish hello, then plan — the plan records the current state token.
apply(&env, &manifest_path).assert().success();
common::pic_as(&env)
.args(["plan", "--file"])
.arg(&manifest_path)
.assert()
.success();
assert!(
dir.path().join(".picloud/plan.json").exists(),
"plan must record the bound-plan token under .picloud/"
);
// Out-of-band change: deploy an extra script the manifest doesn't mention.
fs::write(dir.path().join("scripts/sneaky.rhai"), "let y = 2; y").unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/sneaky.rhai"))
.args(["--app", &slug])
.assert()
.success();
// Apply must now refuse — the live state no longer matches the plan.
let refused = apply(&env, &manifest_path).output().expect("apply");
assert!(
!refused.status.success(),
"apply must refuse after out-of-band change"
);
let err = String::from_utf8_lossy(&refused.stderr);
assert!(
err.contains("changed since") || err.contains("pic plan"),
"refusal should explain the staleness:\n{err}"
);
// `--force` bypasses the check and applies anyway.
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.arg("--force")
.assert()
.success();
}
fn apply(env: &common::TestEnv, manifest_path: &std::path::Path) -> assert_cmd::Command {
let mut cmd = common::pic_as(env);
cmd.args(["apply", "--file"]).arg(manifest_path);
cmd
}