TF-3976 Support DNS SRV resolvers without Cloudflare/Google dependency
This commit is contained in:
@@ -1,23 +1,23 @@
|
|||||||
import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.dart';
|
import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
|
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/model/recent_login_url.dart';
|
import 'package:tmail_ui_user/features/login/domain/model/recent_login_url.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/model/recent_login_username.dart';
|
import 'package:tmail_ui_user/features/login/domain/model/recent_login_username.dart';
|
||||||
import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart';
|
import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart';
|
||||||
|
|
||||||
class LoginDataSourceImpl implements LoginDataSource {
|
class LoginDataSourceImpl implements LoginDataSource {
|
||||||
|
|
||||||
final DNSService _dnsService;
|
final DnsLookupManager _dnsLookupManager;
|
||||||
final ExceptionThrower _exceptionThrower;
|
final ExceptionThrower _exceptionThrower;
|
||||||
|
|
||||||
LoginDataSourceImpl(
|
LoginDataSourceImpl(
|
||||||
this._dnsService,
|
this._dnsLookupManager,
|
||||||
this._exceptionThrower
|
this._exceptionThrower
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> dnsLookupToGetJmapUrl(String emailAddress) {
|
Future<String> dnsLookupToGetJmapUrl(String emailAddress) {
|
||||||
return Future.sync(() async {
|
return Future.sync(() async {
|
||||||
return await _dnsService.getJmapUrl(emailAddress);
|
return await _dnsLookupManager.lookupJmapUrl(emailAddress);
|
||||||
}).catchError(_exceptionThrower.throwException);
|
}).catchError(_exceptionThrower.throwException);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:core/utils/app_logger.dart';
|
||||||
|
import 'package:core/utils/build_utils.dart';
|
||||||
|
import 'package:super_dns_client/super_dns_client.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_priority.dart';
|
||||||
|
|
||||||
|
/// Handles DNS SRV lookups for JMAP service discovery.
|
||||||
|
///
|
||||||
|
/// The manager attempts lookups in order of priority:
|
||||||
|
/// **System → Public UDP → Public DoH → Cloud (Google/Cloudflare)**.
|
||||||
|
class DnsLookupManager {
|
||||||
|
static const String _jmapServicePrefix = '_jmap._tcp';
|
||||||
|
static const Duration _defaultTimeout = Duration(seconds: 3);
|
||||||
|
|
||||||
|
/// Builds the JMAP SRV hostname from [emailAddress].
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```
|
||||||
|
/// input : user@example.com
|
||||||
|
/// output: _jmap._tcp.example.com
|
||||||
|
/// ```
|
||||||
|
String buildJmapHostName(String emailAddress) {
|
||||||
|
final parts = emailAddress.split('@');
|
||||||
|
if (parts.length != 2 || parts[1].isEmpty) {
|
||||||
|
throw ArgumentError('Invalid email address: $emailAddress');
|
||||||
|
}
|
||||||
|
return '$_jmapServicePrefix.${parts[1]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the appropriate [DnsClient] for the given [priority].
|
||||||
|
DnsClient createClient(DnsLookupPriority priority) {
|
||||||
|
const debug = BuildUtils.isDebugMode;
|
||||||
|
switch (priority) {
|
||||||
|
case DnsLookupPriority.system:
|
||||||
|
return SystemUdpSrvClient(debugMode: debug);
|
||||||
|
case DnsLookupPriority.publicUdp:
|
||||||
|
return PublicUdpSrvClient(debugMode: debug);
|
||||||
|
case DnsLookupPriority.publicDoh:
|
||||||
|
return DnsOverHttpsBinaryClient(debugMode: debug);
|
||||||
|
case DnsLookupPriority.cloud:
|
||||||
|
return DnsOverHttps.google(debugMode: debug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts SRV resolution for the JMAP hostname derived from [emailAddress].
|
||||||
|
///
|
||||||
|
/// Each lookup attempt will timeout after [_defaultTimeout] seconds.
|
||||||
|
/// Returns the first successfully resolved hostname, or an empty string if all fail.
|
||||||
|
Future<String> lookupJmapUrl(String emailAddress) async {
|
||||||
|
final jmapHostName = buildJmapHostName(emailAddress);
|
||||||
|
log('$runtimeType::lookupJmapUrl → Resolving SRV for: $jmapHostName');
|
||||||
|
|
||||||
|
final priorities = List.of(DnsLookupPriority.values)
|
||||||
|
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||||
|
|
||||||
|
for (final priority in priorities) {
|
||||||
|
final client = createClient(priority);
|
||||||
|
log('$runtimeType::lookupJmapUrl → 🔍 Trying ${priority.label} (timeout: ${_defaultTimeout.inSeconds}s)...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final records = client is DnsOverHttps
|
||||||
|
? await client.lookupSrvParallel(jmapHostName).timeout(
|
||||||
|
_defaultTimeout,
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException(
|
||||||
|
'Lookup timed out after ${_defaultTimeout.inSeconds}s');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: await client.lookupSrv(jmapHostName).timeout(
|
||||||
|
_defaultTimeout,
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException(
|
||||||
|
'Lookup timed out after ${_defaultTimeout.inSeconds}s');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (records.isNotEmpty) {
|
||||||
|
final target = records.first.target;
|
||||||
|
log('$runtimeType::lookupJmapUrl → ✅ Success via ${priority.label}: $target');
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('$runtimeType::lookupJmapUrl → ⚠️ No records via ${priority.label}, continuing...');
|
||||||
|
} on TimeoutException catch (_) {
|
||||||
|
logError(
|
||||||
|
'$runtimeType::lookupJmapUrl → ⏱️ ${priority.label} lookup timed out');
|
||||||
|
} catch (error, stack) {
|
||||||
|
logError(
|
||||||
|
'$runtimeType::lookupJmapUrl → ❌ ${priority.label} lookup failed: $error, $stack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log('$runtimeType::lookupJmapUrl → 🚨 All DNS lookups failed for $jmapHostName');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// Represents the priority order and description of different DNS lookup modes.
|
||||||
|
///
|
||||||
|
/// Priority level increases with fallback order:
|
||||||
|
/// 1 → System default
|
||||||
|
/// 2 → Public resolvers (UDP/TCP or DOH)
|
||||||
|
/// 3 → Cloud resolvers (Google/Cloudflare)
|
||||||
|
enum DnsLookupPriority {
|
||||||
|
/// Uses the device's system-configured DNS (e.g., from ISP or OS settings).
|
||||||
|
system(1, 'System Default'),
|
||||||
|
|
||||||
|
/// Uses open DNS resolvers accessible via UDP/TCP (e.g., Quad9, OpenDNS).
|
||||||
|
publicUdp(2, 'Public DNS (UDP/TCP)'),
|
||||||
|
|
||||||
|
/// Uses DNS-over-HTTPS (DoH) resolvers for secure name resolution.
|
||||||
|
publicDoh(2, 'Public DNS (DoH)'),
|
||||||
|
|
||||||
|
/// Uses Google or Cloudflare DNS resolvers.
|
||||||
|
cloud(3, 'Cloud DNS (Google/Cloudflare)');
|
||||||
|
|
||||||
|
/// The lookup priority (lower means higher priority).
|
||||||
|
final int priority;
|
||||||
|
|
||||||
|
/// A human-readable description for UI or logging.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const DnsLookupPriority(this.priority, this.label);
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
|
|
||||||
import 'package:core/utils/app_logger.dart';
|
|
||||||
import 'package:dns_client/dns_client.dart';
|
|
||||||
import 'package:tmail_ui_user/features/login/domain/exceptions/login_exception.dart';
|
|
||||||
|
|
||||||
class DNSService {
|
|
||||||
static const String _jmapServiceName = '_jmap._tcp';
|
|
||||||
|
|
||||||
Future<String> _dnsLookupToUrlFromSRVType({required String hostName}) async {
|
|
||||||
try {
|
|
||||||
final url = await _dnsLookupToUrlByGoogle(hostName: hostName);
|
|
||||||
if (url.isEmpty) {
|
|
||||||
return await _dnsLookupToUrlByCloudflare(hostName: hostName);
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
} catch (e) {
|
|
||||||
return await _dnsLookupToUrlByCloudflare(hostName: hostName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _dnsLookupToUrlByGoogle({required String hostName}) async {
|
|
||||||
final dns = DnsOverHttps.google();
|
|
||||||
final listData = await dns.lookupDataByRRType(hostName, RRType.SRVType);
|
|
||||||
if (listData.isEmpty) {
|
|
||||||
throw NotFoundDataResourceRecordException();
|
|
||||||
}
|
|
||||||
return _parsingUrlFromDataResourceRecord(listData.first);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _dnsLookupToUrlByCloudflare({required String hostName}) async {
|
|
||||||
final dns = DnsOverHttps.cloudflare();
|
|
||||||
final listData = await dns.lookupDataByRRType(hostName, RRType.SRVType);
|
|
||||||
if (listData.isEmpty) {
|
|
||||||
throw NotFoundDataResourceRecordException();
|
|
||||||
}
|
|
||||||
return _parsingUrlFromDataResourceRecord(listData.first);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _parsingUrlFromDataResourceRecord(String data) {
|
|
||||||
if (data.isEmpty) {
|
|
||||||
throw NotFoundDataResourceRecordException();
|
|
||||||
}
|
|
||||||
final listFieldData = data.split(' ');
|
|
||||||
if (listFieldData.isEmpty) {
|
|
||||||
throw NotFoundUrlException();
|
|
||||||
}
|
|
||||||
final url = _removeDotAtEndOfString(listFieldData.last);
|
|
||||||
log('DNSService::_parsingUrlFromDataResourceRecord:url: $url');
|
|
||||||
if (url.trim().isEmpty) {
|
|
||||||
throw NotFoundUrlException();
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> getJmapUrl(String emailAddress) async {
|
|
||||||
final domainName = emailAddress.split('@')[1];
|
|
||||||
final jmapHostName = '$_jmapServiceName.$domainName';
|
|
||||||
log('DNSHandler::getJmapUrl:jmapHostName: $jmapHostName');
|
|
||||||
return await _dnsLookupToUrlFromSRVType(hostName: jmapHostName);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _removeDotAtEndOfString(String value) {
|
|
||||||
if (value.lastIndexOf('.') == value.length - 1) {
|
|
||||||
return value.substring(0, value.length - 1);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.da
|
|||||||
import 'package:tmail_ui_user/features/login/data/datasource_impl/hive_login_datasource_impl.dart';
|
import 'package:tmail_ui_user/features/login/data/datasource_impl/hive_login_datasource_impl.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/datasource_impl/login_datasource_impl.dart';
|
import 'package:tmail_ui_user/features/login/data/datasource_impl/login_datasource_impl.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
|
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
|
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/repository/login_repository_impl.dart';
|
import 'package:tmail_ui_user/features/login/data/repository/login_repository_impl.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart';
|
import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart';
|
import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart';
|
||||||
@@ -74,7 +74,7 @@ class LoginBindings extends BaseBindings {
|
|||||||
Get.find<CacheExceptionThrower>()
|
Get.find<CacheExceptionThrower>()
|
||||||
));
|
));
|
||||||
Get.lazyPut(() => LoginDataSourceImpl(
|
Get.lazyPut(() => LoginDataSourceImpl(
|
||||||
Get.find<DNSService>(),
|
Get.find<DnsLookupManager>(),
|
||||||
Get.find<RemoteExceptionThrower>(),
|
Get.find<RemoteExceptionThrower>(),
|
||||||
));
|
));
|
||||||
Get.lazyPut(() => SaasAuthenticationDataSourceImpl(
|
Get.lazyPut(() => SaasAuthenticationDataSourceImpl(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import 'package:tmail_ui_user/features/login/data/local/authentication_info_cach
|
|||||||
import 'package:tmail_ui_user/features/login/data/local/oidc_configuration_cache_manager.dart';
|
import 'package:tmail_ui_user/features/login/data/local/oidc_configuration_cache_manager.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/local/token_oidc_cache_manager.dart';
|
import 'package:tmail_ui_user/features/login/data/local/token_oidc_cache_manager.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
|
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
|
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/network/interceptors/authorization_interceptors.dart';
|
import 'package:tmail_ui_user/features/login/data/network/interceptors/authorization_interceptors.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/network/oidc_http_client.dart';
|
import 'package:tmail_ui_user/features/login/data/network/oidc_http_client.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/utils/library_platform/app_auth_plugin/app_auth_plugin.dart';
|
import 'package:tmail_ui_user/features/login/data/utils/library_platform/app_auth_plugin/app_auth_plugin.dart';
|
||||||
@@ -144,6 +144,6 @@ class NetworkBindings extends Bindings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _bindingServices() {
|
void _bindingServices() {
|
||||||
Get.put(DNSService());
|
Get.put(DnsLookupManager());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+36
-13
@@ -77,10 +77,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: args
|
name: args
|
||||||
sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2"
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -463,15 +463,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
dns_client:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: "."
|
|
||||||
ref: twake-supported
|
|
||||||
resolved-ref: "0966c504c1a813ff40e22ea8896537c32d97b82d"
|
|
||||||
url: "https://github.com/linagora/dns_client.git"
|
|
||||||
source: git
|
|
||||||
version: "0.2.1"
|
|
||||||
dotted_border:
|
dotted_border:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1176,10 +1167,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: freezed_annotation
|
name: freezed_annotation
|
||||||
sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d
|
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.4"
|
||||||
frontend_server_client:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -2166,6 +2157,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
super_dns:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: super_dns
|
||||||
|
sha256: "213d06aafacdcb342ba491faceb50cfca19e90df9cbd2216b39949b5080b283d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
|
super_dns_client:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: super_dns_client
|
||||||
|
sha256: "78c2b9c38d96d95f85d495902fc3bc49354cd63db7c4ebd15faa55bff2e4d13f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
|
super_ip:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: super_ip
|
||||||
|
sha256: "450f781cddd61b34793063e6f0508c190ca0664bb80a7df12a12b9ec4cd90d0e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
|
super_raw:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: super_raw
|
||||||
|
sha256: "7f33a7b6d11b0a829c9a8eda27767ea63f8ef61e1bcc179055861a84f1ad5744"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
super_tag_editor:
|
super_tag_editor:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-7
@@ -90,13 +90,6 @@ dependencies:
|
|||||||
url: https://github.com/linagora/flutter-date-range-picker.git
|
url: https://github.com/linagora/flutter-date-range-picker.git
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
# TODO: We will change it when the PR in upstream repository will be merged
|
|
||||||
# https://github.com/dietfriends/dns_client/pull/9
|
|
||||||
dns_client:
|
|
||||||
git:
|
|
||||||
url: https://github.com/linagora/dns_client.git
|
|
||||||
ref: twake-supported
|
|
||||||
|
|
||||||
linagora_design_flutter:
|
linagora_design_flutter:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/linagora/linagora-design-flutter.git
|
url: https://github.com/linagora/linagora-design-flutter.git
|
||||||
@@ -112,6 +105,8 @@ dependencies:
|
|||||||
### Dependencies from pub.dev ###
|
### Dependencies from pub.dev ###
|
||||||
super_tag_editor: 1.1.0
|
super_tag_editor: 1.1.0
|
||||||
|
|
||||||
|
super_dns_client: 0.3.1
|
||||||
|
|
||||||
external_app_launcher: 4.0.3
|
external_app_launcher: 4.0.3
|
||||||
|
|
||||||
cupertino_icons: 1.0.6
|
cupertino_icons: 1.0.6
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mockito/annotations.dart';
|
||||||
|
import 'package:mockito/mockito.dart';
|
||||||
|
import 'package:super_dns_client/super_dns_client.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_priority.dart';
|
||||||
|
|
||||||
|
// Generate mock class for DnsClient
|
||||||
|
@GenerateMocks([DnsClient])
|
||||||
|
import 'dns_lookup_manager_test.mocks.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockDnsClient mockSystemClient;
|
||||||
|
late MockDnsClient mockPublicClient;
|
||||||
|
late MockDnsClient mockDohClient;
|
||||||
|
late MockDnsClient mockCloudClient;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockSystemClient = MockDnsClient();
|
||||||
|
mockPublicClient = MockDnsClient();
|
||||||
|
mockDohClient = MockDnsClient();
|
||||||
|
mockCloudClient = MockDnsClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DnsLookupManager.lookupJmapUrl', () {
|
||||||
|
test('✅ should return target when system resolver succeeds', () async {
|
||||||
|
// Arrange
|
||||||
|
when(mockSystemClient.lookupSrv(any)).thenAnswer((_) async => [
|
||||||
|
const SrvRecord(
|
||||||
|
name: '_jmap._tcp.example.com',
|
||||||
|
port: 443,
|
||||||
|
priority: 10,
|
||||||
|
weight: 5,
|
||||||
|
ttl: 3600,
|
||||||
|
target: 'mail.example.com',
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
final manager = _TestableDnsLookupManager({
|
||||||
|
DnsLookupPriority.system: mockSystemClient,
|
||||||
|
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||||
|
DnsLookupPriority.publicDoh: mockDohClient,
|
||||||
|
DnsLookupPriority.cloud: mockCloudClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final result = await manager.lookupJmapUrl('user@example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result, equals('mail.example.com'));
|
||||||
|
verify(mockSystemClient.lookupSrv('_jmap._tcp.example.com')).called(1);
|
||||||
|
verifyNever(mockPublicClient.lookupSrv(any));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('✅ should fall back when previous resolver fails', () async {
|
||||||
|
// Arrange
|
||||||
|
when(mockSystemClient.lookupSrv(any))
|
||||||
|
.thenThrow(Exception('System failed'));
|
||||||
|
when(mockPublicClient.lookupSrv(any)).thenAnswer((_) async => [
|
||||||
|
const SrvRecord(
|
||||||
|
name: '_jmap._tcp.example.com',
|
||||||
|
port: 443,
|
||||||
|
priority: 20,
|
||||||
|
weight: 5,
|
||||||
|
ttl: 3600,
|
||||||
|
target: 'mail-backup.example.com',
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
final manager = _TestableDnsLookupManager({
|
||||||
|
DnsLookupPriority.system: mockSystemClient,
|
||||||
|
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||||
|
DnsLookupPriority.publicDoh: mockDohClient,
|
||||||
|
DnsLookupPriority.cloud: mockCloudClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final result = await manager.lookupJmapUrl('user@example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result, equals('mail-backup.example.com'));
|
||||||
|
verifyInOrder([
|
||||||
|
mockSystemClient.lookupSrv(any),
|
||||||
|
mockPublicClient.lookupSrv(any),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('✅ should skip to next resolver when previous returns empty list',
|
||||||
|
() async {
|
||||||
|
// Arrange
|
||||||
|
when(mockSystemClient.lookupSrv(any)).thenAnswer((_) async => []);
|
||||||
|
when(mockPublicClient.lookupSrv(any)).thenAnswer((_) async => [
|
||||||
|
const SrvRecord(
|
||||||
|
name: '_jmap._tcp.example.com',
|
||||||
|
port: 443,
|
||||||
|
priority: 15,
|
||||||
|
weight: 5,
|
||||||
|
ttl: 3600,
|
||||||
|
target: 'mail-fallback.example.com',
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
final manager = _TestableDnsLookupManager({
|
||||||
|
DnsLookupPriority.system: mockSystemClient,
|
||||||
|
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||||
|
DnsLookupPriority.publicDoh: mockDohClient,
|
||||||
|
DnsLookupPriority.cloud: mockCloudClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final result = await manager.lookupJmapUrl('user@example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result, equals('mail-fallback.example.com'));
|
||||||
|
verifyInOrder([
|
||||||
|
mockSystemClient.lookupSrv(any),
|
||||||
|
mockPublicClient.lookupSrv(any),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('⚠️ should continue when some resolvers throw exceptions', () async {
|
||||||
|
// Arrange
|
||||||
|
when(mockSystemClient.lookupSrv(any))
|
||||||
|
.thenThrow(Exception('System crashed'));
|
||||||
|
when(mockPublicClient.lookupSrv(any))
|
||||||
|
.thenThrow(Exception('Network down'));
|
||||||
|
when(mockDohClient.lookupSrv(any)).thenAnswer((_) async => [
|
||||||
|
const SrvRecord(
|
||||||
|
name: '_jmap._tcp.example.com',
|
||||||
|
port: 443,
|
||||||
|
priority: 5,
|
||||||
|
weight: 5,
|
||||||
|
ttl: 3600,
|
||||||
|
target: 'mail-doh.example.com',
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
final manager = _TestableDnsLookupManager({
|
||||||
|
DnsLookupPriority.system: mockSystemClient,
|
||||||
|
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||||
|
DnsLookupPriority.publicDoh: mockDohClient,
|
||||||
|
DnsLookupPriority.cloud: mockCloudClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final result = await manager.lookupJmapUrl('user@example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result, equals('mail-doh.example.com'));
|
||||||
|
verify(mockDohClient.lookupSrv(any)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('⏱️ should return empty string when all resolvers timeout', () async {
|
||||||
|
// Arrange: simulate all resolvers hanging
|
||||||
|
when(mockSystemClient.lookupSrv(any)).thenAnswer(
|
||||||
|
(_) => Future.delayed(const Duration(seconds: 10), () => []));
|
||||||
|
when(mockPublicClient.lookupSrv(any)).thenAnswer(
|
||||||
|
(_) => Future.delayed(const Duration(seconds: 10), () => []));
|
||||||
|
when(mockDohClient.lookupSrv(any)).thenAnswer(
|
||||||
|
(_) => Future.delayed(const Duration(seconds: 10), () => []));
|
||||||
|
when(mockCloudClient.lookupSrv(any)).thenAnswer(
|
||||||
|
(_) => Future.delayed(const Duration(seconds: 10), () => []));
|
||||||
|
|
||||||
|
final manager = _TestableDnsLookupManager({
|
||||||
|
DnsLookupPriority.system: mockSystemClient,
|
||||||
|
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||||
|
DnsLookupPriority.publicDoh: mockDohClient,
|
||||||
|
DnsLookupPriority.cloud: mockCloudClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final result = await manager.lookupJmapUrl('user@example.com');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('❌ should throw ArgumentError when email is invalid', () async {
|
||||||
|
final manager = _TestableDnsLookupManager({});
|
||||||
|
expect(
|
||||||
|
() => manager.lookupJmapUrl('invalid-email'),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DnsLookupManager._buildJmapHostName', () {
|
||||||
|
test('✅ should build correct host name from email', () {
|
||||||
|
final manager = DnsLookupManager();
|
||||||
|
final result = manager.buildJmapHostNameForTest('user@domain.com');
|
||||||
|
expect(result, equals('_jmap._tcp.domain.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('❌ should throw ArgumentError for malformed email', () {
|
||||||
|
final manager = DnsLookupManager();
|
||||||
|
expect(
|
||||||
|
() => manager.buildJmapHostNameForTest('invalid'),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test helper subclass that injects mock DNS clients.
|
||||||
|
class _TestableDnsLookupManager extends DnsLookupManager {
|
||||||
|
final Map<DnsLookupPriority, DnsClient> clients;
|
||||||
|
|
||||||
|
_TestableDnsLookupManager(this.clients);
|
||||||
|
|
||||||
|
@override
|
||||||
|
DnsClient createClient(DnsLookupPriority priority) {
|
||||||
|
final client = clients[priority];
|
||||||
|
if (client == null) {
|
||||||
|
throw StateError('No mock client provided for $priority');
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extends real DnsLookupManager only for testing private helpers.
|
||||||
|
extension DnsLookupManagerTestable on DnsLookupManager {
|
||||||
|
String buildJmapHostNameForTest(String email) => buildJmapHostName(email);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user