401 lines
20 KiB
Dart
401 lines
20 KiB
Dart
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<int> 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<void>.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<BuildContext> host(WidgetTester t) async {
|
|
await t.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox())));
|
|
return t.element(find.byType(SizedBox));
|
|
}
|
|
|
|
Future<void> work(WidgetTester t) async {
|
|
for (var i = 0; i < 30; i++) {
|
|
await t.runAsync(() => Future<void>.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<CheckboxListTile>(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('관리자 판단 (10/21): "앱 설정 전체" 는 이 판이 모르는 설정 키도 지우고, 다른 판의 비밀 값은 "저장된 비밀번호" 를 골랐을 때만', () async {
|
|
final tmp = Directory.systemTemp.createTempSync('jj_master_full_');
|
|
addTearDown(() => tmp.deleteSync(recursive: true));
|
|
final file = File(p.join(tmp.path, 'settings.json'))
|
|
..writeAsStringSync(jsonEncode({'explorerLayout': 'single', 'zz_new_version': {'keep': 1}}));
|
|
final secrets = MemorySecretStore()
|
|
..values['nfs.pw.n1'] = 'new-version-pw' // 다른 (새) 판이 쓴 열쇠
|
|
..values['sec.master'] = 'hash';
|
|
final store = SettingsStore(file.path, secrets);
|
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()),
|
|
settingsStore: store);
|
|
await c.init();
|
|
// 보통 저장: 모르는 키 · 다른 판의 열쇠 남음 (40)
|
|
await c.updateSettings((s) => s.explorerLayout = 'dual');
|
|
Map<String, dynamic> saved() => jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
|
expect(saved()['zz_new_version'], {'keep': 1});
|
|
expect(secrets.values['nfs.pw.n1'], 'new-version-pw');
|
|
// 앱 설정 전체: 모르는 키 없음 · 다른 판의 열쇠는 아직 남음 (비밀번호 체크를 안 함)
|
|
await resetAllSettings(c);
|
|
expect(saved().containsKey('zz_new_version'), isFalse);
|
|
expect(saved()['explorerLayout'], AppSettings().explorerLayout);
|
|
expect(secrets.values['nfs.pw.n1'], 'new-version-pw', reason: '[저장된 실제 비밀번호] 를 고르지 않으면 남김');
|
|
// 저장된 실제 비밀번호 · API 키: 다른 판의 열쇠도 지움 · 마스터 (sec.) 는 남김
|
|
await clearStoredSecrets(c);
|
|
expect(secrets.values.containsKey('nfs.pw.n1'), isFalse);
|
|
expect(secrets.values['sec.master'], 'hash');
|
|
});
|
|
|
|
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<NavigatorState>();
|
|
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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 20)));
|
|
await t.pump();
|
|
}
|
|
expect(lock.hasSuper, isFalse);
|
|
});
|
|
}
|