this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Cover both workspace delete branches in manager_tests

The existing cabinet delete regression test guards only one of three
paths delete() can take. The two workspace paths — owner (atomic apply)
and non-owner (directoryUpdate proposal) — had no coverage, so a
refactor that flipped the Applied/Proposed branch would have passed
cargo test silently.

Add tests for both. The owner test mirrors the cabinet shape
(getRecord + applyWrites with [delete, update]) and asserts
MutationOutcome::Applied. The non-owner test skips the getRecord
(proposal path doesn't touch the parent directory) and asserts the
single applyWrites batch carries [delete, createProposal], with the
proposal round-tripping to a RemoveEntry variant carrying the
correct keyring, directory, and entry URIs.

+172
+172
crates/opake-core/src/manager/manager_tests.rs
··· 122 122 assert_eq!(updated.modified_at.as_deref(), Some("2026-01-01T00:00:00Z")); 123 123 } 124 124 125 + /// Workspace delete where the caller owns the parent directory is the same 126 + /// "atomic delete + parent update" shape as the cabinet path, not a proposal. 127 + /// The owner branch writes directly to the canonical directory record; the 128 + /// Applied / Proposed split is entirely driven by `dir_owner == self.opake.did`. 129 + #[tokio::test] 130 + async fn workspace_owner_delete_is_applied_not_proposed() { 131 + use crate::client::{LegacySession, Session, XrpcClient}; 132 + use crate::crypto::{generate_content_key, OsRng}; 133 + use crate::directories::tests::{ 134 + dummy_directory_with_entries, get_record_response, put_record_response, 135 + }; 136 + use crate::manager::types::FileContext; 137 + use crate::opake::Opake; 138 + use crate::storage::{Identity, NoopStorage}; 139 + use crate::test_utils::MockTransport; 140 + use crate::workspace::Workspace; 141 + 142 + // The current user is also the parent-directory owner — so the write 143 + // goes through the canonical directory record, not a directoryUpdate. 144 + const DID: &str = "did:plc:alice"; 145 + const PARENT_URI: &str = "at://did:plc:alice/app.opake.directory/self"; 146 + const DOC_URI: &str = "at://did:plc:alice/app.opake.document/doc1"; 147 + const OTHER_DOC_URI: &str = "at://did:plc:alice/app.opake.document/keep"; 148 + const KEYRING_URI: &str = "at://did:plc:alice/app.opake.keyring/ws1"; 149 + 150 + let mock = MockTransport::new(); 151 + mock.enqueue(get_record_response( 152 + PARENT_URI, 153 + &dummy_directory_with_entries("root", vec![DOC_URI.into(), OTHER_DOC_URI.into()]), 154 + )); 155 + mock.enqueue(put_record_response(PARENT_URI)); 156 + 157 + let session = Session::Legacy(LegacySession { 158 + did: DID.into(), 159 + handle: "alice.test".into(), 160 + access_jwt: "test-jwt".into(), 161 + refresh_jwt: "test-refresh".into(), 162 + }); 163 + let client = XrpcClient::with_session(mock.clone(), "https://pds.test".into(), session); 164 + let identity = Identity::generate(DID, &mut OsRng); 165 + let mut opake = Opake::new( 166 + client, 167 + DID.into(), 168 + Some(identity), 169 + OsRng, 170 + NoopStorage, 171 + || "2026-01-01T00:00:00Z".into(), 172 + || 1_700_000_000_000_000, 173 + ); 174 + 175 + let group_key = generate_content_key(&mut OsRng); 176 + let workspace = Workspace::from_keyring( 177 + KEYRING_URI.into(), 178 + "Alice's workspace".into(), 179 + None, 180 + DID.into(), 181 + group_key, 182 + 1, 183 + ); 184 + let ctx = FileContext::Workspace(workspace); 185 + let mut mgr = opake.file_manager(&ctx); 186 + let outcome = mgr.delete(DOC_URI, PARENT_URI).await.unwrap(); 187 + 188 + assert!(outcome.is_applied(), "owner path must produce Applied"); 189 + assert!(!outcome.is_proposed()); 190 + 191 + let reqs = mock.requests(); 192 + assert_eq!(reqs.len(), 2, "expected getRecord + applyWrites (no proposal)"); 193 + let Some(RequestBody::Json(body)) = &reqs[1].body else { 194 + panic!("applyWrites body should be JSON"); 195 + }; 196 + let writes = body["writes"].as_array().expect("writes array"); 197 + assert_eq!(writes.len(), 2, "atomic batch: [delete doc, update dir]"); 198 + assert_eq!(writes[0]["$type"], "com.atproto.repo.applyWrites#delete"); 199 + assert_eq!(writes[1]["$type"], "com.atproto.repo.applyWrites#update"); 200 + assert_eq!(writes[1]["collection"], "app.opake.directory"); 201 + } 202 + 203 + /// Workspace delete where the caller does NOT own the parent directory 204 + /// generates a directoryUpdate proposal record alongside the document 205 + /// delete — the proposal records the intent and the owner (or any admin) 206 + /// can apply it later. 207 + #[tokio::test] 208 + async fn workspace_non_owner_delete_emits_directory_update_proposal() { 209 + use crate::client::{LegacySession, Session, XrpcClient}; 210 + use crate::crypto::{generate_content_key, OsRng}; 211 + use crate::directories::tests::put_record_response; 212 + use crate::manager::types::FileContext; 213 + use crate::opake::Opake; 214 + use crate::records::{ 215 + DirectoryUpdate, DirectoryUpdateRecord, DIRECTORY_UPDATE_COLLECTION, 216 + }; 217 + use crate::storage::{Identity, NoopStorage}; 218 + use crate::test_utils::MockTransport; 219 + use crate::workspace::Workspace; 220 + 221 + // Alice is the caller, Bob owns the parent directory (and the workspace). 222 + const ALICE_DID: &str = "did:plc:alice"; 223 + const BOB_DID: &str = "did:plc:bob"; 224 + const PARENT_URI: &str = "at://did:plc:bob/app.opake.directory/self"; 225 + const DOC_URI: &str = "at://did:plc:alice/app.opake.document/doc1"; 226 + const KEYRING_URI: &str = "at://did:plc:bob/app.opake.keyring/ws1"; 227 + 228 + // Non-owner path: no getRecord on the parent (no direct rewrite), only 229 + // one applyWrites batch carrying the delete + the proposal record. 230 + let mock = MockTransport::new(); 231 + mock.enqueue(put_record_response("at://did:plc:alice/app.opake.directoryUpdate/tid")); 232 + 233 + let session = Session::Legacy(LegacySession { 234 + did: ALICE_DID.into(), 235 + handle: "alice.test".into(), 236 + access_jwt: "test-jwt".into(), 237 + refresh_jwt: "test-refresh".into(), 238 + }); 239 + let client = XrpcClient::with_session(mock.clone(), "https://pds.test".into(), session); 240 + let identity = Identity::generate(ALICE_DID, &mut OsRng); 241 + let mut opake = Opake::new( 242 + client, 243 + ALICE_DID.into(), 244 + Some(identity), 245 + OsRng, 246 + NoopStorage, 247 + || "2026-01-01T00:00:00Z".into(), 248 + || 1_700_000_000_000_000, 249 + ); 250 + 251 + let group_key = generate_content_key(&mut OsRng); 252 + let workspace = Workspace::from_keyring( 253 + KEYRING_URI.into(), 254 + "Bob's workspace".into(), 255 + None, 256 + BOB_DID.into(), 257 + group_key, 258 + 1, 259 + ); 260 + let ctx = FileContext::Workspace(workspace); 261 + let mut mgr = opake.file_manager(&ctx); 262 + let outcome = mgr.delete(DOC_URI, PARENT_URI).await.unwrap(); 263 + 264 + assert!(outcome.is_proposed(), "non-owner path must produce Proposed"); 265 + assert!(!outcome.is_applied()); 266 + 267 + let reqs = mock.requests(); 268 + assert_eq!(reqs.len(), 1, "expected a single applyWrites batch"); 269 + let Some(RequestBody::Json(body)) = &reqs[0].body else { 270 + panic!("applyWrites body should be JSON"); 271 + }; 272 + let writes = body["writes"].as_array().expect("writes array"); 273 + assert_eq!(writes.len(), 2, "batch: [delete doc, create proposal]"); 274 + 275 + assert_eq!(writes[0]["$type"], "com.atproto.repo.applyWrites#delete"); 276 + assert_eq!(writes[0]["collection"], "app.opake.document"); 277 + 278 + assert_eq!(writes[1]["$type"], "com.atproto.repo.applyWrites#create"); 279 + assert_eq!(writes[1]["collection"], DIRECTORY_UPDATE_COLLECTION); 280 + let proposal: DirectoryUpdateRecord = serde_json::from_value(writes[1]["value"].clone()) 281 + .expect("proposal record should round-trip"); 282 + match proposal.update { 283 + DirectoryUpdate::RemoveEntry { 284 + keyring, 285 + directory, 286 + entry, 287 + .. 288 + } => { 289 + assert_eq!(keyring, KEYRING_URI, "proposal keyring must match workspace"); 290 + assert_eq!(directory, PARENT_URI, "proposal directory must match parent"); 291 + assert_eq!(entry, DOC_URI, "proposal must target the deleted document"); 292 + } 293 + other => panic!("expected RemoveEntry proposal, got {other:?}"), 294 + } 295 + } 296 + 125 297 #[test] 126 298 fn mutation_outcome_predicates() { 127 299 let applied = MutationOutcome::Applied;