An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

test(relay): replace MM-89 tests with MM-90 AC2-AC4 coverage for POST /v1/dids

Replaces entire #[cfg(test)] mod tests block in crates/relay/src/routes/create_did.rs.

Changes:
- Remove TEST_MASTER_KEY constant (not needed for device-signed ceremony)
- Remove relay_signing_key insertion from insert_test_data
- Add make_signed_op() helper using crypto::build_did_plc_genesis_op
- Simplify TestSetup struct (remove signing_key_id, rotation_key_id)
- Simplify test_state_for_did (no signing_key_master_key manipulation)
- Update create_did_request to use new MM-90 request shape (rotationKeyPublic, signedCreationOp)

Replaces 7 MM-89 test functions with 9 MM-90 test functions:
✓ happy_path_promotes_account_and_returns_did (AC2.1/2.2/2.3/2.4/2.5/4.1/4.2/4.3)
✓ retry_with_pending_did_skips_plc_directory (AC2.6)
✓ invalid_signature_returns_400 (AC3.1)
✓ wrong_handle_in_op_returns_400 (AC3.2)
✓ wrong_service_endpoint_returns_400 (AC3.3)
✓ wrong_rotation_key_in_op_returns_400 (AC3.4)
✓ already_promoted_account_returns_409 (AC3.5)
✓ missing_auth_returns_401 (AC3.6)
✓ plc_directory_error_returns_502 (AC3.7)

All 185 relay tests pass. All workspace tests pass. Clippy: 0 warnings.

authored by malpercio.dev and committed by

Tangled c246a14b 01b69050

+271 -319
+271 -319
crates/relay/src/routes/create_did.rs
··· 356 356 357 357 // ── Test setup helpers ──────────────────────────────────────────────────── 358 358 359 - /// A test master key: 32 bytes of 0x01. 360 - const TEST_MASTER_KEY: [u8; 32] = [0x01u8; 32]; 361 - 362 - /// All data needed to call POST /v1/dids in a test. 363 359 struct TestSetup { 364 360 session_token: String, 365 - signing_key_id: String, 366 - rotation_key_id: String, 367 361 account_id: String, 368 - /// The handle stored in `pending_accounts`. Needed for AC2.10 to re-create 369 - /// a second pending account that derives the same DID (same keys + same handle). 370 362 handle: String, 371 363 } 372 364 373 - /// Insert all prerequisite rows for a DID-creation test. 365 + /// Generate a signed genesis op verifiable by the returned rotation_key_public. 374 366 /// 375 - /// Inserts: relay_signing_key, pending_account (with claim code), device, pending_session. 376 - /// 377 - /// Pre-step: Read `crates/relay/src/routes/test_utils.rs` to see if helpers already 378 - /// exist for inserting claim codes, pending accounts, or pending sessions. Use them here 379 - /// if available. If not, use the raw SQL below. 380 - async fn insert_test_data(db: &sqlx::SqlitePool) -> TestSetup { 381 - use crypto::{encrypt_private_key, generate_p256_keypair}; 382 - 383 - // Generate signing and rotation keypairs. 384 - let signing_kp = generate_p256_keypair().expect("signing keypair"); 385 - let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 386 - 387 - // Encrypt the signing private key with the test master key. 388 - let encrypted = encrypt_private_key(&signing_kp.private_key_bytes, &TEST_MASTER_KEY) 389 - .expect("encrypt key"); 390 - 391 - // Insert relay_signing_key. 392 - sqlx::query( 393 - "INSERT INTO relay_signing_keys \ 394 - (id, algorithm, public_key, private_key_encrypted, created_at) \ 395 - VALUES (?, 'p256', ?, ?, datetime('now'))", 367 + /// Uses the same keypair for both rotation and signing: kp signs the op, 368 + /// AND kp.key_id appears at rotationKeys[0]. Calling verify_genesis_op with 369 + /// kp.key_id will succeed. 370 + fn make_signed_op(handle: &str, public_url: &str) -> (String, serde_json::Value) { 371 + use crypto::{build_did_plc_genesis_op, generate_p256_keypair}; 372 + let kp = generate_p256_keypair().expect("keypair"); 373 + let private_bytes = *kp.private_key_bytes; 374 + let genesis_op = build_did_plc_genesis_op( 375 + &kp.key_id, // rotation key — placed at rotationKeys[0] 376 + &kp.key_id, // signing key (same) — kp's private key performs the signing 377 + &private_bytes, 378 + handle, 379 + public_url, 396 380 ) 397 - .bind(&signing_kp.key_id.0) 398 - .bind(&signing_kp.public_key) 399 - .bind(&encrypted) 400 - .execute(db) 401 - .await 402 - .expect("insert relay_signing_key"); 381 + .expect("genesis op"); 382 + let signed_op_value: serde_json::Value = 383 + serde_json::from_str(&genesis_op.signed_op_json).expect("valid JSON"); 384 + (kp.key_id.0, signed_op_value) 385 + } 403 386 404 - // Insert a claim_code row (required FK for pending_accounts). 387 + /// Insert prerequisite rows for a DID-creation test. 388 + /// 389 + /// Inserts: claim_code, pending_account, device, pending_session. 390 + /// No relay signing key needed for MM-90. 391 + async fn insert_test_data(db: &sqlx::SqlitePool) -> TestSetup { 405 392 let claim_code = format!("TEST-{}", Uuid::new_v4()); 406 393 sqlx::query( 407 394 "INSERT INTO claim_codes (code, expires_at, created_at) \ ··· 412 399 .await 413 400 .expect("insert claim_code"); 414 401 415 - // Insert pending_account. 416 402 let account_id = Uuid::new_v4().to_string(); 417 403 let handle = format!("alice{}.example.com", &account_id[..8]); 418 404 sqlx::query( ··· 428 414 .await 429 415 .expect("insert pending_account"); 430 416 431 - // Insert a device (required FK for pending_sessions). 432 417 let device_id = Uuid::new_v4().to_string(); 433 418 sqlx::query( 434 419 "INSERT INTO devices \ ··· 441 426 .await 442 427 .expect("insert device"); 443 428 444 - // Generate pending session token. 445 429 let mut token_bytes = [0u8; 32]; 446 430 OsRng.fill_bytes(&mut token_bytes); 447 431 let session_token = URL_SAFE_NO_PAD.encode(token_bytes); ··· 449 433 .iter() 450 434 .map(|b| format!("{b:02x}")) 451 435 .collect(); 452 - 453 - // Insert pending_session. 454 436 sqlx::query( 455 437 "INSERT INTO pending_sessions \ 456 438 (id, account_id, device_id, token_hash, created_at, expires_at) \ ··· 464 446 .await 465 447 .expect("insert pending_session"); 466 448 467 - TestSetup { 468 - session_token, 469 - signing_key_id: signing_kp.key_id.0, 470 - rotation_key_id: rotation_kp.key_id.0, 471 - account_id, 472 - handle, 473 - } 449 + TestSetup { session_token, account_id, handle } 474 450 } 475 451 476 - /// Create an AppState with TEST_MASTER_KEY set and plc_directory_url pointing to the mock. 452 + /// Create an AppState with plc_directory_url pointing to the mock server. 453 + /// No signing_key_master_key needed for MM-90. 477 454 async fn test_state_for_did(plc_url: String) -> AppState { 478 - use common::Sensitive; 479 - use std::sync::Arc; 480 - use std::time::Duration; 481 - use zeroize::Zeroizing; 482 - 483 - let base = test_state_with_plc_url(plc_url).await; 484 - let mut config = (*base.config).clone(); 485 - config.signing_key_master_key = Some(Sensitive(Zeroizing::new(TEST_MASTER_KEY))); 486 - 487 - let http_client = reqwest::Client::builder() 488 - .timeout(Duration::from_secs(10)) 489 - .build() 490 - .expect("test http client"); 491 - 492 - AppState { 493 - config: Arc::new(config), 494 - db: base.db, 495 - http_client, 496 - } 455 + test_state_with_plc_url(plc_url).await 497 456 } 498 457 499 - /// Build a POST /v1/dids request with the given session token and body. 458 + /// Build a POST /v1/dids request with the MM-90 body shape. 500 459 fn create_did_request( 501 460 session_token: &str, 502 - signing_key: &str, 503 - rotation_key: &str, 461 + rotation_key_public: &str, 462 + signed_creation_op: &serde_json::Value, 504 463 ) -> Request<Body> { 505 464 let body = serde_json::json!({ 506 - "signingKey": signing_key, 507 - "rotationKey": rotation_key, 465 + "rotationKeyPublic": rotation_key_public, 466 + "signedCreationOp": signed_creation_op, 508 467 }); 509 468 Request::builder() 510 469 .method("POST") ··· 515 474 .unwrap() 516 475 } 517 476 518 - // ── AC2.1: Valid request returns 200 with { did, status: "active" } ─────── 477 + // ── AC2.1/2.2/2.3/2.4/2.5/4.1/4.2/4.3: Happy path ─────────────────────── 519 478 520 - /// MM-89.AC2.1, AC2.2, AC2.3, AC2.4, AC2.5: Happy path — full promotion 479 + /// MM-90.AC2.1, AC2.2, AC2.3, AC2.4, AC2.5, AC4.1, AC4.2, AC4.3: 480 + /// Valid request promotes account and returns full DID response. 521 481 #[tokio::test] 522 482 async fn happy_path_promotes_account_and_returns_did() { 523 483 let mock_server = MockServer::start().await; ··· 532 492 let state = test_state_for_did(mock_server.uri()).await; 533 493 let db = state.db.clone(); 534 494 let setup = insert_test_data(&db).await; 495 + let (rotation_key_public, signed_op) = 496 + make_signed_op(&setup.handle, &state.config.public_url); 535 497 536 - let app = crate::app::app(state); 498 + let app = crate::app::app(state.clone()); 537 499 let response = app 538 - .oneshot(create_did_request( 539 - &setup.session_token, 540 - &setup.signing_key_id, 541 - &setup.rotation_key_id, 542 - )) 500 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 543 501 .await 544 502 .unwrap(); 545 503 546 - // AC2.1: 200 OK with did + status 547 - assert_eq!(response.status(), StatusCode::OK); 548 - let body: serde_json::Value = serde_json::from_slice( 549 - &axum::body::to_bytes(response.into_body(), usize::MAX) 550 - .await 551 - .unwrap(), 552 - ) 553 - .unwrap(); 554 - let did = body["did"].as_str().expect("did field"); 504 + // AC2.1: 200 OK with { did, did_document, status: "active" } 505 + assert_eq!(response.status(), StatusCode::OK, "expected 200 OK"); 506 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 507 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 555 508 assert!( 556 - did.starts_with("did:plc:"), 509 + body["did"].as_str().map(|d| d.starts_with("did:plc:")).unwrap_or(false), 557 510 "did should start with did:plc:" 558 511 ); 559 - assert_eq!(body["status"], "active"); 512 + assert_eq!(body["status"], "active", "status should be active"); 513 + assert!(body["did_document"].is_object(), "did_document should be a JSON object"); 514 + 515 + let did = body["did"].as_str().unwrap(); 516 + let doc = &body["did_document"]; 517 + 518 + // AC4.2: alsoKnownAs contains at://{handle} 519 + let also_known_as = doc["alsoKnownAs"].as_array().expect("alsoKnownAs is array"); 520 + assert!( 521 + also_known_as.iter().any(|e| e.as_str() == Some(&format!("at://{}", setup.handle))), 522 + "alsoKnownAs should contain at://{}", setup.handle 523 + ); 524 + 525 + // AC4.1: verificationMethod has publicKeyMultibase starting with "z" 526 + let vm = &doc["verificationMethod"][0]; 527 + let pkm = vm["publicKeyMultibase"].as_str().expect("publicKeyMultibase is string"); 528 + assert!(pkm.starts_with('z'), "publicKeyMultibase should start with 'z'"); 529 + 530 + // AC4.3: service entry has serviceEndpoint matching public_url 531 + let service = &doc["service"][0]; 532 + assert_eq!( 533 + service["serviceEndpoint"].as_str(), 534 + Some("https://test.example.com"), 535 + "serviceEndpoint should match config.public_url" 536 + ); 560 537 561 - // AC2.2: accounts row with null password_hash 562 - let (stored_email, stored_hash): (String, Option<String>) = 538 + // AC2.2: accounts row with correct did, email; password_hash IS NULL 539 + let row: Option<(String, Option<String>)> = 563 540 sqlx::query_as("SELECT email, password_hash FROM accounts WHERE did = ?") 564 541 .bind(did) 565 - .fetch_one(&db) 542 + .fetch_optional(&db) 566 543 .await 567 - .expect("accounts row should exist"); 568 - assert!(stored_hash.is_none(), "password_hash should be NULL"); 569 - assert!(stored_email.contains("alice"), "email should be set"); 544 + .unwrap(); 545 + let (email, password_hash) = row.expect("accounts row should exist"); 546 + assert!(email.contains("alice"), "email should match test account"); 547 + assert!(password_hash.is_none(), "password_hash should be NULL for device-provisioned account"); 570 548 571 - // AC2.3: did_documents row with non-empty document 572 - let (doc,): (String,) = sqlx::query_as("SELECT document FROM did_documents WHERE did = ?") 573 - .bind(did) 574 - .fetch_one(&db) 575 - .await 576 - .expect("did_documents row should exist"); 577 - assert!(!doc.is_empty(), "did_document should be non-empty"); 549 + // AC2.3: did_documents row exists with non-empty document 550 + let doc_row: Option<(String,)> = 551 + sqlx::query_as("SELECT document FROM did_documents WHERE did = ?") 552 + .bind(did) 553 + .fetch_optional(&db) 554 + .await 555 + .unwrap(); 556 + let (document,) = doc_row.expect("did_documents row should exist"); 557 + assert!(!document.is_empty(), "document should be non-empty"); 578 558 579 - // AC2.4: handles row 580 - let (handle_did,): (String,) = sqlx::query_as("SELECT did FROM handles WHERE did = ?") 581 - .bind(did) 582 - .fetch_one(&db) 583 - .await 584 - .expect("handles row should exist"); 585 - assert_eq!(handle_did, did); 559 + // AC2.4: handles row links handle to did 560 + let handle_row: Option<(String,)> = 561 + sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 562 + .bind(&setup.handle) 563 + .fetch_optional(&db) 564 + .await 565 + .unwrap(); 566 + let (handle_did,) = handle_row.expect("handles row should exist"); 567 + assert_eq!(handle_did, did, "handles.did should match response did"); 586 568 587 569 // AC2.5: pending_accounts and pending_sessions deleted 588 570 let pending_count: i64 = ··· 591 573 .fetch_one(&db) 592 574 .await 593 575 .unwrap(); 594 - assert_eq!(pending_count, 0, "pending_account should be deleted"); 576 + assert_eq!(pending_count, 0, "pending_accounts row should be deleted"); 595 577 596 578 let session_count: i64 = 597 579 sqlx::query_scalar("SELECT COUNT(*) FROM pending_sessions WHERE account_id = ?") ··· 599 581 .fetch_one(&db) 600 582 .await 601 583 .unwrap(); 602 - assert_eq!(session_count, 0, "pending_sessions should be deleted"); 584 + assert_eq!(session_count, 0, "pending_sessions rows should be deleted"); 603 585 } 604 586 605 - /// MM-89.AC2.6: Retry path — pending_did pre-set, plc.directory NOT called 587 + // ── AC2.6: Retry path skips plc.directory ───────────────────────────────── 588 + 589 + /// MM-90.AC2.6: When pending_did already set, plc.directory is not called. 606 590 #[tokio::test] 607 591 async fn retry_with_pending_did_skips_plc_directory() { 608 592 let mock_server = MockServer::start().await; 609 - // Expect zero calls to plc.directory on a retry. 610 - // MockServer auto-verifies .expect(0) on drop — if plc.directory is called, 611 - // the mock panics and the test fails. 593 + // plc.directory must NOT be called on retry 612 594 Mock::given(method("POST")) 613 - .and(path_regex(r"^/did:plc:.*$")) 614 595 .respond_with(ResponseTemplate::new(200)) 615 - .expect(0) // Must NOT be called 616 - .named("plc.directory (should not be called on retry)") 596 + .expect(0) 597 + .named("plc.directory should not be called") 617 598 .mount(&mock_server) 618 599 .await; 619 600 620 601 let state = test_state_for_did(mock_server.uri()).await; 621 602 let db = state.db.clone(); 622 603 let setup = insert_test_data(&db).await; 604 + let (rotation_key_public, signed_op) = 605 + make_signed_op(&setup.handle, &state.config.public_url); 623 606 624 - // Derive the DID from the same inputs that the handler will use. 625 - // This ensures the pre-stored pending_did matches what the handler will derive. 626 - let rotation_key = crypto::DidKeyUri(setup.rotation_key_id.clone()); 627 - let signing_key = crypto::DidKeyUri(setup.signing_key_id.clone()); 628 - 629 - // Look up the private key (same as handler does). 630 - let (private_key_encrypted,): (String,) = 631 - sqlx::query_as("SELECT private_key_encrypted FROM relay_signing_keys WHERE id = ?") 632 - .bind(&setup.signing_key_id) 633 - .fetch_one(&db) 634 - .await 635 - .expect("signing key must exist"); 636 - 637 - let private_key_bytes = 638 - crypto::decrypt_private_key(&private_key_encrypted, &TEST_MASTER_KEY) 639 - .expect("decrypt key"); 640 - 641 - // Build the genesis op to get the DID (same as handler does). 642 - let genesis = crypto::build_did_plc_genesis_op( 643 - &rotation_key, 644 - &signing_key, 645 - &private_key_bytes, 646 - &setup.handle, 647 - &state.config.public_url, 607 + // Derive the DID from the signed op to pre-store it. 608 + let signed_op_str = serde_json::to_string(&signed_op).unwrap(); 609 + let verified = crypto::verify_genesis_op( 610 + &signed_op_str, 611 + &crypto::DidKeyUri(rotation_key_public.clone()), 648 612 ) 649 - .expect("build genesis"); 613 + .expect("verify should succeed"); 650 614 651 - let derived_did = genesis.did.clone(); 652 - 653 - // Simulate a partial-failure retry: pre-store the same DID that will be derived. 615 + // Pre-set pending_did to simulate a retry scenario. 654 616 sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 655 - .bind(&derived_did) 617 + .bind(&verified.did) 656 618 .bind(&setup.account_id) 657 619 .execute(&db) 658 620 .await ··· 660 622 661 623 let app = crate::app::app(state); 662 624 let response = app 663 - .oneshot(create_did_request( 664 - &setup.session_token, 665 - &setup.signing_key_id, 666 - &setup.rotation_key_id, 667 - )) 625 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 668 626 .await 669 627 .unwrap(); 670 628 671 - // The route detects the pre-stored DID, verifies it matches the derived DID, 672 - // skips plc.directory (enforced by .expect(0) above), and proceeds 673 - // to promote the account using the crypto-derived DID. Returns 200. 674 - assert_eq!( 675 - response.status(), 676 - StatusCode::OK, 677 - "retry should succeed with 200" 678 - ); 629 + assert_eq!(response.status(), StatusCode::OK, "retry should return 200"); 630 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 631 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 632 + assert_eq!(body["did"].as_str(), Some(verified.did.as_str()), "did should match pre-computed DID"); 633 + // wiremock verifies expect(0) on mock_server drop 679 634 } 680 635 681 - /// MM-89.AC2.7: Missing Authorization header returns 401 636 + // ── AC3.1: Invalid signature ─────────────────────────────────────────────── 637 + 638 + /// MM-90.AC3.1: Corrupted signature returns 400 INVALID_CLAIM. 682 639 #[tokio::test] 683 - async fn missing_auth_header_returns_401() { 684 - let state = test_state_with_plc_url("https://plc.directory".to_string()).await; 685 - let app = crate::app::app(state); 640 + async fn invalid_signature_returns_400() { 641 + let state = test_state_for_did("https://plc.directory".to_string()).await; 642 + let db = state.db.clone(); 643 + let setup = insert_test_data(&db).await; 644 + let (rotation_key_public, mut signed_op) = 645 + make_signed_op(&setup.handle, &state.config.public_url); 686 646 687 - let request = Request::builder() 688 - .method("POST") 689 - .uri("/v1/dids") 690 - .header("Content-Type", "application/json") 691 - .body(Body::from( 692 - r#"{"signingKey":"did:key:z...","rotationKey":"did:key:z..."}"#, 693 - )) 647 + // Corrupt the sig: decode, flip one byte, re-encode. 648 + let sig_str = signed_op["sig"].as_str().unwrap().to_string(); 649 + let mut sig_bytes = URL_SAFE_NO_PAD.decode(&sig_str).unwrap(); 650 + sig_bytes[0] ^= 0xff; 651 + signed_op["sig"] = serde_json::json!(URL_SAFE_NO_PAD.encode(&sig_bytes)); 652 + 653 + let app = crate::app::app(state); 654 + let response = app 655 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 656 + .await 694 657 .unwrap(); 695 658 696 - let response = app.oneshot(request).await.unwrap(); 697 - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 659 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "expected 400"); 660 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 661 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 662 + assert_eq!(body["error"]["code"], "INVALID_CLAIM"); 698 663 } 699 664 700 - /// MM-89.AC2.8: Expired session token returns 401 665 + // ── AC3.2: Wrong handle in alsoKnownAs ──────────────────────────────────── 666 + 667 + /// MM-90.AC3.2: alsoKnownAs mismatch returns 400 INVALID_CLAIM. 701 668 #[tokio::test] 702 - async fn expired_session_returns_401() { 669 + async fn wrong_handle_in_op_returns_400() { 703 670 let state = test_state_for_did("https://plc.directory".to_string()).await; 704 671 let db = state.db.clone(); 705 672 let setup = insert_test_data(&db).await; 706 - 707 - // Manually expire the session. 708 - sqlx::query("UPDATE pending_sessions SET expires_at = datetime('now', '-1 hour') WHERE account_id = ?") 709 - .bind(&setup.account_id) 710 - .execute(&db) 711 - .await 712 - .expect("expire session"); 673 + // Build op with a different handle — pending_accounts has setup.handle. 674 + let (rotation_key_public, signed_op) = 675 + make_signed_op("different.handle.com", &state.config.public_url); 713 676 714 677 let app = crate::app::app(state); 715 678 let response = app 716 - .oneshot(create_did_request( 717 - &setup.session_token, 718 - &setup.signing_key_id, 719 - &setup.rotation_key_id, 720 - )) 679 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 721 680 .await 722 681 .unwrap(); 723 682 724 - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 683 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "expected 400"); 684 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 685 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 686 + assert_eq!(body["error"]["code"], "INVALID_CLAIM"); 725 687 } 726 688 727 - /// MM-89.AC2.9: signingKey not in relay_signing_keys returns 404 689 + // ── AC3.3: Wrong service endpoint ───────────────────────────────────────── 690 + 691 + /// MM-90.AC3.3: services.atproto_pds.endpoint mismatch returns 400 INVALID_CLAIM. 728 692 #[tokio::test] 729 - async fn unknown_signing_key_returns_404() { 693 + async fn wrong_service_endpoint_returns_400() { 730 694 let state = test_state_for_did("https://plc.directory".to_string()).await; 731 695 let db = state.db.clone(); 732 696 let setup = insert_test_data(&db).await; 697 + // Build op with wrong service endpoint. 698 + let (rotation_key_public, signed_op) = 699 + make_signed_op(&setup.handle, "https://wrong.example.com"); 733 700 734 701 let app = crate::app::app(state); 735 702 let response = app 736 - .oneshot(create_did_request( 737 - &setup.session_token, 738 - "did:key:zNONEXISTENT", // Not in relay_signing_keys 739 - &setup.rotation_key_id, 740 - )) 703 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 741 704 .await 742 705 .unwrap(); 743 706 744 - assert_eq!(response.status(), StatusCode::NOT_FOUND); 707 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "expected 400"); 708 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 709 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 710 + assert_eq!(body["error"]["code"], "INVALID_CLAIM"); 745 711 } 746 712 747 - /// MM-89.AC2.10: Account already promoted returns 409 DID_ALREADY_EXISTS 713 + // ── AC3.4: rotationKeys[0] mismatch ─────────────────────────────────────── 714 + 715 + /// MM-90.AC3.4: rotationKeys[0] in op != rotationKeyPublic in request body → 400 INVALID_CLAIM. 748 716 /// 749 - /// The DID is deterministic from (rotation_key, signing_key, handle, service_endpoint). 750 - /// To reliably trigger 409, we: 751 - /// 1. First call promotes setup's account (deletes pending_accounts + pending_sessions). 752 - /// 2. Create a NEW pending account+session using the SAME signing key, rotation key, 753 - /// and handle as setup. Same inputs → same crypto-derived DID. 754 - /// 3. Second call: handler derives the same DID, finds the existing `accounts` row, 755 - /// returns 409 DID_ALREADY_EXISTS. 717 + /// To isolate semantic validation (not crypto failure): use kp_x as the signer 718 + /// (signature verifies with kp_x), but put kp_y at rotationKeys[0]. Send kp_x 719 + /// as rotationKeyPublic — verify passes (kp_x signed), but rotation_keys[0] == kp_y ≠ kp_x. 756 720 #[tokio::test] 757 - async fn already_promoted_account_returns_409() { 758 - let mock_server = MockServer::start().await; 759 - Mock::given(method("POST")) 760 - .and(path_regex(r"^/did:plc:.*$")) 761 - .respond_with(ResponseTemplate::new(200)) 762 - .expect(1) // Only first call should hit plc.directory 763 - .mount(&mock_server) 764 - .await; 721 + async fn wrong_rotation_key_in_op_returns_400() { 722 + use crypto::{build_did_plc_genesis_op, generate_p256_keypair}; 765 723 766 - let state = test_state_for_did(mock_server.uri()).await; 724 + let state = test_state_for_did("https://plc.directory".to_string()).await; 767 725 let db = state.db.clone(); 768 726 let setup = insert_test_data(&db).await; 769 - let signing_kp = crypto::generate_p256_keypair().expect("signing keypair"); 770 - let encrypted = 771 - crypto::encrypt_private_key(&signing_kp.private_key_bytes, &TEST_MASTER_KEY) 772 - .expect("encrypt key"); 773 - sqlx::query( 774 - "INSERT INTO relay_signing_keys \ 775 - (id, algorithm, public_key, private_key_encrypted, created_at) \ 776 - VALUES (?, 'p256', ?, ?, datetime('now'))", 727 + 728 + let kp_x = generate_p256_keypair().expect("signer keypair"); 729 + let kp_y = generate_p256_keypair().expect("rotation keypair"); 730 + let x_private = *kp_x.private_key_bytes; 731 + 732 + // Build op: rotationKeys[0] = kp_y, signing key = kp_x (signs with kp_x). 733 + let genesis_op = build_did_plc_genesis_op( 734 + &kp_y.key_id, // rotationKeys[0] = kp_y 735 + &kp_x.key_id, // signing key = kp_x, signs with kp_x's private key 736 + &x_private, 737 + &setup.handle, 738 + &state.config.public_url, 777 739 ) 778 - .bind(&signing_kp.key_id.0) 779 - .bind(&signing_kp.public_key) 780 - .bind(&encrypted) 781 - .execute(&db) 782 - .await 783 - .expect("insert second signing key"); 740 + .expect("genesis op"); 741 + let signed_op: serde_json::Value = 742 + serde_json::from_str(&genesis_op.signed_op_json).unwrap(); 784 743 785 - // First call: promotes setup's account (deletes pending_accounts + pending_sessions). 786 - let app1 = crate::app::app(state); 787 - let resp1 = app1 788 - .oneshot(create_did_request( 789 - &setup.session_token, 790 - &setup.signing_key_id, 791 - &setup.rotation_key_id, 792 - )) 744 + // Send request with rotationKeyPublic = kp_x (not kp_y). 745 + // verify_genesis_op(op, kp_x) passes (kp_x signed it), 746 + // but rotation_keys[0] == kp_y ≠ kp_x → semantic validation fails. 747 + let app = crate::app::app(state); 748 + let response = app 749 + .oneshot(create_did_request(&setup.session_token, &kp_x.key_id.0, &signed_op)) 793 750 .await 794 751 .unwrap(); 795 - assert_eq!(resp1.status(), StatusCode::OK, "first call should succeed"); 752 + 753 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "expected 400"); 754 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 755 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 756 + assert_eq!(body["error"]["code"], "INVALID_CLAIM"); 757 + } 758 + 759 + // ── AC3.5: Already promoted ──────────────────────────────────────────────── 796 760 797 - // setup's pending_accounts row is now deleted. Create a NEW pending account 798 - // with the SAME handle and signing key. Since pending_accounts.handle has no 799 - // unique constraint, we can reuse setup.handle here. 800 - let claim_code2 = format!("TEST-{}", Uuid::new_v4()); 801 - sqlx::query( 802 - "INSERT INTO claim_codes (code, expires_at, created_at) \ 803 - VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 804 - ) 805 - .bind(&claim_code2) 806 - .execute(&db) 807 - .await 808 - .expect("claim_code2"); 761 + /// MM-90.AC3.5: Account already promoted returns 409 DID_ALREADY_EXISTS. 762 + #[tokio::test] 763 + async fn already_promoted_account_returns_409() { 764 + let state = test_state_for_did("https://plc.directory".to_string()).await; 765 + let db = state.db.clone(); 766 + let setup = insert_test_data(&db).await; 767 + let (rotation_key_public, signed_op) = 768 + make_signed_op(&setup.handle, &state.config.public_url); 809 769 810 - let account_id2 = Uuid::new_v4().to_string(); 811 - sqlx::query( 812 - "INSERT INTO pending_accounts \ 813 - (id, email, handle, tier, claim_code, created_at) \ 814 - VALUES (?, ?, ?, 'free', ?, datetime('now'))", 770 + // Derive the DID and pre-insert an accounts row. 771 + let signed_op_str = serde_json::to_string(&signed_op).unwrap(); 772 + let verified = crypto::verify_genesis_op( 773 + &signed_op_str, 774 + &crypto::DidKeyUri(rotation_key_public.clone()), 815 775 ) 816 - .bind(&account_id2) 817 - .bind(format!("retry{}@example.com", &account_id2[..8])) 818 - .bind(&setup.handle) // same handle → same DID with same signing/rotation keys 819 - .bind(&claim_code2) 820 - .execute(&db) 821 - .await 822 - .expect("pending_account2"); 823 - 824 - let device_id2 = Uuid::new_v4().to_string(); 776 + .unwrap(); 825 777 sqlx::query( 826 - "INSERT INTO devices \ 827 - (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 828 - VALUES (?, ?, 'ios', 'retry_pubkey', 'retry_device_hash', datetime('now'), datetime('now'))", 778 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 779 + VALUES (?, 'other@example.com', NULL, datetime('now'), datetime('now'))", 829 780 ) 830 - .bind(&device_id2) 831 - .bind(&account_id2) 781 + .bind(&verified.did) 832 782 .execute(&db) 833 783 .await 834 - .expect("device2"); 784 + .expect("pre-insert promoted account"); 785 + 786 + let app = crate::app::app(state); 787 + let response = app 788 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 789 + .await 790 + .unwrap(); 835 791 836 - let mut token_bytes2 = [0u8; 32]; 837 - OsRng.fill_bytes(&mut token_bytes2); 838 - let session_token2 = URL_SAFE_NO_PAD.encode(token_bytes2); 839 - let token_hash2: String = Sha256::digest(token_bytes2) 840 - .iter() 841 - .map(|b| format!("{b:02x}")) 842 - .collect(); 843 - sqlx::query( 844 - "INSERT INTO pending_sessions \ 845 - (id, account_id, device_id, token_hash, created_at, expires_at) \ 846 - VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))", 847 - ) 848 - .bind(Uuid::new_v4().to_string()) 849 - .bind(&account_id2) 850 - .bind(&device_id2) 851 - .bind(&token_hash2) 852 - .execute(&db) 853 - .await 854 - .expect("session2"); 792 + assert_eq!(response.status(), StatusCode::CONFLICT, "expected 409"); 793 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 794 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 795 + assert_eq!(body["error"]["code"], "DID_ALREADY_EXISTS"); 796 + } 797 + 798 + // ── AC3.6: Missing auth ──────────────────────────────────────────────────── 855 799 856 - // Second call: same signing_key + rotation_key + handle → same DID. 857 - // accounts table already has this DID → handler returns 409. 858 - let state2 = test_state_for_did(mock_server.uri()).await; 859 - let app2 = crate::app::app(AppState { 860 - config: state2.config, 861 - db: db.clone(), 862 - http_client: state2.http_client, 863 - }); 864 - let resp2 = app2 865 - .oneshot(create_did_request( 866 - &session_token2, 867 - &setup.signing_key_id, // same signing key 868 - &setup.rotation_key_id, // same rotation key 800 + /// MM-90.AC3.6: Missing Authorization header returns 401 UNAUTHORIZED. 801 + #[tokio::test] 802 + async fn missing_auth_returns_401() { 803 + let state = test_state_for_did("https://plc.directory".to_string()).await; 804 + let signed_op = serde_json::json!({}); 805 + let request = Request::builder() 806 + .method("POST") 807 + .uri("/v1/dids") 808 + .header("Content-Type", "application/json") 809 + .body(Body::from( 810 + serde_json::json!({ 811 + "rotationKeyPublic": "did:key:z123", 812 + "signedCreationOp": signed_op 813 + }) 814 + .to_string(), 869 815 )) 870 - .await 871 816 .unwrap(); 872 - assert_eq!( 873 - resp2.status(), 874 - StatusCode::CONFLICT, 875 - "should return 409 DID_ALREADY_EXISTS" 876 - ); 817 + 818 + let app = crate::app::app(state); 819 + let response = app.oneshot(request).await.unwrap(); 820 + 821 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "expected 401"); 822 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 823 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 824 + assert_eq!(body["error"]["code"], "UNAUTHORIZED"); 877 825 } 878 826 879 - /// MM-89.AC2.11: plc.directory returns non-2xx → 502 PLC_DIRECTORY_ERROR 827 + // ── AC3.7: plc.directory error ──────────────────────────────────────────── 828 + 829 + /// MM-90.AC3.7: plc.directory non-2xx returns 502 PLC_DIRECTORY_ERROR. 880 830 #[tokio::test] 881 831 async fn plc_directory_error_returns_502() { 882 832 let mock_server = MockServer::start().await; 883 833 Mock::given(method("POST")) 884 - .and(path_regex(r"^/did:plc:.*$")) 885 - .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error")) 834 + .and(path_regex(r"^/did:plc:[a-z2-7]+$")) 835 + .respond_with(ResponseTemplate::new(500)) 886 836 .expect(1) 837 + .named("plc.directory returns 500") 887 838 .mount(&mock_server) 888 839 .await; 889 840 890 841 let state = test_state_for_did(mock_server.uri()).await; 891 842 let db = state.db.clone(); 892 843 let setup = insert_test_data(&db).await; 844 + let (rotation_key_public, signed_op) = 845 + make_signed_op(&setup.handle, &state.config.public_url); 893 846 894 847 let app = crate::app::app(state); 895 848 let response = app 896 - .oneshot(create_did_request( 897 - &setup.session_token, 898 - &setup.signing_key_id, 899 - &setup.rotation_key_id, 900 - )) 849 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 901 850 .await 902 851 .unwrap(); 903 852 904 - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 853 + assert_eq!(response.status(), StatusCode::BAD_GATEWAY, "expected 502"); 854 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 855 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 856 + assert_eq!(body["error"]["code"], "PLC_DIRECTORY_ERROR"); 905 857 } 906 858 }