import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/master_lock.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/sftp.dart'; import 'package:jj_mkvmaker/core/webdav.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'; import 'package:jj_mkvmaker/services/image_ai.dart' show AiService; import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:jj_mkvmaker/ui/master_prompt.dart'; import 'package:jj_mkvmaker/ui/settings_page.dart'; import 'package:path/path.dart' as p; String hex(List b) => b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); void main() { group('124: 해시', () { test('PBKDF2-HMAC-SHA256 이 알려진 값과 같다 (RFC 6070 방식 시험 값)', () { expect(hex(pbkdf2Sha256(utf8.encode('password'), utf8.encode('salt'), 1, 32)), '120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b'); expect(hex(pbkdf2Sha256(utf8.encode('password'), utf8.encode('salt'), 2, 32)), 'ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43'); expect(hex(pbkdf2Sha256(utf8.encode('password'), utf8.encode('salt'), 4096, 32)), 'c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a'); // 64 바이트보다 긴 열쇠 · 32 바이트보다 긴 결과 expect(hex(pbkdf2Sha256(utf8.encode('passwordPASSWORDpassword'), utf8.encode('saltSALTsaltSALTsaltSALTsaltSALTsalt'), 4096, 40)), '348c89dbcbd32b2f32d814b8116e84cf2b17347ebc1800181c4e2a1fb8dd53e1c635518c7dac47e9'); // 64 바이트보다 긴 비밀번호 (열쇠를 먼저 해시) · 딱 64 바이트 - 값은 Python hashlib.pbkdf2_hmac 으로 구함 expect(hex(pbkdf2Sha256(utf8.encode('긴비밀번호' * 10), utf8.encode('saltSALT'), 1000, 32)), '1b2d0e02ae0a189fe6958ca9b786239b9b03c65c9c46276a0c1a673f06a6df7c'); expect(hex(pbkdf2Sha256(utf8.encode('x' * 64), utf8.encode('salt'), 3, 32)), '0582d52c2133ec90a168409527adb1a18782d00151ca2939679a72dedca074b5'); }); test('원문은 남지 않고, 솔트가 달라 같은 비밀번호도 해시가 다르다 · 맞는 것만 통과', () { final a = PasswordHash.make('내 비밀번호', iterations: 1000); final b = PasswordHash.make('내 비밀번호', iterations: 1000); expect(a, isNot(contains('내 비밀번호'))); expect(a, startsWith('pbkdf2-sha256\$1000\$')); expect(a, isNot(b)); expect(PasswordHash.verify('내 비밀번호', a), isTrue); expect(PasswordHash.verify('내 비밀번호 ', a), isFalse); expect(PasswordHash.verify('x', 'garbage'), isFalse); }); }); group('124: 마스터 · 최상', () { late MemorySecretStore secrets; late DateTime clock; MasterLock make() => MasterLock(secrets, now: () => clock); setUp(() { secrets = MemorySecretStore(); clock = DateTime(2026, 10, 9, 12); }); test('정하기 · 안전 저장소에 해시만 · 둘이 같으면 못 정함 · 다시 읽기', () async { final l = make(); expect(await l.setMaster('m-pass'), isTrue); expect(await l.setSuper('m-pass'), isFalse, reason: '마스터와 같음'); expect(await l.setSuper('s-pass'), isTrue); expect(await l.setMaster('s-pass'), isFalse, reason: '최상과 같음'); expect(secrets.values[MasterLock.masterKey], startsWith('pbkdf2-sha256\$')); expect(secrets.values.values.any((v) => v.contains('m-pass') || v.contains('s-pass')), isFalse); final again = make(); await again.load(); expect((again.hasMaster, again.hasSuper, again.unlocked), (true, true, false)); expect(await again.check('m-pass'), MasterInput.master); expect(again.unlocked, isTrue); final third = make(); await third.load(); expect(await third.check('s-pass'), MasterInput.superPassword); expect(third.unlocked, isFalse, reason: '최상은 초기화 창으로 (풀지는 않음)'); }); test('묻는 때: 물어보지 않기 · 처음 시작 시 · 쓸 때 / 풀면 앱을 끌 때까지 묻지 않음', () async { final l = make(); expect(l.needsPrompt(), isFalse, reason: '마스터가 없으면 묻지 않음'); await l.setMaster('m'); final x = make(); await x.load(); expect(x.ask, 'onUse', reason: '처음 정하면 쓸 때 묻기'); await x.setAsk('never'); expect((x.needsPrompt(startup: true), x.needsPrompt()), (false, false)); await x.setAsk('startup'); expect((x.needsPrompt(startup: true), x.needsPrompt()), (true, true)); await x.setAsk('onUse'); expect((x.needsPrompt(startup: true), x.needsPrompt()), (false, true)); await x.check('m'); expect(x.needsPrompt(), isFalse); }); test('129: 묻는 때는 안전 저장소에 - 값이 없거나 틀리면 쓸 때 묻기 (설정 파일을 지우거나 고쳐도 자물쇠가 꺼지지 않음)', () async { final l = make(); await l.setMaster('m'); await l.setAsk('never'); expect(secrets.values[MasterLock.askKey], 'never'); // 사용자가 고른 '물어보지 않기' 는 그대로 final a = make(); await a.load(); expect(a.ask, 'never'); // 값이 사라지거나 (예전 판 보관본 · 앱 설정 전체 초기화) 망가지면 쓸 때 묻기 secrets.values.remove(MasterLock.askKey); final b = make(); await b.load(); expect(b.ask, 'onUse'); secrets.values[MasterLock.askKey] = 'garbage'; final c = make(); await c.load(); expect(c.ask, 'onUse'); // 마스터를 끄면 묻지 않음 · 값도 지움 await c.clearMaster(); expect((c.ask, secrets.values.containsKey(MasterLock.askKey)), ('never', false)); }); test('128: 한 번 취소하면 자동 · 배경 호출은 다시 묻지 않고, 사용자가 직접 연 곳 (force) 만 다시 묻는다', () async { final l = make(); await l.setMaster('m'); final x = make(); await x.load(); var shown = 0; var answer = false; MasterLock.prompt = (_) async { shown++; if (answer) await x.check('m'); return answer; }; addTearDown(() => MasterLock.prompt = null); expect(await x.ensure(), isFalse); // 배경: 처음엔 묻고 취소 expect(shown, 1); for (var i = 0; i < 5; i++) { expect(await x.ensure(), isFalse); // 30초마다 와도 다시 띄우지 않음 } expect(shown, 1); answer = true; expect(await x.ensure(force: true), isTrue); // 사용자가 직접 누름 expect(shown, 2); expect(await x.ensure(), isTrue); expect(shown, 2, reason: '풀린 뒤로는 묻지 않음'); }); test('여러 번 틀리면 기다리게 하고, 껐다 켜도 남는다 · 맞히면 풀림', () async { final l = make(); await l.setMaster('m'); final x = make(); await x.load(); for (var i = 0; i < 4; i++) { expect(await x.check('bad'), MasterInput.wrong); } expect(x.waiting, isNull); expect(await x.check('bad'), MasterInput.wrong); expect(x.waiting, const Duration(seconds: 30)); expect(await x.check('m'), MasterInput.wait, reason: '기다리는 동안은 맞아도 안 받음'); final restarted = make(); await restarted.load(); expect(restarted.waiting, isNotNull); clock = clock.add(const Duration(seconds: 31)); expect(await restarted.check('m'), MasterInput.master); expect(restarted.fails, 0); expect(MasterLock.waitFor(6), const Duration(minutes: 1)); expect(MasterLock.waitFor(40), const Duration(minutes: 15)); }); test('쓸 때 묻기: 여러 곳에서 동시에 불러도 창은 하나 · 취소하면 막힘', () async { final l = make(); await l.setMaster('m'); final x = make(); await x.load(); var shown = 0; MasterLock.prompt = (_) async { shown++; await Future.delayed(const Duration(milliseconds: 10)); return false; }; addTearDown(() => MasterLock.prompt = null); final r = await Future.wait([x.ensure(), x.ensure(), x.ensure()]); expect(r, [false, false, false]); expect(shown, 1); }); }); group('124: 창', () { AppController controller() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..webdavServers = [const DavServer(id: 'n', name: 'NAS', url: 'https://nas', user: 'u', password: 'pw')] ..openSubtitlesKey = 'k' ..openSubtitlesUser = 'ou' ..openSubtitlesPassword = 'op'; return c; } Future host(WidgetTester t) async { await t.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); return t.element(find.byType(SizedBox)); } Future work(WidgetTester t) async { for (var i = 0; i < 30; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await t.pump(); } } testWidgets('마스터 창에 최상을 넣으면 초기화 창 - 처음 체크는 마스터만, 한 번 더 확인 뒤 하고 알림', (t) async { final c = controller(); final lock = MasterLock(MemorySecretStore()); await t.runAsync(() async { await lock.setMaster('m'); await lock.setSuper('s'); }); lock.unlocked = false; final ctx = await host(t); bool? result; askMaster(ctx, c, lock).then((r) => result = r); await t.pumpAndSettle(); await t.enterText(find.byType(TextField), 's'); await t.tap(find.text('확인')); await work(t); expect(find.text('최상 비밀번호 - 초기화'), findsOneWidget); final boxes = t.widgetList(find.byType(CheckboxListTile)).map((x) => x.value).toList(); expect(boxes, [true, false, false]); await t.tap(find.widgetWithText(FilledButton, '초기화')); await t.pumpAndSettle(); expect(find.text('정말 초기화할까요?'), findsOneWidget); await t.tap(find.widgetWithText(FilledButton, '초기화')); await work(t); expect(find.text('초기화했습니다'), findsOneWidget); expect(find.textContaining('마스터 비밀번호를 초기화했습니다'), findsOneWidget); await t.tap(find.text('닫기')); await work(t); expect(result, isTrue); expect(lock.hasMaster, isFalse); expect(c.settings.webdavServers.single.password, 'pw', reason: '마스터만 골랐으니 비밀번호는 그대로'); }); testWidgets('잊었습니다: 저장된 비밀번호 · API 키를 지우고 마스터 초기화 (서버 목록 · 아이디는 남김)', (t) async { final c = controller(); final lock = MasterLock(MemorySecretStore()); await t.runAsync(() => lock.setMaster('m')); lock.unlocked = false; final ctx = await host(t); askMaster(ctx, c, lock); await t.pumpAndSettle(); await t.tap(find.text('비밀번호를 잊었습니다')); await t.pumpAndSettle(); await t.tap(find.widgetWithText(FilledButton, '지우고 초기화')); await work(t); expect(find.text('초기화했습니다'), findsOneWidget); expect(c.settings.webdavServers.single.password, ''); expect(c.settings.webdavServers.single.user, 'u'); expect((c.settings.openSubtitlesKey, c.settings.openSubtitlesPassword, c.settings.openSubtitlesUser), ('', '', 'ou')); expect(lock.hasMaster, isFalse); }); testWidgets('틀리면 다시 묻고 몇 번째인지 보여 줌', (t) async { final c = controller(); final lock = MasterLock(MemorySecretStore()); await t.runAsync(() => lock.setMaster('m')); lock.unlocked = false; final ctx = await host(t); askMaster(ctx, c, lock); await t.pumpAndSettle(); await t.enterText(find.byType(TextField), 'bad'); await t.tap(find.text('확인')); await work(t); expect(find.text('비밀번호가 틀렸습니다 (1번)'), findsOneWidget); expect(lock.unlocked, isFalse); }); test('"저장된 비밀번호 · API 키" 지우기: 안전 저장소의 os. · dav. · sftp. · ai. 값이 모두 없어진다 (AI 키가 남던 것 - 관리자 10/17)', () async { final tmp = Directory.systemTemp.createTempSync('jj_master_clear_'); addTearDown(() => tmp.deleteSync(recursive: true)); final secrets = MemorySecretStore(); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()), settingsStore: store); await c.updateSettings((s) => s ..openSubtitlesKey = 'k' ..openSubtitlesPassword = 'op' ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'PRIVATE KEY', keyPassphrase: 'kp')] ..aiServices = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]); await store.save(c.settings); bool secret(String k) => k.startsWith('os.') || k.startsWith('dav.pw.') || k.startsWith('sftp.') || k.startsWith('ai.'); expect(secrets.values.keys.where(secret).length, 7, reason: '넣은 비밀 값이 저장소에 있다 (os 2 · dav 1 · sftp 3 · ai 1)'); await clearStoredSecrets(c); await store.save(c.settings); expect(secrets.values.keys.where(secret), isEmpty); // 목록 · 아이디 · 믿은 서버 키는 남김 expect((c.settings.webdavServers.single.user, c.settings.sftpServers.single.user, c.settings.aiServices.single.url), ('u', 'u', 'https://ai')); }); test('최상으로 "앱 설정 전체" 를 지우면 처음 상태', () async { final c = controller()..settings.explorerLayout = 'single'; await resetAllSettings(c); expect(c.settings.explorerLayout, AppSettings().explorerLayout); expect(c.settings.webdavServers, isEmpty); }); }); testWidgets('124: 환경 설정은 마스터를 넣기 전에는 잠김 · 넣으면 보안 묶음까지 보임', (t) async { t.view.physicalSize = const Size(1400, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); // 설정 화면의 rsync 확인이 앱 폴더를 묻는다 (시험 환경에는 path_provider 가 없음) final tmp = Directory.systemTemp.createTempSync('jj_master_pp_'); addTearDown(() => tmp.deleteSync(recursive: true)); const pp = MethodChannel('plugins.flutter.io/path_provider'); t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path); addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null)); final lock = MasterLock(MemorySecretStore()); await t.runAsync(() async { await lock.setMaster('m'); await lock.setSuper('s'); }); lock.unlocked = false; MasterLock.instance = lock; addTearDown(() => MasterLock.instance = null); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); // 앱처럼: 묻는 창은 화면에 (setupMasterLock 이 하는 것) final nav = GlobalKey(); MasterLock.prompt = (reason) => askMaster(nav.currentContext!, c, lock, reason: reason); addTearDown(() => MasterLock.prompt = null); await t.pumpWidget(MaterialApp(navigatorKey: nav, home: SettingsPage(c: c))); await t.pumpAndSettle(); expect(find.text('마스터 비밀번호'), findsOneWidget, reason: '열자마자 묻는다'); await t.tap(find.text('취소')); await t.pumpAndSettle(); expect(find.text('마스터 비밀번호로 잠겨 있습니다'), findsOneWidget); expect(find.text('바뀌면 바로 저장됩니다'), findsNothing); await t.tap(find.text('마스터 비밀번호 넣기')); await t.pumpAndSettle(); await t.enterText(find.byType(TextField), 'm'); await t.tap(find.text('확인')); for (var i = 0; i < 30; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await t.pump(); } await t.pumpAndSettle(); expect(find.text('마스터 비밀번호로 잠겨 있습니다'), findsNothing); expect(find.text('최상 비밀번호'), findsOneWidget); expect(find.text('마스터 비밀번호를 묻는 때'), findsOneWidget); // 132: 넓은 화면에서도 최상의 [끄기] 가 보이고 (잘리지 않음), 지금 비밀번호 · 확인을 거쳐 끈다 final offs = find.widgetWithText(OutlinedButton, '끄기'); expect(offs, findsNWidgets(2), reason: '마스터 · 최상 모두'); await t.ensureVisible(offs.last); await t.tap(offs.last); await t.pumpAndSettle(); await t.enterText(find.byType(TextField).last, 'm'); await t.tap(find.text('확인')); for (var i = 0; i < 30; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await t.pump(); } await t.pumpAndSettle(); expect(find.text('최상 비밀번호를 끌까요?'), findsOneWidget); await t.tap(find.widgetWithText(FilledButton, '끄기')); for (var i = 0; i < 10; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await t.pump(); } expect(lock.hasSuper, isFalse); }); }