import 'package:flutter/foundation.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'; import 'package:local_auth/local_auth.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/pairing/pairing_page.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:buzz/shared/widgets/tappable_flapping_bee.dart'; import '../../helpers/widget_helpers.dart'; void main() { group('PairingPage', () { testWidgets('renders branding and progressive pairing actions', ( tester, ) async { await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage()), ); expect(find.byType(TappableFlappingBee), findsOneWidget); expect(find.text('Welcome to Buzz'), findsOneWidget); expect(find.text('Scan a QR code'), findsOneWidget); expect(find.text('Use pairing code'), findsOneWidget); expect(find.text('Connect'), findsNothing); expect(find.byType(TextField), findsNothing); }); testWidgets('uses compact desktop-style onboarding actions', ( tester, ) async { await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage()), ); final scanButton = tester.getSize( find.widgetWithText(FilledButton, 'Scan a QR code'), ); final pairingCodeButton = tester.getSize( find.widgetWithText(TextButton, 'Use pairing code'), ); expect(scanButton.width, lessThan(440)); expect(pairingCodeButton.width, lessThan(440)); expect(find.byType(OutlinedButton), findsNothing); }); testWidgets('uses dark status-bar icons on the onboarding surface', ( tester, ) async { await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage()), ); final overlay = tester.widget>( find.byKey(const Key('pairing-onboarding-system-overlay')), ); expect(overlay.value.statusBarIconBrightness, Brightness.dark); expect(overlay.value.statusBarColor, Colors.transparent); }); testWidgets('uses the onboarding surface for dark-theme SAS verification', ( tester, ) async { await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), ], child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), ), ); final overlay = tester.widget>( find.byKey(const Key('pairing-sas-system-overlay')), ); expect(overlay.value.statusBarIconBrightness, Brightness.dark); expect(overlay.value.statusBarColor, Colors.transparent); final background = tester.widget( find.byKey(const Key('pairing-onboarding-background')), ); final backgroundDecoration = background.decoration as BoxDecoration; final backgroundGradient = backgroundDecoration.gradient! as LinearGradient; expect(backgroundGradient.colors, const [ Color(0xFFD7D72E), Color(0xFFD7E7F6), ]); expect( tester.widget(find.byType(Scaffold)).backgroundColor, Colors.transparent, ); expect(find.text('Confirm desktop code'), findsOneWidget); expect( find.text( 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.', ), findsOneWidget, ); expect(find.text('Does your desktop app show this code?'), findsNothing); }); testWidgets('uses Cancel as the only visible SAS exit', (tester) async { final notifier = _ConfirmingSasPairingNotifier(); await tester.pumpWidget( ProviderScope( overrides: [pairingProvider.overrideWith(() => notifier)], child: MaterialApp( theme: AppTheme.dark(), home: const PairingPage(addingCommunity: true), ), ), ); expect(find.byType(AppBar), findsNothing); expect(find.text('Add Community'), findsNothing); expect(find.byIcon(LucideIcons.arrowLeft), findsNothing); expect(find.byKey(const Key('pairing-pop-scope')), findsOneWidget); await tester.tap(find.widgetWithText(TextButton, 'Cancel')); expect(notifier.denied, isTrue); }); testWidgets('keeps the add-community header outside SAS', (tester) async { await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)), ); expect(find.byType(AppBar), findsOneWidget); expect(find.text('Add Community'), findsOneWidget); expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget); }); testWidgets('uses the native glass back control on iOS', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)), ); final nativeBack = tester.widget(find.byType(UiKitView)); expect(nativeBack.viewType, 'buzz/navigation_glass'); expect(nativeBack.creationParams, containsPair('icon', 'back')); expect( nativeBack.creationParams, containsPair('buttonCenterX', iosGlassChannelHeaderButtonCenterX), ); expect( nativeBack.creationParams, containsPair('hitTargetWidth', iosGlassChannelHeaderLeadingWidth), ); final backRect = tester.getRect( find.byKey(const ValueKey('pairing-ios-glass-back')), ); expect( backRect.left + iosGlassChannelHeaderButtonCenterX, Grid.quarter + iosGlassChannelHeaderButtonCenterX, ); expect(find.byTooltip('Back'), findsOneWidget); debugDefaultTargetPlatformOverride = null; }); testWidgets('reveals pairing code field and connect action', ( tester, ) async { await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage()), ); await _expandPairingCode(tester); expect(find.text('Hide pairing code'), findsOneWidget); expect(find.text('Connect'), findsOneWidget); expect(find.byType(TextField), findsOneWidget); }); testWidgets('connect button is below text field, not beside it', ( tester, ) async { await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage()), ); await _expandPairingCode(tester); final textField = tester.getBottomLeft(find.byType(TextField)); final connectButton = tester.getTopLeft( find.widgetWithText(FilledButton, 'Connect'), ); // The connect button should be below the text field. expect(connectButton.dy, greaterThan(textField.dy)); }); testWidgets('connect button is full width', (tester) async { await tester.pumpWidget( WidgetHelpers.testable(child: const PairingPage()), ); await _expandPairingCode(tester); final connectButton = tester.getSize( find.widgetWithText(FilledButton, 'Connect'), ); final textField = tester.getSize(find.byType(TextField)); // Button width should be close to the text field width (both full-width). expect(connectButton.width, closeTo(textField.width, 2.0)); }); testWidgets('shows error container when pairing fails', (tester) async { await tester.pumpWidget( WidgetHelpers.testable( overrides: [ pairingProvider.overrideWith( () => _ErrorPairingNotifier('Invalid pairing code: bad input'), ), ], child: const PairingPage(), ), ); await tester.pump(); expect(find.text('Invalid pairing code: bad input'), findsOneWidget); }); testWidgets('shows spinner when connecting', (tester) async { await tester.pumpWidget( WidgetHelpers.testable( overrides: [ pairingProvider.overrideWith(() => _ConnectingPairingNotifier()), ], child: const PairingPage(), ), ); await tester.pump(); expect(find.byType(BuzzLoadingIndicator), findsOneWidget); // Connect text should be replaced by spinner. expect(find.text('Connect'), findsNothing); }); testWidgets('pairing actions are disabled when connecting', (tester) async { await tester.pumpWidget( WidgetHelpers.testable( overrides: [ pairingProvider.overrideWith(() => _ConnectingPairingNotifier()), ], child: const PairingPage(), ), ); await tester.pump(); final scanButton = tester.widget(find.byType(FilledButton)); final pairingCodeButton = tester.widget( find.widgetWithText(TextButton, 'Use pairing code'), ); expect(scanButton.onPressed, isNull); expect(pairingCodeButton.onPressed, isNull); }); testWidgets('recovery entry rejects ordinary nostrpair codes', ( tester, ) async { final notifier = _RecordingPairingNotifier(); await tester.pumpWidget( WidgetHelpers.testable( overrides: [pairingProvider.overrideWith(() => notifier)], child: const PairingPage( addingCommunity: true, identityRecoveryOnly: true, ), ), ); await _expandPairingCode(tester); await tester.enterText(find.byType(TextField), 'nostrpair://ordinary'); await tester.tap(find.text('Connect')); await tester.pump(); expect(find.text('Scan a desktop recovery code.'), findsOneWidget); expect(notifier.pairedCodes, isEmpty); }); testWidgets('recovery entry accepts mode=recover codes', (tester) async { final notifier = _RecordingPairingNotifier(); await tester.pumpWidget( WidgetHelpers.testable( overrides: [pairingProvider.overrideWith(() => notifier)], child: const PairingPage( addingCommunity: true, identityRecoveryOnly: true, ), ), ); await _expandPairingCode(tester); const code = 'nostrpair://desktop?mode=recover'; await tester.enterText(find.byType(TextField), code); await tester.tap(find.text('Connect')); await tester.pump(); expect(notifier.pairedCodes, [code]); }); testWidgets('new identity import offers protection checked by default', ( tester, ) async { await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), ], child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), ), ); final checkbox = tester.widget( find.byKey(const Key('protect-sensitive-actions-checkbox')), ); expect(checkbox.value, isTrue); expect(find.text('Use biometrics'), findsOneWidget); expect(find.text('For secure actions'), findsOneWidget); }); testWidgets('uses the native Face ID label on iOS', (tester) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; try { await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith( () => _ConfirmingSasPairingNotifier(), ), enrolledBiometricsProvider.overrideWith( (_) async => const [BiometricType.face], ), ], child: MaterialApp( theme: AppTheme.dark(), home: const PairingPage(), ), ), ); await tester.pump(); expect(find.text('Use Face ID'), findsOneWidget); expect(find.text('Use biometrics'), findsNothing); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } }); testWidgets('uses the native Touch ID label on iOS', (tester) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; try { await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith( () => _ConfirmingSasPairingNotifier(), ), enrolledBiometricsProvider.overrideWith( (_) async => const [BiometricType.fingerprint], ), ], child: MaterialApp( theme: AppTheme.dark(), home: const PairingPage(), ), ), ); await tester.pump(); expect(find.text('Use Touch ID'), findsOneWidget); expect(find.text('Use Face ID'), findsNothing); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } }); testWidgets('desktop recovery does not show protection checkbox', ( tester, ) async { await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith( () => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true), ), ], child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), ), ); expect( find.byKey(const Key('protect-sensitive-actions-checkbox')), findsNothing, ); }); testWidgets('recovery SAS puts permanent desktop access in the subtitle', ( tester, ) async { await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith( () => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true), ), ], child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), ), ); expect(find.textContaining('full Buzz identity'), findsOneWidget); expect(find.textContaining('permanent access'), findsOneWidget); expect(find.textContaining('started this recovery'), findsOneWidget); expect(find.text('Codes match'), findsOneWidget); }); testWidgets('matches the onboarding visual system and SAS action layout', ( tester, ) async { await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), ], child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), ), ); expect(find.byIcon(LucideIcons.shieldCheck), findsNothing); expect(find.text('Confirm desktop code'), findsOneWidget); expect( find.text( 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.', ), findsOneWidget, ); expect(find.text('Does your desktop app show this code?'), findsNothing); final digitFinders = [ for (var index = 1; index <= 6; index++) find.byKey(Key('pairing-sas-code-digit-$index')), ]; for (final digitFinder in digitFinders) { expect(tester.getSize(digitFinder).width, 54); expect( tester.widget(digitFinder).padding, const EdgeInsets.symmetric(vertical: Grid.xs), ); } const onboardingInk = Color(0xFF111111); const onboardingMutedInk = Color(0xB3111111); const onboardingCtaLabel = Color(0xFFD7E6F0); final theme = AppTheme.dark(); final protectionTile = tester.widget( find.byKey(const Key('protect-sensitive-actions-checkbox')), ); expect(protectionTile.activeColor, onboardingInk); expect(protectionTile.checkColor, onboardingCtaLabel); expect(protectionTile.side?.color, onboardingInk); expect((protectionTile.title as Text).style?.color, onboardingInk); expect( (protectionTile.subtitle as Text).style?.color, onboardingMutedInk, ); final firstDigitContainer = tester.widget(digitFinders.first); final firstDigitDecoration = firstDigitContainer.decoration! as BoxDecoration; expect(firstDigitDecoration.color, Colors.white.withValues(alpha: 0.7)); expect( (firstDigitDecoration.border! as Border).top.color, theme.colorScheme.primary.withValues(alpha: 0.15), ); final firstDigitText = tester.widget( find.descendant(of: digitFinders.first, matching: find.text('1')), ); expect(firstDigitText.style?.fontFamily, 'Inter'); expect( firstDigitText.style?.fontSize, theme.textTheme.displaySmall?.fontSize, ); expect(firstDigitText.style?.fontSize, greaterThanOrEqualTo(36)); expect(firstDigitText.style?.fontWeight, FontWeight.w600); expect(firstDigitText.style?.fontFeatures, isNull); expect(firstDigitText.style?.color, onboardingInk); final firstDigit = tester.getTopLeft(digitFinders[0]); final secondDigit = tester.getTopLeft(digitFinders[1]); final thirdDigit = tester.getTopLeft(digitFinders[2]); final fourthDigit = tester.getTopLeft(digitFinders[3]); expect(secondDigit.dx - firstDigit.dx, 60); expect(fourthDigit.dx - thirdDigit.dx, 68); final confirmFinder = find.widgetWithText(FilledButton, 'Codes match'); final cancelFinder = find.widgetWithText(TextButton, 'Cancel'); final confirmButton = tester.widget(confirmFinder); final cancelButton = tester.widget(cancelFinder); expect( confirmButton.style?.backgroundColor?.resolve({}), onboardingInk, ); expect( confirmButton.style?.foregroundColor?.resolve({}), onboardingCtaLabel, ); expect( confirmButton.style?.shape?.resolve({}), isA(), ); expect( cancelButton.style?.backgroundColor?.resolve({}), onboardingInk.withValues(alpha: 0.1), ); expect( cancelButton.style?.foregroundColor?.resolve({}), onboardingInk, ); expect( cancelButton.style?.shape?.resolve({}), isA(), ); final confirmTopLeft = tester.getTopLeft(confirmFinder); final cancelTopLeft = tester.getTopLeft(cancelFinder); final scaffoldWidth = tester.getSize(find.byType(Scaffold)).width; expect(confirmTopLeft.dy, lessThan(cancelTopLeft.dy)); expect(confirmTopLeft.dx, cancelTopLeft.dx); expect(confirmTopLeft.dx, Grid.sm); expect(tester.getSize(confirmFinder).width, scaffoldWidth - Grid.sm * 2); expect(tester.getSize(cancelFinder).width, scaffoldWidth - Grid.sm * 2); expect(tester.getSize(confirmFinder).height, 48); expect(tester.getSize(cancelFinder).height, 48); expect( find.textContaining( 'Only continue if you started this pairing from your desktop.', ), findsOneWidget, ); expect( tester.getBottomLeft(find.byType(Scaffold)).dy - tester.getBottomLeft(cancelFinder).dy, Grid.sm, ); }); testWidgets('uses accessible SAS error contrast in both themes', ( tester, ) async { const errorMessage = 'Identity confirmation failed. Nothing transferred.'; const errorInk = Color(0xFF7A1025); const gradientColors = [Color(0xFFD7D72E), Color(0xFFD7E7F6)]; for (final theme in [AppTheme.light(), AppTheme.dark()]) { await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith( () => _ConfirmingSasPairingNotifier(errorMessage: errorMessage), ), ], child: MaterialApp(theme: theme, home: const PairingPage()), ), ); final errorText = tester.widget(find.text(errorMessage)); expect(errorText.style?.color, errorInk); for (final background in gradientColors) { expect( _contrastRatio(errorInk, background), greaterThanOrEqualTo(4.5), ); } expect(tester.takeException(), isNull); } }); testWidgets('keeps SAS actions above the keyboard on small screens', ( tester, ) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(360, 560); tester.view.viewInsets = const FakeViewPadding(bottom: 200); addTearDown(tester.view.reset); await tester.pumpWidget( ProviderScope( overrides: [ pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), ], child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), ), ); expect(tester.takeException(), isNull); expect(find.byType(SingleChildScrollView), findsOneWidget); final cancelFinder = find.widgetWithText(TextButton, 'Cancel'); expect(tester.getBottomLeft(cancelFinder).dy, 560 - 200 - Grid.sm); await tester.drag( find.byType(SingleChildScrollView), const Offset(0, -100), ); await tester.pump(); expect(tester.takeException(), isNull); expect(find.text('Confirm desktop code'), findsOneWidget); expect(find.textContaining('matches on both devices'), findsOneWidget); expect( find.textContaining('Buzz identity will transfer'), findsOneWidget, ); expect(find.text('Codes match'), findsOneWidget); }); }); } double _contrastRatio(Color foreground, Color background) { final foregroundLuminance = foreground.computeLuminance(); final backgroundLuminance = background.computeLuminance(); final lighter = foregroundLuminance > backgroundLuminance ? foregroundLuminance : backgroundLuminance; final darker = foregroundLuminance > backgroundLuminance ? backgroundLuminance : foregroundLuminance; return (lighter + 0.05) / (darker + 0.05); } Future _expandPairingCode(WidgetTester tester) async { await tester.tap(find.text('Use pairing code')); await tester.pumpAndSettle(); } class _ErrorPairingNotifier extends Notifier implements PairingNotifier { final String error; _ErrorPairingNotifier(this.error); @override PairingState build() => PairingState(status: PairingStatus.error, errorMessage: error); @override Future authorizeIdentityExport({required Community community}) async => true; @override Future pair(String rawInput) async {} @override void reset() {} @override void confirmSas() {} @override void setProtectSensitiveActions(bool value) {} @override void denySas() {} } class _ConnectingPairingNotifier extends Notifier implements PairingNotifier { @override PairingState build() => const PairingState(status: PairingStatus.connecting); @override Future authorizeIdentityExport({required Community community}) async => true; @override Future pair(String rawInput) async {} @override void reset() {} @override void confirmSas() {} @override void setProtectSensitiveActions(bool value) {} @override void denySas() {} } class _RecordingPairingNotifier extends Notifier implements PairingNotifier { final pairedCodes = []; @override PairingState build() => const PairingState(); @override Future authorizeIdentityExport({required Community community}) async => true; @override Future pair(String rawInput) async => pairedCodes.add(rawInput); @override void reset() {} @override void confirmSas() {} @override void setProtectSensitiveActions(bool value) {} @override void denySas() {} } class _ConfirmingSasPairingNotifier extends Notifier implements PairingNotifier { _ConfirmingSasPairingNotifier({ this.sendsIdentityToDesktop = false, this.errorMessage, }); final bool sendsIdentityToDesktop; final String? errorMessage; bool denied = false; @override PairingState build() => PairingState( status: PairingStatus.confirmingSas, sasCode: '123456', sendsIdentityToDesktop: sendsIdentityToDesktop, errorMessage: errorMessage, ); @override Future authorizeIdentityExport({required Community community}) async => true; @override Future pair(String rawInput) async {} @override void reset() {} @override void confirmSas() {} @override void setProtectSensitiveActions(bool value) {} @override void denySas() => denied = true; }