mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
fix(mobile): scope discovery retries and preserve saved communities
Bind community selections to the signed-in account, match existing records by canonical relay identity, and offer skip or revised choices after a failed addition. Accept websocket host forms and filter unsupported destinations without losing valid results. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -17,6 +17,17 @@ class HostedCommunitySelection extends HookConsumerWidget {
|
||||
useListenable(flow);
|
||||
final invite = useTextEditingController();
|
||||
final error = useState<String?>(null);
|
||||
Future<void> 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'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<HostedFlow>((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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,19 +24,27 @@ Future<List<AddCommunityDeepLink>> 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);
|
||||
|
||||
58
mobile/lib/shared/hosted/hosted_community_connection.dart
Normal file
58
mobile/lib/shared/hosted/hosted_community_connection.dart
Normal file
@@ -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<void> connectHostedCommunity({
|
||||
required BuzzDeepLink link,
|
||||
required String subject,
|
||||
required String pubkey,
|
||||
required bool Function() isCurrent,
|
||||
required CommunityStorage storage,
|
||||
required HostedAdmission admission,
|
||||
required Future<void> 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);
|
||||
}
|
||||
@@ -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<String> selectedRelays = const {};
|
||||
final Set<String> _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<void> _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<void> 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<void> 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<void> 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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,6 +13,12 @@ import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
class DiscoveryBrowser extends BrowserAuth {
|
||||
final callbacks = StreamController<BrowserAuthCode>.broadcast();
|
||||
Map<String, String>? startedIntent;
|
||||
@override
|
||||
Future<void> start(Map<String, String> intent) async {
|
||||
startedIntent = intent;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<BrowserAuthCode> 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<Map<String, dynamic>> rows = [];
|
||||
String? bound;
|
||||
bool rejectList = false;
|
||||
bool expired = false;
|
||||
String subject = 'owner';
|
||||
Future<void> Function(String)? beforeAdmission;
|
||||
Future<void> 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void>();
|
||||
final release = Completer<void>();
|
||||
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<void>();
|
||||
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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<SavedCommunityIdentityConflict>()),
|
||||
);
|
||||
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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user