An encrypted personal cloud built on the AT Protocol.

Extract test modules to separate files

Move #[cfg(test)] blocks from 6 large source files into sibling
*_tests.rs files using #[path] attributes. No behavioral changes —
all 252 tests pass unchanged.

Files split:
- config.rs → config_tests.rs (281 lines)
- xrpc.rs → xrpc_tests.rs (312 lines)
- download_keyring.rs → download_keyring_tests.rs (395 lines)
- did.rs → did_tests.rs (217 lines)
- crypto.rs → crypto_tests.rs (207 lines)
- remove_member.rs → remove_member_tests.rs (155 lines)

+1559 -1564
+2 -280
crates/opake-cli/src/config.rs
··· 128 128 } 129 129 130 130 #[cfg(test)] 131 - mod tests { 132 - use super::*; 133 - use crate::utils::test_harness::with_test_dir; 134 - 135 - fn test_config(did: &str, pds_url: &str, handle: &str) -> Config { 136 - let mut accounts = BTreeMap::new(); 137 - accounts.insert( 138 - did.to_string(), 139 - AccountConfig { 140 - pds_url: pds_url.into(), 141 - handle: handle.into(), 142 - }, 143 - ); 144 - Config { 145 - default_did: Some(did.to_string()), 146 - accounts, 147 - } 148 - } 149 - 150 - #[test] 151 - fn save_and_load_config_roundtrip() { 152 - with_test_dir(|_| { 153 - let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 154 - save_config(&config).unwrap(); 155 - 156 - let loaded = load_config().unwrap(); 157 - assert_eq!(loaded.default_did.unwrap(), "did:plc:alice"); 158 - let acc = loaded.accounts.get("did:plc:alice").unwrap(); 159 - assert_eq!(acc.pds_url, "https://pds.test"); 160 - assert_eq!(acc.handle, "alice.test"); 161 - }); 162 - } 163 - 164 - #[test] 165 - fn config_with_multiple_accounts_roundtrips() { 166 - with_test_dir(|_| { 167 - let mut accounts = BTreeMap::new(); 168 - accounts.insert( 169 - "did:plc:alice".into(), 170 - AccountConfig { 171 - pds_url: "https://pds.alice".into(), 172 - handle: "alice.test".into(), 173 - }, 174 - ); 175 - accounts.insert( 176 - "did:plc:bob".into(), 177 - AccountConfig { 178 - pds_url: "https://pds.bob".into(), 179 - handle: "bob.test".into(), 180 - }, 181 - ); 182 - let config = Config { 183 - default_did: Some("did:plc:alice".into()), 184 - accounts, 185 - }; 186 - save_config(&config).unwrap(); 187 - 188 - let loaded = load_config().unwrap(); 189 - assert_eq!(loaded.accounts.len(), 2); 190 - assert_eq!( 191 - loaded.accounts.get("did:plc:bob").unwrap().handle, 192 - "bob.test" 193 - ); 194 - }); 195 - } 196 - 197 - #[test] 198 - fn sanitize_did_replaces_colons() { 199 - assert_eq!(sanitize_did("did:plc:abc123"), "did_plc_abc123"); 200 - } 201 - 202 - #[test] 203 - fn sanitize_did_handles_did_web() { 204 - assert_eq!(sanitize_did("did:web:example.com"), "did_web_example.com"); 205 - } 206 - 207 - #[test] 208 - fn account_dir_uses_sanitized_did() { 209 - with_test_dir(|_| { 210 - let dir = account_dir("did:plc:test"); 211 - assert!(dir.ends_with("accounts/did_plc_test")); 212 - }); 213 - } 214 - 215 - #[test] 216 - fn save_and_load_account_json_roundtrip() { 217 - with_test_dir(|_| { 218 - let did = "did:plc:test"; 219 - let data = serde_json::json!({"key": "value"}); 220 - save_account_json(did, "test.json", &data).unwrap(); 221 - 222 - let loaded: serde_json::Value = load_account_json(did, "test.json").unwrap(); 223 - assert_eq!(loaded["key"], "value"); 224 - }); 225 - } 226 - 227 - #[test] 228 - fn load_account_json_missing_file_errors() { 229 - with_test_dir(|_| { 230 - let result: anyhow::Result<serde_json::Value> = 231 - load_account_json("did:plc:nobody", "nope.json"); 232 - let err = result.unwrap_err().to_string(); 233 - assert!(err.contains("opake login"), "expected login hint: {err}"); 234 - }); 235 - } 236 - 237 - #[test] 238 - fn ensure_account_dir_creates_nested_dirs() { 239 - with_test_dir(|_| { 240 - let did = "did:plc:nested"; 241 - ensure_account_dir(did).unwrap(); 242 - assert!(account_dir(did).exists()); 243 - }); 244 - } 245 - 246 - #[test] 247 - fn load_config_without_file_errors() { 248 - with_test_dir(|_| { 249 - let result = load_config(); 250 - assert!(result.is_err()); 251 - let err = result.unwrap_err().to_string(); 252 - assert!(err.contains("opake login"), "expected login hint: {err}"); 253 - }); 254 - } 255 - 256 - #[test] 257 - fn ensure_data_dir_creates_directory() { 258 - with_test_dir(|dir| { 259 - let target = dir.path().join("nested"); 260 - std::env::set_var("OPAKE_DATA_DIR", &target); 261 - assert!(!target.exists()); 262 - ensure_data_dir().unwrap(); 263 - assert!(target.exists()); 264 - }); 265 - } 266 - 267 - #[test] 268 - fn load_config_rejects_garbage_content() { 269 - with_test_dir(|_| { 270 - ensure_data_dir().unwrap(); 271 - fs::write(data_dir().join("config.toml"), "not valid toml {{{").unwrap(); 272 - let result = load_config(); 273 - assert!(result.is_err()); 274 - }); 275 - } 276 - 277 - #[test] 278 - fn load_config_ignores_unknown_keys() { 279 - with_test_dir(|_| { 280 - ensure_data_dir().unwrap(); 281 - fs::write(data_dir().join("config.toml"), "[section]\nkey = 42\n").unwrap(); 282 - // New Config has all optional/default fields — unknown keys are ignored 283 - let loaded = load_config().unwrap(); 284 - assert!(loaded.default_did.is_none()); 285 - assert!(loaded.accounts.is_empty()); 286 - }); 287 - } 288 - 289 - #[test] 290 - fn load_config_empty_file_gives_defaults() { 291 - with_test_dir(|_| { 292 - ensure_data_dir().unwrap(); 293 - fs::write(data_dir().join("config.toml"), "").unwrap(); 294 - let loaded = load_config().unwrap(); 295 - assert!(loaded.default_did.is_none()); 296 - assert!(loaded.accounts.is_empty()); 297 - }); 298 - } 299 - 300 - #[test] 301 - fn load_config_rejects_binary_noise() { 302 - with_test_dir(|_| { 303 - ensure_data_dir().unwrap(); 304 - fs::write(data_dir().join("config.toml"), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap(); 305 - let result = load_config(); 306 - assert!(result.is_err()); 307 - }); 308 - } 309 - 310 - // -- resolve_handle_or_did -- 311 - 312 - #[test] 313 - fn resolve_handle_or_did_passes_did_through() { 314 - let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 315 - let result = resolve_handle_or_did(&config, "did:plc:someone").unwrap(); 316 - assert_eq!(result, "did:plc:someone"); 317 - } 318 - 319 - #[test] 320 - fn resolve_handle_or_did_looks_up_handle() { 321 - let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 322 - let result = resolve_handle_or_did(&config, "alice.test").unwrap(); 323 - assert_eq!(result, "did:plc:alice"); 324 - } 325 - 326 - #[test] 327 - fn resolve_handle_or_did_unknown_handle_errors() { 328 - let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 329 - let err = resolve_handle_or_did(&config, "nobody.test").unwrap_err(); 330 - assert!(err.to_string().contains("nobody.test")); 331 - } 332 - 333 - // -- remove_account -- 334 - 335 - #[test] 336 - fn remove_account_deletes_dir_and_config_entry() { 337 - with_test_dir(|_| { 338 - let did = "did:plc:alice"; 339 - let config = test_config(did, "https://pds.alice", "alice.test"); 340 - save_config(&config).unwrap(); 341 - ensure_account_dir(did).unwrap(); 342 - assert!(account_dir(did).exists()); 343 - 344 - remove_account(did).unwrap(); 345 - 346 - let loaded = load_config().unwrap(); 347 - assert!(!loaded.accounts.contains_key(did)); 348 - assert!(loaded.default_did.is_none()); 349 - assert!(!account_dir(did).exists()); 350 - }); 351 - } 352 - 353 - #[test] 354 - fn remove_account_promotes_next_default() { 355 - with_test_dir(|_| { 356 - let mut accounts = BTreeMap::new(); 357 - accounts.insert( 358 - "did:plc:alice".into(), 359 - AccountConfig { 360 - pds_url: "https://pds.alice".into(), 361 - handle: "alice.test".into(), 362 - }, 363 - ); 364 - accounts.insert( 365 - "did:plc:bob".into(), 366 - AccountConfig { 367 - pds_url: "https://pds.bob".into(), 368 - handle: "bob.test".into(), 369 - }, 370 - ); 371 - save_config(&Config { 372 - default_did: Some("did:plc:alice".into()), 373 - accounts, 374 - }) 375 - .unwrap(); 376 - 377 - remove_account("did:plc:alice").unwrap(); 378 - 379 - let loaded = load_config().unwrap(); 380 - assert_eq!(loaded.default_did.as_deref(), Some("did:plc:bob")); 381 - assert_eq!(loaded.accounts.len(), 1); 382 - }); 383 - } 384 - 385 - #[test] 386 - fn remove_account_unknown_did_errors() { 387 - with_test_dir(|_| { 388 - let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 389 - save_config(&config).unwrap(); 390 - 391 - let err = remove_account("did:plc:nobody").unwrap_err(); 392 - assert!(err.to_string().contains("did:plc:nobody")); 393 - }); 394 - } 395 - 396 - #[test] 397 - fn remove_account_without_dir_still_works() { 398 - with_test_dir(|_| { 399 - let did = "did:plc:alice"; 400 - let config = test_config(did, "https://pds.test", "alice.test"); 401 - save_config(&config).unwrap(); 402 - // don't create account dir — should still succeed 403 - 404 - remove_account(did).unwrap(); 405 - 406 - let loaded = load_config().unwrap(); 407 - assert!(!loaded.accounts.contains_key(did)); 408 - }); 409 - } 410 - } 131 + #[path = "config_tests.rs"] 132 + mod tests;
+278
crates/opake-cli/src/config_tests.rs
··· 1 + use super::*; 2 + use crate::utils::test_harness::with_test_dir; 3 + 4 + fn test_config(did: &str, pds_url: &str, handle: &str) -> Config { 5 + let mut accounts = BTreeMap::new(); 6 + accounts.insert( 7 + did.to_string(), 8 + AccountConfig { 9 + pds_url: pds_url.into(), 10 + handle: handle.into(), 11 + }, 12 + ); 13 + Config { 14 + default_did: Some(did.to_string()), 15 + accounts, 16 + } 17 + } 18 + 19 + #[test] 20 + fn save_and_load_config_roundtrip() { 21 + with_test_dir(|_| { 22 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 23 + save_config(&config).unwrap(); 24 + 25 + let loaded = load_config().unwrap(); 26 + assert_eq!(loaded.default_did.unwrap(), "did:plc:alice"); 27 + let acc = loaded.accounts.get("did:plc:alice").unwrap(); 28 + assert_eq!(acc.pds_url, "https://pds.test"); 29 + assert_eq!(acc.handle, "alice.test"); 30 + }); 31 + } 32 + 33 + #[test] 34 + fn config_with_multiple_accounts_roundtrips() { 35 + with_test_dir(|_| { 36 + let mut accounts = BTreeMap::new(); 37 + accounts.insert( 38 + "did:plc:alice".into(), 39 + AccountConfig { 40 + pds_url: "https://pds.alice".into(), 41 + handle: "alice.test".into(), 42 + }, 43 + ); 44 + accounts.insert( 45 + "did:plc:bob".into(), 46 + AccountConfig { 47 + pds_url: "https://pds.bob".into(), 48 + handle: "bob.test".into(), 49 + }, 50 + ); 51 + let config = Config { 52 + default_did: Some("did:plc:alice".into()), 53 + accounts, 54 + }; 55 + save_config(&config).unwrap(); 56 + 57 + let loaded = load_config().unwrap(); 58 + assert_eq!(loaded.accounts.len(), 2); 59 + assert_eq!( 60 + loaded.accounts.get("did:plc:bob").unwrap().handle, 61 + "bob.test" 62 + ); 63 + }); 64 + } 65 + 66 + #[test] 67 + fn sanitize_did_replaces_colons() { 68 + assert_eq!(sanitize_did("did:plc:abc123"), "did_plc_abc123"); 69 + } 70 + 71 + #[test] 72 + fn sanitize_did_handles_did_web() { 73 + assert_eq!(sanitize_did("did:web:example.com"), "did_web_example.com"); 74 + } 75 + 76 + #[test] 77 + fn account_dir_uses_sanitized_did() { 78 + with_test_dir(|_| { 79 + let dir = account_dir("did:plc:test"); 80 + assert!(dir.ends_with("accounts/did_plc_test")); 81 + }); 82 + } 83 + 84 + #[test] 85 + fn save_and_load_account_json_roundtrip() { 86 + with_test_dir(|_| { 87 + let did = "did:plc:test"; 88 + let data = serde_json::json!({"key": "value"}); 89 + save_account_json(did, "test.json", &data).unwrap(); 90 + 91 + let loaded: serde_json::Value = load_account_json(did, "test.json").unwrap(); 92 + assert_eq!(loaded["key"], "value"); 93 + }); 94 + } 95 + 96 + #[test] 97 + fn load_account_json_missing_file_errors() { 98 + with_test_dir(|_| { 99 + let result: anyhow::Result<serde_json::Value> = 100 + load_account_json("did:plc:nobody", "nope.json"); 101 + let err = result.unwrap_err().to_string(); 102 + assert!(err.contains("opake login"), "expected login hint: {err}"); 103 + }); 104 + } 105 + 106 + #[test] 107 + fn ensure_account_dir_creates_nested_dirs() { 108 + with_test_dir(|_| { 109 + let did = "did:plc:nested"; 110 + ensure_account_dir(did).unwrap(); 111 + assert!(account_dir(did).exists()); 112 + }); 113 + } 114 + 115 + #[test] 116 + fn load_config_without_file_errors() { 117 + with_test_dir(|_| { 118 + let result = load_config(); 119 + assert!(result.is_err()); 120 + let err = result.unwrap_err().to_string(); 121 + assert!(err.contains("opake login"), "expected login hint: {err}"); 122 + }); 123 + } 124 + 125 + #[test] 126 + fn ensure_data_dir_creates_directory() { 127 + with_test_dir(|dir| { 128 + let target = dir.path().join("nested"); 129 + std::env::set_var("OPAKE_DATA_DIR", &target); 130 + assert!(!target.exists()); 131 + ensure_data_dir().unwrap(); 132 + assert!(target.exists()); 133 + }); 134 + } 135 + 136 + #[test] 137 + fn load_config_rejects_garbage_content() { 138 + with_test_dir(|_| { 139 + ensure_data_dir().unwrap(); 140 + fs::write(data_dir().join("config.toml"), "not valid toml {{{").unwrap(); 141 + let result = load_config(); 142 + assert!(result.is_err()); 143 + }); 144 + } 145 + 146 + #[test] 147 + fn load_config_ignores_unknown_keys() { 148 + with_test_dir(|_| { 149 + ensure_data_dir().unwrap(); 150 + fs::write(data_dir().join("config.toml"), "[section]\nkey = 42\n").unwrap(); 151 + // New Config has all optional/default fields — unknown keys are ignored 152 + let loaded = load_config().unwrap(); 153 + assert!(loaded.default_did.is_none()); 154 + assert!(loaded.accounts.is_empty()); 155 + }); 156 + } 157 + 158 + #[test] 159 + fn load_config_empty_file_gives_defaults() { 160 + with_test_dir(|_| { 161 + ensure_data_dir().unwrap(); 162 + fs::write(data_dir().join("config.toml"), "").unwrap(); 163 + let loaded = load_config().unwrap(); 164 + assert!(loaded.default_did.is_none()); 165 + assert!(loaded.accounts.is_empty()); 166 + }); 167 + } 168 + 169 + #[test] 170 + fn load_config_rejects_binary_noise() { 171 + with_test_dir(|_| { 172 + ensure_data_dir().unwrap(); 173 + fs::write(data_dir().join("config.toml"), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap(); 174 + let result = load_config(); 175 + assert!(result.is_err()); 176 + }); 177 + } 178 + 179 + // -- resolve_handle_or_did -- 180 + 181 + #[test] 182 + fn resolve_handle_or_did_passes_did_through() { 183 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 184 + let result = resolve_handle_or_did(&config, "did:plc:someone").unwrap(); 185 + assert_eq!(result, "did:plc:someone"); 186 + } 187 + 188 + #[test] 189 + fn resolve_handle_or_did_looks_up_handle() { 190 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 191 + let result = resolve_handle_or_did(&config, "alice.test").unwrap(); 192 + assert_eq!(result, "did:plc:alice"); 193 + } 194 + 195 + #[test] 196 + fn resolve_handle_or_did_unknown_handle_errors() { 197 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 198 + let err = resolve_handle_or_did(&config, "nobody.test").unwrap_err(); 199 + assert!(err.to_string().contains("nobody.test")); 200 + } 201 + 202 + // -- remove_account -- 203 + 204 + #[test] 205 + fn remove_account_deletes_dir_and_config_entry() { 206 + with_test_dir(|_| { 207 + let did = "did:plc:alice"; 208 + let config = test_config(did, "https://pds.alice", "alice.test"); 209 + save_config(&config).unwrap(); 210 + ensure_account_dir(did).unwrap(); 211 + assert!(account_dir(did).exists()); 212 + 213 + remove_account(did).unwrap(); 214 + 215 + let loaded = load_config().unwrap(); 216 + assert!(!loaded.accounts.contains_key(did)); 217 + assert!(loaded.default_did.is_none()); 218 + assert!(!account_dir(did).exists()); 219 + }); 220 + } 221 + 222 + #[test] 223 + fn remove_account_promotes_next_default() { 224 + with_test_dir(|_| { 225 + let mut accounts = BTreeMap::new(); 226 + accounts.insert( 227 + "did:plc:alice".into(), 228 + AccountConfig { 229 + pds_url: "https://pds.alice".into(), 230 + handle: "alice.test".into(), 231 + }, 232 + ); 233 + accounts.insert( 234 + "did:plc:bob".into(), 235 + AccountConfig { 236 + pds_url: "https://pds.bob".into(), 237 + handle: "bob.test".into(), 238 + }, 239 + ); 240 + save_config(&Config { 241 + default_did: Some("did:plc:alice".into()), 242 + accounts, 243 + }) 244 + .unwrap(); 245 + 246 + remove_account("did:plc:alice").unwrap(); 247 + 248 + let loaded = load_config().unwrap(); 249 + assert_eq!(loaded.default_did.as_deref(), Some("did:plc:bob")); 250 + assert_eq!(loaded.accounts.len(), 1); 251 + }); 252 + } 253 + 254 + #[test] 255 + fn remove_account_unknown_did_errors() { 256 + with_test_dir(|_| { 257 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 258 + save_config(&config).unwrap(); 259 + 260 + let err = remove_account("did:plc:nobody").unwrap_err(); 261 + assert!(err.to_string().contains("did:plc:nobody")); 262 + }); 263 + } 264 + 265 + #[test] 266 + fn remove_account_without_dir_still_works() { 267 + with_test_dir(|_| { 268 + let did = "did:plc:alice"; 269 + let config = test_config(did, "https://pds.test", "alice.test"); 270 + save_config(&config).unwrap(); 271 + // don't create account dir — should still succeed 272 + 273 + remove_account(did).unwrap(); 274 + 275 + let loaded = load_config().unwrap(); 276 + assert!(!loaded.accounts.contains_key(did)); 277 + }); 278 + }
+2 -217
crates/opake-core/src/client/did.rs
··· 171 171 } 172 172 173 173 #[cfg(test)] 174 - mod tests { 175 - use super::*; 176 - use crate::test_utils::MockTransport; 177 - 178 - fn response(status: u16, body: &str) -> HttpResponse { 179 - HttpResponse { 180 - status, 181 - body: body.as_bytes().to_vec(), 182 - } 183 - } 184 - 185 - fn success_response(body: &str) -> HttpResponse { 186 - response(200, body) 187 - } 188 - 189 - // -- resolve_handle -- 190 - 191 - #[tokio::test] 192 - async fn resolve_handle_happy_path() { 193 - let mock = MockTransport::new(); 194 - mock.enqueue(success_response(r#"{"did":"did:plc:abc123"}"#)); 195 - 196 - let did = resolve_handle(&mock, "https://pds.test", "alice.test") 197 - .await 198 - .unwrap(); 199 - assert_eq!(did, "did:plc:abc123"); 200 - 201 - let reqs = mock.requests(); 202 - assert_eq!(reqs.len(), 1); 203 - assert!(reqs[0].url.contains("resolveHandle")); 204 - assert!(reqs[0].url.contains("handle=alice.test")); 205 - assert!(reqs[0].headers.is_empty()); 206 - } 207 - 208 - #[tokio::test] 209 - async fn resolve_handle_not_found() { 210 - let mock = MockTransport::new(); 211 - mock.enqueue(response( 212 - 400, 213 - r#"{"error":"InvalidHandle","message":"Unable to resolve handle"}"#, 214 - )); 215 - 216 - let err = resolve_handle(&mock, "https://pds.test", "nobody.fake") 217 - .await 218 - .unwrap_err(); 219 - assert!(matches!(err, Error::Xrpc { status: 400, .. })); 220 - } 221 - 222 - // -- get_record_public -- 223 - 224 - #[tokio::test] 225 - async fn get_record_public_happy_path() { 226 - let mock = MockTransport::new(); 227 - mock.enqueue(success_response( 228 - r#"{"uri":"at://did:plc:abc/col/rkey","cid":"bafy","value":{"hello":"world"}}"#, 229 - )); 230 - 231 - let entry = get_record_public(&mock, "https://pds.other", "did:plc:abc", "col", "rkey") 232 - .await 233 - .unwrap(); 234 - assert_eq!(entry.uri, "at://did:plc:abc/col/rkey"); 235 - assert_eq!(entry.value["hello"], "world"); 236 - 237 - let reqs = mock.requests(); 238 - assert!(reqs[0].url.starts_with("https://pds.other")); 239 - assert!(reqs[0].headers.is_empty()); 240 - } 241 - 242 - #[tokio::test] 243 - async fn get_record_public_404() { 244 - let mock = MockTransport::new(); 245 - mock.enqueue(response( 246 - 404, 247 - r#"{"error":"RecordNotFound","message":"not found"}"#, 248 - )); 249 - 250 - let err = get_record_public(&mock, "https://pds.other", "did:plc:abc", "col", "rkey") 251 - .await 252 - .unwrap_err(); 253 - assert!(matches!(err, Error::NotFound(_))); 254 - } 255 - 256 - // -- get_blob_public -- 257 - 258 - #[tokio::test] 259 - async fn get_blob_public_happy_path() { 260 - let mock = MockTransport::new(); 261 - let blob_data = b"encrypted-blob-bytes"; 262 - mock.enqueue(HttpResponse { 263 - status: 200, 264 - body: blob_data.to_vec(), 265 - }); 266 - 267 - let data = get_blob_public(&mock, "https://pds.owner", "did:plc:owner", "bafyblob123") 268 - .await 269 - .unwrap(); 270 - assert_eq!(data, blob_data); 271 - 272 - let reqs = mock.requests(); 273 - assert_eq!(reqs.len(), 1); 274 - assert!(reqs[0].url.starts_with("https://pds.owner")); 275 - assert!(reqs[0].url.contains("getBlob")); 276 - assert!(reqs[0].url.contains("did=did:plc:owner")); 277 - assert!(reqs[0].url.contains("cid=bafyblob123")); 278 - assert!(reqs[0].headers.is_empty()); 279 - } 280 - 281 - #[tokio::test] 282 - async fn get_blob_public_404() { 283 - let mock = MockTransport::new(); 284 - mock.enqueue(response( 285 - 404, 286 - r#"{"error":"BlobNotFound","message":"not found"}"#, 287 - )); 288 - 289 - let err = get_blob_public(&mock, "https://pds.owner", "did:plc:abc", "bafymissing") 290 - .await 291 - .unwrap_err(); 292 - assert!(matches!(err, Error::NotFound(_))); 293 - } 294 - 295 - // -- resolve_did_document -- 296 - 297 - fn plc_document_json() -> String { 298 - serde_json::json!({ 299 - "id": "did:plc:abc123", 300 - "alsoKnownAs": ["at://alice.test"], 301 - "service": [{ 302 - "id": "#atproto_pds", 303 - "type": "AtprotoPersonalDataServer", 304 - "serviceEndpoint": "https://pds.alice.example.com" 305 - }] 306 - }) 307 - .to_string() 308 - } 309 - 310 - #[tokio::test] 311 - async fn resolve_did_document_plc() { 312 - let mock = MockTransport::new(); 313 - mock.enqueue(success_response(&plc_document_json())); 314 - 315 - let doc = resolve_did_document(&mock, "did:plc:abc123").await.unwrap(); 316 - assert_eq!(doc.id, "did:plc:abc123"); 317 - assert_eq!(doc.also_known_as, vec!["at://alice.test"]); 318 - assert_eq!(doc.service.len(), 1); 319 - assert_eq!(doc.service[0].id, "#atproto_pds"); 320 - 321 - let reqs = mock.requests(); 322 - assert!(reqs[0].url.contains("plc.directory/did:plc:abc123")); 323 - } 324 - 325 - #[tokio::test] 326 - async fn resolve_did_document_web() { 327 - let mock = MockTransport::new(); 328 - mock.enqueue(success_response( 329 - &serde_json::json!({ 330 - "id": "did:web:example.com", 331 - "service": [{ 332 - "id": "#atproto_pds", 333 - "serviceEndpoint": "https://pds.example.com" 334 - }] 335 - }) 336 - .to_string(), 337 - )); 338 - 339 - let doc = resolve_did_document(&mock, "did:web:example.com") 340 - .await 341 - .unwrap(); 342 - assert_eq!(doc.id, "did:web:example.com"); 343 - 344 - let reqs = mock.requests(); 345 - assert!(reqs[0].url.contains("example.com/.well-known/did.json")); 346 - } 347 - 348 - #[tokio::test] 349 - async fn resolve_did_document_unsupported_method() { 350 - let mock = MockTransport::new(); 351 - let err = resolve_did_document(&mock, "did:key:z123") 352 - .await 353 - .unwrap_err(); 354 - assert!(err.to_string().contains("unsupported DID method")); 355 - } 356 - 357 - // -- pds_from_did_document -- 358 - 359 - #[test] 360 - fn pds_from_did_document_extracts_endpoint() { 361 - let doc: DidDocument = serde_json::from_str(&plc_document_json()).unwrap(); 362 - let pds = pds_from_did_document(&doc).unwrap(); 363 - assert_eq!(pds, "https://pds.alice.example.com"); 364 - } 365 - 366 - #[test] 367 - fn pds_from_did_document_no_service() { 368 - let doc = DidDocument { 369 - id: "did:plc:test".into(), 370 - also_known_as: vec![], 371 - service: vec![], 372 - }; 373 - let err = pds_from_did_document(&doc).unwrap_err(); 374 - assert!(matches!(err, Error::NotFound(_))); 375 - assert!(err.to_string().contains("#atproto_pds")); 376 - } 377 - 378 - #[test] 379 - fn pds_from_did_document_wrong_service_id() { 380 - let doc = DidDocument { 381 - id: "did:plc:test".into(), 382 - also_known_as: vec![], 383 - service: vec![DidService { 384 - id: "#something_else".into(), 385 - service_endpoint: "https://other.example.com".into(), 386 - }], 387 - }; 388 - assert!(pds_from_did_document(&doc).is_err()); 389 - } 390 - } 174 + #[path = "did_tests.rs"] 175 + mod tests;
+215
crates/opake-core/src/client/did_tests.rs
··· 1 + use super::*; 2 + use crate::test_utils::MockTransport; 3 + 4 + fn response(status: u16, body: &str) -> HttpResponse { 5 + HttpResponse { 6 + status, 7 + body: body.as_bytes().to_vec(), 8 + } 9 + } 10 + 11 + fn success_response(body: &str) -> HttpResponse { 12 + response(200, body) 13 + } 14 + 15 + // -- resolve_handle -- 16 + 17 + #[tokio::test] 18 + async fn resolve_handle_happy_path() { 19 + let mock = MockTransport::new(); 20 + mock.enqueue(success_response(r#"{"did":"did:plc:abc123"}"#)); 21 + 22 + let did = resolve_handle(&mock, "https://pds.test", "alice.test") 23 + .await 24 + .unwrap(); 25 + assert_eq!(did, "did:plc:abc123"); 26 + 27 + let reqs = mock.requests(); 28 + assert_eq!(reqs.len(), 1); 29 + assert!(reqs[0].url.contains("resolveHandle")); 30 + assert!(reqs[0].url.contains("handle=alice.test")); 31 + assert!(reqs[0].headers.is_empty()); 32 + } 33 + 34 + #[tokio::test] 35 + async fn resolve_handle_not_found() { 36 + let mock = MockTransport::new(); 37 + mock.enqueue(response( 38 + 400, 39 + r#"{"error":"InvalidHandle","message":"Unable to resolve handle"}"#, 40 + )); 41 + 42 + let err = resolve_handle(&mock, "https://pds.test", "nobody.fake") 43 + .await 44 + .unwrap_err(); 45 + assert!(matches!(err, Error::Xrpc { status: 400, .. })); 46 + } 47 + 48 + // -- get_record_public -- 49 + 50 + #[tokio::test] 51 + async fn get_record_public_happy_path() { 52 + let mock = MockTransport::new(); 53 + mock.enqueue(success_response( 54 + r#"{"uri":"at://did:plc:abc/col/rkey","cid":"bafy","value":{"hello":"world"}}"#, 55 + )); 56 + 57 + let entry = get_record_public(&mock, "https://pds.other", "did:plc:abc", "col", "rkey") 58 + .await 59 + .unwrap(); 60 + assert_eq!(entry.uri, "at://did:plc:abc/col/rkey"); 61 + assert_eq!(entry.value["hello"], "world"); 62 + 63 + let reqs = mock.requests(); 64 + assert!(reqs[0].url.starts_with("https://pds.other")); 65 + assert!(reqs[0].headers.is_empty()); 66 + } 67 + 68 + #[tokio::test] 69 + async fn get_record_public_404() { 70 + let mock = MockTransport::new(); 71 + mock.enqueue(response( 72 + 404, 73 + r#"{"error":"RecordNotFound","message":"not found"}"#, 74 + )); 75 + 76 + let err = get_record_public(&mock, "https://pds.other", "did:plc:abc", "col", "rkey") 77 + .await 78 + .unwrap_err(); 79 + assert!(matches!(err, Error::NotFound(_))); 80 + } 81 + 82 + // -- get_blob_public -- 83 + 84 + #[tokio::test] 85 + async fn get_blob_public_happy_path() { 86 + let mock = MockTransport::new(); 87 + let blob_data = b"encrypted-blob-bytes"; 88 + mock.enqueue(HttpResponse { 89 + status: 200, 90 + body: blob_data.to_vec(), 91 + }); 92 + 93 + let data = get_blob_public(&mock, "https://pds.owner", "did:plc:owner", "bafyblob123") 94 + .await 95 + .unwrap(); 96 + assert_eq!(data, blob_data); 97 + 98 + let reqs = mock.requests(); 99 + assert_eq!(reqs.len(), 1); 100 + assert!(reqs[0].url.starts_with("https://pds.owner")); 101 + assert!(reqs[0].url.contains("getBlob")); 102 + assert!(reqs[0].url.contains("did=did:plc:owner")); 103 + assert!(reqs[0].url.contains("cid=bafyblob123")); 104 + assert!(reqs[0].headers.is_empty()); 105 + } 106 + 107 + #[tokio::test] 108 + async fn get_blob_public_404() { 109 + let mock = MockTransport::new(); 110 + mock.enqueue(response( 111 + 404, 112 + r#"{"error":"BlobNotFound","message":"not found"}"#, 113 + )); 114 + 115 + let err = get_blob_public(&mock, "https://pds.owner", "did:plc:abc", "bafymissing") 116 + .await 117 + .unwrap_err(); 118 + assert!(matches!(err, Error::NotFound(_))); 119 + } 120 + 121 + // -- resolve_did_document -- 122 + 123 + fn plc_document_json() -> String { 124 + serde_json::json!({ 125 + "id": "did:plc:abc123", 126 + "alsoKnownAs": ["at://alice.test"], 127 + "service": [{ 128 + "id": "#atproto_pds", 129 + "type": "AtprotoPersonalDataServer", 130 + "serviceEndpoint": "https://pds.alice.example.com" 131 + }] 132 + }) 133 + .to_string() 134 + } 135 + 136 + #[tokio::test] 137 + async fn resolve_did_document_plc() { 138 + let mock = MockTransport::new(); 139 + mock.enqueue(success_response(&plc_document_json())); 140 + 141 + let doc = resolve_did_document(&mock, "did:plc:abc123").await.unwrap(); 142 + assert_eq!(doc.id, "did:plc:abc123"); 143 + assert_eq!(doc.also_known_as, vec!["at://alice.test"]); 144 + assert_eq!(doc.service.len(), 1); 145 + assert_eq!(doc.service[0].id, "#atproto_pds"); 146 + 147 + let reqs = mock.requests(); 148 + assert!(reqs[0].url.contains("plc.directory/did:plc:abc123")); 149 + } 150 + 151 + #[tokio::test] 152 + async fn resolve_did_document_web() { 153 + let mock = MockTransport::new(); 154 + mock.enqueue(success_response( 155 + &serde_json::json!({ 156 + "id": "did:web:example.com", 157 + "service": [{ 158 + "id": "#atproto_pds", 159 + "serviceEndpoint": "https://pds.example.com" 160 + }] 161 + }) 162 + .to_string(), 163 + )); 164 + 165 + let doc = resolve_did_document(&mock, "did:web:example.com") 166 + .await 167 + .unwrap(); 168 + assert_eq!(doc.id, "did:web:example.com"); 169 + 170 + let reqs = mock.requests(); 171 + assert!(reqs[0].url.contains("example.com/.well-known/did.json")); 172 + } 173 + 174 + #[tokio::test] 175 + async fn resolve_did_document_unsupported_method() { 176 + let mock = MockTransport::new(); 177 + let err = resolve_did_document(&mock, "did:key:z123") 178 + .await 179 + .unwrap_err(); 180 + assert!(err.to_string().contains("unsupported DID method")); 181 + } 182 + 183 + // -- pds_from_did_document -- 184 + 185 + #[test] 186 + fn pds_from_did_document_extracts_endpoint() { 187 + let doc: DidDocument = serde_json::from_str(&plc_document_json()).unwrap(); 188 + let pds = pds_from_did_document(&doc).unwrap(); 189 + assert_eq!(pds, "https://pds.alice.example.com"); 190 + } 191 + 192 + #[test] 193 + fn pds_from_did_document_no_service() { 194 + let doc = DidDocument { 195 + id: "did:plc:test".into(), 196 + also_known_as: vec![], 197 + service: vec![], 198 + }; 199 + let err = pds_from_did_document(&doc).unwrap_err(); 200 + assert!(matches!(err, Error::NotFound(_))); 201 + assert!(err.to_string().contains("#atproto_pds")); 202 + } 203 + 204 + #[test] 205 + fn pds_from_did_document_wrong_service_id() { 206 + let doc = DidDocument { 207 + id: "did:plc:test".into(), 208 + also_known_as: vec![], 209 + service: vec![DidService { 210 + id: "#something_else".into(), 211 + service_endpoint: "https://other.example.com".into(), 212 + }], 213 + }; 214 + assert!(pds_from_did_document(&doc).is_err()); 215 + }
+2 -312
crates/opake-core/src/client/xrpc.rs
··· 448 448 } 449 449 450 450 #[cfg(test)] 451 - mod tests { 452 - use super::*; 453 - use crate::test_utils::MockTransport; 454 - 455 - fn response(status: u16, body: &str) -> HttpResponse { 456 - HttpResponse { 457 - status, 458 - body: body.as_bytes().to_vec(), 459 - } 460 - } 461 - 462 - // -- 2xx success range -- 463 - 464 - #[test] 465 - fn ok_200_passes() { 466 - assert!(check_response(&response(200, "")).is_ok()); 467 - } 468 - 469 - #[test] 470 - fn created_201_passes() { 471 - assert!(check_response(&response(201, "")).is_ok()); 472 - } 473 - 474 - #[test] 475 - fn no_content_204_passes() { 476 - assert!(check_response(&response(204, "")).is_ok()); 477 - } 478 - 479 - // -- XRPC error bodies -- 480 - 481 - #[test] 482 - fn error_500_with_xrpc_body() { 483 - let r = response( 484 - 500, 485 - r#"{"error":"InternalServerError","message":"Internal Server Error"}"#, 486 - ); 487 - let err = check_response(&r).unwrap_err(); 488 - match err { 489 - Error::Xrpc { status, message } => { 490 - assert_eq!(status, 500); 491 - assert!(message.contains("InternalServerError")); 492 - assert!(message.contains("Internal Server Error")); 493 - } 494 - other => panic!("expected Xrpc error, got: {other}"), 495 - } 496 - } 497 - 498 - #[test] 499 - fn error_400_with_error_code_only() { 500 - let r = response(400, r#"{"error":"InvalidRequest"}"#); 501 - let err = check_response(&r).unwrap_err(); 502 - match err { 503 - Error::Xrpc { status, message } => { 504 - assert_eq!(status, 400); 505 - assert_eq!(message, "InvalidRequest"); 506 - } 507 - other => panic!("expected Xrpc error, got: {other}"), 508 - } 509 - } 510 - 511 - #[test] 512 - fn error_403_with_message_only() { 513 - let r = response(403, r#"{"message":"not authorized"}"#); 514 - let err = check_response(&r).unwrap_err(); 515 - match err { 516 - Error::Xrpc { status, message } => { 517 - assert_eq!(status, 403); 518 - assert_eq!(message, "not authorized"); 519 - } 520 - other => panic!("expected Xrpc error, got: {other}"), 521 - } 522 - } 523 - 524 - // -- 404 maps to NotFound -- 525 - 526 - #[test] 527 - fn error_404_returns_not_found() { 528 - let r = response( 529 - 404, 530 - r#"{"error":"RecordNotFound","message":"no such record"}"#, 531 - ); 532 - let err = check_response(&r).unwrap_err(); 533 - assert!(matches!(err, Error::NotFound(_))); 534 - } 535 - 536 - // -- Non-JSON error bodies -- 537 - 538 - #[test] 539 - fn error_502_with_html_body() { 540 - let r = response(502, "<html><body>Bad Gateway</body></html>"); 541 - let err = check_response(&r).unwrap_err(); 542 - match err { 543 - Error::Xrpc { status, message } => { 544 - assert_eq!(status, 502); 545 - assert_eq!(message, "HTTP 502"); 546 - } 547 - other => panic!("expected Xrpc error, got: {other}"), 548 - } 549 - } 550 - 551 - #[test] 552 - fn error_500_with_empty_body() { 553 - let r = response(500, ""); 554 - let err = check_response(&r).unwrap_err(); 555 - match err { 556 - Error::Xrpc { status, message } => { 557 - assert_eq!(status, 500); 558 - assert_eq!(message, "HTTP 500"); 559 - } 560 - other => panic!("expected Xrpc error, got: {other}"), 561 - } 562 - } 563 - 564 - #[test] 565 - fn error_500_with_empty_json_object() { 566 - let r = response(500, "{}"); 567 - let err = check_response(&r).unwrap_err(); 568 - match err { 569 - Error::Xrpc { status, message } => { 570 - assert_eq!(status, 500); 571 - assert_eq!(message, "HTTP 500"); 572 - } 573 - other => panic!("expected Xrpc error, got: {other}"), 574 - } 575 - } 576 - 577 - // -- Edge: 3xx is not success -- 578 - 579 - #[test] 580 - fn redirect_300_is_error() { 581 - assert!(check_response(&response(300, "")).is_err()); 582 - } 583 - 584 - // -- Token refresh tests -- 585 - 586 - fn expired_token_response() -> HttpResponse { 587 - HttpResponse { 588 - status: 400, 589 - body: br#"{"error":"ExpiredToken","message":"Token has expired"}"#.to_vec(), 590 - } 591 - } 592 - 593 - fn refresh_session_response() -> HttpResponse { 594 - let body = serde_json::json!({ 595 - "did": "did:plc:test", 596 - "handle": "test.handle", 597 - "accessJwt": "fresh-access-jwt", 598 - "refreshJwt": "fresh-refresh-jwt", 599 - }); 600 - HttpResponse { 601 - status: 200, 602 - body: serde_json::to_vec(&body).unwrap(), 603 - } 604 - } 605 - 606 - fn success_response(body: &str) -> HttpResponse { 607 - HttpResponse { 608 - status: 200, 609 - body: body.as_bytes().to_vec(), 610 - } 611 - } 612 - 613 - fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 614 - let session = Session { 615 - did: "did:plc:test".into(), 616 - handle: "test.handle".into(), 617 - access_jwt: "stale-access-jwt".into(), 618 - refresh_jwt: "valid-refresh-jwt".into(), 619 - }; 620 - XrpcClient::with_session(mock, "https://pds.test".into(), session) 621 - } 622 - 623 - #[tokio::test] 624 - async fn refresh_on_expired_token_then_retry() { 625 - let mock = MockTransport::new(); 626 - // First request: expired token 627 - mock.enqueue(expired_token_response()); 628 - // Refresh succeeds 629 - mock.enqueue(refresh_session_response()); 630 - // Retry succeeds 631 - mock.enqueue(success_response(r#"{"records":[]}"#)); 632 - 633 - let mut client = mock_client(mock.clone()); 634 - let page = client 635 - .list_records("app.opake.cloud.document", Some(100), None) 636 - .await 637 - .unwrap(); 638 - 639 - assert!(page.records.is_empty()); 640 - assert!(client.session_refreshed()); 641 - 642 - let session = client.session().unwrap(); 643 - assert_eq!(session.access_jwt, "fresh-access-jwt"); 644 - assert_eq!(session.refresh_jwt, "fresh-refresh-jwt"); 645 - 646 - // Verify: 3 requests — original, refresh, retry 647 - let reqs = mock.requests(); 648 - assert_eq!(reqs.len(), 3); 649 - assert!(reqs[0].url.contains("listRecords")); 650 - assert!(reqs[1].url.contains("refreshSession")); 651 - assert!(reqs[2].url.contains("listRecords")); 652 - 653 - // Retry used the new token 654 - let retry_auth = reqs[2] 655 - .headers 656 - .iter() 657 - .find(|(k, _)| k == "Authorization") 658 - .unwrap(); 659 - assert_eq!(retry_auth.1, "Bearer fresh-access-jwt"); 660 - } 661 - 662 - #[tokio::test] 663 - async fn refresh_failure_propagates_error() { 664 - let mock = MockTransport::new(); 665 - mock.enqueue(expired_token_response()); 666 - // Refresh fails 667 - mock.enqueue(HttpResponse { 668 - status: 401, 669 - body: br#"{"error":"InvalidToken","message":"bad refresh token"}"#.to_vec(), 670 - }); 671 - 672 - let mut client = mock_client(mock); 673 - let err = client 674 - .list_records("app.opake.cloud.document", Some(100), None) 675 - .await 676 - .unwrap_err(); 677 - 678 - assert!(err.to_string().contains("session refresh failed")); 679 - assert!(err.to_string().contains("opake login")); 680 - assert!(!client.session_refreshed()); 681 - } 682 - 683 - #[tokio::test] 684 - async fn non_expired_error_passes_through() { 685 - let mock = MockTransport::new(); 686 - mock.enqueue(HttpResponse { 687 - status: 500, 688 - body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 689 - }); 690 - 691 - let mut client = mock_client(mock); 692 - let err = client 693 - .list_records("app.opake.cloud.document", Some(100), None) 694 - .await 695 - .unwrap_err(); 696 - 697 - assert!(matches!(err, Error::Xrpc { status: 500, .. })); 698 - assert!(!client.session_refreshed()); 699 - } 700 - 701 - #[test] 702 - fn is_expired_token_detects_correctly() { 703 - assert!(XrpcClient::<MockTransport>::is_expired_token( 704 - &expired_token_response() 705 - )); 706 - } 707 - 708 - #[test] 709 - fn is_expired_token_rejects_other_400() { 710 - let r = response(400, r#"{"error":"InvalidRequest"}"#); 711 - assert!(!XrpcClient::<MockTransport>::is_expired_token(&r)); 712 - } 713 - 714 - #[test] 715 - fn is_expired_token_rejects_500() { 716 - let r = response(500, r#"{"error":"ExpiredToken"}"#); 717 - assert!(!XrpcClient::<MockTransport>::is_expired_token(&r)); 718 - } 719 - 720 - #[test] 721 - fn is_expired_token_rejects_no_json() { 722 - let r = response(400, "not json"); 723 - assert!(!XrpcClient::<MockTransport>::is_expired_token(&r)); 724 - } 725 - 726 - #[tokio::test] 727 - async fn put_record_sends_rkey_and_returns_ref() { 728 - let mock = MockTransport::new(); 729 - let body = serde_json::json!({ 730 - "uri": "at://did:plc:test/app.opake.cloud.publicKey/self", 731 - "cid": "bafyputrecord", 732 - }); 733 - mock.enqueue(success_response(&body.to_string())); 734 - 735 - let mut client = mock_client(mock.clone()); 736 - 737 - let record = serde_json::json!({ "hello": "world" }); 738 - let result = client 739 - .put_record("app.opake.cloud.publicKey", "self", &record) 740 - .await 741 - .unwrap(); 742 - 743 - assert_eq!( 744 - result.uri, 745 - "at://did:plc:test/app.opake.cloud.publicKey/self" 746 - ); 747 - assert_eq!(result.cid, "bafyputrecord"); 748 - 749 - let reqs = mock.requests(); 750 - assert_eq!(reqs.len(), 1); 751 - assert!(reqs[0].url.contains("putRecord")); 752 - 753 - // Verify the body includes rkey 754 - let sent_body = match &reqs[0].body { 755 - Some(RequestBody::Json(v)) => v.clone(), 756 - _ => panic!("expected JSON body"), 757 - }; 758 - assert_eq!(sent_body["rkey"], "self"); 759 - assert_eq!(sent_body["collection"], "app.opake.cloud.publicKey"); 760 - assert_eq!(sent_body["repo"], "did:plc:test"); 761 - } 762 - } 451 + #[path = "xrpc_tests.rs"] 452 + mod tests;
+310
crates/opake-core/src/client/xrpc_tests.rs
··· 1 + use super::*; 2 + use crate::test_utils::MockTransport; 3 + 4 + fn response(status: u16, body: &str) -> HttpResponse { 5 + HttpResponse { 6 + status, 7 + body: body.as_bytes().to_vec(), 8 + } 9 + } 10 + 11 + // -- 2xx success range -- 12 + 13 + #[test] 14 + fn ok_200_passes() { 15 + assert!(check_response(&response(200, "")).is_ok()); 16 + } 17 + 18 + #[test] 19 + fn created_201_passes() { 20 + assert!(check_response(&response(201, "")).is_ok()); 21 + } 22 + 23 + #[test] 24 + fn no_content_204_passes() { 25 + assert!(check_response(&response(204, "")).is_ok()); 26 + } 27 + 28 + // -- XRPC error bodies -- 29 + 30 + #[test] 31 + fn error_500_with_xrpc_body() { 32 + let r = response( 33 + 500, 34 + r#"{"error":"InternalServerError","message":"Internal Server Error"}"#, 35 + ); 36 + let err = check_response(&r).unwrap_err(); 37 + match err { 38 + Error::Xrpc { status, message } => { 39 + assert_eq!(status, 500); 40 + assert!(message.contains("InternalServerError")); 41 + assert!(message.contains("Internal Server Error")); 42 + } 43 + other => panic!("expected Xrpc error, got: {other}"), 44 + } 45 + } 46 + 47 + #[test] 48 + fn error_400_with_error_code_only() { 49 + let r = response(400, r#"{"error":"InvalidRequest"}"#); 50 + let err = check_response(&r).unwrap_err(); 51 + match err { 52 + Error::Xrpc { status, message } => { 53 + assert_eq!(status, 400); 54 + assert_eq!(message, "InvalidRequest"); 55 + } 56 + other => panic!("expected Xrpc error, got: {other}"), 57 + } 58 + } 59 + 60 + #[test] 61 + fn error_403_with_message_only() { 62 + let r = response(403, r#"{"message":"not authorized"}"#); 63 + let err = check_response(&r).unwrap_err(); 64 + match err { 65 + Error::Xrpc { status, message } => { 66 + assert_eq!(status, 403); 67 + assert_eq!(message, "not authorized"); 68 + } 69 + other => panic!("expected Xrpc error, got: {other}"), 70 + } 71 + } 72 + 73 + // -- 404 maps to NotFound -- 74 + 75 + #[test] 76 + fn error_404_returns_not_found() { 77 + let r = response( 78 + 404, 79 + r#"{"error":"RecordNotFound","message":"no such record"}"#, 80 + ); 81 + let err = check_response(&r).unwrap_err(); 82 + assert!(matches!(err, Error::NotFound(_))); 83 + } 84 + 85 + // -- Non-JSON error bodies -- 86 + 87 + #[test] 88 + fn error_502_with_html_body() { 89 + let r = response(502, "<html><body>Bad Gateway</body></html>"); 90 + let err = check_response(&r).unwrap_err(); 91 + match err { 92 + Error::Xrpc { status, message } => { 93 + assert_eq!(status, 502); 94 + assert_eq!(message, "HTTP 502"); 95 + } 96 + other => panic!("expected Xrpc error, got: {other}"), 97 + } 98 + } 99 + 100 + #[test] 101 + fn error_500_with_empty_body() { 102 + let r = response(500, ""); 103 + let err = check_response(&r).unwrap_err(); 104 + match err { 105 + Error::Xrpc { status, message } => { 106 + assert_eq!(status, 500); 107 + assert_eq!(message, "HTTP 500"); 108 + } 109 + other => panic!("expected Xrpc error, got: {other}"), 110 + } 111 + } 112 + 113 + #[test] 114 + fn error_500_with_empty_json_object() { 115 + let r = response(500, "{}"); 116 + let err = check_response(&r).unwrap_err(); 117 + match err { 118 + Error::Xrpc { status, message } => { 119 + assert_eq!(status, 500); 120 + assert_eq!(message, "HTTP 500"); 121 + } 122 + other => panic!("expected Xrpc error, got: {other}"), 123 + } 124 + } 125 + 126 + // -- Edge: 3xx is not success -- 127 + 128 + #[test] 129 + fn redirect_300_is_error() { 130 + assert!(check_response(&response(300, "")).is_err()); 131 + } 132 + 133 + // -- Token refresh tests -- 134 + 135 + fn expired_token_response() -> HttpResponse { 136 + HttpResponse { 137 + status: 400, 138 + body: br#"{"error":"ExpiredToken","message":"Token has expired"}"#.to_vec(), 139 + } 140 + } 141 + 142 + fn refresh_session_response() -> HttpResponse { 143 + let body = serde_json::json!({ 144 + "did": "did:plc:test", 145 + "handle": "test.handle", 146 + "accessJwt": "fresh-access-jwt", 147 + "refreshJwt": "fresh-refresh-jwt", 148 + }); 149 + HttpResponse { 150 + status: 200, 151 + body: serde_json::to_vec(&body).unwrap(), 152 + } 153 + } 154 + 155 + fn success_response(body: &str) -> HttpResponse { 156 + HttpResponse { 157 + status: 200, 158 + body: body.as_bytes().to_vec(), 159 + } 160 + } 161 + 162 + fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 163 + let session = Session { 164 + did: "did:plc:test".into(), 165 + handle: "test.handle".into(), 166 + access_jwt: "stale-access-jwt".into(), 167 + refresh_jwt: "valid-refresh-jwt".into(), 168 + }; 169 + XrpcClient::with_session(mock, "https://pds.test".into(), session) 170 + } 171 + 172 + #[tokio::test] 173 + async fn refresh_on_expired_token_then_retry() { 174 + let mock = MockTransport::new(); 175 + // First request: expired token 176 + mock.enqueue(expired_token_response()); 177 + // Refresh succeeds 178 + mock.enqueue(refresh_session_response()); 179 + // Retry succeeds 180 + mock.enqueue(success_response(r#"{"records":[]}"#)); 181 + 182 + let mut client = mock_client(mock.clone()); 183 + let page = client 184 + .list_records("app.opake.cloud.document", Some(100), None) 185 + .await 186 + .unwrap(); 187 + 188 + assert!(page.records.is_empty()); 189 + assert!(client.session_refreshed()); 190 + 191 + let session = client.session().unwrap(); 192 + assert_eq!(session.access_jwt, "fresh-access-jwt"); 193 + assert_eq!(session.refresh_jwt, "fresh-refresh-jwt"); 194 + 195 + // Verify: 3 requests — original, refresh, retry 196 + let reqs = mock.requests(); 197 + assert_eq!(reqs.len(), 3); 198 + assert!(reqs[0].url.contains("listRecords")); 199 + assert!(reqs[1].url.contains("refreshSession")); 200 + assert!(reqs[2].url.contains("listRecords")); 201 + 202 + // Retry used the new token 203 + let retry_auth = reqs[2] 204 + .headers 205 + .iter() 206 + .find(|(k, _)| k == "Authorization") 207 + .unwrap(); 208 + assert_eq!(retry_auth.1, "Bearer fresh-access-jwt"); 209 + } 210 + 211 + #[tokio::test] 212 + async fn refresh_failure_propagates_error() { 213 + let mock = MockTransport::new(); 214 + mock.enqueue(expired_token_response()); 215 + // Refresh fails 216 + mock.enqueue(HttpResponse { 217 + status: 401, 218 + body: br#"{"error":"InvalidToken","message":"bad refresh token"}"#.to_vec(), 219 + }); 220 + 221 + let mut client = mock_client(mock); 222 + let err = client 223 + .list_records("app.opake.cloud.document", Some(100), None) 224 + .await 225 + .unwrap_err(); 226 + 227 + assert!(err.to_string().contains("session refresh failed")); 228 + assert!(err.to_string().contains("opake login")); 229 + assert!(!client.session_refreshed()); 230 + } 231 + 232 + #[tokio::test] 233 + async fn non_expired_error_passes_through() { 234 + let mock = MockTransport::new(); 235 + mock.enqueue(HttpResponse { 236 + status: 500, 237 + body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 238 + }); 239 + 240 + let mut client = mock_client(mock); 241 + let err = client 242 + .list_records("app.opake.cloud.document", Some(100), None) 243 + .await 244 + .unwrap_err(); 245 + 246 + assert!(matches!(err, Error::Xrpc { status: 500, .. })); 247 + assert!(!client.session_refreshed()); 248 + } 249 + 250 + #[test] 251 + fn is_expired_token_detects_correctly() { 252 + assert!(XrpcClient::<MockTransport>::is_expired_token( 253 + &expired_token_response() 254 + )); 255 + } 256 + 257 + #[test] 258 + fn is_expired_token_rejects_other_400() { 259 + let r = response(400, r#"{"error":"InvalidRequest"}"#); 260 + assert!(!XrpcClient::<MockTransport>::is_expired_token(&r)); 261 + } 262 + 263 + #[test] 264 + fn is_expired_token_rejects_500() { 265 + let r = response(500, r#"{"error":"ExpiredToken"}"#); 266 + assert!(!XrpcClient::<MockTransport>::is_expired_token(&r)); 267 + } 268 + 269 + #[test] 270 + fn is_expired_token_rejects_no_json() { 271 + let r = response(400, "not json"); 272 + assert!(!XrpcClient::<MockTransport>::is_expired_token(&r)); 273 + } 274 + 275 + #[tokio::test] 276 + async fn put_record_sends_rkey_and_returns_ref() { 277 + let mock = MockTransport::new(); 278 + let body = serde_json::json!({ 279 + "uri": "at://did:plc:test/app.opake.cloud.publicKey/self", 280 + "cid": "bafyputrecord", 281 + }); 282 + mock.enqueue(success_response(&body.to_string())); 283 + 284 + let mut client = mock_client(mock.clone()); 285 + 286 + let record = serde_json::json!({ "hello": "world" }); 287 + let result = client 288 + .put_record("app.opake.cloud.publicKey", "self", &record) 289 + .await 290 + .unwrap(); 291 + 292 + assert_eq!( 293 + result.uri, 294 + "at://did:plc:test/app.opake.cloud.publicKey/self" 295 + ); 296 + assert_eq!(result.cid, "bafyputrecord"); 297 + 298 + let reqs = mock.requests(); 299 + assert_eq!(reqs.len(), 1); 300 + assert!(reqs[0].url.contains("putRecord")); 301 + 302 + // Verify the body includes rkey 303 + let sent_body = match &reqs[0].body { 304 + Some(RequestBody::Json(v)) => v.clone(), 305 + _ => panic!("expected JSON body"), 306 + }; 307 + assert_eq!(sent_body["rkey"], "self"); 308 + assert_eq!(sent_body["collection"], "app.opake.cloud.publicKey"); 309 + assert_eq!(sent_body["repo"], "did:plc:test"); 310 + }
+2 -206
crates/opake-core/src/crypto.rs
··· 248 248 } 249 249 250 250 #[cfg(test)] 251 - mod tests { 252 - use super::*; 253 - use aes_gcm::aead::rand_core::OsRng; 254 - 255 - // -- Content encryption tests (AES-256-GCM) -- 256 - 257 - #[test] 258 - fn roundtrip_encrypt_decrypt() { 259 - let key = generate_content_key(&mut OsRng); 260 - let plaintext = b"hello opake"; 261 - let payload = encrypt_blob(&key, plaintext, &mut OsRng).unwrap(); 262 - let decrypted = decrypt_blob(&key, &payload).unwrap(); 263 - assert_eq!(decrypted, plaintext); 264 - } 265 - 266 - #[test] 267 - fn wrong_key_fails_decryption() { 268 - let key = generate_content_key(&mut OsRng); 269 - let wrong_key = generate_content_key(&mut OsRng); 270 - let payload = encrypt_blob(&key, b"secret", &mut OsRng).unwrap(); 271 - assert!(decrypt_blob(&wrong_key, &payload).is_err()); 272 - } 273 - 274 - #[test] 275 - fn empty_plaintext_roundtrips() { 276 - let key = generate_content_key(&mut OsRng); 277 - let payload = encrypt_blob(&key, b"", &mut OsRng).unwrap(); 278 - let decrypted = decrypt_blob(&key, &payload).unwrap(); 279 - assert!(decrypted.is_empty()); 280 - } 281 - 282 - #[test] 283 - fn ciphertext_differs_from_plaintext() { 284 - let key = generate_content_key(&mut OsRng); 285 - let plaintext = b"not encrypted i promise"; 286 - let payload = encrypt_blob(&key, plaintext, &mut OsRng).unwrap(); 287 - assert_ne!(payload.ciphertext, plaintext); 288 - } 289 - 290 - #[test] 291 - fn unique_nonces_per_encryption() { 292 - let key = generate_content_key(&mut OsRng); 293 - let a = encrypt_blob(&key, b"same", &mut OsRng).unwrap(); 294 - let b = encrypt_blob(&key, b"same", &mut OsRng).unwrap(); 295 - assert_ne!(a.nonce, b.nonce); 296 - } 297 - 298 - #[test] 299 - fn tampered_ciphertext_fails() { 300 - let key = generate_content_key(&mut OsRng); 301 - let mut payload = encrypt_blob(&key, b"integrity", &mut OsRng).unwrap(); 302 - payload.ciphertext[0] ^= 0xff; 303 - assert!(decrypt_blob(&key, &payload).is_err()); 304 - } 305 - 306 - #[test] 307 - fn large_payload_roundtrips() { 308 - let key = generate_content_key(&mut OsRng); 309 - let plaintext = vec![0xAB_u8; 1_000_000]; 310 - let payload = encrypt_blob(&key, &plaintext, &mut OsRng).unwrap(); 311 - let decrypted = decrypt_blob(&key, &payload).unwrap(); 312 - assert_eq!(decrypted, plaintext); 313 - } 314 - 315 - #[test] 316 - fn tampered_nonce_fails() { 317 - let key = generate_content_key(&mut OsRng); 318 - let mut payload = encrypt_blob(&key, b"nonce matters", &mut OsRng).unwrap(); 319 - payload.nonce[0] ^= 0xff; 320 - assert!(decrypt_blob(&key, &payload).is_err()); 321 - } 322 - 323 - // -- Key wrapping tests (x25519-hkdf-a256kw) -- 324 - 325 - fn test_keypair() -> (StaticSecret, PublicKey) { 326 - let private = StaticSecret::random_from_rng(OsRng); 327 - let public = PublicKey::from(&private); 328 - (private, public) 329 - } 330 - 331 - #[test] 332 - fn wrap_unwrap_roundtrips() { 333 - let content_key = generate_content_key(&mut OsRng); 334 - let (private, public) = test_keypair(); 335 - 336 - let wrapped = 337 - wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 338 - let unwrapped = unwrap_key(&wrapped, &private.to_bytes()).unwrap(); 339 - 340 - assert_eq!(content_key.0, unwrapped.0); 341 - } 342 - 343 - #[test] 344 - fn wrap_produces_correct_algo() { 345 - let content_key = generate_content_key(&mut OsRng); 346 - let (_private, public) = test_keypair(); 347 - 348 - let wrapped = 349 - wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 350 - assert_eq!(wrapped.algo, "x25519-hkdf-a256kw"); 351 - assert_eq!(wrapped.did, "did:plc:test"); 352 - } 353 - 354 - #[test] 355 - fn wrap_ciphertext_is_expected_length() { 356 - let content_key = generate_content_key(&mut OsRng); 357 - let (_private, public) = test_keypair(); 358 - 359 - let wrapped = 360 - wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 361 - let decoded = BASE64.decode(&wrapped.ciphertext.encoded).unwrap(); 362 - assert_eq!(decoded.len(), CIPHERTEXT_LEN); 363 - } 364 - 365 - #[test] 366 - fn wrong_private_key_fails_unwrap() { 367 - let content_key = generate_content_key(&mut OsRng); 368 - let (_private, public) = test_keypair(); 369 - let (wrong_private, _) = test_keypair(); 370 - 371 - let wrapped = 372 - wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 373 - assert!(unwrap_key(&wrapped, &wrong_private.to_bytes()).is_err()); 374 - } 375 - 376 - #[test] 377 - fn tampered_wrapped_ciphertext_fails_unwrap() { 378 - let content_key = generate_content_key(&mut OsRng); 379 - let (private, public) = test_keypair(); 380 - 381 - let mut wrapped = 382 - wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 383 - let mut bytes = BASE64.decode(&wrapped.ciphertext.encoded).unwrap(); 384 - bytes[40] ^= 0xff; 385 - wrapped.ciphertext.encoded = BASE64.encode(&bytes); 386 - 387 - assert!(unwrap_key(&wrapped, &private.to_bytes()).is_err()); 388 - } 389 - 390 - #[test] 391 - fn each_wrap_produces_unique_ciphertext() { 392 - let content_key = generate_content_key(&mut OsRng); 393 - let (_private, public) = test_keypair(); 394 - 395 - let a = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 396 - let b = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 397 - assert_ne!(a.ciphertext.encoded, b.ciphertext.encoded); 398 - } 399 - 400 - #[test] 401 - fn create_group_key_wraps_to_all_members() { 402 - let (priv_a, pub_a) = test_keypair(); 403 - let (priv_b, pub_b) = test_keypair(); 404 - 405 - let members: Vec<(&str, &[u8; 32])> = vec![ 406 - ("did:plc:alice", pub_a.as_bytes()), 407 - ("did:plc:bob", pub_b.as_bytes()), 408 - ]; 409 - 410 - let (group_key, wrapped_keys) = create_group_key(&members, &mut OsRng).unwrap(); 411 - assert_eq!(wrapped_keys.len(), 2); 412 - assert_eq!(wrapped_keys[0].did, "did:plc:alice"); 413 - assert_eq!(wrapped_keys[1].did, "did:plc:bob"); 414 - 415 - let unwrapped_a = unwrap_key(&wrapped_keys[0], &priv_a.to_bytes()).unwrap(); 416 - let unwrapped_b = unwrap_key(&wrapped_keys[1], &priv_b.to_bytes()).unwrap(); 417 - assert_eq!(group_key.0, unwrapped_a.0); 418 - assert_eq!(group_key.0, unwrapped_b.0); 419 - } 420 - 421 - // -- Keyring wrapping (symmetric AES-KW) -- 422 - 423 - #[test] 424 - fn keyring_content_key_roundtrips() { 425 - let group_key = generate_content_key(&mut OsRng); 426 - let content_key = generate_content_key(&mut OsRng); 427 - let wrapped = wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 428 - let unwrapped = unwrap_content_key_from_keyring(&wrapped, &group_key).unwrap(); 429 - assert_eq!(content_key.0, unwrapped.0); 430 - } 431 - 432 - #[test] 433 - fn keyring_wrapped_key_is_expected_length() { 434 - let group_key = generate_content_key(&mut OsRng); 435 - let content_key = generate_content_key(&mut OsRng); 436 - let wrapped = wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 437 - assert_eq!(wrapped.len(), WRAPPED_KEY_LEN); 438 - } 439 - 440 - #[test] 441 - fn keyring_wrong_group_key_fails() { 442 - let group_key = generate_content_key(&mut OsRng); 443 - let wrong_key = generate_content_key(&mut OsRng); 444 - let content_key = generate_content_key(&mut OsRng); 445 - let wrapped = wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 446 - assert!(unwrap_content_key_from_keyring(&wrapped, &wrong_key).is_err()); 447 - } 448 - 449 - #[test] 450 - fn keyring_wrong_length_input_fails() { 451 - let group_key = generate_content_key(&mut OsRng); 452 - assert!(unwrap_content_key_from_keyring(&[0u8; 10], &group_key).is_err()); 453 - assert!(unwrap_content_key_from_keyring(&[0u8; 64], &group_key).is_err()); 454 - assert!(unwrap_content_key_from_keyring(&[], &group_key).is_err()); 455 - } 456 - } 251 + #[path = "crypto_tests.rs"] 252 + mod tests;
+200
crates/opake-core/src/crypto_tests.rs
··· 1 + use super::*; 2 + use aes_gcm::aead::rand_core::OsRng; 3 + 4 + // -- Content encryption tests (AES-256-GCM) -- 5 + 6 + #[test] 7 + fn roundtrip_encrypt_decrypt() { 8 + let key = generate_content_key(&mut OsRng); 9 + let plaintext = b"hello opake"; 10 + let payload = encrypt_blob(&key, plaintext, &mut OsRng).unwrap(); 11 + let decrypted = decrypt_blob(&key, &payload).unwrap(); 12 + assert_eq!(decrypted, plaintext); 13 + } 14 + 15 + #[test] 16 + fn wrong_key_fails_decryption() { 17 + let key = generate_content_key(&mut OsRng); 18 + let wrong_key = generate_content_key(&mut OsRng); 19 + let payload = encrypt_blob(&key, b"secret", &mut OsRng).unwrap(); 20 + assert!(decrypt_blob(&wrong_key, &payload).is_err()); 21 + } 22 + 23 + #[test] 24 + fn empty_plaintext_roundtrips() { 25 + let key = generate_content_key(&mut OsRng); 26 + let payload = encrypt_blob(&key, b"", &mut OsRng).unwrap(); 27 + let decrypted = decrypt_blob(&key, &payload).unwrap(); 28 + assert!(decrypted.is_empty()); 29 + } 30 + 31 + #[test] 32 + fn ciphertext_differs_from_plaintext() { 33 + let key = generate_content_key(&mut OsRng); 34 + let plaintext = b"not encrypted i promise"; 35 + let payload = encrypt_blob(&key, plaintext, &mut OsRng).unwrap(); 36 + assert_ne!(payload.ciphertext, plaintext); 37 + } 38 + 39 + #[test] 40 + fn unique_nonces_per_encryption() { 41 + let key = generate_content_key(&mut OsRng); 42 + let a = encrypt_blob(&key, b"same", &mut OsRng).unwrap(); 43 + let b = encrypt_blob(&key, b"same", &mut OsRng).unwrap(); 44 + assert_ne!(a.nonce, b.nonce); 45 + } 46 + 47 + #[test] 48 + fn tampered_ciphertext_fails() { 49 + let key = generate_content_key(&mut OsRng); 50 + let mut payload = encrypt_blob(&key, b"integrity", &mut OsRng).unwrap(); 51 + payload.ciphertext[0] ^= 0xff; 52 + assert!(decrypt_blob(&key, &payload).is_err()); 53 + } 54 + 55 + #[test] 56 + fn large_payload_roundtrips() { 57 + let key = generate_content_key(&mut OsRng); 58 + let plaintext = vec![0xAB_u8; 1_000_000]; 59 + let payload = encrypt_blob(&key, &plaintext, &mut OsRng).unwrap(); 60 + let decrypted = decrypt_blob(&key, &payload).unwrap(); 61 + assert_eq!(decrypted, plaintext); 62 + } 63 + 64 + #[test] 65 + fn tampered_nonce_fails() { 66 + let key = generate_content_key(&mut OsRng); 67 + let mut payload = encrypt_blob(&key, b"nonce matters", &mut OsRng).unwrap(); 68 + payload.nonce[0] ^= 0xff; 69 + assert!(decrypt_blob(&key, &payload).is_err()); 70 + } 71 + 72 + // -- Key wrapping tests (x25519-hkdf-a256kw) -- 73 + 74 + fn test_keypair() -> (StaticSecret, PublicKey) { 75 + let private = StaticSecret::random_from_rng(OsRng); 76 + let public = PublicKey::from(&private); 77 + (private, public) 78 + } 79 + 80 + #[test] 81 + fn wrap_unwrap_roundtrips() { 82 + let content_key = generate_content_key(&mut OsRng); 83 + let (private, public) = test_keypair(); 84 + 85 + let wrapped = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 86 + let unwrapped = unwrap_key(&wrapped, &private.to_bytes()).unwrap(); 87 + 88 + assert_eq!(content_key.0, unwrapped.0); 89 + } 90 + 91 + #[test] 92 + fn wrap_produces_correct_algo() { 93 + let content_key = generate_content_key(&mut OsRng); 94 + let (_private, public) = test_keypair(); 95 + 96 + let wrapped = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 97 + assert_eq!(wrapped.algo, "x25519-hkdf-a256kw"); 98 + assert_eq!(wrapped.did, "did:plc:test"); 99 + } 100 + 101 + #[test] 102 + fn wrap_ciphertext_is_expected_length() { 103 + let content_key = generate_content_key(&mut OsRng); 104 + let (_private, public) = test_keypair(); 105 + 106 + let wrapped = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 107 + let decoded = BASE64.decode(&wrapped.ciphertext.encoded).unwrap(); 108 + assert_eq!(decoded.len(), CIPHERTEXT_LEN); 109 + } 110 + 111 + #[test] 112 + fn wrong_private_key_fails_unwrap() { 113 + let content_key = generate_content_key(&mut OsRng); 114 + let (_private, public) = test_keypair(); 115 + let (wrong_private, _) = test_keypair(); 116 + 117 + let wrapped = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 118 + assert!(unwrap_key(&wrapped, &wrong_private.to_bytes()).is_err()); 119 + } 120 + 121 + #[test] 122 + fn tampered_wrapped_ciphertext_fails_unwrap() { 123 + let content_key = generate_content_key(&mut OsRng); 124 + let (private, public) = test_keypair(); 125 + 126 + let mut wrapped = 127 + wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 128 + let mut bytes = BASE64.decode(&wrapped.ciphertext.encoded).unwrap(); 129 + bytes[40] ^= 0xff; 130 + wrapped.ciphertext.encoded = BASE64.encode(&bytes); 131 + 132 + assert!(unwrap_key(&wrapped, &private.to_bytes()).is_err()); 133 + } 134 + 135 + #[test] 136 + fn each_wrap_produces_unique_ciphertext() { 137 + let content_key = generate_content_key(&mut OsRng); 138 + let (_private, public) = test_keypair(); 139 + 140 + let a = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 141 + let b = wrap_key(&content_key, public.as_bytes(), "did:plc:test", &mut OsRng).unwrap(); 142 + assert_ne!(a.ciphertext.encoded, b.ciphertext.encoded); 143 + } 144 + 145 + #[test] 146 + fn create_group_key_wraps_to_all_members() { 147 + let (priv_a, pub_a) = test_keypair(); 148 + let (priv_b, pub_b) = test_keypair(); 149 + 150 + let members: Vec<(&str, &[u8; 32])> = vec![ 151 + ("did:plc:alice", pub_a.as_bytes()), 152 + ("did:plc:bob", pub_b.as_bytes()), 153 + ]; 154 + 155 + let (group_key, wrapped_keys) = create_group_key(&members, &mut OsRng).unwrap(); 156 + assert_eq!(wrapped_keys.len(), 2); 157 + assert_eq!(wrapped_keys[0].did, "did:plc:alice"); 158 + assert_eq!(wrapped_keys[1].did, "did:plc:bob"); 159 + 160 + let unwrapped_a = unwrap_key(&wrapped_keys[0], &priv_a.to_bytes()).unwrap(); 161 + let unwrapped_b = unwrap_key(&wrapped_keys[1], &priv_b.to_bytes()).unwrap(); 162 + assert_eq!(group_key.0, unwrapped_a.0); 163 + assert_eq!(group_key.0, unwrapped_b.0); 164 + } 165 + 166 + // -- Keyring wrapping (symmetric AES-KW) -- 167 + 168 + #[test] 169 + fn keyring_content_key_roundtrips() { 170 + let group_key = generate_content_key(&mut OsRng); 171 + let content_key = generate_content_key(&mut OsRng); 172 + let wrapped = wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 173 + let unwrapped = unwrap_content_key_from_keyring(&wrapped, &group_key).unwrap(); 174 + assert_eq!(content_key.0, unwrapped.0); 175 + } 176 + 177 + #[test] 178 + fn keyring_wrapped_key_is_expected_length() { 179 + let group_key = generate_content_key(&mut OsRng); 180 + let content_key = generate_content_key(&mut OsRng); 181 + let wrapped = wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 182 + assert_eq!(wrapped.len(), WRAPPED_KEY_LEN); 183 + } 184 + 185 + #[test] 186 + fn keyring_wrong_group_key_fails() { 187 + let group_key = generate_content_key(&mut OsRng); 188 + let wrong_key = generate_content_key(&mut OsRng); 189 + let content_key = generate_content_key(&mut OsRng); 190 + let wrapped = wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 191 + assert!(unwrap_content_key_from_keyring(&wrapped, &wrong_key).is_err()); 192 + } 193 + 194 + #[test] 195 + fn keyring_wrong_length_input_fails() { 196 + let group_key = generate_content_key(&mut OsRng); 197 + assert!(unwrap_content_key_from_keyring(&[0u8; 10], &group_key).is_err()); 198 + assert!(unwrap_content_key_from_keyring(&[0u8; 64], &group_key).is_err()); 199 + assert!(unwrap_content_key_from_keyring(&[], &group_key).is_err()); 200 + }
+2 -394
crates/opake-core/src/documents/download_keyring.rs
··· 149 149 } 150 150 151 151 #[cfg(test)] 152 - mod tests { 153 - use super::*; 154 - use crate::client::HttpResponse; 155 - use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 156 - use crate::records::{AtBytes, BlobRef, CidLink, KeyringEncryption, KeyringRef, WrappedKey}; 157 - use crate::test_utils::MockTransport; 158 - use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 159 - 160 - const OWNER_DID: &str = "did:plc:owner"; 161 - const OWNER_PDS: &str = "https://pds.owner.example.com"; 162 - const MEMBER_DID: &str = "did:plc:member"; 163 - const KR_RKEY: &str = "kr1"; 164 - const DOC_URI: &str = "at://did:plc:owner/app.opake.cloud.document/doc1"; 165 - const KR_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 166 - 167 - fn did_document_response() -> HttpResponse { 168 - let body = serde_json::json!({ 169 - "id": OWNER_DID, 170 - "alsoKnownAs": ["at://owner.test"], 171 - "service": [{ 172 - "id": "#atproto_pds", 173 - "type": "AtprotoPersonalDataServer", 174 - "serviceEndpoint": OWNER_PDS, 175 - }] 176 - }); 177 - HttpResponse { 178 - status: 200, 179 - body: serde_json::to_vec(&body).unwrap(), 180 - } 181 - } 182 - 183 - fn record_response(uri: &str, value: &impl serde::Serialize) -> HttpResponse { 184 - let body = serde_json::json!({ 185 - "uri": uri, 186 - "cid": "bafyrecord", 187 - "value": value, 188 - }); 189 - HttpResponse { 190 - status: 200, 191 - body: serde_json::to_vec(&body).unwrap(), 192 - } 193 - } 194 - 195 - fn blob_response(data: &[u8]) -> HttpResponse { 196 - HttpResponse { 197 - status: 200, 198 - body: data.to_vec(), 199 - } 200 - } 201 - 202 - /// Encrypt plaintext under a keyring group key and wrap the group key 203 - /// to a member. Returns everything needed to build test fixtures. 204 - struct KeyringFixture { 205 - ciphertext: Vec<u8>, 206 - nonce: [u8; 12], 207 - group_key: ContentKey, 208 - owner_wrapped_gk: WrappedKey, 209 - member_wrapped_gk: WrappedKey, 210 - wrapped_content_key_bytes: Vec<u8>, 211 - } 212 - 213 - fn create_keyring_fixture( 214 - plaintext: &[u8], 215 - owner_pubkey: &[u8; 32], 216 - member_pubkey: &[u8; 32], 217 - ) -> KeyringFixture { 218 - let rng = &mut OsRng; 219 - 220 - // Generate group key and wrap to owner + member 221 - let group_key = crypto::generate_content_key(rng); 222 - let owner_wrapped_gk = crypto::wrap_key(&group_key, owner_pubkey, OWNER_DID, rng).unwrap(); 223 - let member_wrapped_gk = 224 - crypto::wrap_key(&group_key, member_pubkey, MEMBER_DID, rng).unwrap(); 225 - 226 - // Generate content key, encrypt blob, wrap CK under group key 227 - let content_key = crypto::generate_content_key(rng); 228 - let payload = crypto::encrypt_blob(&content_key, plaintext, rng).unwrap(); 229 - let wrapped_content_key_bytes = 230 - crypto::wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 231 - 232 - KeyringFixture { 233 - ciphertext: payload.ciphertext, 234 - nonce: payload.nonce, 235 - group_key, 236 - owner_wrapped_gk, 237 - member_wrapped_gk, 238 - wrapped_content_key_bytes, 239 - } 240 - } 241 - 242 - fn keyring_document_at_rotation(fixture: &KeyringFixture, rotation: u64) -> Document { 243 - Document { 244 - mime_type: Some("text/plain".into()), 245 - size: Some(42), 246 - ..Document::new( 247 - "keyring-file.txt".into(), 248 - BlobRef { 249 - blob_type: "blob".into(), 250 - reference: CidLink { 251 - cid: "bafyblob".into(), 252 - }, 253 - mime_type: "application/octet-stream".into(), 254 - size: fixture.ciphertext.len() as u64, 255 - }, 256 - Encryption::Keyring(KeyringEncryption { 257 - keyring_ref: KeyringRef { 258 - keyring: KR_URI.into(), 259 - wrapped_content_key: AtBytes { 260 - encoded: BASE64.encode(&fixture.wrapped_content_key_bytes), 261 - }, 262 - rotation, 263 - }, 264 - algo: "aes-256-gcm".into(), 265 - nonce: AtBytes { 266 - encoded: BASE64.encode(fixture.nonce), 267 - }, 268 - }), 269 - "2026-03-01T00:00:00Z".into(), 270 - ) 271 - } 272 - } 273 - 274 - fn keyring_document(fixture: &KeyringFixture) -> Document { 275 - keyring_document_at_rotation(fixture, 0) 276 - } 277 - 278 - fn keyring_record(fixture: &KeyringFixture) -> Keyring { 279 - Keyring::new( 280 - "test-keyring".into(), 281 - vec![ 282 - fixture.owner_wrapped_gk.clone(), 283 - fixture.member_wrapped_gk.clone(), 284 - ], 285 - "2026-03-01T00:00:00Z".into(), 286 - ) 287 - } 288 - 289 - fn member_keypair() -> ([u8; 32], [u8; 32]) { 290 - let secret = X25519DalekStaticSecret::random_from_rng(OsRng); 291 - let public = X25519DalekPublicKey::from(&secret); 292 - (*public.as_bytes(), secret.to_bytes()) 293 - } 294 - 295 - fn owner_keypair() -> ([u8; 32], [u8; 32]) { 296 - let secret = X25519DalekStaticSecret::random_from_rng(OsRng); 297 - let public = X25519DalekPublicKey::from(&secret); 298 - (*public.as_bytes(), secret.to_bytes()) 299 - } 300 - 301 - #[tokio::test] 302 - async fn roundtrip() { 303 - let (owner_pub, _) = owner_keypair(); 304 - let (member_pub, member_priv) = member_keypair(); 305 - 306 - let plaintext = b"shared keyring content"; 307 - let fixture = create_keyring_fixture(plaintext, &owner_pub, &member_pub); 308 - let doc = keyring_document(&fixture); 309 - let keyring = keyring_record(&fixture); 310 - 311 - let mock = MockTransport::new(); 312 - mock.enqueue(did_document_response()); 313 - mock.enqueue(record_response(DOC_URI, &doc)); 314 - mock.enqueue(record_response(KR_URI, &keyring)); 315 - mock.enqueue(blob_response(&fixture.ciphertext)); 316 - 317 - let result = download_from_keyring_member(&mock, MEMBER_DID, &member_priv, DOC_URI) 318 - .await 319 - .unwrap(); 320 - 321 - assert_eq!(result.filename, "keyring-file.txt"); 322 - assert_eq!(result.plaintext, plaintext); 323 - assert_eq!(result.keyring_rkey, KR_RKEY); 324 - 325 - let reqs = mock.requests(); 326 - assert_eq!(reqs.len(), 4); 327 - assert!(reqs[0].url.contains("plc.directory"), "DID resolution"); 328 - assert!(reqs[1].url.contains("getRecord"), "document fetch"); 329 - assert!(reqs[1].url.contains(OWNER_PDS), "from owner PDS"); 330 - assert!(reqs[2].url.contains("getRecord"), "keyring fetch"); 331 - assert!(reqs[3].url.contains("getBlob"), "blob fetch"); 332 - } 333 - 334 - #[tokio::test] 335 - async fn rejects_non_document_uri() { 336 - let mock = MockTransport::new(); 337 - let err = download_from_keyring_member( 338 - &mock, 339 - MEMBER_DID, 340 - &[0u8; 32], 341 - "at://did:plc:x/app.opake.cloud.grant/abc", 342 - ) 343 - .await 344 - .unwrap_err(); 345 - assert!( 346 - err.to_string().contains("document"), 347 - "expected document error, got: {err}" 348 - ); 349 - } 350 - 351 - #[tokio::test] 352 - async fn rejects_direct_encrypted_document() { 353 - let (member_pub, member_priv) = member_keypair(); 354 - 355 - // Build a direct-encrypted document (not keyring) 356 - let content_key = crypto::generate_content_key(&mut OsRng); 357 - let payload = crypto::encrypt_blob(&content_key, b"data", &mut OsRng).unwrap(); 358 - let wrapped = crypto::wrap_key(&content_key, &member_pub, MEMBER_DID, &mut OsRng).unwrap(); 359 - 360 - let doc = Document { 361 - mime_type: Some("text/plain".into()), 362 - size: Some(4), 363 - ..Document::new( 364 - "direct-file.txt".into(), 365 - BlobRef { 366 - blob_type: "blob".into(), 367 - reference: CidLink { 368 - cid: "bafyblob".into(), 369 - }, 370 - mime_type: "application/octet-stream".into(), 371 - size: payload.ciphertext.len() as u64, 372 - }, 373 - Encryption::Direct(records::DirectEncryption { 374 - envelope: records::EncryptionEnvelope { 375 - algo: "aes-256-gcm".into(), 376 - nonce: AtBytes { 377 - encoded: BASE64.encode(payload.nonce), 378 - }, 379 - keys: vec![wrapped], 380 - }, 381 - }), 382 - "2026-03-01T00:00:00Z".into(), 383 - ) 384 - }; 385 - 386 - let mock = MockTransport::new(); 387 - mock.enqueue(did_document_response()); 388 - mock.enqueue(record_response(DOC_URI, &doc)); 389 - 390 - let err = download_from_keyring_member(&mock, MEMBER_DID, &member_priv, DOC_URI) 391 - .await 392 - .unwrap_err(); 393 - assert!( 394 - err.to_string().contains("direct encryption"), 395 - "expected direct encryption error, got: {err}" 396 - ); 397 - } 398 - 399 - #[tokio::test] 400 - async fn rejects_non_member() { 401 - let (owner_pub, _) = owner_keypair(); 402 - let (member_pub, _) = member_keypair(); 403 - let (_, outsider_priv) = member_keypair(); 404 - 405 - let fixture = create_keyring_fixture(b"secret", &owner_pub, &member_pub); 406 - let doc = keyring_document(&fixture); 407 - let keyring = keyring_record(&fixture); 408 - 409 - let mock = MockTransport::new(); 410 - mock.enqueue(did_document_response()); 411 - mock.enqueue(record_response(DOC_URI, &doc)); 412 - mock.enqueue(record_response(KR_URI, &keyring)); 413 - 414 - let err = download_from_keyring_member(&mock, "did:plc:outsider", &outsider_priv, DOC_URI) 415 - .await 416 - .unwrap_err(); 417 - assert!( 418 - err.to_string().contains("not a member"), 419 - "expected member error, got: {err}" 420 - ); 421 - } 422 - 423 - #[tokio::test] 424 - async fn returns_group_key_for_caching() { 425 - let (owner_pub, _) = owner_keypair(); 426 - let (member_pub, member_priv) = member_keypair(); 427 - 428 - let plaintext = b"cache test content"; 429 - let fixture = create_keyring_fixture(plaintext, &owner_pub, &member_pub); 430 - 431 - // Save the original group key bytes for comparison 432 - let original_gk_bytes = fixture.group_key.0; 433 - 434 - let doc = keyring_document(&fixture); 435 - let keyring = keyring_record(&fixture); 436 - 437 - let mock = MockTransport::new(); 438 - mock.enqueue(did_document_response()); 439 - mock.enqueue(record_response(DOC_URI, &doc)); 440 - mock.enqueue(record_response(KR_URI, &keyring)); 441 - mock.enqueue(blob_response(&fixture.ciphertext)); 442 - 443 - let result = download_from_keyring_member(&mock, MEMBER_DID, &member_priv, DOC_URI) 444 - .await 445 - .unwrap(); 446 - 447 - // The returned group key should match what was used to wrap content keys 448 - assert_eq!(result.group_key.0, original_gk_bytes); 449 - 450 - // Verify the group key can unwrap the content key independently 451 - let ck = crypto::unwrap_content_key_from_keyring( 452 - &fixture.wrapped_content_key_bytes, 453 - &result.group_key, 454 - ) 455 - .expect("cached group key should unwrap content key"); 456 - 457 - // Re-decrypt using the independently unwrapped content key 458 - let re_decrypted = crypto::decrypt_blob( 459 - &ck, 460 - &crypto::EncryptedPayload { 461 - ciphertext: fixture.ciphertext.clone(), 462 - nonce: fixture.nonce, 463 - }, 464 - ) 465 - .unwrap(); 466 - assert_eq!(re_decrypted, plaintext); 467 - } 468 - 469 - #[tokio::test] 470 - async fn download_from_previous_rotation_via_history() { 471 - let (owner_pub, _) = owner_keypair(); 472 - let (member_pub, member_priv) = member_keypair(); 473 - 474 - let plaintext = b"pre-rotation content"; 475 - let fixture = create_keyring_fixture(plaintext, &owner_pub, &member_pub); 476 - 477 - // Document was uploaded at rotation 0 478 - let doc = keyring_document_at_rotation(&fixture, 0); 479 - 480 - // Keyring has since rotated to 1 — rotation 0 members are in key_history 481 - let mut keyring = Keyring { 482 - rotation: 1, 483 - members: vec![fixture.owner_wrapped_gk.clone()], 484 - key_history: vec![records::KeyHistoryEntry { 485 - rotation: 0, 486 - members: vec![ 487 - fixture.owner_wrapped_gk.clone(), 488 - fixture.member_wrapped_gk.clone(), 489 - ], 490 - }], 491 - ..keyring_record(&fixture) 492 - }; 493 - // Suppress the members from new() since we overwrote them 494 - let _ = &mut keyring; 495 - 496 - let mock = MockTransport::new(); 497 - mock.enqueue(did_document_response()); 498 - mock.enqueue(record_response(DOC_URI, &doc)); 499 - mock.enqueue(record_response(KR_URI, &keyring)); 500 - mock.enqueue(blob_response(&fixture.ciphertext)); 501 - 502 - let result = download_from_keyring_member(&mock, MEMBER_DID, &member_priv, DOC_URI) 503 - .await 504 - .unwrap(); 505 - 506 - assert_eq!(result.plaintext, plaintext); 507 - assert_eq!(result.rotation, 1); // returns current keyring rotation for caching 508 - } 509 - 510 - #[tokio::test] 511 - async fn rejects_member_not_present_at_historical_rotation() { 512 - let (owner_pub, _) = owner_keypair(); 513 - let (member_pub, _) = member_keypair(); 514 - let (_, outsider_priv) = member_keypair(); 515 - 516 - let fixture = create_keyring_fixture(b"data", &owner_pub, &member_pub); 517 - 518 - // Document encrypted at rotation 0 519 - let doc = keyring_document_at_rotation(&fixture, 0); 520 - 521 - // Keyring is at rotation 1, history has rotation 0 with only owner 522 - let keyring = Keyring { 523 - rotation: 1, 524 - members: vec![fixture.owner_wrapped_gk.clone()], 525 - key_history: vec![records::KeyHistoryEntry { 526 - rotation: 0, 527 - members: vec![fixture.owner_wrapped_gk.clone()], 528 - }], 529 - ..keyring_record(&fixture) 530 - }; 531 - 532 - let mock = MockTransport::new(); 533 - mock.enqueue(did_document_response()); 534 - mock.enqueue(record_response(DOC_URI, &doc)); 535 - mock.enqueue(record_response(KR_URI, &keyring)); 536 - 537 - let err = download_from_keyring_member(&mock, "did:plc:outsider", &outsider_priv, DOC_URI) 538 - .await 539 - .unwrap_err(); 540 - assert!( 541 - err.to_string().contains("not a member"), 542 - "expected member error, got: {err}" 543 - ); 544 - } 545 - } 152 + #[path = "download_keyring_tests.rs"] 153 + mod tests;
+391
crates/opake-core/src/documents/download_keyring_tests.rs
··· 1 + use super::*; 2 + use crate::client::HttpResponse; 3 + use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 4 + use crate::records::{AtBytes, BlobRef, CidLink, KeyringEncryption, KeyringRef, WrappedKey}; 5 + use crate::test_utils::MockTransport; 6 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 7 + 8 + const OWNER_DID: &str = "did:plc:owner"; 9 + const OWNER_PDS: &str = "https://pds.owner.example.com"; 10 + const MEMBER_DID: &str = "did:plc:member"; 11 + const KR_RKEY: &str = "kr1"; 12 + const DOC_URI: &str = "at://did:plc:owner/app.opake.cloud.document/doc1"; 13 + const KR_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 14 + 15 + fn did_document_response() -> HttpResponse { 16 + let body = serde_json::json!({ 17 + "id": OWNER_DID, 18 + "alsoKnownAs": ["at://owner.test"], 19 + "service": [{ 20 + "id": "#atproto_pds", 21 + "type": "AtprotoPersonalDataServer", 22 + "serviceEndpoint": OWNER_PDS, 23 + }] 24 + }); 25 + HttpResponse { 26 + status: 200, 27 + body: serde_json::to_vec(&body).unwrap(), 28 + } 29 + } 30 + 31 + fn record_response(uri: &str, value: &impl serde::Serialize) -> HttpResponse { 32 + let body = serde_json::json!({ 33 + "uri": uri, 34 + "cid": "bafyrecord", 35 + "value": value, 36 + }); 37 + HttpResponse { 38 + status: 200, 39 + body: serde_json::to_vec(&body).unwrap(), 40 + } 41 + } 42 + 43 + fn blob_response(data: &[u8]) -> HttpResponse { 44 + HttpResponse { 45 + status: 200, 46 + body: data.to_vec(), 47 + } 48 + } 49 + 50 + /// Encrypt plaintext under a keyring group key and wrap the group key 51 + /// to a member. Returns everything needed to build test fixtures. 52 + struct KeyringFixture { 53 + ciphertext: Vec<u8>, 54 + nonce: [u8; 12], 55 + group_key: ContentKey, 56 + owner_wrapped_gk: WrappedKey, 57 + member_wrapped_gk: WrappedKey, 58 + wrapped_content_key_bytes: Vec<u8>, 59 + } 60 + 61 + fn create_keyring_fixture( 62 + plaintext: &[u8], 63 + owner_pubkey: &[u8; 32], 64 + member_pubkey: &[u8; 32], 65 + ) -> KeyringFixture { 66 + let rng = &mut OsRng; 67 + 68 + // Generate group key and wrap to owner + member 69 + let group_key = crypto::generate_content_key(rng); 70 + let owner_wrapped_gk = crypto::wrap_key(&group_key, owner_pubkey, OWNER_DID, rng).unwrap(); 71 + let member_wrapped_gk = crypto::wrap_key(&group_key, member_pubkey, MEMBER_DID, rng).unwrap(); 72 + 73 + // Generate content key, encrypt blob, wrap CK under group key 74 + let content_key = crypto::generate_content_key(rng); 75 + let payload = crypto::encrypt_blob(&content_key, plaintext, rng).unwrap(); 76 + let wrapped_content_key_bytes = 77 + crypto::wrap_content_key_for_keyring(&content_key, &group_key).unwrap(); 78 + 79 + KeyringFixture { 80 + ciphertext: payload.ciphertext, 81 + nonce: payload.nonce, 82 + group_key, 83 + owner_wrapped_gk, 84 + member_wrapped_gk, 85 + wrapped_content_key_bytes, 86 + } 87 + } 88 + 89 + fn keyring_document_at_rotation(fixture: &KeyringFixture, rotation: u64) -> Document { 90 + Document { 91 + mime_type: Some("text/plain".into()), 92 + size: Some(42), 93 + ..Document::new( 94 + "keyring-file.txt".into(), 95 + BlobRef { 96 + blob_type: "blob".into(), 97 + reference: CidLink { 98 + cid: "bafyblob".into(), 99 + }, 100 + mime_type: "application/octet-stream".into(), 101 + size: fixture.ciphertext.len() as u64, 102 + }, 103 + Encryption::Keyring(KeyringEncryption { 104 + keyring_ref: KeyringRef { 105 + keyring: KR_URI.into(), 106 + wrapped_content_key: AtBytes { 107 + encoded: BASE64.encode(&fixture.wrapped_content_key_bytes), 108 + }, 109 + rotation, 110 + }, 111 + algo: "aes-256-gcm".into(), 112 + nonce: AtBytes { 113 + encoded: BASE64.encode(fixture.nonce), 114 + }, 115 + }), 116 + "2026-03-01T00:00:00Z".into(), 117 + ) 118 + } 119 + } 120 + 121 + fn keyring_document(fixture: &KeyringFixture) -> Document { 122 + keyring_document_at_rotation(fixture, 0) 123 + } 124 + 125 + fn keyring_record(fixture: &KeyringFixture) -> Keyring { 126 + Keyring::new( 127 + "test-keyring".into(), 128 + vec![ 129 + fixture.owner_wrapped_gk.clone(), 130 + fixture.member_wrapped_gk.clone(), 131 + ], 132 + "2026-03-01T00:00:00Z".into(), 133 + ) 134 + } 135 + 136 + fn member_keypair() -> ([u8; 32], [u8; 32]) { 137 + let secret = X25519DalekStaticSecret::random_from_rng(OsRng); 138 + let public = X25519DalekPublicKey::from(&secret); 139 + (*public.as_bytes(), secret.to_bytes()) 140 + } 141 + 142 + fn owner_keypair() -> ([u8; 32], [u8; 32]) { 143 + let secret = X25519DalekStaticSecret::random_from_rng(OsRng); 144 + let public = X25519DalekPublicKey::from(&secret); 145 + (*public.as_bytes(), secret.to_bytes()) 146 + } 147 + 148 + #[tokio::test] 149 + async fn roundtrip() { 150 + let (owner_pub, _) = owner_keypair(); 151 + let (member_pub, member_priv) = member_keypair(); 152 + 153 + let plaintext = b"shared keyring content"; 154 + let fixture = create_keyring_fixture(plaintext, &owner_pub, &member_pub); 155 + let doc = keyring_document(&fixture); 156 + let keyring = keyring_record(&fixture); 157 + 158 + let mock = MockTransport::new(); 159 + mock.enqueue(did_document_response()); 160 + mock.enqueue(record_response(DOC_URI, &doc)); 161 + mock.enqueue(record_response(KR_URI, &keyring)); 162 + mock.enqueue(blob_response(&fixture.ciphertext)); 163 + 164 + let result = download_from_keyring_member(&mock, MEMBER_DID, &member_priv, DOC_URI) 165 + .await 166 + .unwrap(); 167 + 168 + assert_eq!(result.filename, "keyring-file.txt"); 169 + assert_eq!(result.plaintext, plaintext); 170 + assert_eq!(result.keyring_rkey, KR_RKEY); 171 + 172 + let reqs = mock.requests(); 173 + assert_eq!(reqs.len(), 4); 174 + assert!(reqs[0].url.contains("plc.directory"), "DID resolution"); 175 + assert!(reqs[1].url.contains("getRecord"), "document fetch"); 176 + assert!(reqs[1].url.contains(OWNER_PDS), "from owner PDS"); 177 + assert!(reqs[2].url.contains("getRecord"), "keyring fetch"); 178 + assert!(reqs[3].url.contains("getBlob"), "blob fetch"); 179 + } 180 + 181 + #[tokio::test] 182 + async fn rejects_non_document_uri() { 183 + let mock = MockTransport::new(); 184 + let err = download_from_keyring_member( 185 + &mock, 186 + MEMBER_DID, 187 + &[0u8; 32], 188 + "at://did:plc:x/app.opake.cloud.grant/abc", 189 + ) 190 + .await 191 + .unwrap_err(); 192 + assert!( 193 + err.to_string().contains("document"), 194 + "expected document error, got: {err}" 195 + ); 196 + } 197 + 198 + #[tokio::test] 199 + async fn rejects_direct_encrypted_document() { 200 + let (member_pub, member_priv) = member_keypair(); 201 + 202 + // Build a direct-encrypted document (not keyring) 203 + let content_key = crypto::generate_content_key(&mut OsRng); 204 + let payload = crypto::encrypt_blob(&content_key, b"data", &mut OsRng).unwrap(); 205 + let wrapped = crypto::wrap_key(&content_key, &member_pub, MEMBER_DID, &mut OsRng).unwrap(); 206 + 207 + let doc = Document { 208 + mime_type: Some("text/plain".into()), 209 + size: Some(4), 210 + ..Document::new( 211 + "direct-file.txt".into(), 212 + BlobRef { 213 + blob_type: "blob".into(), 214 + reference: CidLink { 215 + cid: "bafyblob".into(), 216 + }, 217 + mime_type: "application/octet-stream".into(), 218 + size: payload.ciphertext.len() as u64, 219 + }, 220 + Encryption::Direct(records::DirectEncryption { 221 + envelope: records::EncryptionEnvelope { 222 + algo: "aes-256-gcm".into(), 223 + nonce: AtBytes { 224 + encoded: BASE64.encode(payload.nonce), 225 + }, 226 + keys: vec![wrapped], 227 + }, 228 + }), 229 + "2026-03-01T00:00:00Z".into(), 230 + ) 231 + }; 232 + 233 + let mock = MockTransport::new(); 234 + mock.enqueue(did_document_response()); 235 + mock.enqueue(record_response(DOC_URI, &doc)); 236 + 237 + let err = download_from_keyring_member(&mock, MEMBER_DID, &member_priv, DOC_URI) 238 + .await 239 + .unwrap_err(); 240 + assert!( 241 + err.to_string().contains("direct encryption"), 242 + "expected direct encryption error, got: {err}" 243 + ); 244 + } 245 + 246 + #[tokio::test] 247 + async fn rejects_non_member() { 248 + let (owner_pub, _) = owner_keypair(); 249 + let (member_pub, _) = member_keypair(); 250 + let (_, outsider_priv) = member_keypair(); 251 + 252 + let fixture = create_keyring_fixture(b"secret", &owner_pub, &member_pub); 253 + let doc = keyring_document(&fixture); 254 + let keyring = keyring_record(&fixture); 255 + 256 + let mock = MockTransport::new(); 257 + mock.enqueue(did_document_response()); 258 + mock.enqueue(record_response(DOC_URI, &doc)); 259 + mock.enqueue(record_response(KR_URI, &keyring)); 260 + 261 + let err = download_from_keyring_member(&mock, "did:plc:outsider", &outsider_priv, DOC_URI) 262 + .await 263 + .unwrap_err(); 264 + assert!( 265 + err.to_string().contains("not a member"), 266 + "expected member error, got: {err}" 267 + ); 268 + } 269 + 270 + #[tokio::test] 271 + async fn returns_group_key_for_caching() { 272 + let (owner_pub, _) = owner_keypair(); 273 + let (member_pub, member_priv) = member_keypair(); 274 + 275 + let plaintext = b"cache test content"; 276 + let fixture = create_keyring_fixture(plaintext, &owner_pub, &member_pub); 277 + 278 + // Save the original group key bytes for comparison 279 + let original_gk_bytes = fixture.group_key.0; 280 + 281 + let doc = keyring_document(&fixture); 282 + let keyring = keyring_record(&fixture); 283 + 284 + let mock = MockTransport::new(); 285 + mock.enqueue(did_document_response()); 286 + mock.enqueue(record_response(DOC_URI, &doc)); 287 + mock.enqueue(record_response(KR_URI, &keyring)); 288 + mock.enqueue(blob_response(&fixture.ciphertext)); 289 + 290 + let result = download_from_keyring_member(&mock, MEMBER_DID, &member_priv, DOC_URI) 291 + .await 292 + .unwrap(); 293 + 294 + // The returned group key should match what was used to wrap content keys 295 + assert_eq!(result.group_key.0, original_gk_bytes); 296 + 297 + // Verify the group key can unwrap the content key independently 298 + let ck = crypto::unwrap_content_key_from_keyring( 299 + &fixture.wrapped_content_key_bytes, 300 + &result.group_key, 301 + ) 302 + .expect("cached group key should unwrap content key"); 303 + 304 + // Re-decrypt using the independently unwrapped content key 305 + let re_decrypted = crypto::decrypt_blob( 306 + &ck, 307 + &crypto::EncryptedPayload { 308 + ciphertext: fixture.ciphertext.clone(), 309 + nonce: fixture.nonce, 310 + }, 311 + ) 312 + .unwrap(); 313 + assert_eq!(re_decrypted, plaintext); 314 + } 315 + 316 + #[tokio::test] 317 + async fn download_from_previous_rotation_via_history() { 318 + let (owner_pub, _) = owner_keypair(); 319 + let (member_pub, member_priv) = member_keypair(); 320 + 321 + let plaintext = b"pre-rotation content"; 322 + let fixture = create_keyring_fixture(plaintext, &owner_pub, &member_pub); 323 + 324 + // Document was uploaded at rotation 0 325 + let doc = keyring_document_at_rotation(&fixture, 0); 326 + 327 + // Keyring has since rotated to 1 — rotation 0 members are in key_history 328 + let mut keyring = Keyring { 329 + rotation: 1, 330 + members: vec![fixture.owner_wrapped_gk.clone()], 331 + key_history: vec![records::KeyHistoryEntry { 332 + rotation: 0, 333 + members: vec![ 334 + fixture.owner_wrapped_gk.clone(), 335 + fixture.member_wrapped_gk.clone(), 336 + ], 337 + }], 338 + ..keyring_record(&fixture) 339 + }; 340 + // Suppress the members from new() since we overwrote them 341 + let _ = &mut keyring; 342 + 343 + let mock = MockTransport::new(); 344 + mock.enqueue(did_document_response()); 345 + mock.enqueue(record_response(DOC_URI, &doc)); 346 + mock.enqueue(record_response(KR_URI, &keyring)); 347 + mock.enqueue(blob_response(&fixture.ciphertext)); 348 + 349 + let result = download_from_keyring_member(&mock, MEMBER_DID, &member_priv, DOC_URI) 350 + .await 351 + .unwrap(); 352 + 353 + assert_eq!(result.plaintext, plaintext); 354 + assert_eq!(result.rotation, 1); // returns current keyring rotation for caching 355 + } 356 + 357 + #[tokio::test] 358 + async fn rejects_member_not_present_at_historical_rotation() { 359 + let (owner_pub, _) = owner_keypair(); 360 + let (member_pub, _) = member_keypair(); 361 + let (_, outsider_priv) = member_keypair(); 362 + 363 + let fixture = create_keyring_fixture(b"data", &owner_pub, &member_pub); 364 + 365 + // Document encrypted at rotation 0 366 + let doc = keyring_document_at_rotation(&fixture, 0); 367 + 368 + // Keyring is at rotation 1, history has rotation 0 with only owner 369 + let keyring = Keyring { 370 + rotation: 1, 371 + members: vec![fixture.owner_wrapped_gk.clone()], 372 + key_history: vec![records::KeyHistoryEntry { 373 + rotation: 0, 374 + members: vec![fixture.owner_wrapped_gk.clone()], 375 + }], 376 + ..keyring_record(&fixture) 377 + }; 378 + 379 + let mock = MockTransport::new(); 380 + mock.enqueue(did_document_response()); 381 + mock.enqueue(record_response(DOC_URI, &doc)); 382 + mock.enqueue(record_response(KR_URI, &keyring)); 383 + 384 + let err = download_from_keyring_member(&mock, "did:plc:outsider", &outsider_priv, DOC_URI) 385 + .await 386 + .unwrap_err(); 387 + assert!( 388 + err.to_string().contains("not a member"), 389 + "expected member error, got: {err}" 390 + ); 391 + }
+2 -155
crates/opake-core/src/keyrings/remove_member.rs
··· 88 88 } 89 89 90 90 #[cfg(test)] 91 - mod tests { 92 - use super::*; 93 - use crate::client::{HttpResponse, RequestBody, Session, XrpcClient}; 94 - use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 95 - use crate::records::{AtBytes, Keyring, WrappedKey}; 96 - use crate::test_utils::MockTransport; 97 - 98 - const TEST_DID: &str = "did:plc:owner"; 99 - const KEYRING_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 100 - 101 - fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 102 - let session = Session { 103 - did: TEST_DID.into(), 104 - handle: "owner.test".into(), 105 - access_jwt: "test-jwt".into(), 106 - refresh_jwt: "test-refresh".into(), 107 - }; 108 - XrpcClient::with_session(mock, "https://pds.test".into(), session) 109 - } 110 - 111 - fn test_keypair() -> (X25519PublicKey, [u8; 32]) { 112 - let secret = X25519DalekStaticSecret::random_from_rng(OsRng); 113 - let public = X25519DalekPublicKey::from(&secret); 114 - (public.to_bytes(), secret.to_bytes()) 115 - } 116 - 117 - fn two_member_keyring() -> Keyring { 118 - Keyring::new( 119 - "test-keyring".into(), 120 - vec![ 121 - WrappedKey { 122 - did: TEST_DID.into(), 123 - ciphertext: AtBytes { 124 - encoded: "AAAA".into(), 125 - }, 126 - algo: "x25519-hkdf-a256kw".into(), 127 - }, 128 - WrappedKey { 129 - did: "did:plc:bob".into(), 130 - ciphertext: AtBytes { 131 - encoded: "BBBB".into(), 132 - }, 133 - algo: "x25519-hkdf-a256kw".into(), 134 - }, 135 - ], 136 - "2026-03-01T00:00:00Z".into(), 137 - ) 138 - } 139 - 140 - fn get_record_response(keyring: &Keyring) -> HttpResponse { 141 - HttpResponse { 142 - status: 200, 143 - body: serde_json::to_vec(&serde_json::json!({ 144 - "uri": KEYRING_URI, 145 - "cid": "bafykeyring", 146 - "value": keyring, 147 - })) 148 - .unwrap(), 149 - } 150 - } 151 - 152 - fn put_record_response() -> HttpResponse { 153 - HttpResponse { 154 - status: 200, 155 - body: serde_json::to_vec(&serde_json::json!({ 156 - "uri": KEYRING_URI, 157 - "cid": "bafyrotated", 158 - })) 159 - .unwrap(), 160 - } 161 - } 162 - 163 - #[tokio::test] 164 - async fn happy_path_removes_and_rotates() { 165 - let keyring = two_member_keyring(); 166 - let (owner_pubkey, owner_privkey) = test_keypair(); 167 - 168 - let mock = MockTransport::new(); 169 - mock.enqueue(get_record_response(&keyring)); 170 - mock.enqueue(put_record_response()); 171 - 172 - let remaining = [MemberKey { 173 - did: TEST_DID, 174 - public_key: &owner_pubkey, 175 - }]; 176 - 177 - let mut client = mock_client(mock.clone()); 178 - let (new_group_key, new_rotation) = remove_member( 179 - &mut client, 180 - KEYRING_URI, 181 - "did:plc:bob", 182 - &remaining, 183 - "2026-03-01T12:00:00Z", 184 - &mut OsRng, 185 - ) 186 - .await 187 - .unwrap(); 188 - 189 - assert_eq!(new_rotation, 1); 190 - 191 - let reqs = mock.requests(); 192 - assert_eq!(reqs.len(), 2); 193 - 194 - match &reqs[1].body { 195 - Some(RequestBody::Json(v)) => { 196 - let updated: Keyring = serde_json::from_value(v["record"].clone()).unwrap(); 197 - assert_eq!(updated.members.len(), 1); 198 - assert_eq!(updated.members[0].did, TEST_DID); 199 - assert_eq!(updated.rotation, 1); 200 - assert!(updated.modified_at.is_some()); 201 - 202 - // key_history should contain one entry for rotation 0 203 - assert_eq!(updated.key_history.len(), 1); 204 - assert_eq!(updated.key_history[0].rotation, 0); 205 - // The removed member (bob) should NOT be in history — 206 - // only the remaining owner's wrapped key is preserved 207 - assert_eq!(updated.key_history[0].members.len(), 1); 208 - assert_eq!(updated.key_history[0].members[0].did, TEST_DID); 209 - 210 - // Owner can unwrap the new group key 211 - let unwrapped = crypto::unwrap_key(&updated.members[0], &owner_privkey).unwrap(); 212 - assert_eq!(unwrapped.0, new_group_key.0); 213 - } 214 - _ => panic!("expected JSON body"), 215 - } 216 - } 217 - 218 - #[tokio::test] 219 - async fn rejects_nonexistent_member() { 220 - let keyring = two_member_keyring(); 221 - let (owner_pubkey, _) = test_keypair(); 222 - 223 - let mock = MockTransport::new(); 224 - mock.enqueue(get_record_response(&keyring)); 225 - 226 - let remaining = [MemberKey { 227 - did: TEST_DID, 228 - public_key: &owner_pubkey, 229 - }]; 230 - 231 - let mut client = mock_client(mock); 232 - let err = remove_member( 233 - &mut client, 234 - KEYRING_URI, 235 - "did:plc:nobody", 236 - &remaining, 237 - "2026-03-01T12:00:00Z", 238 - &mut OsRng, 239 - ) 240 - .await 241 - .unwrap_err(); 242 - 243 - assert!(err.to_string().contains("not a member"), "got: {err}"); 244 - } 245 - } 91 + #[path = "remove_member_tests.rs"] 92 + mod tests;
+153
crates/opake-core/src/keyrings/remove_member_tests.rs
··· 1 + use super::*; 2 + use crate::client::{HttpResponse, RequestBody, Session, XrpcClient}; 3 + use crate::crypto::{OsRng, X25519DalekPublicKey, X25519DalekStaticSecret}; 4 + use crate::records::{AtBytes, Keyring, WrappedKey}; 5 + use crate::test_utils::MockTransport; 6 + 7 + const TEST_DID: &str = "did:plc:owner"; 8 + const KEYRING_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 9 + 10 + fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 11 + let session = Session { 12 + did: TEST_DID.into(), 13 + handle: "owner.test".into(), 14 + access_jwt: "test-jwt".into(), 15 + refresh_jwt: "test-refresh".into(), 16 + }; 17 + XrpcClient::with_session(mock, "https://pds.test".into(), session) 18 + } 19 + 20 + fn test_keypair() -> (X25519PublicKey, [u8; 32]) { 21 + let secret = X25519DalekStaticSecret::random_from_rng(OsRng); 22 + let public = X25519DalekPublicKey::from(&secret); 23 + (public.to_bytes(), secret.to_bytes()) 24 + } 25 + 26 + fn two_member_keyring() -> Keyring { 27 + Keyring::new( 28 + "test-keyring".into(), 29 + vec![ 30 + WrappedKey { 31 + did: TEST_DID.into(), 32 + ciphertext: AtBytes { 33 + encoded: "AAAA".into(), 34 + }, 35 + algo: "x25519-hkdf-a256kw".into(), 36 + }, 37 + WrappedKey { 38 + did: "did:plc:bob".into(), 39 + ciphertext: AtBytes { 40 + encoded: "BBBB".into(), 41 + }, 42 + algo: "x25519-hkdf-a256kw".into(), 43 + }, 44 + ], 45 + "2026-03-01T00:00:00Z".into(), 46 + ) 47 + } 48 + 49 + fn get_record_response(keyring: &Keyring) -> HttpResponse { 50 + HttpResponse { 51 + status: 200, 52 + body: serde_json::to_vec(&serde_json::json!({ 53 + "uri": KEYRING_URI, 54 + "cid": "bafykeyring", 55 + "value": keyring, 56 + })) 57 + .unwrap(), 58 + } 59 + } 60 + 61 + fn put_record_response() -> HttpResponse { 62 + HttpResponse { 63 + status: 200, 64 + body: serde_json::to_vec(&serde_json::json!({ 65 + "uri": KEYRING_URI, 66 + "cid": "bafyrotated", 67 + })) 68 + .unwrap(), 69 + } 70 + } 71 + 72 + #[tokio::test] 73 + async fn happy_path_removes_and_rotates() { 74 + let keyring = two_member_keyring(); 75 + let (owner_pubkey, owner_privkey) = test_keypair(); 76 + 77 + let mock = MockTransport::new(); 78 + mock.enqueue(get_record_response(&keyring)); 79 + mock.enqueue(put_record_response()); 80 + 81 + let remaining = [MemberKey { 82 + did: TEST_DID, 83 + public_key: &owner_pubkey, 84 + }]; 85 + 86 + let mut client = mock_client(mock.clone()); 87 + let (new_group_key, new_rotation) = remove_member( 88 + &mut client, 89 + KEYRING_URI, 90 + "did:plc:bob", 91 + &remaining, 92 + "2026-03-01T12:00:00Z", 93 + &mut OsRng, 94 + ) 95 + .await 96 + .unwrap(); 97 + 98 + assert_eq!(new_rotation, 1); 99 + 100 + let reqs = mock.requests(); 101 + assert_eq!(reqs.len(), 2); 102 + 103 + match &reqs[1].body { 104 + Some(RequestBody::Json(v)) => { 105 + let updated: Keyring = serde_json::from_value(v["record"].clone()).unwrap(); 106 + assert_eq!(updated.members.len(), 1); 107 + assert_eq!(updated.members[0].did, TEST_DID); 108 + assert_eq!(updated.rotation, 1); 109 + assert!(updated.modified_at.is_some()); 110 + 111 + // key_history should contain one entry for rotation 0 112 + assert_eq!(updated.key_history.len(), 1); 113 + assert_eq!(updated.key_history[0].rotation, 0); 114 + // The removed member (bob) should NOT be in history — 115 + // only the remaining owner's wrapped key is preserved 116 + assert_eq!(updated.key_history[0].members.len(), 1); 117 + assert_eq!(updated.key_history[0].members[0].did, TEST_DID); 118 + 119 + // Owner can unwrap the new group key 120 + let unwrapped = crypto::unwrap_key(&updated.members[0], &owner_privkey).unwrap(); 121 + assert_eq!(unwrapped.0, new_group_key.0); 122 + } 123 + _ => panic!("expected JSON body"), 124 + } 125 + } 126 + 127 + #[tokio::test] 128 + async fn rejects_nonexistent_member() { 129 + let keyring = two_member_keyring(); 130 + let (owner_pubkey, _) = test_keypair(); 131 + 132 + let mock = MockTransport::new(); 133 + mock.enqueue(get_record_response(&keyring)); 134 + 135 + let remaining = [MemberKey { 136 + did: TEST_DID, 137 + public_key: &owner_pubkey, 138 + }]; 139 + 140 + let mut client = mock_client(mock); 141 + let err = remove_member( 142 + &mut client, 143 + KEYRING_URI, 144 + "did:plc:nobody", 145 + &remaining, 146 + "2026-03-01T12:00:00Z", 147 + &mut OsRng, 148 + ) 149 + .await 150 + .unwrap_err(); 151 + 152 + assert!(err.to_string().contains("not a member"), "got: {err}"); 153 + }