diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart index 40644e3463..37a7a7958a 100644 --- a/mobile/lib/features/onboarding/hosted_connect_page.dart +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; import '../../shared/hosted/hosted_flow_problem_message.dart'; +import '../../shared/utils/string_utils.dart'; import '../../shared/theme/grid.dart'; import '../../shared/theme/theme_extensions.dart'; @@ -24,6 +25,8 @@ class HostedConnectPage extends HookConsumerWidget { final message = switch (flow.stage) { HostedFlowStage.idle => 'Continue connecting your account.', HostedFlowStage.working => 'Connecting your account…', + HostedFlowStage.chooseIdentity => + 'Choose the identity to connect to Builderlab. Existing device and cloud keys will be kept.', HostedFlowStage.signIn => 'Complete sign-in in your browser.', HostedFlowStage.browserReturned => 'Continue setup in your browser. Open your community in Buzz when it is ready.', @@ -56,9 +59,26 @@ class HostedConnectPage extends HookConsumerWidget { children: [ Text(message, style: context.textTheme.titleLarge), const SizedBox(height: Grid.sm), + for (final choice in flow.identityChoices) + Padding( + padding: const EdgeInsets.only(bottom: Grid.sm), + child: OutlinedButton( + onPressed: () => flow.chooseIdentity(choice.pubkey), + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + children: [ + Text(choice.label), + Text(fullNpub(choice.pubkey) ?? choice.pubkey), + ], + ), + ), + ), + ), if (working) const Center(child: CircularProgressIndicator()), if (!working && + flow.stage != HostedFlowStage.chooseIdentity && flow.stage != HostedFlowStage.complete && flow.stage != HostedFlowStage.browserReturned && flow.canRetry) diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index 410264acfc..56a5e615a5 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -14,6 +14,7 @@ import 'shared/hosted/builderlab_account.dart'; import 'shared/hosted/hosted_admission.dart'; import 'shared/hosted/hosted_flow.dart'; import 'shared/hosted/hosted_identity_service.dart'; +import 'shared/hosted/device_identity_source.dart'; import 'shared/hosted/hosted_environment.dart'; // Set by a community-entry surface while it owns setup presentation. @@ -37,7 +38,11 @@ final hostedFlowProvider = Provider((ref) { account: account, browser: browser, store: store, - identity: HostedIdentityService(account: account, store: store), + identity: HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ), enterCommunity: (link, subject, pubkey, isCurrent) async { final relay = switch (link) { InviteDeepLink(:final relayUrl) => relayUrl, @@ -192,7 +197,8 @@ class HostedAppGate extends HookConsumerWidget { fit: StackFit.expand, children: [ child, - if (visible && !inlineEntry) + if (visible && + (!inlineEntry || flow.stage == HostedFlowStage.chooseIdentity)) HostedConnectPage( flow: flow, onClose: () => unawaited(flow.leave()), diff --git a/mobile/lib/shared/hosted/account_identity_store.dart b/mobile/lib/shared/hosted/account_identity_store.dart index 0f50ad0d3f..982b03423b 100644 --- a/mobile/lib/shared/hosted/account_identity_store.dart +++ b/mobile/lib/shared/hosted/account_identity_store.dart @@ -204,6 +204,9 @@ class AccountIdentityStore { } } + /// Validate a candidate without exposing signing material to the UI. + static String publicKeyForNsec(String nsec) => _keys(nsec).public; + static nostr.Keys _keys(String nsec) { try { final decoded = nostr.Nip19.decode(payload: nsec); diff --git a/mobile/lib/shared/hosted/device_identity_source.dart b/mobile/lib/shared/hosted/device_identity_source.dart new file mode 100644 index 0000000000..709eba840b --- /dev/null +++ b/mobile/lib/shared/hosted/device_identity_source.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'account_identity_store.dart'; + +/// Public identity choices; never contains private signing material. +class DeviceIdentityChoice { + const DeviceIdentityChoice(this.pubkey, this.label); + final String pubkey; + final String label; +} + +/// Read-only inventory, including legacy records hidden by a newer community list. +/// Do not call CommunityStorage.loadAll here: it migrates and deletes records. +class DeviceIdentitySource { + DeviceIdentitySource({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + final FlutterSecureStorage _storage; + + Future> choices() async => [ + for (final entry in (await _read()).entries) + DeviceIdentityChoice(entry.key, entry.value.label), + ]; + + /// Reread and validate the chosen key immediately before create-only adoption. + Future adopt( + String pubkey, + AccountIdentityStore store, { + required void Function() checkCurrent, + }) async { + final candidate = (await _read())[pubkey]; + if (candidate == null) throw AccountIdentityFailure.absent; + checkCurrent(); + return store.create(candidate.nsec, checkCurrent: checkCurrent); + } + + Future> _read() async { + try { + final result = {}; + void add(dynamic nsec, dynamic advertised, String label) { + if (nsec == null) { + // An identity reference without its secret is not authoritative absence. + if (advertised != null) throw AccountIdentityFailure.unreadable; + return; + } + if (nsec is! String) throw AccountIdentityFailure.unreadable; + final pubkey = AccountIdentityStore.publicKeyForNsec(nsec); + if (advertised != null && advertised != pubkey) { + throw AccountIdentityFailure.unreadable; + } + final previous = result[pubkey]; + result[pubkey] = ( + nsec: nsec, + label: previous == null ? label : '${previous.label}, $label', + ); + } + + for (final key in ['buzz_communities', 'buzz_workspaces']) { + final raw = await _storage.read(key: key); + if (raw == null) continue; + final records = jsonDecode(raw) as List; + for (final record in records) { + final value = record as Map; + add( + value['nsec'], + value['pubkey'], + value['name'] as String? ?? 'Saved community', + ); + } + } + add( + await _storage.read(key: 'buzz_nsec'), + await _storage.read(key: 'buzz_pubkey'), + 'Previous device identity', + ); + return result; + } catch (_) { + throw AccountIdentityFailure.unreadable; + } + } +} diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 89134edf82..f02b309f2e 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../deeplink/deep_link.dart'; import 'account_identity_store.dart'; +import 'device_identity_source.dart'; import 'browser_auth.dart'; import 'builderlab_account.dart'; import 'hosted_identity_service.dart'; @@ -17,6 +18,7 @@ enum HostedFlowStage { idle, working, signIn, + chooseIdentity, blocked, browserReturned, complete, @@ -71,6 +73,7 @@ class HostedFlow extends ChangeNotifier { final Future Function(Uri) _openWeb; HostedFlowStage stage = HostedFlowStage.idle; HostedFlowProblem? problem; + List identityChoices = const []; BuzzDeepLink? _target; bool accountOnly = false; Future _tail = Future.value(); @@ -222,12 +225,26 @@ class HostedFlow extends ChangeNotifier { } } - Future _continue(int generation) async { + /// Resume only an offered public identity; reread local and cloud facts first. + Future chooseIdentity(String pubkey) { + if (stage != HostedFlowStage.chooseIdentity || + !identityChoices.any((item) => item.pubkey == pubkey)) { + return Future.value(); + } + final generation = ++_generation; + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation, selectedPubkey: pubkey)); + } + + Future _continue(int generation, {String? selectedPubkey}) async { final target = _target; final session = account.account; if (target == null || session == null) throw AccountFailure.signedOut; if (!_acceptTarget(target)) return; - var resolved = await identity.resolve(); + var resolved = await identity.resolve( + isCurrent: () => !_disposed && generation == _generation, + selectedPubkey: selectedPubkey, + ); if (target is! NostrBindDeepLink) { for (var step = 0; step < 2; step++) { if (_disposed || generation != _generation) return; @@ -278,10 +295,17 @@ class HostedFlow extends ChangeNotifier { break; } if (_disposed || generation != _generation) return; - resolved = await identity.resolve(); + resolved = await identity.resolve( + isCurrent: () => !_disposed && generation == _generation, + ); } } if (generation != _generation || _disposed) return; + if (resolved.state == HostedIdentityState.needsSelection) { + identityChoices = List.unmodifiable(resolved.choices); + _set(HostedFlowStage.chooseIdentity); + return; + } final pubkey = resolved.pubkey; if (target is NostrBindDeepLink && pubkey != null && @@ -402,6 +426,7 @@ class HostedFlow extends ChangeNotifier { void _set(HostedFlowStage next, [HostedFlowProblem? failure]) { if (_disposed) return; + if (next != HostedFlowStage.chooseIdentity) identityChoices = const []; stage = next; problem = failure; notifyListeners(); diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart index 64c6b7e221..5153c38c38 100644 --- a/mobile/lib/shared/hosted/hosted_identity_service.dart +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -4,9 +4,11 @@ import 'package:nostr/nostr.dart' as nostr; import 'account_identity_store.dart'; import 'builderlab_account.dart'; +import 'device_identity_source.dart'; enum HostedIdentityState { ready, + needsSelection, needsBinding, needsBackup, mismatch, @@ -15,9 +17,14 @@ enum HostedIdentityState { } class HostedIdentityResult { - const HostedIdentityResult(this.state, {this.pubkey}); + const HostedIdentityResult( + this.state, { + this.pubkey, + this.choices = const [], + }); final HostedIdentityState state; final String? pubkey; + final List choices; } /// Resolves identity safely. Generation requires a separately verified @@ -28,22 +35,30 @@ class HostedIdentityService { required this.account, required this.store, this.canGenerateAfterLegacyReconciliation, + this.deviceIdentities, }); /// Return true only when legacy community identities have been reconciled. /// Must not call AccountIdentityStore: invoked under its writer lock. final Future Function()? canGenerateAfterLegacyReconciliation; + final DeviceIdentitySource? deviceIdentities; final BuilderlabAccountClient account; final AccountIdentityStore store; Future _tail = Future.value(); - Future resolve({bool Function()? isCurrent}) { - final result = _tail.then((_) => _resolve(isCurrent)); + Future resolve({ + bool Function()? isCurrent, + String? selectedPubkey, + }) { + final result = _tail.then((_) => _resolve(isCurrent, selectedPubkey)); _tail = result.then((_) {}, onError: (_) {}); return result; } - Future _resolve(bool Function()? isCurrent) async { + Future _resolve( + bool Function()? isCurrent, + String? selectedPubkey, + ) async { void checkCurrent() { if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable; } @@ -64,7 +79,7 @@ class HostedIdentityService { } else { throw AccountFailure.invalidResponse; } - if (local != null) return _result(local, bound, subject); + if (local != null && bound != null) return _result(local, bound, subject); final status = await account.post('/v1/buzz/nsec-backups/status', {}); _sameAccount(subject); @@ -78,13 +93,50 @@ class HostedIdentityService { : _pubkey(status['current_nostr_pubkey']); final statusKeys = listed.map(_pubkey).toSet(); if (statusBound != bound) throw AccountFailure.invalidResponse; + if (local != null) { + if (statusKeys.any((key) => key != local.pubkey)) { + return const HostedIdentityResult(HostedIdentityState.mismatch); + } + return _result(local, bound, subject); + } + final device = + await deviceIdentities?.choices() ?? const []; + _sameAccount(subject); + checkCurrent(); + if (statusKeys.isEmpty && + (bound == null || device.any((item) => item.pubkey == bound))) { + final eligible = device + .where((item) => bound == null || item.pubkey == bound) + .toList(); + if (eligible.isNotEmpty) { + final chosen = + selectedPubkey ?? + (eligible.length == 1 ? eligible.single.pubkey : null); + if (!eligible.any((item) => item.pubkey == chosen)) { + return HostedIdentityResult( + HostedIdentityState.needsSelection, + choices: eligible, + ); + } + final saved = await deviceIdentities!.adopt( + chosen!, + store, + checkCurrent: () { + _sameAccount(subject); + checkCurrent(); + }, + ); + return _result(saved, bound, subject); + } + } if (statusBound == null && statusKeys.isEmpty) { final generated = await store.generateIfAbsent( authorize: () async { _sameAccount(subject); checkCurrent(); - final reconciled = - await canGenerateAfterLegacyReconciliation?.call() ?? false; + final reconciled = deviceIdentities != null + ? (await deviceIdentities!.choices()).isEmpty + : await canGenerateAfterLegacyReconciliation?.call() ?? false; _sameAccount(subject); checkCurrent(); return reconciled; @@ -165,6 +217,19 @@ class HostedIdentityService { if (bound != null && bound != candidate.keys.public) { return const HostedIdentityResult(HostedIdentityState.mismatch); } + if (device.isNotEmpty && + !device.any((item) => item.pubkey == candidate.keys.public) && + selectedPubkey != candidate.keys.public) { + return HostedIdentityResult( + HostedIdentityState.needsSelection, + choices: [ + DeviceIdentityChoice( + candidate.keys.public, + 'Builderlab backup (keep existing community identities)', + ), + ], + ); + } _sameAccount(subject); checkCurrent(); final saved = await store.create( diff --git a/mobile/test/features/onboarding/identity_choice_test.dart b/mobile/test/features/onboarding/identity_choice_test.dart new file mode 100644 index 0000000000..9ee5939c00 --- /dev/null +++ b/mobile/test/features/onboarding/identity_choice_test.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:buzz/features/onboarding/hosted_connect_page.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../shared/hosted/device_identity_reconciliation_test.dart' + show InventoryAccount; + +class _Browser extends BrowserAuth { + @override + Future initialize() async {} + @override + Future?> retainedIntent() async => null; + @override + Future clearIntent() async {} + @override + Future cancel() async {} +} + +void main() { + testWidgets( + 'inline link flow shows identity choice; leave and explicit selection preserve keys', + (tester) async { + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + final raw = jsonEncode([ + {'name': 'First community', 'nsec': first.nsec, 'pubkey': first.public}, + { + 'name': 'Second community', + 'nsec': second.nsec, + 'pubkey': second.public, + }, + ]); + FlutterSecureStorage.setMockInitialValues({'buzz_communities': raw}); + PendingDeepLinkNotifier.debugUriStreamOverride = + const Stream.empty(); + final account = InventoryAccount(); + final store = AccountIdentityStore(); + final browser = _Browser(); + var admitted = false; + final flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ), + enterCommunity: (_, _, _, _) async { + admitted = true; + }, + ); + final container = ProviderContainer( + overrides: [hostedFlowProvider.overrideWithValue(flow)], + ); + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + container.dispose(); + flow.dispose(); + await browser.dispose(); + account.dispose(); + }); + container.read(hostedInlineEntryProvider.notifier).setActive(true); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: HostedAppGate( + authenticated: false, + child: Scaffold(body: Text('Link form')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + const target = AddCommunityDeepLink( + relayUrl: 'wss://team.communities.buzz.xyz', + name: 'Team', + ); + await flow.start(target); + await tester.pumpAndSettle(); + expect(find.byType(HostedConnectPage), findsOneWidget); + expect(find.text('First community'), findsOneWidget); + expect(find.text('Second community'), findsOneWidget); + expect(await store.read(), isNull); + await tester.tap(find.text('Leave setup')); + await tester.pumpAndSettle(); + expect(flow.stage, HostedFlowStage.idle); + expect(await store.read(), isNull); + await flow.start(target); + await tester.pumpAndSettle(); + await tester.tap(find.text('Second community')); + await tester.pumpAndSettle(); + expect((await store.read())!.pubkey, second.public); + expect( + await const FlutterSecureStorage().read(key: 'buzz_communities'), + raw, + ); + expect(admitted, isFalse); + // Fixture refuses binding: choosing a key never bypasses remaining gates. + expect(flow.problem, HostedFlowProblem.denied); + expect( + account.calls.where((path) => path.endsWith('/challenge')).length, + 1, + ); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart new file mode 100644 index 0000000000..998fd4863b --- /dev/null +++ b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart @@ -0,0 +1,206 @@ +import 'dart:convert'; + +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class InventoryAccount extends BuilderlabAccountClient { + String? bound; + List backups = []; + Map recovery = {}; + final calls = []; + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100)); + @override + Future restore() async => account; + @override + Future> currentIdentity() async => { + if (bound != null) 'identity': {'pubkey_hex': bound}, + }; + @override + Future> post( + String path, + Map body, + ) async { + calls.add(path); + if (path.endsWith('/status')) { + return {'current_nostr_pubkey': bound, 'backup_nostr_pubkeys': backups}; + } + if (path.endsWith('/recovery')) return recovery; + throw AccountFailure.denied; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + const secure = FlutterSecureStorage(); + late InventoryAccount account; + late AccountIdentityStore store; + late HostedIdentityService service; + Map record(nostr.Keys key, String name) => { + 'nsec': key.nsec, + 'pubkey': key.public, + 'name': name, + }; + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + account = InventoryAccount(); + store = AccountIdentityStore(); + service = HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ); + }); + tearDown(() => account.dispose()); + + test( + 'single legacy key is reused without migrating or deleting any device record', + () async { + final raw = jsonEncode([record(first, 'Old community')]); + await secure.write(key: 'buzz_workspaces', value: raw); + final result = await service.resolve(); + expect(result.pubkey, first.public); + expect(result.state, HostedIdentityState.needsBinding); + expect(await secure.read(key: 'buzz_workspaces'), raw); + expect(await secure.read(key: 'buzz_communities'), isNull); + expect(account.calls, ['/v1/buzz/nsec-backups/status']); + }, + ); + + test( + 'multiple current and hidden legacy keys require choice; no writes before choice', + () async { + await secure.write( + key: 'buzz_communities', + value: jsonEncode([record(first, 'Current')]), + ); + await secure.write(key: 'buzz_nsec', value: second.nsec); + final before = await secure.readAll(); + final pending = await service.resolve(); + expect(pending.state, HostedIdentityState.needsSelection); + expect(pending.choices.map((item) => item.pubkey), [ + first.public, + second.public, + ]); + expect(await secure.readAll(), before); + final selected = await service.resolve(selectedPubkey: second.public); + expect(selected.pubkey, second.public); + for (final entry in before.entries) { + expect(await secure.read(key: entry.key), entry.value); + } + expect(account.calls.every((path) => path.endsWith('/status')), isTrue); + expect( + (await service.resolve(selectedPubkey: first.public)).pubkey, + second.public, + ); + }, + ); + + test( + 'duplicate identities auto-resolve and truly empty inventory generates once', + () async { + final firstResult = await service.resolve(); + expect(firstResult.state, HostedIdentityState.needsBinding); + expect((await service.resolve()).pubkey, firstResult.pubkey); + FlutterSecureStorage.setMockInitialValues({ + 'buzz_nsec': first.nsec, + 'buzz_communities': jsonEncode([record(first, 'Same identity')]), + }); + expect((await service.resolve()).pubkey, first.public); + }, + ); + + test( + 'corrupt, incomplete and mismatched legacy records never authorize generation', + () async { + for (final data in [ + {'buzz_workspaces': '{broken'}, + {'buzz_nsec': ''}, + {'buzz_pubkey': first.public}, + { + 'buzz_communities': jsonEncode([ + {'nsec': first.nsec, 'pubkey': second.public}, + ]), + }, + ]) { + FlutterSecureStorage.setMockInitialValues(data); + await expectLater( + service.resolve(), + throwsA(AccountIdentityFailure.unreadable), + ); + expect(await secure.readAll(), data); + } + }, + ); + + test( + 'cloud backup differing from device requires consent and preserves old key', + () async { + await secure.write(key: 'buzz_nsec', value: first.nsec); + account.bound = second.public; + account.backups = [second.public]; + account.recovery = { + 'backups': [ + { + 'backup_id': 'fixture-backup', + 'nostr_pubkey': second.public, + 'secret_key': base64UrlEncode([ + ...List.filled(31, 0), + 2, + ]).replaceAll('=', ''), + }, + ], + 'failed_backup_ids': [], + }; + expect( + (await service.resolve()).state, + HostedIdentityState.needsSelection, + ); + expect(await store.read(), isNull); + expect( + (await service.resolve(selectedPubkey: first.public)).state, + HostedIdentityState.needsSelection, + ); + expect(await store.read(), isNull); + expect( + (await service.resolve(selectedPubkey: second.public)).state, + HostedIdentityState.ready, + ); + expect(await secure.read(key: 'buzz_nsec'), first.nsec); + expect( + account.calls.every( + (path) => path.endsWith('/status') || path.endsWith('/recovery'), + ), + isTrue, + ); + }, + ); + + test( + 'existing account slot cannot overwrite or bind over a different cloud backup', + () async { + await store.create(first.nsec); + final before = await secure.readAll(); + account.backups = [second.public]; + expect((await service.resolve()).state, HostedIdentityState.mismatch); + expect(await secure.readAll(), before); + }, + ); + + test('cancelled resolution cannot adopt or generate', () async { + await secure.write(key: 'buzz_nsec', value: first.nsec); + await expectLater( + service.resolve(isCurrent: () => false), + throwsA(AccountFailure.unavailable), + ); + expect(await store.read(), isNull); + }); +} diff --git a/mobile/test/shared/hosted/hosted_flow_test.dart b/mobile/test/shared/hosted/hosted_flow_test.dart index 768493c545..2dad32bfff 100644 --- a/mobile/test/shared/hosted/hosted_flow_test.dart +++ b/mobile/test/shared/hosted/hosted_flow_test.dart @@ -187,6 +187,11 @@ void main() { data = { 'identity': bound ? {'pubkey_hex': keys.public} : null, }; + } else if (path.endsWith('/status')) { + data = { + 'current_nostr_pubkey': bound ? keys.public : null, + 'backup_nostr_pubkeys': [], + }; } else if (path.endsWith('/challenge')) { effects.add('challenge'); data = { diff --git a/mobile/test/shared/hosted/hosted_identity_service_test.dart b/mobile/test/shared/hosted/hosted_identity_service_test.dart index 78b44eca99..bf17d33d2e 100644 --- a/mobile/test/shared/hosted/hosted_identity_service_test.dart +++ b/mobile/test/shared/hosted/hosted_identity_service_test.dart @@ -186,7 +186,7 @@ void main() { final first = await service.resolve(); expect(first.state, HostedIdentityState.needsBinding); expect((await service.resolve()).pubkey, first.pubkey); - expect(calls.where((p) => p.endsWith('/status')).length, 1); + expect(calls.where((p) => p.endsWith('/status')).length, 2); expect(calls.any((p) => p.endsWith('/recovery')), isFalse); }, );