JJ_MKVMaker/test/tor_test.dart

457 lines
22 KiB
Dart

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';
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()..startScreen = 'tor';
expect(AppNavButtons.homeId(s), 'mkv', reason: '설치하지 않았으면 첫 화면');
s.components = [...s.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<int> 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<ComponentException>()),
);
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<AppController> open(WidgetTester t,
{required Future<void> Function() startTor,
Future<List<String>> Function()? missing,
required Future<bool> Function() applyProxy,
required List<String> loads,
List<TorViewHost>? hosts,
Future<void> Function()? onLeft,
GlobalKey<NavigatorState>? 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
..torNoticeSeen = true
..components = [...c.settings.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 = <String>[];
final proxy = Completer<bool>();
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 = <String>[];
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 = <String>[];
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 = <String>[];
var left = 0, applied = 0;
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final nav = GlobalKey<NavigatorState>();
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<void>(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.torHomeUrl);
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.torHomeUrl);
expect(find.textContaining('VIEW'), findsOneWidget);
});
testWidgets('떠난 뒤 tor 는 정한 시간 뒤에 끈다 · 돌아오면 그대로 · 0 은 바로 (가짜 시간)', (t) async {
final s = TorService.instance;
addTearDown(() => s.debugRunning = false);
s.debugRunning = true;
s.status.value = const TorStatus(TorPhase.ready, percent: 100);
await s.stopAfter(const Duration(minutes: 2));
expect(s.idleStopAt.value, isNotNull);
await t.pump(const Duration(minutes: 1, seconds: 59));
expect(s.running, isTrue);
await t.pump(const Duration(seconds: 2));
expect(s.running, isFalse);
expect(s.idleStopAt.value, isNull);
expect(s.status.value.phase, TorPhase.off);
// 그 사이 돌아오면 그대로 켜 둔다
s.debugRunning = true;
s.status.value = const TorStatus(TorPhase.ready, percent: 100);
await s.stopAfter(const Duration(minutes: 2));
await t.pump(const Duration(minutes: 1));
s.cancelIdleStop();
await t.pump(const Duration(minutes: 5));
expect(s.running, isTrue);
// 0 = 바로
await s.stopAfter(Duration.zero);
expect(s.running, isFalse);
});
test('설정: Tor 를 켜 둘 시간 (처음 2분, 틀린 값은 2분)', () {
expect(AppSettings().torIdleMinutes, 2);
expect(AppSettings.fromJson({'torIdleMinutes': 0}).torIdleMinutes, 0);
expect(AppSettings.fromJson({'torIdleMinutes': 5}).torIdleMinutes, 5);
expect(AppSettings.fromJson({'torIdleMinutes': -1}).torIdleMinutes, 2);
expect(AppSettings.fromJson({'torIdleMinutes': 'x'}).torIdleMinutes, 2);
});
testWidgets('227: 막은 파일 주소는 주소 칸에 남지 않고 보이던 페이지 주소로', (t) async {
final loads = <String>[];
final hosts = <TorViewHost>[];
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()));
final s = TorService.instance;
addTearDown(() async {
s.cancelIdleStop();
s.debugRunning = false;
});
await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: TorSettings(c: c)))));
expect(find.textContaining('Tor 대기 중'), findsNothing);
s.debugRunning = true;
await s.stopAfter(const Duration(minutes: 2));
await t.pump();
expect(find.text('Tor 대기 중 (2분 뒤 끔)'), findsOneWidget);
await t.tap(find.text('지금 끄기'));
await t.pump();
expect(s.running, isFalse);
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<ScaffoldMessengerState>();
watchDnsFailure(key, c);
await t.pumpWidget(MaterialApp(scaffoldMessengerKey: key, home: const Scaffold(body: Text('home'))));
const text = 'DNS 서버 (192.0.2.1) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.';
Future<void> 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.dnsMode, 'custom');
await t.tap(find.text('시스템 DNS 로 바꾸기'));
await t.pump();
expect(c.settings.dnsMode, 'system');
g.failure.value = null;
await t.pump();
expect(find.textContaining('에 닿지 않음'), findsNothing);
});
testWidgets('파일 링크를 누르면 받지 않고 이유를 알린다 (아무 반응 없음이 아니게)', (t) async {
final loads = <String>[];
final hosts = <TorViewHost>[];
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);
});
});
}