diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index f2ba95d4..51037144 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -462,17 +462,20 @@ class MongoConnection { } } - /// Tests the connection. - Future 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), + ); } } diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index d23914b4..e56d6d69 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -484,17 +484,23 @@ class MysqlConnection { ? 'SET SESSION TRANSACTION READ ONLY' : 'SET SESSION TRANSACTION READ WRITE'; - Future 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(); } diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index b928f948..4df63544 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -305,13 +305,18 @@ class RedisConnection { return RedisBulkValue.fromReply(result).text ?? ''; } - Future 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(); } diff --git a/lib/features/connections/connection_test_result.dart b/lib/features/connections/connection_test_result.dart new file mode 100644 index 00000000..a76dae26 --- /dev/null +++ b/lib/features/connections/connection_test_result.dart @@ -0,0 +1,19 @@ +/// The result a connection form keeps after *Test Connection*: `success`, +/// `failed` (no reason known), `error: ` (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); diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 87ad6707..a6931fc8 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -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'; @@ -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); }); } @@ -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, diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 53a4abd0..a504f062 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -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'; @@ -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); }); } @@ -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'); } @@ -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, diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index e24ea94e..8bad8312 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -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'; @@ -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); }); } @@ -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'); } @@ -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, diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 8350a59c..1dce7875 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -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'; @@ -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); }); } @@ -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, diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index c34ac378..e1f5337f 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -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'; @@ -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); }); } @@ -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'); } @@ -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, diff --git a/test/features/connections/connection_test_result_test.dart b/test/features/connections/connection_test_result_test.dart new file mode 100644 index 00000000..833e9f3f --- /dev/null +++ b/test/features/connections/connection_test_result_test.dart @@ -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 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')); + }); + }); +}