JJ_MKVMaker/test/sftp_test.dart

385 lines
20 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/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';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'support/rsync_scenes.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('관리자 지시 (10/19): SFTP 맞추기도 거르기를 따름 (보내지 않기 · 받는 쪽 지키기 · 거른 것이 든 폴더)', () async {
await start();
SftpRegistry.configure([srv(fp: fingerprint)]);
final src = Directory(p.join(tmp.path, 'local', 'fsrc'))..createSync(recursive: true);
File(p.join(src.path, 'a.txt')).writeAsStringSync('a');
File(p.join(src.path, 'skip.tmp')).writeAsStringSync('s');
Directory(p.join(root.path, 'fdst', 'gone')).createSync(recursive: true);
File(p.join(root.path, 'fdst', 'only.tmp')).writeAsStringSync('o');
File(p.join(root.path, 'fdst', 'old.txt')).writeAsStringSync('old');
File(p.join(root.path, 'fdst', 'gone', 'g.tmp')).writeAsStringSync('g');
File(p.join(root.path, 'fdst', 'gone', 'g.txt')).writeAsStringSync('g');
const dst = 'sftp://s1/fdst';
final filter = RsyncFilter.parse('-av --exclude=*.tmp');
final pv = await previewSync(src.path, dst, toRight: true, delete: true, filter: filter);
expect({for (final x in pv) if (x.action == PreviewAction.delete) x.rel}, {'old.txt', 'gone/g.txt'});
await FileOps().mirror(src.path, dst, delete: true, filter: filter);
String real(String rel) => p.joinAll([root.path, 'fdst', ...rel.split('/')]);
expect(File(real('a.txt')).existsSync(), isTrue);
expect(File(real('skip.tmp')).existsSync(), isFalse);
expect(File(real('only.tmp')).existsSync(), isTrue);
expect(File(real('old.txt')).existsSync(), isFalse);
expect(File(real('gone/g.tmp')).existsSync(), isTrue);
expect(File(real('gone/g.txt')).existsSync(), isFalse);
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('관리자 지시 (10/20): SFTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
await start();
SftpRegistry.configure([srv(fp: fingerprint)]);
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, 'sftp://s1/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: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음', timeout: const Timeout(Duration(minutes: 5)));
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.keyPassphrase);
expect('$bad', contains('개인 키의 암호'));
}, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음');
test('관리자 지시 (10/21): SSH 키 파일 로그인 - ed25519 · rsa · ecdsa × OpenSSH · PEM (암호 있음 · 없음) 이 실제 서버에 붙고, 서버 거부 · 암호 없음은 따로', () async {
final dir = Directory(p.join(tmp.path, 'keys'))..createSync();
Future<String?> gen(String name, List<String> args) async {
final f = p.join(dir.path, name);
final r = await Process.run(keygen, ['-q', ...args, '-f', f, '-C', 'jj-test']);
return r.exitCode == 0 ? f : null;
}
final cases = [
('ed25519 OpenSSH 암호', ['-t', 'ed25519', '-N', 'kp'], 'kp'),
('ed25519 OpenSSH 암호 없음', ['-t', 'ed25519', '-N', ''], ''),
('rsa OpenSSH 암호', ['-t', 'rsa', '-b', '2048', '-N', 'kp'], 'kp'),
('rsa PEM 암호 없음', ['-t', 'rsa', '-b', '2048', '-m', 'PEM', '-N', ''], ''),
('ecdsa OpenSSH 암호', ['-t', 'ecdsa', '-b', '256', '-N', 'kp'], 'kp'),
('ecdsa PEM 암호 없음', ['-t', 'ecdsa', '-b', '256', '-m', 'PEM', '-N', ''], ''),
];
final failed = <String>[];
for (final (i, (name, args, kp)) in cases.indexed) {
final key = await gen('k$i', args);
if (key == null) return markTestSkipped('ssh-keygen 이 $name 을 만들지 못함');
await server?.stop();
await start(password: null, pubkey: '$key.pub');
final pem = File(key).readAsStringSync();
expect(checkPrivateKey(pem), kp.isNotEmpty, reason: '$name: 암호가 걸렸는지');
final c = SftpRemote(srv(password: '', key: pem, kp: kp, fp: fingerprint));
try {
await c.list('/');
} catch (e) {
failed.add('$name: $e');
} finally {
c.close();
}
}
expect(failed, isEmpty, reason: failed.join('\n'));
// 서버가 모르는 키 (짝 공개 키가 서버에 없음) → 키 거부 (비밀번호 틀림과 따로)
final other = await gen('other', ['-t', 'ed25519', '-N', '']);
final rejected = await err(() => SftpRemote(srv(password: '', key: File(other!).readAsStringSync(), fp: fingerprint)).list('/'));
expect((rejected! as SftpException).kind, SftpFailure.keyRejected, reason: '$rejected');
expect('$rejected', contains('authorized_keys'));
// 암호가 걸린 키인데 키 암호를 넣지 않음 → 그렇다고 (서버에 묻기 전에)
final locked = File(p.join(dir.path, 'k0')).readAsStringSync();
final noPass = await err(() => SftpRemote(srv(password: '', key: locked, fp: fingerprint)).list('/'));
expect((noPass! as SftpException).kind, SftpFailure.keyPassphrase);
expect('$noPass', contains('키 암호를 넣으세요'));
}, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음', timeout: const Timeout(Duration(minutes: 3)));
test('관리자 지시 (10/21): 쓸 수 없는 키 꼴은 고를 때 바로 - PuTTY (.ppk) · PKCS#8 · 공개 키 · 다른 파일', () async {
final dir = Directory(p.join(tmp.path, 'keys'))..createSync();
final f = p.join(dir.path, 'p8');
final r = await Process.run(keygen, ['-q', '-t', 'rsa', '-b', '2048', '-m', 'PKCS8', '-N', '', '-f', f]);
final samples = {
'PuTTY': 'PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: x\nPublic-Lines: 1\nAAAA\n',
if (r.exitCode == 0) 'PKCS#8': File(f).readAsStringSync(),
if (r.exitCode == 0) '공개 키': File('$f.pub').readAsStringSync(),
'다른 파일': 'hello',
};
for (final MapEntry(key: name, value: text) in samples.entries) {
Object? e;
try {
checkPrivateKey(text);
} catch (x) {
e = x;
}
expect(e, isA<SftpException>().having((x) => x.kind, 'kind', SftpFailure.keyFormat), reason: name);
if (name == 'PuTTY') expect('$e', contains('Export OpenSSH key'));
}
}, skip: File(keygen).existsSync() ? false : '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) 없음');
});
}