JJ_MKVMaker/test/sftp_test.dart

272 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/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<void> 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<void>.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<Object?> err(Future<void> 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<SftpHostKeyException>());
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<SftpHostKeyException>());
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<List<int>> 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<SftpException>());
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) 없음');
});
}