feat(mobile): discover owned communities after Builderlab sign-in

Continue through identity and backup setup, open a single active community, or offer multiple communities selected by default. Preserve successful additions on retry and reuse hosted admission for every target.

Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-10-15 17:45:00 -07:00
parent 7191d0bb33
commit 908d0db104
9 changed files with 759 additions and 25 deletions

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
import '../../shared/hosted/hosted_flow.dart';
import '../../shared/theme/grid.dart';
import 'community_link_input.dart';
/// Public community choices only; identity material remains in secure storage.
class HostedCommunitySelection extends HookConsumerWidget {
const HostedCommunitySelection({super.key, required this.flow});
final HostedFlow flow;
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenable(flow);
final invite = useTextEditingController();
final error = useState<String?>(null);
if (flow.communities.isNotEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final community in flow.communities)
CheckboxListTile(
key: ValueKey(community.relayUrl),
title: Text(community.name),
subtitle: Text(Uri.parse(community.relayUrl).host),
value: flow.selectedRelays.contains(community.relayUrl),
onChanged: (value) =>
flow.selectCommunity(community.relayUrl, value ?? false),
),
const SizedBox(height: Grid.sm),
FilledButton(
onPressed: flow.selectedRelays.isEmpty
? null
: flow.addSelectedCommunities,
child: const Text('Continue'),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
onPressed: flow.openCommunityCreation,
child: const Text('Create a community in Builderlab'),
),
TextButton(
onPressed: flow.refreshCommunities,
child: const Text('Refresh communities'),
),
const SizedBox(height: Grid.sm),
TextField(
controller: invite,
keyboardType: TextInputType.url,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: 'Community address or invite link',
errorText: error.value,
),
),
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);
},
child: const Text('Open community link'),
),
],
);
}
}

View File

@@ -3,6 +3,7 @@ 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';
@@ -24,7 +25,14 @@ class HostedConnectPage extends HookConsumerWidget {
final working = flow.stage == HostedFlowStage.working;
final message = switch (flow.stage) {
HostedFlowStage.idle => 'Continue connecting your account.',
HostedFlowStage.working => 'Connecting your account…',
HostedFlowStage.working =>
flow.addingCommunityName == null
? 'Securing your account and finding communities…'
: 'Adding ${flow.addingCommunityName}…',
HostedFlowStage.chooseCommunities =>
flow.communities.isEmpty
? 'No active owned communities yet. Create one in Builderlab, then refresh, or enter a community link.'
: 'Choose your communities',
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.'
@@ -77,10 +85,19 @@ class HostedConnectPage extends HookConsumerWidget {
),
),
),
if (flow.stage == HostedFlowStage.chooseCommunities)
HostedCommunitySelection(flow: flow),
if (flow.stage == HostedFlowStage.blocked &&
flow.addedCommunityCount > 0)
Text(
'${flow.addedCommunityCount} communities added. '
'They have been kept; retry continues the remaining additions.',
),
if (working)
const Center(child: CircularProgressIndicator()),
if (!working &&
flow.stage != HostedFlowStage.chooseIdentity &&
flow.stage != HostedFlowStage.chooseCommunities &&
flow.stage != HostedFlowStage.complete &&
flow.stage != HostedFlowStage.browserReturned &&
flow.canRetry)

View File

@@ -112,7 +112,6 @@ class HostedAppGate extends HookConsumerWidget {
}, [flow]);
final inlineEntry = ref.watch(hostedInlineEntryProvider);
final visible =
!flow.accountOnly &&
flow.stage != HostedFlowStage.idle &&
flow.stage != HostedFlowStage.complete;
return DeepLinkDispatcher(
@@ -198,7 +197,9 @@ class HostedAppGate extends HookConsumerWidget {
children: [
child,
if (visible &&
(!inlineEntry || flow.stage == HostedFlowStage.chooseIdentity))
(!inlineEntry ||
flow.accountOnly ||
flow.stage == HostedFlowStage.chooseIdentity))
HostedConnectPage(
flow: flow,
onClose: () => unawaited(flow.leave()),

View File

@@ -31,8 +31,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
flow.accountOnly &&
(flow.stage == HostedFlowStage.working ||
flow.stage == HostedFlowStage.signIn);
final connected =
flow.accountOnly && flow.stage == HostedFlowStage.complete;
final connected = flow.account.account != null;
final problem = flow.accountOnly ? flow.problem : null;
final status = connected
? 'Connected to Builderlab. Your session is saved securely on this device.'
@@ -64,12 +63,10 @@ class OnboardingAppEntry extends HookConsumerWidget {
existingUser: existingUser.value,
enteringCommunityLink: enteringCommunityLink.value,
onCommunityLink: () => enteringCommunityLink.value = true,
onBuilderlab: busy || connected
? null
: () => unawaited(flow.signInAccount()),
onBuilderlab: busy ? null : () => unawaited(flow.signInAccount()),
accountStatus: status,
builderlabLabel: connected
? 'Connected to Builderlab'
? 'Continue with Builderlab'
: busy
? 'Signing in…'
: 'Connect to Builderlab',

View File

@@ -0,0 +1,43 @@
import '../deeplink/deep_link.dart';
import 'builderlab_account.dart';
import 'hosted_environment.dart';
/// Validated, active communities owned by the signed-in Builderlab identity.
/// Discovery never grants admission; each target still goes through HostedAdmission.
Future<List<AddCommunityDeepLink>> loadHostedCommunities(
BuilderlabAccountClient account,
) async {
final response = await account.post('/v1/buzz/communities/list', {});
final rows = response['communities'];
if (response['error'] != null || rows is! List) {
throw AccountFailure.invalidResponse;
}
final communities = <String, AddCommunityDeepLink>{};
for (final row in rows) {
if (row is! Map<String, dynamic>) throw AccountFailure.invalidResponse;
final archived = row['archived_at'];
if (archived != null) {
if (archived is! String || archived.isEmpty) {
throw AccountFailure.invalidResponse;
}
continue;
}
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) {
throw AccountFailure.invalidResponse;
}
final relay = Uri(scheme: 'wss', host: host);
if (!HostedEnvironment.supportsRelay(relay)) {
throw AccountFailure.invalidResponse;
}
communities.putIfAbsent(
host,
() => AddCommunityDeepLink(relayUrl: relay.toString(), name: name),
);
}
return List.unmodifiable(communities.values);
}

View File

@@ -12,6 +12,7 @@ import 'hosted_identity_service.dart';
import 'hosted_backup_service.dart';
import 'hpke_backup.dart';
import 'hosted_environment.dart';
import 'hosted_communities.dart';
import 'web_connect_binding.dart';
enum HostedFlowStage {
@@ -19,6 +20,7 @@ enum HostedFlowStage {
working,
signIn,
chooseIdentity,
chooseCommunities,
blocked,
browserReturned,
complete,
@@ -76,6 +78,12 @@ class HostedFlow extends ChangeNotifier {
List<DeviceIdentityChoice> identityChoices = const [];
BuzzDeepLink? _target;
bool accountOnly = false;
List<AddCommunityDeepLink> communities = const [];
Set<String> selectedRelays = const {};
final Set<String> _addedRelays = {};
bool _addingCommunities = false;
String? addingCommunityName;
int get addedCommunityCount => _addedRelays.length;
Future<void> _tail = Future.value();
StreamSubscription<BrowserAuthCode>? _codes;
StreamSubscription<BrowserAuthFailure>? _failures;
@@ -96,8 +104,7 @@ class HostedFlow extends ChangeNotifier {
_set(HostedFlowStage.working);
await account.exchange(code: code.code, verifier: code.verifier);
if (_disposed || generation != _generation) return;
await browser.clearIntent();
_set(HostedFlowStage.complete);
await _continue(generation);
return;
}
accountOnly = false;
@@ -155,7 +162,8 @@ class HostedFlow extends ChangeNotifier {
});
}
/// Account setup only: never resolve or generate a Nostr identity here.
/// Explicit Connect completes identity setup and discovers owned communities.
/// Passive startup restore only checks the saved session.
Future<void> signInAccount({bool openBrowser = true}) {
if (_target != null ||
stage == HostedFlowStage.working ||
@@ -165,24 +173,27 @@ class HostedFlow extends ChangeNotifier {
final generation = openBrowser ? ++_generation : _generation;
if (openBrowser) {
accountOnly = true;
communities = const [];
selectedRelays = const {};
_addedRelays.clear();
_addingCommunities = false;
addingCommunityName = null;
_set(HostedFlowStage.working);
}
return _enqueue(() async {
if (!openBrowser) {
if (_target != null || stage != HostedFlowStage.idle) return;
accountOnly = true;
_set(HostedFlowStage.working);
}
final restored = await account.restore();
if (_disposed || generation != _generation) return;
if (restored != null) {
await browser.clearIntent();
_set(HostedFlowStage.complete);
} else if (openBrowser) {
if (!openBrowser) {
// Mounting the welcome page must not create/bind/back up a key.
_set(HostedFlowStage.idle);
} else if (restored != null) {
await _continue(generation);
} else {
_set(HostedFlowStage.signIn);
await browser.start({'type': 'account'});
} else {
_set(HostedFlowStage.idle);
}
});
}
@@ -213,11 +224,26 @@ class HostedFlow extends ChangeNotifier {
Future<void> retry() async {
final target = _target;
if (target == null) return;
if (target == null && !accountOnly) return;
final generation = ++_generation;
try {
await browser.cancel();
if (!_disposed && generation == _generation) await start(target);
if (_disposed || generation != _generation) return;
if (target != null) {
await start(target);
} else {
_set(HostedFlowStage.working);
await _enqueue(() async {
final restored = await account.restore();
if (_disposed || generation != _generation) return;
if (restored == null) {
_set(HostedFlowStage.signIn);
await browser.start({'type': 'account'});
} else {
await _continue(generation);
}
});
}
} catch (_) {
if (!_disposed && generation == _generation) {
_set(HostedFlowStage.blocked, HostedFlowProblem.retry);
@@ -227,7 +253,7 @@ class HostedFlow extends ChangeNotifier {
/// Resume only an offered public identity; reread local and cloud facts first.
Future<void> chooseIdentity(String pubkey) {
if (_target == null ||
if ((_target == null && !accountOnly) ||
stage != HostedFlowStage.chooseIdentity ||
!identityChoices.any((item) => item.pubkey == pubkey)) {
return Future.value();
@@ -240,8 +266,10 @@ class HostedFlow extends ChangeNotifier {
Future<void> _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;
if ((target == null && !accountOnly) || session == null) {
throw AccountFailure.signedOut;
}
if (target != null && !_acceptTarget(target)) return;
var resolved = await identity.resolve(
isCurrent: () => !_disposed && generation == _generation,
selectedPubkey: selectedPubkey,
@@ -338,6 +366,10 @@ class HostedFlow extends ChangeNotifier {
_set(HostedFlowStage.blocked, failure);
return;
}
if (target == null) {
await _continueCommunities(generation, session.subject, pubkey);
return;
}
await enterCommunity(
target,
session.subject,
@@ -349,6 +381,89 @@ class HostedFlow extends ChangeNotifier {
_set(HostedFlowStage.complete);
}
void selectCommunity(String relay, bool selected) {
if (stage != HostedFlowStage.chooseCommunities ||
!communities.any((item) => item.relayUrl == relay)) {
return;
}
selectedRelays = Set.unmodifiable({
...selectedRelays.where((item) => item != relay),
if (selected) relay,
});
notifyListeners();
}
Future<void> addSelectedCommunities() {
if (!accountOnly ||
stage != HostedFlowStage.chooseCommunities ||
selectedRelays.isEmpty) {
return Future.value();
}
_addingCommunities = true;
final generation = ++_generation;
_set(HostedFlowStage.working);
return _enqueue(() => _continue(generation));
}
Future<void> refreshCommunities() {
if (!accountOnly || stage != HostedFlowStage.chooseCommunities) {
return Future.value();
}
return retry();
}
Future<void> openCommunityCreation() async {
if (stage != HostedFlowStage.chooseCommunities) return;
final generation = _generation;
try {
if (!await _openWeb(HostedEnvironment.origin.resolve('/buzz'))) {
throw BrowserAuthFailure.unavailable;
}
} catch (error) {
if (!_disposed && generation == _generation) {
_set(HostedFlowStage.blocked, _problemFor(error));
}
}
}
Future<void> _continueCommunities(
int generation,
String subject,
String pubkey,
) async {
bool current() =>
!_disposed &&
generation == _generation &&
account.account?.subject == subject;
if (!_addingCommunities) {
final listed = await loadHostedCommunities(account);
if (!current()) return;
communities = listed;
selectedRelays = Set.unmodifiable(listed.map((item) => item.relayUrl));
if (listed.length != 1) {
_set(HostedFlowStage.chooseCommunities);
return;
}
_addingCommunities = true;
}
for (final community in communities) {
if (!current()) return;
if (!selectedRelays.contains(community.relayUrl) ||
_addedRelays.contains(community.relayUrl)) {
continue;
}
addingCommunityName = community.name;
notifyListeners();
await enterCommunity(community, subject, pubkey, current);
if (!current()) return;
_addedRelays.add(community.relayUrl);
}
if (!current()) return;
addingCommunityName = null;
await browser.clearIntent();
if (current()) _set(HostedFlowStage.complete);
}
bool _acceptTarget(BuzzDeepLink target) {
final relay = switch (target) {
InviteDeepLink(:final relayUrl) => relayUrl,
@@ -437,6 +552,12 @@ class HostedFlow extends ChangeNotifier {
++_generation;
final generation = _generation;
_target = null;
accountOnly = false;
communities = const [];
selectedRelays = const {};
_addedRelays.clear();
_addingCommunities = false;
addingCommunityName = null;
try {
await browser.cancel();
if (_disposed || generation != _generation) return;

View File

@@ -0,0 +1,106 @@
import 'package:buzz/features/onboarding/hosted_connect_page.dart';
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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/hosted_communities_fixture.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets(
'welcome discovery selection and empty-state handoff use the real flow',
(tester) async {
// Keep the process-wide secure-store queue in one widget-test zone.
{
FlutterSecureStorage.setMockInitialValues({});
PendingDeepLinkNotifier.debugUriStreamOverride =
const Stream<Uri>.empty();
final f = HostedCommunitiesFixture();
await f.initialize(widget: true);
f.rows = [f.row('one'), f.row('two')];
addTearDown(() async {
PendingDeepLinkNotifier.debugUriStreamOverride = null;
await f.dispose();
});
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: true,
child: const HostedAppGate(
authenticated: false,
child: OnboardingAppEntry(),
),
overrides: [hostedFlowProvider.overrideWithValue(f.flow)],
),
);
await tester.pumpAndSettle();
expect(find.text('Continue with Builderlab'), findsOneWidget);
expect(find.byType(HostedConnectPage), findsNothing);
await tester.tap(find.text('Continue with Builderlab'));
await tester.pumpAndSettle();
expect(find.text('Choose your communities'), findsOneWidget);
expect(
tester
.widgetList<CheckboxListTile>(find.byType(CheckboxListTile))
.every((tile) => tile.value == true),
isTrue,
);
await tester.tap(find.text('one'));
await tester.pumpAndSettle();
await tester.tap(find.text('Continue').hitTestable());
await tester.pumpAndSettle();
expect(f.admitted, ['two']);
expect(find.byType(HostedConnectPage), findsNothing);
}
await tester.pumpWidget(const SizedBox.shrink());
{
FlutterSecureStorage.setMockInitialValues({});
PendingDeepLinkNotifier.debugUriStreamOverride =
const Stream<Uri>.empty();
final f = HostedCommunitiesFixture();
await f.initialize(widget: true);
addTearDown(() async {
PendingDeepLinkNotifier.debugUriStreamOverride = null;
await f.dispose();
});
await f.flow.signInAccount();
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: true,
child: HostedConnectPage(flow: f.flow, onClose: f.flow.leave),
),
);
await tester.pumpAndSettle();
expect(find.text('Create a community in Builderlab'), findsOneWidget);
await tester.tap(find.text('Create a community in Builderlab'));
await tester.pumpAndSettle();
expect(f.opened?.path, '/buzz');
expect(find.text('Refresh communities'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'not a valid url');
await tester.tap(find.text('Open community link'));
await tester.pumpAndSettle();
expect(
find.text('Enter a valid community address or invite link.'),
findsOneWidget,
);
await tester.enterText(
find.byType(TextField),
'https://one.communities.buzz.xyz/invite/code',
);
await tester.tap(find.text('Open community link'));
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.idle);
final container = ProviderScope.containerOf(
tester.element(find.byType(HostedConnectPage)),
listen: false,
);
expect(container.read(pendingDeepLinkProvider), isA<InviteDeepLink>());
}
},
);
}

View File

@@ -0,0 +1,160 @@
import 'dart:convert';
import 'dart:async';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/hosted/browser_auth.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:buzz/shared/hosted/hosted_identity_service.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
class DiscoveryBrowser extends BrowserAuth {
final callbacks = StreamController<BrowserAuthCode>.broadcast();
@override
Stream<BrowserAuthCode> get codes => callbacks.stream;
@override
Future<void> dispose() async {
await callbacks.close();
await super.dispose();
}
@override
Future<void> initialize() async {}
@override
Future<Map<String, String>?> retainedIntent() async => null;
@override
Future<void> clearIntent() async {}
@override
Future<void> cancel() async {}
}
class WidgetDiscoveryAccount extends BuilderlabAccountClient {
WidgetDiscoveryAccount(this.fixture);
final HostedCommunitiesFixture fixture;
@override
BuilderlabAccount get account => BuilderlabAccount(
subject: 'owner',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
);
@override
Future<BuilderlabAccount?> restore() async => account;
@override
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body,
) async {
if (path.endsWith('/current')) {
return {
'identity': {'pubkey_hex': fixture.bound},
};
}
if (path.endsWith('/communities/list')) {
return {'communities': fixture.rows};
}
throw AccountFailure.unavailable;
}
}
class HostedCommunitiesFixture {
final keys = nostr.Keys('1'.padLeft(64, '0'));
final store = AccountIdentityStore();
final browser = DiscoveryBrowser();
late BuilderlabAccountClient account;
late final HostedFlow flow;
final admitted = <String>[];
final requests = <String>[];
List<Map<String, dynamic>> rows = [];
String? bound;
bool rejectList = false;
Future<void> Function(String)? beforeAdmission;
Future<void> Function()? beforeList;
Uri? opened;
Map<String, dynamic> row(String name, {bool archived = false}) => {
'id': name,
'name': name,
'normalized_host': '$name.communities.buzz.xyz',
if (archived) 'archived_at': '2026-10-01T00:00:00Z',
};
Future<void> initialize({bool backup = true, bool widget = false}) async {
bound = keys.public;
final expiry = DateTime.now()
.toUtc()
.add(const Duration(hours: 1))
.toIso8601String();
account = BuilderlabAccountClient(
httpClient: MockClient((request) async {
final path = request.url.path;
requests.add(path);
if (path.endsWith('/exchange')) {
return http.Response(
jsonEncode({'session_credential': 'fixture', 'expires_at': expiry}),
200,
);
}
if (path.endsWith('/me')) {
return http.Response(
jsonEncode({'subject': 'owner', 'expires_at': expiry}),
200,
);
}
if (path.endsWith('/current')) {
return http.Response(
jsonEncode({
'identity': {'pubkey_hex': bound},
}),
200,
);
}
if (path.endsWith('/communities/list')) {
await beforeList?.call();
if (rejectList) return http.Response('{}', 503);
return http.Response(jsonEncode({'communities': rows}), 200);
}
return http.Response('{}', 503);
}),
);
if (widget) {
account.dispose();
account = WidgetDiscoveryAccount(this);
} else {
await account.exchange(code: 'code', verifier: 'a' * 43);
}
await store.create(keys.nsec);
if (backup) {
await store.confirmBackup(
BackupReceipt(
backend: BuilderlabAccountClient.origin.toString(),
subject: 'owner',
pubkey: keys.public,
backupId: 'fixture-backup',
),
);
}
flow = HostedFlow(
account: account,
browser: browser,
store: store,
identity: HostedIdentityService(account: account, store: store),
openWeb: (uri) async {
opened = uri;
return true;
},
enterCommunity: (link, subject, pubkey, current) async {
final target = link as AddCommunityDeepLink;
await beforeAdmission?.call(target.name);
if (current()) admitted.add(target.name);
},
);
}
Future<void> dispose() async {
flow.dispose();
await browser.dispose();
account.dispose();
}
}

View File

@@ -0,0 +1,207 @@
import 'dart:async';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/browser_auth.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';
import '../../helpers/hosted_communities_fixture.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
Future<HostedCommunitiesFixture> fixture({bool backup = true}) async {
final result = HostedCommunitiesFixture();
await result.initialize(backup: backup);
addTearDown(result.dispose);
return result;
}
test(
'account browser callback continues into discovery after exchange',
() async {
final f = await fixture();
f.rows = [f.row('one'), f.row('two')];
await f.flow.initialize();
final selected = Completer<void>();
f.flow.addListener(() {
if (f.flow.stage == HostedFlowStage.chooseCommunities &&
!selected.isCompleted) {
selected.complete();
}
});
f.browser.callbacks.add(
BrowserAuthCode('returned-code', 'a' * 43, {'type': 'account'}),
);
await selected.future.timeout(const Duration(seconds: 2));
expect(f.flow.selectedRelays.length, 2);
expect(f.admitted, isEmpty);
},
);
test(
'passive session restore cannot discover or enter communities',
() async {
final f = await fixture();
f.rows = [f.row('one')];
await f.flow.signInAccount(openBrowser: false);
expect(f.flow.stage, HostedFlowStage.idle);
expect(f.requests.any((path) => path.endsWith('/current')), isFalse);
expect(f.admitted, isEmpty);
await f.flow.signInAccount();
expect(f.admitted, ['one']);
expect(f.flow.stage, HostedFlowStage.complete);
},
);
test(
'filters archived and duplicate hosts before direct single entry',
() async {
final f = await fixture();
f.rows = [f.row('archived', archived: true), f.row('one'), f.row('one')];
await f.flow.signInAccount();
expect(f.admitted, ['one']);
},
);
test(
'multiple communities default selected; only selected ones enter',
() async {
final f = await fixture();
f.rows = [f.row('one'), f.row('two')];
await f.flow.signInAccount();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(f.flow.selectedRelays.length, 2);
expect(f.admitted, isEmpty);
f.flow.selectCommunity('wss://one.communities.buzz.xyz', false);
await f.flow.addSelectedCommunities();
expect(f.admitted, ['two']);
},
);
test('empty selection does not complete or admit', () async {
final f = await fixture();
f.rows = [f.row('one'), f.row('two')];
await f.flow.signInAccount();
for (final relay in f.flow.selectedRelays.toList()) {
f.flow.selectCommunity(relay, false);
}
await f.flow.addSelectedCommunities();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(f.admitted, isEmpty);
});
test(
'partial failure preserves successes and retry only adds remaining',
() 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.unavailable;
};
await f.flow.signInAccount();
await f.flow.addSelectedCommunities();
expect(f.flow.stage, HostedFlowStage.blocked);
expect(f.admitted, ['one']);
expect(f.flow.addedCommunityCount, 1);
f.beforeAdmission = null;
await f.flow.retry();
expect(f.admitted, ['one', 'two', 'three']);
expect(f.flow.stage, HostedFlowStage.complete);
},
);
test('cancellation fences admission and stale selection', () async {
final f = await fixture();
f.rows = [f.row('one')];
final started = Completer<void>();
final release = Completer<void>();
f.beforeAdmission = (_) async {
started.complete();
await release.future;
};
final pending = f.flow.signInAccount();
await started.future;
await f.flow.leave();
release.complete();
await pending;
await f.flow.addSelectedCommunities();
expect(f.admitted, isEmpty);
expect(f.flow.stage, HostedFlowStage.idle);
});
test('cancelled list cannot display or auto-add its late response', () async {
final f = await fixture();
f.rows = [f.row('one')];
final started = Completer<void>();
final release = Completer<void>();
f.beforeList = () async {
started.complete();
await release.future;
};
final pending = f.flow.signInAccount();
await started.future;
await f.flow.leave();
release.complete();
await pending;
expect(f.admitted, isEmpty);
expect(f.flow.stage, HostedFlowStage.idle);
});
test(
'zero communities can open creation and refresh into single entry',
() async {
final f = await fixture();
await f.flow.signInAccount();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
await f.flow.openCommunityCreation();
expect(f.opened, Uri.parse('https://app.builderlab.xyz/buzz'));
f.rows = [f.row('created')];
await f.flow.refreshCommunities();
expect(f.admitted, ['created']);
},
);
test('identity mismatch blocks list and preserves saved key', () async {
final f = await fixture();
f.bound = '2' * 64;
await f.flow.signInAccount();
expect(f.flow.problem, HostedFlowProblem.identityMismatch);
expect(
f.requests.any((path) => path.endsWith('/communities/list')),
isFalse,
);
expect((await f.store.read())?.pubkey, f.keys.public);
expect(f.admitted, isEmpty);
});
test('missing backup cannot unlock discovery or admission', () async {
final f = await fixture(backup: false);
await f.flow.signInAccount();
expect(f.flow.stage, HostedFlowStage.blocked);
expect(
f.requests.any((path) => path.endsWith('/communities/list')),
isFalse,
);
expect(f.admitted, isEmpty);
});
test(
'untrusted list targets and failed discovery never look like empty lists',
() async {
final f = await fixture();
f.rows = [
{...f.row('one'), 'normalized_host': 'evil.example'},
];
await f.flow.signInAccount();
expect(f.flow.problem, HostedFlowProblem.invalidResponse);
expect(f.admitted, isEmpty);
f.rejectList = true;
await f.flow.signInAccount();
expect(f.flow.stage, HostedFlowStage.blocked);
expect(f.flow.problem, HostedFlowProblem.retry);
},
);
}