mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
**Category:** new-feature **User Impact:** People who lose a desktop identity can securely restore it from a signed-in Buzz phone without creating a replacement identity. **Problem:** A fresh or identity-lost desktop could not recover its existing full Buzz identity from an already-authorized phone. **Solution:** Add a SAS-confirmed reverse NIP-AB transfer, durable desktop import, a dedicated mobile recovery entry point, and clearer desktop recovery dialogs with tested loading, drag-and-drop, and failure states. https://github.com/user-attachments/assets/e9215c9c-80d0-462f-9161-0fa184ca2f74 <details> <summary>File changes</summary> **crates/buzz-core/src/pairing/session.rs** Adds the reverse encrypted payload and source-completion state transitions used for phone-to-desktop recovery. **desktop/src-tauri/src/commands/identity.rs** Exposes the existing guarded identity commit path for recovery imports. **desktop/src-tauri/src/commands/pairing.rs** Adds recovery-mode pairing, durable nsec import, start serialization, stale-task protection, and explicit rejection of unsupported recovery payloads. **desktop/src-tauri/src/lib.rs** Registers the recovery pairing command. **desktop/src/app/App.tsx** Refreshes the recovered identity before continuing onboarding. **desktop/src/features/onboarding/machineOnboarding.ts** Adds recovery transitions to the onboarding state machine. **desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx** Adds the visual backup-to-password-to-unlock progression. **desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx** Implements QR generation, copy fallback, SAS confirmation, cancellation, expiry, and completion UI. **desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx** Connects private-key, phone, and backup recovery paths to the onboarding flow. **desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx** Polishes recovery dialogs, backup drag-and-drop, loading stability, and security copy. **desktop/src/shared/api/tauri.ts** Keeps the existing pairing API surface focused on standard desktop-to-mobile pairing. **desktop/src/shared/api/tauriPairing.ts** Adds the recovery pairing invoke without growing the ratcheted shared API file. **desktop/src/testing/e2eBridge.ts** Mocks recovery pairing commands and lifecycle events for browser tests. **desktop/tests/e2e/identity-lost.spec.ts** Covers lost-identity entry, QR/copy recovery, SAS, cancellation, expiry, success, errors, backup import, drag-and-drop, and screenshots. **desktop/tests/e2e/onboarding.spec.ts** Verifies recovered identities continue through harness setup without replacement-key side effects. **mobile/lib/features/pairing/pairing_page.dart** Adds recovery-only scanning and explicit identity-handoff warnings. **mobile/lib/features/pairing/pairing_provider.dart** Recognizes recovery codes, returns the signed-in nsec after mutual SAS approval, and waits for desktop completion. **mobile/lib/features/settings/settings_page.dart** Accepts the recovery route builder at the app composition boundary to preserve feature isolation. **mobile/lib/features/settings/settings_page/connection_section.dart** Adds the signed-in “Send identity to desktop” settings action. **mobile/test/features/pairing/pairing_page_test.dart** Covers recovery-only validation and handoff messaging. **mobile/test/features/pairing/pairing_provider_test.dart** Covers reverse payload encryption, confirmation ordering, success, failure, timeout, and cleanup. </details> ## Reproduction steps 1. Launch Buzz Desktop with identity-lost state and choose **Recover from your phone**. 2. Confirm the QR and persistent **Copy pairing code** fallback appear without layout shift. 3. On a signed-in phone, open **Settings → Send identity to desktop**, scan or paste the recovery code, and compare the six-digit SAS on both devices. 4. Confirm on both sides and verify Desktop restores the identity and continues to harness setup. 5. Repeat from identity-lost state with **Recover from a backup file**; verify picker and drag-and-drop both advance to password entry and restore the encrypted backup. 6. Exercise cancellation, mismatched/unsupported codes, expired sessions, and an invalid backup; verify each returns actionable, non-stuck UI. ## Screenshots ### Desktop phone recovery — complete flow | Recovery entry | Pairing QR | Code match | Receiving identity | |---|---|---|---| |  |  |  |  | ### iOS Simulator — complete handoff flow | Settings entry | Recovery scanner | Manual recovery code | Code confirmation | |---|---|---|---| |  |  |  |  | ### Encrypted backup recovery — adjusted file flow | File picker | Drag-and-drop target | Password step | |---|---|---| |  |  |  | ## Verification - `cargo test -p buzz-core pairing` — 71 passed - `just mobile-test` — 1,169 passed - `pnpm build:e2e && pnpm exec playwright test identity-lost.spec.ts --project=smoke` — 15 passed - Full pre-push gates — desktop checks, desktop unit tests, Rust tests, Tauri checks, and mobile tests passed --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
338 lines
9.8 KiB
Dart
338 lines
9.8 KiB
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:buzz/features/pairing/pairing_page.dart';
|
|
import 'package:buzz/features/pairing/pairing_provider.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('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<void> pair(String rawInput) async {}
|
|
|
|
@override
|
|
void reset() {}
|
|
|
|
@override
|
|
void confirmSas() {}
|
|
|
|
@override
|
|
void denySas() {}
|
|
}
|
|
|
|
class _ConnectingPairingNotifier extends Notifier<PairingState>
|
|
implements PairingNotifier {
|
|
@override
|
|
PairingState build() => const PairingState(status: PairingStatus.connecting);
|
|
|
|
@override
|
|
Future<void> pair(String rawInput) async {}
|
|
|
|
@override
|
|
void reset() {}
|
|
|
|
@override
|
|
void confirmSas() {}
|
|
|
|
@override
|
|
void denySas() {}
|
|
}
|
|
|
|
class _RecordingPairingNotifier extends Notifier<PairingState>
|
|
implements PairingNotifier {
|
|
final pairedCodes = <String>[];
|
|
|
|
@override
|
|
PairingState build() => const PairingState();
|
|
|
|
@override
|
|
Future<void> pair(String rawInput) async => pairedCodes.add(rawInput);
|
|
|
|
@override
|
|
void reset() {}
|
|
|
|
@override
|
|
void confirmSas() {}
|
|
|
|
@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<void> pair(String rawInput) async {}
|
|
|
|
@override
|
|
void reset() {}
|
|
|
|
@override
|
|
void confirmSas() {}
|
|
|
|
@override
|
|
void denySas() {}
|
|
}
|