From 4c940b87bcea681c12931db15e01b98755b4e071 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 12:08:42 +0300 Subject: [PATCH] test(secrets): fault injection for the test keyring backend, regression tests for a failing keyring The memory backend counts calls and can fail the Nth call of a kind or every call until healed, before it touches the stored values. The tests cover a save that fails part-way (add, add with SSH secrets, update), a read that fails on the second key, and a retry after the refusal. --- .../storage/memory_secrets_backend_test.dart | 112 ++++++++ test/core/storage/secrets_failure_test.dart | 255 ++++++++++++++++++ .../connections/save_new_connection_test.dart | 33 +++ test/memory_secrets_backend.dart | 106 ++++++-- 4 files changed, 491 insertions(+), 15 deletions(-) create mode 100644 test/core/storage/memory_secrets_backend_test.dart create mode 100644 test/core/storage/secrets_failure_test.dart diff --git a/test/core/storage/memory_secrets_backend_test.dart b/test/core/storage/memory_secrets_backend_test.dart new file mode 100644 index 00000000..3577a6c1 --- /dev/null +++ b/test/core/storage/memory_secrets_backend_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../memory_secrets_backend.dart'; + +/// The fault injection of the test backend itself. The tests of the app's +/// handling of a failing keyring (`secrets_failure_test.dart`) lean on these +/// guarantees. +void main() { + late MemorySecretsStorageBackend backend; + + setUp(() => backend = MemorySecretsStorageBackend()); + + test('without faults it stores, reads and deletes, and counts the calls', + () async { + await backend.write('a', '1'); + expect(await backend.read('a'), '1'); + await backend.write('a', null); + expect(await backend.read('a'), isNull); + await backend.write('b', '2'); + await backend.delete('b'); + + expect(backend.keys, isEmpty); + expect(backend.writeCalls, 3); + expect(backend.readCalls, 2); + expect(backend.deleteCalls, 1); + }); + + test('failOnCall fires on the Nth call of that kind only, once', () async { + backend.failOnCall(SecretsOp.write, 2, StateError('second')); + + await backend.write('a', '1'); + await expectLater(backend.write('b', '2'), throwsA(isA())); + await backend.write('c', '3'); + + expect(backend.keys, {'a', 'c'}); + // Reads and deletes have their own numbering. + expect(await backend.read('a'), '1'); + await backend.delete('a'); + }); + + test('a failing call is counted and leaves the values untouched', () async { + await backend.write('k', 'old'); + backend.failOnCall(SecretsOp.write, 2, StateError('write')); + backend.failOnCall(SecretsOp.delete, 1, StateError('delete')); + + await expectLater(backend.write('k', 'new'), throwsA(isA())); + await expectLater(backend.delete('k'), throwsA(isA())); + + expect(backend.writeCalls, 2); + expect(backend.deleteCalls, 1); + expect(await backend.read('k'), 'old'); + }); + + test('failAlways keeps failing until heal; heal leaves values and counts', + () async { + await backend.write('k', 'v'); + backend.failAlways(SecretsOp.read, Exception('locked')); + + await expectLater(backend.read('k'), throwsException); + await expectLater(backend.read('k'), throwsException); + expect(backend.readCalls, 2); + + backend.heal(SecretsOp.read); + expect(await backend.read('k'), 'v'); + expect(backend.readCalls, 3); + }); + + test('the failNext flags still fire once and win over the other faults', + () async { + backend.failNextRead = StateError('flag'); + backend.failOnCall(SecretsOp.read, 1, ArgumentError('scheduled')); + backend.failAlways(SecretsOp.read, const FormatException('always')); + + await expectLater(backend.read('k'), throwsA(isA())); + expect(backend.failNextRead, isNull); + await expectLater(backend.read('k'), throwsA(isA())); + }); + + test('resetCounts restarts the numbering and keeps values and faults', + () async { + await backend.write('k', 'v'); + await backend.write('k2', 'v2'); + backend.resetCounts(); + expect(backend.writeCalls, 0); + + backend.failOnCall(SecretsOp.write, 1, StateError('first after reset')); + await expectLater(backend.write('k3', 'v3'), throwsA(isA())); + expect(backend.keys, {'k', 'k2'}); + }); + + test('clear drops values, faults and counts', () async { + await backend.write('k', 'v'); + backend.failNextWrite = StateError('x'); + backend.failOnCall(SecretsOp.read, 5, StateError('y')); + backend.failAlways(SecretsOp.delete, StateError('z')); + + backend.clear(); + + expect(backend.keys, isEmpty); + expect(backend.writeCalls, 0); + await backend.write('k', 'v'); + await backend.delete('k'); + for (var i = 0; i < 6; i++) { + await backend.read('k'); + } + }); + + test('a call number below 1 is refused', () { + expect(() => backend.failOnCall(SecretsOp.write, 0, StateError('x')), + throwsArgumentError); + }); +} diff --git a/test/core/storage/secrets_failure_test.dart b/test/core/storage/secrets_failure_test.dart new file mode 100644 index 00000000..f2d06528 --- /dev/null +++ b/test/core/storage/secrets_failure_test.dart @@ -0,0 +1,255 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +import '../../memory_secrets_backend.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +/// What the app does when the system keyring refuses a request part-way +/// through a save or a read: the write path is all-or-nothing as far as the +/// stored connection goes (the row and its secrets are rolled back, the +/// original error is rethrown), and the read path tells "could not read" from +/// "no secret" (#1303). Faults are aimed with the numbered calls of +/// `memory_secrets_backend.dart`: a connection's secrets are written one key +/// after another (password, connection string, then the four SSH keys). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_secrets_fail_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + testMemorySecrets.clear(); + for (final c in await LocalDb.instance.getConnections()) { + if (c.id != null) await LocalDb.instance.removeConnection(c.id!); + } + testMemorySecrets.clear(); + }); + + ConnectionRow row(String name, {SshTunnelSecrets? ssh}) => ConnectionRow( + type: 'postgresql', + name: name, + host: 'localhost', + port: 5432, + username: 'admin', + password: 'db-password', + connectionString: 'postgres://admin@localhost/db', + createdAt: '2026-01-01T00:00:00Z', + sshSecrets: ssh, + ); + + Future> rowsNamed(String name) async => + (await LocalDb.instance.getConnections()) + .where((c) => c.name == name) + .toList(); + + group('a write that fails part-way', () { + test( + 'addConnection: the first secret is stored, the second is refused: ' + 'no row, no secret left, the original error comes out', () async { + final error = StateError('connection string refused'); + testMemorySecrets.failOnCall(SecretsOp.write, 2, error); + + await expectLater( + LocalDb.instance.addConnection(row('Partial add')), + throwsA(same(error)), + ); + + expect(testMemorySecrets.writeCalls, 2); + expect(await rowsNamed('Partial add'), isEmpty); + expect(testMemorySecrets.keys, isEmpty, + reason: 'the password written before the refusal must not stay'); + }); + + test('addConnection: a refusal among the SSH secrets rolls back them all', + () async { + // Writes: password 1, connection string 2, SSH password 3, SSH key 4. + final error = StateError('ssh key refused'); + testMemorySecrets.failOnCall(SecretsOp.write, 4, error); + + await expectLater( + LocalDb.instance.addConnection(row( + 'Partial ssh', + ssh: SshTunnelSecrets(password: 'ssh-pw', privateKey: 'ssh-key'), + )), + throwsA(same(error)), + ); + + expect(await rowsNamed('Partial ssh'), isEmpty); + expect(testMemorySecrets.keys, isEmpty); + }); + + test( + 'addConnection: when the cleanup is refused as well the row is still ' + 'removed and the first error is the one reported', () async { + final error = StateError('write refused'); + testMemorySecrets.failOnCall(SecretsOp.write, 2, error); + testMemorySecrets.failAlways(SecretsOp.delete, StateError('no delete')); + + await expectLater( + LocalDb.instance.addConnection(row('Stuck cleanup')), + throwsA(same(error)), + ); + + expect(await rowsNamed('Stuck cleanup'), isEmpty); + }); + + test( + 'updateConnection: a refusal after the new password was stored puts ' + 'back the old row and both old secrets', () async { + final id = await LocalDb.instance.addConnection(row('Before')); + testMemorySecrets.resetCounts(); + // Writes of the update: password 1 (applied), connection string 2. + final error = StateError('connection string refused'); + testMemorySecrets.failOnCall(SecretsOp.write, 2, error); + + await expectLater( + LocalDb.instance.updateConnection(ConnectionRow( + id: id, + type: 'postgresql', + name: 'After', + host: 'other-host', + port: 5433, + username: 'root', + password: 'new-password', + connectionString: 'postgres://root@other-host/db', + createdAt: '2026-01-01T00:00:00Z', + )), + throwsA(same(error)), + ); + + final loaded = (await LocalDb.instance.getConnections( + hydrateSecrets: true, + )) + .singleWhere((c) => c.id == id); + expect(loaded.name, 'Before'); + expect(loaded.host, 'localhost'); + expect(loaded.password, 'db-password'); + expect(loaded.connectionString, 'postgres://admin@localhost/db'); + }); + }); + + group('a read that fails', () { + test( + 'a refusal on the second key is "could not read", not "no password", ' + 'and a retry reads both', () async { + final id = await LocalDb.instance.addConnection(row('Read me')); + testMemorySecrets.resetCounts(); + final cause = Exception('keyring is locked'); + testMemorySecrets.failOnCall(SecretsOp.read, 2, cause); + + await expectLater( + ConnectionSecretsStore.readForConnection(id), + throwsA(isA() + .having((e) => e.cause, 'cause', same(cause))), + ); + expect(testMemorySecrets.readCalls, 2); + + final retry = await ConnectionSecretsStore.readForConnection(id); + expect(retry.password, 'db-password'); + expect(retry.connectionString, 'postgres://admin@localhost/db'); + }); + + test( + 'the list still loads while the keyring is down, rows without their ' + 'password, and has them once it answers', () async { + final id = await LocalDb.instance.addConnection(row('Locked list')); + testMemorySecrets.failAlways(SecretsOp.read, Exception('locked')); + + final during = (await LocalDb.instance.getConnections( + hydrateSecrets: true, + )) + .singleWhere((c) => c.id == id); + expect(during.name, 'Locked list'); + expect(during.password, isNull); + + testMemorySecrets.heal(SecretsOp.read); + final after = (await LocalDb.instance.getConnections( + hydrateSecrets: true, + )) + .singleWhere((c) => c.id == id); + expect(after.password, 'db-password'); + }); + }); + + group('a retry after a refusal', () { + test('addConnection stores the connection once the keyring answers', + () async { + testMemorySecrets.failAlways(SecretsOp.write, StateError('locked')); + await expectLater( + LocalDb.instance.addConnection(row('Retry add')), + throwsA(isA()), + ); + expect(await rowsNamed('Retry add'), isEmpty); + + testMemorySecrets.heal(SecretsOp.write); + final id = await LocalDb.instance.addConnection(row('Retry add')); + + expect(await rowsNamed('Retry add'), hasLength(1)); + final secrets = await ConnectionSecretsStore.readForConnection(id); + expect(secrets.password, 'db-password'); + expect(secrets.connectionString, 'postgres://admin@localhost/db'); + }); + + test('updateConnection applies the edit once the keyring answers', + () async { + final id = await LocalDb.instance.addConnection(row('Retry edit')); + final edited = ConnectionRow( + id: id, + type: 'postgresql', + name: 'Retry edit', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'changed-password', + connectionString: 'postgres://admin@localhost/db', + createdAt: '2026-01-01T00:00:00Z', + ); + + testMemorySecrets.failNextWrite = StateError('locked'); + await expectLater( + LocalDb.instance.updateConnection(edited), + throwsA(isA()), + ); + expect( + (await ConnectionSecretsStore.readForConnection(id)).password, + 'db-password', + ); + + await LocalDb.instance.updateConnection(edited); + expect( + (await ConnectionSecretsStore.readForConnection(id)).password, + 'changed-password', + ); + }); + }); +} diff --git a/test/features/connections/save_new_connection_test.dart b/test/features/connections/save_new_connection_test.dart index 2e3a4bcb..24fd200e 100644 --- a/test/features/connections/save_new_connection_test.dart +++ b/test/features/connections/save_new_connection_test.dart @@ -112,4 +112,37 @@ void main() { await tester.pump(const Duration(seconds: 6)); await tester.pumpAndSettle(); }); + + testWidgets( + 'a save that fails on the second secret leaves nothing behind, and the ' + 'same row saves once the keyring answers', (tester) async { + final host = await pumpHost(tester); + testMemorySecrets.failOnCall( + SecretsOp.write, + 2, + StateError('connection string refused'), + ); + final withString = _row.copyWith(connectionString: 'postgres://x@y/z'); + + final first = + await tester.runAsync(() => saveNewConnection(host, withString)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(first, isFalse); + expect(await savedRows(tester), isEmpty); + expect(testMemorySecrets.keys, isEmpty); + expect(find.textContaining('was not saved'), findsOneWidget); + + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + + final second = + await tester.runAsync(() => saveNewConnection(host, withString)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(second, isTrue); + expect(await savedRows(tester), hasLength(1)); + expect(find.textContaining('was not saved'), findsNothing); + }); } diff --git a/test/memory_secrets_backend.dart b/test/memory_secrets_backend.dart index 5253a205..ab16534e 100644 --- a/test/memory_secrets_backend.dart +++ b/test/memory_secrets_backend.dart @@ -4,9 +4,29 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; final MemorySecretsStorageBackend testMemorySecrets = MemorySecretsStorageBackend(); +/// The three backend operations a fault can be aimed at. +enum SecretsOp { read, write, delete } + /// In-memory secrets backend for `flutter test` (no OS keychain / libsecret). +/// +/// Fault injection, all of it off by default: +/// * `failNextRead` / `failNextWrite` / `failNextDelete`: the next call of +/// that kind throws, then the flag clears. +/// * [failOnCall]: the Nth call of one kind throws, once. +/// * [failAlways]: every call of one kind throws until [heal]. +/// +/// A failing call throws before it touches the stored values, as a keyring +/// that refuses a request does. Calls are numbered per kind from 1, counting +/// every call that reaches the backend, failed ones included; [clear] and +/// [resetCounts] start the numbering over. When several faults apply to one +/// call, the `failNext*` flag wins, then [failOnCall], then [failAlways]. class MemorySecretsStorageBackend implements SecretsStorageBackend { final Map _values = {}; + final Map _calls = {for (final op in SecretsOp.values) op: 0}; + final Map> _scheduled = { + for (final op in SecretsOp.values) op: {}, + }; + final Map _persistent = {}; /// When non-null, the next [read] throws this error (then clears the flag). Object? failNextRead; @@ -17,23 +37,77 @@ class MemorySecretsStorageBackend implements SecretsStorageBackend { /// When non-null, the next [delete] throws this error (then clears the flag). Object? failNextDelete; + /// Calls of each kind since the last [clear] / [resetCounts], failed ones + /// included. + int get readCalls => _calls[SecretsOp.read]!; + int get writeCalls => _calls[SecretsOp.write]!; + int get deleteCalls => _calls[SecretsOp.delete]!; + + /// The keys that currently hold a value. + Set get keys => Set.unmodifiable(_values.keys); + + /// Makes call number [callNumber] (1-based, per kind, counted from the last + /// reset) throw [error], once. A number that has already passed never fires. + void failOnCall(SecretsOp op, int callNumber, Object error) { + if (callNumber < 1) { + throw ArgumentError.value(callNumber, 'callNumber', 'calls start at 1'); + } + _scheduled[op]![callNumber] = error; + } + + /// Makes every call of kind [op] throw [error] until [heal]. + void failAlways(SecretsOp op, Object error) => _persistent[op] = error; + + /// Drops every pending fault of kind [op] (the one-shot flag, scheduled + /// calls and [failAlways]). Values and counters stay. + void heal(SecretsOp op) { + _scheduled[op]!.clear(); + _persistent.remove(op); + switch (op) { + case SecretsOp.read: + failNextRead = null; + case SecretsOp.write: + failNextWrite = null; + case SecretsOp.delete: + failNextDelete = null; + } + } + + /// Starts the call numbering over. Faults and values stay. + void resetCounts() { + for (final op in SecretsOp.values) { + _calls[op] = 0; + } + } + + /// Counts the call, and throws when a fault is aimed at it. + void _enter(SecretsOp op) { + final number = _calls[op] = _calls[op]! + 1; + final Object? oneShot; + switch (op) { + case SecretsOp.read: + oneShot = failNextRead; + failNextRead = null; + case SecretsOp.write: + oneShot = failNextWrite; + failNextWrite = null; + case SecretsOp.delete: + oneShot = failNextDelete; + failNextDelete = null; + } + final fault = oneShot ?? _scheduled[op]!.remove(number) ?? _persistent[op]; + if (fault != null) throw fault; + } + @override Future read(String key) async { - final fail = failNextRead; - if (fail != null) { - failNextRead = null; - throw fail; - } + _enter(SecretsOp.read); return _values[key]; } @override Future write(String key, String? value) async { - final fail = failNextWrite; - if (fail != null) { - failNextWrite = null; - throw fail; - } + _enter(SecretsOp.write); if (value == null || value.isEmpty) { _values.remove(key); } else { @@ -43,18 +117,20 @@ class MemorySecretsStorageBackend implements SecretsStorageBackend { @override Future delete(String key) async { - final fail = failNextDelete; - if (fail != null) { - failNextDelete = null; - throw fail; - } + _enter(SecretsOp.delete); _values.remove(key); } + /// Back to a fresh backend: no values, no faults, numbering from 1. void clear() { _values.clear(); failNextRead = null; failNextWrite = null; failNextDelete = null; + for (final op in SecretsOp.values) { + _scheduled[op]!.clear(); + } + _persistent.clear(); + resetCounts(); } }