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

docs: add test plan for MM-89 did:plc genesis op and account promotion

authored by malpercio.dev and committed by

Tangled dbbd02ef 5e5cf279

+110
+110
docs/test-plans/2026-03-13-MM-89.md
··· 1 + # Human Test Plan: MM-89 — DID Creation via PLC Directory Proxy 2 + 3 + **Generated from:** `docs/implementation-plans/2026-03-13-MM-89/` 4 + **Coverage status:** PASS — 19/19 acceptance criteria covered by automated tests 5 + 6 + --- 7 + 8 + ## Prerequisites 9 + 10 + - Development shell active (`nix develop --impure --accept-flake-config` or direnv auto-activation) 11 + - All tests passing: `cargo test` (253 tests total, 0 failures) 12 + - A local relay instance running with: 13 + - A valid `signing_key_master_key` configured 14 + - A valid `plc_directory_url` pointing to either a real PLC directory staging instance or a local mock 15 + - At least one relay signing key created via `POST /v1/relay/keys` 16 + 17 + --- 18 + 19 + ## Phase 1: Crypto Pure Core Verification (Optional) 20 + 21 + These steps verify the `build_did_plc_genesis_op` function output in isolation. Since this is a pure function fully covered by unit tests, Phase 1 is optional and included only for completeness. 22 + 23 + | Step | Action | Expected | 24 + |------|--------|----------| 25 + | 1.1 | Run `cargo test -p crypto plc::tests` | 7 tests pass in under 1 second | 26 + | 1.2 | Run `cargo test -p crypto plc::tests -- --nocapture did_matches_expected_format` | Test passes; output shows DID matching `did:plc:[a-z2-7]{24}` | 27 + 28 + --- 29 + 30 + ## Phase 2: Relay Integration (Happy Path) 31 + 32 + | Step | Action | Expected | 33 + |------|--------|----------| 34 + | 2.1 | Create a claim code: `POST /v1/accounts/claim-codes` with admin token, body `{"count": 1}`. Note the returned code. | 200 OK, JSON array with one claim code string | 35 + | 2.2 | Create a mobile account: `POST /v1/accounts/mobile` with body `{"email": "test@example.com", "handle": "testuser.example.com", "claimCode": "<code>", "platform": "ios", "publicKey": "<any-base64>", "deviceTokenHash": "<any-hex>"}`. Note the `sessionToken` and `accountId` from the response. | 200 OK, JSON with `accountId`, `sessionToken`, `deviceId` | 36 + | 2.3 | Create a signing key: `POST /v1/relay/keys` with admin token. Note the returned `id` (a `did:key:z...` URI). | 200 OK, JSON with `id`, `algorithm: "p256"` | 37 + | 2.4 | Generate a rotation key client-side (any valid P-256 `did:key:z...` URI). For manual testing, reuse the signing key ID from step 2.3 as a stand-in rotation key. | A `did:key:z...` string ready to use | 38 + | 2.5 | Create DID: `POST /v1/dids` with `Authorization: Bearer <sessionToken>`, body `{"signingKey": "<signing_key_id>", "rotationKey": "<rotation_key_id>"}`. | 200 OK, JSON `{"did": "did:plc:...", "status": "active"}` | 39 + | 2.6 | Verify the DID format in the response matches `did:plc:[a-z2-7]{24}`. | DID suffix is exactly 24 lowercase base32 characters | 40 + | 2.7 | Query the database: `SELECT did, email, password_hash FROM accounts WHERE did = '<returned_did>'` | Row exists; `email` matches step 2.2 input; `password_hash` is NULL | 41 + | 2.8 | Query the database: `SELECT document FROM did_documents WHERE did = '<returned_did>'` | Row exists; `document` is valid JSON containing `@context`, `id`, `verificationMethod`, `service` | 42 + | 2.9 | Query the database: `SELECT handle, did FROM handles WHERE did = '<returned_did>'` | Row exists; `handle` matches step 2.2 input; `did` matches the response | 43 + | 2.10 | Query the database: `SELECT COUNT(*) FROM pending_accounts WHERE id = '<accountId>'` | Returns 0 | 44 + | 2.11 | Query the database: `SELECT COUNT(*) FROM pending_sessions WHERE account_id = '<accountId>'` | Returns 0 | 45 + 46 + --- 47 + 48 + ## Phase 3: Relay Integration (Error Paths) 49 + 50 + | Step | Action | Expected | 51 + |------|--------|----------| 52 + | 3.1 | Send `POST /v1/dids` without an Authorization header, body `{"signingKey": "did:key:z...", "rotationKey": "did:key:z..."}` | 401 Unauthorized, error code `UNAUTHORIZED` | 53 + | 3.2 | Send `POST /v1/dids` with an expired or invalid Bearer token | 401 Unauthorized, error code `UNAUTHORIZED` | 54 + | 3.3 | Send `POST /v1/dids` with a valid session token but `signingKey` set to `"did:key:zNONEXISTENT"` (not in relay_signing_keys) | 404 Not Found, error code `NOT_FOUND` | 55 + | 3.4 | Repeat step 2.5 with the same session token (account already fully promoted, pending data deleted) | 401 Unauthorized (pending session no longer exists) | 56 + 57 + --- 58 + 59 + ## Phase 4: Retry Resilience 60 + 61 + | Step | Action | Expected | 62 + |------|--------|----------| 63 + | 4.1 | Create a new mobile account (steps 2.1–2.4 with fresh data). Before calling `POST /v1/dids`, manually set `pending_did` on the pending account: `UPDATE pending_accounts SET pending_did = 'did:plc:testretryvalue0000000' WHERE id = '<accountId>'` | UPDATE succeeds | 64 + | 4.2 | Call `POST /v1/dids` with the session token from step 4.1 | 200 OK. The handler detects `pending_did IS NOT NULL`, skips the plc.directory HTTP call, and proceeds directly to DB promotion. The returned DID is derived from the crypto function (not the manually-set value). | 65 + | 4.3 | Verify relay logs show the retry-detection message: `"retry detected: pending_did already set, skipping plc.directory"` | Log message present at INFO level | 66 + 67 + --- 68 + 69 + ## End-to-End: Full Account Lifecycle (Mobile Provisioning) 70 + 71 + **Purpose:** Validates the complete mobile account onboarding flow from claim code creation through DID assignment, spanning MM-84 (mobile account creation) and MM-89 (DID creation). 72 + 73 + | Step | Action | Expected | 74 + |------|--------|----------| 75 + | E2E.1 | Admin creates claim code (`POST /v1/accounts/claim-codes`) | Claim code returned | 76 + | E2E.2 | Client creates mobile account (`POST /v1/accounts/mobile`) using the claim code | `accountId`, `sessionToken`, `deviceId` returned; `pending_accounts` row created | 77 + | E2E.3 | Admin creates relay signing key (`POST /v1/relay/keys`) | Signing key `did:key:z...` returned | 78 + | E2E.4 | Client generates device P-256 keypair and derives `did:key:z...` rotation key | Rotation key ready | 79 + | E2E.5 | Client creates DID (`POST /v1/dids`) with session token, signing key, rotation key | `did:plc:...` and `status: "active"` returned | 80 + | E2E.6 | Verify `accounts` table has the DID with correct email, NULL password_hash | Row present | 81 + | E2E.7 | Verify `did_documents` table has a valid DID document JSON | Document contains `@context`, `verificationMethod`, `service` | 82 + | E2E.8 | Verify `handles` table links the handle to the DID | Row present, handle matches original input | 83 + | E2E.9 | Verify `pending_accounts`, `pending_sessions`, and `devices` rows are deleted | All counts are 0 for the original `accountId` | 84 + | E2E.10 | If using a real PLC directory staging instance, verify the DID resolves: `GET https://plc.directory/<did>` | Returns the PLC operation log containing the genesis operation | 85 + 86 + --- 87 + 88 + ## Traceability 89 + 90 + | Acceptance Criterion | Automated Test | Manual Step | 91 + |----------------------|----------------|-------------| 92 + | MM-89.AC1.1 | `crypto::plc::tests::did_matches_expected_format` | 1.2, 2.6 | 93 + | MM-89.AC1.2 | `crypto::plc::tests::signed_op_json_contains_required_fields` | E2E.10 (if real PLC directory) | 94 + | MM-89.AC1.3 | `crypto::plc::tests::keys_placed_in_correct_positions` | E2E.10 (if real PLC directory) | 95 + | MM-89.AC1.4 | `crypto::plc::tests::same_inputs_produce_same_did` | 4.2 (retry produces consistent DID) | 96 + | MM-89.AC1.5 | `crypto::plc::tests::invalid_signing_key_returns_error` | (no manual step needed) | 97 + | MM-89.AC2.1 | `relay::routes::create_did::tests::happy_path_promotes_account_and_returns_did` | 2.5, 2.6 | 98 + | MM-89.AC2.2 | `relay::routes::create_did::tests::happy_path_promotes_account_and_returns_did` | 2.7 | 99 + | MM-89.AC2.3 | `relay::routes::create_did::tests::happy_path_promotes_account_and_returns_did` | 2.8 | 100 + | MM-89.AC2.4 | `relay::routes::create_did::tests::happy_path_promotes_account_and_returns_did` | 2.9 | 101 + | MM-89.AC2.5 | `relay::routes::create_did::tests::happy_path_promotes_account_and_returns_did` | 2.10, 2.11 | 102 + | MM-89.AC2.6 | `relay::routes::create_did::tests::retry_with_pending_did_skips_plc_directory` | 4.1, 4.2, 4.3 | 103 + | MM-89.AC2.7 | `relay::routes::create_did::tests::missing_auth_header_returns_401` | 3.1 | 104 + | MM-89.AC2.8 | `relay::routes::create_did::tests::expired_session_returns_401` | 3.2 | 105 + | MM-89.AC2.9 | `relay::routes::create_did::tests::unknown_signing_key_returns_404` | 3.3 | 106 + | MM-89.AC2.10 | `relay::routes::create_did::tests::already_promoted_account_returns_409` | 3.4 (indirect) | 107 + | MM-89.AC2.11 | `relay::routes::create_did::tests::plc_directory_error_returns_502` | (requires PLC directory to be down; not practical for manual testing) | 108 + | MM-89.AC3.1 | (implicit: all 7 relay integration tests apply V008 migration) | 2.7 (NULL password_hash), 4.1 (pending_did column) | 109 + | MM-89.AC3.2 | `crypto::plc::tests::sig_field_is_base64url_no_padding_and_64_bytes` | (no manual step needed) | 110 + | MM-89.AC3.3 | `crypto::plc::tests::also_known_as_contains_at_uri` | E2E.10 (if real PLC directory) |