//! §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" ); } /// 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") }