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/sftp.dart'; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; import 'support/sftp_server.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; /// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - /// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. void main() { final hasServer = sftpTestServerAvailable; final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen'; group('설정 · 경로 (서버 없이)', () { test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async { final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_'); addTearDown(() => tmp.deleteSync(recursive: true)); final secrets = MemorySecretStore(); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final s = AppSettings() ..sftpServers = [ const SftpServer( id: 's1', name: 'NAS', host: 'nas.local', port: 2222, user: 'jj', password: 'pw-secret', privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----', keyPassphrase: 'kp-secret', legacyRsa: true, hostKeyType: 'ssh-ed25519', hostKeyFingerprint: 'SHA256:abc'), ]; await store.save(s); final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync(); expect(text, isNot(contains('pw-secret'))); expect(text, isNot(contains('kp-secret'))); expect(text, isNot(contains('OPENSSH PRIVATE KEY'))); expect(secrets.values['sftp.pw.s1'], 'pw-secret'); expect(secrets.values['sftp.kp.s1'], 'kp-secret'); expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); final x = back.sftpServers.single; expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc')); expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret')); expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); // .bak · 모르는 항목 보존용 JSON 에서도 빠진다 final stripped = AppSettings.stripSecrets({ 'sftpServers': [ {'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'} ] }); expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"')))); expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}'); }); test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () { SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]); addTearDown(() => SftpRegistry.configure(const [])); final d = DavPath.parse(r'sftp://s1/a\b/'); expect(d.full, 'sftp://s1/a/b'); expect(d.isSftp, isTrue); expect(d.parent.full, 'sftp://s1/a'); expect(d.parent.parent.full, 'sftp://s1/'); expect(d.child('c').full, 'sftp://s1/a/b/c'); expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x'); expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a'); expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)'); expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true)); expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b'); expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다'); expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse); }); }); group('실제 SFTP 서버 (paramiko)', () { late Directory tmp; late Directory root; SftpTestServer? server; late int port; late String fingerprint; Future start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async { final s = await SftpTestServer.start( root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'), password: password, pubkey: pubkey, keytype: keytype, legacyOnly: legacyOnly); server = s; port = s.port; fingerprint = s.fingerprint; } setUp(() { tmp = Directory.systemTemp.createTempSync('jj_sftp88_'); root = Directory(p.join(tmp.path, 'root'))..createSync(); }); tearDown(() async { SftpRegistry.configure(const []); final s = server; server = null; await s?.stop(); 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)); } } }); SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) => SftpServer( id: 's1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, privateKey: key, keyPassphrase: kp, legacyRsa: legacy, hostKeyFingerprint: fp, root: rootDir); Future err(Future Function() f) async { try { await f(); return null; } catch (e) { return e; } } test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async { await start(); final e = await err(() => SftpRemote(srv()).list('/')); expect(e, isA()); final hk = e! as SftpHostKeyException; expect(hk.kind, SftpFailure.hostKeyUnknown); expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문'); expect(hk.type, 'ssh-ed25519'); expect(hk.changed, isFalse); // 믿음 → 연결 final ok = SftpRemote(srv(fp: fingerprint)); addTearDown(ok.close); expect(await ok.list('/'), isEmpty); // 바뀐 키 → 막고 예전 · 지금 지문을 알림 final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/')); expect(e2, isA()); expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged); expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키'); expect('$e2', contains('바뀌었습니다')); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async { await start(); final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/')); expect((bad! as SftpException).kind, SftpFailure.auth); final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/')); expect((none! as SftpException).kind, SftpFailure.noSecret); expect('$none', '저장된 비밀번호가 없습니다'); // 닫힌 포트 final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final closedPort = free.port; await free.close(); final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'), timeout: const Duration(seconds: 3)) .list('/')); expect((off! as SftpException).kind, SftpFailure.unreachable); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async { await start(); SftpRegistry.configure([srv(fp: fingerprint)]); final local = Directory(p.join(tmp.path, 'local', 'src'))..createSync(recursive: true); File(p.join(local.path, 'x')).writeAsStringSync('file'); Directory(p.join(root.path, 'dst', 'x', 'sub')).createSync(recursive: true); File(p.join(root.path, 'dst', 'x', 'keep.txt')).writeAsStringSync('k'); const dst = 'sftp://s1/dst'; var pv = await previewSync(local.path, dst, toRight: true); expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']); final ops = FileOps(); await ops.mirror(local.path, dst); expect(File(p.join(root.path, 'dst', 'x', 'keep.txt')).existsSync(), isTrue); expect(ops.dirConflicts, hasLength(1)); pv = await previewSync(local.path, dst, toRight: true, delete: true); final del = pv.singleWhere((x) => x.action == PreviewAction.delete); expect((del.replacedByFile, del.inner), (true, 2)); await FileOps().mirror(local.path, dst, delete: true); expect(File(p.join(root.path, 'dst', 'x')).readAsStringSync(), 'file'); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async { await start(); SftpRegistry.configure([srv(fp: fingerprint)]); const base = 'sftp://s1/'; await vMkdirs('${base}a/b'); expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue); Stream> bytes(String s) => Stream.value(utf8.encode(s)); await vWrite('${base}a/b/f.txt', bytes('hello')); await vWrite('${base}a/b/f.txt', bytes('hello again')); expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)'); expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음'); final list = await vList('${base}a/b'); expect(list.single.path, 'sftp://s1/a/b/f.txt'); expect((list.single.isDir, list.single.size), (false, 11)); final st = await vStat('${base}a'); expect(st!.isDir, isTrue); expect(await vStat('$base없음'), isNull); expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again'); await vRename('${base}a/b/f.txt', '${base}a/g.txt'); expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue); // 받아서 열기 (임시 폴더) final local = await vLocalCopy('${base}a/g.txt', tmp.path); expect(File(local).readAsStringSync(), 'hello again'); expect(local, contains('jj_sftp')); await vDelete('${base}a'); expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse); // 시작 폴더: 그 안이 탭의 맨 위 Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true); File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m'); SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]); expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']); final (free, _) = await DavPath.parse(base).client.quota(); expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null'); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async { final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}'); final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]); if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음'); addTearDown(() { for (final f in [key, '$key.pub']) { if (File(f).existsSync()) File(f).deleteSync(); } }); await start(password: null, pubkey: '$key.pub'); final pem = File(key).readAsStringSync(); final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint)); addTearDown(c.close); expect(await c.list('/'), isEmpty); final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); expect((bad! as SftpException).kind, SftpFailure.auth); expect('$bad', contains('개인 키의 암호')); }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { await start(keytype: 'rsa', legacyOnly: true); final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/')); expect(e, isA()); expect('$e', contains('ssh-rsa')); final c = SftpRemote(srv(fp: fingerprint, legacy: true)); addTearDown(c.close); expect(await c.list('/'), isEmpty); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); }); }