An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

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

fix(identity-wallet): rewrite plc_monitor behavior tests with real crypto keys

The previous behavior tests used fake did:key URIs that don't correspond
to real P-256 keys, causing verify_plc_operation to always fail. Tests
also missed required IdentityStore setup (add_identity + device key
generation), causing IdentityNotFound errors.

Fixed by:
- Adding setup_identity() helper that registers DIDs, generates device
keys via IdentityStore, and retrieves private key bytes from Keychain
- Using crypto::build_did_plc_genesis_op with real device key material
- Using crypto::generate_p256_keypair for "other" keys in unauthorized tests
- Unique DID names per test to avoid keychain state interference
- Running tests with --test-threads=1 for keychain isolation

All 15 tests pass (8 serialization + 7 behavior covering AC6.1, AC6.2,
AC6.3, AC6.7, AC6.8, and multi-identity variants).

+208 -328
+208 -328
apps/identity-wallet/src-tauri/src/plc_monitor.rs
··· 273 273 assert_eq!(json["unauthorizedChanges"].as_array().unwrap().len(), 1); 274 274 } 275 275 276 - /// Test PlcMonitor can be created with a PdsClient reference. 276 + /// PlcMonitor borrows PdsClient; verify the reference is well-formed. 277 277 #[test] 278 278 fn test_plc_monitor_creation() { 279 279 let pds_client = PdsClient::new(); 280 280 let _monitor = PlcMonitor::new(&pds_client); 281 - // Verify the monitor is created successfully 282 281 } 283 282 284 283 /// Test MonitorError serialization with correct error tag. ··· 318 317 } 319 318 320 319 // ── Behavior tests: check_for_changes ────────────────────────────────── 320 + // 321 + // Each test registers DIDs with IdentityStore, generates real device keys, 322 + // and builds properly-signed PLC operations via the crypto crate. 323 + 324 + /// Register a DID in IdentityStore and return its device key info + private bytes. 325 + fn setup_identity(did: &str) -> (crate::device_key::DevicePublicKey, [u8; 32]) { 326 + let store = IdentityStore; 327 + // add_identity may fail if already registered from a prior test — ignore 328 + let _ = store.add_identity(did); 329 + // Clear per-DID keychain entries to ensure fresh device key generation 330 + for suffix in [ 331 + "device-key", 332 + "device-key-pub", 333 + "device-key-app-label", 334 + "did-doc", 335 + "plc-log", 336 + "oauth-tokens", 337 + ] { 338 + let _ = crate::keychain::delete_item(&format!("{did}:{suffix}")); 339 + } 340 + let device_pub = store 341 + .get_or_create_device_key(did) 342 + .expect("device key generation failed"); 343 + let priv_bytes_vec = crate::keychain::get_item(&format!("{did}:device-key")) 344 + .expect("device key not in keychain"); 345 + let priv_bytes: [u8; 32] = priv_bytes_vec 346 + .try_into() 347 + .expect("device key bytes not 32 bytes"); 348 + (device_pub, priv_bytes) 349 + } 321 350 322 351 /// AC6.1: Monitor detects a new PLC operation signed by the device key 323 352 /// and updates cached log without alerting. ··· 325 354 async fn test_ac6_1_authorized_change_detected() { 326 355 use httpmock::prelude::*; 327 356 357 + let did = "did:plc:ac61auth"; 358 + let _ = crate::keychain::delete_item("managed-dids"); 359 + let (device_pub, device_priv) = setup_identity(did); 360 + 328 361 let mock_server = MockServer::start(); 329 362 let client = PdsClient::new_for_test(mock_server.base_url()); 330 363 let monitor = PlcMonitor::new(&client); 331 364 332 - // Generate rotation and device keys 333 - let rotation_key = crypto::DidKeyUri("did:key:zQ3test_rotation".to_string()); 334 - let device_key = crypto::DidKeyUri("did:key:zQ3test_device".to_string()); 335 - let device_key_bytes: &[u8; 32] = &[1; 32]; 336 - 337 - // Build a valid genesis operation signed with the device key 365 + // Use a separate rotation key (rotationKeys[0]); device key signs as rotationKeys[1] 366 + let other_kp = crypto::generate_p256_keypair().expect("keygen"); 338 367 let genesis_op = crypto::build_did_plc_genesis_op( 339 - &rotation_key, 340 - &device_key, 341 - device_key_bytes, 368 + &other_kp.key_id, 369 + &DidKeyUri(device_pub.key_id.clone()), 370 + &device_priv, 342 371 "test.bsky.social", 343 372 "https://pds.test", 344 373 ) 345 - .expect("Failed to build genesis op"); 346 - 347 - let did = "did:plc:test_authorized"; 374 + .expect("build genesis op"); 348 375 349 - // Parse signed_op_json to get the operation object 350 376 let operation: serde_json::Value = 351 - serde_json::from_str(&genesis_op.signed_op_json).expect("Failed to parse operation"); 377 + serde_json::from_str(&genesis_op.signed_op_json).expect("parse op json"); 352 378 353 - // Build audit log with the genesis operation 354 - let audit_log_json = serde_json::json!([ 355 - { 356 - "did": did, 357 - "cid": "bafy123authorized", 358 - "createdAt": "2026-03-29T00:00:00Z", 359 - "nullified": false, 360 - "operation": operation 361 - } 362 - ]); 379 + let audit_log = serde_json::json!([{ 380 + "did": did, 381 + "cid": "bafy_ac61_genesis", 382 + "createdAt": "2026-03-29T00:00:00Z", 383 + "nullified": false, 384 + "operation": operation 385 + }]); 363 386 364 387 mock_server.mock(|when, then| { 365 - when.method(GET) 366 - .path(format!("/{}/log/audit", did).as_str()); 388 + when.method(GET).path(format!("/{did}/log/audit")); 367 389 then.status(200) 368 390 .header("content-type", "application/json") 369 - .json_body(audit_log_json.clone()); 391 + .json_body(audit_log.clone()); 370 392 }); 371 393 372 - // First call: cache is empty, so entry is new; device key authorizes it 373 - let result = monitor.check_for_changes(did).await; 374 - assert!(result.is_ok()); 375 - let changes = result.unwrap(); 376 - assert_eq!( 377 - changes.len(), 378 - 0, 379 - "Authorized change should not create alert" 380 - ); 394 + // First call: new entry signed by device key → authorized → no alert 395 + let changes = monitor.check_for_changes(did).await.expect("check failed"); 396 + assert_eq!(changes.len(), 0, "Device-key-signed op should not alert"); 381 397 382 - // Second call: cache is updated, no new entries 383 - let result = monitor.check_for_changes(did).await; 384 - assert!(result.is_ok()); 385 - let changes = result.unwrap(); 386 - assert_eq!(changes.len(), 0, "No new changes should be detected"); 398 + // Second call: cache updated, no new entries 399 + let changes = monitor.check_for_changes(did).await.expect("check failed"); 400 + assert_eq!(changes.len(), 0, "No new changes after cache update"); 387 401 } 388 402 389 403 /// AC6.2: Monitor detects a new PLC operation signed by a different key ··· 392 406 async fn test_ac6_2_unauthorized_change_detected() { 393 407 use httpmock::prelude::*; 394 408 409 + let did = "did:plc:ac62unauth"; 410 + let _ = crate::keychain::delete_item("managed-dids"); 411 + let (device_pub, _device_priv) = setup_identity(did); 412 + 395 413 let mock_server = MockServer::start(); 396 414 let client = PdsClient::new_for_test(mock_server.base_url()); 397 415 let monitor = PlcMonitor::new(&client); 398 416 399 - let device_key = crypto::DidKeyUri("did:key:zQ3test_device".to_string()); 400 - let other_key = crypto::DidKeyUri("did:key:zQ3test_other".to_string()); 401 - let rotation_key = crypto::DidKeyUri("did:key:zQ3test_rotation".to_string()); 402 - 403 - let did = "did:plc:test_unauthorized"; 404 - 405 - // Build initial operation (signed with device key) 417 + // Sign the genesis op with a DIFFERENT key (not the device key) 418 + let other_kp = crypto::generate_p256_keypair().expect("keygen"); 406 419 let genesis_op = crypto::build_did_plc_genesis_op( 407 - &rotation_key, 408 - &device_key, 409 - &[1; 32], 420 + &DidKeyUri(device_pub.key_id.clone()), 421 + &other_kp.key_id, 422 + &*other_kp.private_key_bytes, 410 423 "test.bsky.social", 411 424 "https://pds.test", 412 425 ) 413 - .expect("Failed to build genesis op"); 414 - 415 - let genesis_op_obj: serde_json::Value = 416 - serde_json::from_str(&genesis_op.signed_op_json).expect("Failed to parse genesis op"); 417 - 418 - // Build a rotation operation signed with a different key (other_key_bytes = [2; 32]) 419 - // Use the genesis_op's signed_op_json to get the CID 420 - let rotation_op = crypto::build_did_plc_rotation_op( 421 - "bafy123genesis", 422 - vec![device_key.0.clone(), other_key.0.clone()], 423 - std::collections::BTreeMap::new(), 424 - vec![], 425 - std::collections::BTreeMap::new(), 426 - |data| { 427 - let signing_key = 428 - p256::ecdsa::SigningKey::from_bytes(&p256::FieldBytes::from_slice(&[2; 32])) 429 - .map_err(|e| crypto::CryptoError::PlcOperation(e.to_string()))?; 430 - let sig: p256::ecdsa::Signature = 431 - p256::ecdsa::signature::Signer::sign(&signing_key, data); 432 - Ok(sig.to_bytes().to_vec()) 433 - }, 434 - ) 435 - .expect("Failed to build rotation op"); 426 + .expect("build genesis op"); 436 427 437 - let rotation_op_obj: serde_json::Value = 438 - serde_json::from_str(&rotation_op.signed_op_json).expect("Failed to parse rotation op"); 428 + let operation: serde_json::Value = 429 + serde_json::from_str(&genesis_op.signed_op_json).expect("parse op json"); 439 430 440 - let audit_log_json = serde_json::json!([ 441 - { 442 - "did": did, 443 - "cid": "bafy123genesis", 444 - "createdAt": "2026-03-29T00:00:00Z", 445 - "nullified": false, 446 - "operation": genesis_op_obj, 447 - "rotationKeys": [device_key.0, other_key.0] 448 - }, 449 - { 450 - "did": did, 451 - "cid": "bafy123rotation", 452 - "createdAt": "2026-03-29T01:00:00Z", 453 - "nullified": false, 454 - "operation": rotation_op_obj 455 - } 456 - ]); 431 + let audit_log = serde_json::json!([{ 432 + "did": did, 433 + "cid": "bafy_ac62_genesis", 434 + "createdAt": "2026-03-29T01:00:00Z", 435 + "nullified": false, 436 + "operation": operation 437 + }]); 457 438 458 439 mock_server.mock(|when, then| { 459 - when.method(GET) 460 - .path(format!("/{}/log/audit", did).as_str()); 440 + when.method(GET).path(format!("/{did}/log/audit")); 461 441 then.status(200) 462 442 .header("content-type", "application/json") 463 - .json_body(audit_log_json); 443 + .json_body(audit_log); 464 444 }); 465 445 466 - // First call: cache is empty 467 - let result = monitor.check_for_changes(did).await; 468 - assert!(result.is_ok()); 469 - let changes = result.unwrap(); 446 + let changes = monitor.check_for_changes(did).await.expect("check failed"); 470 447 assert_eq!(changes.len(), 1, "Should detect one unauthorized change"); 471 - 472 - let change = &changes[0]; 473 - assert_eq!(change.cid, "bafy123rotation"); 474 - // The signing key identification will depend on the rotation keys in previous operation 448 + assert_eq!(changes[0].cid, "bafy_ac62_genesis"); 475 449 } 476 450 477 451 /// AC6.3: Alert includes correct recovery deadline (created_at from audit log). ··· 479 453 async fn test_ac6_3_created_at_matches_audit_log() { 480 454 use httpmock::prelude::*; 481 455 456 + let did = "did:plc:ac63time"; 457 + let _ = crate::keychain::delete_item("managed-dids"); 458 + let (device_pub, _device_priv) = setup_identity(did); 459 + 482 460 let mock_server = MockServer::start(); 483 461 let client = PdsClient::new_for_test(mock_server.base_url()); 484 462 let monitor = PlcMonitor::new(&client); 485 463 486 - let device_key = crypto::DidKeyUri("did:key:zQ3test_device".to_string()); 487 - let other_key = crypto::DidKeyUri("did:key:zQ3test_other".to_string()); 488 - let rotation_key = crypto::DidKeyUri("did:key:zQ3test_rotation".to_string()); 489 - 490 - let did = "did:plc:test_deadline"; 491 464 let expected_timestamp = "2026-03-29T12:34:56.789Z"; 492 465 466 + let other_kp = crypto::generate_p256_keypair().expect("keygen"); 493 467 let genesis_op = crypto::build_did_plc_genesis_op( 494 - &rotation_key, 495 - &device_key, 496 - &[1; 32], 468 + &DidKeyUri(device_pub.key_id.clone()), 469 + &other_kp.key_id, 470 + &*other_kp.private_key_bytes, 497 471 "test.bsky.social", 498 472 "https://pds.test", 499 473 ) 500 - .expect("Failed to build genesis op"); 474 + .expect("build genesis op"); 501 475 502 - let genesis_op_obj: serde_json::Value = 503 - serde_json::from_str(&genesis_op.signed_op_json).expect("Failed to parse genesis op"); 504 - 505 - let rotation_op = crypto::build_did_plc_rotation_op( 506 - "bafy123genesis", 507 - vec![device_key.0.clone(), other_key.0.clone()], 508 - std::collections::BTreeMap::new(), 509 - vec![], 510 - std::collections::BTreeMap::new(), 511 - |data| { 512 - let signing_key = 513 - p256::ecdsa::SigningKey::from_bytes(&p256::FieldBytes::from_slice(&[2; 32])) 514 - .map_err(|e| crypto::CryptoError::PlcOperation(e.to_string()))?; 515 - let sig: p256::ecdsa::Signature = 516 - p256::ecdsa::signature::Signer::sign(&signing_key, data); 517 - Ok(sig.to_bytes().to_vec()) 518 - }, 519 - ) 520 - .expect("Failed to build rotation op"); 476 + let operation: serde_json::Value = 477 + serde_json::from_str(&genesis_op.signed_op_json).expect("parse op json"); 521 478 522 - let rotation_op_obj: serde_json::Value = 523 - serde_json::from_str(&rotation_op.signed_op_json).expect("Failed to parse rotation op"); 524 - 525 - let audit_log_json = serde_json::json!([ 526 - { 527 - "did": did, 528 - "cid": "bafy123genesis", 529 - "createdAt": "2026-03-29T00:00:00Z", 530 - "nullified": false, 531 - "operation": genesis_op_obj, 532 - "rotationKeys": [device_key.0, other_key.0] 533 - }, 534 - { 535 - "did": did, 536 - "cid": "bafy123rotation", 537 - "createdAt": expected_timestamp, 538 - "nullified": false, 539 - "operation": rotation_op_obj 540 - } 541 - ]); 479 + let audit_log = serde_json::json!([{ 480 + "did": did, 481 + "cid": "bafy_ac63_genesis", 482 + "createdAt": expected_timestamp, 483 + "nullified": false, 484 + "operation": operation 485 + }]); 542 486 543 487 mock_server.mock(|when, then| { 544 - when.method(GET) 545 - .path(format!("/{}/log/audit", did).as_str()); 488 + when.method(GET).path(format!("/{did}/log/audit")); 546 489 then.status(200) 547 490 .header("content-type", "application/json") 548 - .json_body(audit_log_json); 491 + .json_body(audit_log); 549 492 }); 550 493 551 - let result = monitor.check_for_changes(did).await; 552 - assert!(result.is_ok()); 553 - let changes = result.unwrap(); 494 + let changes = monitor.check_for_changes(did).await.expect("check failed"); 554 495 assert_eq!(changes.len(), 1); 555 - assert_eq!(changes[0].created_at, expected_timestamp); 496 + assert_eq!( 497 + changes[0].created_at, expected_timestamp, 498 + "created_at must match the audit log timestamp for frontend deadline computation" 499 + ); 556 500 } 557 501 558 502 /// AC6.7: Monitor handles plc.directory being unreachable gracefully ··· 565 509 let client = PdsClient::new_for_test(mock_server.base_url()); 566 510 let monitor = PlcMonitor::new(&client); 567 511 568 - let did = "did:plc:test_unreachable"; 512 + let did = "did:plc:ac67net"; 569 513 570 - // Mock returns 500 error (network failure) 571 514 mock_server.mock(|when, then| { 572 - when.method(GET) 573 - .path(format!("/{}/log/audit", did).as_str()); 515 + when.method(GET).path(format!("/{did}/log/audit")); 574 516 then.status(500); 575 517 }); 576 518 577 519 let result = monitor.check_for_changes(did).await; 578 520 assert!(result.is_ok(), "Network error should return Ok, not Err"); 579 - let changes = result.unwrap(); 580 - assert_eq!(changes.len(), 0, "Network error should return empty vec"); 521 + assert_eq!( 522 + result.unwrap().len(), 523 + 0, 524 + "Network error should return empty vec" 525 + ); 581 526 } 582 527 583 528 /// AC6.8: Monitor handles empty audit log (newly created identity, no operations yet). ··· 585 530 async fn test_ac6_8_empty_audit_log() { 586 531 use httpmock::prelude::*; 587 532 533 + let did = "did:plc:ac68empty"; 534 + let _ = crate::keychain::delete_item("managed-dids"); 535 + let _ = setup_identity(did); 536 + 588 537 let mock_server = MockServer::start(); 589 538 let client = PdsClient::new_for_test(mock_server.base_url()); 590 539 let monitor = PlcMonitor::new(&client); 591 540 592 - let did = "did:plc:test_empty"; 593 - 594 - // Empty audit log 595 - let audit_log_json = serde_json::json!([]); 596 - 597 541 mock_server.mock(|when, then| { 598 - when.method(GET) 599 - .path(format!("/{}/log/audit", did).as_str()); 542 + when.method(GET).path(format!("/{did}/log/audit")); 600 543 then.status(200) 601 544 .header("content-type", "application/json") 602 - .json_body(audit_log_json); 545 + .json_body(serde_json::json!([])); 603 546 }); 604 547 605 - let result = monitor.check_for_changes(did).await; 606 - assert!(result.is_ok()); 607 - let changes = result.unwrap(); 548 + let changes = monitor.check_for_changes(did).await.expect("check failed"); 608 549 assert_eq!(changes.len(), 0, "Empty audit log should return no changes"); 609 550 } 610 551 ··· 613 554 async fn test_ac6_1_multi_identity_all_authorized() { 614 555 use httpmock::prelude::*; 615 556 557 + let _ = crate::keychain::delete_item("managed-dids"); 558 + let did_alice = "did:plc:ac61alice"; 559 + let did_bob = "did:plc:ac61bob"; 560 + let (alice_pub, alice_priv) = setup_identity(did_alice); 561 + let (bob_pub, bob_priv) = setup_identity(did_bob); 562 + 616 563 let mock_server = MockServer::start(); 617 564 let client = PdsClient::new_for_test(mock_server.base_url()); 618 565 let monitor = PlcMonitor::new(&client); 619 566 620 - let device_key_alice = crypto::DidKeyUri("did:key:zQ3alice_device".to_string()); 621 - let rotation_key_alice = crypto::DidKeyUri("did:key:zQ3alice_rotation".to_string()); 622 - let did_alice = "did:plc:alice"; 623 - 624 - let device_key_bob = crypto::DidKeyUri("did:key:zQ3bob_device".to_string()); 625 - let rotation_key_bob = crypto::DidKeyUri("did:key:zQ3bob_rotation".to_string()); 626 - let did_bob = "did:plc:bob"; 627 - 628 - let genesis_op_alice = crypto::build_did_plc_genesis_op( 629 - &rotation_key_alice, 630 - &device_key_alice, 631 - &[1; 32], 567 + // Alice: genesis signed by alice's device key 568 + let alice_rot = crypto::generate_p256_keypair().expect("keygen"); 569 + let alice_genesis = crypto::build_did_plc_genesis_op( 570 + &alice_rot.key_id, 571 + &DidKeyUri(alice_pub.key_id.clone()), 572 + &alice_priv, 632 573 "alice.bsky.social", 633 574 "https://pds.alice", 634 575 ) 635 - .expect("Failed to build alice genesis op"); 576 + .expect("build alice genesis"); 577 + let alice_op: serde_json::Value = 578 + serde_json::from_str(&alice_genesis.signed_op_json).expect("parse"); 636 579 637 - let genesis_op_bob = crypto::build_did_plc_genesis_op( 638 - &rotation_key_bob, 639 - &device_key_bob, 640 - &[3; 32], 580 + // Bob: genesis signed by bob's device key 581 + let bob_rot = crypto::generate_p256_keypair().expect("keygen"); 582 + let bob_genesis = crypto::build_did_plc_genesis_op( 583 + &bob_rot.key_id, 584 + &DidKeyUri(bob_pub.key_id.clone()), 585 + &bob_priv, 641 586 "bob.bsky.social", 642 587 "https://pds.bob", 643 588 ) 644 - .expect("Failed to build bob genesis op"); 645 - 646 - let genesis_op_alice_obj: serde_json::Value = 647 - serde_json::from_str(&genesis_op_alice.signed_op_json) 648 - .expect("Failed to parse alice genesis op"); 649 - let genesis_op_bob_obj: serde_json::Value = 650 - serde_json::from_str(&genesis_op_bob.signed_op_json) 651 - .expect("Failed to parse bob genesis op"); 652 - 653 - let audit_log_alice = serde_json::json!([ 654 - { 655 - "did": did_alice, 656 - "cid": "bafy_alice1", 657 - "createdAt": "2026-03-29T00:00:00Z", 658 - "nullified": false, 659 - "operation": genesis_op_alice_obj 660 - } 661 - ]); 662 - 663 - let audit_log_bob = serde_json::json!([ 664 - { 665 - "did": did_bob, 666 - "cid": "bafy_bob1", 667 - "createdAt": "2026-03-29T00:00:00Z", 668 - "nullified": false, 669 - "operation": genesis_op_bob_obj 670 - } 671 - ]); 589 + .expect("build bob genesis"); 590 + let bob_op: serde_json::Value = 591 + serde_json::from_str(&bob_genesis.signed_op_json).expect("parse"); 672 592 673 593 mock_server.mock(|when, then| { 674 - when.method(GET) 675 - .path(format!("/{}/log/audit", did_alice).as_str()); 594 + when.method(GET).path(format!("/{did_alice}/log/audit")); 676 595 then.status(200) 677 596 .header("content-type", "application/json") 678 - .json_body(audit_log_alice); 597 + .json_body(serde_json::json!([{ 598 + "did": did_alice, "cid": "bafy_alice1", 599 + "createdAt": "2026-03-29T00:00:00Z", 600 + "nullified": false, "operation": alice_op 601 + }])); 679 602 }); 680 603 681 604 mock_server.mock(|when, then| { 682 - when.method(GET) 683 - .path(format!("/{}/log/audit", did_bob).as_str()); 605 + when.method(GET).path(format!("/{did_bob}/log/audit")); 684 606 then.status(200) 685 607 .header("content-type", "application/json") 686 - .json_body(audit_log_bob); 608 + .json_body(serde_json::json!([{ 609 + "did": did_bob, "cid": "bafy_bob1", 610 + "createdAt": "2026-03-29T00:00:00Z", 611 + "nullified": false, "operation": bob_op 612 + }])); 687 613 }); 688 614 689 - let result_alice = monitor.check_for_changes(did_alice).await; 690 - assert!(result_alice.is_ok()); 691 - assert_eq!(result_alice.unwrap().len(), 0); 692 - 693 - let result_bob = monitor.check_for_changes(did_bob).await; 694 - assert!(result_bob.is_ok()); 695 - assert_eq!(result_bob.unwrap().len(), 0); 615 + let statuses = monitor.check_all().await.expect("check_all failed"); 616 + assert_eq!(statuses.len(), 2, "Should have two identity statuses"); 617 + for status in &statuses { 618 + assert_eq!( 619 + status.alert_count, 0, 620 + "DID {} should have no alerts", 621 + status.did 622 + ); 623 + } 696 624 } 697 625 698 - /// AC6.2 (multi-identity): Two identities, one with authorized op, one with unauthorized op. 626 + /// AC6.2 (multi-identity): Two identities, one authorized, one unauthorized. 699 627 #[tokio::test] 700 628 async fn test_ac6_2_multi_identity_mixed_auth() { 701 629 use httpmock::prelude::*; 702 630 631 + let _ = crate::keychain::delete_item("managed-dids"); 632 + let did_alice = "did:plc:ac62alice"; 633 + let did_bob = "did:plc:ac62bob"; 634 + let (alice_pub, alice_priv) = setup_identity(did_alice); 635 + let (bob_pub, _bob_priv) = setup_identity(did_bob); 636 + 703 637 let mock_server = MockServer::start(); 704 638 let client = PdsClient::new_for_test(mock_server.base_url()); 705 639 let monitor = PlcMonitor::new(&client); 706 640 707 - let device_key_alice = crypto::DidKeyUri("did:key:zQ3alice_device".to_string()); 708 - let rotation_key_alice = crypto::DidKeyUri("did:key:zQ3alice_rotation".to_string()); 709 - let did_alice = "did:plc:alice"; 710 - 711 - let device_key_bob = crypto::DidKeyUri("did:key:zQ3bob_device".to_string()); 712 - let other_key_bob = crypto::DidKeyUri("did:key:zQ3bob_other".to_string()); 713 - let rotation_key_bob = crypto::DidKeyUri("did:key:zQ3bob_rotation".to_string()); 714 - let did_bob = "did:plc:bob"; 715 - 716 - let genesis_op_alice = crypto::build_did_plc_genesis_op( 717 - &rotation_key_alice, 718 - &device_key_alice, 719 - &[1; 32], 641 + // Alice: genesis signed by alice's device key → authorized 642 + let alice_rot = crypto::generate_p256_keypair().expect("keygen"); 643 + let alice_genesis = crypto::build_did_plc_genesis_op( 644 + &alice_rot.key_id, 645 + &DidKeyUri(alice_pub.key_id.clone()), 646 + &alice_priv, 720 647 "alice.bsky.social", 721 648 "https://pds.alice", 722 649 ) 723 - .expect("Failed to build alice genesis op"); 650 + .expect("build alice genesis"); 651 + let alice_op: serde_json::Value = 652 + serde_json::from_str(&alice_genesis.signed_op_json).expect("parse"); 724 653 725 - let genesis_op_bob = crypto::build_did_plc_genesis_op( 726 - &rotation_key_bob, 727 - &device_key_bob, 728 - &[3; 32], 654 + // Bob: genesis signed by a DIFFERENT key → unauthorized 655 + let bob_other = crypto::generate_p256_keypair().expect("keygen"); 656 + let bob_genesis = crypto::build_did_plc_genesis_op( 657 + &DidKeyUri(bob_pub.key_id.clone()), 658 + &bob_other.key_id, 659 + &*bob_other.private_key_bytes, 729 660 "bob.bsky.social", 730 661 "https://pds.bob", 731 662 ) 732 - .expect("Failed to build bob genesis op"); 733 - 734 - let genesis_op_alice_obj: serde_json::Value = 735 - serde_json::from_str(&genesis_op_alice.signed_op_json) 736 - .expect("Failed to parse alice genesis op"); 737 - let genesis_op_bob_obj: serde_json::Value = 738 - serde_json::from_str(&genesis_op_bob.signed_op_json) 739 - .expect("Failed to parse bob genesis op"); 740 - 741 - let rotation_op_bob = crypto::build_did_plc_rotation_op( 742 - "bafy_bob_genesis", 743 - vec![device_key_bob.0.clone(), other_key_bob.0.clone()], 744 - std::collections::BTreeMap::new(), 745 - vec![], 746 - std::collections::BTreeMap::new(), 747 - |data| { 748 - let signing_key = 749 - p256::ecdsa::SigningKey::from_bytes(&p256::FieldBytes::from_slice(&[4; 32])) 750 - .map_err(|e| crypto::CryptoError::PlcOperation(e.to_string()))?; 751 - let sig: p256::ecdsa::Signature = 752 - p256::ecdsa::signature::Signer::sign(&signing_key, data); 753 - Ok(sig.to_bytes().to_vec()) 754 - }, 755 - ) 756 - .expect("Failed to build bob rotation op"); 757 - 758 - let rotation_op_bob_obj: serde_json::Value = 759 - serde_json::from_str(&rotation_op_bob.signed_op_json) 760 - .expect("Failed to parse bob rotation op"); 761 - 762 - let audit_log_alice = serde_json::json!([ 763 - { 764 - "did": did_alice, 765 - "cid": "bafy_alice1", 766 - "createdAt": "2026-03-29T00:00:00Z", 767 - "nullified": false, 768 - "operation": genesis_op_alice_obj 769 - } 770 - ]); 771 - 772 - let audit_log_bob = serde_json::json!([ 773 - { 774 - "did": did_bob, 775 - "cid": "bafy_bob_genesis", 776 - "createdAt": "2026-03-29T00:00:00Z", 777 - "nullified": false, 778 - "operation": genesis_op_bob_obj, 779 - "rotationKeys": [device_key_bob.0, other_key_bob.0] 780 - }, 781 - { 782 - "did": did_bob, 783 - "cid": "bafy_bob_rotation", 784 - "createdAt": "2026-03-29T01:00:00Z", 785 - "nullified": false, 786 - "operation": rotation_op_bob_obj 787 - } 788 - ]); 663 + .expect("build bob genesis"); 664 + let bob_op: serde_json::Value = 665 + serde_json::from_str(&bob_genesis.signed_op_json).expect("parse"); 789 666 790 667 mock_server.mock(|when, then| { 791 - when.method(GET) 792 - .path(format!("/{}/log/audit", did_alice).as_str()); 668 + when.method(GET).path(format!("/{did_alice}/log/audit")); 793 669 then.status(200) 794 670 .header("content-type", "application/json") 795 - .json_body(audit_log_alice); 671 + .json_body(serde_json::json!([{ 672 + "did": did_alice, "cid": "bafy_alice1", 673 + "createdAt": "2026-03-29T00:00:00Z", 674 + "nullified": false, "operation": alice_op 675 + }])); 796 676 }); 797 677 798 678 mock_server.mock(|when, then| { 799 - when.method(GET) 800 - .path(format!("/{}/log/audit", did_bob).as_str()); 679 + when.method(GET).path(format!("/{did_bob}/log/audit")); 801 680 then.status(200) 802 681 .header("content-type", "application/json") 803 - .json_body(audit_log_bob); 682 + .json_body(serde_json::json!([{ 683 + "did": did_bob, "cid": "bafy_bob1", 684 + "createdAt": "2026-03-29T00:00:00Z", 685 + "nullified": false, "operation": bob_op 686 + }])); 804 687 }); 805 688 806 - let result_alice = monitor.check_for_changes(did_alice).await; 807 - assert!(result_alice.is_ok()); 808 - assert_eq!( 809 - result_alice.unwrap().len(), 810 - 0, 811 - "Alice should have no alerts" 812 - ); 689 + let statuses = monitor.check_all().await.expect("check_all failed"); 690 + assert_eq!(statuses.len(), 2); 813 691 814 - let result_bob = monitor.check_for_changes(did_bob).await; 815 - assert!(result_bob.is_ok()); 816 - assert_eq!(result_bob.unwrap().len(), 1, "Bob should have one alert"); 692 + let alice_status = statuses.iter().find(|s| s.did == did_alice).unwrap(); 693 + assert_eq!(alice_status.alert_count, 0, "Alice should have no alerts"); 694 + 695 + let bob_status = statuses.iter().find(|s| s.did == did_bob).unwrap(); 696 + assert_eq!(bob_status.alert_count, 1, "Bob should have one alert"); 817 697 } 818 698 }