Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Original file line number Diff line number Diff line change
@@ -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);
});
}
55 changes: 55 additions & 0 deletions test/e2e/01_connection_lifecycle/e2e_ssh_bastion_tunnel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
}
100 changes: 100 additions & 0 deletions test/e2e/01_connection_lifecycle/e2e_url_dialog_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<void> 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:[email protected]: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);
});
}
Loading