Files
PiCloud/crates/picloud-cli/tests/init.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

81 lines
2.2 KiB
Rust

//! `pic init` journey — offline scaffolding, no server/DB needed (so these
//! tests are NOT gated on `DATABASE_URL` and never touch `common::fixture`).
use std::fs;
use assert_cmd::Command;
use tempfile::TempDir;
fn pic() -> Command {
Command::cargo_bin("pic").expect("pic binary")
}
#[test]
fn init_scaffolds_a_deployable_project() {
let dir = TempDir::new().unwrap();
pic()
.current_dir(dir.path())
.args(["init", "demo-app"])
.assert()
.success();
let toml = fs::read_to_string(dir.path().join("picloud.toml")).unwrap();
assert!(toml.contains("slug = \"demo-app\""), "got:\n{toml}");
assert!(
toml.contains("name = \"Demo App\""),
"name defaults to title-cased slug:\n{toml}"
);
assert!(
dir.path().join("scripts/hello.rhai").exists(),
"scaffold writes the example script"
);
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
gitignore.lines().any(|l| l.trim() == ".picloud/"),
"init must gitignore .picloud/:\n{gitignore}"
);
}
#[test]
fn init_refuses_to_overwrite_without_force() {
let dir = TempDir::new().unwrap();
pic()
.current_dir(dir.path())
.args(["init", "demo-app"])
.assert()
.success();
// A second run must refuse rather than clobber edits.
pic()
.current_dir(dir.path())
.args(["init", "demo-app"])
.assert()
.failure();
// `--force` overrides.
pic()
.current_dir(dir.path())
.args(["init", "demo-app", "--force"])
.assert()
.success();
}
#[test]
fn init_appends_to_an_existing_gitignore_once() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
pic()
.current_dir(dir.path())
.args(["init", "demo-app"])
.assert()
.success();
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
gitignore.contains("target/"),
"must preserve existing rules"
);
assert_eq!(
gitignore.matches(".picloud/").count(),
1,
"must add the ignore exactly once:\n{gitignore}"
);
}