fix(profiles): clean up orphaned connections on Plex sign-out

close #1423
This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent 6e8320f782
commit 2025e1f9fd
10 changed files with 674 additions and 188 deletions
@@ -3,9 +3,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/models/plex/plex_home_user.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/profile_connection.dart';
import 'package:plezy/profiles/profile_connection_cleanup.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/services/plex_auth_service.dart';
import 'package:plezy/services/storage_service.dart';
@@ -26,16 +29,16 @@ JellyfinConnection _jellyfin({String machineId = 'jf-machine', String userId = '
);
}
PlexAccountConnection _plex() {
PlexAccountConnection _plex({String id = 'plex-account', String serverMachineId = 'plex-machine'}) {
return PlexAccountConnection(
id: 'plex-account',
id: id,
accountToken: 'account-token',
clientIdentifier: 'client-1',
accountLabel: 'Plex',
servers: [
PlexServer(
name: 'Plex Server',
clientIdentifier: 'plex-machine',
clientIdentifier: serverMachineId,
accessToken: 'server-token',
connections: [
PlexConnection(
@@ -56,6 +59,25 @@ PlexAccountConnection _plex() {
);
}
PlexHomeUser _homeUser(String uuid) {
return PlexHomeUser(
id: 1,
uuid: uuid,
title: 'User $uuid',
thumb: '',
hasPassword: false,
restricted: false,
updatedAt: null,
admin: false,
guest: false,
protected: false,
);
}
ProfileConnection _row(String profileId, Connection conn, {String userIdentifier = 'user'}) {
return ProfileConnection(profileId: profileId, connectionId: conn.id, userToken: 't', userIdentifier: userIdentifier);
}
void main() {
late AppDatabase db;
late ConnectionRegistry connections;
@@ -195,6 +217,143 @@ void main() {
expect(storage.getHiddenLibraries(), {'jf-machine:movies'});
});
test(
'sign-out removes the account, its virtual profiles, and their borrowed Jellyfin connection (#1423)',
() async {
const uuid = 'aaaaaaaaaaaaaaaa';
final acct = _plex();
final jf = _jellyfin();
final vProfile = plexHomeProfileId(accountConnectionId: acct.id, homeUserUuid: uuid);
await connections.upsert(acct);
await connections.upsert(jf);
await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid));
await profileConnections.upsert(_row(vProfile, jf));
await storage.savePlexHomeUsersCache(acct.id, [_homeUser(uuid).toJson()]);
await storage.markProfileUsed(vProfile, DateTime.fromMillisecondsSinceEpoch(2_000_000));
await storage.setActiveProfileId(vProfile);
await storage.saveHiddenLibraries({'jf-machine:movies'});
final removal = await removePlexAccountConnectionAndCleanup(
account: acct,
profileConnections: profileConnections,
connections: connections,
storage: storage,
);
expect(removal.removedVirtualProfileIds, {vProfile});
expect(removal.borrowerProfileIds, isEmpty);
expect(await connections.list(), isEmpty);
expect(await profileConnections.listAll(), isEmpty);
expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull);
expect(storage.getProfileLastUsed(vProfile), isNull);
expect(storage.getHiddenLibraries(), isEmpty);
},
);
test('sign-out keeps a borrowed Jellyfin connection another profile still references', () async {
const uuid = 'aaaaaaaaaaaaaaaa';
final acct = _plex();
final jf = _jellyfin();
final vProfile = plexHomeProfileId(accountConnectionId: acct.id, homeUserUuid: uuid);
await connections.upsert(acct);
await connections.upsert(jf);
await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid));
await profileConnections.upsert(_row(vProfile, jf));
await profileConnections.upsert(_row('local-1', jf));
await removePlexAccountConnectionAndCleanup(
account: acct,
profileConnections: profileConnections,
connections: connections,
storage: storage,
);
expect(await connections.get(jf.id), isNotNull);
final remaining = await profileConnections.listAll();
expect(remaining, hasLength(1));
expect(remaining.single.profileId, 'local-1');
});
test(
'sign-out from a local borrower keeps the local profile and its Jellyfin connection (repro 2 shape)',
() async {
final acct = _plex();
final jf = _jellyfin();
await connections.upsert(acct);
await connections.upsert(jf);
await profileConnections.upsert(_row('local-1', acct));
await profileConnections.upsert(_row('local-1', jf));
final removal = await removePlexAccountConnectionAndCleanup(
account: acct,
profileConnections: profileConnections,
connections: connections,
storage: storage,
);
expect(removal.removedVirtualProfileIds, isEmpty);
expect(removal.borrowerProfileIds, {'local-1'});
expect(await connections.get(acct.id), isNull);
expect(await connections.get(jf.id), isNotNull);
final remaining = await profileConnections.listAll();
expect(remaining, hasLength(1));
expect(remaining.single.connectionId, jf.id);
},
);
test('sign-out leaves another account and its virtual profiles untouched (hyphen-bearing ids)', () async {
const uuid1 = 'aaaaaaaaaaaaaaaa';
const uuid2 = 'bbbbbbbbbbbbbbbb';
final acct1 = _plex();
final acct2 = _plex(id: 'plex-account-2', serverMachineId: 'plex-machine-2');
final v1 = plexHomeProfileId(accountConnectionId: acct1.id, homeUserUuid: uuid1);
final v2 = plexHomeProfileId(accountConnectionId: acct2.id, homeUserUuid: uuid2);
await connections.upsert(acct1);
await connections.upsert(acct2);
await profileConnections.upsert(_row(v1, acct1, userIdentifier: uuid1));
await profileConnections.upsert(_row(v2, acct2, userIdentifier: uuid2));
await storage.savePlexHomeUsersCache(acct2.id, [_homeUser(uuid2).toJson()]);
final removal = await removePlexAccountConnectionAndCleanup(
account: acct1,
profileConnections: profileConnections,
connections: connections,
storage: storage,
);
expect(removal.removedVirtualProfileIds, {v1});
expect(await connections.get(acct2.id), isNotNull);
final remaining = await profileConnections.listAll();
expect(remaining, hasLength(1));
expect(remaining.single.profileId, v2);
expect(storage.getPlexHomeUsersCacheJson(acct2.id), isNotNull);
});
test('sign-out is idempotent', () async {
const uuid = 'aaaaaaaaaaaaaaaa';
final acct = _plex();
final jf = _jellyfin();
final vProfile = plexHomeProfileId(accountConnectionId: acct.id, homeUserUuid: uuid);
await connections.upsert(acct);
await connections.upsert(jf);
await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid));
await profileConnections.upsert(_row(vProfile, jf));
Future<PlexAccountRemoval> run() => removePlexAccountConnectionAndCleanup(
account: acct,
profileConnections: profileConnections,
connections: connections,
storage: storage,
);
await run();
final second = await run();
expect(second.removedVirtualProfileIds, isEmpty);
expect(await connections.list(), isEmpty);
expect(await profileConnections.listAll(), isEmpty);
});
test('Plex profile unlink clears only that profile because Plex Home access can be implicit', () async {
final conn = _plex();
await connections.upsert(conn);
@@ -223,4 +382,84 @@ void main() {
expect(storage.getHiddenLibraries(), {'plex-machine:movies'});
});
});
group('resolvePostRemovalState', () {
late ProfileRegistry profileRegistry;
setUp(() {
profileRegistry = ProfileRegistry(db);
});
Future<({PostRemovalRoute route, List<Profile> profiles})> resolve({
Map<String, List<PlexHomeUser>> plexHomeUsers = const {},
}) {
return resolvePostRemovalState(
profileRegistry: profileRegistry,
profileConnections: profileConnections,
connections: connections,
plexHomeUsers: plexHomeUsers,
storage: storage,
);
}
Profile local(String id) =>
Profile.local(id: id, displayName: id, createdAt: DateTime.fromMillisecondsSinceEpoch(1_000_000));
test('no connections → signed out', () async {
final result = await resolve();
expect(result.route, PostRemovalRoute.signedOut);
expect(result.profiles, isEmpty);
});
test('only an orphaned Jellyfin connection → pruned, signed out (the #1423 wedge)', () async {
final jf = _jellyfin();
await connections.upsert(jf);
final result = await resolve();
expect(result.route, PostRemovalRoute.signedOut);
expect(await connections.list(), isEmpty);
});
test('account with cached home users → stay signed in with the virtual profiles', () async {
final acct = _plex();
await connections.upsert(acct);
final result = await resolve(
plexHomeUsers: {
acct.id: [_homeUser('aaaaaaaaaaaaaaaa')],
},
);
expect(result.route, PostRemovalRoute.staySignedIn);
expect(result.profiles.single.isPlexHome, isTrue);
});
test('account but no resolvable home users and no locals → signed out (boot-guard mirror)', () async {
final acct = _plex();
await connections.upsert(acct);
// Referenced so the prune keeps nothing to do; still unresolvable.
await profileConnections.upsert(_row('plex-home-${acct.id}-aaaaaaaaaaaaaaaa', acct));
final result = await resolve();
expect(result.route, PostRemovalRoute.signedOut);
});
test('local profile survives alongside an orphaned Jellyfin connection → stay signed in, orphan pruned', () async {
final jf = _jellyfin();
final referencedJf = _jellyfin(userId: 'user-b');
await connections.upsert(jf);
await connections.upsert(referencedJf);
await profileRegistry.upsert(local('local-1'));
await profileConnections.upsert(_row('local-1', referencedJf));
final result = await resolve();
expect(result.route, PostRemovalRoute.staySignedIn);
expect(result.profiles.single.id, 'local-1');
expect(await connections.get(jf.id), isNull);
expect(await connections.get(referencedJf.id), isNotNull);
});
});
}