import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/wifi_share.dart'; import 'package:path/path.dart' as p; /// 89: Wi-Fi 공유 서버 - 코드 없으면 401 · 틀리면 막기 · 폴더 밖 · 링크 403 · 읽기만 (405) · Range · 격리 · 저절로 끄기 void main() { late Directory tmp, shared; late WifiShare s; final client = HttpClient(); setUp(() async { tmp = Directory.systemTemp.createTempSync('jj_wifi_'); shared = Directory(p.join(tmp.path, 'shared'))..createSync(); File(p.join(shared.path, 'a.txt')).writeAsStringSync('0123456789'); File(p.join(shared.path, '한글 이름.srt')).writeAsStringSync('자막'); File(p.join(shared.path, 'page.html')).writeAsStringSync(''); Directory(p.join(shared.path, 'sub')).createSync(); File(p.join(shared.path, 'sub', 'b.bin')).writeAsBytesSync([1, 2, 3]); File(p.join(tmp.path, 'secret.txt')).writeAsStringSync('밖의 파일'); s = await WifiShare.start(shared.path, port: 0, random: Random(7), loopbackOnly: true); }); tearDown(() async { await s.stop(); for (var i = 0; i < 5 && tmp.existsSync(); i++) { try { tmp.deleteSync(recursive: true); } on FileSystemException { await Future.delayed(const Duration(milliseconds: 100)); } } }); Future get(String path, {String? cookie, String method = 'GET', Map headers = const {}}) async { final req = await client.openUrl(method, Uri.parse('http://127.0.0.1:${s.port}$path')); req.followRedirects = false; if (cookie != null) req.headers.set('Cookie', cookie); headers.forEach(req.headers.set); return req.close(); } Future body(HttpClientResponse r) => r.transform(utf8.decoder).join(); Future login() async { final r = await get('/?code=${s.code}'); expect(r.statusCode, 303); expect(r.headers.value('location'), '/', reason: '코드가 주소에 남지 않게'); await r.drain(); return r.cookies.where((c) => c.name == 'jjshare').map((c) => '${c.name}=${c.value}').single; } /// HttpClient 는 ../ 를 미리 정리하므로 날 요청을 보낸다 (상태 코드 · 본문) Future<(int, String)> raw(String path, String cookie) async { final sock = await Socket.connect('127.0.0.1', s.port); sock.write('GET $path HTTP/1.1\r\nHost: x\r\nCookie: $cookie\r\nConnection: close\r\n\r\n'); final text = await utf8.decoder.bind(sock).join(); sock.destroy(); return (int.parse(text.split(' ')[1]), text); } Future rawStatus(String path, String cookie) async => (await raw(path, cookie)).$1; test('코드: 6자리 · 없으면 401 · 틀리면 401 · 맞으면 쿠키로 목록', () async { expect(s.code, matches(RegExp(r'^\d{6}$'))); var r = await get('/'); expect(r.statusCode, 401); expect(await body(r), contains('name="code"')); r = await get('/?code=${s.code == '000000' ? '111111' : '000000'}'); expect(r.statusCode, 401); expect(await body(r), contains('코드가 맞지 않습니다')); final cookie = await login(); r = await get('/', cookie: cookie); expect(r.statusCode, 200); final html = await body(r); expect(html, contains('a.txt')); expect(html, contains('한글 이름.srt')); expect(html, contains('sub')); expect(html, isNot(contains('secret.txt'))); expect(r.headers.value('content-security-policy'), contains("default-src 'none'")); // 다른 쿠키 값은 안 됨 r = await get('/', cookie: 'jjshare=xxx'); expect(r.statusCode, 401); await r.drain(); }); test('읽기만: GET · HEAD 말고는 405', () async { final cookie = await login(); for (final m in ['POST', 'PUT', 'DELETE', 'MKCOL', 'PROPFIND']) { final r = await get('/a.txt', cookie: cookie, method: m); expect(r.statusCode, 405, reason: m); await r.drain(); } final r = await get('/a.txt', cookie: cookie, method: 'HEAD'); expect(r.statusCode, 200); await r.drain(); expect(File(p.join(shared.path, 'a.txt')).readAsStringSync(), '0123456789'); }); test('폴더 밖은 403 (../ · %2e%2e · 역슬래시 · 드라이브), 없는 것은 404', () async { final cookie = await login(); // 서버가 경로의 ../ 를 먼저 정리하면 (공유 폴더 안의) 없는 이름이 되어 404, 정리되지 않은 것은 403 - 어느 쪽이든 밖은 열리지 않는다 for (final path in ['/../secret.txt', '/%2e%2e/secret.txt', '/sub/../../secret.txt', '/..%5csecret.txt', '/%2e%2e%5csecret.txt', '/C:%5cWindows']) { final (code, text) = await raw(path, cookie); expect(code, isIn([403, 404]), reason: path); expect(text, isNot(contains('밖의 파일')), reason: path); } expect(await rawStatus('/..%5csecret.txt', cookie), 403, reason: '역슬래시는 정리되지 않으므로 막아야 함'); expect(await rawStatus('/C:%5cWindows', cookie), 403); expect(await rawStatus('/없는것.txt', cookie), 404); expect(await rawStatus('/sub/b.bin', cookie), 200); }); test('링크 · junction 은 목록에도 없고 열면 403', () async { final link = Link(p.join(shared.path, 'out')); try { link.createSync(tmp.path); // 공유 폴더 밖 (secret.txt 가 있는 곳) } catch (_) { markTestSkipped('링크를 만들 수 없음 (권한)'); return; } final cookie = await login(); final r = await get('/', cookie: cookie); expect(await body(r), isNot(contains('>📁 out<'))); expect(await rawStatus('/out/secret.txt', cookie), 403); expect(await rawStatus('/out/', cookie), 403); }); test('파일: 한글 이름 · Range (동영상 건너뛰기) · 받기 · HTML 은 격리해 내려받기로', () async { final cookie = await login(); var r = await get('/${Uri.encodeComponent('한글 이름.srt')}', cookie: cookie); expect(r.statusCode, 200); expect(await body(r), '자막'); r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=2-4'}); expect(r.statusCode, 206); expect(r.headers.value('content-range'), 'bytes 2-4/10'); expect(await body(r), '234'); r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=-3'}); expect(await body(r), '789'); r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=50-'}); expect(r.statusCode, 416); await r.drain(); r = await get('/a.txt?dl=1', cookie: cookie); expect(r.headers.value('content-disposition'), startsWith('attachment')); await r.drain(); r = await get('/page.html', cookie: cookie); expect(r.headers.value('content-type'), 'application/octet-stream'); expect(r.headers.value('content-disposition'), startsWith('attachment')); expect(r.headers.value('content-security-policy'), 'sandbox'); expect(r.headers.value('x-content-type-options'), 'nosniff'); await r.drain(); }); test('코드를 5번 틀리면 1분 막고 (429), 모두 30번이면 공유를 끈다', () async { final wrong = s.code == '000000' ? '111111' : '000000'; for (var i = 0; i < WifiShare.perClientFails; i++) { final r = await get('/?code=$wrong'); expect(r.statusCode, 401); await r.drain(); } var r = await get('/?code=${s.code}'); expect(r.statusCode, 429, reason: '막힌 동안은 맞는 코드도 안 됨'); await r.drain(); // 모두 합쳐 30번 (막힌 동안은 세지 않으므로 시험에서 바로 센다) for (var i = WifiShare.perClientFails; i < WifiShare.totalFails; i++) { s.debugFail('10.0.0.${i % 200}'); } await Future.delayed(const Duration(milliseconds: 50)); expect(s.running, isFalse); expect(s.stopReason, contains('너무 많이 틀려')); expect(WifiShare.instance, isNull); }); test('정한 시간이 지나면 저절로 꺼지고, 접속 기록에는 코드가 없다', () async { final cookie = await login(); final r = await get('/a.txt', cookie: cookie); await r.drain(); // 기록은 응답을 다 보낸 뒤에 적힌다 for (var i = 0; i < 50 && !s.log.any((x) => x.path == '/a.txt'); i++) { await Future.delayed(const Duration(milliseconds: 20)); } expect(s.log.map((x) => x.path), contains('/a.txt')); expect(s.log.any((x) => x.path.contains(s.code)), isFalse); await s.stop(); final t = await WifiShare.start(shared.path, port: 0, autoOff: const Duration(milliseconds: 200), loopbackOnly: true); expect(t.endsAt, isNotNull); await Future.delayed(const Duration(milliseconds: 500)); expect(t.running, isFalse); expect(t.stopReason, contains('저절로')); s = t; }); test('같은 네트워크 판단: 사설망 · Tailscale 은 받고 공인 주소는 막는다', () { for (final ip in ['192.168.0.5', '10.1.2.3', '172.16.0.1', '172.31.255.1', '100.69.29.106', '127.0.0.1', '169.254.1.1']) { expect(WifiShare.isLocalNetwork(ip), isTrue, reason: ip); } for (final ip in ['8.8.8.8', '172.32.0.1', '100.128.0.1', '1.2.3.4', 'x']) { expect(WifiShare.isLocalNetwork(ip), isFalse, reason: ip); } }); }