274 lines
14 KiB
Dart
274 lines
14 KiB
Dart
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';
|
||
|
||
/// 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('동영상 스트리밍 다리: 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('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 : '시험용 파이썬 없음');
|
||
});
|
||
}
|