modular_sync #6 F11 나머지: 원격 등록 4벌 · 마스터 문 4벌을 RemoteRegistry · requireSecretGate 하나로

- lib/core/remote_registry.dart: RemoteRegistry<S, C> (configure · servers · server · client · closeAll, onDrop) - 서버마다 다른 것은
  비밀 값 목록 · "설정 없음" 오류 · 만들기뿐. DavRegistry · SftpRegistry · SmbRegistry · FtpRegistry 는 같은 이름 그대로 이것을 부름
- 다시 접속하는 바뀜은 예전과 같게 (관리자 조건 ①): 지움 · 비밀이 아닌 칸 (toJson: 주소 · 포트 · 사용자 · 루트 · 보안 · 지문 · 공유 · 도메인 · insecure) ·
  비밀 값 (WebDAV · SMB · FTP = 비밀번호, SFTP = 비밀번호 · 개인 키 · 키 암호). 접속에 쓰는 칸은 모두 이 둘 안 - 주소 · 사용자가 바뀌었는데
  예전 클라이언트를 쓰는 경우는 예전에도 없음. SMB: 버린 연결의 서명 기록 지우기 · 서명 필수가 바뀌면 모두 닫기 그대로
- 문 (관리자 조건 ②, 보안): SecretGate.pass 를 부르는 곳은 requireSecretGate 하나 - 비밀 값이 있으면 연결 전에 마스터 확인
- 약속 시험 remote_registry_contract (7): 4종 등록 규칙 · SMB 서명 필수 · 4종 문 (거절이면 연결 전 DavLockedException, SFTP 는 키만 있어도) ·
  비밀 값이 없으면 묻지 않음. 바꾸기 전 판 (fe8e5b7) 에서도 7개 모두 같게 통과 (동작 그대로임을 확인)

사용자가 보게 될 동작: 바뀌는 것 없음 (구조만)

확인: analyze 0 · 단위 1497 ×2 · 통합 6/6 ×2 · base10 214장 0px · ftp_shot · sshkey_shot · remote_idle_shot ×2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 14:59:15 +09:00
parent fe8e5b7bc6
commit c27eb9b59e
6 changed files with 235 additions and 115 deletions

View File

@ -5,11 +5,11 @@ import 'dart:io';
import '../../l10n/tr.dart';
import '../dns_resolver.dart';
import '../remote_fs.dart';
import '../secret_gate.dart';
import '../webdav.dart' show DavItem, DavLockedException;
import '../remote_idle.dart';
import 'ftp_server.dart';
import 'ftp_session.dart';
import '../remote_registry.dart';
// 약속 (나누기 ftp): FTP · FTPS 원격 저장소 ([RemoteClient]) 와 서버마다 하나인 클라이언트 목록 ([FtpRegistry]).
// 입력: 서버 기준 경로 ("/" 로 시작). 출력: 목록 · 정보 ([DavItem]) · 읽기 흐름 · 쓰기 · 남은 공간 (FTP 는 모름).
@ -29,7 +29,7 @@ class FtpRemote implements RemoteClient {
Future<FtpSession> _connect() async {
final s = server;
if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other);
if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
await requireSecretGate(s.password.isNotEmpty);
if (s.user.isNotEmpty && s.password.isEmpty) {
throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret);
}
@ -307,8 +307,13 @@ class FtpRemote implements RemoteClient {
/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나)
class FtpRegistry {
static final _clients = <String, FtpRemote>{};
static final _servers = <String, FtpServer>{};
// F11: 몸통은 RemoteRegistry 하나 - 비밀번호는 toJson 에 없으므로 따로 비교
static final _r = RemoteRegistry<FtpServer, FtpRemote>(
idOf: (s) => s.id,
toJson: (s) => s.toJson(),
secretsOf: (s) => [s.password],
create: FtpRemote.new,
missing: (id) => FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other));
/// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup)
static FtpResolve resolve = (host) async => InternetAddress.tryParse(host);
@ -322,25 +327,8 @@ class FtpRegistry {
return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull;
};
static void configure(List<FtpServer> servers) {
final keep = {for (final s in servers) s.id: s};
for (final id in _clients.keys.toList()) {
final old = _servers[id], now = keep[id];
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
_clients.remove(id)?.close();
}
}
_servers
..clear()
..addAll(keep);
}
static List<FtpServer> get servers => _servers.values.toList();
static FtpServer? server(String id) => _servers[id];
static FtpRemote client(String id) {
final s = _servers[id];
if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other);
return _clients.putIfAbsent(id, () => FtpRemote(s));
}
static void configure(List<FtpServer> servers) => _r.configure(servers);
static List<FtpServer> get servers => _r.servers;
static FtpServer? server(String id) => _r.server(id);
static FtpRemote client(String id) => _r.client(id);
}

View File

@ -0,0 +1,79 @@
/// 약속 (modular_sync #6 · F11): 원격 서버 등록 하나 - 설정의 서버 목록 ([RemoteRegistry.configure]) → 서버 id 마다 클라이언트 하나
/// ([RemoteRegistry.client]). WebDAV · SFTP · SMB · FTP 가 같은 몸통을 쓰고, 서버마다 다른 것은 셋뿐: 비밀 값 목록 · "설정 없음" 오류 · 만들기.
/// 다시 접속 (예전 클라이언트를 닫고 다음에 새로) 하는 바뀜 - 예전 4벌과 똑같이 (관리자 10/24 조건 ①):
/// 서버를 지움 · 새로 들어옴 · toJson 이 다름 (비밀이 아닌 모든 칸: 주소 · 포트 · 사용자 · 루트 · 보안 · 지문 · 공유 · 도메인 · insecure …) ·
/// 비밀 값이 다름 (WebDAV · SMB · FTP = password, SFTP = password · privateKey · keyPassphrase)
/// 문 ([requireSecretGate]): 비밀 값이 있으면 마스터 확인 (SecretGate) 을 지나야 연다 - 못 지나면 [DavLockedException] (연결 전에)
library;
import 'dart:convert';
import '../l10n/tr.dart';
import 'remote_fs.dart';
import 'secret_gate.dart';
import 'webdav.dart' show DavLockedException;
class RemoteRegistry<S, C extends RemoteClient> {
RemoteRegistry({required this.idOf, required this.toJson, required this.secretsOf, required this.create, required this.missing, this.onDrop});
final String Function(S server) idOf;
final Map<String, Object?> Function(S server) toJson;
/// 비밀 값들 (toJson 에 없는 것 - 바뀌면 다시 접속)
final List<String> Function(S server) secretsOf;
final C Function(S server) create;
/// 등록에 없는 id 로 [client] 를 부를 때의 오류
final Exception Function(String id) missing;
/// 클라이언트를 버릴 때 (다시 접속 · 지움) - SMB 의 지난 연결 서명 기록 지우기 등
final void Function(String id)? onDrop;
final _clients = <String, C>{};
final _servers = <String, S>{};
bool _same(S old, S now) {
final a = secretsOf(old), b = secretsOf(now);
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return jsonEncode(toJson(old)) == jsonEncode(toJson(now));
}
void configure(List<S> servers) {
final keep = {for (final s in servers) idOf(s): s};
for (final id in _clients.keys.toList()) {
final old = _servers[id], now = keep[id];
if (now == null || old == null || !_same(old, now)) {
_clients.remove(id)?.close();
onDrop?.call(id);
}
}
_servers
..clear()
..addAll(keep);
}
List<S> get servers => _servers.values.toList();
S? server(String id) => _servers[id];
/// 열린 클라이언트를 모두 닫는다 (다음 [client] 에서 새로 - 연결 규칙이 바뀐 때)
void closeAll() {
for (final c in _clients.values) {
c.close();
}
_clients.clear();
}
C client(String id) {
final s = _servers[id];
if (s == null) throw missing(id);
return _clients.putIfAbsent(id, () => create(s));
}
}
/// 문: 비밀 값이 있으면 마스터 확인을 지나야 한다 (못 지나면 연결 전에 [DavLockedException]). 비밀 값이 없으면 묻지 않는다
Future<void> requireSecretGate(bool hasSecret) async {
if (hasSecret && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
}

View File

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
@ -8,10 +7,10 @@ import 'package:dartssh2/dartssh2.dart';
import '../../l10n/tr.dart';
import '../dns_resolver.dart';
import '../remote_fs.dart';
import '../secret_gate.dart';
import '../webdav.dart' show DavItem, DavLockedException;
import '../webdav.dart' show DavItem;
import '../remote_idle.dart';
import 'sftp_server.dart';
import '../remote_registry.dart';
// 약속 (나누기 sftp): SFTP 원격 저장소 ([RemoteClient], dartssh2) 와 서버마다 하나인 클라이언트 목록 ([SftpRegistry]).
// 입력: 서버 기준 경로 ("/" 로 시작). 출력: 목록 · 정보 ([DavItem]) · 부분 읽기 흐름 · 쓰기 · 남은 공간 (모르면 null).
@ -48,7 +47,7 @@ class SftpRemote implements RemoteClient {
final s = server;
if (s.host.trim().isEmpty) throw SftpException(tr('SFTP 서버 주소가 없습니다'), SftpFailure.other);
final hasSecret = s.password.isNotEmpty || s.privateKey.isNotEmpty;
if (hasSecret && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
await requireSecretGate(hasSecret);
// 키도 비밀번호도 없으면 (저장하지 않음) 시도하지 않고 정확히 알린다 (40 과 같게)
if (!hasSecret) throw SftpException(tr('저장된 비밀번호가 없습니다'), SftpFailure.noSecret);
Socket sock;
@ -404,8 +403,6 @@ class _Socket implements SSHSocket {
/// 설정의 SFTP 서버 목록과 클라이언트 (서버마다 하나)
class SftpRegistry {
static final _clients = <String, SftpRemote>{};
static final _servers = <String, SftpServer>{};
/// 연결 방법 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS
static SftpConnect connect = (host, port, timeout) => Socket.connect(host, port, timeout: timeout);
@ -427,31 +424,16 @@ class SftpRegistry {
throw last ?? SocketException(trf('{0} 에 연결할 수 없습니다', [host]));
};
static void configure(List<SftpServer> servers) {
final keep = {for (final s in servers) s.id: s};
for (final id in _clients.keys.toList()) {
final old = _servers[id], now = keep[id];
// 비밀 값은 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 값으로 접속)
if (now == null ||
old == null ||
old.password != now.password ||
old.privateKey != now.privateKey ||
old.keyPassphrase != now.keyPassphrase ||
jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
_clients.remove(id)?.close();
}
}
_servers
..clear()
..addAll(keep);
}
// F11: 몸통은 RemoteRegistry 하나 - 비밀 값 (비밀번호 · 개인 키 · 키 암호) 은 toJson 에 없으므로 따로 비교
static final _r = RemoteRegistry<SftpServer, SftpRemote>(
idOf: (s) => s.id,
toJson: (s) => s.toJson(),
secretsOf: (s) => [s.password, s.privateKey, s.keyPassphrase],
create: SftpRemote.new,
missing: (id) => SftpException(trf('SFTP 서버 설정이 없습니다 ({0})', [id]), SftpFailure.other));
static List<SftpServer> get servers => _servers.values.toList();
static SftpServer? server(String id) => _servers[id];
static SftpRemote client(String id) {
final s = _servers[id];
if (s == null) throw SftpException(trf('SFTP 서버 설정이 없습니다 ({0})', [id]), SftpFailure.other);
return _clients.putIfAbsent(id, () => SftpRemote(s));
}
static void configure(List<SftpServer> servers) => _r.configure(servers);
static List<SftpServer> get servers => _r.servers;
static SftpServer? server(String id) => _r.server(id);
static SftpRemote client(String id) => _r.client(id);
}

View File

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show InternetAddress;
import 'dart:math' show min;
import 'dart:typed_data';
@ -8,9 +7,9 @@ import 'package:flutter/services.dart';
import '../l10n/tr.dart';
import 'remote_fs.dart';
import 'secret_gate.dart';
import 'webdav.dart' show DavException, DavItem, DavLockedException;
import 'webdav.dart' show DavException, DavItem;
import 'remote_idle.dart';
import 'remote_registry.dart';
/// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1).
/// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
@ -129,7 +128,7 @@ class SmbRemote implements RemoteClient {
if (server.host.trim().isEmpty || server.share.trim().isEmpty) {
throw SmbException(tr('SMB 서버 주소 · 공유 이름이 없습니다'), SmbFailure.other);
}
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
await requireSecretGate(server.password.isNotEmpty);
if (server.user.isNotEmpty && server.password.isEmpty) throw SmbException(tr('저장된 비밀번호가 없습니다'), SmbFailure.noPassword);
// 보안 검토 5: 이름은 여기서 풀어 주소로 넘긴다 (Android 의 SMB 다리가 시스템 DNS 로 풀어 강제 DNS 를 비켜 가지 않게)
String host;
@ -306,8 +305,14 @@ class SmbRemote implements RemoteClient {
/// 설정의 SMB 서버 목록과 클라이언트 (서버마다 하나)
class SmbRegistry {
static final _clients = <String, SmbRemote>{};
static final _servers = <String, SmbServer>{};
// F11: 몸통은 RemoteRegistry 하나 - 비밀번호는 toJson 에 없으므로 따로 비교, 버린 연결의 서명 기록도 지운다
static final _r = RemoteRegistry<SmbServer, SmbRemote>(
idOf: (s) => s.id,
toJson: (s) => s.toJson(),
secretsOf: (s) => [s.password],
create: SmbRemote.new,
missing: (id) => SmbException(trf('SMB 서버 설정이 없습니다 ({0})', [id]), SmbFailure.other),
onDrop: (id) => _secure.remove(id));
/// 보안 검토 6: 서버 id → 지난 연결이 서명이나 암호화되었는지 (모르면 없음)
static final _secure = <String, bool>{};
@ -321,33 +326,16 @@ class SmbRegistry {
static set requireSecure(bool v) {
if (v == _requireSecure) return;
_requireSecure = v;
for (final c in _clients.values) {
c.close();
}
_clients.clear();
_r.closeAll();
}
static void configure(List<SmbServer> servers) {
final keep = {for (final s in servers) s.id: s};
for (final id in _clients.keys.toList()) {
final old = _servers[id], now = keep[id];
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
_clients.remove(id)?.close();
_secure.remove(id);
}
}
_secure.removeWhere((id, _) => !keep.containsKey(id));
_servers
..clear()
..addAll(keep);
_r.configure(servers);
_secure.removeWhere((id, _) => _r.server(id) == null);
}
static List<SmbServer> get servers => _servers.values.toList();
static SmbServer? server(String id) => _servers[id];
static SmbRemote client(String id) {
final s = _servers[id];
if (s == null) throw SmbException(trf('SMB 서버 설정이 없습니다 ({0})', [id]), SmbFailure.other);
return _clients.putIfAbsent(id, () => SmbRemote(s));
}
static List<SmbServer> get servers => _r.servers;
static SmbServer? server(String id) => _r.server(id);
static SmbRemote client(String id) => _r.client(id);
}

View File

@ -6,8 +6,8 @@ import 'package:xml/xml.dart';
import '../l10n/tr.dart';
import 'remote_fs.dart';
import 'secret_gate.dart';
import 'remote_idle.dart';
import 'remote_registry.dart';
/// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
class DavServer {
@ -145,7 +145,7 @@ class DavClient implements RemoteClient {
Future<HttpClientRequest> _open(String method, Uri uri) async {
// 124: 저장된 비밀번호로 접속하기 전에 (마스터 비밀번호를 정해 두었으면 묻는다)
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
await requireSecretGate(server.password.isNotEmpty);
final req = await _http.openUrl(method, uri);
final a = _auth;
if (a != null) req.headers.set(HttpHeaders.authorizationHeader, a);
@ -380,32 +380,16 @@ class DavClient implements RemoteClient {
/// 설정의 WebDAV 서버들 (id → 클라이언트). 설정이 바뀌면 [configure].
class DavRegistry {
static final _clients = <String, DavClient>{};
static final _servers = <String, DavServer>{};
// F11: 몸통은 RemoteRegistry 하나 - 비밀번호는 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 비밀번호로 접속 - 40-2)
static final _r = RemoteRegistry<DavServer, DavClient>(
idOf: (s) => s.id,
toJson: (s) => s.toJson(),
secretsOf: (s) => [s.password],
create: DavClient.new,
missing: (id) => DavException(trf('WebDAV 서버 설정이 없습니다 ({0})', [id])));
static void configure(List<DavServer> servers) {
final keep = {for (final s in servers) s.id: s};
for (final id in _clients.keys.toList()) {
final old = _servers[id], now = keep[id];
// 비밀번호는 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 비밀번호로 접속 - 40-2)
if (now == null ||
old == null ||
old.password != now.password ||
jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
_clients.remove(id)?.close();
}
}
_servers
..clear()
..addAll(keep);
}
static List<DavServer> get servers => _servers.values.toList();
static DavServer? server(String id) => _servers[id];
static DavClient client(String id) {
final s = _servers[id];
if (s == null) throw DavException(trf('WebDAV 서버 설정이 없습니다 ({0})', [id]));
return _clients.putIfAbsent(id, () => DavClient(s));
}
static void configure(List<DavServer> servers) => _r.configure(servers);
static List<DavServer> get servers => _r.servers;
static DavServer? server(String id) => _r.server(id);
static DavClient client(String id) => _r.client(id);
}

View File

@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/ftp.dart';
import 'package:jj_mkvmaker/core/remote_fs.dart';
import 'package:jj_mkvmaker/core/secret_gate.dart';
import 'package:jj_mkvmaker/core/sftp.dart';
import 'package:jj_mkvmaker/core/smb.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
/// modular_sync #6 F11 약속: 원격 등록 4벌 · 마스터 문 4벌을 한 몸통 (RemoteRegistry · requireSecretGate) 으로.
/// ① 다시 접속하는 바뀜은 예전과 같게: 지움 · 비밀이 아닌 칸 (주소 · 사용자 …) · 비밀 값 (SFTP 는 키 · 키 암호까지)
/// ② 문 (보안): 비밀 값이 있는데 마스터 확인 없이 열리는 길이 없음 - 거절이면 연결 전에 DavLockedException, 비밀 값이 없으면 묻지 않음
void main() {
tearDown(() {
SecretGate.check = null;
DavRegistry.configure(const []);
SftpRegistry.configure(const []);
SmbRegistry.configure(const []);
FtpRegistry.configure(const []);
SmbRegistry.requireSecure = false;
});
/// 등록 하나의 규칙: 같으면 같은 클라이언트 · [changes] 마다 새 클라이언트 · 지우면 [missing] 오류
void rules<S>(String name, S base, List<S> changes, void Function(List<S>) configure, RemoteClient Function(String) client,
Matcher missing) {
test('$name: 같으면 같은 클라이언트 · 바뀌면 새로 · 지우면 설정 없음', () {
configure([base]);
final first = client('x');
configure([base]);
expect(identical(client('x'), first), isTrue, reason: '그대로면 다시 접속하지 않음');
var prev = first;
for (final (i, c) in changes.indexed) {
configure([base]);
prev = client('x');
configure([c]);
expect(identical(client('x'), prev), isFalse, reason: '바뀜 $i 이면 새 클라이언트');
}
configure(const []);
expect(() => client('x'), throwsA(missing));
});
}
const dav = DavServer(id: 'x', name: 'n', url: 'http://127.0.0.1:1/dav', user: 'u', password: 'p');
rules<DavServer>('WebDAV', dav, [dav.copyWith(url: 'http://127.0.0.1:2/dav'), dav.copyWith(user: 'v'), dav.copyWith(password: 'q'),
dav.copyWith(insecure: true)], DavRegistry.configure, DavRegistry.client, isA<DavException>());
const sftp = SftpServer(id: 'x', name: 'n', host: '127.0.0.1', port: 1, user: 'u', password: 'p', privateKey: 'k', keyPassphrase: 'kp');
rules<SftpServer>('SFTP', sftp, [sftp.copyWith(host: 'h2'), sftp.copyWith(user: 'v'), sftp.copyWith(password: 'q'),
sftp.copyWith(privateKey: 'k2'), sftp.copyWith(keyPassphrase: 'kp2')], SftpRegistry.configure, SftpRegistry.client, isA<SftpException>());
const smb = SmbServer(id: 'x', name: 'n', host: '127.0.0.1', port: 1, share: 's', user: 'u', password: 'p');
rules<SmbServer>('SMB', smb, [smb.copyWith(host: 'h2'), smb.copyWith(share: 't'), smb.copyWith(user: 'v'), smb.copyWith(password: 'q')],
SmbRegistry.configure, SmbRegistry.client, isA<SmbException>());
const ftp = FtpServer(id: 'x', name: 'n', host: '127.0.0.1', port: 1, user: 'u', password: 'p', security: 'none');
rules<FtpServer>('FTP', ftp, [ftp.copyWith(host: 'h2'), ftp.copyWith(user: 'v'), ftp.copyWith(password: 'q'), ftp.copyWith(security: 'explicit')],
FtpRegistry.configure, FtpRegistry.client, isA<FtpException>());
test('SMB: 서명 · 암호화 필수를 바꾸면 열린 연결을 버림', () {
SmbRegistry.configure(const [smb]);
final first = SmbRegistry.client('x');
SmbRegistry.requireSecure = true;
expect(identical(SmbRegistry.client('x'), first), isFalse);
});
test('문: 비밀 값이 있으면 4종 모두 마스터 확인을 지나야 함 - 거절이면 연결 전에 DavLockedException', () async {
var asked = 0;
SecretGate.check = (_) async {
asked++;
return false;
};
final clients = <RemoteClient>[DavClient(dav), SftpRemote(sftp), SmbRemote(smb), FtpRemote(ftp)];
for (final c in clients) {
await expectLater(c.list('/'), throwsA(isA<DavLockedException>()), reason: '${c.runtimeType}');
c.close();
}
expect(asked, clients.length, reason: '서버마다 물었음');
// SFTP 는 개인 키만 있어도 비밀 값
final keyOnly = SftpRemote(const SftpServer(id: 'y', name: 'n', host: '127.0.0.1', port: 1, user: 'u', privateKey: 'k'));
await expectLater(keyOnly.list('/'), throwsA(isA<DavLockedException>()));
keyOnly.close();
});
test('문: 비밀 값이 없으면 묻지 않는다 (막히는 것은 마스터가 아니라 그 서버의 까닭)', () async {
var asked = 0;
SecretGate.check = (_) async {
asked++;
return false;
};
for (final c in <RemoteClient>[
DavClient(const DavServer(id: 'z', name: 'n', url: 'http://127.0.0.1:1/dav')),
SftpRemote(const SftpServer(id: 'z', name: 'n', host: '127.0.0.1', port: 1, user: 'u')),
FtpRemote(const FtpServer(id: 'z', name: 'n', host: '127.0.0.1', port: 1, security: 'none')),
]) {
await expectLater(c.list('/'), throwsA(isNot(isA<DavLockedException>())), reason: '${c.runtimeType}');
c.close();
}
expect(asked, 0);
});
}