JJ_MKVMaker/test/ftp_test.dart

337 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'support/rsync_scenes.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
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';
/// 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.<id>) · 다시 읽으면 채움', () 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;
late String caFp;
Future<void> start(String mode, {bool chain = false}) async {
final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, if (chain) '--chain']);
proc = pr;
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
pr.stderr.drain<void>();
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] : '';
final ca = (await lines.firstWhere((l) => l.startsWith('CA_FINGERPRINT')).timeout(const Duration(seconds: 5))).split(' ');
caFp = ca.length > 1 ? ca[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<void>.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<Object?> err(Future<void> Function() f) async {
try {
await f();
return null;
} catch (e) {
return e;
}
}
Future<void> 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<String> 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('보안 검토 7: 사설 CA 가 서명한 서버 인증서 - 지문은 서버 인증서 (잎) 것, CA 지문을 믿어도 같은 CA 의 다른 인증서를 받지 않는다', () async {
await start('explicit', chain: true);
expect(caFp, isNotEmpty);
expect(caFp, isNot(fp));
final e = await err(() => FtpRemote(srv()).list('/'));
expect(e, isA<FtpCertException>().having((x) => x.fingerprint, 'fp', fp).having((x) => x.subject, 'subject', contains('127.0.0.1')));
// CA 의 지문을 믿고 있어도 막는다 (같은 CA 가 서명한 다른 서버로 바뀌어도 모르게 되지 않게)
final viaCa = await err(() => FtpRemote(srv(cert: caFp)).list('/'));
expect(viaCa, isA<FtpCertException>().having((x) => x.changed, 'changed', isTrue).having((x) => x.fingerprint, 'fp', fp));
// 잎 지문을 믿으면 명령 · 자료 연결 모두
final c = FtpRemote(srv(cert: fp));
await roundTrip(c);
c.close();
}, skip: hasPy ? false : '시험용 파이썬 없음');
test('FTPS (명시): 처음 보는 자체 서명 인증서는 지문을 보이고 막음 → 믿으면 폴더 · 쓰기 · 목록 · 부분 읽기 · 옮기기 · 지우기', () async {
await start('explicit');
final e = await err(() => FtpRemote(srv()).list('/'));
expect(e, isA<FtpCertException>().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<FtpCertException>().having((x) => x.changed, 'changed', isTrue));
}, skip: hasPy ? false : '시험용 파이썬 없음');
test('관리자 지시 (10/19): FTP 맞추기도 거르기 5가지를 따르고 미리 보기 = 실제', () async {
await start('plain');
FtpRegistry.configure([srv(security: 'none')]);
final src = Directory(p.join(tmp.path, 'fsrc'));
void fresh() {
for (final d in [src, Directory(p.join(root.path, 'dst'))]) {
if (d.existsSync()) d.deleteSync(recursive: true);
}
void lf(String rel, String s) => File(p.join(src.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(s);
void rf(String rel, String s) => File(p.join(root.path, 'dst', rel))
..parent.createSync(recursive: true)
..writeAsStringSync(s);
lf('a.txt', 'a');
lf('skip.tmp', 's');
lf('cache/c.bin', 'c');
lf('keep/x.txt', 'x');
lf('keep/y.log', 'y');
rf('only.tmp', 'o');
rf('old.txt', 'old');
rf('cache/remote.bin', 'r');
rf('gone/g.tmp', 'g');
rf('gone/g.txt', 'g');
}
String real(String rel) => p.joinAll([root.path, 'dst', ...rel.split('/').where((s) => s.isNotEmpty)]);
for (final (opts, delete) in [
('-av --exclude=*.tmp --exclude=cache/', true),
('-av --exclude=*.tmp --exclude=cache/ --delete-excluded', true),
('-av --include=keep/ --include=keep/*.txt --exclude=keep/* --exclude=*.tmp', true),
('-av --filter="- *.log" --filter="- /cache"', true),
('-av --exclude=*.tmp', false),
]) {
fresh();
final filter = RsyncFilter.parse(opts);
final before = <String>{};
void scan(Directory d, String rel) {
for (final e in d.listSync()) {
final r = rel.isEmpty ? p.basename(e.path) : '$rel/${p.basename(e.path)}';
before.add(r);
if (e is Directory) scan(e, r);
}
}
scan(Directory(real('')), '');
final pv = await previewSync(src.path, 'ftp://f1/dst', toRight: true, delete: delete, filter: filter);
await FileOps().mirror(src.path, 'ftp://f1/dst', delete: delete, filter: filter);
for (final x in pv) {
final there = FileSystemEntity.typeSync(real(x.rel)) != FileSystemEntityType.notFound;
if (x.action == PreviewAction.add || x.action == PreviewAction.update) expect(there, isTrue, reason: '+/~ ${x.rel} ($opts)');
if (x.action == PreviewAction.delete) expect(there, isFalse, reason: '− ${x.rel} ($opts)');
}
final shown = {for (final x in pv) if (x.action == PreviewAction.delete) x.rel};
for (final r in before) {
if (FileSystemEntity.typeSync(real(r)) == FileSystemEntityType.notFound) {
expect(shown.any((d) => r == d || r.startsWith('$d/')), isTrue, reason: '실제로 지워진 $r 는 미리 보기에 ($opts)');
}
}
}
// 하나 자세히: 받는 쪽의 거른 것 지키기 · 거른 것이 든 폴더는 남기고 안의 것만
fresh();
await FileOps().mirror(src.path, 'ftp://f1/dst', delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/'));
expect(File(real('skip.tmp')).existsSync(), isFalse);
expect(File(real('only.tmp')).existsSync(), isTrue);
expect(File(real('cache/remote.bin')).existsSync(), isTrue);
expect(File(real('old.txt')).existsSync(), isFalse);
expect((File(real('gone/g.tmp')).existsSync(), File(real('gone/g.txt')).existsSync()), (true, false));
}, skip: hasPy ? false : '시험용 파이썬 없음');
test('관리자 지시 (10/20): FTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
await start('plain');
FtpRegistry.configure([srv(security: 'none')]);
final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene');
final bad = <String>[];
for (final opts in sceneOptions) {
writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts);
writeScene(src, got);
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
final d = treeDiff(w, treeOf(got));
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
}
expect(bad, isEmpty, reason: bad.join('\n'));
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
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<String> 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('관리자 지시 (10/20): FTP 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async {
await start('plain');
FtpRegistry.configure([srv(security: 'none')]);
Directory(p.join(root.path, '영상')).createSync();
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789');
File(p.join(root.path, '영상', '2화.mkv')).writeAsStringSync('abcdefghij');
File(p.join(root.path, '영상', '2화.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n둘째\n');
File(p.join(root.path, '영상', '메모.txt')).writeAsStringSync('t');
addTearDown(RemoteStreamBridge.close);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
final plan = await c.preparePlayback(['ftp://f1/영상/1화.mkv']);
expect(plan!.$1, ['ftp://f1/영상/1화.mkv', 'ftp://f1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)');
final bridge = await RemoteStreamBridge.get();
final u1 = bridge.url('ftp://f1/영상/1화.mkv'), u2 = bridge.url('ftp://f1/영상/2화.mkv');
expect(u1, startsWith('http://127.0.0.1:'));
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient();
try {
final req = await http.getUrl(Uri.parse(url));
if (range != null) req.headers.set('Range', range);
final res = await req.close();
return (res.statusCode, await res.transform(utf8.decoder).join());
} finally {
http.close(force: true);
}
}
expect(await get(u1), (200, '0123456789'));
expect(await get(u1, 'bytes=4-6'), (206, '456'), reason: '건너뛰기 - 그 자리부터');
expect(await get(u1, 'bytes=7-'), (206, '789'));
expect(await get(u2), (200, 'abcdefghij'), reason: '다음 화도 받지 않고 바로');
final subs = await c.externalSubtitlesFor('ftp://f1/영상/2화.mkv');
expect(subs.map(p.basename), ['2화.ko.srt']);
expect(File(subs.single).readAsStringSync(), contains('둘째'));
}, 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<FtpException>().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<FtpException>().having((x) => x.kind, 'k', FtpFailure.auth));
expect(await err(() => FtpRemote(srv(security: 'none', password: '')).list('/')), isA<FtpException>().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<FtpException>().having((x) => x.kind, 'k', FtpFailure.unreachable));
}, skip: hasPy ? false : '시험용 파이썬 없음');
});
}