Files
buzz/mobile/test/features/pairing/pairing_page_test.dart
Taylor HoandGitHub d8281b9c93 feat(mobile): require device authentication for identity export (#5116)
**Category:** new-feature
**User Impact:** Mobile users must confirm with Face ID, biometrics, or
their device passcode before sending their Buzz identity to Desktop.

**Problem:** A signed-in phone could send its full identity, including
the `nsec`, to a desktop without fresh local verification.

**Solution:** Require OS device authentication before opening the
identity-recovery scanner, retain that authorization only for the active
pairing session and short pairing window, and require fresh
authentication again if it expires before the identity payload is sent.
Normal app opening, identity import, and community removal remain
unchanged.

## Screencasts

| Enable Face ID | Use Face ID |
| --- | --- |
| ![Enabling Face ID during identity
import](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5116/enable-face-id.gif)
| ![Using Face ID for identity
export](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5116/use-face-id.gif)
|

<details>
<summary>File changes</summary>

**Android and iOS integration**
- `mobile/android/app/build.gradle.kts` declares the AppCompat
dependency required by the biometric activity theme.
-
`mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt`
uses the activity type required by the system authentication prompt.
- `mobile/android/app/src/main/res/values/styles.xml` and
`mobile/android/app/src/main/res/values-night/styles.xml` use the
compatible launch theme.
- `mobile/ios/Podfile.lock` records the native local-authentication
dependency.
- `mobile/ios/Runner/Info.plist` explains why Buzz requests Face ID
access.

**Identity policy and pairing flow**
- `mobile/lib/shared/security/sensitive_action_authorizer.dart` wraps OS
authentication and maps platform errors to stable app-level outcomes.
- `mobile/lib/shared/community/community.dart` and
`mobile/lib/shared/community/community_storage.dart` persist the
sensitive-action policy.
- `mobile/lib/features/invites/invite_join_provider.dart` assigns the
explicit policy for invite-created communities.
- `mobile/lib/features/pairing/pairing_provider.dart` gates export,
binds grants to the active community/session, reauthenticates expired
grants, and clears grants on every terminal path.
- `mobile/lib/features/pairing/pairing_page.dart` lets users choose
biometric protection while importing an identity.
- `mobile/lib/features/settings/settings_page.dart` wires pairing into
settings.
- `mobile/lib/features/settings/settings_page/connection_section.dart`
authenticates before opening export recovery and bounds the
foreground-resume wait.
- `mobile/pubspec.yaml` and `mobile/pubspec.lock` add and lock
`local_auth`.

**Coverage**
- `mobile/test/shared/security/sensitive_action_authorizer_test.dart`
covers native result mapping, unsupported devices, and single-flight
behavior.
- `mobile/test/shared/community/community_test.dart` and
`mobile/test/shared/community/community_storage_test.dart` cover policy
defaults and persistence.
- `mobile/test/features/invites/invite_join_provider_test.dart` covers
the invite policy.
- `mobile/test/features/pairing/pairing_page_test.dart` covers import
protection controls.
- `mobile/test/features/pairing/pairing_provider_test.dart` covers
export/import authorization, stale/reset/concurrent guards, malformed
payload cleanup, and no-export failure paths.
- `mobile/test/features/settings/connection_section_test.dart` covers
the tap gate, lifecycle resume, and timeout behavior.

</details>

## Reproduction steps

1. Pair an identity into the mobile app.
2. Open Settings and choose “Send identity to desktop.”
3. Verify Face ID, biometrics, or the device passcode is required before
the recovery scanner opens.
4. Cancel device authentication and verify the scanner does not open and
no identity transfer begins.
5. Authenticate, scan a Desktop recovery code, confirm the SAS, and
verify the identity transfer completes.

## Validation

At `be5620f5f10aa6cc16e86a4f01f102f3d9aeef9b`:
- `cd mobile && ../bin/flutter analyze` — no issues
- `cd mobile && ../bin/flutter test` — 1,368 tests passed
- `cd mobile/android && JAVA_HOME=$(/usr/libexec/java_home -v 21)
./gradlew app:assembleDebug` — debug APK assembled successfully

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-15 18:34:02 -07:00

468 lines
14 KiB
Dart

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: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/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<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('pairing-onboarding-system-overlay')),
);
expect(overlay.value.statusBarIconBrightness, Brightness.dark);
expect(overlay.value.statusBarColor, Colors.transparent);
});
testWidgets('uses light status-bar icons 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<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('pairing-sas-system-overlay')),
);
expect(overlay.value.statusBarIconBrightness, Brightness.light);
expect(overlay.value.statusBarColor, Colors.transparent);
expect(find.text('Verify Security Code'), findsOneWidget);
});
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<FilledButton>(find.byType(FilledButton));
final pairingCodeButton = tester.widget<TextButton>(
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<CheckboxListTile>(
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 warns about permanent desktop access', (
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.text('Codes Match'), findsOneWidget);
});
});
}
Future<void> _expandPairingCode(WidgetTester tester) async {
await tester.tap(find.text('Use pairing code'));
await tester.pumpAndSettle();
}
class _ErrorPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
final String error;
_ErrorPairingNotifier(this.error);
@override
PairingState build() =>
PairingState(status: PairingStatus.error, errorMessage: error);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}
class _ConnectingPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
@override
PairingState build() => const PairingState(status: PairingStatus.connecting);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}
class _RecordingPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
final pairedCodes = <String>[];
@override
PairingState build() => const PairingState();
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> 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<PairingState>
implements PairingNotifier {
_ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false});
final bool sendsIdentityToDesktop;
@override
PairingState build() => PairingState(
status: PairingStatus.confirmingSas,
sasCode: '123456',
sendsIdentityToDesktop: sendsIdentityToDesktop,
);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}