fix(profiles): require the target profile's PIN before managing or deleting it from the picker

Long-pressing a PIN-protected local profile in the picker offered Manage and Delete with no verification, so any user could open its detail screen, clear its PIN, or delete it outright. Both actions now verify the target's PIN first (active profile and Plex Home profiles keep their existing flows).
This commit is contained in:
edde746
2026-08-21 19:23:44 +02:00
parent 5591a0094d
commit ed1d0f1b10
2 changed files with 184 additions and 0 deletions
@@ -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<ProfileSwitchScreen> 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<bool> _verifyPinForProfileAction(Profile profile) async {
if (!profile.isLocal || !profile.isPinProtected) return true;
if (profile.id == context.read<ActiveProfileProvider>().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<void> _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<ProfileSwitchScreen> with MountedS
}
Future<void> _deleteProfile(Profile profile) async {
if (!await _verifyPinForProfileAction(profile)) return;
if (!mounted) return;
await confirmAndDeleteProfile(
context,
profile: profile,
@@ -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<NavigatorState>(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<NavigatorState>(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<void> _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<void> _pumpPicker(
required List<Connection> connections,
List<ProfileConnection> profileConnections = const [],
List<PlexHomeUser> 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<void> _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