Files
plezy/test/database/tvos_database_recovery_test.dart
T

1096 lines
47 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:drift/drift.dart' show OrderingTerm, Value;
import 'package:drift/native.dart';
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/database/download_operations.dart';
import 'package:plezy/database/tvos_database_recovery_store.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/profiles/profile.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/services/base_shared_preferences_service.dart';
import 'package:plezy/services/credential_vault.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../test_helpers/prefs.dart';
JellyfinConnection _connection(String id) => JellyfinConnection(
id: id,
baseUrl: 'https://media.invalid/$id',
serverName: 'Server $id',
serverMachineId: id,
userId: 'user-$id',
userName: 'User $id',
accessToken: 'protected-connection-canary-$id',
deviceId: 'device-$id',
createdAt: DateTime.fromMillisecondsSinceEpoch(1000),
);
Profile _profile(String id) => Profile.local(
id: id,
displayName: 'Profile $id',
pinHash: computePinHash('1234'),
createdAt: DateTime.fromMillisecondsSinceEpoch(2000),
);
final class _FailingStringPreferencesPlatform extends SharedPreferencesAsyncPlatform {
_FailingStringPreferencesPlatform(this.delegate);
final SharedPreferencesAsyncPlatform delegate;
String? failNextKey;
final Map<String, int> stringWriteAttempts = {};
Future<String?> persistedString(String key) => delegate.getString(key, const SharedPreferencesOptions());
@override
Future<void> setString(String key, String value, SharedPreferencesOptions options) async {
stringWriteAttempts[key] = (stringWriteAttempts[key] ?? 0) + 1;
if (key == failNextKey) {
failNextKey = null;
throw StateError('injected string persistence failure');
}
await delegate.setString(key, value, options);
}
@override
Future<void> setInt(String key, int value, SharedPreferencesOptions options) => delegate.setInt(key, value, options);
@override
Future<void> setBool(String key, bool value, SharedPreferencesOptions options) =>
delegate.setBool(key, value, options);
@override
Future<void> setDouble(String key, double value, SharedPreferencesOptions options) =>
delegate.setDouble(key, value, options);
@override
Future<void> setStringList(String key, List<String> value, SharedPreferencesOptions options) =>
delegate.setStringList(key, value, options);
@override
Future<String?> getString(String key, SharedPreferencesOptions options) => delegate.getString(key, options);
@override
Future<bool?> getBool(String key, SharedPreferencesOptions options) => delegate.getBool(key, options);
@override
Future<double?> getDouble(String key, SharedPreferencesOptions options) => delegate.getDouble(key, options);
@override
Future<int?> getInt(String key, SharedPreferencesOptions options) => delegate.getInt(key, options);
@override
Future<List<String>?> getStringList(String key, SharedPreferencesOptions options) =>
delegate.getStringList(key, options);
@override
Future<void> clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.clear(parameters, options);
@override
Future<Map<String, Object>> getPreferences(GetPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.getPreferences(parameters, options);
@override
Future<Set<String>> getKeys(GetPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.getKeys(parameters, options);
}
Future<void> _deleteDatabase(File file) async {
for (final path in [file.path, '${file.path}-wal', '${file.path}-shm']) {
final candidate = File(path);
if (await candidate.exists()) await candidate.delete();
}
}
Future<List<List<Object>>> _criticalRows(AppDatabase db) async => [
await (db.select(db.connections)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(),
await (db.select(db.profiles)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(),
await (db.select(
db.profileConnections,
)..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get(),
await (db.select(db.offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(),
];
Future<void> _seedCriticalRows(AppDatabase db) async {
await ConnectionRegistry(db).upsert(_connection('server-1'));
await ProfileRegistry(db).upsert(_profile('local-1'));
await ProfileConnectionRegistry(db).upsert(
const ProfileConnection(
profileId: 'local-1',
connectionId: 'server-1',
userToken: 'protected-profile-token-canary',
userIdentifier: 'user-1',
),
);
await db.upsertProgressAction(
profileId: 'local-1',
serverId: ServerId('server-1'),
clientScopeId: 'server-1/user-1',
ratingKey: 'progress',
viewOffset: 1234,
duration: 9999,
shouldMarkWatched: false,
);
await db.insertWatchAction(
profileId: 'local-1',
serverId: ServerId('server-1'),
clientScopeId: 'server-1/user-1',
ratingKey: 'watched',
actionType: OfflineActionType.watched.id,
);
await db.insertWatchAction(
profileId: 'local-1',
serverId: ServerId('server-1'),
clientScopeId: 'server-1/user-1',
ratingKey: 'unwatched',
actionType: OfflineActionType.unwatched.id,
);
final rows = await db.getPendingWatchActions(profileId: 'local-1');
for (var index = 0; index < rows.length; index++) {
await (db.update(db.offlineWatchProgress)..where((t) => t.id.equals(rows[index].id))).write(
OfflineWatchProgressCompanion(createdAt: Value(3000 + index), updatedAt: Value(4000 + index)),
);
}
await db.updateSyncAttempt(rows.first.id, 'retry-without-protected-payload');
await db.updateSyncAttempt(rows.first.id, 'retry-without-protected-payload');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
late File databaseFile;
late SharedPreferencesWithCache prefs;
AppDatabase? database;
Future<AppDatabaseBootstrap> open({
bool isTvos = true,
TvosDatabaseRecoveryStore? store,
TvosDatabaseRecoveryPriorInstallEvidence? priorEvidence,
}) async {
final result = await AppDatabase.open(
isTvos: isTvos,
databaseFile: databaseFile,
preferences: prefs,
recoveryStore: store,
priorInstallEvidence: priorEvidence,
);
database = result.database;
return result;
}
Future<void> closeAndDelete() async {
await database?.close();
database = null;
await _deleteDatabase(databaseFile);
}
setUp(() async {
resetSharedPreferencesForTest();
tempDir = await Directory.systemTemp.createTemp('plezy_tvos_recovery_');
databaseFile = File('${tempDir.path}/plezy_downloads.db');
prefs = await BaseSharedPreferencesService.sharedCache();
});
tearDown(() async {
await database?.close();
await tempDir.delete(recursive: true);
});
test('restores exact four critical groups and excludes reconstructible tables', () async {
final first = await open();
expect(first.recoveryOutcome, TvosDatabaseRecoveryOutcome.fresh);
await _seedCriticalRows(first.database);
await first.database.into(first.database.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'cache', data: '{}'));
await first.database
.into(first.database.downloadedMedia)
.insert(
DownloadedMediaCompanion.insert(
serverId: 'server-1',
ratingKey: 'download',
globalKey: 'server-1:download',
type: 'movie',
status: DownloadStatus.completed.index,
),
);
await first.database.addDownloadOwner(profileId: 'local-1', globalKey: 'server-1:download');
await first.database
.into(first.database.downloadQueue)
.insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'server-1:download', addedAt: 5000));
await first.database.insertSyncRule(
profileId: 'local-1',
serverId: ServerId('server-1'),
ratingKey: 'rule',
globalKey: 'server-1:rule',
targetType: 'movie',
episodeCount: 1,
);
final expected = await _criticalRows(first.database);
await closeAndDelete();
final restored = await open();
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await _criticalRows(restored.database), expected);
expect(await restored.database.select(restored.database.apiCache).get(), isEmpty);
expect(await restored.database.select(restored.database.downloadedMedia).get(), isEmpty);
expect(await restored.database.select(restored.database.downloadOwners).get(), isEmpty);
expect(await restored.database.select(restored.database.downloadQueue).get(), isEmpty);
expect(await restored.database.select(restored.database.syncRules).get(), isEmpty);
});
test('legacy plaintext committed image is restored and replaced with protected credentials', () async {
CredentialVault.resetKeyForTesting();
const connectionToken = 'legacy-plaintext-connection-token';
const profileToken = 'legacy-plaintext-profile-token';
final legacyConfig = jsonEncode({
'baseUrl': 'https://legacy.invalid',
'accessToken': connectionToken,
'userId': 'legacy-user',
});
final identityPayload = jsonEncode({
'version': TvosDatabaseRecoveryStore.recoveryFormatVersion,
'rows': {
'connections': [
{
'id': 'legacy-jellyfin',
'kind': 'jellyfin',
'displayName': 'Legacy Jellyfin',
'configJson': legacyConfig,
'isDefault': true,
'createdAt': 1000,
'lastAuthenticatedAt': null,
},
],
'profiles': [
{
'id': 'legacy-profile',
'kind': 'local',
'displayName': 'Legacy Profile',
'avatarThumbUrl': null,
'configJson': '{}',
'sortOrder': 0,
'createdAt': 2000,
'lastUsedAt': null,
},
],
'profileConnections': [
{
'profileId': 'legacy-profile',
'connectionId': 'legacy-jellyfin',
'userToken': profileToken,
'userIdentifier': 'legacy-user',
'isDefault': true,
'tokenAcquiredAt': null,
'lastUsedAt': null,
},
],
},
});
final pendingPayload = jsonEncode({
'version': TvosDatabaseRecoveryStore.recoveryFormatVersion,
'rows': {'offlineWatchProgress': <Object?>[]},
});
final manifest = jsonEncode({
'version': TvosDatabaseRecoveryStore.recoveryFormatVersion,
'state': 'committed',
'identityDigest': sha256.convert(utf8.encode(identityPayload)).toString(),
'pendingDigest': sha256.convert(utf8.encode(pendingPayload)).toString(),
});
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identityPayload);
await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pendingPayload);
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest);
final restored = await open();
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
final connectionRow = await restored.database.select(restored.database.connections).getSingle();
final restoredConfig = jsonDecode(connectionRow.configJson) as Map<String, dynamic>;
final protectedConnectionToken = restoredConfig['accessToken'] as String;
expect(CredentialVault.isProtected(protectedConnectionToken), isTrue);
expect(await CredentialVault.reveal(protectedConnectionToken), connectionToken);
final joinRow = await restored.database.select(restored.database.profileConnections).getSingle();
expect(CredentialVault.isProtected(joinRow.userToken), isTrue);
expect(await CredentialVault.reveal(joinRow.userToken), profileToken);
final replacementIdentity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!;
expect(replacementIdentity, isNot(contains(connectionToken)));
expect(replacementIdentity, isNot(contains(profileToken)));
final replacementRows = (jsonDecode(replacementIdentity) as Map<String, dynamic>)['rows'] as Map<String, dynamic>;
final replacementConnection = (replacementRows['connections'] as List).single as Map<String, dynamic>;
final replacementConfig = jsonDecode(replacementConnection['configJson'] as String) as Map<String, dynamic>;
expect(await CredentialVault.reveal(replacementConfig['accessToken'] as String), connectionToken);
final replacementJoin = (replacementRows['profileConnections'] as List).single as Map<String, dynamic>;
expect(await CredentialVault.reveal(replacementJoin['userToken'] as String), profileToken);
final replacementManifest =
jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
expect(replacementManifest['state'], 'committed');
expect(replacementManifest['identityDigest'], sha256.convert(utf8.encode(replacementIdentity)).toString());
});
test('rejects unreleased generation-bearing recovery payloads', () async {
final first = await open();
await ProfileRegistry(first.database).upsert(_profile('unknown-generation'));
await first.database.close();
database = null;
final identity = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.identityKey)!) as Map<String, dynamic>;
final pending = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!) as Map<String, dynamic>;
final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
identity['generation'] = 7;
pending['generation'] = 11;
final legacyIdentity = jsonEncode(identity);
final legacyPending = jsonEncode(pending);
manifest
..['identityGeneration'] = 7
..['identityDigest'] = sha256.convert(utf8.encode(legacyIdentity)).toString()
..['pendingGeneration'] = 11
..['pendingDigest'] = sha256.convert(utf8.encode(legacyPending)).toString();
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, legacyIdentity);
await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, legacyPending);
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest));
await _deleteDatabase(databaseFile);
final rejected = await open();
expect(rejected.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect(await _criticalRows(rejected.database), everyElement(isEmpty));
});
for (final failurePhase in ['mutation', 'row read']) {
test('$failurePhase failure does not poison later durable mutations', () async {
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true);
final first = await open(store: store);
final originalError = StateError('injected $failurePhase failure');
await expectLater(
store.runDurableMutation<void>(
group: TvosDatabaseRecoveryGroup.identity,
mutation: () async {
if (failurePhase == 'mutation') throw originalError;
},
readIdentity: () async {
if (failurePhase == 'row read') throw originalError;
return const {'connections': <Object?>[], 'profiles': <Object?>[], 'profileConnections': <Object?>[]};
},
readPending: () async => const {'offlineWatchProgress': <Object?>[]},
),
throwsA(same(originalError)),
);
await ProfileRegistry(first.database).upsert(_profile('after-$failurePhase'));
expect(await ProfileRegistry(first.database).get('after-$failurePhase'), isNotNull);
});
}
test('failed manifest invalidation reloads durable state and retries before mutation', () async {
await database?.close();
database = null;
resetSharedPreferencesForTest();
final preferencesPlatform = _FailingStringPreferencesPlatform(SharedPreferencesAsyncPlatform.instance!);
SharedPreferencesAsyncPlatform.instance = preferencesPlatform;
addTearDown(() => SharedPreferencesAsyncPlatform.instance = preferencesPlatform.delegate);
prefs = await BaseSharedPreferencesService.sharedCache();
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true);
await open(store: store);
final durableManifest = await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey);
expect(durableManifest, contains('committed'));
var mutationCount = 0;
Future<void> mutate() => store.runDurableMutation<void>(
group: TvosDatabaseRecoveryGroup.identity,
mutation: () async {
mutationCount++;
},
readIdentity: () async => const {
'connections': <Object?>[],
'profiles': <Object?>[],
'profileConnections': <Object?>[],
},
readPending: () async => const {'offlineWatchProgress': <Object?>[]},
);
preferencesPlatform.failNextKey = TvosDatabaseRecoveryStore.manifestKey;
await expectLater(mutate(), throwsA(isA<TvosDatabaseDurabilityException>()));
expect(mutationCount, 0, reason: 'the authoritative mutation must wait for durable invalidation');
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), durableManifest);
expect(await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey), durableManifest);
final attemptsAfterFailure = preferencesPlatform.stringWriteAttempts[TvosDatabaseRecoveryStore.manifestKey]!;
await mutate();
expect(mutationCount, 1);
expect(
preferencesPlatform.stringWriteAttempts[TvosDatabaseRecoveryStore.manifestKey],
greaterThan(attemptsAfterFailure),
);
expect(await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
});
test('unrelated preferences do not consume the recovery image budget', () async {
final first = await open();
await ProfileRegistry(first.database).upsert(_profile('survives-large-settings'));
await first.database.close();
database = null;
await prefs.setString('unrelated_large_setting', List.filled(500000, 'x').join());
await _deleteDatabase(databaseFile);
final restored = await open();
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await ProfileRegistry(restored.database).get('survives-large-settings'), isNotNull);
});
group('startup classification', () {
test('non-tvOS is notApplicable and writes no recovery keys', () async {
final result = await open(isTvos: false);
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.notApplicable);
expect(prefs.keys.where((key) => key.startsWith(TvosDatabaseRecoveryStore.keyPrefix)), isEmpty);
});
test('missing database without marker or evidence is fresh', () async {
final result = await open();
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.fresh);
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
});
test('missing database with prior-install evidence requires recovery', () async {
await prefs.setString('active_app_profile_id', 'surviving-profile');
final result = await open();
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty);
});
test('recovery-required marker gates a materialized empty database until acknowledgement', () async {
await prefs.setString('active_app_profile_id', 'surviving-profile');
final missing = await open();
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
await missing.database.close();
database = null;
final restarted = await open();
expect(restarted.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
await expectLater(
ProfileRegistry(restarted.database).upsert(_profile('blocked-after-restart')),
throwsA(isA<TvosDatabaseDurabilityException>()),
);
expect(await ProfileRegistry(restarted.database).get('blocked-after-restart'), isNull);
await restarted.database.acknowledgeTvosDatabaseRecoveryRequired();
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull);
await ProfileRegistry(restarted.database).upsert(_profile('acknowledged'));
await closeAndDelete();
final restored = await open();
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await ProfileRegistry(restored.database).get('acknowledged'), isNotNull);
});
test('successful restore clears recovery-required gate on a materialized candidate', () async {
final first = await open();
await ProfileRegistry(first.database).upsert(_profile('restored-after-restart'));
await first.database.close();
database = null;
final identity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!;
final pending = prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!;
final manifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!;
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, '$identity-corrupt');
await _deleteDatabase(databaseFile);
final missing = await open();
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
expect(await ProfileRegistry(missing.database).get('restored-after-restart'), isNull);
await missing.database.close();
database = null;
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identity);
await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pending);
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest);
final restarted = await open();
expect(restarted.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull);
expect(await ProfileRegistry(restarted.database).get('restored-after-restart'), isNotNull);
});
test('failed marker removal replays a successful restore idempotently on restart', () async {
final first = await open();
await _seedCriticalRows(first.database);
final expected = await _criticalRows(first.database);
await first.database.close();
database = null;
final identity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!;
final pending = prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!;
final manifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!;
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, '$identity-corrupt');
await _deleteDatabase(databaseFile);
final missing = await open();
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
await missing.database.close();
database = null;
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identity);
await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pending);
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest);
var markerRemovalAttempts = 0;
final failingStore = TvosDatabaseRecoveryStore(
prefs,
isTvos: true,
debugBeforePreferenceWrite: (key) async {
if (key == TvosDatabaseRecoveryStore.recoveryRequiredKey) {
markerRemovalAttempts++;
throw StateError('marker removal failed');
}
},
);
final restoredButUnmarked = await open(store: failingStore);
expect(restoredButUnmarked.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect(markerRemovalAttempts, 1);
expect(await _criticalRows(restoredButUnmarked.database), expected);
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
await restoredButUnmarked.database.close();
database = null;
final replayed = await open();
expect(replayed.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await _criticalRows(replayed.database), expected);
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull);
await replayed.database.close();
database = null;
final converged = await open();
expect(converged.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
expect(await _criticalRows(converged.database), expected);
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull);
});
test('existing database without marker is adopted', () async {
final raw = AppDatabase.forTesting(NativeDatabase(databaseFile));
await raw.select(raw.connections).get();
await raw.close();
final result = await open();
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
});
test('existing readable database is adopted when its recovery image cannot be serialized', () async {
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true);
final outcome = await store.reconcile(
databaseExisted: true,
readIdentity: () async => {
'connections': [
{'unsupported': Object()},
],
'profiles': <Object?>[],
'profileConnections': <Object?>[],
},
readPending: () async => const {'offlineWatchProgress': <Object?>[]},
restore: (_) async => fail('an existing database must not be restored'),
hasPriorInstallEvidence: () async => false,
);
expect(outcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
var mutationRan = false;
await store.runDurableMutation<void>(
group: TvosDatabaseRecoveryGroup.identity,
mutation: () async => mutationRan = true,
readIdentity: () async => throw StateError('recovery was not disabled'),
readPending: () async => throw StateError('recovery was not disabled'),
);
expect(mutationRan, isTrue);
});
for (final failingGroup in ['identity', 'pending']) {
test('existing database $failingGroup row reader failure propagates', () async {
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true);
final readError = StateError('$failingGroup database read failed');
await expectLater(
store.reconcile(
databaseExisted: true,
readIdentity: () async {
if (failingGroup == 'identity') throw readError;
return const {'connections': <Object?>[], 'profiles': <Object?>[], 'profileConnections': <Object?>[]};
},
readPending: () async {
if (failingGroup == 'pending') throw readError;
return const {'offlineWatchProgress': <Object?>[]};
},
restore: (_) async => fail('an existing database must not be restored'),
hasPriorInstallEvidence: () async => false,
),
throwsA(same(readError)),
);
expect(prefs.keys.where((key) => key.startsWith(TvosDatabaseRecoveryStore.keyPrefix)), isEmpty);
});
}
test('existing database remains authoritative when recovery preferences cannot be written', () async {
final raw = AppDatabase.forTesting(NativeDatabase(databaseFile));
await raw.select(raw.connections).get();
await raw.close();
final store = TvosDatabaseRecoveryStore(
prefs,
isTvos: true,
debugBeforePreferenceWrite: (_) async => throw StateError('write failed'),
);
final result = await open(store: store);
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
await ProfileRegistry(result.database).upsert(_profile('database-remains-authoritative'));
expect(await ProfileRegistry(result.database).get('database-remains-authoritative'), isNotNull);
});
test('existing database remains authoritative when recovery image exceeds its budget', () async {
final raw = AppDatabase.forTesting(NativeDatabase(databaseFile));
await raw.select(raw.connections).get();
await raw.close();
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1);
final result = await open(store: store);
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
await ProfileRegistry(result.database).upsert(_profile('database-survives-recovery-budget'));
expect(await ProfileRegistry(result.database).get('database-survives-recovery-budget'), isNotNull);
});
test('startup invalidation failure blocks identity mutation until durable retry', () async {
final first = await open();
await ProfileRegistry(first.database).upsert(_profile('stale-image'));
final staleManifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!;
await first.database.close();
database = null;
var failManifestWrites = true;
var failedInvalidations = 0;
final store = TvosDatabaseRecoveryStore(
prefs,
isTvos: true,
debugBeforePreferenceWrite: (key) async {
if (failManifestWrites && key == TvosDatabaseRecoveryStore.manifestKey) {
failedInvalidations++;
throw StateError('manifest invalidation failed');
}
},
);
final existing = await open(store: store);
expect(existing.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), staleManifest);
await expectLater(
ProfileRegistry(existing.database).upsert(_profile('blocked-by-stale-image')),
throwsA(isA<TvosDatabaseDurabilityException>()),
);
expect(failedInvalidations, 2);
expect(await ProfileRegistry(existing.database).get('blocked-by-stale-image'), isNull);
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), staleManifest);
await existing.database.close();
database = null;
final probeFile = File('${tempDir.path}/stale-image-probe.db');
final staleRestore = await AppDatabase.open(isTvos: true, databaseFile: probeFile, preferences: prefs);
expect(staleRestore.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await ProfileRegistry(staleRestore.database).get('stale-image'), isNotNull);
expect(await ProfileRegistry(staleRestore.database).get('blocked-by-stale-image'), isNull);
await staleRestore.database.close();
await _deleteDatabase(probeFile);
failManifestWrites = false;
final retried = await open(store: store);
expect(retried.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
await ProfileRegistry(retried.database).upsert(_profile('committed-after-retry'));
expect(await ProfileRegistry(retried.database).get('committed-after-retry'), isNotNull);
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), isNot(staleManifest));
await retried.database.close();
database = null;
await _deleteDatabase(databaseFile);
final recovered = await open();
expect(recovered.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await ProfileRegistry(recovered.database).get('stale-image'), isNotNull);
expect(await ProfileRegistry(recovered.database).get('committed-after-retry'), isNotNull);
expect(await ProfileRegistry(recovered.database).get('blocked-by-stale-image'), isNull);
});
test('existing database repairs an interrupted manifest authoritatively', () async {
final first = await open();
await ProfileRegistry(first.database).upsert(_profile('authoritative'));
final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
manifest['state'] = 'invalidated';
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest));
await first.database.close();
database = null;
final repaired = await open();
expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
expect(await ProfileRegistry(repaired.database).get('authoritative'), isNotNull);
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
await closeAndDelete();
final restored = await open();
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await ProfileRegistry(restored.database).get('authoritative'), isNotNull);
});
});
group('invalid missing-database recovery images', () {
Future<void> expectRejected(Future<void> Function() corrupt) async {
final first = await open();
await ProfileRegistry(first.database).upsert(_profile('must-not-partially-restore'));
await first.database.close();
database = null;
await corrupt();
await _deleteDatabase(databaseFile);
final result = await open();
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty);
}
test('absent payload', () => expectRejected(() => prefs.remove(TvosDatabaseRecoveryStore.identityKey)));
test('corrupt manifest', () => expectRejected(() => prefs.setString(TvosDatabaseRecoveryStore.manifestKey, '{')));
test('unsupported version', () async {
await expectRejected(() async {
final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
manifest['version'] = 99;
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest));
});
});
test('digest mismatch', () async {
await expectRejected(() async {
await prefs.setString(
TvosDatabaseRecoveryStore.identityKey,
'${prefs.getString(TvosDatabaseRecoveryStore.identityKey)} ',
);
});
});
test('structurally invalid payload with matching digest', () async {
await expectRejected(() async {
final payload = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.identityKey)!) as Map<String, dynamic>;
(payload['rows'] as Map<String, dynamic>).remove('profiles');
final encoded = jsonEncode(payload);
final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
manifest['identityDigest'] = sha256.convert(utf8.encode(encoded)).toString();
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, encoded);
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest));
});
});
test('over-budget complete preference image', () async {
final first = await open();
await first.database.close();
database = null;
await _deleteDatabase(databaseFile);
final tinyStore = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1);
final result = await open(store: tinyStore);
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty);
});
});
test('explicit sign-in acknowledgement replaces invalid evidence before new mutations', () async {
final first = await open();
await first.database.close();
database = null;
await prefs.setString(
TvosDatabaseRecoveryStore.identityKey,
'${prefs.getString(TvosDatabaseRecoveryStore.identityKey)}-digest-mismatch',
);
await _deleteDatabase(databaseFile);
var result = await open();
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
await expectLater(
ConnectionRegistry(result.database).upsert(_connection('blocked-before-acknowledgement')),
throwsA(isA<TvosDatabaseDurabilityException>()),
);
expect(await result.database.select(result.database.connections).get(), isEmpty);
await result.database.acknowledgeTvosDatabaseRecoveryRequired();
await ConnectionRegistry(result.database).upsert(_connection('new-sign-in'));
final expected = await _criticalRows(result.database);
await closeAndDelete();
result = await open();
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await _criticalRows(result.database), expected);
});
group('two-phase crash protocol', () {
for (final point in TvosDatabaseRecoveryCrashPoint.values) {
test('$point converges from authoritative database', () async {
var armed = false;
final crashStore = TvosDatabaseRecoveryStore(
prefs,
isTvos: true,
debugCrash: (candidate) async {
if (armed && candidate == point) throw StateError('simulated process stop');
},
);
final first = await open(store: crashStore);
armed = true;
await expectLater(ProfileRegistry(first.database).upsert(_profile('crash-row')), throwsA(anything));
armed = false;
final expected = await _criticalRows(first.database);
await first.database.close();
database = null;
final repaired = await open();
expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
expect(await _criticalRows(repaired.database), expected);
await closeAndDelete();
final restored = await open();
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await _criticalRows(restored.database), expected);
});
}
for (final point in const [
TvosDatabaseRecoveryCrashPoint.afterInvalidation,
TvosDatabaseRecoveryCrashPoint.afterDatabaseMutation,
TvosDatabaseRecoveryCrashPoint.afterPayloadWrite,
]) {
test('$point never restores stale state when database is missing', () async {
var armed = false;
final crashStore = TvosDatabaseRecoveryStore(
prefs,
isTvos: true,
debugCrash: (candidate) async {
if (armed && candidate == point) throw StateError('simulated process stop');
},
);
final first = await open(store: crashStore);
armed = true;
await expectLater(ProfileRegistry(first.database).upsert(_profile('uncommitted')), throwsA(anything));
await closeAndDelete();
final missing = await open();
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect((await _criticalRows(missing.database)).expand((rows) => rows), isEmpty);
});
}
});
test('preference-write failure is typed, payload-free, and later converges', () async {
var armed = false;
final store = TvosDatabaseRecoveryStore(
prefs,
isTvos: true,
debugBeforePreferenceWrite: (key) async {
if (armed && key == TvosDatabaseRecoveryStore.identityKey) throw StateError('write failed');
},
);
final first = await open(store: store);
armed = true;
Object? failure;
try {
await ConnectionRegistry(first.database).upsert(_connection('write-failure-canary'));
} catch (error) {
failure = error;
}
expect(failure, isA<TvosDatabaseDurabilityException>());
expect(failure.toString(), isNot(contains('protected-connection-canary')));
expect(await first.database.select(first.database.connections).get(), hasLength(1));
armed = false;
await first.database.close();
database = null;
final repaired = await open();
expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
await closeAndDelete();
final restored = await open();
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await restored.database.select(restored.database.connections).get(), hasLength(1));
});
test('oversized identity degrades recovery without rejecting authoritative mutations', () async {
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1200);
final first = await open(store: store);
final canary = List.filled(3000, 'PROTECTED-SIZE-CANARY').join();
final huge = JellyfinConnection(
id: 'huge',
baseUrl: 'https://media.invalid',
serverName: canary,
serverMachineId: 'huge',
userId: 'user',
userName: 'user',
accessToken: canary,
deviceId: 'device',
createdAt: DateTime.fromMillisecondsSinceEpoch(1),
);
await ConnectionRegistry(first.database).upsert(huge);
await ProfileRegistry(first.database).upsert(_profile('after-degradation'));
expect(await first.database.select(first.database.connections).get(), hasLength(1));
expect(await first.database.select(first.database.profiles).get(), hasLength(1));
await closeAndDelete();
final missing = await open(store: TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1200));
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
expect(await missing.database.select(missing.database.connections).get(), isEmpty);
expect(await missing.database.select(missing.database.profiles).get(), isEmpty);
});
test('oversized pending actions retain identity in a bounded recovery image', () async {
const ceiling = 5000;
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: ceiling);
final first = await open(store: store);
await ConnectionRegistry(first.database).upsert(_connection('retained'));
for (var index = 0; index < 20; index++) {
await first.database.insertWatchAction(
profileId: 'profile',
serverId: ServerId('server'),
ratingKey: '$index-${List.filled(500, 'pending').join()}',
actionType: OfflineActionType.watched.id,
);
}
expect(await first.database.getPendingWatchActions(), hasLength(20));
await closeAndDelete();
final restored = await open(
store: TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: ceiling),
);
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
expect(await restored.database.select(restored.database.connections).get(), hasLength(1));
expect(await restored.database.getPendingWatchActions(), isEmpty);
});
test('pending mutation wrappers preserve updates, deletes, profile teardown, and clears', () async {
var result = await open();
await result.database.upsertProgressAction(
profileId: 'p1',
serverId: ServerId('s1'),
ratingKey: 'a',
viewOffset: 10,
duration: 100,
shouldMarkWatched: false,
);
await result.database.adoptLegacyOfflineWatchActionsForProfile('p1');
await result.database.insertWatchAction(
profileId: 'p1',
serverId: ServerId('s1'),
ratingKey: 'b',
actionType: OfflineActionType.watched.id,
);
final rows = await result.database.getPendingWatchActions();
await result.database.updateSyncAttempt(rows.first.id, 'retry');
await result.database.deleteWatchAction(rows.last.id);
await result.database.insertWatchAction(
profileId: 'p2',
serverId: ServerId('s1'),
ratingKey: 'c',
actionType: OfflineActionType.unwatched.id,
);
await result.database.deleteWatchActionsForProfile('p1');
var expected = await _criticalRows(result.database);
await closeAndDelete();
result = await open();
expect(await _criticalRows(result.database), expected);
await result.database.clearAllWatchActions();
expected = await _criticalRows(result.database);
await closeAndDelete();
result = await open();
expect(await _criticalRows(result.database), expected);
expect(await result.database.getPendingWatchActions(), isEmpty);
});
test('identity mutation wrappers preserve defaults, tokens, cascades, teardown, and clears', () async {
var result = await open();
var connections = ConnectionRegistry(result.database);
var profiles = ProfileRegistry(result.database);
var joins = ProfileConnectionRegistry(result.database);
await connections.upsert(_connection('c1'));
await connections.upsert(_connection('c2'));
await connections.setDefault('c2');
await connections.recordAuthSuccess('c2', DateTime.fromMillisecondsSinceEpoch(9000));
await profiles.upsert(_profile('p1'));
await profiles.upsert(_profile('p2'));
await profiles.markUsed('p1', DateTime.fromMillisecondsSinceEpoch(9100));
await profiles.upsert(
Profile.plexHome(
id: 'legacy-home',
displayName: 'Legacy',
parentConnectionId: 'c1',
createdAt: DateTime.fromMillisecondsSinceEpoch(1),
),
);
await profiles.dropAllPlexHomeRows();
await joins.upsert(
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 'token-1', userIdentifier: 'u1'),
);
await joins.upsert(
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 'token-2', userIdentifier: 'u2'),
);
await joins.recordToken('p1', 'c1', 'token-refreshed');
final protectedToken = await CredentialVault.protect('token-clear-canary');
final protectedEnvelope = jsonDecode(protectedToken.substring('enc:v1:'.length)) as Map<String, dynamic>;
final ciphertext = protectedEnvelope['c'] as String;
protectedEnvelope['c'] = '${ciphertext.startsWith('A') ? 'B' : 'A'}${ciphertext.substring(1)}';
final corruptedToken = 'enc:v1:${jsonEncode(protectedEnvelope)}';
await (result.database.update(
result.database.profileConnections,
)..where((t) => t.connectionId.equals('c1'))).write(ProfileConnectionsCompanion(userToken: Value(corruptedToken)));
await joins.get('p1', 'c1');
final clearedTokenRow = await (result.database.select(
result.database.profileConnections,
)..where((t) => t.connectionId.equals('c1'))).getSingle();
expect(clearedTokenRow.userToken, isEmpty);
await joins.markUsed('p1', 'c1');
await joins.setDefault('p1', 'c2');
await joins.remove('p1', 'c2');
await joins.promoteMissingDefaults();
await joins.removeAllForConnection('c2');
await connections.remove('c2');
await profiles.remove('p2');
var expected = await _criticalRows(result.database);
await closeAndDelete();
result = await open();
expect(await _criticalRows(result.database), expected);
connections = ConnectionRegistry(result.database);
profiles = ProfileRegistry(result.database);
joins = ProfileConnectionRegistry(result.database);
await joins.clear();
await profiles.clear();
await connections.clear();
expected = await _criticalRows(result.database);
await closeAndDelete();
result = await open();
expect(await _criticalRows(result.database), expected);
expect(expected.take(3).expand((rows) => rows), isEmpty);
});
}