fix(mobile): retain invite keys and reject incomplete onboarding (#8753)

Self-hosted invite claims retain the generated identity in secure
storage before the HTTP request and reuse it after a lost response or
restart. Saving or reopening an existing destination retires the
matching journal. Sign-out/removal deletes all matching pending key
copies before removing the saved community, preserving the community for
retry if cleanup fails.

Pairing verifies that relay authentication completed before
saving/activating a destination or reporting success. On iOS 16, hosted
entry permits recovery and reuse of backed-up identities, including
binding a recovered identity with a confirmed backup receipt. Paths
requiring a new secure backup explain the iOS 17 requirement before
generating or binding a new key. Self-hosted entry remains available.

### Validation

- Initial focused entry/recovery files: 83 passed, 1 skipped. Review-fix
focused storage, community, invite and hosted files: 82 passed, 1
skipped; extended invite journey: 17 passed, 1 skipped.
- Final hosted journey extension: 20 passed, including both bound and
unbound recovery with native sealing unavailable.
- Isolated mutations removing invite retention, pairing validation,
journal removal/re-entry cleanup or restoring the blanket platform gate
fail their intended regressions. Restored controls pass.
- iOS simulator build passed on the original integration base; final
native/device acceptance remains outstanding.
- Carl and three specialist reviewers found no source-review blockers at
`323cec6ea2`. `a55a246857` adds analyzer fixes, copy cleanup and the
recovered-unbound journey coverage. Reviewers did not run tests or the
app.

- Fixed a push-hook timeout in `4564b3562a`: idle serial queues now
release completed futures, avoiding scheduler retention across widget
tests while preserving in-flight ordering and failure isolation. The
existing dispatcher journey fails promptly before the fix and passes
afterward; Carl reviewed this delta with no blockers. Focused
dispatcher/invite/storage checks passed, and all 20 hosted journeys
passed with hosted auth enabled.

- Mandatory pre-push checks passed at `4564b3562a` (scope, branch skew,
file size, mobile analysis/tests and crypto package checks); GitHub head
verified at that commit.

### Remaining acceptance

Draft pending agent/device and human acceptance. Exercise interrupted
one-use invite claim and retry across restart, sign-out then reopen
without restoring the removed identity, rejected self-hosted pairing,
iOS 16 backed-up identity recovery (including binding), and iOS 16 new
setup blocked before key creation. Synthetic network/storage boundaries
do not prove native durability or live server behavior. Hosted CI
results are not claimed here.

Optional follow-ups remain outside this patch: pruning provably
unadmitted journals, malformed-record recovery, avoiding an iOS 16
dead-end device-key selection, and pre-existing removal/push-revocation
ordering.

This PR contains three existing-path fixes. The two organization-login
fixes depend on #8700 and remain separate: its stale parent conflicts
with main in unrelated relay code, and the required branch-skew hook
prevents publishing that stack unchanged. This PR does not refresh that
parent or change deployment policy.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-10-30 09:38:24 -07:00
committed by GitHub
parent 5d70622ac0
commit 75854111b8
14 changed files with 440 additions and 73 deletions

View File

@@ -90,6 +90,10 @@ import os.log
browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger)
hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger)
hostedHpkeChannel?.setMethodCallHandler { call, result in
if call.method == "isSupported" {
if #available(iOS 17.0, *) { result(true) } else { result(false) }
return
}
guard call.method == "seal" else { result(FlutterMethodNotImplemented); return }
guard #available(iOS 17.0, *) else {
result(FlutterError(code: "unsupported", message: "Backup requires iOS 17 or later", details: nil)); return

View File

@@ -125,6 +125,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
final communities = await ref.read(communityListProvider.future);
final existing = _existingCommunity(communities, invite.relayUrl);
if (existing != null) {
await _retirePendingInvite(invite, existing);
await ref
.read(communityListProvider.notifier)
.switchCommunity(existing.id);
@@ -177,6 +178,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
final communities = await ref.read(communityListProvider.future);
final existing = _existingCommunity(communities, invite.relayUrl);
if (existing != null) {
await _retirePendingInvite(invite, existing);
await ref
.read(communityListProvider.notifier)
.switchCommunity(existing.id);
@@ -202,7 +204,17 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
return;
}
final keys = ref.read(inviteKeyGeneratorProvider)();
final storage = ref.read(communityStorageProvider);
final nsec = await storage.pendingInviteIdentity(
invite.relayUrl,
invite.code,
() => ref.read(inviteKeyGeneratorProvider)().nsec,
);
final decodedKey = nostr.Nip19.decode(payload: nsec);
if (decodedKey.prefix != nostr.Nip19Prefix.nsec) {
throw const FormatException('Pending invite identity is unreadable');
}
final keys = nostr.Keys(decodedKey.data);
final body = jsonEncode({
'code': invite.code,
if (invite.policyReceipt != null)
@@ -250,6 +262,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
await ref
.read(authProvider.notifier)
.authenticateWithCommunity(community);
await _retirePendingInvite(invite, community);
_pendingStarterSetupCommunity = community;
state = state.copyWith(isStarterSetupRecovery: true);
await startStarterSetupRecovery();
@@ -263,6 +276,20 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
}
}
Future<void> _retirePendingInvite(
InviteDeepLink invite,
Community community,
) async {
try {
await ref
.read(communityStorageProvider)
.completePendingInvite(invite.relayUrl, invite.code, community);
} catch (_) {
// The saved destination is authoritative. Reopening retries cleanup;
// community removal must erase every journal copy before it succeeds.
}
}
/// Runs the pending recovery after its progress UI has been presented.
Future<void> startStarterSetupRecovery() async {
final community = _pendingStarterSetupCommunity;

View File

@@ -945,6 +945,9 @@ class PairingNotifier extends Notifier<PairingState> {
_validationSocket = socket;
try {
await socket.connect().timeout(const Duration(seconds: 8));
if (socket.state != SocketState.connected) {
throw StateError('Relay did not authenticate the paired identity');
}
} finally {
if (identical(_validationSocket, socket)) _validationSocket = null;
await socket.disconnect();

View File

@@ -1,8 +1,10 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../storage/secure_storage.dart';
import '../storage/serial_queue.dart';
import 'community.dart';
@@ -71,6 +73,55 @@ class CommunityStorage {
return [];
}
static final _pendingInvites = SerialQueue();
static const _pendingInvitePrefix = 'buzz_pending_invite_v1_';
static String _pendingInviteKey(String relayUrl, String code) {
final uri = Uri.parse(relayUrl);
final origin = uri
.replace(
scheme: ['https', 'wss'].contains(uri.scheme) ? 'https' : 'http',
)
.origin;
return '$_pendingInvitePrefix${sha256.convert(utf8.encode(jsonEncode([origin, code])))}';
}
/// Retain the claim identity before sending a request with uncertain outcomes.
/// A failed read/write must never generate a replacement for an unknown key.
Future<String> pendingInviteIdentity(
String relayUrl,
String code,
String Function() generate,
) => _pendingInvites.run(() async {
final key = _pendingInviteKey(relayUrl, code);
final saved = await _secure.read(key: key);
if (saved != null) return saved;
final nsec = generate();
await _secure.write(key: key, value: nsec);
if (await _secure.read(key: key) != nsec) {
throw StateError('Could not retain the pending invite identity');
}
return nsec;
});
/// Retire the journal only after the destination identity is durably saved.
Future<void> completePendingInvite(
String relayUrl,
String code,
Community community,
) => _pendingInvites.run(() async {
final saved = await loadAll();
if (!saved.any(
(item) => item.id == community.id && item.nsec == community.nsec,
)) {
return;
}
final key = _pendingInviteKey(relayUrl, code);
if (await _secure.read(key: key) == community.nsec) {
await _secure.delete(key: key);
}
});
Future<void> save(Community community) async {
final all = await loadAll();
final index = all.indexWhere((w) => w.id == community.id);
@@ -85,11 +136,30 @@ class CommunityStorage {
/// Replaces the complete stored community list in one secure-storage write.
Future<void> saveAll(List<Community> communities) => _saveList(communities);
Future<void> remove(String id) async {
Future<void> remove(String id) => _pendingInvites.run(() async {
final all = await loadAll();
final removedKeys = all
.where((item) => item.id == id)
.map((item) => item.nsec)
.whereType<String>()
.toSet();
if (removedKeys.isNotEmpty) {
// Retire every journal copy before removing the saved community. If a
// deletion fails, keep that community so sign-out can safely retry.
final records = await _secure.readAll();
for (final entry in records.entries) {
if (entry.key.startsWith(_pendingInvitePrefix) &&
removedKeys.contains(entry.value)) {
await _secure.delete(key: entry.key);
if (await _secure.read(key: entry.key) != null) {
throw StateError('Could not remove the pending invite identity');
}
}
}
}
all.removeWhere((w) => w.id == id);
await _saveList(all);
}
});
Future<String?> loadActiveId() async {
return _secure.read(key: _keyActiveId);

View File

@@ -42,6 +42,7 @@ enum HostedFlowProblem {
differentAccount,
identityUnreadable,
backupUnavailable,
unsupportedPlatform,
invalidResponse,
requestRejected,
identityMismatch,
@@ -508,10 +509,13 @@ class HostedFlow extends ChangeNotifier {
_clearCommunityBatch();
_batchSubject = session.subject;
}
final canSealBackup =
target is NostrBindDeepLink || await HpkeBackup.supportsSealing();
if (_disposed || generation != _generation) return;
var resolved = await identity.resolve(
isCurrent: _isCurrent(generation),
selectedPubkey: selectedPubkey ?? _pairedPubkey,
createNew: createNew,
createNew: createNew && canSealBackup,
);
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
_set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch);
@@ -520,6 +524,26 @@ class HostedFlow extends ChangeNotifier {
if (target is! NostrBindDeepLink) {
for (var step = 0; step < 2; step++) {
if (_disposed || generation != _generation) return;
if (!canSealBackup) {
final saved = await store.read();
if (_disposed || generation != _generation) return;
final needsNewBackup =
resolved.state == HostedIdentityState.needsSetup ||
resolved.state == HostedIdentityState.needsBackup ||
(resolved.state == HostedIdentityState.needsBinding &&
!(saved?.hasBackupFor(
backend: HostedEnvironment.origin.origin,
subject: session.subject,
) ??
false));
if (needsNewBackup) {
_set(
HostedFlowStage.blocked,
HostedFlowProblem.unsupportedPlatform,
);
return;
}
}
if (resolved.state == HostedIdentityState.needsBinding &&
resolved.pubkey != null) {
final response = await account.post(

View File

@@ -26,6 +26,8 @@ String hostedFlowProblemMessage(
'Your browser signed in to a different account. Sign in to your original account on the web, then reopen setup. Your saved account and keys have been kept.',
HostedFlowProblem.identityUnreadable =>
'Your saved identity could not be read safely. Leave setup and recover access to this device’s secure storage before continuing.',
HostedFlowProblem.unsupportedPlatform =>
'Creating a secure backup requires iOS 17 or later. Update iOS to continue. You can still use self-hosted communities.',
HostedFlowProblem.backupUnavailable =>
'Secure backup is not supported on this device yet. Your identity has been kept; joining requires a confirmed backup.',
HostedFlowProblem.requestRejected =>

View File

@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:buzz/shared/crypto/event_signing.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
/// Never include input, plaintext, or native exception details in this error.
enum HpkeBackupFailure { invalidEnrollment, identityMismatch, unavailable }
@@ -14,6 +15,12 @@ enum HpkeBackupFailure { invalidEnrollment, identityMismatch, unavailable }
class HpkeBackup {
static const _channel = MethodChannel('buzz/hosted_hpke');
/// Existing backups can be recovered on iOS 16, but new ones require iOS 17.
static Future<bool> supportsSealing() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return true;
return await _channel.invokeMethod<bool>('isSupported') == true;
}
Future<Map<String, dynamic>> seal({
required Map<String, dynamic> enrollment,
required String secretHex,

View File

@@ -1,10 +1,21 @@
/// Serializes operations without letting a failed operation poison later work.
class SerialQueue {
Future<void> _tail = Future<void>.value();
Future<void>? _tail;
Future<T> run<T>(Future<T> Function() operation) {
final result = _tail.then((_) => operation());
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
final result = (_tail ?? Future<void>.value()).then((_) => operation());
late final Future<void> tail;
void release() {
// An idle queue must not retain a completed future's scheduler/zone.
// A later operation may already have replaced this tail.
if (identical(_tail, tail)) _tail = null;
}
tail = result.then<void>(
(_) => release(),
onError: (Object _, StackTrace _) => release(),
);
_tail = tail;
return result;
}
}

View File

@@ -827,7 +827,7 @@ void main() {
);
await tester.pump();
await tester.pump();
await recovery.started.future;
expect(recovery.started.isCompleted, isTrue);
expect(find.text('Finish setting up'), findsOneWidget);
expect(find.text('Finishing setup…'), findsOneWidget);
@@ -893,7 +893,7 @@ void main() {
);
await tester.pump();
await tester.pump();
await recovery.started.future;
expect(recovery.started.isCompleted, isTrue);
recovery.completeError(Exception('relay did not respond'));
await tester.pumpAndSettle();

View File

@@ -676,65 +676,167 @@ void main() {
});
}
test('failed claim can be retried and preserves policy receipt', () async {
final keys = nostr.Keys.generate();
var attempts = 0;
final bodies = <String>[];
final storage = CommunityStorage(secure: FakeSecureStorage());
final auth = _RecordingAuthNotifier();
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
authProvider.overrideWith(() => auth),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
attempts++;
bodies.add(request.body);
if (attempts == 1) {
return http.Response(jsonEncode({'error': 'temporary'}), 503);
}
return http.Response(
jsonEncode({
'status': 'joined',
'host': 'relay.example.com',
'role': 'member',
}),
200,
);
}),
),
],
);
addTearDown(container.dispose);
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
policyReceipt: 'receipt.value',
test(
'committed claim with lost response retries the same key after restart',
() async {
var attempts = 0;
String? claimedPubkey;
String? lastClaimPubkey;
final bodies = <String>[];
final secure = FakeSecureStorage();
final storage = CommunityStorage(secure: secure);
final auth = _RecordingAuthNotifier();
ProviderContainer open() => ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((_) async {}),
communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(
(_) async => false,
),
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
authProvider.overrideWith(() => auth),
// Keep the production fresh-key generator: a fixed fake masks key loss.
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
attempts++;
bodies.add(request.body);
final event =
jsonDecode(
utf8.decode(
base64Decode(
request.headers['Authorization']!.substring(
'Nostr '.length,
),
),
),
)
as Map;
final pubkey = event['pubkey'] as String;
lastClaimPubkey = pubkey;
if (attempts == 1) {
claimedPubkey = pubkey;
throw http.ClientException('Response lost after commit');
}
if (pubkey != claimedPubkey) {
return http.Response(
jsonEncode({'error': 'invite exhausted'}),
403,
);
}
return http.Response(
jsonEncode({
'status': 'already_member',
'host': 'relay.example.com',
'role': 'member',
}),
200,
);
}),
),
],
);
const invite = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
policyReceipt: 'receipt.value',
);
var container = open();
await container.read(inviteJoinProvider.notifier).prepare(invite);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
expect(await storage.loadAll(), isEmpty);
container.dispose();
container = open();
addTearDown(container.dispose);
await container.read(inviteJoinProvider.notifier).prepare(invite);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(
container.read(inviteJoinProvider).status,
InviteJoinStatus.success,
);
expect(attempts, 2);
expect(
bodies,
everyElement(
jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}),
),
);
expect((await storage.loadAll()).single.pubkey, claimedPubkey);
expect(
(await secure.readAll()).keys.where(
(key) => key.startsWith('buzz_pending_invite_'),
),
isEmpty,
);
final saved = (await storage.loadAll()).single;
// A crash after saving the community can leave the journal behind.
await storage.pendingInviteIdentity(
invite.relayUrl,
invite.code,
() => saved.nsec!,
);
await container.read(inviteJoinProvider.notifier).prepare(invite);
expect(
(await secure.readAll()).values.where((value) => value == saved.nsec),
isEmpty,
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
// Also cover a destination saved after the confirmation page was opened.
await storage.saveAll([]);
container.invalidate(communityListProvider);
await container.read(inviteJoinProvider.notifier).prepare(invite);
await storage.saveAll([saved]);
container.invalidate(communityListProvider);
await storage.pendingInviteIdentity(
invite.relayUrl,
invite.code,
() => saved.nsec!,
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(
(await secure.readAll()).values.where((value) => value == saved.nsec),
isEmpty,
);
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.success);
expect(attempts, 2);
expect(
bodies,
everyElement(
jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}),
),
);
expect(auth.authenticatedCommunities, hasLength(1));
});
// Any matching journal, including a different invite, must be retired on
// sign-out. Unrelated pending identities must survive.
await storage.pendingInviteIdentity(
invite.relayUrl,
invite.code,
() => saved.nsec!,
);
await storage.pendingInviteIdentity(
invite.relayUrl,
'another-code',
() => saved.nsec!,
);
final unrelated = nostr.Keys.generate().nsec;
await storage.pendingInviteIdentity(
'wss://other.example',
'other-code',
() => unrelated,
);
secure.failPendingInviteDeletes = true;
await expectLater(
container.read(authProvider.notifier).signOut(),
throwsStateError,
);
expect((await storage.loadAll()).single.nsec, saved.nsec);
secure.failPendingInviteDeletes = false;
await container.read(authProvider.notifier).signOut();
expect(await storage.loadAll(), isEmpty);
expect((await secure.readAll()).values, isNot(contains(saved.nsec)));
expect((await secure.readAll()).values, contains(unrelated));
await container.read(inviteJoinProvider.notifier).prepare(invite);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(attempts, 3);
expect(lastClaimPubkey, isNot(claimedPubkey));
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
expect(await storage.loadAll(), isEmpty);
},
);
test('builds a fresh recovery with the second community identity', () async {
final firstKeys = nostr.Keys.generate();

View File

@@ -24,6 +24,8 @@ import 'package:buzz/shared/relay/relay_provider.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:buzz/shared/utils/string_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
@@ -68,6 +70,30 @@ void main() {
f.bound = null;
f.offerProfile = false;
await f.mount(tester);
// iOS 16 stops before any identity generation, binding or admission.
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('buzz/hosted_hpke'),
(_) async => false,
);
try {
await f.flow.signInAccount();
expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform);
expect(await f.store.read(), isNull);
expect(f.bound, isNull);
expect(f.probes, isEmpty);
} finally {
debugDefaultTargetPlatformOverride = null;
}
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('buzz/hosted_hpke'),
(call) async => call.method == 'isSupported'
? true
: {
'enc': Uint8List.fromList([4, ...List.filled(64, 2)]),
'ciphertext': Uint8List(48),
},
);
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
@@ -753,13 +779,36 @@ void main() {
'new device recovers and preserves existing/unreadable profile ($unreadableProfile)',
(tester, f) async {
await f.initialize(localIdentity: false);
f.cloudKeys.addAll([f.secondKeys, f.keys]);
// Exercise both an already-bound recovery and recovery that must bind
// its confirmed backup, without requiring native sealing in either case.
if (unreadableProfile) {
f.bound = null;
f.cloudKeys.add(f.keys);
} else {
f.cloudKeys.addAll([f.secondKeys, f.keys]);
}
f.rows.addAll([f.row('one'), f.row('two')]);
f.offerProfile = false;
f.profileUnavailable = unreadableProfile;
await f.mount(tester);
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('buzz/hosted_hpke'),
(call) async {
expect(
call.method,
'isSupported',
reason: 'Recovery must not seal a new backup',
);
return false;
},
);
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await f.flow.signInAccount();
} finally {
debugDefaultTargetPlatformOverride = null;
}
await f.settle(tester);
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(f.probes, isEmpty);
@@ -784,7 +833,9 @@ void main() {
expect(saved.pubkey, f.keys.public);
expect((await f.store.read())!.pubkey, f.keys.public);
expect(f.sent('/recovery'), hasLength(1));
expect(f.sent('/challenge'), isEmpty);
expect(f.sent('/challenge'), hasLength(unreadableProfile ? 1 : 0));
expect(f.sent('/nsec-backups'), isEmpty);
expect(f.sent('/enrollment'), isEmpty);
expect(await f.storage.loadActiveId(), saved.id);
expect(find.byType(OnboardingAppEntry), findsNothing);
},

View File

@@ -73,6 +73,45 @@ void main() {
},
);
test(
'legacy validation rejects failed connection before saving, then retries',
() async {
final auth = FakeAuthNotifier();
var connected = false;
final notifier = PairingNotifier(
validationSocketFactory:
({
required wsUrl,
required nsec,
required onMessage,
required onConnected,
required onDisconnected,
}) => _ValidationOutcomeSocket(() => connected),
);
container = ProviderContainer(
overrides: [
pairingProvider.overrideWith(() => notifier),
authProvider.overrideWith(() => auth),
],
);
final input = base64Url.encode(
utf8.encode(
jsonEncode({
'relayUrl': 'https://relay.example',
'nsec': nostr.Keys.generate().nsec,
}),
),
);
await container.read(pairingProvider.notifier).pair(input);
expect(container.read(pairingProvider).status, PairingStatus.error);
expect(auth.lastCommunity, isNull);
connected = true;
await notifier.pair(input);
expect(container.read(pairingProvider).status, PairingStatus.success);
expect(auth.lastCommunity, isNotNull);
},
);
test('starts in idle state', () {
container = createContainer();
final state = container.read(pairingProvider);
@@ -1475,3 +1514,22 @@ class _PendingValidationSocket extends RelaySocket {
@override
Future<void> disconnect() async => disposed = true;
}
class _ValidationOutcomeSocket extends RelaySocket {
_ValidationOutcomeSocket(this.connected)
: super(
wsUrl: 'wss://relay.example',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) {},
);
final bool Function() connected;
@override
SocketState get state =>
connected() ? SocketState.connected : SocketState.disconnected;
@override
Future<void> connect() async {}
@override
Future<void> disconnect() async {}
}

View File

@@ -323,10 +323,12 @@ class HostedJourneyFixture {
await tester.binding.setSurfaceSize(const Size(440, 1100));
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('buzz/hosted_hpke'),
(_) async => {
'enc': Uint8List.fromList([4, ...List.filled(64, 2)]),
'ciphertext': Uint8List(48),
},
(call) async => call.method == 'isSupported'
? true
: {
'enc': Uint8List.fromList([4, ...List.filled(64, 2)]),
'ciphertext': Uint8List(48),
},
);
await tester.pumpWidget(
WidgetHelpers.testable(

View File

@@ -12,6 +12,7 @@ import 'package:buzz/shared/push/push_subscription.dart';
class FakeSecureStorage extends Fake implements FlutterSecureStorage {
final Map<String, String> _data = {};
final Map<String, int> _writeCounts = {};
bool failPendingInviteDeletes = false;
@override
Future<String?> read({
@@ -52,7 +53,12 @@ class FakeSecureStorage extends Fake implements FlutterSecureStorage {
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data.remove(key);
}) async {
if (failPendingInviteDeletes && key.startsWith('buzz_pending_invite_')) {
throw StateError('Secure storage deletion unavailable');
}
_data.remove(key);
}
@override
Future<Map<String, String>> readAll({