258 lines
13 KiB
Dart
258 lines
13 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;
|
|
|
|
/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 -
|
|
/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다.
|
|
void main() {
|
|
final py = Platform.environment['JJ_SFTP_PY'] ??
|
|
p.normalize(p.join(Directory.current.path, '..', 'tools', 'sftp_test_py', Platform.isWindows ? 'Scripts/python.exe' : 'bin/python'));
|
|
final hasServer = File(py).existsSync();
|
|
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;
|
|
Process? server;
|
|
late int port;
|
|
late String fingerprint;
|
|
|
|
Future<void> start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async {
|
|
final pr = await Process.start(py, [
|
|
'tool/sftp_test_server.py',
|
|
'--root', root.path,
|
|
'--hostkey', p.join(tmp.path, 'hostkey_$keytype'),
|
|
'--keytype', keytype,
|
|
'--user', 'jj',
|
|
if (password != null) ...['--password', password],
|
|
if (pubkey != null) ...['--pubkey', pubkey],
|
|
if (legacyOnly) '--legacy-only',
|
|
]);
|
|
server = 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 '))).split(' ')[1]);
|
|
fingerprint = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT '))).split(' ')[1];
|
|
}
|
|
|
|
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;
|
|
if (s != null) {
|
|
await s.stdin.close().catchError((_) {});
|
|
s.kill();
|
|
await s.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));
|
|
}
|
|
}
|
|
});
|
|
|
|
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('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) 없음');
|
|
});
|
|
}
|