diff --git a/mobile/lib/features/onboarding/hosted_community_selection.dart b/mobile/lib/features/onboarding/hosted_community_selection.dart index 7d906a5c77..0612934e22 100644 --- a/mobile/lib/features/onboarding/hosted_community_selection.dart +++ b/mobile/lib/features/onboarding/hosted_community_selection.dart @@ -17,6 +17,17 @@ class HostedCommunitySelection extends HookConsumerWidget { useListenable(flow); final invite = useTextEditingController(); final error = useState(null); + Future openLink() async { + final uri = parseCommunityLinkInput(invite.text); + if (uri == null) { + error.value = 'Enter a valid community address or invite link.'; + return; + } + final pending = ref.read(pendingDeepLinkProvider.notifier); + await flow.leave(); + if (flow.stage == HostedFlowStage.idle) pending.open(uri); + } + if (flow.communities.isNotEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -55,6 +66,9 @@ class HostedCommunitySelection extends HookConsumerWidget { TextField( controller: invite, keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + onSubmitted: (_) => openLink(), + onChanged: (_) => error.value = null, autocorrect: false, enableSuggestions: false, decoration: InputDecoration( @@ -63,17 +77,7 @@ class HostedCommunitySelection extends HookConsumerWidget { ), ), OutlinedButton( - onPressed: () async { - final uri = parseCommunityLinkInput(invite.text); - if (uri == null) { - error.value = 'Enter a valid community address or invite link.'; - return; - } - // Hand off to the existing dispatcher, including self-hosted invites. - final pending = ref.read(pendingDeepLinkProvider.notifier); - await flow.leave(); - if (flow.stage == HostedFlowStage.idle) pending.open(uri); - }, + onPressed: openLink, child: const Text('Open community link'), ), ], diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart index c74c50008c..4c914c6bc9 100644 --- a/mobile/lib/features/onboarding/hosted_connect_page.dart +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -3,11 +3,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; -import 'hosted_community_selection.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'; +import 'hosted_community_selection.dart'; /// Presents the app-scoped real flow without retaining credentials or keys. class HostedConnectPage extends HookConsumerWidget { @@ -31,8 +31,11 @@ class HostedConnectPage extends HookConsumerWidget { : 'Adding ${flow.addingCommunityName}…', HostedFlowStage.chooseCommunities => flow.communities.isEmpty - ? 'No active owned communities yet. Create one in Builderlab, then refresh, or enter a community link.' + ? 'No communities to add. Create one in Builderlab, then refresh, or enter a community link.' : 'Choose your communities', + HostedFlowStage.communityConflict => + '${flow.addingCommunityName} is already saved with a different identity. ' + 'Keep that identity and skip this addition, or return to your choices?', HostedFlowStage.chooseIdentity => flow.identityChoices.any((choice) => choice.fromCloud) ? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.' @@ -87,17 +90,30 @@ class HostedConnectPage extends HookConsumerWidget { ), if (flow.stage == HostedFlowStage.chooseCommunities) HostedCommunitySelection(flow: flow), - if (flow.stage == HostedFlowStage.blocked && + if (flow.stage == HostedFlowStage.communityConflict) + FilledButton( + onPressed: flow.skipConflictingCommunity, + child: const Text('Keep saved identity and skip'), + ), + if (flow.canChangeCommunities) + OutlinedButton( + onPressed: flow.backToCommunityChoices, + child: const Text('Back to choices'), + ), + if ((flow.stage == HostedFlowStage.blocked || + flow.stage == HostedFlowStage.communityConflict) && flow.addedCommunityCount > 0) Text( - '${flow.addedCommunityCount} communities added. ' - 'They have been kept; retry continues the remaining additions.', + '${flow.addedCommunityCount} ' + '${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.' + '${flow.canRetry && flow.stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}', ), if (working) const Center(child: CircularProgressIndicator()), if (!working && flow.stage != HostedFlowStage.chooseIdentity && flow.stage != HostedFlowStage.chooseCommunities && + flow.stage != HostedFlowStage.communityConflict && 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 6ab24b7c5a..1c34b0c3d8 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -12,6 +12,7 @@ import 'shared/hosted/account_identity_store.dart'; import 'shared/hosted/browser_auth.dart'; import 'shared/hosted/builderlab_account.dart'; import 'shared/hosted/hosted_admission.dart'; +import 'shared/hosted/hosted_community_connection.dart'; import 'shared/hosted/hosted_flow.dart'; import 'shared/hosted/hosted_identity_service.dart'; import 'shared/hosted/device_identity_source.dart'; @@ -44,36 +45,17 @@ final hostedFlowProvider = Provider((ref) { deviceIdentities: DeviceIdentitySource(), ), enterCommunity: (link, subject, pubkey, isCurrent) async { - final relay = switch (link) { - InviteDeepLink(:final relayUrl) => relayUrl, - AddCommunityDeepLink(:final relayUrl) => relayUrl, - _ => throw AccountFailure.invalidResponse, - }; - final communities = await ref.read(communityStorageProvider).loadAll(); - final existing = communities - .where( - (item) => - Uri.parse(item.relayUrl).replace(path: '').toString() == - Uri.parse(relay).replace(path: '').toString(), - ) - .firstOrNull; - if (existing != null && existing.pubkey != pubkey) { - throw AccountIdentityFailure.mismatch; - } - if (!isCurrent()) return; - final prepared = await admission.prepare( - link, - subject, - pubkey, + await connectHostedCommunity( + link: link, + subject: subject, + pubkey: pubkey, isCurrent: isCurrent, + storage: ref.read(communityStorageProvider), + admission: admission, + persist: (community, current) => ref + .read(authProvider.notifier) + .authenticateWithCommunity(community, shouldContinue: current), ); - final community = existing == null - ? prepared - : existing.copyWith(nsec: prepared.nsec); - if (!isCurrent()) return; - await ref - .read(authProvider.notifier) - .authenticateWithCommunity(community, shouldContinue: isCurrent); }, ); ref.onDispose(() { @@ -128,8 +110,7 @@ class HostedAppGate extends HookConsumerWidget { .loadAll(); final existing = communities .where( - (item) => - _relayIdentity(item.relayUrl) == _relayIdentity(relay), + (item) => relayIdentity(item.relayUrl) == relayIdentity(relay), ) .firstOrNull; if (existing != null) { @@ -209,13 +190,3 @@ class HostedAppGate extends HookConsumerWidget { ); } } - -({bool secure, String host, int port}) _relayIdentity(String value) { - final uri = Uri.parse(value); - final secure = uri.scheme == 'wss' || uri.scheme == 'https'; - return ( - secure: secure, - host: uri.host.toLowerCase(), - port: uri.hasPort ? uri.port : (secure ? 443 : 80), - ); -} diff --git a/mobile/lib/shared/hosted/hosted_communities.dart b/mobile/lib/shared/hosted/hosted_communities.dart index 0d95ae1525..e70ab19810 100644 --- a/mobile/lib/shared/hosted/hosted_communities.dart +++ b/mobile/lib/shared/hosted/hosted_communities.dart @@ -24,19 +24,27 @@ Future> loadHostedCommunities( } final host = row['normalized_host']; final name = row['name']; - if (host is! String || - !RegExp(r'^[a-z0-9]+(?:[.-][a-z0-9]+)*$').hasMatch(host) || - name is! String || - name.trim().isEmpty) { + if (host is! String || name is! String || name.trim().isEmpty) { throw AccountFailure.invalidResponse; } - final relay = Uri(scheme: 'wss', host: host); - if (!HostedEnvironment.supportsRelay(relay)) { + final relay = Uri.tryParse(host.contains('://') ? host : 'wss://$host'); + if (relay == null || + relay.scheme != 'wss' || + !RegExp(r'^[a-z0-9]+(?:[.-][a-z0-9]+)*$').hasMatch(relay.host) || + relay.userInfo.isNotEmpty || + relay.hasQuery || + relay.hasFragment || + (relay.path.isNotEmpty && relay.path != '/') || + (relay.hasPort && relay.port != 443)) { throw AccountFailure.invalidResponse; } + // A hosted listing can contain communities this build cannot enter. + // Never redirect the account session or discard otherwise usable entries. + if (!HostedEnvironment.supportsRelay(relay)) continue; + final canonical = Uri(scheme: 'wss', host: relay.host); communities.putIfAbsent( - host, - () => AddCommunityDeepLink(relayUrl: relay.toString(), name: name), + canonical.host, + () => AddCommunityDeepLink(relayUrl: canonical.toString(), name: name), ); } return List.unmodifiable(communities.values); diff --git a/mobile/lib/shared/hosted/hosted_community_connection.dart b/mobile/lib/shared/hosted/hosted_community_connection.dart new file mode 100644 index 0000000000..4e178ab448 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_community_connection.dart @@ -0,0 +1,58 @@ +import '../community/community.dart'; +import '../community/community_storage.dart'; +import '../deeplink/deep_link.dart'; +import 'builderlab_account.dart'; +import 'hosted_admission.dart'; + +/// A saved community uses another identity. Only explicit skip/leave is safe. +class SavedCommunityIdentityConflict implements Exception { + const SavedCommunityIdentityConflict(); +} + +/// Canonical community identity shared by discovery and deep-link entry. +({bool secure, String host, int port}) relayIdentity(String value) { + final uri = Uri.parse(value); + final secure = uri.scheme == 'wss' || uri.scheme == 'https'; + return ( + secure: secure, + host: uri.host.toLowerCase(), + port: uri.hasPort ? uri.port : (secure ? 443 : 80), + ); +} + +/// Reuses existing records and their ids before the normal admission/persistence +/// boundary. A different existing key is never replaced. +Future connectHostedCommunity({ + required BuzzDeepLink link, + required String subject, + required String pubkey, + required bool Function() isCurrent, + required CommunityStorage storage, + required HostedAdmission admission, + required Future Function(Community, bool Function()) persist, +}) async { + final relay = switch (link) { + InviteDeepLink(:final relayUrl) => relayUrl, + AddCommunityDeepLink(:final relayUrl) => relayUrl, + _ => throw AccountFailure.invalidResponse, + }; + final communities = await storage.loadAll(); + final existing = communities + .where((item) => relayIdentity(item.relayUrl) == relayIdentity(relay)) + .firstOrNull; + if (existing != null && existing.pubkey != pubkey) { + throw const SavedCommunityIdentityConflict(); + } + if (!isCurrent()) return; + final prepared = await admission.prepare( + link, + subject, + pubkey, + isCurrent: isCurrent, + ); + final community = existing == null + ? prepared + : existing.copyWith(nsec: prepared.nsec); + if (!isCurrent()) return; + await persist(community, isCurrent); +} diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 321a7bae9b..68ee400ae3 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -13,6 +13,7 @@ import 'hosted_backup_service.dart'; import 'hpke_backup.dart'; import 'hosted_environment.dart'; import 'hosted_communities.dart'; +import 'hosted_community_connection.dart'; import 'web_connect_binding.dart'; enum HostedFlowStage { @@ -21,6 +22,7 @@ enum HostedFlowStage { signIn, chooseIdentity, chooseCommunities, + communityConflict, blocked, browserReturned, complete, @@ -82,6 +84,14 @@ class HostedFlow extends ChangeNotifier { Set selectedRelays = const {}; final Set _addedRelays = {}; bool _addingCommunities = false; + String? _batchSubject; + String? _conflictingRelay; + bool get canChangeCommunities => + accountOnly && + account.account?.subject == _batchSubject && + (stage == HostedFlowStage.blocked || + stage == HostedFlowStage.communityConflict) && + communities.isNotEmpty; String? addingCommunityName; int get addedCommunityCount => _addedRelays.length; Future _tail = Future.value(); @@ -173,11 +183,7 @@ class HostedFlow extends ChangeNotifier { final generation = openBrowser ? ++_generation : _generation; if (openBrowser) { accountOnly = true; - communities = const []; - selectedRelays = const {}; - _addedRelays.clear(); - _addingCommunities = false; - addingCommunityName = null; + _clearCommunityBatch(); _set(HostedFlowStage.working); } return _enqueue(() async { @@ -206,6 +212,7 @@ class HostedFlow extends ChangeNotifier { } final generation = ++_generation; accountOnly = false; + _clearCommunityBatch(); _target = target; _set(HostedFlowStage.working); return _enqueue(() async { @@ -270,6 +277,10 @@ class HostedFlow extends ChangeNotifier { throw AccountFailure.signedOut; } if (target != null && !_acceptTarget(target)) return; + if (target == null && _batchSubject != session.subject) { + _clearCommunityBatch(); + _batchSubject = session.subject; + } var resolved = await identity.resolve( isCurrent: () => !_disposed && generation == _generation, selectedPubkey: selectedPubkey, @@ -405,10 +416,38 @@ class HostedFlow extends ChangeNotifier { return _enqueue(() => _continue(generation)); } + /// Keep completed additions and let the user omit a failing target. + void backToCommunityChoices() { + if (!canChangeCommunities) return; + ++_generation; + communities = List.unmodifiable( + communities.where((item) => !_addedRelays.contains(item.relayUrl)), + ); + selectedRelays = Set.unmodifiable(selectedRelays.difference(_addedRelays)); + _conflictingRelay = null; + addingCommunityName = null; + _set(HostedFlowStage.chooseCommunities); + } + + Future skipConflictingCommunity() { + final relay = _conflictingRelay; + if (stage != HostedFlowStage.communityConflict || relay == null) { + return Future.value(); + } + selectedRelays = Set.unmodifiable( + selectedRelays.where((item) => item != relay), + ); + _conflictingRelay = null; + final generation = ++_generation; + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation)); + } + Future refreshCommunities() { if (!accountOnly || stage != HostedFlowStage.chooseCommunities) { return Future.value(); } + _addingCommunities = false; return retry(); } @@ -454,12 +493,26 @@ class HostedFlow extends ChangeNotifier { } addingCommunityName = community.name; notifyListeners(); - await enterCommunity(community, subject, pubkey, current); + try { + await enterCommunity(community, subject, pubkey, current); + } on SavedCommunityIdentityConflict { + if (!current()) return; + _conflictingRelay = community.relayUrl; + _set(HostedFlowStage.communityConflict); + return; + } if (!current()) return; _addedRelays.add(community.relayUrl); } if (!current()) return; addingCommunityName = null; + if (_addedRelays.isEmpty) { + communities = List.unmodifiable( + communities.where((item) => selectedRelays.contains(item.relayUrl)), + ); + _set(HostedFlowStage.chooseCommunities); + return; + } await browser.clearIntent(); if (current()) _set(HostedFlowStage.complete); } @@ -529,6 +582,7 @@ class HostedFlow extends ChangeNotifier { AccountIdentityFailure.invalidKey || AccountFailure.storage || HostedBackupFailure.storage => HostedFlowProblem.identityUnreadable, + SavedCommunityIdentityConflict() || AccountIdentityFailure.mismatch || AccountIdentityFailure.occupied || HostedBackupFailure.identityMismatch || @@ -548,16 +602,22 @@ class HostedFlow extends ChangeNotifier { notifyListeners(); } + void _clearCommunityBatch() { + communities = const []; + selectedRelays = const {}; + _addedRelays.clear(); + _batchSubject = null; + _conflictingRelay = null; + _addingCommunities = false; + addingCommunityName = null; + } + Future leave() async { ++_generation; final generation = _generation; _target = null; accountOnly = false; - communities = const []; - selectedRelays = const {}; - _addedRelays.clear(); - _addingCommunities = false; - addingCommunityName = null; + _clearCommunityBatch(); try { await browser.cancel(); if (_disposed || generation != _generation) return; diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index 242c1c159d..d99f36efc1 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -3,6 +3,8 @@ import 'package:buzz/hosted_app_gate.dart'; import 'package:buzz/onboarding_app_entry.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_community_connection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; @@ -56,6 +58,34 @@ void main() { await tester.pumpAndSettle(); expect(f.admitted, ['two']); expect(find.byType(HostedConnectPage), findsNothing); + f.admitted.clear(); + f.beforeAdmission = (name) async { + if (name == 'two') throw const SavedCommunityIdentityConflict(); + }; + await tester.tap(find.text('Continue with Builderlab')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('Keep saved identity and skip'), findsOneWidget); + expect(find.text('1 community added and kept.'), findsOneWidget); + await tester.tap(find.text('Keep saved identity and skip')); + await tester.pumpAndSettle(); + expect(f.admitted, ['one']); + expect(find.byType(HostedConnectPage), findsNothing); + f.admitted.clear(); + f.beforeAdmission = (name) async { + if (name == 'two') throw AccountFailure.rejected; + }; + await tester.tap(find.text('Continue with Builderlab')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('1 community added and kept.'), findsOneWidget); + expect(find.text('Try again').hitTestable(), findsNothing); + await tester.tap(find.text('Back to choices')); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile), findsOneWidget); + expect(find.text('two'), findsOneWidget); } await tester.pumpWidget(const SizedBox.shrink()); { @@ -92,7 +122,7 @@ void main() { find.byType(TextField), 'https://one.communities.buzz.xyz/invite/code', ); - await tester.tap(find.text('Open community link')); + await tester.testTextInput.receiveAction(TextInputAction.go); await tester.pumpAndSettle(); expect(f.flow.stage, HostedFlowStage.idle); final container = ProviderScope.containerOf( diff --git a/mobile/test/helpers/hosted_communities_fixture.dart b/mobile/test/helpers/hosted_communities_fixture.dart index 81d8bc8426..9c2d9c21f4 100644 --- a/mobile/test/helpers/hosted_communities_fixture.dart +++ b/mobile/test/helpers/hosted_communities_fixture.dart @@ -13,6 +13,12 @@ import 'package:nostr/nostr.dart' as nostr; class DiscoveryBrowser extends BrowserAuth { final callbacks = StreamController.broadcast(); + Map? startedIntent; + @override + Future start(Map intent) async { + startedIntent = intent; + } + @override Stream get codes => callbacks.stream; @override @@ -36,7 +42,7 @@ class WidgetDiscoveryAccount extends BuilderlabAccountClient { final HostedCommunitiesFixture fixture; @override BuilderlabAccount get account => BuilderlabAccount( - subject: 'owner', + subject: fixture.subject, expiresAt: DateTime.now().add(const Duration(hours: 1)), ); @override @@ -69,6 +75,8 @@ class HostedCommunitiesFixture { List> rows = []; String? bound; bool rejectList = false; + bool expired = false; + String subject = 'owner'; Future Function(String)? beforeAdmission; Future Function()? beforeList; Uri? opened; @@ -97,8 +105,9 @@ class HostedCommunitiesFixture { ); } if (path.endsWith('/me')) { + if (expired) return http.Response('{}', 401); return http.Response( - jsonEncode({'subject': 'owner', 'expires_at': expiry}), + jsonEncode({'subject': subject, 'expires_at': expiry}), 200, ); } diff --git a/mobile/test/shared/hosted/hosted_communities_flow_test.dart b/mobile/test/shared/hosted/hosted_communities_flow_test.dart index 183ae47d16..d0cade2dce 100644 --- a/mobile/test/shared/hosted/hosted_communities_flow_test.dart +++ b/mobile/test/shared/hosted/hosted_communities_flow_test.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:buzz/shared/hosted/builderlab_account.dart'; import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/hosted_community_connection.dart'; import 'package:buzz/shared/hosted/hosted_flow.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -134,7 +136,7 @@ void main() { test('cancelled list cannot display or auto-add its late response', () async { final f = await fixture(); - f.rows = [f.row('one')]; + f.rows = [f.row('one'), f.row('two')]; final started = Completer(); final release = Completer(); f.beforeList = () async { @@ -148,6 +150,8 @@ void main() { await pending; expect(f.admitted, isEmpty); expect(f.flow.stage, HostedFlowStage.idle); + expect(f.flow.communities, isEmpty); + expect(f.flow.selectedRelays, isEmpty); }); test( @@ -189,11 +193,11 @@ void main() { }); test( - 'untrusted list targets and failed discovery never look like empty lists', + 'malformed list targets and failed discovery never look like empty lists', () async { final f = await fixture(); f.rows = [ - {...f.row('one'), 'normalized_host': 'evil.example'}, + {...f.row('one'), 'normalized_host': 'wss://user@evil.example/path'}, ]; await f.flow.signInAccount(); expect(f.flow.problem, HostedFlowProblem.invalidResponse); @@ -204,4 +208,110 @@ void main() { expect(f.flow.problem, HostedFlowProblem.retry); }, ); + test('expired A batch is discarded after browser login as B', () async { + final f = await fixture(); + await f.flow.initialize(); + f.rows = [f.row('a-one'), f.row('a-two')]; + f.beforeAdmission = (name) async { + if (name == 'a-two') throw AccountFailure.unavailable; + }; + await f.flow.signInAccount(); + await f.flow.addSelectedCommunities(); + expect(f.admitted, ['a-one']); + f.expired = true; + await f.flow.retry(); + expect(f.flow.stage, HostedFlowStage.signIn); + expect(f.browser.startedIntent, {'type': 'account'}); + f.expired = false; + f.subject = 'other-owner'; + f.rows = [f.row('b-one')]; + await f.store.confirmBackup( + BackupReceipt( + backend: BuilderlabAccountClient.origin.toString(), + subject: f.subject, + pubkey: f.keys.public, + backupId: 'other-backup', + ), + ); + final completed = Completer(); + f.flow.addListener(() { + if (f.flow.stage == HostedFlowStage.complete && !completed.isCompleted) { + completed.complete(); + } + }); + f.browser.callbacks.add( + BrowserAuthCode('other-code', 'a' * 43, {'type': 'account'}), + ); + await completed.future.timeout(const Duration(seconds: 2)); + expect(f.admitted, ['a-one', 'b-one']); + }); + + test( + 'conflict asks and explicit skip preserves other selected additions', + () async { + final f = await fixture(); + f.rows = [f.row('one'), f.row('two'), f.row('three')]; + f.beforeAdmission = (name) async { + if (name == 'two') throw const SavedCommunityIdentityConflict(); + }; + await f.flow.signInAccount(); + await f.flow.addSelectedCommunities(); + expect(f.flow.stage, HostedFlowStage.communityConflict); + expect(f.admitted, ['one']); + await f.flow.skipConflictingCommunity(); + expect(f.admitted, ['one', 'three']); + expect(f.flow.stage, HostedFlowStage.complete); + }, + ); + + test( + 'blocked batch can return to remaining choices and omit failure', + () async { + final f = await fixture(); + f.rows = [f.row('one'), f.row('two'), f.row('three')]; + f.beforeAdmission = (name) async { + if (name == 'two') throw AccountFailure.rejected; + }; + await f.flow.signInAccount(); + await f.flow.addSelectedCommunities(); + expect(f.flow.canRetry, isFalse); + f.flow.backToCommunityChoices(); + expect(f.flow.communities.map((item) => item.name), ['two', 'three']); + f.flow.selectCommunity('wss://two.communities.buzz.xyz', false); + await f.flow.addSelectedCommunities(); + expect(f.admitted, ['one', 'three']); + }, + ); + + test( + 'single conflicting community can be skipped then creation refreshed', + () async { + final f = await fixture(); + f.rows = [f.row('conflict')]; + f.beforeAdmission = (_) async => + throw const SavedCommunityIdentityConflict(); + await f.flow.signInAccount(); + await f.flow.skipConflictingCommunity(); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.flow.communities, isEmpty); + f.rows = [f.row('created')]; + f.beforeAdmission = null; + await f.flow.refreshCommunities(); + expect(f.admitted, ['created']); + }, + ); + + test('unsupported rows are skipped and wss prefix canonicalized', () async { + final f = await fixture(); + f.rows = [ + {...f.row('external'), 'normalized_host': 'external.example'}, + { + ...f.row('one'), + 'normalized_host': 'wss://one.communities.buzz.xyz:443/', + }, + f.row('one'), + ]; + await f.flow.signInAccount(); + expect(f.admitted, ['one']); + }); } diff --git a/mobile/test/shared/hosted/hosted_community_connection_test.dart b/mobile/test/shared/hosted/hosted_community_connection_test.dart new file mode 100644 index 0000000000..40e4e6ade4 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_community_connection_test.dart @@ -0,0 +1,83 @@ +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/hosted_admission.dart'; +import 'package:buzz/shared/hosted/hosted_community_connection.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../helpers/hosted_communities_fixture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + for (final conflicting in [false, true]) { + test( + 'real admission preserves saved :443 record (conflict=$conflicting)', + () async { + final f = HostedCommunitiesFixture(); + await f.initialize(); + addTearDown(f.dispose); + final storage = CommunityStorage(); + final savedKeys = conflicting + ? nostr.Keys('2'.padLeft(64, '0')) + : f.keys; + final existing = Community.create( + name: 'Saved name', + relayUrl: 'wss://one.communities.buzz.xyz:443/', + pubkey: savedKeys.public, + nsec: savedKeys.nsec, + ); + await storage.save(existing); + final unrelated = Community.create( + name: 'Other', + relayUrl: 'wss://other.example', + pubkey: savedKeys.public, + nsec: savedKeys.nsec, + ); + await storage.save(unrelated); + var probes = 0; + final admission = HostedAdmission( + account: f.account, + store: f.store, + probe: (_) async { + probes++; + }, + ); + addTearDown(admission.dispose); + final operation = connectHostedCommunity( + link: const AddCommunityDeepLink( + relayUrl: 'wss://one.communities.buzz.xyz', + name: 'one', + ), + subject: 'owner', + pubkey: f.keys.public, + isCurrent: () => true, + storage: storage, + admission: admission, + persist: (community, current) async { + if (current()) await storage.save(community); + }, + ); + if (conflicting) { + await expectLater( + operation, + throwsA(isA()), + ); + expect(probes, 0); + } else { + await operation; + expect(probes, 1); + } + final after = await storage.loadAll(); + expect(after.length, 2); + expect(after.first.id, existing.id); + expect(after.first.name, existing.name); + expect(after.first.nsec, savedKeys.nsec); + expect(after.last.toJson(), unrelated.toJson()); + }, + ); + } +}