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
13 changes: 8 additions & 5 deletions lib/core/database/mongodb_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -462,17 +462,20 @@ class MongoConnection {
}
}

/// Tests the connection.
Future<bool> testConnection() async {
/// Tests the connection. [error] is the reason in the user's terms (#1308).
Future<({bool ok, String? error})> testConnection() async {
try {
await connect();
if (_db != null) {
await _db!.runCommand({'ping': 1});
return true;
return (ok: true, error: null);
}
return false;
return (ok: false, error: 'Connection could not be established.');
} catch (e) {
return false;
return (
ok: false,
error: describeDatabaseError(e, driver: DatabaseDriver.mongodb),
);
}
}

Expand Down
14 changes: 10 additions & 4 deletions lib/core/database/mysql_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -484,17 +484,23 @@ class MysqlConnection {
? 'SET SESSION TRANSACTION READ ONLY'
: 'SET SESSION TRANSACTION READ WRITE';

Future<bool> testConnection() async {
/// Connects, runs `SELECT 1` and disconnects. [error] is the reason in the
/// user's terms (*Authentication failed. Check the username...*), not a
/// bare "failed" (#1308).
Future<({bool ok, String? error})> testConnection() async {
try {
await connect();
if (_conn != null) {
await execute('SELECT 1');
return true;
return (ok: true, error: null);
}
return false;
return (ok: false, error: 'Connection could not be established.');
} catch (e) {
debugPrint('MysqlConnection.testConnection: $e');
return false;
return (
ok: false,
error: describeDatabaseError(e, driver: DatabaseDriver.mysql),
);
} finally {
await disconnect();
}
Expand Down
11 changes: 8 additions & 3 deletions lib/core/database/redis_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,18 @@ class RedisConnection {
return RedisBulkValue.fromReply(result).text ?? '';
}

Future<bool> testConnection() async {
/// Connects and disconnects. [error] is the reason in the user's terms
/// (#1308).
Future<({bool ok, String? error})> testConnection() async {
try {
await connect();
return true;
return (ok: true, error: null);
} catch (e) {
debugPrint('RedisConnection.testConnection: $e');
return false;
return (
ok: false,
error: describeDatabaseError(e, driver: DatabaseDriver.redis),
);
} finally {
await disconnect();
}
Expand Down
19 changes: 19 additions & 0 deletions lib/features/connections/connection_test_result.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// The result a connection form keeps after *Test Connection*: `success`,
/// `failed` (no reason known), `error: <text>` (an unexpected exception), or
/// the reason itself, in the user's terms (#1308).
const connectionTestSuccess = 'success';

/// The line the form shows for [result].
String connectionTestMessage(String result) {
if (result == connectionTestSuccess) return 'Connection successful!';
if (result == 'failed') return 'Connection failed';
if (result.startsWith('error:')) return result.substring(6).trim();
return result;
}

/// How long the result stays before it dismisses itself: a reason takes
/// longer to read than "Connection successful!".
Duration connectionTestResultLifetime(String result) => result ==
connectionTestSuccess
? const Duration(seconds: 5)
: const Duration(seconds: 20);
9 changes: 3 additions & 6 deletions lib/features/connections/sqlite_connection_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart' as material;
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/security/connection_environment.dart';
import 'package:querya_desktop/features/connections/connection_test_result.dart';
import 'package:querya_desktop/features/connections/environment_section.dart';
import 'package:querya_desktop/core/database/sqlite_connection.dart';
import 'package:querya_desktop/core/layout/window_layout.dart';
Expand Down Expand Up @@ -107,7 +108,7 @@ class _SqliteConnectionFormContentState
_isTesting = false;
_testResult = result;
});
_dismissTimer = Timer(const Duration(seconds: 5), () {
_dismissTimer = Timer(connectionTestResultLifetime(result), () {
if (mounted) setState(() => _testResult = null);
});
}
Expand Down Expand Up @@ -354,11 +355,7 @@ class _SqliteConnectionFormContentState
const Gap(10),
material.Expanded(
child: Text(
_testResult == 'success'
? 'Connection successful!'
: _testResult!.startsWith('error:')
? _testResult!.substring(7)
: 'Connection failed',
connectionTestMessage(_testResult!),
style: material.TextStyle(
fontSize: 13,
color: theme.foreground,
Expand Down
15 changes: 7 additions & 8 deletions lib/features/mongodb/mongodb_connection_form.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';

import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/features/connections/connection_test_result.dart';
import 'package:querya_desktop/features/connections/connection_default_name.dart';
import 'package:querya_desktop/core/security/connection_environment.dart';
import 'package:querya_desktop/features/connections/environment_section.dart';
Expand Down Expand Up @@ -266,7 +267,7 @@ class _MongoConnectionFormContentState
_isTesting = false;
_testResult = result;
});
_dismissTimer = Timer(const Duration(seconds: 5), () {
_dismissTimer = Timer(connectionTestResultLifetime(result), () {
if (mounted) setState(() => _testResult = null);
});
}
Expand Down Expand Up @@ -309,10 +310,12 @@ class _MongoConnectionFormContentState
sshSecrets: secrets.sshSecrets,
);

final success = await connection.testConnection();
final result = await connection.testConnection();
await connection.disconnect();

if (mounted) _showTestResult(success ? 'success' : 'failed');
if (mounted) {
_showTestResult(result.ok ? 'success' : (result.error ?? 'failed'));
}
} catch (e) {
if (mounted) _showTestResult('error: $e');
}
Expand Down Expand Up @@ -669,11 +672,7 @@ class _MongoConnectionFormContentState
const Gap(10),
material.Expanded(
child: Text(
_testResult == 'success'
? 'Connection successful!'
: _testResult!.startsWith('error:')
? _testResult!.substring(7)
: 'Connection failed',
connectionTestMessage(_testResult!),
style: material.TextStyle(
fontSize: 13,
color: theme.foreground,
Expand Down
15 changes: 7 additions & 8 deletions lib/features/mysql/mysql_connection_form.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';

import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/features/connections/connection_test_result.dart';
import 'package:querya_desktop/features/connections/connection_default_name.dart';
import 'package:querya_desktop/core/security/connection_environment.dart';
import 'package:querya_desktop/features/connections/environment_section.dart';
Expand Down Expand Up @@ -191,7 +192,7 @@ class _MysqlConnectionFormContentState
_isTesting = false;
_testResult = result;
});
_dismissTimer = Timer(const Duration(seconds: 5), () {
_dismissTimer = Timer(connectionTestResultLifetime(result), () {
if (mounted) setState(() => _testResult = null);
});
}
Expand Down Expand Up @@ -238,8 +239,10 @@ class _MysqlConnectionFormContentState
sshConfig: _sshConfig.enabled ? _sshConfig : null,
sshSecrets: secrets.sshSecrets,
);
final ok = await conn.testConnection();
if (mounted) _showTestResult(ok ? 'success' : 'failed');
final result = await conn.testConnection();
if (mounted) {
_showTestResult(result.ok ? 'success' : (result.error ?? 'failed'));
}
} catch (e) {
if (mounted) _showTestResult('error: $e');
}
Expand Down Expand Up @@ -577,11 +580,7 @@ class _MysqlConnectionFormContentState
const Gap(10),
material.Expanded(
child: Text(
_testResult == 'success'
? 'Connection successful!'
: _testResult!.startsWith('error:')
? _testResult!.substring(7)
: 'Connection failed',
connectionTestMessage(_testResult!),
style: material.TextStyle(
fontSize: 13,
color: theme.foreground,
Expand Down
9 changes: 3 additions & 6 deletions lib/features/postgresql/postgresql_connection_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/features/connections/connection_test_result.dart';
import 'package:querya_desktop/features/connections/connection_default_name.dart';
import 'package:querya_desktop/core/security/connection_environment.dart';
import 'package:querya_desktop/features/connections/environment_section.dart';
Expand Down Expand Up @@ -252,7 +253,7 @@ class _PostgresConnectionFormContentState
_isTesting = false;
_testResult = result;
});
_dismissTimer = Timer(const Duration(seconds: 5), () {
_dismissTimer = Timer(connectionTestResultLifetime(result), () {
if (mounted) setState(() => _testResult = null);
});
}
Expand Down Expand Up @@ -757,11 +758,7 @@ class _PostgresConnectionFormContentState
const Gap(10),
material.Expanded(
child: Text(
_testResult == 'success'
? 'Connection successful!'
: _testResult!.startsWith('error:')
? _testResult!.substring(7)
: 'Connection failed',
connectionTestMessage(_testResult!),
style: material.TextStyle(
fontSize: 13,
color: theme.foreground,
Expand Down
15 changes: 7 additions & 8 deletions lib/features/redis/redis_connection_form.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';

import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/features/connections/connection_test_result.dart';
import 'package:querya_desktop/features/connections/connection_default_name.dart';
import 'package:querya_desktop/core/security/connection_environment.dart';
import 'package:querya_desktop/features/connections/environment_section.dart';
Expand Down Expand Up @@ -177,7 +178,7 @@ class _RedisConnectionFormContentState
_isTesting = false;
_testResult = result;
});
_dismissTimer = Timer(const Duration(seconds: 5), () {
_dismissTimer = Timer(connectionTestResultLifetime(result), () {
if (mounted) setState(() => _testResult = null);
});
}
Expand Down Expand Up @@ -210,8 +211,10 @@ class _RedisConnectionFormContentState
connectionString: secrets.connectionString,
sshSecrets: secrets.sshSecrets,
));
final ok = await conn.testConnection();
if (mounted) _showTestResult(ok ? 'success' : 'failed');
final result = await conn.testConnection();
if (mounted) {
_showTestResult(result.ok ? 'success' : (result.error ?? 'failed'));
}
} catch (e) {
if (mounted) _showTestResult('error: $e');
}
Expand Down Expand Up @@ -519,11 +522,7 @@ class _RedisConnectionFormContentState
const Gap(10),
material.Expanded(
child: Text(
_testResult == 'success'
? 'Connection successful!'
: _testResult!.startsWith('error:')
? _testResult!.substring(7)
: 'Connection failed',
connectionTestMessage(_testResult!),
style: material.TextStyle(
fontSize: 13,
color: theme.foreground,
Expand Down
91 changes: 91 additions & 0 deletions test/features/connections/connection_test_result_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/database/mysql_connection.dart';
import 'package:querya_desktop/core/database/redis_connection.dart';
import 'package:querya_desktop/features/connections/connection_test_result.dart';

/// #1308: Test Connection says why it failed.
void main() {
group('connectionTestMessage', () {
test('success, a bare failure, an exception and a reason', () {
expect(connectionTestMessage('success'), 'Connection successful!');
expect(connectionTestMessage('failed'), 'Connection failed');
expect(connectionTestMessage('error: boom'), 'boom');
// The SQLite form writes `error:` without a space; no letter is lost.
expect(connectionTestMessage('error:no such file'), 'no such file');
expect(
connectionTestMessage(
'Authentication failed. Check the username and password'),
'Authentication failed. Check the username and password',
);
});

test('a reason stays on screen longer than a success', () {
expect(connectionTestResultLifetime('success'),
lessThan(connectionTestResultLifetime('Authentication failed')));
});
});

/// A port nobody listens on.
Future<int> closedPort() async {
final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final port = s.port;
await s.close();
return port;
}

group('testConnection returns the reason', () {
test('Redis: a wrong password is an authentication failure', () async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(server.close);
server.listen((socket) {
socket.listen((data) {
final text = utf8.decode(data, allowMalformed: true).toUpperCase();
if (text.contains('AUTH')) {
socket.write('-WRONGPASS invalid username-password pair\r\n');
} else if (text.contains('PING')) {
socket.write('+PONG\r\n');
}
}, onError: (_) {}, onDone: socket.destroy);
});

final r = await RedisConnection(
id: 0,
name: 'r',
host: '127.0.0.1',
port: server.port,
password: 'wrong',
).testConnection();

expect(r.ok, isFalse);
expect(r.error, contains('Authentication failed'));
});

test('Redis: nothing listening is unreachable', () async {
final r = await RedisConnection(
id: 0,
name: 'r',
host: '127.0.0.1',
port: await closedPort(),
).testConnection();

expect(r.ok, isFalse);
expect(r.error, contains('Cannot reach'));
});

test('MySQL: nothing listening is unreachable', () async {
final r = await MysqlConnection(
id: 0,
name: 'm',
host: '127.0.0.1',
port: await closedPort(),
useSSL: false,
).testConnection();

expect(r.ok, isFalse);
expect(r.error, contains('Cannot reach'));
});
});
}
Loading