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'; /// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫) class _FakeSmb { /// 공유 안 경로 ("a\\b") → 내용 (null 이면 폴더) final files = ?>{}; final calls = []; String? failWith; bool encrypted = true; bool signed = false; Map lastArgs = const {}; final _open = {}; var _next = 1; String _key(Object? path) => '$path'.replaceAll(RegExp(r'^/+'), '').replaceAll('/', r'\'); Future 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.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 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. 에만 · 다시 읽으면 채움', () 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().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)); }); }