···11+# MM-146 DID Ceremony Flow — Test Plan
22+33+## Coverage Summary
44+55+**Automated Criteria:** 11 | **Covered:** 11 | **Missing:** 0
66+77+### Automated Test Coverage
88+99+| Criterion | Test File | Verifies |
1010+|-----------|-----------|----------|
1111+| **AC1.1** Returns 200 with `{ keyId, publicKey, algorithm }` when a signing key is provisioned | `crates/relay/src/routes/get_relay_signing_key.rs` :: `get_relay_keys_returns_200_with_active_key` | Inserts a test key via `insert_test_key`, sends GET /v1/relay/keys, asserts 200 status, deserializes body and asserts `keyId`, `algorithm`, and `publicKey` presence in the JSON response. |
1212+| **AC1.2** Returns the most recently created key when multiple keys exist | `crates/relay/src/routes/get_relay_signing_key.rs` :: `get_relay_keys_returns_most_recently_created_key` | Inserts two keys with different `created_at` timestamps ("2026-01-01" and "2026-01-02"), sends GET, asserts that the response `keyId` matches the newer key ("did:key:zNewerKey"). Confirms the `ORDER BY created_at DESC LIMIT 1` query logic. |
1313+| **AC1.3** Returns 503 when no signing key is provisioned | `crates/relay/src/routes/get_relay_signing_key.rs` :: `get_relay_keys_returns_503_when_no_key_provisioned` | Uses an empty test DB (no keys inserted), sends GET, asserts `StatusCode::SERVICE_UNAVAILABLE` (503). |
1414+| **AC1.4** Endpoint requires no authentication (public, no Bearer token) | `crates/relay/src/routes/get_relay_signing_key.rs` :: `get_relay_keys_requires_no_authentication` | `get_keys()` helper sends no Authorization header. Test inserts a key and asserts 200 response, proving the endpoint is accessible without auth. Additionally confirmed in `app.rs` line 124: the route is registered as `.route("/v1/relay/keys", get(get_relay_signing_key).post(create_signing_key))` with no auth middleware layer applied to the GET handler. The `test_state()` has `admin_token: None`, and the handler signature takes only `State(state)` with no auth extractor. |
1515+| **AC2.1** Callback receives CBOR-encoded unsigned op bytes; returned `PlcGenesisOp` passes `verify_genesis_op` | `crates/crypto/src/plc.rs` :: `external_signer_callback_produces_valid_genesis_op` | Generates a real P-256 keypair, creates a `SigningKey` from it, passes a closure that calls `Signer::sign(&sk, data)` to `build_did_plc_genesis_op_with_external_signer`. Then calls `verify_genesis_op` on the resulting `signed_op_json` with the signing key and asserts the verified DID matches the builder's DID. This proves the callback receives valid CBOR and the resulting op is cryptographically valid. |
1616+| **AC2.2** Callback returning `Err` propagates as `CryptoError::PlcOperation` | `crates/crypto/src/plc.rs` :: `external_signer_callback_error_propagates_as_plc_operation` | Passes a callback that returns `Err(CryptoError::PlcOperation("SE signing failed"))`. Asserts the result is `Err`, matches `CryptoError::PlcOperation`, and the error message contains "SE signing failed". |
1717+| **AC2.3** Existing `build_did_plc_genesis_op` (wrapper) produces identical output to before | `crates/crypto/src/plc.rs` :: *(all pre-existing tests)* | The existing tests (`did_matches_expected_format`, `signed_op_json_contains_required_fields`, `keys_placed_in_correct_positions`, `same_inputs_produce_same_did`, `invalid_signing_key_returns_error`, `sig_field_is_base64url_no_padding_and_64_bytes`, `also_known_as_contains_at_uri`, plus all `verify_genesis_op` tests) all call `build_did_plc_genesis_op`, which now delegates to `build_did_plc_genesis_op_with_external_signer` internally. If the wrapper delegation introduced a regression, these tests would fail. No new test code needed. |
1818+| **AC3.4** `NoRelaySigningKey` serializes as `{ code: "NO_RELAY_SIGNING_KEY" }` | `apps/identity-wallet/src-tauri/src/lib.rs` :: `did_ceremony_error_no_relay_signing_key_serializes_correctly` | Serializes `DIDCeremonyError::NoRelaySigningKey` to JSON and asserts `json["code"] == "NO_RELAY_SIGNING_KEY"`. |
1919+| **AC3.5** `RelayKeyFetchFailed` serializes correctly | `apps/identity-wallet/src-tauri/src/lib.rs` :: `did_ceremony_error_relay_key_fetch_failed_serializes_correctly` | Serializes `DIDCeremonyError::RelayKeyFetchFailed` to JSON and asserts `json["code"] == "RELAY_KEY_FETCH_FAILED"`. |
2020+| **AC3.6** `SigningFailed` serializes correctly | `apps/identity-wallet/src-tauri/src/lib.rs` :: `did_ceremony_error_signing_failed_serializes_correctly` | Serializes `DIDCeremonyError::SigningFailed` to JSON and asserts `json["code"] == "SIGNING_FAILED"`. |
2121+| **AC3.7** `DidCreationFailed` serializes correctly | `apps/identity-wallet/src-tauri/src/lib.rs` :: `did_ceremony_error_did_creation_failed_serializes_correctly` | Serializes `DIDCeremonyError::DidCreationFailed` to JSON and asserts `json["code"] == "DID_CREATION_FAILED"`. |
2222+2323+### Supporting Tests (not mapped to ACs but strengthen coverage)
2424+2525+| Test | File | Verifies |
2626+|------|------|----------|
2727+| `did_ceremony_result_serializes_did_in_camel_case` | `lib.rs` | `DIDCeremonyResult { did }` serializes with `did` field name (camelCase rename from `#[serde(rename_all = "camelCase")]`). Ensures the TypeScript `DIDCeremonyResult.did` field matches. |
2828+| `did_ceremony_error_key_not_found_serializes_correctly` | `lib.rs` | `KeyNotFound` variant serializes as `{ code: "KEY_NOT_FOUND" }`. |
2929+| `did_ceremony_error_keychain_error_serializes_correctly` | `lib.rs` | `KeychainError` variant serializes as `{ code: "KEYCHAIN_ERROR" }`. |
3030+| `did_ceremony_error_network_error_serializes_with_message` | `lib.rs` | `NetworkError { message }` variant serializes as `{ code: "NETWORK_ERROR", message: "..." }`. |
3131+3232+---
3333+3434+## Human Test Plan
3535+3636+### Prerequisites
3737+3838+- macOS with Xcode installed (iOS Simulator platform downloaded)
3939+- Nix dev shell activated from workspace root: `nix develop --impure --accept-flake-config`
4040+- Frontend dependencies installed: `cd apps/identity-wallet && pnpm install`
4141+- Xcode project generated: `cargo tauri ios init` (with PATH and sandbox patches applied per CLAUDE.md)
4242+- `cargo test` passing (confirms all automated criteria are green)
4343+- A local relay available to start/stop: `cargo run -p relay` (with appropriate config)
4444+4545+### Phase 1: Relay Endpoint Smoke Test (AC1)
4646+4747+These are already fully automated but serve as a quick sanity check before mobile testing.
4848+4949+| Step | Action | Expected |
5050+|------|--------|----------|
5151+| 1 | Start the local relay: `cargo run -p relay` | Relay starts on `http://localhost:8080` |
5252+| 2 | Provision a signing key: `POST http://localhost:8080/v1/relay/keys` with admin auth (use Bruno collection, `local` environment) | 201 response with `keyId`, `publicKey`, `algorithm` |
5353+| 3 | Fetch the key without auth: `curl http://localhost:8080/v1/relay/keys` | 200 response with JSON `{ "keyId": "did:key:z...", "publicKey": "z...", "algorithm": "p256" }` |
5454+| 4 | Stop the relay. Start a fresh relay (empty database). Fetch the key: `curl http://localhost:8080/v1/relay/keys` | 503 response with error body |
5555+5656+### Phase 2: DID Ceremony Happy Path (AC3.1, AC3.2, AC3.3, AC4.1, AC4.2)
5757+5858+| Step | Action | Expected |
5959+|------|--------|----------|
6060+| 1 | Start the local relay and provision a signing key (POST /v1/relay/keys with admin auth) | Relay running with active signing key |
6161+| 2 | Generate a claim code via the relay admin API (POST /v1/accounts/claim-codes with admin auth) | Claim code returned |
6262+| 3 | Launch the app on the iOS Simulator: `cd apps/identity-wallet && cargo tauri ios dev` | App opens in Simulator showing the Welcome screen |
6363+| 4 | Tap "Get Started" on the Welcome screen | App transitions to the Claim Code screen |
6464+| 5 | Enter the claim code from step 2, tap "Next" | App transitions to the Email screen |
6565+| 6 | Enter a valid email address (e.g. `test@example.com`), tap "Next" | App transitions to the Handle screen |
6666+| 7 | Enter a valid handle (e.g. `alice`), tap "Next" | App shows the loading screen with text "Creating your account..." briefly, then transitions to the DID Ceremony screen |
6767+| 8 | **Observe the DID Ceremony loading state** | The screen displays `LoadingScreen` with text "Setting up your identity..." (confirms AC4.1). This may be brief on localhost — use Network Link Conditioner to add latency if needed. |
6868+| 9 | **Wait for the ceremony to complete** | The app transitions to the DID Success screen showing: heading "Identity Created!", a truncated DID in `did:plc:xxxxx…xxxx` format, and a "Continue" button (confirms AC4.2) |
6969+| 10 | **Verify the DID format** | The displayed DID starts with `did:plc:` and the full (non-truncated) DID is 32 characters (`did:plc:` prefix + 24 alphanumeric chars). Verify by checking Xcode console logs or relay database. (confirms AC3.1) |
7070+| 11 | **Verify Keychain session-token was overwritten** | In Terminal (while simulator is still running): check Xcode console for `tracing` log output from `keychain::store_item`, or restart the app and verify it reads the upgraded token. (confirms AC3.2) |
7171+| 12 | **Verify Keychain DID was stored** | Same approach as step 11, checking for key `"did"`. The stored value should match the DID shown on the success screen. (confirms AC3.3) |
7272+7373+### Phase 3: DID Success Screen Navigation (AC4.5)
7474+7575+| Step | Action | Expected |
7676+|------|--------|----------|
7777+| 1 | After a successful ceremony (from Phase 2 step 9), tap the "Continue" button on the DID Success screen | App transitions to a placeholder screen with heading "Backup" and text "Shamir backup coming soon…" |
7878+7979+### Phase 4: Error Path — No Relay Signing Key (AC3.4, AC4.3)
8080+8181+| Step | Action | Expected |
8282+|------|--------|----------|
8383+| 1 | Start a fresh local relay instance (empty database, no signing key provisioned) | Relay running, GET /v1/relay/keys returns 503 |
8484+| 2 | Launch the app on the iOS Simulator and complete account creation (claim code, email, handle) — the relay must accept the account creation step, so provision a claim code first | App reaches the DID Ceremony screen |
8585+| 3 | **Observe the error state** | The ceremony screen shows red error text: "The relay hasn't been configured yet. Please try again later." and a "Retry" button. The app does NOT navigate back to a previous screen. (confirms AC3.4 runtime behavior, AC4.3) |
8686+8787+### Phase 5: Error Path — Relay Unreachable (AC3.5, AC4.3, AC4.4)
8888+8989+| Step | Action | Expected |
9090+|------|--------|----------|
9191+| 1 | Start the relay, provision a signing key, and provision a claim code | Relay ready |
9292+| 2 | Launch the app and complete account creation (claim code, email, handle) | App creates the account and transitions to the DID Ceremony screen |
9393+| 3 | **Immediately stop the relay process** (kill or Ctrl-C) before or during the ceremony | The ceremony screen shows red error text: "Couldn't reach the server. Check your connection." and a "Retry" button. The app does NOT navigate back. (confirms AC3.5 runtime behavior, AC4.3) |
9494+| 4 | **Restart the relay** (with the same database so the signing key is still provisioned) | Relay running again |
9595+| 5 | **Tap the "Retry" button** | The loading screen reappears with "Setting up your identity…", the ceremony re-executes from the beginning (re-fetches device key, relay key, builds genesis op), and on success transitions to the DID Success screen. (confirms AC4.4) |
9696+9797+### Phase 6: Error Path — DID Creation Fails (AC3.7)
9898+9999+| Step | Action | Expected |
100100+|------|--------|----------|
101101+| 1 | Start the relay, provision a signing key and claim code | Relay ready |
102102+| 2 | Complete account creation in the app | App transitions to DID Ceremony screen |
103103+| 3 | **Cause POST /v1/dids to fail.** Options: (a) use the same account that already has a DID (re-run the ceremony after a successful one — the pending session token is now invalid), or (b) use a proxy like mitmproxy to intercept and return a 400/500 for POST /v1/dids | The ceremony screen shows: "Couldn't create your identity. Please try again." and a "Retry" button. (confirms AC3.7 runtime behavior) |
104104+105105+### Phase 7: Error Path — Signing Failed (AC3.6)
106106+107107+| Step | Action | Expected |
108108+|------|--------|----------|
109109+| 1 | **Code review verification** (this error is difficult to trigger on Simulator): Inspect `DIDCeremonyScreen.svelte` line 47 | The `errorMessage` function maps `SIGNING_FAILED` to "Device signing failed. Please try again." |
110110+| 2 | **Automated test verification**: Confirm `did_ceremony_error_signing_failed_serializes_correctly` passes in `cargo test -p identity-wallet` | `DIDCeremonyError::SigningFailed` serializes as `{ "code": "SIGNING_FAILED" }`, matching the TypeScript switch case |
111111+| 3 | **Indirect runtime verification**: If a Secure Enclave signing failure can be triggered (e.g., by revoking key access on a physical device with biometric policy), the error path would produce the message above. On Simulator, the software signing path uses RFC 6979 and is unlikely to fail. | Accept as verified via code review and serde test. |
112112+113113+### End-to-End: Full Onboarding Flow
114114+115115+**Purpose:** Validates that all eight onboarding steps work in sequence, end-to-end, with real IPC round-trips between the Svelte frontend and Rust backend.
116116+117117+**Steps:**
118118+1. Start the local relay, provision a signing key and a claim code.
119119+2. Launch the app on the iOS Simulator via `cargo tauri ios dev`.
120120+3. Welcome screen: tap "Get Started".
121121+4. Claim Code screen: enter the claim code, tap "Next".
122122+5. Email screen: enter an email, tap "Next".
123123+6. Handle screen: enter a handle, tap "Next".
124124+7. Loading screen: observe "Creating your account..." text (brief).
125125+8. DID Ceremony screen: observe "Setting up your identity..." loading state.
126126+9. DID Success screen: verify "Identity Created!" heading, truncated DID, and "Continue" button.
127127+10. Tap "Continue": verify the Shamir Backup placeholder screen appears ("Backup" / "Shamir backup coming soon…").
128128+11. Verify the relay database contains the new DID record (query `SELECT * FROM dids ORDER BY created_at DESC LIMIT 1`).
129129+130130+---
131131+132132+## Human Verification Required
133133+134134+| Criterion | Why Manual | Steps |
135135+|-----------|------------|-------|
136136+| AC3.1: Returns `DIDCeremonyResult { did }` with valid `did:plc:` | Requires Keychain (token retrieval), Secure Enclave/software signing, and live HTTP round-trips to a running relay. No mock seams exist. | Phase 2, steps 8-10 |
137137+| AC3.2: Keychain `"session-token"` overwritten with full session token | Keychain writes require a running app with correct entitlements; `Security.framework` calls cannot be mocked in `cargo test`. | Phase 2, step 11 |
138138+| AC3.3: Keychain `"did"` populated with resulting DID | Same as AC3.2. | Phase 2, step 12 |
139139+| AC3.4: `NoRelaySigningKey` when relay has no key (runtime) | Requires an actual HTTP 503 from a real relay with no signing key. | Phase 4, steps 1-3 |
140140+| AC3.5: `RelayKeyFetchFailed` when relay unreachable (runtime) | Requires actual network failure (relay process stopped). | Phase 5, steps 1-3 |
141141+| AC3.6: `SigningFailed` when SE signing fails (runtime) | Secure Enclave failures cannot be reliably triggered on Simulator. Verified via code review + serde test. | Phase 7 |
142142+| AC3.7: `DidCreationFailed` when POST /v1/dids returns non-2xx (runtime) | Requires specific relay state producing a non-2xx response. | Phase 6, steps 1-3 |
143143+| AC4.1: Loading screen with "Setting up your identity..." | UI rendering requires Tauri runtime + WKWebView. | Phase 2, step 8 |
144144+| AC4.2: Success screen with truncated DID and "Continue" button | Requires real DID from ceremony and Svelte rendering pipeline. | Phase 2, step 9 |
145145+| AC4.3: Error shows inline message and Retry button (no rewind) | Tests the error UI path end-to-end. | Phase 4, step 3; Phase 5, step 3 |
146146+| AC4.4: Retry button re-invokes ceremony from beginning | Verifies `runCeremony()` is called again from scratch. | Phase 5, steps 4-5 |
147147+| AC4.5: "Continue" transitions to `shamir_backup` placeholder | Simple navigation check. | Phase 3, step 1 |
148148+149149+---
150150+151151+## Traceability
152152+153153+| Acceptance Criterion | Automated Test | Manual Step |
154154+|----------------------|----------------|-------------|
155155+| AC1.1: GET /v1/relay/keys returns 200 with key fields | `get_relay_keys_returns_200_with_active_key` | Phase 1, step 3 |
156156+| AC1.2: Returns most recently created key | `get_relay_keys_returns_most_recently_created_key` | — |
157157+| AC1.3: Returns 503 when no key provisioned | `get_relay_keys_returns_503_when_no_key_provisioned` | Phase 1, step 4 |
158158+| AC1.4: No authentication required | `get_relay_keys_requires_no_authentication` | Phase 1, step 3 (no auth header used) |
159159+| AC2.1: External signer callback produces valid genesis op | `external_signer_callback_produces_valid_genesis_op` | — |
160160+| AC2.2: Callback error propagates as PlcOperation | `external_signer_callback_error_propagates_as_plc_operation` | — |
161161+| AC2.3: Wrapper produces identical output | *(all pre-existing plc.rs tests)* | — |
162162+| AC3.1: Ceremony returns valid did:plc | — | Phase 2, steps 8-10 |
163163+| AC3.2: Keychain session-token overwritten | — | Phase 2, step 11 |
164164+| AC3.3: Keychain did populated | — | Phase 2, step 12 |
165165+| AC3.4: NoRelaySigningKey error (serde + runtime) | `did_ceremony_error_no_relay_signing_key_serializes_correctly` | Phase 4, steps 1-3 |
166166+| AC3.5: RelayKeyFetchFailed error (serde + runtime) | `did_ceremony_error_relay_key_fetch_failed_serializes_correctly` | Phase 5, steps 1-3 |
167167+| AC3.6: SigningFailed error (serde + runtime) | `did_ceremony_error_signing_failed_serializes_correctly` | Phase 7 (code review) |
168168+| AC3.7: DidCreationFailed error (serde + runtime) | `did_ceremony_error_did_creation_failed_serializes_correctly` | Phase 6, steps 1-3 |
169169+| AC4.1: Loading screen during ceremony | — | Phase 2, step 8 |
170170+| AC4.2: Success screen with truncated DID | — | Phase 2, step 9 |
171171+| AC4.3: Error shows inline message + Retry (no rewind) | — | Phase 4, step 3; Phase 5, step 3 |
172172+| AC4.4: Retry re-invokes ceremony from beginning | — | Phase 5, steps 4-5 |
173173+| AC4.5: Continue transitions to shamir_backup | — | Phase 3, step 1 |