#![cfg(feature = "libsql")] //! Integration tests for multi-scope workspace reads using file-backed libSQL. //! //! Guards the PR2 contract: workspaces can read from multiple user scopes //! while writes remain isolated to the primary scope. use std::sync::Arc; use ironclaw::db::Database; use ironclaw::db::libsql::LibSqlBackend; use ironclaw::workspace::Workspace; async fn setup() -> (Arc, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create temp dir"); let db_path = dir.path().join("test.db"); let backend = LibSqlBackend::new_local(&db_path).await.expect("create db"); backend.run_migrations().await.expect("run migrations"); let db: Arc = Arc::new(backend); (db, dir) } #[tokio::test] async fn read_across_scopes() { let (db, _dir) = setup().await; // Write docs as the "shared" user let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("docs/team-standup.md", "Team standup notes from Monday") .await .expect("shared write failed"); // Alice's workspace with "shared" as an additional read scope let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); // Alice can read shared docs let doc = ws_alice .read("docs/team-standup.md") .await .expect("cross-scope read failed"); assert_eq!(doc.content, "Team standup notes from Monday"); } #[tokio::test] async fn write_stays_in_primary_scope() { let (db, _dir) = setup().await; // Alice has "shared" as a read scope let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); // Alice writes a personal note ws_alice .write("notes/personal.md", "Alice's private note") .await .expect("alice write failed"); // The "shared" workspace should NOT see Alice's note let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); let result = ws_shared.read("notes/personal.md").await; assert!(result.is_err(), "Shared scope should not see Alice's note"); } #[tokio::test] async fn list_paths_merges_across_scopes() { let (db, _dir) = setup().await; // Write as alice let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); ws_alice_plain .write("notes/personal.md", "My notes") .await .expect("alice write failed"); // Write as shared let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("docs/shared-doc.md", "Shared document") .await .expect("shared write failed"); // Alice with multi-scope should see both let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); let all_paths = ws_alice.list_all().await.expect("list_all failed"); assert!( all_paths.contains(&"notes/personal.md".to_string()), "Should contain alice's note: {:?}", all_paths ); assert!( all_paths.contains(&"docs/shared-doc.md".to_string()), "Should contain shared doc: {:?}", all_paths ); } #[tokio::test] async fn list_directory_merges_across_scopes() { let (db, _dir) = setup().await; // Alice writes to docs/ let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); ws_alice_plain .write("docs/alice-doc.md", "Alice's doc") .await .expect("alice write failed"); // Shared writes to docs/ let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("docs/shared-doc.md", "Shared doc") .await .expect("shared write failed"); // Alice with multi-scope lists docs/ let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); let entries = ws_alice.list("docs").await.expect("list failed"); let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect(); assert!( paths.contains(&"docs/alice-doc.md"), "Should contain alice's doc: {:?}", paths ); assert!( paths.contains(&"docs/shared-doc.md"), "Should contain shared doc: {:?}", paths ); } #[tokio::test] async fn search_spans_scopes() { let (db, _dir) = setup().await; // Write searchable content in shared scope let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write( "docs/architecture.md", "The microservice architecture uses gRPC for inter-service communication", ) .await .expect("shared write failed"); // Write searchable content in alice scope let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); ws_alice_plain .write("notes/ideas.md", "Consider switching to GraphQL federation") .await .expect("alice write failed"); // Alice with multi-scope searches let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); // Search for content in the shared scope let results = ws_alice .search("microservice architecture gRPC", 10) .await .expect("search failed"); assert!(!results.is_empty(), "Should find results from shared scope"); } #[tokio::test] async fn read_priority_primary_first() { let (db, _dir) = setup().await; // Write same path in both scopes let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("config/settings.md", "Shared settings v1") .await .expect("shared write failed"); let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); ws_alice_plain .write("config/settings.md", "Alice's settings override") .await .expect("alice write failed"); // Alice with multi-scope should get her own version (primary scope wins) let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); let doc = ws_alice .read("config/settings.md") .await .expect("read failed"); assert_eq!( doc.content, "Alice's settings override", "Primary scope should take priority" ); } #[tokio::test] async fn exists_spans_scopes() { let (db, _dir) = setup().await; // Write a doc as "shared" let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("docs/shared-only.md", "Shared content") .await .expect("shared write failed"); // Alice without multi-scope should NOT see it let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); assert!( !ws_alice_plain .exists("docs/shared-only.md") .await .expect("exists failed"), "Alice without multi-scope should not see shared doc" ); // Alice with multi-scope should see it let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); assert!( ws_alice .exists("docs/shared-only.md") .await .expect("exists failed"), "Alice with multi-scope should see shared doc" ); } #[tokio::test] async fn append_stays_in_primary_scope() { let (db, _dir) = setup().await; // Write a document as "shared" let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("notes/log.md", "shared original content") .await .expect("shared write failed"); // Alice has "shared" as a read scope and appends to the same path let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); ws_alice .append("notes/log.md", "alice appended line") .await .expect("alice append failed"); // Shared document must be unchanged (write isolation) let shared_doc = ws_shared .read("notes/log.md") .await .expect("shared read failed"); assert_eq!( shared_doc.content, "shared original content", "Append must not modify the secondary scope's document" ); // Alice should have her own copy with the appended content let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); let alice_doc = ws_alice_plain .read("notes/log.md") .await .expect("alice read failed"); assert_eq!( alice_doc.content, "alice appended line", "Append should create a new document in alice's scope" ); } #[tokio::test] async fn append_memory_stays_in_primary_scope() { let (db, _dir) = setup().await; // Write MEMORY.md as "shared" let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("MEMORY.md", "shared memory baseline") .await .expect("shared write failed"); // Alice has "shared" as a read scope and appends a memory entry let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string()]); ws_alice .append_memory("alice remembers this") .await .expect("alice append_memory failed"); // Shared MEMORY.md must be unchanged let shared_doc = ws_shared .read("MEMORY.md") .await .expect("shared read failed"); assert_eq!( shared_doc.content, "shared memory baseline", "append_memory must not modify the secondary scope's document" ); // Alice should have her own MEMORY.md let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db)); let alice_doc = ws_alice_plain .read("MEMORY.md") .await .expect("alice read failed"); assert_eq!( alice_doc.content, "alice remembers this", "append_memory should create in alice's scope" ); } // ==================== Identity isolation tests ==================== #[tokio::test] async fn identity_files_not_readable_from_secondary_scope() { let (db, _dir) = setup().await; let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db)); ws_other .write("IDENTITY.md", "I am the other user") .await .expect("write failed"); ws_other .write("SOUL.md", "Other user soul overlay") .await .expect("write failed"); ws_other .write("USER.md", "Other user profile") .await .expect("write failed"); ws_other .write("AGENTS.md", "Other user agent config") .await .expect("write failed"); let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)) .with_additional_read_scopes(vec!["other-user".to_string()]); for path in &["IDENTITY.md", "SOUL.md", "USER.md", "AGENTS.md"] { let result = ws_primary.read(path).await; assert!( result.is_err(), "Primary should NOT read other user's {} via secondary scope", path ); } } #[tokio::test] async fn identity_files_not_in_search_from_secondary_scope() { let (db, _dir) = setup().await; let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db)); ws_other .write("SOUL.md", "Other user loves xylophone music passionately") .await .expect("write failed"); ws_other .write( "notes/music.md", "Other user played xylophone at the concert", ) .await .expect("write failed"); let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)) .with_additional_read_scopes(vec!["other-user".to_string()]); let results = ws_primary .search("xylophone", 10) .await .expect("search failed"); let has_concert = results.iter().any(|r| r.content.contains("concert")); assert!( has_concert, "Should find non-identity content from secondary scope" ); let has_soul = results.iter().any(|r| r.content.contains("passionately")); assert!( !has_soul, "SOUL.md content from secondary scope should not appear in search results" ); } #[tokio::test] async fn identity_files_not_in_list_from_secondary_scope() { let (db, _dir) = setup().await; let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db)); ws_other .write("IDENTITY.md", "I am the other user") .await .expect("write failed"); ws_other .write("notes/shared-note.md", "A shared note") .await .expect("write failed"); let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)) .with_additional_read_scopes(vec!["other-user".to_string()]); let paths = ws_primary.list_all().await.expect("list failed"); assert!( !paths.contains(&"IDENTITY.md".to_string()), "IDENTITY.md from secondary scope should not appear" ); assert!( paths.contains(&"notes/shared-note.md".to_string()), "Non-identity files should be listed" ); } #[tokio::test] async fn empty_read_scopes_reads_primary_only() { let (db, _dir) = setup().await; let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("docs/note.md", "Shared note") .await .expect("write failed"); let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)).with_additional_read_scopes(vec![]); let result = ws_primary.read("docs/note.md").await; assert!( result.is_err(), "Empty read scopes should not grant cross-scope access" ); } #[tokio::test] async fn duplicate_read_scopes_handled() { let (db, _dir) = setup().await; let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db)); ws_shared .write("docs/note.md", "One note") .await .expect("write failed"); let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db)) .with_additional_read_scopes(vec!["shared".to_string(), "shared".to_string()]); let doc = ws_primary.read("docs/note.md").await.expect("read failed"); assert_eq!(doc.content, "One note"); }