import 'dart:async'; import 'dart:io'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/component_store.dart'; import 'package:jj_mkvmaker/app/components.dart'; import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/tor_bundle.dart'; import 'package:jj_mkvmaker/app/tor_service.dart'; import 'package:jj_mkvmaker/core/tor_control.dart'; import 'package:jj_mkvmaker/main.dart' show watchDnsFailure; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/app_actions.dart'; import 'package:jj_mkvmaker/ui/tor_page.dart'; import 'package:jj_mkvmaker/ui/network_settings.dart'; import 'package:jj_mkvmaker/core/idle_stop.dart'; void main() { test('tor 알림 줄 읽기 · torrc', () { expect(parseBootstrap('Oct 10 19:16:01.000 [notice] Bootstrapped 45% (loading_descriptors): Loading relay descriptors'), (45, 'Loading relay descriptors')); expect(parseBootstrap('[notice] Bootstrapped 100% (done): Done')?.$1, 100); expect(parseBootstrap('[notice] Opening Socks listener on 127.0.0.1:0'), isNull); expect(parseSocksPort('[notice] Opened Socks listener connection (ready) on 127.0.0.1:53017'), 53017); final rc = buildTorrc(dataDir: r'C:\d\tor_data', controlPortFile: r'C:\d\tor_data\cp.txt', ownerPid: 1234, geoip: r'C:\g\geoip'); expect(rc, contains('SocksPort 127.0.0.1:auto')); expect(rc, contains('ControlPort 127.0.0.1:auto')); expect(rc, contains('CookieAuthentication 1')); expect(rc, contains('__OwningControllerProcess 1234')); expect(rc, contains(r'GeoIPFile C:\g\geoip')); expect(rc, isNot(contains('GeoIPv6File'))); }); test('새는 것 막기: Tor 100% · 프록시 · 전용 프로필이 모두 되어야 웹 뷰를 만든다', () { const ready = TorStatus(TorPhase.ready, percent: 100); expect(torViewMayLoad(status: ready, proxyApplied: true, profileReady: true), isTrue); expect(torViewMayLoad(status: ready, proxyApplied: false, profileReady: true), isFalse); expect(torViewMayLoad(status: ready, proxyApplied: true, profileReady: false), isFalse); for (final s in [TorStatus.off, const TorStatus(TorPhase.starting, percent: 99), const TorStatus(TorPhase.failed)]) { expect(torViewMayLoad(status: s, proxyApplied: true, profileReady: true), isFalse, reason: '${s.phase}'); } }); test('Tor 검색은 DuckDuckGo (Google 로 보내지 않음), 주소는 그대로', () { expect(torAddress('tor project'), 'https://duckduckgo.com/?q=tor+project'); expect(torAddress('check.torproject.org'), 'https://check.torproject.org'); expect(torAddress('http://example.onion/'), 'http://example.onion/'); }); test('Edge 실행 인수: 모든 연결 (localhost 포함) 을 Tor 다리로 · WebRTC UDP 막기 · QUIC 끄기', () { final a = torBrowserArguments(41234); expect(a, contains('--proxy-server=http://127.0.0.1:41234')); expect(a, contains('--proxy-bypass-list=<-loopback>')); expect(a, contains('--force-webrtc-ip-handling-policy=disable_non_proxied_udp')); expect(a, contains('--disable-quic')); expect(torWebRtcBlockScript, contains('RTCPeerConnection')); }); test('컴포넌트: Tor 는 처음 꺼짐 · 화면은 웹 브라우저 바로 뒤 (예전 순서에도)', () { expect(AppComponent.tor.builtIn, isFalse); expect(AppComponent.defaultInstalled, isNot(contains('tor'))); expect(AppComponent.defaultOrder.indexOf('tor'), AppComponent.defaultOrder.indexOf('browser') + 1); final ids = [for (final p in AppComponent.pages(['mkv', 'browser', 'tor', 'explorer'], ['explorer', 'mkv', 'browser'])) p.id]; expect(ids, ['explorer', 'mkv', 'browser', 'tor']); expect([for (final p in AppComponent.pages(['mkv', 'browser', 'tor'], ['browser', 'mkv'])) p.id], ['browser', 'tor', 'mkv']); final s = AppSettings()..display.startScreen = 'tor'; expect(AppNavButtons.homeId(s), 'mkv', reason: '설치하지 않았으면 첫 화면'); s.display.components = [...s.display.components, 'tor']; expect(AppNavButtons.homeId(s), 'tor'); }); group('Tor 컴포넌트 (Windows 번들)', () { test('판 · SHA-256 고정 (공식 sha256sums-signed-build.txt)', () { final hex = RegExp(r'^[0-9a-f]{64}$'); expect(TorBundle.windowsSha256, matches(hex)); expect(TorBundle.androidSha256, matches(hex)); expect(TorBundle.windowsSha256, 'e9dc6ccc93cd6afa507193f4de284d6424233ff5102155cd2c94b259e8a22b65'); expect(TorBundle.windowsFile, 'tor-expert-bundle-windows-x86_64-${TorBundle.version}.tar.gz'); for (final u in TorBundle.urls(TorBundle.windowsFile)) { expect(u, startsWith('https://')); expect(u, contains('torproject.org/')); expect(u, endsWith('/${TorBundle.version}/${TorBundle.windowsFile}')); } // Android 준비 스크립트도 같은 판 · 같은 SHA-256 final ps = File('tool/fetch_tor_android.ps1').readAsStringSync(); expect(ps, contains("'${TorBundle.version}'")); expect(ps, contains(TorBundle.androidSha256)); }); late HttpServer server; late Directory tmp; late List bundle; setUp(() async { // 이 파일의 testWidgets 가 HttpClient 를 막아 둔다 - 이 시험은 실제 (로컬) 서버에 연결 HttpOverrides.global = null; tmp = Directory.systemTemp.createTempSync('jj_tor_test_'); final a = Archive() ..addFile(ArchiveFile.bytes('tor/tor.exe', [77, 90, 1, 2])) ..addFile(ArchiveFile.bytes('data/geoip', [1])) ..addFile(ArchiveFile.bytes('docs/tor.txt', 'Tor license'.codeUnits)); bundle = GZipEncoder().encode(TarEncoder().encode(a)); server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); server.listen((req) async { req.response.add(bundle); await req.response.close(); }); }); tearDown(() async { await server.close(force: true); tmp.deleteSync(recursive: true); }); test('SHA-256 이 다르면 설치하지 않는다', () async { final target = '${tmp.path}${Platform.pathSeparator}tor'; await expectLater( TorBundle.installWindows( urlsOverride: ['http://127.0.0.1:${server.port}/b.tar.gz'], sha256Override: '0' * 64, targetOverride: target), throwsA(isA()), ); expect(Directory(target).existsSync(), isFalse); }); test('맞으면 tor.exe · geoip · 라이선스 문서 · 설치 표시 (판)', () async { final target = '${tmp.path}${Platform.pathSeparator}tor'; await TorBundle.installWindows( urlsOverride: ['http://127.0.0.1:${server.port}/b.tar.gz'], sha256Override: sha256.convert(bundle).toString(), targetOverride: target); for (final f in ['tor.exe', 'geoip', 'docs/tor.txt', 'installed.json']) { expect(File('$target/$f').existsSync(), isTrue, reason: f); } expect(File('$target/installed.json').readAsStringSync(), contains('"version":"${TorBundle.version}"')); }, skip: Platform.isWindows ? false : 'Windows tar'); }); group('Tor 화면: 준비되기 전에는 아무것도 읽지 않는다', () { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_tor_page_')); tearDown(() { TorService.instance.status.value = TorStatus.off; tmp.deleteSync(recursive: true); }); Future open(WidgetTester t, {required Future Function() startTor, Future> Function()? missing, required Future Function() applyProxy, required List loads, List? hosts, Future Function()? onLeft, GlobalKey? navKey}) async { t.view.physicalSize = const Size(1400, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); const pp = MethodChannel('plugins.flutter.io/path_provider'); t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path); addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..tor.noticeSeen = true ..display.components = [...c.settings.display.components, 'tor']; await t.pumpWidget(MaterialApp( navigatorKey: navKey, builder: (context, child) => AppScope(controller: c, child: child!), home: TorPage( c: c, onLeft: onLeft, startTor: startTor, missingFeatures: missing ?? () async => const [], applyProxy: applyProxy, viewBuilder: (host, url) { loads.add(url); hosts?.add(host); return Text('VIEW $url'); }, ), )); await t.pump(); return c; } testWidgets('연결 중 → 100% → 프록시 적용 뒤에야 웹 뷰', (t) async { final loads = []; final proxy = Completer(); final st = TorService.instance.status; await open(t, startTor: () async => st.value = const TorStatus(TorPhase.starting, percent: 10, message: 'x'), applyProxy: () => proxy.future, loads: loads); await t.pump(); expect(find.textContaining('Tor 연결 중 (10%)'), findsOneWidget); expect(loads, isEmpty); st.value = const TorStatus(TorPhase.starting, percent: 90); await t.pump(); expect(loads, isEmpty); st.value = const TorStatus(TorPhase.ready, percent: 100); await t.pump(); expect(loads, isEmpty, reason: '프록시를 걸기 전'); proxy.complete(true); await t.pump(); await t.pump(); expect(loads, ['https://check.torproject.org/']); expect(find.text('Tor 연결됨'), findsOneWidget); // Tor 가 끊기면 웹 뷰를 치운다 (다리는 Tor 없이 잇지 않음) st.value = const TorStatus(TorPhase.failed, message: 'x'); await t.pump(); expect(find.textContaining('VIEW'), findsNothing); expect(find.text('다시 시도'), findsOneWidget); }); testWidgets('프록시를 걸지 못하면 (Android 웹 뷰 프록시 실패) 읽지 않는다', (t) async { final loads = []; await open(t, startTor: () async => TorService.instance.status.value = const TorStatus(TorPhase.ready, percent: 100), applyProxy: () async => false, loads: loads); await t.pump(); await t.pump(); expect(loads, isEmpty); }); testWidgets('웹 뷰에 필요한 기능이 없으면 Tor 를 켜지 않고 안내만', (t) async { final loads = []; var started = false; await open(t, startTor: () async { started = true; TorService.instance.status.value = const TorStatus(TorPhase.ready, percent: 100); }, missing: () async => ['PROXY_OVERRIDE'], applyProxy: () async => true, loads: loads); await t.pump(); expect(started, isFalse); expect(loads, isEmpty); expect(find.textContaining('PROXY_OVERRIDE'), findsOneWidget); }); testWidgets('다른 화면으로 가면 웹 뷰를 바로 치우고 정리 · 돌아오면 홈부터 다시', (t) async { final loads = []; var left = 0, applied = 0; t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); final nav = GlobalKey(); final c = await open(t, startTor: () async => TorService.instance.status.value = const TorStatus(TorPhase.ready, percent: 100), applyProxy: () async { applied++; return true; }, loads: loads, onLeft: () async => left++, navKey: nav); await t.pump(); await t.pump(); expect(find.textContaining('VIEW'), findsOneWidget); expect(applied, 1); // 다른 화면 (예: 일반 브라우저) 이 위로 nav.currentState!.push(MaterialPageRoute(builder: (_) => const Scaffold(body: Text('other')))); await t.pumpAndSettle(); expect(left, 1, reason: '떠나자마자 정리'); expect(find.textContaining('VIEW', skipOffstage: false), findsNothing, reason: '웹 뷰를 두지 않음'); // 돌아오면 홈부터 다시 (프록시도 다시 건 뒤에) nav.currentState!.pop(); await t.pumpAndSettle(); expect(applied, 2); expect(loads.last, c.settings.tor.homeUrl); expect(find.textContaining('VIEW'), findsOneWidget); // 앱이 뒤로 가도 같다 t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); await t.pump(); await t.pump(); // 앱이 뒤에 있으면 화면을 그리지 않으므로 웹 뷰는 돌아와서 빠진다 - 정리 (프록시 · 다리 · 프로필) 는 바로 expect(left, 2); t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await t.pump(); await t.pump(); expect(applied, 3, reason: '돌아오면 프록시를 다시 건 뒤에'); expect(loads.last, c.settings.tor.homeUrl); expect(find.textContaining('VIEW'), findsOneWidget); }); testWidgets('떠난 뒤 tor 는 정한 시간 뒤에 끈다 · 돌아오면 그대로 · 0 은 바로 (가짜 시간, IdleStop 약속)', (t) async { var stopped = 0; final idle = IdleStop(() async => stopped++); await idle.schedule(const Duration(minutes: 2)); expect(idle.stopAt.value, isNotNull); await t.pump(const Duration(minutes: 1, seconds: 59)); expect(stopped, 0); await t.pump(const Duration(seconds: 2)); expect([stopped, idle.stopAt.value], [1, null]); // 그 사이 돌아오면 그대로 켜 둔다 await idle.schedule(const Duration(minutes: 2)); await t.pump(const Duration(minutes: 1)); idle.cancel(); await t.pump(const Duration(minutes: 5)); expect([stopped, idle.stopAt.value], [1, null]); // 다시 정하면 앞의 것은 잊는다 await idle.schedule(const Duration(minutes: 1)); await idle.schedule(const Duration(minutes: 3)); await t.pump(const Duration(minutes: 2)); expect(stopped, 1); await t.pump(const Duration(minutes: 1, seconds: 1)); expect(stopped, 2); // 0 = 바로 await idle.schedule(Duration.zero); expect([stopped, idle.stopAt.value], [3, null]); }); test('tor 가 떠 있지 않으면 stopAfter 는 아무것도 하지 않는다', () async { final s = TorService.instance; expect(s.running, isFalse); await s.stopAfter(const Duration(minutes: 2)); expect(s.idleStopAt.value, isNull); }); test('설정: Tor 를 켜 둘 시간 (처음 2분, 틀린 값은 2분)', () { expect(AppSettings().tor.idleMinutes, 2); expect(AppSettings.fromJson({'torIdleMinutes': 0}).tor.idleMinutes, 0); expect(AppSettings.fromJson({'torIdleMinutes': 5}).tor.idleMinutes, 5); expect(AppSettings.fromJson({'torIdleMinutes': -1}).tor.idleMinutes, 2); expect(AppSettings.fromJson({'torIdleMinutes': 'x'}).tor.idleMinutes, 2); }); testWidgets('227: 막은 파일 주소는 주소 칸에 남지 않고 보이던 페이지 주소로', (t) async { final loads = []; final hosts = []; await open(t, startTor: () async => TorService.instance.status.value = const TorStatus(TorPhase.ready, percent: 100), applyProxy: () async => true, loads: loads, hosts: hosts); await t.pump(); await t.pump(); final h = hosts.last; h.onUrl('https://check.torproject.org/'); h.onShown?.call('https://check.torproject.org/'); // 파일 링크: 읽기 시작에 주소 칸이 파일 주소로 바뀐 뒤 막힘 h.onUrl('https://dist.torproject.org/x.tar.gz.asc'); await t.pump(); expect(find.text('https://dist.torproject.org/x.tar.gz.asc'), findsOneWidget); h.onDownloadBlocked('https://dist.torproject.org/x.tar.gz.asc'); await t.pump(); expect(find.text('https://dist.torproject.org/x.tar.gz.asc'), findsNothing); expect(find.text('https://check.torproject.org/'), findsOneWidget); }); testWidgets('226: 떠난 뒤 tor 를 켜 두는 동안 환경 설정 > Tor 에 "Tor 대기 중 (n분 뒤 끔)" · [지금 끄기]', (t) async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); var stopped = 0; final idle = IdleStop(() async => stopped++); addTearDown(idle.cancel); await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: TorSettings(c: c, idle: idle))))); expect(find.textContaining('Tor 대기 중'), findsNothing); await idle.schedule(const Duration(minutes: 2)); await t.pump(); expect(find.text('Tor 대기 중 (2분 뒤 끔)'), findsOneWidget); await t.tap(find.text('지금 끄기')); await t.pump(); expect(stopped, 1); expect(find.textContaining('Tor 대기 중'), findsNothing); }); testWidgets('248: 아래 알림 - 상태가 풀리면 닫힘 · 같은 안내를 보이는 화면 (덮개 · 설정 DNS 칸) 이 열려 있으면 띄우지 않고, 열리면 닫힘', (t) async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final g = NetGuard.instance; addTearDown(() { g.failure.value = null; g.noticeBlockers.value = 0; }); final key = GlobalKey(); watchDnsFailure(key, c); await t.pumpWidget(MaterialApp(scaffoldMessengerKey: key, home: const Scaffold(body: Text('home')))); const text = 'DNS 서버 (192.0.2.1) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.'; Future settle() async { for (var i = 0; i < 10; i++) { await t.pump(const Duration(milliseconds: 100)); } } // 1) 뜬 뒤 상태가 풀리면 (정한 서버가 다시 답함 · 설정을 바꿈) 바로 닫힌다 g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now()); await settle(); expect(find.text(text), findsOneWidget); await t.pump(const Duration(minutes: 5)); expect(find.text(text), findsOneWidget, reason: '버튼이 있는 안내는 저절로 사라지지 않는다'); g.failure.value = null; await settle(); expect(find.text(text), findsNothing); // 2) 같은 안내를 보이는 화면이 열려 있으면 띄우지 않는다 g.noticeBlockers.value = 1; g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now()); await settle(); expect(find.text(text), findsNothing); g.failure.value = null; g.noticeBlockers.value = 0; // 3) 떠 있는 동안 그런 화면이 열리면 닫힌다 g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now()); await settle(); expect(find.text(text), findsOneWidget); g.noticeBlockers.value = 1; await settle(); expect(find.text(text), findsNothing); }); testWidgets('248: 환경 설정의 DNS 칸 · 브라우저 덮개가 열려 있는 동안 알림을 막는 수', (t) async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final g = NetGuard.instance; expect(g.noticeBlockers.value, 0); await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: DnsSettings(c: c))))); expect(g.noticeBlockers.value, 1); await t.pumpWidget(const SizedBox()); await t.pump(); expect(g.noticeBlockers.value, 0); }); testWidgets('249: DNS 서버에 닿지 않는 동안 환경 설정 > 네트워크 · DNS 에 상태 줄 · [시스템 DNS 로 바꾸기]', (t) async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final g = NetGuard.instance; addTearDown(() => g.failure.value = null); await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: DnsSettings(c: c))))); expect(find.textContaining('에 닿지 않음'), findsNothing); g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime(2026, 10, 12, 9, 5)); await t.pump(); expect(find.text('DNS 서버 (192.0.2.1) 에 닿지 않음 (09:05부터)'), findsOneWidget); expect(c.settings.network.mode, 'custom'); await t.tap(find.text('시스템 DNS 로 바꾸기')); await t.pump(); expect(c.settings.network.mode, 'system'); g.failure.value = null; await t.pump(); expect(find.textContaining('에 닿지 않음'), findsNothing); }); testWidgets('파일 링크를 누르면 받지 않고 이유를 알린다 (아무 반응 없음이 아니게)', (t) async { final loads = []; final hosts = []; await open(t, startTor: () async => TorService.instance.status.value = const TorStatus(TorPhase.ready, percent: 100), applyProxy: () async => true, loads: loads, hosts: hosts); await t.pump(); await t.pump(); expect(hosts, isNotEmpty); hosts.last.onDownloadBlocked('https://example.com/a.zip'); await t.pump(); expect(find.text('Tor 화면에서는 파일을 받지 않습니다 (Tor 밖으로 나가지 않게)'), findsOneWidget); }); }); }