mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## Summary Enter a desktop-only, random six-digit code on mobile to pair without a desktop confirmation click. Verification uses the encrypted session, limits guesses to five, and never treats the QR-derived transcript as approval. Older clients retain desktop confirmation. Adds digit-only error shakes and native haptics, a separate biometrics choice, and a direct community-icon arrival animation. Fixes dropped early offers and resets completed pairing so reopening or signing out cannot get stuck. ### Related issue Related: #8060; no duplicate code-entry PR found. <img width="780" height="1688" alt="01-enter-code" src="https://github.com/user-attachments/assets/a99ced3e-4305-4ac6-a6cc-d598ced423e7" /> <img width="780" height="1688" alt="02-incorrect-code" src="https://github.com/user-attachments/assets/19cdb109-1f10-40a9-a661-1d7ba89c81c1" /> <img width="780" height="1688" alt="03-biometrics" src="https://github.com/user-attachments/assets/af1b2924-9ab6-4d1a-b4b1-fccfdf446404" /> <img width="780" height="1688" alt="04-community-loading" src="https://github.com/user-attachments/assets/84145d82-843a-4c25-aef7-6492fc241d4b" /> <img width="897" height="388" alt="05-desktop-code" src="https://github.com/user-attachments/assets/d8a2e469-7e42-466a-988f-bab6b1df85fc" /> ### Release order Deploy the updated pairing relay first, then desktop, then mobile. The relay now allows eight events per connection (seven for a successful fifth guess plus room for cancellation), with a corresponding bounded delivery budget. Existing clients remain supported; older client combinations keep desktop confirmation. ### Testing - 103 mobile pairing tests, 270 core tests, 51 relay integration tests, and the new real-relay fifth-guess flow passed. - The real-relay regression verifies proof, decrypted identity import, and completion after four wrong guesses. Restoring the old six-event cap makes it fail. - A paused-clock desktop regression confirms slow readiness cannot extend the visible QR beyond protocol expiry. Mobile regression covers typing before negotiation completes. - Core/pair-relay Clippy and full native desktop Clippy/workspace tests passed. Mobile analysis passed; broad mobile runs each hit an unrelated failure (a gateway-script timeout and a native-menu missing-plugin error), and both affected test files passed separately. - Repository-wide Rust testing exhausted local disk space while compiling unrelated packages. The final push omitted that resource-blocked hook and avoided repeating the desktop/mobile runs above; hosted CI remains required. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
494 lines
16 KiB
Dart
494 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'package:buzz/features/pairing/pairing_page.dart';
|
|
import 'package:buzz/features/pairing/pairing_provider.dart';
|
|
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
class _EntryNotifier extends PairingNotifier {
|
|
_EntryNotifier({
|
|
this.code = '123456',
|
|
this.recovery = false,
|
|
this.remote = false,
|
|
});
|
|
final bool remote;
|
|
final submissions = <String>[];
|
|
Completer<bool>? verification;
|
|
@override
|
|
Future<bool> verifyDesktopCode(String value) {
|
|
submissions.add(value);
|
|
verification = Completer<bool>();
|
|
return verification!.future;
|
|
}
|
|
|
|
void completePairing() =>
|
|
state = state.copyWith(status: PairingStatus.success);
|
|
final String code;
|
|
final bool recovery;
|
|
int confirmations = 0;
|
|
bool? chosenProtection;
|
|
|
|
@override
|
|
PairingState build() => PairingState(
|
|
status: PairingStatus.confirmingSas,
|
|
sasCode: code,
|
|
sendsIdentityToDesktop: recovery,
|
|
requiresDesktopCode: remote,
|
|
);
|
|
|
|
@override
|
|
void confirmSas() {
|
|
confirmations++;
|
|
state = state.copyWith(userConfirmedSas: true);
|
|
}
|
|
|
|
@override
|
|
void setProtectSensitiveActions(bool value) {
|
|
chosenProtection = value;
|
|
super.setProtectSensitiveActions(value);
|
|
}
|
|
|
|
void biometricCancelled() {
|
|
state = state.copyWith(
|
|
userConfirmedSas: false,
|
|
errorMessage: 'Biometric confirmation cancelled. Try again or skip.',
|
|
);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
final field = find.byKey(const Key('pairing-code-input'));
|
|
|
|
Future<_EntryNotifier> showEntry(
|
|
WidgetTester tester, {
|
|
bool reducedMotion = false,
|
|
String code = '123456',
|
|
bool recovery = false,
|
|
bool remote = false,
|
|
}) async {
|
|
final notifier = _EntryNotifier(
|
|
code: code,
|
|
recovery: recovery,
|
|
remote: remote,
|
|
);
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [pairingProvider.overrideWith(() => notifier)],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
builder: (context, child) => MediaQuery(
|
|
data: MediaQuery.of(
|
|
context,
|
|
).copyWith(disableAnimations: reducedMotion),
|
|
child: child!,
|
|
),
|
|
home: const PairingPage(),
|
|
),
|
|
),
|
|
);
|
|
return notifier;
|
|
}
|
|
|
|
testWidgets(
|
|
'remote verification waits for desktop and does not trust the derived SAS',
|
|
(tester) async {
|
|
final notifier = await showEntry(tester, remote: true);
|
|
await tester.enterText(field, '123456');
|
|
await tester.pump();
|
|
expect(find.text('Protect your identity'), findsNothing);
|
|
expect(notifier.submissions, ['123456']);
|
|
notifier.verification!.complete(false);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Protect your identity'), findsNothing);
|
|
tester.testTextInput.updateEditingValue(
|
|
const TextEditingValue(text: '1234567'),
|
|
);
|
|
await tester.pump();
|
|
expect(notifier.submissions, ['123456']);
|
|
await tester.enterText(field, '654321');
|
|
await tester.pump();
|
|
notifier.verification!.complete(true);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Protect your identity'), findsOneWidget);
|
|
expect(notifier.confirmations, 0);
|
|
await tester.tap(find.text('Skip'));
|
|
await tester.pump();
|
|
expect(notifier.confirmations, 1);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'successful pushed pairing resets after dismissal and can reopen',
|
|
(tester) async {
|
|
final notifier = _EntryNotifier();
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [pairingProvider.overrideWith(() => notifier)],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Builder(
|
|
builder: (context) => Scaffold(
|
|
body: TextButton(
|
|
onPressed: () => Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => const PairingPage(addingCommunity: true),
|
|
),
|
|
),
|
|
child: const Text('Open pairing'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('Open pairing'));
|
|
await tester.pumpAndSettle();
|
|
notifier.completePairing();
|
|
await tester.pump();
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(PairingPage), findsNothing);
|
|
await tester.tap(find.text('Open pairing'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Scan a QR code'), findsOneWidget);
|
|
expect(find.byKey(const Key('pairing-community-loading')), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'starts empty with no extra confirmation or biometrics controls',
|
|
(tester) async {
|
|
await showEntry(tester);
|
|
expect(find.text('Enter pairing code'), findsOneWidget);
|
|
expect(tester.widget<TextField>(field).controller!.text, isEmpty);
|
|
expect(find.text('123456'), findsNothing);
|
|
expect(find.text('123 456'), findsNothing);
|
|
expect(find.text('Continue'), findsNothing);
|
|
expect(find.text('Use biometrics'), findsNothing);
|
|
for (var index = 0; index < 6; index++) {
|
|
expect(find.byKey(Key('pairing-code-cell-$index')), findsOneWidget);
|
|
}
|
|
},
|
|
);
|
|
|
|
testWidgets('wrong code shakes only the digit boxes with one haptic', (
|
|
tester,
|
|
) async {
|
|
final notifier = await showEntry(tester);
|
|
final haptics = <Object?>[];
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(const MethodChannel('buzz/haptics'), (
|
|
call,
|
|
) async {
|
|
if (call.method == 'error') {
|
|
haptics.add(call.method);
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(
|
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(const MethodChannel('buzz/haptics'), null),
|
|
);
|
|
await tester.enterText(field, '999999');
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 40));
|
|
final shake = tester.widget<Transform>(
|
|
find.byKey(const Key('pairing-error-shake')),
|
|
);
|
|
expect(shake.transform.storage[12].abs(), greaterThan(0));
|
|
expect(
|
|
find.ancestor(
|
|
of: find.text('Enter pairing code'),
|
|
matching: find.byKey(const Key('pairing-error-shake')),
|
|
),
|
|
findsNothing,
|
|
);
|
|
final cell = tester.widget<AnimatedContainer>(
|
|
find.byKey(const Key('pairing-code-cell-0')),
|
|
);
|
|
final errorColor = Theme.of(tester.element(field)).colorScheme.error;
|
|
expect(
|
|
((cell.decoration as BoxDecoration).border as Border).top.color,
|
|
errorColor,
|
|
);
|
|
expect(
|
|
tester
|
|
.widget<Text>(find.text('Check the desktop code and try again.'))
|
|
.style!
|
|
.color,
|
|
tester.widget<Text>(find.text('Enter pairing code')).style!.color,
|
|
);
|
|
expect(haptics, ['error']);
|
|
expect(find.text('Check the desktop code and try again.'), findsOneWidget);
|
|
expect(notifier.confirmations, 0);
|
|
expect(find.text('Protect your identity'), findsNothing);
|
|
});
|
|
|
|
testWidgets(
|
|
'extra digits repeat error feedback even when the code stays full',
|
|
(tester) async {
|
|
await showEntry(tester);
|
|
var errorHaptics = 0;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(const MethodChannel('buzz/haptics'), (
|
|
call,
|
|
) async {
|
|
if (call.method == 'error') errorHaptics++;
|
|
return null;
|
|
});
|
|
addTearDown(
|
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(
|
|
const MethodChannel('buzz/haptics'),
|
|
null,
|
|
),
|
|
);
|
|
final heading = find.text('Enter pairing code');
|
|
final titlePosition = tester.getTopLeft(heading);
|
|
await tester.enterText(field, '999999');
|
|
await tester.pumpAndSettle();
|
|
expect(errorHaptics, 1);
|
|
for (final digit in ['1', '2', '3']) {
|
|
await tester.enterText(field, '999999$digit');
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 40));
|
|
final shake = tester.widget<Transform>(
|
|
find.byKey(const Key('pairing-error-shake')),
|
|
);
|
|
expect(shake.transform.storage[12].abs(), greaterThan(0));
|
|
expect(tester.widget<TextField>(field).controller!.text, '999999');
|
|
expect(tester.getTopLeft(heading), titlePosition);
|
|
}
|
|
expect(errorHaptics, 4);
|
|
await tester.enterText(field, '99999');
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
errorHaptics,
|
|
4,
|
|
reason: 'Deleting to correct the code is not an error',
|
|
);
|
|
await tester.enterText(field, '123456');
|
|
await tester.pumpAndSettle();
|
|
expect(errorHaptics, 4);
|
|
expect(find.text('Protect your identity'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('reference shake keyframes and error fade preserve layout', (
|
|
tester,
|
|
) async {
|
|
await showEntry(tester);
|
|
final heading = find.text('Enter pairing code');
|
|
final initialPosition = tester.getTopLeft(heading);
|
|
final initialY = initialPosition.dy;
|
|
final error = find.byKey(const Key('pairing-code-error'));
|
|
expect(tester.widget<AnimatedOpacity>(error).opacity, 0);
|
|
await tester.enterText(field, '999999');
|
|
await tester.pump();
|
|
expect(tester.getTopLeft(heading).dy, initialY);
|
|
expect(
|
|
tester.widget<AnimatedOpacity>(error).duration,
|
|
const Duration(milliseconds: 280),
|
|
);
|
|
for (final (milliseconds, expectedX) in [
|
|
(80, 6.0),
|
|
(80, -6.0),
|
|
(60, 4.0),
|
|
(60, 0.0),
|
|
]) {
|
|
await tester.pump(Duration(milliseconds: milliseconds));
|
|
final transform = tester.widget<Transform>(
|
|
find.byKey(const Key('pairing-error-shake')),
|
|
);
|
|
expect(transform.transform.storage[12], closeTo(expectedX, 0.01));
|
|
expect(tester.getTopLeft(heading), initialPosition);
|
|
expect(
|
|
find.ancestor(
|
|
of: field,
|
|
matching: find.byKey(const Key('pairing-error-shake')),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
}
|
|
expect(
|
|
tester
|
|
.widget<AnimatedContainer>(
|
|
find.byKey(const Key('pairing-code-cell-0')),
|
|
)
|
|
.duration,
|
|
const Duration(milliseconds: 280),
|
|
);
|
|
await tester.enterText(field, '99999');
|
|
await tester.pump();
|
|
expect(tester.widget<AnimatedOpacity>(error).opacity, 0);
|
|
expect(tester.getTopLeft(heading).dy, initialY);
|
|
await tester.pump(const Duration(milliseconds: 280));
|
|
final fade = tester.widget<FadeTransition>(
|
|
find.descendant(of: error, matching: find.byType(FadeTransition)).first,
|
|
);
|
|
expect(fade.opacity.value, 0);
|
|
});
|
|
|
|
testWidgets(
|
|
'correct code advances automatically and Use biometrics confirms once',
|
|
(tester) async {
|
|
final notifier = await showEntry(tester);
|
|
await tester.enterText(field, '123456');
|
|
await tester.pump();
|
|
expect(field, findsNothing);
|
|
expect(find.text('Protect your identity'), findsOneWidget);
|
|
expect(
|
|
find.textContaining('before sending your Buzz identity'),
|
|
findsOneWidget,
|
|
);
|
|
expect(notifier.confirmations, 0);
|
|
final useBiometrics = find.widgetWithText(FilledButton, 'Use biometrics');
|
|
await tester.tap(useBiometrics);
|
|
await tester.tap(useBiometrics);
|
|
await tester.pump();
|
|
expect(notifier.chosenProtection, isTrue);
|
|
expect(notifier.confirmations, 1);
|
|
expect(find.text('Confirmed — waiting for desktop'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('Skip records disabled protection before confirming', (
|
|
tester,
|
|
) async {
|
|
final notifier = await showEntry(tester);
|
|
await tester.enterText(field, '123456');
|
|
await tester.pump();
|
|
await tester.tap(find.text('Skip'));
|
|
await tester.pump();
|
|
expect(notifier.chosenProtection, isFalse);
|
|
expect(notifier.confirmations, 1);
|
|
});
|
|
|
|
testWidgets(
|
|
'cancelled biometrics lets the user retry or skip without retyping',
|
|
(tester) async {
|
|
final notifier = await showEntry(tester);
|
|
await tester.enterText(field, '123456');
|
|
await tester.pump();
|
|
await tester.tap(find.text('Use biometrics'));
|
|
await tester.pump();
|
|
notifier.biometricCancelled();
|
|
await tester.pump();
|
|
expect(find.text('Protect your identity'), findsOneWidget);
|
|
expect(
|
|
find.textContaining('Biometric confirmation cancelled'),
|
|
findsOneWidget,
|
|
);
|
|
await tester.tap(find.text('Skip'));
|
|
await tester.pump();
|
|
expect(notifier.chosenProtection, isFalse);
|
|
expect(notifier.confirmations, 2);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'desktop recovery confirms matching input without a protection step',
|
|
(tester) async {
|
|
final notifier = await showEntry(tester, recovery: true);
|
|
await tester.enterText(field, '123456');
|
|
await tester.pump();
|
|
expect(notifier.confirmations, 1);
|
|
expect(find.text('Protect your identity'), findsNothing);
|
|
expect(find.text('Code confirmed'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('desktop recovery can retry cancelled authorization', (
|
|
tester,
|
|
) async {
|
|
final notifier = await showEntry(tester, recovery: true);
|
|
await tester.enterText(field, '123456');
|
|
await tester.pump();
|
|
notifier.biometricCancelled();
|
|
await tester.pump();
|
|
await tester.tap(find.text('Try again'));
|
|
await tester.pump();
|
|
expect(notifier.confirmations, 2);
|
|
expect(notifier.chosenProtection, isNull);
|
|
});
|
|
|
|
testWidgets('paste filters non-digits, limits length and preserves zeros', (
|
|
tester,
|
|
) async {
|
|
final notifier = await showEntry(tester, code: '012345');
|
|
await tester.enterText(field, '01 23-45abc67');
|
|
await tester.pump();
|
|
expect(find.text('Protect your identity'), findsOneWidget);
|
|
expect(notifier.confirmations, 0);
|
|
});
|
|
|
|
testWidgets(
|
|
'tapping a cell selects its digit; deleting and correcting advances',
|
|
(tester) async {
|
|
await showEntry(tester);
|
|
await tester.enterText(field, '123956');
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(const Key('pairing-code-cell-3')));
|
|
await tester.pump();
|
|
final controller = tester.widget<TextField>(field).controller!;
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 3, extentOffset: 4),
|
|
);
|
|
tester.testTextInput.updateEditingValue(
|
|
const TextEditingValue(
|
|
text: '12356',
|
|
selection: TextSelection.collapsed(offset: 3),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(controller.text, '12356');
|
|
expect(
|
|
tester
|
|
.widget<AnimatedOpacity>(
|
|
find.byKey(const Key('pairing-code-error')),
|
|
)
|
|
.opacity,
|
|
0,
|
|
);
|
|
tester.testTextInput.updateEditingValue(
|
|
const TextEditingValue(
|
|
text: '123456',
|
|
selection: TextSelection.collapsed(offset: 4),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(field, findsNothing);
|
|
expect(find.text('Protect your identity'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('reduced motion removes shaking but keeps the error message', (
|
|
tester,
|
|
) async {
|
|
await showEntry(tester, reducedMotion: true);
|
|
await tester.enterText(field, '999999');
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 40));
|
|
final shake = tester.widget<Transform>(
|
|
find.byKey(const Key('pairing-error-shake')),
|
|
);
|
|
expect(shake.transform.storage[12], 0);
|
|
final cell = find.byKey(const Key('pairing-code-cell-0'));
|
|
expect(tester.widget<AnimatedContainer>(cell).duration, Duration.zero);
|
|
expect(find.text('Check the desktop code and try again.'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('numeric entry is exposed as one labelled accessible field', (
|
|
tester,
|
|
) async {
|
|
final semantics = tester.ensureSemantics();
|
|
await showEntry(tester);
|
|
expect(find.bySemanticsLabel('Desktop code, six digits'), findsOneWidget);
|
|
expect(tester.widget<TextField>(field).keyboardType, TextInputType.number);
|
|
semantics.dispose();
|
|
});
|
|
}
|