//! §11.6 shared group collections, end to end via `pic`: //! * a group declares a shared KV collection `catalog`; two apps under it //! share one store — an authenticated write in app A is read back in app B //! (cross-app data sharing, the deliberate inverse of per-app isolation), //! * an app under a SIBLING group naming `catalog` gets CollectionNotShared //! (the ancestry walk is the isolation boundary), //! * `pic collections ls --group` lists the declared name; re-plan is NoOp. //! //! The anonymous-write-fails-closed half of the trust model is unit-tested in //! `group_kv_service` (a journey invoke always carries the admin principal). use std::fs; use tempfile::TempDir; use crate::common; use crate::common::cleanup::{AppGuard, GroupGuard}; fn manifest_dir() -> TempDir { let dir = TempDir::new().expect("tempdir"); fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir"); dir } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn shared_collection_is_read_write_across_the_subtree() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("coll-grp"); let sibling = common::unique_slug("coll-sib"); let app_a = common::unique_slug("coll-a"); let app_b = common::unique_slug("coll-b"); let foreign = common::unique_slug("coll-f"); let _g = GroupGuard::new(&env.url, &env.token, &group); let _gs = GroupGuard::new(&env.url, &env.token, &sibling); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); common::pic_as(&env) .args(["groups", "create", &sibling]) .assert() .success(); // The group declares `catalog` a shared collection (declarative apply). let dir = manifest_dir(); let gmanifest = format!("[group]\nslug = \"{group}\"\nname = \"Coll\"\n\ncollections = [\"catalog\"]\n"); let gpath = dir.path().join("group.toml"); fs::write(&gpath, &gmanifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&gpath) .assert() .success(); // `collections ls --group` shows the declared name. let ls = String::from_utf8( common::pic_as(&env) .args(["collections", "ls", "--group", &group]) .output() .unwrap() .stdout, ) .unwrap(); assert!( ls.contains("catalog"), "ls should list the collection:\n{ls}" ); // Re-apply is a no-op (the marker already exists). common::pic_as(&env) .args(["apply", "--file"]) .arg(&gpath) .assert() .success(); // Two apps under the group; one under a sibling group. let _a = AppGuard::new(&env.url, &env.token, &app_a); let _b = AppGuard::new(&env.url, &env.token, &app_b); let _f = AppGuard::new(&env.url, &env.token, &foreign); common::pic_as(&env) .args(["apps", "create", &app_a, "--group", &group]) .assert() .success(); common::pic_as(&env) .args(["apps", "create", &app_b, "--group", &group]) .assert() .success(); common::pic_as(&env) .args(["apps", "create", &foreign, "--group", &sibling]) .assert() .success(); // App A writes to the shared collection (authenticated invoke → admin // principal, so the editor+ write gate is satisfied). fs::write( dir.path().join("scripts/writer.rhai"), r#"kv::shared_collection("catalog").set("sku-1", #{ price: 9 }); "ok""#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/writer.rhai")) .args(["--app", &app_a, "--name", "writer"]) .assert() .success(); let writer_id = app_script_id(&env, &app_a, "writer"); common::pic_as(&env) .args(["scripts", "invoke", &writer_id]) .assert() .success(); // App B reads the SAME store back — cross-app sharing. fs::write( dir.path().join("scripts/reader.rhai"), r#"kv::shared_collection("catalog").get("sku-1")"#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/reader.rhai")) .args(["--app", &app_b, "--name", "reader"]) .assert() .success(); let reader_id = app_script_id(&env, &app_b, "reader"); assert_eq!( invoke_body(&env, &reader_id), serde_json::json!({ "price": 9 }), "an app in the subtree reads what another app wrote to the shared collection" ); // The foreign app (sibling subtree) names `catalog` but it does not resolve // on its chain — CollectionNotShared. The invoke fails. fs::write( dir.path().join("scripts/foreign.rhai"), r#"kv::shared_collection("catalog").get("sku-1")"#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/foreign.rhai")) .args(["--app", &foreign, "--name", "peek"]) .assert() .success(); let foreign_id = app_script_id(&env, &foreign, "peek"); let out = common::pic_as(&env) .args(["scripts", "invoke", &foreign_id]) .output() .expect("invoke"); assert!( !out.status.success(), "a foreign app must not resolve another subtree's shared collection" ); } /// Nearest-ancestor-group-wins (§11.6, the CoW-shadowing guarantee). A parent /// AND a child group both declare `catalog`; an app under the child must bind /// the CHILD's store, not the parent's. Discriminated by a second app directly /// under the parent: it resolves to the PARENT's store, so it must NOT see what /// the deep app wrote. If the resolver's `ORDER BY depth LIMIT 1` regressed to /// the farther group, the shallow app would see the deep app's value. #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn nearest_declaring_group_wins() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let root = common::unique_slug("nw-root"); let team = common::unique_slug("nw-team"); let deep = common::unique_slug("nw-deep"); let shallow = common::unique_slug("nw-shallow"); let _gr = GroupGuard::new(&env.url, &env.token, &root); let _gt = GroupGuard::new(&env.url, &env.token, &team); common::pic_as(&env) .args(["groups", "create", &root]) .assert() .success(); common::pic_as(&env) .args(["groups", "create", &team, "--parent", &root]) .assert() .success(); // BOTH groups declare `catalog` (two distinct stores, same name). let dir = manifest_dir(); for g in [&root, &team] { let m = format!("[group]\nslug = \"{g}\"\nname = \"NW\"\n\ncollections = [\"catalog\"]\n"); let p = dir.path().join(format!("{g}.toml")); fs::write(&p, &m).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&p) .assert() .success(); } // `deep` under team, `shallow` directly under root. let _ad = AppGuard::new(&env.url, &env.token, &deep); let _as = AppGuard::new(&env.url, &env.token, &shallow); common::pic_as(&env) .args(["apps", "create", &deep, "--group", &team]) .assert() .success(); common::pic_as(&env) .args(["apps", "create", &shallow, "--group", &root]) .assert() .success(); // The deep app writes — must land in TEAM's store (nearest declarer). fs::write( dir.path().join("scripts/w.rhai"), r#"kv::shared_collection("catalog").set("k", "team-value"); "ok""#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/w.rhai")) .args(["--app", &deep, "--name", "w"]) .assert() .success(); common::pic_as(&env) .args(["scripts", "invoke", &app_script_id(&env, &deep, "w")]) .assert() .success(); // The deep app reads its own write back (team's store). fs::write( dir.path().join("scripts/r.rhai"), r#"kv::shared_collection("catalog").get("k")"#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/r.rhai")) .args(["--app", &deep, "--name", "r"]) .assert() .success(); assert_eq!( invoke_body(&env, &app_script_id(&env, &deep, "r")), serde_json::json!("team-value"), "the deep app reads back its own write to the nearest (team) store" ); // The shallow app reads catalog → ROOT's store, which is empty. Seeing // `team-value` here would mean the write leaked to the farther group. common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/r.rhai")) .args(["--app", &shallow, "--name", "r"]) .assert() .success(); assert_eq!( invoke_body(&env, &app_script_id(&env, &shallow, "r")), serde_json::Value::Null, "the shallow app resolves the ROOT store (empty) — nearest-wins kept the \ deep write in the team store" ); } /// §11.6 docs slice: a group declares a `docs`-kind shared collection (via the /// string-or-table manifest, mixed with a `kv` one); an authenticated app A /// `create`s a document and app B `find`s it back across the subtree; a /// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows both /// kinds. #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn shared_docs_collection_is_read_write_across_the_subtree() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("dcoll-grp"); let sibling = common::unique_slug("dcoll-sib"); let app_a = common::unique_slug("dcoll-a"); let app_b = common::unique_slug("dcoll-b"); let foreign = common::unique_slug("dcoll-f"); let _g = GroupGuard::new(&env.url, &env.token, &group); let _gs = GroupGuard::new(&env.url, &env.token, &sibling); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); common::pic_as(&env) .args(["groups", "create", &sibling]) .assert() .success(); // The group declares a kv AND a docs collection (string-or-table form). let dir = manifest_dir(); let gmanifest = format!( "[group]\nslug = \"{group}\"\nname = \"DColl\"\n\n\ collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}]\n" ); let gpath = dir.path().join("group.toml"); fs::write(&gpath, &gmanifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&gpath) .assert() .success(); // `collections ls` shows both names with their kinds. let ls = String::from_utf8( common::pic_as(&env) .args(["collections", "ls", "--group", &group]) .output() .unwrap() .stdout, ) .unwrap(); assert!( ls.contains("articles") && ls.contains("docs") && ls.contains("catalog"), "ls should list both kinds:\n{ls}" ); // Re-apply is a no-op (both markers already exist). common::pic_as(&env) .args(["apply", "--file"]) .arg(&gpath) .assert() .success(); let _a = AppGuard::new(&env.url, &env.token, &app_a); let _b = AppGuard::new(&env.url, &env.token, &app_b); let _f = AppGuard::new(&env.url, &env.token, &foreign); for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] { common::pic_as(&env) .args(["apps", "create", app, "--group", grp]) .assert() .success(); } // App A creates a document in the shared docs collection (authenticated). fs::write( dir.path().join("scripts/dwriter.rhai"), r#"docs::shared_collection("articles").create(#{ t: "hi" })"#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/dwriter.rhai")) .args(["--app", &app_a, "--name", "dwriter"]) .assert() .success(); common::pic_as(&env) .args(["scripts", "invoke", &app_script_id(&env, &app_a, "dwriter")]) .assert() .success(); // App B finds the SAME doc back — cross-app docs sharing + the find DSL. fs::write( dir.path().join("scripts/dreader.rhai"), r#"docs::shared_collection("articles").find(#{ t: "hi" })[0].data.t"#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/dreader.rhai")) .args(["--app", &app_b, "--name", "dreader"]) .assert() .success(); assert_eq!( invoke_body(&env, &app_script_id(&env, &app_b, "dreader")), serde_json::json!("hi"), "app B finds the document app A created in the shared docs collection" ); // The foreign app (sibling subtree) → CollectionNotShared. fs::write( dir.path().join("scripts/dpeek.rhai"), r#"docs::shared_collection("articles").find(#{})"#, ) .unwrap(); common::pic_as(&env) .args(["scripts", "deploy"]) .arg(dir.path().join("scripts/dpeek.rhai")) .args(["--app", &foreign, "--name", "dpeek"]) .assert() .success(); let out = common::pic_as(&env) .args(["scripts", "invoke", &app_script_id(&env, &foreign, "dpeek")]) .output() .expect("invoke"); assert!( !out.status.success(), "a foreign app must not resolve another subtree's shared docs collection" ); } /// Id of the named script in an app (`pic scripts ls --app `). fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String { let ls = common::pic_as(env) .args(["scripts", "ls", "--app", app]) .output() .expect("scripts ls"); let table = String::from_utf8(ls.stdout).unwrap(); table .lines() .map(common::cells) .find(|c| c.get(2) == Some(&name)) .and_then(|c| c.first().map(|s| (*s).to_string())) .unwrap_or_else(|| panic!("script `{name}` not found:\n{table}")) } fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value { let out = common::pic_as(env) .args(["scripts", "invoke", id]) .output() .expect("scripts invoke"); assert!( out.status.success(), "invoke failed: {}", String::from_utf8_lossy(&out.stderr) ); serde_json::from_slice(&out.stdout).expect("invoke body is JSON") }