//! M3 multi-repo single-owner ownership (§7) via `pic apply --dir`. //! //! On this build groups pre-exist (created via `pic groups create`; the tree //! apply reconciles a node's CONTENT and CLAIMS its ownership, it does not //! create the group). So each test first creates the group(s), then: //! * the first repo to `apply` a group CLAIMS it (its `.picloud/` project key //! becomes the group's `owner_project`), //! * a SECOND repo (a distinct dir → a distinct project key) applying the //! same group is REJECTED with an ownership conflict, //! * `--takeover` lets the second repo seize it — admin-gated, so the fixture //! instance-owner succeeds, flipping ownership (proven by the first repo now //! being the one rejected), but a group `editor` is 403'd (ownership ⟂ RBAC, //! §7.4), //! * `apply --prune` deletes an owned, now-undeclared, empty group; a group //! this project never claimed is never touched. //! //! Each project lives in its own `TempDir`, so `pic` mints a distinct project //! key per repo (`.picloud/project.json`) — the two-repo topology §7 governs. use std::fs; use std::path::Path; use tempfile::TempDir; use crate::common; use crate::common::cleanup::GroupGuard; use crate::common::member; /// A single-group project dir declaring the (pre-existing) group `slug`. No /// scripts, so a structural prune can delete it (a group holding scripts/apps /// is RESTRICT-pinned). `extra` is appended to the `[group]` table verbatim. fn group_dir(slug: &str, extra: &str) -> TempDir { let dir = TempDir::new().expect("tempdir"); fs::write( dir.path().join("picloud.toml"), format!("[group]\nslug = \"{slug}\"\nname = \"Owned Group\"\n{extra}"), ) .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") } fn create_group(env: &common::TestEnv, slug: &str, parent: Option<&str>) { let mut cmd = common::pic_as(env); cmd.args(["groups", "create", slug]); if let Some(p) = parent { cmd.args(["--parent", p]); } let out = cmd.output().expect("groups create"); assert!( out.status.success(), "groups create {slug} failed: {}", String::from_utf8_lossy(&out.stderr) ); } fn ls_groups(env: &common::TestEnv) -> String { String::from_utf8( common::pic_as(env) .args(["groups", "ls"]) .output() .expect("groups ls") .stdout, ) .unwrap() } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn first_repo_claims_second_is_rejected_then_takeover_flips_ownership() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("own-g"); let _g = GroupGuard::new(&env.url, &env.token, &slug); create_group(&env, &slug, None); // Repo A claims the group on first apply (owner NULL → project A). let repo_a = group_dir(&slug, ""); let a1 = apply(&env, repo_a.path(), &[]); assert!( a1.status.success(), "repo A claim apply failed: {}", String::from_utf8_lossy(&a1.stderr) ); // Repo B (a different dir → a different project key) is rejected: the group // is owned by project A. let repo_b = group_dir(&slug, ""); let b1 = apply(&env, repo_b.path(), &[]); assert!( !b1.status.success(), "repo B apply must be rejected (group owned by A)" ); let b1_err = String::from_utf8_lossy(&b1.stderr).to_lowercase(); assert!( b1_err.contains("owned by another project") || b1_err.contains("takeover"), "conflict message should name the ownership clash:\n{b1_err}" ); // Repo B with --takeover succeeds (the fixture token is instance owner, so // it holds GroupAdmin on every node) and flips ownership to project B. let b2 = apply(&env, repo_b.path(), &["--takeover"]); assert!( b2.status.success(), "repo B --takeover should succeed: {}", String::from_utf8_lossy(&b2.stderr) ); // Proof the flip took: repo A — formerly the owner — is now the one // rejected without --takeover. let a2 = apply(&env, repo_a.path(), &[]); assert!( !a2.status.success(), "after takeover, repo A must now be rejected (B owns the group)" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn takeover_without_group_admin_is_forbidden() { // §7.4 — ownership ⟂ RBAC: `--takeover` needs GroupAdmin specifically, a // second gate beyond the write caps. A group `editor` (who CAN write group // vars) still cannot seize the group for their repo. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("own-ta"); let _g = GroupGuard::new(&env.url, &env.token, &slug); create_group(&env, &slug, None); // Admin (repo A) claims the group. let repo_a = group_dir(&slug, ""); assert!( apply(&env, repo_a.path(), &[]).status.success(), "admin claim apply should succeed" ); // A member granted `editor` (not app_admin/group_admin) on the group. let m = member::member_user(fx, &common::unique_username("ta")); common::pic_as(&env) .args(["groups", "members", "add", &slug, &m.id, "--role", "editor"]) .output() .expect("add group member"); let member_env = common::custom_env(&fx.url, &m.token); common::seed_credentials(&member_env, &m.username); // The member's repo B (a distinct project key) declares a group var (so its // write cap is satisfied) and attempts a takeover → 403 at the GroupAdmin // gate, before any write lands. let repo_b = group_dir(&slug, "\n[vars]\nregion = \"eu\"\n"); let out = apply(&member_env, repo_b.path(), &["--takeover"]); assert!( !out.status.success(), "non-admin --takeover must be forbidden" ); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("forbidden") || err.contains("403"), "takeover denial should be an authz error:\n{err}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn prune_deletes_owned_undeclared_group_only() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let parent = common::unique_slug("own-par"); let child = common::unique_slug("own-child"); // Drop order: child (registered last → dropped first), then parent. let _gp = GroupGuard::new(&env.url, &env.token, &parent); let _gc = GroupGuard::new(&env.url, &env.token, &child); create_group(&env, &parent, None); create_group(&env, &child, Some(&parent)); // Repo declares parent (root manifest) + child (a nested dir); apply claims // both for this project. let dir = TempDir::new().expect("tempdir"); fs::write( dir.path().join("picloud.toml"), format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\n"), ) .unwrap(); fs::create_dir_all(dir.path().join("sub")).unwrap(); fs::write( dir.path().join("sub/picloud.toml"), format!("[group]\nslug = \"{child}\"\nname = \"Child\"\n"), ) .unwrap(); let out = apply(&env, dir.path(), &[]); assert!( out.status.success(), "initial claim apply failed: {}", String::from_utf8_lossy(&out.stderr) ); let groups = ls_groups(&env); assert!( groups.contains(&parent) && groups.contains(&child), "both groups should exist after claim" ); // Drop the child manifest from the SAME repo (keeping its project key) so // the child becomes owned-but-undeclared, then apply --prune: the empty // child is deleted; the parent (still declared) is kept. fs::remove_dir_all(dir.path().join("sub")).unwrap(); let out2 = apply(&env, dir.path(), &["--prune", "--yes"]); assert!( out2.status.success(), "prune apply failed: {}", String::from_utf8_lossy(&out2.stderr) ); let groups = ls_groups(&env); assert!(groups.contains(&parent), "declared parent must be kept"); assert!( !groups.contains(&child), "owned-but-undeclared child must be pruned" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn prune_refuses_to_cascade_a_groups_shared_data() { // §7 M3 data-loss guard: an owned-but-undeclared group is NOT structurally // pruned if it holds §11.6 shared collections/secrets (those CASCADE) — it // is kept with a warning instead of being silently vaporized. let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let parent = common::unique_slug("own-dpar"); let child = common::unique_slug("own-dchild"); let _gp = GroupGuard::new(&env.url, &env.token, &parent); let _gc = GroupGuard::new(&env.url, &env.token, &child); create_group(&env, &parent, None); create_group(&env, &child, Some(&parent)); // Declare parent + child; the child declares a shared KV collection, so the // first apply claims both AND creates a group-owned collection marker. let dir = TempDir::new().expect("tempdir"); fs::write( dir.path().join("picloud.toml"), format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\n"), ) .unwrap(); fs::create_dir_all(dir.path().join("sub")).unwrap(); fs::write( dir.path().join("sub/picloud.toml"), format!("[group]\nslug = \"{child}\"\nname = \"Child\"\n\ncollections = [\"catalog\"]\n"), ) .unwrap(); assert!( apply(&env, dir.path(), &[]).status.success(), "initial claim+collection apply should succeed" ); // Drop the child node, then prune: the child owns a shared collection, so it // must be KEPT (with a warning), not cascaded away. fs::remove_dir_all(dir.path().join("sub")).unwrap(); let out = apply(&env, dir.path(), &["--prune", "--yes"]); assert!( out.status.success(), "prune apply should succeed (skipping the protected group): {}", String::from_utf8_lossy(&out.stderr) ); let combined = format!( "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); assert!( combined.contains("shared collections"), "prune should warn it skipped a group owning shared data:\n{combined}" ); assert!( ls_groups(&env).contains(&child), "a group owning shared collections must NOT be pruned" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn legacy_apply_without_project_key_ignores_ownership() { // Regression (§7): a tree apply with NO project key (legacy / direct-API // caller) must skip ALL ownership — no conflicts, no claims. A bug gated the // conflict classification on `our_project_id` (None for a keyless caller), // so ANY already-owned declared group was spuriously rejected. Drive the raw // wire without `project_key` (the CLI always sends one). let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("own-legacy"); let _g = GroupGuard::new(&env.url, &env.token, &slug); create_group(&env, &slug, None); // Repo A claims the group (a normal CLI apply — sends a project key). let repo_a = group_dir(&slug, ""); assert!( apply(&env, repo_a.path(), &[]).status.success(), "claim apply should succeed" ); // A keyless raw apply of the same (now-owned) group must NOT 409 — ownership // is simply not evaluated without a project key. let http = reqwest::blocking::Client::new(); let body = serde_json::json!({ "bundle": { "nodes": [{ "kind": "group", "slug": slug, "bundle": {} }] }, "prune": false, }); let resp = http .post(format!("{}/api/v1/admin/tree/apply", env.url)) .bearer_auth(&env.token) .json(&body) .send() .expect("tree apply"); assert!( resp.status().is_success(), "a keyless (legacy) apply must ignore ownership, not conflict (got {}: {})", resp.status(), resp.text().unwrap_or_default() ); }