import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/ftp.dart'; import 'package:jj_mkvmaker/core/remote_stream.dart'; import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; import 'support/sftp_server.dart' show sftpTestPython; /// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) - /// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다 void main() { final hasPy = File(sftpTestPython).existsSync(); test('설정: 비밀번호는 설정 파일에 넣지 않음 · 모르는 보안 값은 FTPS', () { const s = FtpServer(id: 'f1', name: 'n', host: 'h', user: 'u', password: 'secret', security: 'none'); final j = s.toJson(); expect(jsonEncode(j), isNot(contains('secret'))); expect(j['hasPassword'], isTrue); expect(FtpServer.fromJson({'id': 'x', 'security': '???'}).security, 'explicit', reason: '처음 값은 FTPS (관리자 지시)'); expect(FtpServer.fromJson({'id': 'x'}).security, 'explicit'); expect(FtpServer.fromJson({'id': 'x', 'security': 'implicit'}).port, 990); expect(FtpServer.fromJson({'id': 'x', 'security': 'none'}).security, 'none'); }); test('설정 파일 · 보관본에는 비밀번호를 넣지 않고 안전 저장소에 (ftp.pw.) · 다시 읽으면 채움', () async { final tmp = Directory.systemTemp.createTempSync('jj_ftp_set_'); addTearDown(() => tmp.deleteSync(recursive: true)); final secrets = MemorySecretStore(); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final s = AppSettings() ..ftpServers = [const FtpServer(id: 'f1', name: 'NAS', host: 'nas.local', port: 2121, user: 'jj', password: 'ftp-secret', certFingerprint: 'SHA256:AB')]; await store.save(s); expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('ftp-secret'))); expect(secrets.values['ftp.pw.f1'], 'ftp-secret'); final x = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).ftpServers.single; expect((x.host, x.port, x.user, x.password, x.security, x.certFingerprint), ('nas.local', 2121, 'jj', 'ftp-secret', 'explicit', 'SHA256:AB')); final stripped = AppSettings.stripSecrets({ 'ftpServers': [ {'id': 'x', 'password': 'leak', 'host': 'h'} ] }); expect(jsonEncode(stripped), isNot(contains('leak'))); }); test('목록 줄 읽기: MLSD · 유닉스 LIST', () { final m = FtpRemote.parseMlsx('type=file;size=11;modify=20261017123456; 가 나.txt')!; expect((m.$1, m.$2, m.$3, m.$4), ('가 나.txt', false, 11, DateTime.utc(2026, 10, 17, 12, 34, 56))); expect(FtpRemote.parseMlsx('type=dir;modify=20261017123456; 폴더')!.$2, isTrue); expect(FtpRemote.parseMlsx('type=cdir; .'), isNull); final l = FtpRemote.parseList('-rw-r--r-- 1 jj jj 1234 Oct 17 2025 some file.mkv')!; expect((l.$1, l.$2, l.$3), ('some file.mkv', false, 1234)); expect(FtpRemote.parseList('drwxr-xr-x 2 jj jj 4096 Oct 17 12:00 dir')!.$2, isTrue); expect(FtpRemote.parseList('lrwxrwxrwx 1 jj jj 4 Oct 17 12:00 ln -> x')!.$1, 'ln'); }); group('실제 서버 (pyftpdlib)', () { late Directory tmp; late Directory root; Process? proc; late int port; late String fp; Future start(String mode) async { final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode]); proc = pr; final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream(); pr.stderr.drain(); port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT ')).timeout(const Duration(seconds: 20))).split(' ')[1]); final f = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT')).timeout(const Duration(seconds: 5))).split(' '); fp = f.length > 1 ? f[1] : ''; } setUp(() { tmp = Directory.systemTemp.createTempSync('jj_ftp_'); root = Directory(p.join(tmp.path, 'root'))..createSync(); }); tearDown(() async { FtpRegistry.configure(const []); final pr = proc; proc = null; if (pr != null) { await pr.stdin.close().catchError((_) {}); pr.kill(); await pr.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => -1); } for (var i = 0;; i++) { try { if (tmp.existsSync()) tmp.deleteSync(recursive: true); break; } on FileSystemException { if (i > 30) rethrow; await Future.delayed(const Duration(milliseconds: 100)); } } }); FtpServer srv({String security = 'explicit', String cert = '', String password = 'pw'}) => FtpServer(id: 'f1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert); Future err(Future Function() f) async { try { await f(); return null; } catch (e) { return e; } } Future roundTrip(FtpRemote c) async { await c.mkdirs('/영상/시즌1'); expect(Directory(p.join(root.path, '영상', '시즌1')).existsSync(), isTrue); await c.write('/영상/시즌1/1화.mkv', Stream.value(utf8.encode('0123456789'))); expect(File(p.join(root.path, '영상', '시즌1', '1화.mkv')).readAsStringSync(), '0123456789'); final l = await c.list('/영상/시즌1'); expect(l.map((e) => (e.rel, e.isDir, e.size)), [('/영상/시즌1/1화.mkv', false, 10)]); expect((await c.stat('/영상'))!.isDir, isTrue); expect(await c.stat('/없음.txt'), isNull); Future read(int off, int? len) async => utf8.decode(await (await c.openReadFrom('/영상/시즌1/1화.mkv', offset: off, length: len)).expand((x) => x).toList()); expect(await read(0, null), '0123456789'); expect(await read(4, 3), '456', reason: 'REST 로 부분 읽기 (동영상 건너뛰기)'); expect(await read(8, null), '89'); await c.move('/영상/시즌1/1화.mkv', '/영상/1화.mkv'); expect(File(p.join(root.path, '영상', '1화.mkv')).existsSync(), isTrue); await c.delete('/영상'); expect(Directory(p.join(root.path, '영상')).existsSync(), isFalse); } test('FTPS (명시): 처음 보는 자체 서명 인증서는 지문을 보이고 막음 → 믿으면 폴더 · 쓰기 · 목록 · 부분 읽기 · 옮기기 · 지우기', () async { await start('explicit'); final e = await err(() => FtpRemote(srv()).list('/')); expect(e, isA().having((x) => x.fingerprint, 'fp', fp).having((x) => x.changed, 'changed', isFalse)); final c = FtpRemote(srv(cert: fp)); await roundTrip(c); c.close(); // 다른 지문을 믿고 있으면 (서버가 바뀜) 막는다 final bad = await err(() => FtpRemote(srv(cert: 'SHA256:00')).list('/')); expect(bad, isA().having((x) => x.changed, 'changed', isTrue)); }, skip: hasPy ? false : '시험용 파이썬 없음'); test('동영상 스트리밍 다리: ftp:// 경로의 부분 읽기 (FtpRemote.openReadFrom - REST 로 그 자리부터)', () async { await start('plain'); FtpRegistry.configure([srv(security: 'none')]); await FtpRegistry.client('f1').write('/v.mkv', Stream.value(utf8.encode('0123456789'))); Future range(int start, int? length) async => utf8.decode(await (await RemoteStreamBridge.openRange('ftp://f1/v.mkv', start, length)).expand((x) => x).toList()); expect(await range(4, 3), '456'); expect(await range(7, null), '789'); }, skip: hasPy ? false : '시험용 파이썬 없음'); test('FTPS (암시, 990 꼴)', () async { await start('implicit'); final c = FtpRemote(srv(security: 'implicit', cert: fp)); await c.write('/a.txt', Stream.value(utf8.encode('A'))); expect((await c.list('/')).map((e) => e.rel), ['/a.txt']); expect(File(p.join(root.path, 'a.txt')).readAsStringSync(), 'A', reason: '올린 내용이 그대로'); c.close(); }, skip: hasPy ? false : '시험용 파이썬 없음'); test('평문 FTP: 고르면 연결 (화면이 "암호화 안 됨" 경고) · FTPS 로 고른 서버가 평문만 되면 몰래 내려가지 않고 이유를', () async { await start('plain'); final c = FtpRemote(srv(security: 'none')); await roundTrip(c); c.close(); final e = await err(() => FtpRemote(srv()).list('/')); expect(e, isA().having((x) => x.kind, 'kind', FtpFailure.noTls)); }, skip: hasPy ? false : '시험용 파이썬 없음'); test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async { await start('plain'); expect(await err(() => FtpRemote(srv(security: 'none', password: 'x')).list('/')), isA().having((x) => x.kind, 'k', FtpFailure.auth)); expect(await err(() => FtpRemote(srv(security: 'none', password: '')).list('/')), isA().having((x) => x.kind, 'k', FtpFailure.noSecret)); final off = FtpServer(id: 'f2', name: 'x', host: '127.0.0.1', port: 1, user: 'jj', password: 'pw', security: 'none'); expect(await err(() => FtpRemote(off, timeout: const Duration(seconds: 3)).list('/')), isA().having((x) => x.kind, 'k', FtpFailure.unreachable)); }, skip: hasPy ? false : '시험용 파이썬 없음'); }); }