348 lines
17 KiB
Dart
348 lines
17 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';
|
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
|
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
|
|
import 'package:jj_mkvmaker/core/remote_stream.dart';
|
|
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
|
import 'package:jj_mkvmaker/services/platform_services.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'));
|
|
});
|
|
|
|
test('관리자 지시 (10/20): SMB 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async {
|
|
SmbRegistry.configure(const [server]);
|
|
// 위젯 시험 바인딩은 HttpClient 를 가짜 (늘 400) 로 바꾼다 - 이 시험은 이 기기 안의 다리 (127.0.0.1) 에 실제로 묻는다
|
|
final saved = HttpOverrides.current;
|
|
HttpOverrides.global = null;
|
|
addTearDown(() => HttpOverrides.global = saved);
|
|
fake.files.addAll({
|
|
'영상': null,
|
|
'영상\\1화.mkv': utf8.encode('0123456789'),
|
|
'영상\\2화.mkv': utf8.encode('abcdefghij'),
|
|
'영상\\2화.ko.srt': utf8.encode('1\n00:00:01,000 --> 00:00:02,000\n둘째\n'),
|
|
'영상\\메모.txt': utf8.encode('t'),
|
|
});
|
|
addTearDown(RemoteStreamBridge.close);
|
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
|
c.settings.playlistMode = PlaylistMode.folder;
|
|
final plan = await c.preparePlayback(['smb://m1/영상/1화.mkv']);
|
|
expect(plan!.$1, ['smb://m1/영상/1화.mkv', 'smb://m1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)');
|
|
|
|
final bridge = await RemoteStreamBridge.get();
|
|
final u1 = bridge.url('smb://m1/영상/1화.mkv'), u2 = bridge.url('smb://m1/영상/2화.mkv');
|
|
expect(u1, startsWith('http://127.0.0.1:'));
|
|
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
|
|
Future<(int, String)> get(String url, [String? range]) async {
|
|
final http = HttpClient();
|
|
try {
|
|
final req = await http.getUrl(Uri.parse(url));
|
|
if (range != null) req.headers.set('Range', range);
|
|
final res = await req.close();
|
|
return (res.statusCode, await res.transform(utf8.decoder).join());
|
|
} finally {
|
|
http.close(force: true);
|
|
}
|
|
}
|
|
|
|
expect(await get(u1), (200, '0123456789'));
|
|
expect(await get(u1, 'bytes=4-6'), (206, '456'), reason: '건너뛰기 - 그 자리부터');
|
|
expect(await get(u1, 'bytes=7-'), (206, '789'));
|
|
expect(await get(u2), (200, 'abcdefghij'), reason: '다음 화도 받지 않고 바로');
|
|
final subs = await c.externalSubtitlesFor('smb://m1/영상/2화.mkv');
|
|
expect(subs.map(p.basename), ['2화.ko.srt']);
|
|
expect(File(subs.single).readAsStringSync(), contains('둘째'));
|
|
});
|
|
}
|