import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/dns_resolver.dart'; import 'package:jj_mkvmaker/core/local_proxy.dart'; import 'support/fake_dns.dart'; /// 정한 DNS 서버로 이름을 푸는지 · 앱 안 프록시가 그 이름으로 잇는지 · 같은 집 안 이름은 기기 DNS 로 · Tor 다리는 이름을 Tor 에 넘기는지 void main() { DnsConfig custom(String primary, [String secondary = '']) => DnsConfig(mode: 'custom', primary: primary, secondary: secondary); group('DNS 풀기 (가짜 서버)', () { late FakeDns dns; setUp(() async { dns = FakeDns({'example.test': '10.1.2.3', 'up.test': '127.0.0.1'}); await dns.start(); }); tearDown(() => dns.close()); test('정한 서버에 묻고 답을 TTL 동안 기억한다', () async { final r = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port); String? server; r.onAnswer = (_, s) => server = s; final a = await r.lookup('example.test'); expect(a.map((x) => x.address), ['10.1.2.3']); expect(server, '127.0.0.1'); final n = dns.queries.length; expect(await r.lookup('EXAMPLE.test.'), a); // 대소문자 · 끝 점은 같은 이름 expect(dns.queries.length, n, reason: '기억한 답'); }); test('없는 이름은 "찾을 수 없음" (서버에 닿지 않음이 아님)', () async { final r = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port); final e = await r.lookup('nope.test').then((_) => null, onError: (Object e) => e); expect(e, isA()); expect((e as DnsException).unreachable, isFalse); }); test('첫 서버가 답하지 않으면 보조 서버', () async { // 127.0.0.2 에는 아무도 없다 final r = DnsResolver(config: () => custom('127.0.0.2', '127.0.0.1'), port: dns.port, timeout: const Duration(milliseconds: 400)); final a = await r.lookup('example.test'); expect(a.single.address, '10.1.2.3'); }); test('잘린 답 (TC) 은 TCP 로 다시 묻는다', () async { final t = FakeDns({'big.test': '10.9.9.9'}, truncateUdp: true); await t.start(); addTearDown(t.close); // 흔들림: UDP (잘림) → TCP 다시 묻기가 두 번 왕복이라, 전체 시험이 무거울 때 처음 값 2.5초를 넘길 수 있다 - 넉넉히 final r = DnsResolver(config: () => custom('127.0.0.1'), port: t.port, timeout: const Duration(seconds: 10)); // 전체 실행에서만 실패한 적이 있다 - 실패하면 실제 오류와 가짜 DNS 의 기록을 남긴다 try { expect((await r.lookup('big.test')).single.address, '10.9.9.9'); expect(t.tcpQueries, greaterThan(0)); } catch (e, st) { // ignore: avoid_print print('TC 시험 실패: $e (${e.runtimeType}${e is SocketException ? ', errno ${e.osError?.errorCode}' : ''})\n' '가짜 DNS: port ${t.port}, ${t.events}, 질문 ${t.queries}\n$st'); rethrow; } }); test('서버에 닿지 않으면 unreachable 오류 (기기 DNS 로 바꾸지 않음)', () async { final r = DnsResolver(config: () => custom('127.0.0.2'), port: dns.port, timeout: const Duration(milliseconds: 300)); final e = await r.lookup('www.google.com').then((_) => null, onError: (Object e) => e); expect(e, isA()); expect((e as DnsException).unreachable, isTrue); expect(e.server, '127.0.0.2'); }); test('DNS over HTTPS (RFC 8484 POST)', () async { final http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => http.close(force: true)); String? contentType; http.listen((req) async { contentType = req.headers.contentType?.mimeType; final q = Uint8List.fromList(await req.fold>([], (a, b) => a..addAll(b))); // 받은 질문을 가짜 DNS 서버에 그대로 넘겨 답을 돌려준다 final s = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); final got = Completer(); s.listen((e) { final d = s.receive(); if (d != null && !got.isCompleted) got.complete(d.data); }); s.send(q, InternetAddress.loopbackIPv4, dns.port); final a = await got.future; s.close(); req.response.headers.contentType = ContentType('application', 'dns-message'); req.response.add(a); await req.response.close(); }); final r = DnsResolver( config: () => DnsConfig(mode: 'doh', dohUrl: 'http://127.0.0.1:${http.port}/dns-query', primary: '127.0.0.2'), dohClient: HttpClient.new); expect((await r.lookup('example.test')).single.address, '10.1.2.3'); expect(contentType, 'application/dns-message'); }); }); test('DNS 질문 · 답 읽기 (줄임 표시)', () { final q = buildDnsQuery('a.example.com', 1, id: 0x1234); expect(q.sublist(0, 2), [0x12, 0x34]); // 답: 질문 + 줄임 표시 (0xc00c) A 1.2.3.4 TTL 60 final res = Uint8List.fromList([ 0x12, 0x34, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0, ...q.sublist(12), // 0xc0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 1, 2, 3, 4, ]); final a = parseDnsResponse(res, id: 0x1234); expect(a.addresses.single.address, '1.2.3.4'); expect(a.ttl, 60); expect(() => parseDnsResponse(res, id: 1), throwsFormatException); }); test('같은 집 안 이름 · 주소 · DNS 서버 주소 확인', () { for (final h in ['localhost', 'ds1815', 'nas.local', 'pc.lan', 'tab-s10.tail1234.ts.net', 'printer.home.arpa']) { expect(isLocalName(h), isTrue, reason: h); } for (final h in ['www.youtube.com', 'github.com', '8.8.8.8']) { expect(isLocalName(h), isFalse, reason: h); } for (final a in ['192.168.0.10', '10.0.0.1', '172.20.1.1', '100.80.195.34', '127.0.0.1', '::1', 'fe80::1']) { expect(isPrivateAddress(InternetAddress(a)), isTrue, reason: a); } expect(isPrivateAddress(InternetAddress('8.8.8.8')), isFalse); expect(parseDnsServer('8.8.8.8')?.address, '8.8.8.8'); expect(parseDnsServer(' 2001:4860:4860::8888 '), isNotNull); expect(parseDnsServer('[2001:4860:4860::8844]'), isNotNull); for (final bad in ['', '8.8.8', 'dns.google', '0.0.0.0', '::', '256.1.1.1']) { expect(parseDnsServer(bad), isNull, reason: bad); } expect(isValidDohUrl('https://dns.google/dns-query'), isTrue); expect(isValidDohUrl('https://cloudflare-dns.com/dns-query{?dns}'), isTrue); expect(isValidDohUrl('http://dns.google/dns-query'), isFalse); }); group('앱 안 프록시', () { late FakeDns dns; late HttpServer up; late DnsResolver resolver; late LocalProxy proxy; final seen = []; setUp(() async { dns = FakeDns({'up.test': '127.0.0.1'}); await dns.start(); up = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); up.listen((req) async { seen.add('${req.method} ${req.uri} ${req.headers.value('connection')}'); req.response.write('hello ${req.uri.path}'); await req.response.close(); }); resolver = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port, timeout: const Duration(milliseconds: 500)); proxy = LocalProxy(DnsConnector(resolver)); await proxy.start(); seen.clear(); }); tearDown(() async { await proxy.close(); await up.close(force: true); await dns.close(); }); HttpClient client() => HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${proxy.port}'); test('http: 이름을 정한 DNS 로 풀어 잇고, 요청 줄은 경로만 · 한 연결에 요청 하나', () async { final c = client(); addTearDown(c.close); final res = await (await c.getUrl(Uri.parse('http://up.test:${up.port}/a?b=1'))).close(); expect(await res.transform(utf8.decoder).join(), 'hello /a'); expect(seen.single, 'GET /a?b=1 close'); expect(dns.queries, contains('up.test/1')); }); test('CONNECT: 터널 (https 와 같은 길) 을 정한 DNS 의 주소로', () async { final s = await Socket.connect(InternetAddress.loopbackIPv4, proxy.port); addTearDown(s.destroy); final lines = StreamIterator(s.cast>().transform(latin1.decoder)); s.write('CONNECT up.test:${up.port} HTTP/1.1\r\nHost: up.test:${up.port}\r\n\r\n'); var got = ''; while (!got.contains('\r\n\r\n') && await lines.moveNext()) { got += lines.current; } expect(got, startsWith('HTTP/1.1 200')); // 터널 안에서 원래 서버에 직접 말한다 s.write('GET /tunnel HTTP/1.1\r\nHost: up.test\r\nConnection: close\r\n\r\n'); var body = got.substring(got.indexOf('\r\n\r\n') + 4); while (await lines.moveNext()) { body += lines.current; } expect(body, contains('hello /tunnel')); expect(dns.queries, contains('up.test/1')); }); test('DNS 서버에 닿지 않으면 직접 잇지 않고 502 + 알림', () async { final bad = LocalProxy(DnsConnector(DnsResolver(config: () => custom('127.0.0.2'), port: dns.port, timeout: const Duration(milliseconds: 300)))); Object? err; bad.onError = (_, e) => err = e; await bad.start(); addTearDown(bad.close); final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bad.port}'); addTearDown(c.close); final res = await (await c.getUrl(Uri.parse('http://up.test:${up.port}/x'))).close(); expect(res.statusCode, 502); await res.drain(); expect(seen, isEmpty, reason: '원래 서버에 닿지 않았다'); expect(err, isA().having((e) => e.unreachable, 'unreachable', isTrue)); }); test('같은 집 안 이름 (localhost) 은 정한 DNS 에 묻지 않고 기기 DNS 로', () async { final c = client(); addTearDown(c.close); final res = await (await c.getUrl(Uri.parse('http://localhost:${up.port}/lan'))).close(); expect(await res.transform(utf8.decoder).join(), 'hello /lan'); expect(dns.queries, isEmpty); }); test('시스템 DNS (끄기) 면 정한 서버에 묻지 않는다', () async { final sys = LocalProxy(DnsConnector(DnsResolver(config: () => const DnsConfig.system(), port: dns.port))); await sys.start(); addTearDown(sys.close); final s = await Socket.connect(InternetAddress.loopbackIPv4, sys.port); addTearDown(s.destroy); s.write('CONNECT 127.0.0.1:${up.port} HTTP/1.1\r\n\r\n'); final first = await s.cast>().transform(latin1.decoder).first; expect(first, startsWith('HTTP/1.1 200')); expect(dns.queries, isEmpty); }); }); group('Tor 다리 (SOCKS5)', () { late ServerSocket socks; late HttpServer up; final asked = []; setUp(() async { asked.clear(); up = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); up.listen((req) async { req.response.write('via tor'); await req.response.close(); }); // 가짜 Tor SOCKS5: 이름 (ATYP 3) 을 받아 적고 원래 서버로 잇는다 socks = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); socks.listen((c) async { final r = SocketReader(c); await r.read(3); c.add([5, 0]); final h = await r.read(4); String host; if (h[3] == 3) { final n = (await r.read(1))[0]; host = 'name:${latin1.decode(await r.read(n))}'; } else { host = 'ip:${InternetAddress.fromRawAddress(await r.read(h[3] == 1 ? 4 : 16)).address}'; } final pb = await r.read(2); asked.add('$host:${(pb[0] << 8) | pb[1]}'); final u = await Socket.connect(InternetAddress.loopbackIPv4, up.port); c.add([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]); unawaited(u.addStream(r.rest()).catchError((_) {})); unawaited(c.addStream(u).then((_) => c.close()).catchError((_) {})); }); }); tearDown(() async { await socks.close(); await up.close(force: true); }); test('이름을 그대로 Tor 에 넘긴다 (이 기기에서 DNS 를 묻지 않음)', () async { final bridge = LocalProxy(TorConnector(() => socks.port)); await bridge.start(); addTearDown(bridge.close); final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bridge.port}'); addTearDown(c.close); final res = await (await c.getUrl(Uri.parse('http://some-site.example:${up.port}/'))).close(); expect(await res.transform(utf8.decoder).join(), 'via tor'); expect(asked.single, 'name:some-site.example:${up.port}'); }); test('Tor 가 준비되지 않았으면 502 (직접 잇지 않음)', () async { final bridge = LocalProxy(TorConnector(() => null)); await bridge.start(); addTearDown(bridge.close); final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bridge.port}'); addTearDown(c.close); final res = await (await c.getUrl(Uri.parse('http://some-site.example:${up.port}/'))).close(); expect(res.statusCode, 502); await res.drain(); expect(asked, isEmpty); }); }); group('앱 안 프록시 인증 (같은 기기의 다른 앱이 쓰지 못하게)', () { late HttpServer up; late LocalProxy proxy; final auth = ProxyAuth('jj', 'secret-value'); setUp(() async { up = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); up.listen((req) async { req.response.write('ok'); await req.response.close(); }); proxy = LocalProxy(DnsConnector(DnsResolver(config: () => const DnsConfig.system())), auth: auth); await proxy.start(); }); tearDown(() async { await proxy.close(); await up.close(force: true); }); /// CONNECT 를 보내고 답의 첫 줄 Future connect(String? authHeader) async { final s = await Socket.connect(InternetAddress.loopbackIPv4, proxy.port); addTearDown(s.destroy); s.write('CONNECT 127.0.0.1:${up.port} HTTP/1.1\r\nHost: 127.0.0.1:${up.port}\r\n' '${authHeader == null ? '' : 'Proxy-Authorization: $authHeader\r\n'}\r\n'); final got = await s.cast>().transform(latin1.decoder).firstWhere((x) => x.contains('\r\n')); return got.split('\r\n').first; } test('인증 없음 · 틀린 비밀값 · 다른 아이디는 407 (위로 잇지 않음)', () async { final asked = []; proxy.onRequest = (m, h, p) => asked.add(h); expect(await connect(null), startsWith('HTTP/1.1 407')); expect(await connect('Basic ${base64.encode(utf8.encode('jj:wrong'))}'), startsWith('HTTP/1.1 407')); expect(await connect('Basic ${base64.encode(utf8.encode('xx:secret-value'))}'), startsWith('HTTP/1.1 407')); expect(await connect('Bearer secret-value'), startsWith('HTTP/1.1 407')); expect(asked, isEmpty); }); test('맞는 비밀값은 통과 (CONNECT · 앱의 HttpClient)', () async { expect(await connect(auth.header), startsWith('HTTP/1.1 200')); final c = HttpClient()..findProxy = ((_) => 'PROXY ${auth.user}:${auth.password}@127.0.0.1:${proxy.port}'); addTearDown(c.close); final res = await (await c.getUrl(Uri.parse('http://127.0.0.1:${up.port}/'))).close(); expect(res.statusCode, 200); expect(await res.transform(utf8.decoder).join(), 'ok'); // 비밀값 없이는 407 final bare = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${proxy.port}'); addTearDown(bare.close); final res2 = await (await bare.getUrl(Uri.parse('http://127.0.0.1:${up.port}/'))).close(); expect(res2.statusCode, 407); }); test('실행마다 다른 비밀값 · 127.0.0.1 에만 열림', () async { expect(ProxyAuth.instance.password.length, greaterThanOrEqualTo(30)); expect(ProxyAuth.instance.password, isNot(contains(':'))); expect(ProxyAuth.instance.password, isNot(contains('@'))); // 127.0.0.1 이 아닌 이 기기 주소로는 닿지 않는다 final others = [ for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) for (final a in i.addresses) if (!a.isLoopback) a, ]; for (final a in others) { await expectLater(Socket.connect(a, proxy.port, timeout: const Duration(seconds: 2)), throwsA(isA())); } }); }); test('일찍 만든 HttpClient 도 나중에 정한 DNS 를 따른다 (업데이트 확인 등이 정한 DNS 를 거치지 않던 것)', () async { final g = NetGuard.instance; final old = HttpOverrides.current; HttpOverrides.global = JjHttpOverrides(); addTearDown(() { HttpOverrides.global = old; g.config = () => const DnsConfig.system(); }); g.config = () => const DnsConfig.system(); // 앱이 시작할 때 (설정을 읽기 전) 만들어 두는 것처럼 final early = HttpClient(); addTearDown(early.close); // 설정을 읽어 DNS 강제가 되면 같은 HttpClient 도 프록시로: 닿지 않는 DNS 라 프록시가 502 로 답한다 // (프록시를 거치지 않았다면 이 기기의 DNS 로 이름을 찾다 SocketException) g.config = () => custom('192.0.2.1'); await g.ensure(); final res = await (await early.getUrl(Uri.parse('http://jj-does-not-exist.example/'))).close(); await res.drain(); expect(res.statusCode, 502); expect(res.headers.value('x-jj-proxy-error'), '1'); }); test('207: Chrome 엔진 문 - 웹 뷰가 있을 때만 · 인증 없음 · 숨긴 루프백 주소 · 다시 열면 같은 주소 · 닫히면 접속 불가', () async { final g = NetGuard.instance; g.config = () => custom('8.8.8.8', '8.8.4.4'); addTearDown(() => g.config = () => const DnsConfig.system()); Future probe(String url) async { final u = Uri.parse(url); try { final s = await Socket.connect(u.host, u.port, timeout: const Duration(seconds: 2)); s.write('CONNECT 127.0.0.1:1 HTTP/1.1\r\nHost: 127.0.0.1:1\r\n\r\n'); final first = await s.cast>().transform(latin1.decoder).first.timeout(const Duration(seconds: 10)); s.destroy(); return first.split('\r\n').first; } on SocketException { return null; } } final a = await g.openCefGate(); expect(a, isNotNull); expect(InternetAddress(Uri.parse(a!).host).isLoopback, isTrue); // 인증을 묻지 않는다 (Chrome 엔진은 407 에 답하지 못함) - 위로 잇지 못해 502 expect(await probe(a), startsWith('HTTP/1.1 502')); // 웹 뷰가 둘이면 하나가 없어져도 열려 있다 expect(await g.openCefGate(), a); await g.closeCefGate(); expect(g.cefGateUrl, a); await g.closeCefGate(); expect(g.cefGateUrl, isNull); expect(await probe(a), isNull, reason: '닫히면 접속 불가 (직접 나가지 않음)'); // 다시 열면 같은 주소 · 포트 (엔진은 시작할 때 정한 주소만 쓴다) expect(await g.openCefGate(), a); await g.closeCefGate(); // DNS 강제가 아니면 문 없음 g.config = () => const DnsConfig.system(); expect(await g.openCefGate(), isNull); await g.closeCefGate(); }); test('NetGuard: 앱의 HTTP · yt-dlp · aria2 가 프록시를 쓰는 조건', () async { final g = NetGuard.instance; var cfg = custom('8.8.8.8', '8.8.4.4'); g.config = () => cfg; await g.ensure(); final port = g.port!; final a = ProxyAuth.instance; // 비밀값을 함께 (같은 기기의 다른 앱은 이 프록시를 쓰지 못한다) expect(g.findProxy(Uri.parse('https://www.youtube.com/')), 'PROXY ${a.user}:${a.password}@127.0.0.1:$port'); expect(g.findProxy(Uri.parse('http://192.168.0.5:5005/')), 'DIRECT'); expect(g.findProxy(Uri.parse('https://ds1815:5006/')), 'DIRECT'); expect(g.findProxy(Uri.parse('http://127.0.0.1:6800/jsonrpc')), 'DIRECT'); expect(g.ytDlpArgs(), ['--proxy', 'http://${a.user}:${a.password}@127.0.0.1:$port']); final aria = ['--all-proxy=http://127.0.0.1:$port', '--all-proxy-user=${a.user}', '--all-proxy-passwd=${a.password}']; expect(g.aria2Args(asyncDns: true), [...aria, '--async-dns=true', '--async-dns-server=8.8.8.8,8.8.4.4']); expect(g.aria2Args(asyncDns: false), aria); cfg = const DnsConfig.system(); expect(g.findProxy(Uri.parse('https://www.youtube.com/')), 'DIRECT'); expect(g.ytDlpArgs(), isEmpty); expect(g.aria2Args(asyncDns: true), isEmpty); expect(g.browserProxy, isNull); }); test('설정: 처음은 직접 지정 8.8.8.8 · 8.8.4.4, 틀린 값은 처음 값, 사용자가 고른 값은 그대로', () { final s = AppSettings(); expect((s.dnsMode, s.dnsPrimary, s.dnsSecondary, s.dnsDohUrl), ('custom', '8.8.8.8', '8.8.4.4', 'https://dns.google/dns-query')); expect(AppSettings.fromJson({}).dnsMode, 'custom'); final bad = AppSettings.fromJson({'dnsMode': 'x', 'dnsPrimary': 'dns.google', 'dnsSecondary': '1.2.3', 'dnsDohUrl': 'ftp://x'}); expect((bad.dnsMode, bad.dnsPrimary, bad.dnsSecondary, bad.dnsDohUrl), ('custom', '8.8.8.8', '8.8.4.4', 'https://dns.google/dns-query')); final mine = AppSettings.fromJson( AppSettings().toJson()..addAll({'dnsMode': 'system', 'dnsPrimary': '1.1.1.1', 'dnsSecondary': '', 'dnsDohUrl': 'https://cloudflare-dns.com/dns-query'})); expect((mine.dnsMode, mine.dnsPrimary, mine.dnsSecondary, mine.dnsDohUrl), ('system', '1.1.1.1', '', 'https://cloudflare-dns.com/dns-query')); final back = AppSettings.fromJson(mine.toJson()); expect(back.dnsConfig.key, mine.dnsConfig.key); // Tor 설정 처음 값 expect((s.torKeepData, s.torHomeUrl, s.torJavaScript, s.torNoticeSeen), (false, 'https://check.torproject.org/', true, false)); }); test('247 · 249: 닿지 않는 DNS - 앱의 HTTP 오류를 사용자 글로 · 상태는 다시 답하거나 설정을 바꿀 때까지 남음', () async { final g = NetGuard.instance; final old = HttpOverrides.current; HttpOverrides.global = JjHttpOverrides(); addTearDown(() async { HttpOverrides.global = old; g.config = () => const DnsConfig.system(); await g.ensure(); }); var shown = 0; void count() { if (g.failure.value != null) shown++; } g.failure.addListener(count); addTearDown(() => g.failure.removeListener(count)); g.config = () => custom('192.0.2.1'); await g.ensure(); expect(g.failure.value, isNull); // 새 버전 확인처럼 https 로 (프록시 CONNECT 가 502 → HttpException "Proxy failed to establish tunnel") final client = HttpClient(); addTearDown(client.close); Object? err; try { await (await client.getUrl(Uri.parse('https://api.github.com/repos/x/y/releases/latest'))).close(); } catch (e) { err = e; } expect('$err', contains('Proxy failed to establish tunnel')); final text = g.explain(err!, '새 버전을 확인하지 못했습니다'); expect(text, 'DNS 서버 (192.0.2.1) 에 닿지 않아 새 버전을 확인하지 못했습니다'); expect(text, isNot(contains('HttpException'))); // 프록시 실패가 아니면 원래 글 (null) expect(g.explain(const SocketException('x'), '새 버전을 확인하지 못했습니다'), isNull); // 상태가 남고 안내는 한 번 (또 실패해도 다시 띄우지 않음) expect(g.failure.value?.server, '192.0.2.1'); g.reportError('www.example.com', const DnsException('again', unreachable: true, server: '192.0.2.1')); expect(shown, 1); // 서버가 다시 답하면 지운다 g.resolver.onAnswer?.call('www.example.com', '192.0.2.1'); expect(g.failure.value, isNull); g.reportError('www.example.com', const DnsException('again', unreachable: true, server: '192.0.2.1')); expect(shown, 2); // DNS 설정을 바꾸면 지운다 g.config = () => const DnsConfig.system(); await g.ensure(); expect(g.failure.value, isNull); }); }