Files
buzz/mobile/test/features/pairing/pairing_socket_test.dart
Daniel Rouleau 14037e3278 fix(mobile): show progress after pairing QR scans (#9009)
## Summary

After a pairing QR scan, the app shows connection progress until code
entry or a connection error. A WebSocket handshake that remains pending
stops after ten seconds. Cancel is available until storage starts. It
resets pairing and restores the entry form. Pasted codes remain
available after an error or cancellation. Back navigation stays blocked
during identity transfer and storage. Cancel on Send identity to desktop
returns to Settings, so another attempt starts through the authorization
step.

### Related issue

Related to [#9001](https://github.com/block/buzz/pull/9001), which
covers organization sign-in after identity transfer. This change covers
the earlier connection stage.

### Testing

A simulator build with PR #9001 completes identity transfer and saves
the community. The community chooser still blocks organization sign-in.
[PR #9028](https://github.com/block/buzz/pull/9028) covers that step.
Physical iPhone testing and successful real-relay checks in light and
dark mode remain pending. On a build with this change, scan a desktop
pairing QR code. Check that progress appears, Cancel returns to the
entry form, and a fresh attempt reaches code entry. Paste a pairing
code, cancel during connection, and check that the form keeps the code.
Test a failed connection and retry. In Settings, open Send identity to
desktop, start a recovery connection, and cancel. Check that Settings
appears and another attempt asks for authorization. Camera recognition
speed and the reported intermittent phone network failure remain
unverified.

Generated with Codex

---------

Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
Co-authored-by: Sol <c35e7cc95f6fdbfb7e861dccc02bdca567b6d6b20f16b5115c3c6ae082bbeede@buzz.block.builderlab.xyz>
2026-11-03 19:12:57 -08:00

333 lines
10 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:buzz/features/pairing/pairing_socket.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
const _privateKey =
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506';
void main() {
group('PairingSocket', () {
for (final ready in [false, true]) {
test('dispose settles connection while channel ready=$ready', () async {
final readiness = Completer<void>();
final channel = _ControlledWebSocketChannel(
readiness: readiness.future,
);
var disconnected = 0;
final socket = _socket(
'ws://unused',
channelFactory: (_) => channel,
onDisconnected: (_) => disconnected++,
);
final connecting = socket.connect();
final cancelled = expectLater(
connecting,
throwsA(isA<PairingAuthException>()),
);
if (ready) {
readiness.complete();
await Future<void>.delayed(Duration.zero);
channel.emit(jsonEncode(['AUTH', 'pending-auth']));
await Future<void>.delayed(Duration.zero);
}
socket.dispose();
await cancelled.timeout(const Duration(seconds: 1));
if (!ready) readiness.complete();
await Future<void>.delayed(Duration.zero);
expect(socket.isConnected, isFalse);
expect(disconnected, 0);
});
}
testWidgets('a stalled upgrade stops within ten seconds', (tester) async {
final readiness = Completer<void>();
final channel = _ControlledWebSocketChannel(readiness: readiness.future);
final socket = _socket('ws://unused', channelFactory: (_) => channel);
Object? failure;
final connection = socket.connect().then<void>(
(_) {},
onError: (Object error) {
failure = error;
},
);
try {
await tester.pump(const Duration(seconds: 11));
expect(failure, isA<TimeoutException>());
expect(socket.isConnected, isFalse);
} finally {
socket.dispose();
readiness.complete();
await connection;
}
});
test('connects when the pairing relay sends no AUTH challenge', () async {
final server = await _TestRelay.start((_) {});
addTearDown(server.close);
final socket = _socket(
server.url,
authChallengeTimeout: const Duration(milliseconds: 30),
);
addTearDown(socket.disconnect);
await socket.connect();
expect(socket.isConnected, isTrue);
});
test('answers an AUTH challenge and requires an accepted OK', () async {
final authReceived = Completer<List<dynamic>>();
final server = await _TestRelay.start((webSocket) async {
webSocket.add(jsonEncode(['AUTH', 'challenge']));
final auth =
jsonDecode(await webSocket.first as String) as List<dynamic>;
authReceived.complete(auth);
final event = auth[1] as Map<String, dynamic>;
webSocket.add(jsonEncode(['OK', event['id'], true, 'authenticated']));
});
addTearDown(server.close);
final socket = _socket(server.url);
addTearDown(socket.disconnect);
await socket.connect();
expect(socket.isConnected, isTrue);
expect((await authReceived.future).first, 'AUTH');
});
test('fails when the pairing relay rejects AUTH', () async {
final server = await _TestRelay.start((webSocket) async {
webSocket.add(jsonEncode(['AUTH', 'challenge']));
final auth =
jsonDecode(await webSocket.first as String) as List<dynamic>;
final event = auth[1] as Map<String, dynamic>;
webSocket.add(jsonEncode(['OK', event['id'], false, 'bad auth']));
});
addTearDown(server.close);
var disconnectCount = 0;
final socket = _socket(
server.url,
onDisconnected: (_) => disconnectCount++,
);
addTearDown(socket.disconnect);
await expectLater(socket.connect(), throwsA(isA<PairingAuthException>()));
expect(socket.isConnected, isFalse);
expect(disconnectCount, 1);
});
test(
'answers a challenge after the optional AUTH wait completes',
() async {
final authReceived = Completer<void>();
final server = await _TestRelay.start((webSocket) async {
await Future<void>.delayed(const Duration(milliseconds: 80));
webSocket.add(jsonEncode(['AUTH', 'late-challenge']));
final auth =
jsonDecode(await webSocket.first as String) as List<dynamic>;
final event = auth[1] as Map<String, dynamic>;
webSocket.add(jsonEncode(['OK', event['id'], true, 'authenticated']));
authReceived.complete();
});
addTearDown(server.close);
final socket = _socket(
server.url,
authChallengeTimeout: const Duration(milliseconds: 30),
);
addTearDown(socket.disconnect);
await socket.connect();
await authReceived.future;
expect(socket.isConnected, isTrue);
},
);
test('fails when AUTH receives no OK response', () async {
final server = await _TestRelay.start((webSocket) {
webSocket.add(jsonEncode(['AUTH', 'challenge']));
});
addTearDown(server.close);
final socket = _socket(
server.url,
authResponseTimeout: const Duration(milliseconds: 100),
);
addTearDown(socket.disconnect);
await expectLater(socket.connect(), throwsA(isA<PairingAuthException>()));
expect(socket.isConnected, isFalse);
});
test('notifies once when a connected stream emits an error', () async {
var disconnectCount = 0;
final channel = _ControlledWebSocketChannel();
final socket = _socket(
'ws://unused',
onDisconnected: (_) => disconnectCount++,
channelFactory: (_) => channel,
authChallengeTimeout: Duration.zero,
);
addTearDown(socket.disconnect);
await socket.connect();
channel.emitError(Exception('stream failed'));
await Future<void>.delayed(Duration.zero);
expect(disconnectCount, 1);
});
test('notifies once when a connected stream closes', () async {
var disconnectCount = 0;
final channel = _ControlledWebSocketChannel();
final socket = _socket(
'ws://unused',
onDisconnected: (_) => disconnectCount++,
channelFactory: (_) => channel,
authChallengeTimeout: Duration.zero,
);
addTearDown(socket.disconnect);
await socket.connect();
await channel.closeStream();
expect(disconnectCount, 1);
});
test(
'does not notify when deliberately disconnected or disposed',
() async {
var disconnectCount = 0;
final disconnectChannel = _ControlledWebSocketChannel();
final disconnectingSocket = _socket(
'ws://unused',
onDisconnected: (_) => disconnectCount++,
channelFactory: (_) => disconnectChannel,
authChallengeTimeout: Duration.zero,
);
await disconnectingSocket.connect();
await disconnectingSocket.disconnect();
final disposeChannel = _ControlledWebSocketChannel();
final disposingSocket = _socket(
'ws://unused',
onDisconnected: (_) => disconnectCount++,
channelFactory: (_) => disposeChannel,
authChallengeTimeout: Duration.zero,
);
await disposingSocket.connect();
disposingSocket.dispose();
await Future<void>.delayed(Duration.zero);
expect(disconnectCount, 0);
},
);
});
}
PairingSocket _socket(
String url, {
Duration authChallengeTimeout = const Duration(milliseconds: 500),
Duration authResponseTimeout = const Duration(seconds: 10),
void Function(Object? error)? onDisconnected,
WebSocketChannel Function(Uri uri)? channelFactory,
}) => PairingSocket(
wsUrl: url,
ephemeralPrivkey: _privateKey,
onMessage: (_) {},
onDisconnected: onDisconnected ?? (_) {},
authChallengeTimeout: authChallengeTimeout,
authResponseTimeout: authResponseTimeout,
channelFactory: channelFactory ?? WebSocketChannel.connect,
);
class _ControlledWebSocketChannel implements WebSocketChannel {
_ControlledWebSocketChannel({Future<void>? readiness})
: _readiness = readiness ?? Future.value();
final Future<void> _readiness;
void emit(String message) => _streamController.add(message);
final StreamController<dynamic> _streamController = StreamController();
final WebSocketSink _sink = _ControlledWebSocketSink();
void emitError(Object error) => _streamController.addError(error);
Future<void> closeStream() => _streamController.close();
@override
Future<void> get ready => _readiness;
@override
Stream<dynamic> get stream => _streamController.stream;
@override
WebSocketSink get sink => _sink;
@override
int? get closeCode => null;
@override
String? get closeReason => null;
@override
String? get protocol => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ControlledWebSocketSink implements WebSocketSink {
@override
void add(dynamic event) {}
@override
void addError(Object error, [StackTrace? stackTrace]) {}
@override
Future<void> addStream(Stream<dynamic> stream) async {
await stream.drain<void>();
}
@override
Future<void> close([int? closeCode, String? closeReason]) async {}
@override
Future<void> get done => Future.value();
}
class _TestRelay {
final HttpServer _server;
final List<WebSocket> _sockets = [];
_TestRelay._(this._server);
String get url => 'ws://${_server.address.host}:${_server.port}';
static Future<_TestRelay> start(
FutureOr<void> Function(WebSocket socket) onConnected,
) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final relay = _TestRelay._(server);
server.listen((request) async {
final socket = await WebSocketTransformer.upgrade(request);
relay._sockets.add(socket);
await onConnected(socket);
});
return relay;
}
Future<void> close() async {
for (final socket in _sockets) {
await socket.close();
}
await _server.close(force: true);
}
}