Files
plezy/test/services/prefs_store_repair_flow_test.dart
T
edde746 3ae7aa554b fix(prefs): recover a preference store whose bytes are not valid UTF-8
`File.readAsString` reports a UTF-8 decode failure as a FileSystemException,
not a FormatException, so three guards written for that case never ran. The
preflight's `on FormatException` branch was unreachable and its
`on FileSystemException` sibling waved the document through; the plugin then
threw the same FileSystemException, which failed the FormatException/TypeError
test that decides repairability; and quarantine's lossy-decode fallback sat
dead behind a rethrow. A store with one bad high byte — a UTF-16 BOM, a stray
0x80 — therefore reached the user as a failure screen with no Repair button
and no way forward at all.

Read bytes and decode explicitly instead, at both sites. Classification moves
into describeStoreDamage, so a failure that surfaces after the preflight
passed is judged by re-reading the file rather than by the error's type: a
denied or locked store is indistinguishable from a decode failure by type or
message, and offering a destructive repair for a permissions problem would
reset every setting and risk the vault key over something a chmod fixes.
isCorruptStoreError went with it, having no remaining callers.

A repair that quarantines the store and then cannot reopen it no longer
strands the process either. The repaired future was built straight from the
cache loader, bypassing the self-healing reset sharedCache installs, so a
failed reopen parked a rejected future in _cacheFuture and every later attempt
replayed that stale error — with the damaged file already moved aside, so a
restart would have booted cleanly.

CorruptPreferenceStoreException now carries reopenSafe and a derived,
content-free shape: byte length, whether it decoded, whether every byte is
zero. #1732 arrived as "FormatException at offset 0" and nothing else, which
cannot separate an all-zero file from a non-JSON first character from bytes
that are not UTF-8; these can, and never quote the document.

Cover the loop against the real desktop backend rather than a fake.
shared_preferences_linux is pure Dart, byte-identical to the Windows
implementation, and exposes fs/pathProvider, so pointing it at a temp
directory exercises the genuine read, parse, cache and write path on any host
— the join between preflight, classification and reopen where every one of
these defects lived, and which had no coverage at all.
2026-08-01 06:59:20 +02:00

203 lines
8.3 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider_linux/path_provider_linux.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/prefs_recovery.dart';
import 'package:plezy/services/sensitive_prefs.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:shared_preferences_linux/shared_preferences_linux.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
import '../test_helpers/io_fakes.dart';
/// The whole #1732 loop, driven against the **real** desktop preference
/// backend rather than an in-memory fake.
///
/// The two halves of the existing coverage never met: file-level tests called
/// `PrefsRecovery` statics with a `storeFileOverride` and never built a gate,
/// while gate-level tests threw synthetic exceptions and never touched a file.
/// Every defect in #1732 lived in the join — the preflight's callsite, the
/// classification of what the plugin threw, and the reopen after a repair.
///
/// `shared_preferences_linux` is pure Dart and byte-identical to the Windows
/// implementation; both expose `fs`/`pathProvider` for exactly this. Pointing
/// its path provider at a temp directory runs the genuine read, parse, cache
/// and write code on any host.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory root;
late Directory support;
late File store;
/// A 32-byte key, the only length `AesGcm.with256bits()` accepts.
final validVaultKey = base64Encode(List<int>.generate(32, (i) => i));
setUp(() async {
root = await Directory.systemTemp.createTemp('plezy_prefs_repair_flow_');
support = Directory(p.join(root.path, 'support'))..createSync(recursive: true);
store = File(p.join(support.path, prefsStoreFileName));
PathProviderPlatform.instance = FakePathProvider(root);
// The desktop store path is Windows/Linux only; without this the preflight
// is a no-op on a macOS host and the test would assert nothing.
PrefsRecovery.debugSetSupportedPlatformOverride(true);
final pathProvider = _TempPathProviderLinux(support.path);
SharedPreferencesStorePlatform.instance = SharedPreferencesLinux()..pathProvider = pathProvider;
SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncLinux()..pathProvider = pathProvider;
BaseSharedPreferencesService.resetForTesting();
SettingsService.resetForTesting();
// `CredentialVault` memoises the first key it sees; the repair reseeds
// before anything can read, which is what makes the reseed safe.
});
tearDown(() async {
BaseSharedPreferencesService.resetForTesting();
SettingsService.resetForTesting();
PrefsRecovery.debugSetSupportedPlatformOverride(null);
if (await root.exists()) await root.delete(recursive: true);
});
test('a store the backend can read opens normally', () async {
await store.writeAsString(jsonEncode({'theme': 'dark'}));
await expectLater(SettingsService.getInstance(), completes);
});
test('an offset-0 store fails the gate as a repairable corruption', () async {
// The exact shape reported in #1732: valid UTF-8, non-empty, and a first
// byte that is not a legal JSON value start.
await store.writeAsBytes([
0,
...utf8.encode(jsonEncode({'theme': 'dark'})),
]);
final error = await SettingsService.getInstance().then<Object?>((_) => null, onError: (Object e) => e);
expect(error, isA<CorruptPreferenceStoreException>());
final corruption = error! as CorruptPreferenceStoreException;
expect(corruption.offset, 0);
// Caught by the preflight, before either backend memoised anything, so the
// repair can reopen in this same process.
expect(corruption.reopenSafe, isTrue);
});
test('repairing an offset-0 store reopens it in the same process and keeps the vault key', () async {
await store.writeAsBytes([
0,
...utf8.encode(jsonEncode({credentialVaultKeyPref: validVaultKey, 'theme': 'dark'})),
]);
await expectLater(SettingsService.getInstance(), throwsA(isA<CorruptPreferenceStoreException>()));
final outcome = await BaseSharedPreferencesService.repairCorruptStore();
expect(outcome.vaultKeySalvaged, isTrue);
expect(outcome.requiresRestart, isFalse);
expect(outcome.backupPath, isNotNull);
expect(await File(outcome.backupPath!).exists(), isTrue);
// The point of the whole exercise: this process, no restart.
final settings = await SettingsService.getInstance();
expect(settings, isNotNull);
// The salvaged key is on disk under the async key name, so every token
// stored as ciphertext in the database stays decryptable.
final reopened = jsonDecode(await store.readAsString()) as Map<String, dynamic>;
expect(reopened[credentialVaultKeyPref], validVaultKey);
// Settings were reset, which is the cost the consent dialog names.
expect(reopened.containsKey('theme'), isFalse);
});
test('a store whose bytes are not UTF-8 is repairable, not a dead end', () async {
// `File.readAsString` reports a decode failure as FileSystemException, so
// this used to slip past the preflight, fail the type check that decides
// repairability, and reach the user as a failure screen with no Repair
// button and no way forward.
await store.writeAsBytes([
0xFF,
0xFE,
...utf8.encode(jsonEncode({credentialVaultKeyPref: validVaultKey})),
]);
final error = await SettingsService.getInstance().then<Object?>((_) => null, onError: (Object e) => e);
expect(error, isA<CorruptPreferenceStoreException>());
expect((error! as CorruptPreferenceStoreException).shape?.validUtf8, isFalse);
final outcome = await BaseSharedPreferencesService.repairCorruptStore();
expect(outcome.vaultKeySalvaged, isTrue);
await expectLater(SettingsService.getInstance(), completes);
});
test('an empty store is a first launch, not damage', () async {
// Both backends skip `json.decode` for an empty document, so rejecting it
// would brick a launch the plugin handles perfectly well.
await store.writeAsBytes(const []);
await expectLater(SettingsService.getInstance(), completes);
});
test('a genuine read failure keeps its own type and stays non-repairable', () async {
// A store the process cannot read is not a damaged *document*. Offering a
// destructive repair for a permissions problem would reset every setting
// and risk the vault key over something a chmod fixes, so the failure has
// to keep its own type and its own non-repairable path.
await store.writeAsString(jsonEncode({'theme': 'dark'}));
if (!_run('chmod', ['000', store.path])) {
// No `chmod` on a Windows PATH, and its ACL model would need a different
// probe entirely.
markTestSkipped('cannot make a file unreadable here');
return;
}
addTearDown(() => _run('chmod', ['600', store.path]));
var readable = true;
try {
store.readAsBytesSync();
} on FileSystemException {
readable = false;
}
// Running as root (some container images) defeats the mode bits entirely.
if (readable) {
markTestSkipped('the store stayed readable after chmod 000; cannot stage an I/O failure here');
return;
}
final error = await SettingsService.getInstance().then<Object?>((_) => null, onError: (Object e) => e);
expect(error, isNotNull);
expect(error, isNot(isA<CorruptPreferenceStoreException>()));
});
}
class _TempPathProviderLinux extends PathProviderLinux {
_TempPathProviderLinux(this.supportPath);
final String supportPath;
@override
Future<String?> getApplicationSupportPath() async => supportPath;
}
/// Runs a POSIX helper, reporting whether it succeeded.
///
/// Returns false rather than throwing when the binary is missing, so a Windows
/// run reaches `markTestSkipped` instead of failing the whole suite on a
/// ProcessException before the test body can decide anything.
bool _run(String executable, List<String> arguments) {
try {
return Process.runSync(executable, arguments).exitCode == 0;
} on ProcessException {
return false;
}
}