diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 1d03cc08d..6dc11cd08 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -25,6 +25,7 @@ import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/profile_switching_overlay.dart'; import '../libraries/state_messages.dart'; import 'add_local_profile_screen.dart'; +import 'pin_entry_dialog.dart'; import 'profile_teardown.dart'; import 'profile_detail_screen.dart'; @@ -261,7 +262,29 @@ class _ProfileSwitchScreenState extends State with MountedS ); } + /// Gate Manage/Delete on a non-active, PIN-protected local profile behind + /// its PIN: without this the picker menu bypasses the lock entirely + /// ([ProfileDetailScreen] exposes rename, PIN removal, and connection + /// edits). The active profile already proved its PIN at switch time, and + /// Plex Home profiles keep their server-side PIN flow. Loops on wrong + /// entries with the same shake-on-error pattern as activation — see + /// [showPinEntryDialog]. + Future _verifyPinForProfileAction(Profile profile) async { + if (!profile.isLocal || !profile.isPinProtected) return true; + if (profile.id == context.read().activeId) return true; + String? errorMessage; + while (true) { + if (!mounted) return false; + final pin = await showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); + if (!mounted || pin == null) return false; + if (verifyProfilePin(profile, pin)) return true; + errorMessage = t.profiles.incorrectPinTryAgain; + } + } + Future _manageProfile(Profile profile) async { + if (!await _verifyPinForProfileAction(profile)) return; + if (!mounted) return; await Navigator.of(context).push(MaterialPageRoute(builder: (_) => ProfileDetailScreen(profile: profile))); } @@ -277,6 +300,8 @@ class _ProfileSwitchScreenState extends State with MountedS } Future _deleteProfile(Profile profile) async { + if (!await _verifyPinForProfileAction(profile)) return; + if (!mounted) return; await confirmAndDeleteProfile( context, profile: profile, diff --git a/test/screens/profile/profile_switch_screen_test.dart b/test/screens/profile/profile_switch_screen_test.dart index 62ea8e751..ca61c9836 100644 --- a/test/screens/profile/profile_switch_screen_test.dart +++ b/test/screens/profile/profile_switch_screen_test.dart @@ -18,6 +18,8 @@ import 'package:plezy/profiles/profile_avatar.dart'; import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/screens/profile/pin_entry_dialog.dart'; +import 'package:plezy/screens/profile/profile_detail_screen.dart'; import 'package:plezy/screens/profile/profile_switch_screen.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -479,6 +481,153 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); }); + + testWidgets('Manage on a PIN-protected non-active local profile requires its PIN', (tester) async { + await _pumpPicker( + tester, + profiles: [ + Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + Profile.local( + id: 'local-kids', + displayName: 'Kids', + pinHash: computePinHash('1234'), + createdAt: DateTime(2026, 1, 2), + ), + ], + connections: const [], + activeProfileId: 'local-owner', + ); + + // Bounded pumps whenever the PIN dialog is open: its autofocused field's + // cursor blink keeps scheduling frames, so pumpAndSettle never settles. + await _openTileMenu(tester, 'Kids'); + await tester.tap(find.text(t.profiles.manage)); + await _pumpBounded(tester); + + expect(find.byType(PinEntryDialog), findsOneWidget, reason: 'Manage on a protected profile must ask for its PIN'); + expect(find.byType(ProfileDetailScreen), findsNothing); + + // A wrong PIN re-prompts with the retry error and never navigates. + await tester.enterText(find.byType(TextField), '9999'); + await _pumpBounded(tester); + + expect(find.byType(PinEntryDialog), findsOneWidget); + expect(find.text(t.profiles.incorrectPinTryAgain), findsOneWidget); + expect(find.byType(ProfileDetailScreen), findsNothing); + + // Backing out of the retry leaves the picker unnavigated. + await tester.tap(find.text(t.common.cancel)); + await _pumpBounded(tester); + + expect(find.byType(PinEntryDialog), findsNothing); + expect(find.byType(ProfileDetailScreen), findsNothing); + + // The right PIN proceeds to the detail screen. Bounded pumps only from + // here: the mounted detail screen never settles (indeterminate chrome), + // so pumpAndSettle would run into the test timeout. + await _openTileMenu(tester, 'Kids'); + await tester.tap(find.text(t.profiles.manage)); + await _pumpBounded(tester); + await tester.enterText(find.byType(TextField), '1234'); + await _pumpBounded(tester); + + expect(find.byType(ProfileDetailScreen), findsOneWidget); + + // Pop the detail route before the shrink teardown so its subtree disposes + // inside a live navigator rather than during a whole-tree unmount. + tester.state(find.byType(Navigator).first).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + await tester.pumpWidget(const SizedBox.shrink()); + // A duration pump: the detail screen's Drift stream builders schedule a + // zero-duration close timer on unmount, and a bare pump does not elapse + // fake time, so the timer would still be pending at the end-of-test check. + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgets('Delete on a PIN-protected non-active local profile requires its PIN', (tester) async { + await _pumpPicker( + tester, + profiles: [ + Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + Profile.local( + id: 'local-kids', + displayName: 'Kids', + pinHash: computePinHash('1234'), + createdAt: DateTime(2026, 1, 2), + ), + ], + connections: const [], + activeProfileId: 'local-owner', + ); + + // Bounded pumps while the PIN dialog is open — see the manage test. + await _openTileMenu(tester, 'Kids'); + await tester.tap(find.text(t.profiles.delete)); + await _pumpBounded(tester); + + expect(find.byType(PinEntryDialog), findsOneWidget, reason: 'Delete on a protected profile must ask for its PIN'); + expect(find.text(t.profiles.deleteThisProfileTitle), findsNothing); + + // Cancelling the PIN prompt blocks the delete confirmation entirely. + await tester.tap(find.text(t.common.cancel)); + await _pumpBounded(tester); + + expect(find.byType(PinEntryDialog), findsNothing); + expect(find.text(t.profiles.deleteThisProfileTitle), findsNothing); + + // The right PIN reaches the confirmation; close it without deleting. + await _openTileMenu(tester, 'Kids'); + await tester.tap(find.text(t.profiles.delete)); + await _pumpBounded(tester); + await tester.enterText(find.byType(TextField), '1234'); + await _pumpBounded(tester); + + expect(find.text(t.profiles.deleteThisProfileTitle), findsOneWidget); + + await tester.tap(find.text(t.common.cancel)); + await _pumpBounded(tester); + expect(find.text('Kids'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgets('Manage on an unprotected profile opens the detail screen without a PIN prompt', (tester) async { + await _pumpPicker( + tester, + profiles: [Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))], + connections: const [], + ); + + await _openTileMenu(tester, 'Owner'); + await tester.tap(find.text(t.profiles.manage)); + // Bounded pumps only: the mounted detail screen never settles. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(PinEntryDialog), findsNothing); + expect(find.byType(ProfileDetailScreen), findsOneWidget); + + tester.state(find.byType(Navigator).first).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 100)); + }); +} + +/// Three bounded pumps covering a dialog/route transition without requiring +/// quiescence: a focused text field's cursor blink and a mounted +/// [ProfileDetailScreen] both keep scheduling work, so [WidgetTester +/// .pumpAndSettle] would hang until the test timeout. +Future _pumpBounded(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); } /// Boots the picker over a fixed profile/connection set. [homeUsers] seeds the @@ -490,12 +639,16 @@ Future _pumpPicker( required List connections, List profileConnections = const [], List homeUsers = const [], + String? activeProfileId, }) async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final profileRegistry = _FakeProfileRegistry(db, profiles); final connectionRegistry = _FakeConnectionRegistry(db, connections); final profileConnectionRegistry = _FakeProfileConnectionRegistry(db, profileConnections); final storage = await StorageService.getInstance(); + if (activeProfileId != null) { + await storage.setActiveProfileId(activeProfileId); + } final plexHome = _NoTimerPlexHomeService( connections: connectionRegistry, profileConnections: profileConnectionRegistry, @@ -569,6 +722,12 @@ Finder _tileFor(String displayName) { return find.ancestor(of: find.text(displayName), matching: find.byType(FocusableWrapper)); } +/// Opens the actions menu on the tile named [name] via its trailing button. +Future _openTileMenu(WidgetTester tester, String name) async { + await tester.tap(find.descendant(of: _tileFor(name), matching: find.byTooltip(t.profiles.manage))); + await tester.pumpAndSettle(); +} + /// The single chip label inside [tile] that names both [first] and [second]. /// /// Asserting that exactly one text node carries both halves is the point: two