diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index f77e4029..45914402 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -63,4 +63,33 @@ void main() { expect(FoldersStorage.instance.folders, isNot(contains('Team A'))); await app.close(tester); }); + + testWidgets('a connection is moved into a folder and the sidebar shows it', + (tester) async { + await app.launch(tester); + await tester.runAsync(() => FoldersStorage.instance.add('Team B')); + final folderId = await tester + .runAsync(() => LocalDb.instance.getFolderIdByName('Team B')); + expect(folderId, isNotNull); + + final id = await E2eConnections.add( + tester, E2eConnections.postgres('E2E Moved')); + final stored = + await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + expect(stored!.folderId, isNull); + + await tester.runAsync(() => LocalDb.instance + .updateConnection(stored.copyWith(folderId: folderId))); + await tester.runAsync(FoldersStorage.instance.reload); + await E2eConnections.reloadSidebar(tester); + + final moved = + await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + expect(moved!.folderId, folderId); + expect(inSidebar('Team B'), findsOneWidget); + + await E2eConnections.remove(tester, id); + await tester.runAsync(() => FoldersStorage.instance.remove('Team B')); + await app.close(tester); + }); } diff --git a/test/e2e/01_connection_lifecycle/e2e_credential_scrubbing_test.dart b/test/e2e/01_connection_lifecycle/e2e_credential_scrubbing_test.dart new file mode 100644 index 00000000..2eea5494 --- /dev/null +++ b/test/e2e/01_connection_lifecycle/e2e_credential_scrubbing_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; + +const _secret = 'Zero-Leak-Pw-91b4'; + +/// After the handshake a connection must not keep the password reachable +/// through its public getters (the "Zero-Leak" rule). +void main() { + test('PostgreSQL drops password and connection string', () { + final c = PostgresConnection( + id: 1, + name: 'pg', + host: 'localhost', + password: _secret, + connectionString: 'postgresql://u:$_secret@localhost/db', + ); + expect(c.password, _secret); + c.scrubCredentials(); + expect(c.password, isNull); + expect(c.connectionString, isNull); + }); + + test('MySQL drops password and connection string', () { + final c = MysqlConnection( + id: 2, + name: 'my', + host: 'localhost', + password: _secret, + connectionString: 'mysql://u:$_secret@localhost/db', + ); + expect(c.password, _secret); + c.scrubCredentials(); + expect(c.password, isNull); + expect(c.connectionString, isNull); + }); + + test('Redis drops password and connection string', () { + final c = RedisConnection( + id: 3, + name: 'redis', + host: 'localhost', + password: _secret, + connectionString: 'redis://:$_secret@localhost', + ); + expect(c.password, _secret); + c.scrubCredentials(); + expect(c.password, isNull); + expect(c.connectionString, isNull); + }); + + test('MongoDB drops password and connection string', () { + final c = MongoConnection( + id: 4, + name: 'mongo', + host: 'localhost', + username: 'u', + password: _secret, + ); + expect(c.password, _secret); + c.scrubCredentials(); + expect(c.password, isNull); + expect(c.connectionString, isNull); + }); +} diff --git a/test/e2e/01_connection_lifecycle/e2e_ssh_bastion_tunnel_test.dart b/test/e2e/01_connection_lifecycle/e2e_ssh_bastion_tunnel_test.dart index 8a845e8c..40e031e6 100644 --- a/test/e2e/01_connection_lifecycle/e2e_ssh_bastion_tunnel_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_ssh_bastion_tunnel_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; @@ -152,4 +153,58 @@ void main() { ); expect(manager.activeSessionCount, 0); }); + + test('a passphrase-protected key is stored in the secure store and opens ' + 'the tunnel', () async { + const passphrase = 'Key-Passphrase-3d8f'; + final keyDir = await Directory.systemTemp.createTemp('e2e_ssh_key_'); + addTearDown(() => keyDir.deleteSync(recursive: true)); + final keyPath = p.join(keyDir.path, 'id_ed25519'); + try { + final r = await Process.run('ssh-keygen', + ['-q', '-t', 'ed25519', '-N', passphrase, '-f', keyPath]); + if (r.exitCode != 0) { + markTestSkipped('ssh-keygen failed'); + return; + } + } on ProcessException { + markTestSkipped('ssh-keygen is not available'); + return; + } + final pem = File(keyPath).readAsStringSync(); + + final cfg = config().copyWith(authType: SshAuthType.privateKey); + final id = await LocalDb.instance.addConnection(ConnectionRow( + type: 'postgresql', + name: 'Key bastion', + host: 'db.internal', + port: 5432, + createdAt: DateTime.utc(2026).toIso8601String(), + sshSecrets: SshTunnelSecrets(privateKey: pem, passphrase: passphrase), + ).withSshTunnelConfig(cfg)); + final row = (await LocalDb.instance.getConnectionById(id))!; + + expect(row.sshTunnelConfig!.authType, SshAuthType.privateKey); + expect(row.driverOptions, isNot(contains(passphrase))); + expect(row.driverOptions, isNot(contains('PRIVATE KEY'))); + + final stored = + await ConnectionSecretsStore.readSshSecretsForConnection(id); + expect(stored.passphrase, passphrase); + expect(stored.privateKey, pem); + + final secrets = SshTunnelSecrets( + privateKey: stored.privateKey, passphrase: stored.passphrase); + final handle = await manager.openTunnel( + config: row.sshTunnelConfig!, + secrets: secrets, + remoteHost: row.host!, + remotePort: row.port!, + ); + expect(handle.localPort, greaterThan(0)); + expect(server.clients.last.passwordRequested, isFalse, + reason: 'key auth must not fall back to a password'); + expect(secrets.isEmpty, isTrue); + await handle.release(); + }); } diff --git a/test/e2e/01_connection_lifecycle/e2e_url_dialog_test.dart b/test/e2e/01_connection_lifecycle/e2e_url_dialog_test.dart new file mode 100644 index 00000000..99e349cf --- /dev/null +++ b/test/e2e/01_connection_lifecycle/e2e_url_dialog_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +/// The "New connection from URL" modal: paste, see the parsed summary, create. +void main() { + ConnectionRow? result; + var closed = false; + + Future open(WidgetTester tester) async { + result = null; + closed = false; + await tester.binding.setSurfaceSize(const material.Size(1000, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.Center( + child: material.TextButton( + onPressed: () async { + result = await showNewConnectionUrlDialog(context); + closed = true; + }, + child: const material.Text('open dialog'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open dialog')); + await tester.pump(const Duration(milliseconds: 400)); + } + + Future type(WidgetTester tester, String url) async { + await tester.enterText(find.byType(material.EditableText), url); + await tester.pump(const Duration(milliseconds: 100)); + } + + testWidgets('a valid URL shows the parsed type and creates the connection', + (tester) async { + await open(tester); + expect(find.text('New connection from URL'), findsOneWidget); + + await type(tester, 'postgresql://app:s3cret@db.example:5433/shop'); + expect(find.textContaining('POSTGRESQL'), findsOneWidget); + + await tester.tap(find.text('Create')); + await tester.pump(const Duration(milliseconds: 400)); + + expect(closed, isTrue); + expect(result, isNotNull); + expect(result!.type, 'postgresql'); + expect(result!.host, 'db.example'); + expect(result!.port, 5433); + expect(result!.username, 'app'); + expect(result!.databaseName, 'shop'); + }); + + testWidgets('an invalid URL shows an error and keeps the dialog open', + (tester) async { + await open(tester); + + await type(tester, 'not a url'); + expect(find.textContaining('POSTGRESQL'), findsNothing); + + await tester.tap(find.text('Create')); + await tester.pump(const Duration(milliseconds: 400)); + + expect(closed, isFalse); + expect(find.text('New connection from URL'), findsOneWidget); + }); + + testWidgets('an empty field cannot be submitted', (tester) async { + await open(tester); + + await tester.tap(find.text('Create')); + await tester.pump(const Duration(milliseconds: 400)); + + expect(closed, isFalse); + expect(find.text('New connection from URL'), findsOneWidget); + }); + + testWidgets('Cancel closes the dialog without a connection', (tester) async { + await open(tester); + + await type(tester, 'redis://:pw@cache:6380/2'); + await tester.tap(find.text('Cancel')); + await tester.pump(const Duration(milliseconds: 400)); + + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 300)); + } + expect(closed, isTrue); + expect(result, isNull); + expect(find.text('New connection from URL'), findsNothing); + }); +}