From 59ae028a360242d43ffbfdafca8a76eda00484d9 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 13 Nov 2026 11:55:32 -0700 Subject: [PATCH] fix(mobile): require Builderlab consent for existing identities (#8823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing mobile identities entering the Builderlab hosted flow now require confirmation before the app reserves the account’s identity slot, binds the identity, or uploads its secret key. Cancelling leaves saved community keys usable and allows another identity to be selected, including after restart. Imported keys remain tentative until approval; paired keys remain safely retained outside the account slot. The confirmation shows the receiving account’s verified email and public key, explains that Builderlab can decrypt the backup and signed-in account users can recover it, and discloses that the app cannot change the linked identity later. **Cancel setup** stops future work without claiming to undo earlier binding or an upload whose acknowledgement was lost. The user can sign out; missing account identification blocks confirmation. Browser-initiated binding uses **Link and continue**, with explicit bind-only wording and no upload. Fresh keys generated in the current hosted attempt use ordinary onboarding without an extra backup prompt. Interrupted attempts without a confirmed backup ask again; approval is deliberately not persisted or carried across retries, cancellation, restart, account changes, or a replaced flow. Confirmed matching backup receipts skip the backup prompt. Cloud recovery and organization-managed backup are separate flows and are not changed here; this PR does not resolve server-side consent or recovery-authorization policy. Updated against main through `ce42853982` (#9418). Integration commit `dfb29fe5e7` includes the landed Hickory security fix and the shared workflow-test/runner repair. The consent implementation is unchanged from Carl’s reviewed `e11025ebed`; the CI merge preserves its configured public consent journeys. Validation at `e11025ebed`: 96 focused tests across eight files passed with public hosted domains configured; Flutter analysis passed. In an isolated source/Git/build copy, removing pre-adoption consent, bypassing the upload gate, and removing stale-confirmation checks each failed the consent journeys; restoring the code passed. Carl’s independent source review found no blockers. Mandatory hooks and hosted mobile CI passed, including both consent journeys in the configured public lane. Integration validation at `dfb29fe5e7`: 99 focused tests passed; one additional fullscreen-onboarding test failed its background community-admission assertion. An isolated clean-main control at `ce42853982` reproduces the same failure with the same expected/actual values. All consent journeys passed. Required commit and push hooks passed, including mobile analysis and the broader default mobile suite. Hosted CI is green at `dfb29fe5e7` ([run 43320753801](https://github.com/block/buzz/actions/runs/43320753801)). Security and the previously failing Rust/workflow gates now pass. The Mobile Checks log confirms both consent journeys executed successfully; that run has no pending checks. Review follow-up `ac9ac9be35`, integrated with main `279a31daac` at current head `1acfd2dff6`, is test-only: the browser journey now restores a saved account key without a receipt, cancels without binding, restarts and requires explicit approval before `/verify`. Both consent journeys passed again after integration using the updated CI settings. Disabling only the production bind gate in an isolated source/Git/build copy now fails the added step; restoring it passes. Mandatory commit/push hooks passed. Hosted CI is green at `1acfd2dff6` ([run 43343333843](https://github.com/block/buzz/actions/runs/43343333843)); no failing or pending checks remain. The Mobile Checks log confirms both consent journeys executed successfully, including the extended browser-binding journey. Carl’s latest review on `dfb29fe5e7` found no production blockers; its recommended test gap is addressed by this follow-up. Native-device and human acceptance have not been recorded here; the author’s ready-for-review setting is preserved. Try it: with two saved self-hosted keys, connect to Builderlab, select A, cancel, restart, then select B. Check the receiving account and key before approval. Repeat with an imported/paired key and a browser bind link; cancel must not reserve or bind the new selection. After an interrupted upload, cancellation must describe that earlier effects may remain. Confirm that ordinary brand-new onboarding has no extra backup step. --------- Signed-off-by: Wes Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- .github/workflows/_ci-clients.yml | 1 + .../onboarding/hosted_connect_sheet.dart | 53 ++- .../shared/hosted/account_identity_store.dart | 3 + mobile/lib/shared/hosted/hosted_flow.dart | 101 +++-- .../hosted/hosted_flow_backup_consent.dart | 135 +++++++ .../hosted/hosted_identity_service.dart | 40 +- .../hosted_backup_consent_test.dart | 382 ++++++++++++++++++ .../onboarding/hosted_journeys_test.dart | 125 +----- .../pairing/pairing_hosted_handoff_test.dart | 7 +- .../test/helpers/hosted_journey_fixture.dart | 2 + .../hosted/account_identity_store_test.dart | 4 + .../device_identity_reconciliation_test.dart | 7 +- .../hosted/hosted_communities_flow_test.dart | 4 + .../identity_choice_lifecycle_test.dart | 2 + 14 files changed, 695 insertions(+), 171 deletions(-) create mode 100644 mobile/lib/shared/hosted/hosted_flow_backup_consent.dart create mode 100644 mobile/test/features/onboarding/hosted_backup_consent_test.dart diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index f067300e98..6fe925c827 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -163,6 +163,7 @@ jobs: test/features/onboarding/hosted_app_gate_environment_test.dart test/features/settings/builderlab_account_row_test.dart test/features/onboarding/hosted_journeys_test.dart + test/features/onboarding/hosted_backup_consent_test.dart test/features/onboarding/onboarding_sign_out_test.dart test/features/onboarding/hosted_communities_test.dart test/features/pairing/pairing_hosted_handoff_test.dart diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart index f7e98fe264..bf9931a4c6 100644 --- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/hosted/hosted_environment.dart'; import '../../shared/widgets/onboarding_step_page.dart'; import '../../shared/widgets/bee_loading_mark.dart'; import '../../shared/widgets/onboarding_loading_page.dart'; @@ -90,6 +91,20 @@ class HostedConnectSheet extends HookConsumerWidget { ? 'Create a new Buzz identity, or import the identity you already use.' : 'Bring your existing Buzz identity from your other device.' : 'Your identity hasn’t been backed up yet. Bring it from your other device to keep using the same Buzz account.', + HostedFlowStage.confirmBackup => + flow.bindingOnly + ? 'Link this identity to the receiving Builderlab account? ' + 'This app cannot change the linked identity later. ' + 'No backup is uploaded by this browser handoff. ' + 'Cancelling stops setup; it does not undo any earlier linking.' + : 'Allow Builderlab to link this identity to your account and upload ' + 'an encrypted copy of your secret key (nsec) to ' + '${HostedEnvironment.origin.host}? Builderlab can decrypt this backup. ' + 'Anyone signed in to this account can recover the key and use your ' + 'identity, including on self-hosted relays. ' + 'This app cannot change the linked identity later. ' + 'Joining requires a backup. Cancelling stops setup; it does not undo ' + 'any earlier linking or upload, including an upload whose confirmation was lost.', HostedFlowStage.chooseCommunities => 'Select a community', HostedFlowStage.communityConflict => '${flow.addingCommunityName} is already saved with a different identity. ' @@ -145,6 +160,37 @@ class HostedConnectSheet extends HookConsumerWidget { ], if (!fullScreen) Text(message, style: context.textTheme.bodyLarge), const SizedBox(height: Grid.sm), + if (stage == HostedFlowStage.confirmBackup) ...[ + Text( + flow.backupAccountEmail == null + ? 'We couldn’t identify the receiving account. Sign out and sign in again before continuing.' + : 'Receiving account: ${flow.backupAccountEmail}', + style: context.textTheme.bodyLarge, + ), + const SizedBox(height: Grid.twelve), + SelectableText( + fullNpub(flow.backupPubkey ?? '') ?? '', + style: context.textTheme.bodySmall, + ), + const SizedBox(height: Grid.twelve), + FilledButton( + style: actionStyle, + onPressed: flow.confirmBackup, + child: Text( + flow.bindingOnly ? 'Link and continue' : 'Back up and continue', + ), + ), + TextButton( + style: actionStyle, + onPressed: () => flow.leave(), + child: const Text('Cancel setup'), + ), + TextButton( + style: actionStyle, + onPressed: flow.signOutAccount, + child: const Text('Sign out'), + ), + ], for (final choice in flow.identityChoices) Padding( padding: const EdgeInsets.only(bottom: Grid.sm), @@ -186,7 +232,9 @@ class HostedConnectSheet extends HookConsumerWidget { ), const SizedBox(height: Grid.twelve), Text( - 'We’ll securely back it up so your next sign-in is easier.', + flow.bindingOnly + ? 'This links the identity to your Builderlab account. This app cannot change it later. No backup is uploaded by this browser handoff.' + : 'New identities are linked to your Builderlab account and backed up automatically. This app cannot change the linked identity later. Existing keys require confirmation before backup.', textAlign: TextAlign.center, style: context.textTheme.bodySmall, ), @@ -253,6 +301,7 @@ class HostedConnectSheet extends HookConsumerWidget { stage != HostedFlowStage.acceptPolicy && stage != HostedFlowStage.chooseIdentity && stage != HostedFlowStage.setupIdentity && + stage != HostedFlowStage.confirmBackup && stage != HostedFlowStage.chooseCommunities && stage != HostedFlowStage.communityConflict && stage != HostedFlowStage.complete && @@ -282,6 +331,8 @@ class HostedConnectSheet extends HookConsumerWidget { ? 'Create or import a Buzz identity' : 'Import your Buzz identity' : 'Bring your identity with you', + HostedFlowStage.confirmBackup => + flow.bindingOnly ? 'Link your identity?' : 'Back up your secret key?', HostedFlowStage.chooseIdentity => 'Choose your identity', HostedFlowStage.working => flow.addingCommunityName == null diff --git a/mobile/lib/shared/hosted/account_identity_store.dart b/mobile/lib/shared/hosted/account_identity_store.dart index 5f7d93cbb0..5d33948760 100644 --- a/mobile/lib/shared/hosted/account_identity_store.dart +++ b/mobile/lib/shared/hosted/account_identity_store.dart @@ -175,8 +175,10 @@ class AccountIdentityStore implements BackupIdentity { /// Authorization runs under the identity writer lock and must not reenter it. /// A completed but unacknowledged write is reused on retry, never regenerated. + /// [onGenerated] runs only after this call confirmed a newly generated key. Future generateIfAbsent({ required Future Function() authorize, + void Function()? onGenerated, }) => _serialized(() async { final existing = await _read(); if (existing != null) return existing.snapshot; @@ -184,6 +186,7 @@ class AccountIdentityStore implements BackupIdentity { final keys = nostr.Keys.generate(); final record = _Record(keys.nsec, keys.public, null); await _write(record); + onGenerated?.call(); return record.snapshot; }); diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 1ecd49ebed..ab44779677 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -22,6 +22,7 @@ import 'web_connect_binding.dart'; import 'hosted_join_policy.dart'; part 'hosted_flow_identity_recovery.dart'; +part 'hosted_flow_backup_consent.dart'; enum HostedFlowStage { idle, @@ -29,6 +30,7 @@ enum HostedFlowStage { signIn, chooseIdentity, setupIdentity, + confirmBackup, chooseCommunities, communityConflict, acceptPolicy, @@ -139,6 +141,9 @@ class HostedFlow extends ChangeNotifier { String? requiredIdentityPubkey; String? _pairedPubkey; + ({String subject, String pubkey})? _pendingBackup; + String? _pendingImport; + String? get backupPubkey => _pendingBackup?.pubkey; SensitiveActionPolicy? pairedSensitiveActionPolicy; List communities = const []; Set selectedRelays = const {}; @@ -180,6 +185,8 @@ class HostedFlow extends ChangeNotifier { int _generation = 0; int _nextGeneration() { + _pendingBackup = null; + _pendingImport = null; _failedRelay = null; _cancelPolicy(); _cancelProfile(); @@ -544,45 +551,13 @@ class HostedFlow extends ChangeNotifier { return _enqueue(() => _continue(generation, selectedPubkey: pubkey)); } - /// Create explicitly from setup, or beside the saved identities offered. - Future createIdentity() { - if ((_target == null && !accountOnly) || - !canCreateIdentity || - (stage != HostedFlowStage.setupIdentity && - stage != HostedFlowStage.chooseIdentity)) { - return Future.value(); - } - final offered = {for (final item in identityChoices) item.pubkey}; - final generation = _nextGeneration(); - _set(HostedFlowStage.working); - return _enqueue( - () => _continue(generation, createNew: true, offered: offered), - ); - } - - /// Import through the secure store, rechecking the account binding. - /// Errors propagate to the import sheet without discarding the setup choice. - Future importIdentity(String nsec) { - final generation = _generation; - final subject = account.account?.subject; - if (stage != HostedFlowStage.setupIdentity || subject == null) { - throw AccountFailure.unavailable; - } - return _queue.run(() async { - bool current() => - !_disposed && - generation == _generation && - account.account?.subject == subject; - if (!current()) throw AccountFailure.unavailable; - await identity.resolve(isCurrent: current, importNsec: nsec); - }); - } - Future _continue( int generation, { String? selectedPubkey, bool createNew = false, + ({String subject, String pubkey})? approvedBackup, Set offered = const {}, + String? importNsec, }) async { final target = _target; final session = account.account; @@ -607,8 +582,19 @@ class HostedFlow extends ChangeNotifier { var resolved = await identity.resolve( isCurrent: _isCurrent(generation), selectedPubkey: selectedPubkey ?? _pairedPubkey, + importNsec: importNsec, + canAdopt: (pubkey) => + approvedBackup == (subject: session.subject, pubkey: pubkey), ); if (_disposed || generation != _generation) return; + if (resolved.state == HostedIdentityState.needsConsent) { + _presentBackupConsent( + session.subject, + resolved.pubkey!, + importNsec: importNsec, + ); + return; + } if (_importRequested && { HostedIdentityState.ready, @@ -643,34 +629,43 @@ class HostedFlow extends ChangeNotifier { isCurrent: _isCurrent(generation), createNew: true, offered: offered, + canAdopt: (pubkey) => + approvedBackup == (subject: session.subject, pubkey: pubkey), ); } + if (_disposed || generation != _generation) return; + if (resolved.state == HostedIdentityState.needsConsent) { + _presentBackupConsent(session.subject, resolved.pubkey!); + return; + } + if (resolved.generated && resolved.pubkey != null) { + approvedBackup = (subject: session.subject, pubkey: resolved.pubkey!); + } if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) { _set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch); return; } + if (target is NostrBindDeepLink && + !await _allowBackup( + resolved, + session.subject, + generation, + canSealBackup, + approvedBackup: approvedBackup, + )) { + return; + } if (target is! NostrBindDeepLink) { for (var step = 0; step < 2; step++) { if (_disposed || generation != _generation) return; - if (!canSealBackup) { - final saved = await store.read(); - if (_disposed || generation != _generation) return; - final needsNewBackup = - resolved.state == HostedIdentityState.needsSetup || - resolved.state == HostedIdentityState.needsBackup || - (resolved.state == HostedIdentityState.needsBinding && - !(saved?.hasBackupFor( - backend: HostedEnvironment.origin.origin, - subject: session.subject, - ) ?? - false)); - if (needsNewBackup) { - _set( - HostedFlowStage.blocked, - HostedFlowProblem.unsupportedPlatform, - ); - return; - } + if (!await _allowBackup( + resolved, + session.subject, + generation, + canSealBackup, + approvedBackup: approvedBackup, + )) { + return; } if (resolved.state == HostedIdentityState.needsBinding && resolved.pubkey != null) { diff --git a/mobile/lib/shared/hosted/hosted_flow_backup_consent.dart b/mobile/lib/shared/hosted/hosted_flow_backup_consent.dart new file mode 100644 index 0000000000..8565181231 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_flow_backup_consent.dart @@ -0,0 +1,135 @@ +part of 'hosted_flow.dart'; + +/// Consent before reserving an existing identity or exporting its secret. +extension HostedFlowBackupConsent on HostedFlow { + bool get bindingOnly => _target is NostrBindDeepLink; + + String? get backupAccountEmail { + if (_pendingBackup?.subject != account.account?.subject) return null; + final email = account.account?.email?.trim(); + return email == null || email.isEmpty ? null : email; + } + + void _presentBackupConsent( + String subject, + String pubkey, { + String? importNsec, + }) { + _pendingBackup = (subject: subject, pubkey: pubkey); + _pendingImport = importNsec; + _set(HostedFlowStage.confirmBackup); + } + + /// Approval is bound to the displayed account, key and live presentation. + Future Function()? get confirmBackup { + final approved = _pendingBackup; + final presentedGeneration = _generation; + if (stage != HostedFlowStage.confirmBackup || + approved == null || + backupAccountEmail == null) { + return null; + } + return () { + if (_disposed || + presentedGeneration != _generation || + stage != HostedFlowStage.confirmBackup || + _pendingBackup != approved || + account.account?.subject != approved.subject) { + return Future.value(); + } + final importNsec = _pendingImport; + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue( + () => _continue( + generation, + selectedPubkey: approved.pubkey, + approvedBackup: approved, + importNsec: importNsec, + ), + ); + }; + } + + /// Create explicitly from setup, or beside the saved identities offered. + Future createIdentity() { + if ((_target == null && !accountOnly) || + !canCreateIdentity || + (stage != HostedFlowStage.setupIdentity && + stage != HostedFlowStage.chooseIdentity)) { + return Future.value(); + } + final offered = {for (final item in identityChoices) item.pubkey}; + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue( + () => _continue(generation, createNew: true, offered: offered), + ); + } + + /// Keep an imported key tentative until the confirmation is accepted. Only + /// its public key is exposed to widgets; cancellation drops the private input. + Future importIdentity(String nsec) { + final generation = _generation; + final subject = account.account?.subject; + if (stage != HostedFlowStage.setupIdentity || subject == null) { + throw AccountFailure.unavailable; + } + return _queue.run(() async { + bool current() => + !_disposed && + generation == _generation && + account.account?.subject == subject; + if (!current()) throw AccountFailure.unavailable; + final resolved = await identity.resolve( + isCurrent: current, + importNsec: nsec, + canAdopt: (_) => false, + ); + if (!current()) throw AccountFailure.unavailable; + if (resolved.state == HostedIdentityState.needsConsent) { + _presentBackupConsent(subject, resolved.pubkey!, importNsec: nsec); + } + }); + } + + Future _allowBackup( + HostedIdentityResult resolved, + String subject, + int generation, + bool canSealBackup, { + ({String subject, String pubkey})? approvedBackup, + }) async { + final saved = await store.forSubject(subject).read(); + if (_disposed || generation != _generation) return false; + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + final confirmed = + saved?.pubkey == resolved.pubkey && + (saved?.hasBackupFor( + backend: HostedEnvironment.origin.origin, + subject: subject, + ) ?? + false); + final needsBackup = + resolved.state == HostedIdentityState.needsSetup || + resolved.state == HostedIdentityState.needsBackup || + (resolved.state == HostedIdentityState.needsBinding && !confirmed); + if (!bindingOnly && needsBackup && !canSealBackup) { + _set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedPlatform); + return false; + } + final pubkey = resolved.pubkey; + if ((resolved.state == HostedIdentityState.needsBinding || + (!bindingOnly && + resolved.state == HostedIdentityState.needsBackup)) && + !confirmed && + pubkey != null && + approvedBackup != (subject: subject, pubkey: pubkey)) { + _presentBackupConsent(subject, pubkey); + return false; + } + return true; + } +} diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart index e6fecb3293..af988a29e4 100644 --- a/mobile/lib/shared/hosted/hosted_identity_service.dart +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -9,6 +9,7 @@ import 'device_identity_source.dart'; enum HostedIdentityState { ready, needsSelection, + needsConsent, needsSetup, needsBinding, needsBackup, @@ -23,10 +24,14 @@ class HostedIdentityResult { this.pubkey, this.choices = const [], this.canCreate = false, + this.generated = false, }); final HostedIdentityState state; final String? pubkey; final bool canCreate; + + /// True only when this resolution confirmed generating the key itself. + final bool generated; final List choices; } @@ -44,13 +49,23 @@ class HostedIdentityService { final BuilderlabAccountClient account; final AccountIdentityStore store; + /// [canAdopt] may require consent before a local key occupies the account slot. + /// Cloud recovery already has a receipt and does not export a local secret. Future resolve({ bool Function()? isCurrent, String? selectedPubkey, bool createNew = false, Set offered = const {}, String? importNsec, - }) => _resolve(isCurrent, selectedPubkey, createNew, offered, importNsec); + bool Function(String pubkey)? canAdopt, + }) => _resolve( + isCurrent, + selectedPubkey, + createNew, + offered, + importNsec, + canAdopt, + ); Future _resolve( bool Function()? isCurrent, @@ -58,6 +73,7 @@ class HostedIdentityService { bool createNew, Set offered, String? importNsec, + bool Function(String pubkey)? canAdopt, ) async { void checkCurrent() { if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable; @@ -106,6 +122,12 @@ class HostedIdentityService { if (bound != null && bound != pubkey) { throw AccountIdentityFailure.mismatch; } + if (canAdopt != null && !canAdopt(pubkey)) { + return HostedIdentityResult( + HostedIdentityState.needsConsent, + pubkey: pubkey, + ); + } final imported = await store.create( importNsec, checkCurrent: () { @@ -163,6 +185,12 @@ class HostedIdentityService { canCreate: unclaimed, ); } + if (canAdopt != null && !canAdopt(chosen!)) { + return HostedIdentityResult( + HostedIdentityState.needsConsent, + pubkey: chosen, + ); + } void checkSelection() { _sameAccount(subject); checkCurrent(); @@ -191,7 +219,9 @@ class HostedIdentityService { canCreate: true, ); } + var generatedNow = false; final generated = await store.generateIfAbsent( + onGenerated: () => generatedNow = true, authorize: () async { _sameAccount(subject); checkCurrent(); @@ -211,7 +241,7 @@ class HostedIdentityService { ? const HostedIdentityResult( HostedIdentityState.needsAuthoritativeAbsence, ) - : _result(generated, bound, subject); + : _result(generated, bound, subject, generated: generatedNow); } final recovered = await account.post('/v1/buzz/nsec-backups/recovery', {}); @@ -336,8 +366,9 @@ class HostedIdentityService { HostedIdentityResult _result( AccountIdentitySnapshot local, String? bound, - String subject, - ) { + String subject, { + bool generated = false, + }) { _sameAccount(subject); if (bound != null && bound != local.pubkey) { return HostedIdentityResult( @@ -349,6 +380,7 @@ class HostedIdentityService { return HostedIdentityResult( HostedIdentityState.needsBinding, pubkey: local.pubkey, + generated: generated, ); } return HostedIdentityResult( diff --git a/mobile/test/features/onboarding/hosted_backup_consent_test.dart b/mobile/test/features/onboarding/hosted_backup_consent_test.dart new file mode 100644 index 0000000000..94ae19f4d7 --- /dev/null +++ b/mobile/test/features/onboarding/hosted_backup_consent_test.dart @@ -0,0 +1,382 @@ +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import '../../helpers/hosted_journey_fixture.dart'; + +void main() { + void journey( + String name, + Future Function(WidgetTester, HostedJourneyFixture) run, + ) { + testWidgets(name, (tester) async { + final f = HostedJourneyFixture(); + await tester.runAsync( + () => http.runWithClient(() async { + try { + await run(tester, f); + } finally { + await f.dispose(tester); + } + }, f.client), + ); + }, skip: HostedEnvironment.authDomains.isEmpty); + } + + // This separate journey covers the first cloud export of an existing local + // key. The general hosted journey file is already at its size limit. + testWidgets('self-hosted key requires informed consent before cloud backup', ( + tester, + ) async { + final f = HostedJourneyFixture(); + await tester.runAsync( + () => http.runWithClient(() async { + try { + await f.initialize(localIdentity: false); + f.bound = null; + f.offerProfile = false; + final community = Community.create( + name: 'Self hosted', + relayUrl: 'wss://self-hosted.example', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await f.storage.save(community); + await f.storage.save( + Community.create( + name: 'Second self hosted', + relayUrl: 'wss://second.example', + pubkey: f.secondKeys.public, + nsec: f.secondKeys.nsec, + ), + ); + await f.storage.saveActiveId(community.id); + await f.mount(tester); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + + f.accountEmail = 'earlier-account@example.com'; + await f.flow.signInAccount(); + await f.settle(tester); + await f.tap(tester, fullNpub(f.keys.public)!); + expect(find.text('Back up your secret key?'), findsOneWidget); + expect( + find.textContaining('encrypted copy of your secret key'), + findsOneWidget, + ); + expect( + find.textContaining(HostedEnvironment.origin.host), + findsOneWidget, + ); + expect( + find.textContaining('Anyone signed in to this account'), + findsOneWidget, + ); + expect(find.text(fullNpub(f.keys.public)!), findsOneWidget); + expect( + find.text('Receiving account: earlier-account@example.com'), + findsOneWidget, + ); + expect( + find.textContaining('cannot change the linked identity later'), + findsOneWidget, + ); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + final staleConfirm = tester + .widget( + find.widgetWithText(FilledButton, 'Back up and continue'), + ) + .onPressed!; + await f.tap(tester, 'Cancel setup'); + expect((await f.storage.loadAll()).first.nsec, f.keys.nsec); + expect(await f.storage.loadActiveId(), community.id); + expect(await f.store.read(), isNull); + + // Re-enter on the same owner: disposal must not be the only fence. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseIdentity); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + staleConfirm(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.confirmBackup); + expect(f.flow.backupPubkey, f.secondKeys.public); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + await f.restart(tester); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseIdentity); + await f.tap(tester, fullNpub(f.keys.public)!); + expect(find.text('Back up your secret key?'), findsOneWidget); + staleConfirm(); + await f.settle(tester); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + // Android/system Back also discards the tentative selection. + await tester.binding.handlePopRoute(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.idle); + expect((await f.storage.loadAll()).first.nsec, f.keys.nsec); + await f.flow.signInAccount(); + await f.settle(tester); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + // A restored unintended account is recognizable and can be signed out. + await f.tap(tester, 'Sign out'); + expect(f.flow.account.account, isNull); + expect(await f.store.read(), isNull); + f.accountEmail = null; + await f.flow.signInAccount(); + await f.settle(tester); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + expect(f.flow.confirmBackup, isNull); + expect( + find.textContaining('couldn’t identify the receiving account'), + findsOneWidget, + ); + await f.tap(tester, 'Cancel setup'); + f.accountEmail = 'owner@example.com'; + await f.flow.signInAccount(); + await f.settle(tester); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + expect( + find.text('Receiving account: owner@example.com'), + findsOneWidget, + ); + + // The server accepted the upload, but the acknowledgement was lost. + f.beforeUploadReturn = () async { + f.cloudKeys.add(f.secondKeys); + throw http.ClientException('Synthetic lost upload response'); + }; + await f.tap(tester, 'Back up and continue'); + expect(f.bound, f.secondKeys.public); + expect(f.sent('/enrollment'), hasLength(1)); + expect(f.sent('/nsec-backups'), hasLength(1)); + expect((await f.store.read())!.backup, isNull); + await f.tap(tester, 'Try again'); + expect(find.textContaining('does not undo'), findsOneWidget); + expect(find.textContaining('confirmation was lost'), findsOneWidget); + await f.tap(tester, 'Cancel setup'); + expect(f.bound, f.secondKeys.public); + expect(f.cloudKeys.single.public, f.secondKeys.public); + expect(f.sent('/nsec-backups'), hasLength(1)); + await f.restart(tester); + await f.flow.signInAccount(); + await f.settle(tester); + expect(find.text('Back up your secret key?'), findsOneWidget); + expect(find.textContaining('does not undo'), findsOneWidget); + f.beforeUploadReturn = null; + await f.tap(tester, 'Back up and continue'); + expect((await f.store.read())!.backup!.pubkey, f.secondKeys.public); + expect( + (await f.storage.loadAll()).map((c) => c.nsec), + containsAll([f.keys.nsec, f.secondKeys.nsec]), + ); + await f.restart(tester); + await f.flow.signInAccount(); + await f.settle(tester); + expect(find.text('Back up your secret key?'), findsNothing); + expect(f.sent('/nsec-backups'), hasLength(2)); + } finally { + await f.dispose(tester); + } + }, f.client), + ); + }, skip: HostedEnvironment.authDomains.isEmpty); + journey( + 'web binding keeps an explicit identity choice without backup support', + (tester, f) async { + await f.initialize(localIdentity: false); + f.bound = null; + await f.mount(tester); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (_) async => false, + ); + final nativeChannels = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + (call) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final channel = MethodChannel('buzz/ios_navigation_bar/$id'); + nativeChannels.add(channel); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => null, + ); + } + return null; + }, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (_) async => true, + ); + void openBindLink() { + f.container + .read(pendingDeepLinkProvider.notifier) + .open( + Uri( + scheme: 'buzz', + host: 'nostr-bind', + queryParameters: { + 'challenge_id': '12345678-1234-1234-1234-123456789012', + 'nonce': 'a' * 43, + 'verification_code': '123456', + 'audience': 'buzz:nostr-identity', + 'action': 'bind_nostr_identity', + 'protocol': 'buzz-nostr-identity', + 'version': '1', + 'origin': HostedEnvironment.origin.toString(), + 'expires_at': '2099-01-01T00:00:00Z', + 'return': 'browser_fragment_v1', + 'callback_url': HostedEnvironment.origin + .resolve('/buzz') + .toString(), + }, + ), + ); + } + + try { + openBindLink(); + await f.settle(tester); + expect( + find.text('Create my Buzz identity').hitTestable(), + findsOneWidget, + ); + expect( + find.text('Import existing identity').hitTestable(), + findsOneWidget, + ); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + await f.tap(tester, 'Import existing identity'); + await tester.enterText( + find.byType(TextField).hitTestable(), + f.secondKeys.nsec, + ); + await f.tap(tester, 'Preview identity'); + await f.tap(tester, 'Import this identity'); + expect(find.text('Link your identity?'), findsOneWidget); + expect(find.textContaining('No backup is uploaded'), findsOneWidget); + expect( + find.text('Receiving account: owner@example.com'), + findsOneWidget, + ); + expect(await f.store.read(), isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + await f.tap(tester, 'Cancel setup'); + expect(await f.store.read(), isNull); + expect(f.sent('/verify'), isEmpty); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + openBindLink(); + await f.settle(tester); + await f.tap(tester, 'Create my Buzz identity'); + expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + // Once the OS supports sealing, creation still requires an explicit tap. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (_) async => true, + ); + await f.flow.retry(); + await f.settle(tester); + expect(await f.store.read(), isNull); + await f.tap(tester, 'Create my Buzz identity'); + final saved = (await f.store.read())!; + expect(f.bound, saved.pubkey); + expect( + saved.backup, + isNull, + ); // Web binding has never promised mobile backup. + expect(f.flow.stage, HostedFlowStage.browserReturned); + + // A returning account may already have a saved key from an interrupted + // setup. No adoption occurs, so the bind-only gate must ask for consent. + await f.flow.signOutAccount(); + f.subject = 'returning-owner'; + f.bound = null; + await f.store.create(f.secondKeys.nsec); + await f.flow.account.exchange( + code: 'synthetic-code', + verifier: 'v' * 43, + ); + debugDefaultTargetPlatformOverride = null; + await f.restart(tester); + f.requests.clear(); + openBindLink(); + await f.settle(tester); + expect(find.text('Link your identity?'), findsOneWidget); + expect(find.textContaining('No backup is uploaded'), findsOneWidget); + expect(find.text(fullNpub(f.secondKeys.public)!), findsOneWidget); + expect((await f.store.read())!.backup, isNull); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + await f.tap(tester, 'Cancel setup'); + expect((await f.store.read())!.pubkey, f.secondKeys.public); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + + // Cancellation and restart retain the key, but never retain approval. + await f.restart(tester); + openBindLink(); + await f.settle(tester); + expect(find.text('Link your identity?'), findsOneWidget); + expect(f.sent('/verify'), isEmpty); + await f.tap(tester, 'Link and continue'); + expect(f.bound, f.secondKeys.public); + expect(f.sent('/verify'), hasLength(1)); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + expect((await f.store.read())!.backup, isNull); + expect(f.flow.stage, HostedFlowStage.browserReturned); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + debugDefaultTargetPlatformOverride = null; + for (final channel in nativeChannels) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + } + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + null, + ); + } + }, + ); +} diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 2e8cf5096b..bb7c0b98a2 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -289,6 +289,9 @@ void main() { release.complete(); f.beforeStatusReturn = null; await f.settle(tester); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + await f.tap(tester, 'Back up and continue'); expect(f.bound, f.keys.public); } final saved = await f.store.read(); @@ -315,6 +318,7 @@ void main() { } else { await f.tap(tester, 'Try again'); } + await f.tap(tester, 'Back up and continue'); expect((await f.store.read())!.pubkey, saved.pubkey); expect((await f.store.read())!.backup, isNotNull); if (entry == 'import') { @@ -341,115 +345,6 @@ void main() { }); } - journey( - 'web binding keeps an explicit identity choice without backup support', - (tester, f) async { - await f.initialize(localIdentity: false); - f.bound = null; - await f.mount(tester); - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('buzz/hosted_hpke'), - (_) async => false, - ); - final nativeChannels = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform_views, - (call) async { - if (call.method == 'create') { - final id = (call.arguments as Map)['id']; - final channel = MethodChannel('buzz/ios_navigation_bar/$id'); - nativeChannels.add(channel); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - (_) async => null, - ); - } - return null; - }, - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('plugins.flutter.io/url_launcher'), - (_) async => true, - ); - try { - f.container - .read(pendingDeepLinkProvider.notifier) - .open( - Uri( - scheme: 'buzz', - host: 'nostr-bind', - queryParameters: { - 'challenge_id': '12345678-1234-1234-1234-123456789012', - 'nonce': 'a' * 43, - 'verification_code': '123456', - 'audience': 'buzz:nostr-identity', - 'action': 'bind_nostr_identity', - 'protocol': 'buzz-nostr-identity', - 'version': '1', - 'origin': HostedEnvironment.origin.toString(), - 'expires_at': '2099-01-01T00:00:00Z', - 'return': 'browser_fragment_v1', - 'callback_url': HostedEnvironment.origin - .resolve('/buzz') - .toString(), - }, - ), - ); - await f.settle(tester); - expect( - find.text('Create my Buzz identity').hitTestable(), - findsOneWidget, - ); - expect( - find.text('Import existing identity').hitTestable(), - findsOneWidget, - ); - expect(await f.store.read(), isNull); - expect(f.bound, isNull); - expect(f.sent('/verify'), isEmpty); - await f.tap(tester, 'Create my Buzz identity'); - expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform); - expect(await f.store.read(), isNull); - expect(f.bound, isNull); - // Once the OS supports sealing, creation still requires an explicit tap. - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('buzz/hosted_hpke'), - (_) async => true, - ); - await f.flow.retry(); - await f.settle(tester); - expect(await f.store.read(), isNull); - await f.tap(tester, 'Create my Buzz identity'); - final saved = (await f.store.read())!; - expect(f.bound, saved.pubkey); - expect( - saved.backup, - isNull, - ); // Web binding has never promised mobile backup. - expect(f.flow.stage, HostedFlowStage.browserReturned); - } finally { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - debugDefaultTargetPlatformOverride = null; - for (final channel in nativeChannels) { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ); - } - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform_views, - null, - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('plugins.flutter.io/url_launcher'), - null, - ); - } - }, - ); - journey('linked account without backup imports its key without replacing it', ( tester, f, @@ -482,6 +377,8 @@ void main() { await tester.enterText(find.byType(TextField).hitTestable(), f.keys.nsec); await f.tap(tester, 'Preview identity'); await f.tap(tester, 'Import this identity'); + expect(await f.store.read(), isNull); + await f.tap(tester, 'Back up and continue'); expect((await f.store.read())!.pubkey, f.keys.public); expect((await f.store.read())!.backup, isNotNull); expect(f.requests.where((r) => r.url.path.endsWith('/recovery')), isEmpty); @@ -1170,6 +1067,7 @@ void main() { // still be durable; resuming must not replay an acknowledged backup. f.beforeUploadReturn = () => f.flow.leave(); await f.link(tester, 'one'); + await f.tap(tester, 'Back up and continue'); expect((await f.store.read())!.backup, isNotNull); f.beforeUploadReturn = null; // Production legal documents exceed the ordinary 64 KiB account limit. @@ -1286,6 +1184,7 @@ void main() { expect(f.sent('/nsec-backups'), isEmpty); expect(await f.store.read(), isNull); await f.tap(tester, fullNpub(f.keys.public)!); + await f.tap(tester, 'Back up and continue'); } final saved = (await f.store.read())!; expect( @@ -1337,6 +1236,7 @@ void main() { await f.flow.signInAccount(); await f.settle(tester); await f.tap(tester, fullNpub(f.keys.public)!); + await f.tap(tester, 'Back up and continue'); return original; } @@ -1350,6 +1250,7 @@ void main() { await f.restart(tester); await f.flow.signInAccount(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); // The saved choice skips selection and retries the same rejection. expect(f.flow.problem, HostedFlowProblem.requestRejected); await f.tap(tester, 'Choose a different identity'); @@ -1438,6 +1339,7 @@ void main() { await f.restart(tester); await f.flow.signInAccount(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); expect(f.flow.problem, HostedFlowProblem.requestRejected); expect(f.flow.canChooseDifferentIdentity, isTrue); keychain.sticky = null; @@ -1500,6 +1402,7 @@ void main() { ); await f.flow.retry(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); expect(f.flow.problem, HostedFlowProblem.requestRejected); expect(f.flow.canChooseDifferentIdentity, isFalse); expect(find.text('Choose a different identity'), findsNothing); @@ -1532,10 +1435,12 @@ void main() { ..bindStatus = 409; await f.flow.retry(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); expect(f.flow.canChooseDifferentIdentity, isTrue); f.challengeStatus = 409; await f.flow.retry(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); // The earlier attempt's rejection is not reused. expect(f.flow.problem, HostedFlowProblem.requestRejected); expect(f.flow.canChooseDifferentIdentity, isFalse); @@ -1580,7 +1485,7 @@ void main() { await f.tap(tester, 'Try again'); expect(f.flow.stage, HostedFlowStage.idle); expect(f.probes, isEmpty); - expect(find.text('Signed in to BuilderLab'), findsOneWidget); + expect(find.text('Signed in as owner@example.com'), findsOneWidget); expect(find.text('Scan a QR code'), findsOneWidget); expect(find.text('Use pairing code'), findsNothing); f.rows.addAll([f.row('one'), f.row('two')]); diff --git a/mobile/test/features/pairing/pairing_hosted_handoff_test.dart b/mobile/test/features/pairing/pairing_hosted_handoff_test.dart index 048573da39..e01ae2f4d3 100644 --- a/mobile/test/features/pairing/pairing_hosted_handoff_test.dart +++ b/mobile/test/features/pairing/pairing_hosted_handoff_test.dart @@ -40,13 +40,18 @@ void main() { final settled = Completer(); f.flow.addListener(() { if ((f.flow.stage == HostedFlowStage.complete || - f.flow.stage == HostedFlowStage.blocked) && + f.flow.stage == HostedFlowStage.blocked || + f.flow.stage == HostedFlowStage.confirmBackup) && !settled.isCompleted) { settled.complete(); } }); await container.read(pairingProvider.notifier).pair(_payload(f.keys)); await settled.future.timeout(const Duration(seconds: 2)); + if (!backup) { + expect(f.flow.stage, HostedFlowStage.confirmBackup); + await f.flow.confirmBackup!(); + } expect(legacyProbes, 0); expect(f.admitted, backup ? ['paired.communities.buzz.xyz'] : isEmpty); expect((await f.store.read())?.pubkey, f.keys.public); diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index 9203b74465..310ea43d60 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -83,6 +83,7 @@ class HostedJourneyFixture { bool denyAssertion = false; bool refuseUpgrade = false; String subject = 'journey-owner'; + String? accountEmail = 'owner@example.com'; /// An organization realm, as an internal build's BUZZ_BUILDERLAB_URL and /// BUZZ_BUILDERLAB_RELAYS configure. Enable before [mount]. @@ -158,6 +159,7 @@ class HostedJourneyFixture { if (expiredSession) return http.Response('{}', 401); result = { 'subject': subject, + 'email': accountEmail, 'expires_at': expiry, 'account_status': needsAccountSetup ? 'ACCOUNT_STATUS_NEEDS_DETAILS' diff --git a/mobile/test/shared/hosted/account_identity_store_test.dart b/mobile/test/shared/hosted/account_identity_store_test.dart index 6875cc6341..6a5e45d8a7 100644 --- a/mobile/test/shared/hosted/account_identity_store_test.dart +++ b/mobile/test/shared/hosted/account_identity_store_test.dart @@ -204,6 +204,7 @@ void main() { final storage = UncertainStorage(); final store = AccountIdentityStore(storage: storage); var authorizations = 0; + var generatedHere = 0; final receipt = BackupReceipt( backend: 'https://app.builderlab.xyz', subject: 'a', @@ -212,6 +213,7 @@ void main() { ); Future write() => generate ? store.generateIfAbsent( + onGenerated: () => generatedHere++, authorize: () async { authorizations++; return true; @@ -223,6 +225,8 @@ void main() { final retried = (await write())!; expect(retried.pubkey, persisted.pubkey); expect(authorizations, generate ? 1 : 0); + // An uncertain write or an existing slot is never fresh creation consent. + expect(generatedHere, 0); if (!generate) { expect(retried.backup?.backupId, 'one'); final other = nostr.Keys('2'.padLeft(64, '0')); diff --git a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart index 870abe8555..c4418298b9 100644 --- a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart +++ b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart @@ -14,8 +14,11 @@ class InventoryAccount extends BuilderlabAccountClient { Map recovery = {}; final calls = []; @override - BuilderlabAccount get account => - BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100)); + BuilderlabAccount get account => BuilderlabAccount( + subject: 'fixture', + email: 'owner@example.com', + expiresAt: DateTime.utc(2100), + ); @override Future restore() async => account; @override diff --git a/mobile/test/shared/hosted/hosted_communities_flow_test.dart b/mobile/test/shared/hosted/hosted_communities_flow_test.dart index 0b420e672b..3a4a6aaf78 100644 --- a/mobile/test/shared/hosted/hosted_communities_flow_test.dart +++ b/mobile/test/shared/hosted/hosted_communities_flow_test.dart @@ -52,6 +52,10 @@ void main() { if (failure == 'binding') f.bound = '2' * 64; final before = await const FlutterSecureStorage().readAll(); await f.flow.signInAccount(); + if (failure == 'backup') { + expect(f.flow.stage, HostedFlowStage.confirmBackup); + await f.flow.confirmBackup!(); + } expect(f.flow.stage, HostedFlowStage.blocked); if (failure == 'binding') { expect(f.flow.problem, HostedFlowProblem.identityMismatch); diff --git a/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart b/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart index 7934f80a72..b2b8470e82 100644 --- a/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart +++ b/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart @@ -97,6 +97,8 @@ void main() { final choosing = flow.chooseIdentity(second.public); expect(flow.identityChoices, isEmpty); await choosing; + expect(flow.stage, HostedFlowStage.confirmBackup); + await flow.confirmBackup!(); expect(flow.problem, HostedFlowProblem.denied); final completedCalls = List.of(account.calls); await flow.chooseIdentity(first.public);