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>
This commit is contained in:
@@ -20,12 +20,18 @@ use crate::common::member;
|
||||
/// `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"
|
||||
[[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\
|
||||
[vars]\nregion = \"eu\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -247,6 +247,21 @@ fn expansion_paths(paths: &[String]) -> Vec<String> {
|
||||
rows.iter().map(|r| r.get(0)).collect()
|
||||
}
|
||||
|
||||
/// `(path, id)` of template-expanded routes among `paths` — for asserting an
|
||||
/// expansion is STABLE (same row id) across a re-apply, i.e. not churned.
|
||||
fn expansion_rows_for(paths: &[String]) -> Vec<(String, String)> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path, id::text FROM routes \
|
||||
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
||||
&[&paths],
|
||||
)
|
||||
.expect("query expansion rows");
|
||||
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
||||
}
|
||||
|
||||
/// M7 (§4.5): a route template fans out to EVERY descendant app in the DB
|
||||
/// subtree, not only the app nodes present in the apply. An app created under
|
||||
/// the group out-of-band (absent from the manifest) still receives the
|
||||
@@ -338,12 +353,28 @@ fn route_template_reaches_out_of_tree_descendant() {
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
let want = [format!("/x/{c}"), format!("/x/{d}")];
|
||||
assert_eq!(
|
||||
expansion_paths(&[format!("/x/{c}"), format!("/x/{d}")]),
|
||||
vec![format!("/x/{c}"), format!("/x/{d}")],
|
||||
expansion_paths(&want),
|
||||
want.to_vec(),
|
||||
"out-of-tree descendants at depth 1 AND 2 receive expansions"
|
||||
);
|
||||
|
||||
// Idempotent re-apply: descendants must not churn (same row ids — provenance
|
||||
// by `from_template`, not delete+recreate), same as the in-tree path.
|
||||
let before = expansion_rows_for(&want);
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
before,
|
||||
expansion_rows_for(&want),
|
||||
"re-apply must not churn descendant expansions (stable ids)"
|
||||
);
|
||||
|
||||
// Remove the template + prune: every descendant's expansion is reaped,
|
||||
// in-tree or not, at any depth.
|
||||
fs::write(dir.path().join("picloud.toml"), group_toml(false)).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user