JJ_MKVMaker/test/smb_test.dart

298 lines
14 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/smb.dart';
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:jj_mkvmaker/ui/smb_settings.dart';
import 'package:path/path.dart' as p;
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'support/rsync_scenes.dart';
/// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫)
class _FakeSmb {
/// 공유 안 경로 ("a\\b") → 내용 (null 이면 폴더)
final files = <String, List<int>?>{};
final calls = <String>[];
String? failWith;
bool encrypted = true;
bool signed = false;
Map<Object?, Object?> lastArgs = const {};
final _open = <int, (String, bool)>{};
var _next = 1;
String _key(Object? path) => '$path'.replaceAll(RegExp(r'^/+'), '').replaceAll('/', r'\');
Future<Object?> handle(MethodCall call) async {
calls.add(call.method);
final a = (call.arguments as Map?) ?? const {};
lastArgs = a;
if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith');
switch (call.method) {
case 'info':
return {'encrypted': encrypted, 'signed': signed, 'dialect': 'SMB_3_1_1'};
case 'list':
final dir = _key(a['path']);
final prefix = dir.isEmpty ? '' : '$dir\\';
if (dir.isNotEmpty && !files.containsKey(dir)) throw PlatformException(code: 'notFound', message: dir);
return [
for (final e in files.entries)
if (e.key.startsWith(prefix) && e.key.length > prefix.length && !e.key.substring(prefix.length).contains(r'\'))
{'name': e.key.substring(prefix.length), 'dir': e.value == null, 'size': e.value?.length ?? 0, 'mtime': 1700000000000},
];
case 'stat':
final k = _key(a['path']);
if (k.isEmpty) return {'dir': true, 'size': 0, 'mtime': 0};
if (!files.containsKey(k)) return null;
return {'dir': files[k] == null, 'size': files[k]?.length ?? 0, 'mtime': 1700000000000};
case 'mkdir':
files[_key(a['path'])] = null;
return null;
case 'delete':
final k = _key(a['path']);
files.removeWhere((x, _) => x == k || x.startsWith('$k\\'));
return null;
case 'move':
final from = _key(a['path']), to = _key(a['to']);
if (files.containsKey(to) && a['overwrite'] != true) throw PlatformException(code: 'exists', message: to);
files[to] = files.remove(from);
return null;
case 'openRead':
final k = _key(a['path']);
if (!files.containsKey(k)) throw PlatformException(code: 'notFound', message: k);
final h = _next++;
_open[h] = (k, false);
return {'handle': h, 'size': files[k]!.length};
case 'readChunk':
final (k, _) = _open[a['handle']]!;
final data = files[k]!;
final off = a['offset'] as int;
if (off >= data.length) return Uint8List(0);
return Uint8List.fromList(data.sublist(off, (off + (a['length'] as int)).clamp(0, data.length)));
case 'openWrite':
final k = _key(a['path']);
files[k] = [];
final h = _next++;
_open[h] = (k, true);
return h;
case 'writeChunk':
final (k, _) = _open[a['handle']]!;
final off = a['offset'] as int;
final data = a['data'] as Uint8List;
final cur = files[k]!;
expect(off, cur.length, reason: '조각은 차례로');
files[k] = [...cur, ...data];
return null;
case 'closeHandle':
_open.remove(a['handle']);
return null;
case 'quota':
return [5 << 30, 1 << 30];
case 'close':
return null;
}
throw PlatformException(code: 'other', message: call.method);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _FakeSmb fake;
setUp(() {
fake = _FakeSmb();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SmbRemote.channel, fake.handle);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SmbRemote.channel, null);
SmbRegistry.configure(const []);
});
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'pw');
test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async {
SmbRegistry.configure([server]);
await vMkdirs('smb://m1/a/b');
expect(fake.files.keys, containsAll([r'a', r'a\b']));
final big = List<int>.generate((SmbRemote.chunk * 2.5).toInt(), (i) => i % 251);
await vWrite('smb://m1/a/b/big.bin', Stream.fromIterable([big.sublist(0, 1000), big.sublist(1000)]));
expect(fake.files[r'a\b\big.bin']!.length, big.length);
expect(fake.calls.where((c) => c == 'writeChunk').length, 3, reason: '1MB 조각');
expect(fake.files.keys.where((k) => k.endsWith('.jjpart')), isEmpty, reason: '.jjpart 로 쓴 뒤 이름 바꿈');
final back = await (await vOpenRead('smb://m1/a/b/big.bin')).expand((x) => x).toList();
expect(back, big);
final list = await vList('smb://m1/a/b');
expect(list.single.path, 'smb://m1/a/b/big.bin');
expect((list.single.isDir, list.single.size), (false, big.length));
await vWrite('smb://m1/a/x.txt', Stream.value(utf8.encode('x')));
await vRename('smb://m1/a/x.txt', 'smb://m1/a/y.txt');
expect(fake.files.containsKey(r'a\y.txt'), isTrue);
expect(await vStat('smb://m1/a/없음'), isNull);
await vDelete('smb://m1/a');
expect(fake.files, isEmpty);
expect(vDisplay('smb://m1/a/b'), '🗄 NAS/a/b');
expect(noStreamUrl('smb://m1/a.mkv'), isTrue, reason: '재생 · 다른 앱은 받아서');
final (free, used) = await DavPath.parse('smb://m1/').client.quota();
expect((free, used), (5 << 30, 1 << 30));
});
test('시작 폴더: 공유 안의 그 폴더가 탭의 맨 위', () async {
SmbRegistry.configure([server.copyWith(root: r'\movies\')]);
fake.files
..['movies'] = null
..[r'movies\m.mkv'] = [1, 2];
expect((await vList('smb://m1/')).map((e) => e.path), ['smb://m1/m.mkv']);
});
test('실패는 이유를 사람 말로 (SMB1 은 보안 문제로 연결하지 않음) · 저장된 비밀번호 없음은 묻지 않고 알림 · 암호화 여부', () async {
Future<String> err(SmbRemote r) async {
try {
await r.list('/');
return '';
} catch (e) {
return '$e';
}
}
for (final (code, want) in [
('auth', '아이디나 비밀번호가 맞지 않습니다'),
('unreachable', 'Tailscale'),
('noShare', '공유 폴더 "video"'),
('smb1', 'SMB1'),
('permission', '권한이 없습니다'),
]) {
fake.failWith = code;
expect(await err(SmbRemote(server)), contains(want), reason: code);
}
fake.failWith = null;
fake.calls.clear();
expect(await err(SmbRemote(server.copyWith(password: ''))), '저장된 비밀번호가 없습니다');
expect(fake.calls, isEmpty, reason: '비밀번호가 없으면 서버에 묻지 않는다');
// 게스트 (아이디 없음) 는 비밀번호 없이
expect(await err(SmbRemote(server.copyWith(user: '', password: ''))), '');
fake.encrypted = false;
expect((await SmbRemote(server).info()).encrypted, isFalse);
});
test('설정: 비밀번호는 안전 저장소 smb.pw.<id> 에만 · 다시 읽으면 채움', () async {
final tmp = Directory.systemTemp.createTempSync('jj_smb88_set_');
addTearDown(() => tmp.deleteSync(recursive: true));
final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
await store.save(AppSettings()..smbServers = [server]);
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('"pw"')));
expect(secrets.values['smb.pw.m1'], 'pw');
final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single;
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'pw'));
});
test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () {
expect(parseSmbAddress('smb://nas/video/a%20b/c'), ('nas', 'video', 'a b/c'));
expect(parseSmbAddress(r'\\nas\home'), ('nas', 'home', ''));
expect(parseSmbAddress('CIFS://x'), ('x', '', ''));
expect(parseSmbAddress('/storage/x'), isNull);
});
test('보안 검토 6: 서명 · 암호화가 안 된 연결은 탐색기 탭에 ⚠ · 설정 "필수" 는 다리에 넘기고 막힌 이유를 알림', () async {
expect(AppSettings().smbRequireSecure, isFalse, reason: '처음 끔 (오래된 NAS)');
expect(AppSettings.fromJson(const {'smbRequireSecure': true}).smbRequireSecure, isTrue);
expect(AppSettings.fromJson(const {'smbRequireSecure': 'x'}).smbRequireSecure, isFalse);
SmbRegistry.configure(const [server]);
expect(DavPath.parse('smb://m1/').mark, '🗄', reason: '연결 전에는 모름');
fake
..encrypted = false
..signed = false;
await SmbRegistry.client('m1').list('/');
expect(fake.calls, ['list', 'info'], reason: '처음 한 번만 물어 둔다');
expect(SmbRegistry.insecure('m1'), isTrue);
expect(DavPath.parse('smb://m1/').mark, '⚠');
expect(fake.lastArgs['requireSecure'], isFalse);
await SmbRegistry.client('m1').list('/');
expect(fake.calls, ['list', 'info', 'list']);
// 서명만 되어도 ⚠ 는 없다
SmbRegistry.configure(const []);
SmbRegistry.configure(const [server]);
fake.signed = true;
await SmbRegistry.client('m1').list('/');
expect(SmbRegistry.insecure('m1'), isFalse);
// 설정 "필수" 를 켜면 다리에 넘기고, 다리가 막으면 이유를 알린다
SmbRegistry.requireSecure = true;
addTearDown(() => SmbRegistry.requireSecure = false);
fake.failWith = 'insecure';
Object? e;
try {
await SmbRegistry.client('m1').list('/');
} catch (x) {
e = x;
}
expect(fake.lastArgs['requireSecure'], isTrue);
expect(e, isA<SmbException>().having((x) => x.kind, 'kind', SmbFailure.insecure).having((x) => x.message, 'm', contains('SMB 서명 · 암호화 필수')));
});
test('관리자 지시 (10/19): SMB 맞추기 (_mirrorV) 도 거르기를 따르고 미리 보기 = 실제 (가짜 채널)', () async {
SmbRegistry.configure(const [server]);
final tmp = Directory.systemTemp.createTempSync('jj_smbfilter_');
addTearDown(() => tmp.deleteSync(recursive: true));
void lf(String rel, String s) => File(p.join(tmp.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(s);
lf('a.txt', 'a');
lf('skip.tmp', 's');
lf('cache/c.bin', 'c');
fake.files.addAll({
'dst': null,
r'dst\only.tmp': utf8.encode('o'),
r'dst\old.txt': utf8.encode('old'),
r'dst\cache': null,
r'dst\cache\remote.bin': utf8.encode('r'),
r'dst\gone': null,
r'dst\gone\g.tmp': utf8.encode('g'),
r'dst\gone\g.txt': utf8.encode('g'),
});
final filter = RsyncFilter.parse('-av --exclude=*.tmp --exclude=cache/');
final pv = await previewSync(tmp.path, 'smb://m1/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'});
expect({for (final x in pv) if (x.action == PreviewAction.add) x.rel}, {'a.txt'});
await FileOps().mirror(tmp.path, 'smb://m1/dst', delete: true, filter: filter);
expect(fake.files.containsKey(r'dst\a.txt'), isTrue);
expect(fake.files.containsKey(r'dst\skip.tmp'), isFalse, reason: '거른 것은 보내지 않음');
expect(fake.files.containsKey(r'dst\cache\c.bin'), isFalse);
expect(fake.files.containsKey(r'dst\only.tmp'), isTrue, reason: '받는 쪽의 거른 것은 지킴');
expect(fake.files.containsKey(r'dst\cache\remote.bin'), isTrue);
expect(fake.files.containsKey(r'dst\old.txt'), isFalse);
expect((fake.files.containsKey(r'dst\gone\g.tmp'), fake.files.containsKey(r'dst\gone\g.txt')), (true, false));
});
test('관리자 지시 (10/20): SMB 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개, 가짜 채널)', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
SmbRegistry.configure(const [server]);
final tmp = Directory.systemTemp.createTempSync('jj_smbscene_');
addTearDown(() => tmp.deleteSync(recursive: true));
final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'), seed = p.join(tmp.path, 'seed');
// 가짜 공유의 scene 폴더 → 트리 (treeOf 와 같은 모양)
Map<String, String> fakeTree() => {
for (final e in fake.files.entries)
if (e.key.startsWith(r'scene\'))
e.key.substring(6).replaceAll(r'\', '/'): e.value == null ? '/' : utf8.decode(e.value!),
};
final bad = <String>[];
for (final opts in sceneOptions) {
writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts);
writeScene(src, seed);
fake.files
..clear()
..['scene'] = null;
for (final e in treeOf(seed).entries) {
fake.files['scene\\${e.key.replaceAll('/', r'\')}'] = e.value == '/' ? null : utf8.encode(e.value);
}
await FileOps().mirror(src, 'smb://m1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
final d = treeDiff(w, fakeTree());
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
}
expect(bad, isEmpty, reason: bad.join('\n'));
});
}