385 lines
20 KiB
Dart
385 lines
20 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';
|
||
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) 없음');
|
||
});
|
||
}
|