diff --git a/apps/desktop/main/security/lock-manager.test.ts b/apps/desktop/main/security/lock-manager.test.ts new file mode 100644 index 00000000..94893284 --- /dev/null +++ b/apps/desktop/main/security/lock-manager.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { mock, test } from 'node:test' + +class FakeMainWindow { + readonly sentMessages: unknown[][] = [] + readonly webContents = { + send: (...args: unknown[]) => this.sentMessages.push(args), + } + + isDestroyed(): boolean { + return false + } +} + +function makeTempDir(): string { + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) + return fs.mkdtempSync(path.join(baseDir, 'chatlab-app-lock-')) +} + +test('desktop app lock persists its canonical config and handles manual, idle, and startup locking', async () => { + const root = makeTempDir() + const settingsDir = path.join(root, 'settings') + const mainWindow = new FakeMainWindow() + let systemIdleSeconds = 0 + let cleanupLockManager: (() => void) | undefined + + await mock.module('electron', { + namedExports: { + powerMonitor: { + getSystemIdleTime: () => systemIdleSeconds, + }, + }, + }) + await mock.module('../paths/provider', { + namedExports: { + getPathProvider: () => ({ getSettingsDir: () => settingsDir }), + }, + }) + await mock.module('../logger', { + namedExports: { + logger: { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, + }, + }, + }) + + try { + fs.mkdirSync(settingsDir, { recursive: true }) + fs.writeFileSync( + path.join(settingsDir, 'app-lock.json'), + JSON.stringify({ enabled: true, idleTimeoutMinutes: 0, lockOnStartup: false }), + 'utf-8' + ) + fs.writeFileSync(path.join(settingsDir, '.app-lock-flag'), 'stale', 'utf-8') + + const lockManager = await import('./lock-manager.js') + cleanupLockManager = lockManager.cleanupLockManager + lockManager.initLockManager(mainWindow as never) + + assert.deepEqual(lockManager.getLockConfig(), { + enabled: false, + idleTimeoutMinutes: 0, + lockOnStartup: false, + }) + assert.equal(fs.existsSync(path.join(settingsDir, '.app-lock-flag')), false) + + assert.deepEqual(await lockManager.setPassword('123'), { success: false, error: 'invalid-pin' }) + assert.deepEqual(await lockManager.setPassword('1234'), { success: true }) + assert.deepEqual(lockManager.getLockConfig(), { + enabled: true, + idleTimeoutMinutes: 0, + lockOnStartup: false, + }) + + const persisted = JSON.parse(fs.readFileSync(path.join(settingsDir, 'app-lock.json'), 'utf-8')) as { + passwordHash: { hash: string; salt: string; version: number } + } + assert.deepEqual(Object.keys(persisted).sort(), ['idleTimeoutMinutes', 'lockOnStartup', 'passwordHash']) + assert.equal(persisted.passwordHash.version, 1) + assert.equal(persisted.passwordHash.hash.length, 128) + assert.equal(persisted.passwordHash.salt.length, 64) + + assert.deepEqual(lockManager.updateLockConfig({ enabled: true }), { + success: false, + error: 'invalid-config', + }) + assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 5 }).success, true) + assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 0 }).success, true) + + assert.deepEqual(lockManager.lockApp(), { success: true }) + assert.equal(lockManager.getLockState(), 'locked') + assert.deepEqual(await lockManager.unlockApp('0000'), { + success: false, + error: 'wrong-password', + wrongPassword: true, + }) + assert.deepEqual(await lockManager.unlockApp('1234'), { success: true }) + assert.deepEqual(mainWindow.sentMessages.slice(-2), [ + ['app-lock-state-changed', true], + ['app-lock-state-changed', false], + ]) + + assert.deepEqual(await lockManager.changePassword('1234', '1234'), { + success: false, + error: 'same-password', + }) + assert.deepEqual(await lockManager.changePassword('1234', '4321'), { success: true }) + + systemIdleSeconds = 60 + assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 1 }).success, true) + assert.equal(lockManager.getLockState(), 'locked') + systemIdleSeconds = 0 + assert.deepEqual(await lockManager.unlockApp('4321'), { success: true }) + + assert.equal(lockManager.updateLockConfig({ lockOnStartup: true }).success, true) + lockManager.cleanupLockManager() + lockManager.initLockManager(mainWindow as never) + assert.equal(lockManager.getLockState(), 'locked') + assert.deepEqual(await lockManager.unlockApp('4321'), { success: true }) + + assert.deepEqual(lockManager.resetAppLockPassword(), { success: true }) + assert.equal(lockManager.getLockConfig().enabled, false) + assert.deepEqual(lockManager.lockApp(), { success: false, error: 'disabled' }) + } finally { + cleanupLockManager?.() + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/main/security/lock-manager.ts b/apps/desktop/main/security/lock-manager.ts index 471c3a18..41669780 100644 --- a/apps/desktop/main/security/lock-manager.ts +++ b/apps/desktop/main/security/lock-manager.ts @@ -65,15 +65,25 @@ function loadConfig(): boolean { } const record = data as Record + const allowedKeys = new Set(['idleTimeoutMinutes', 'lockOnStartup', 'passwordHash']) + if (Object.keys(record).some((key) => !allowedKeys.has(key))) { + logger.warn('App lock config ignored because it contains unsupported fields') + return false + } + if (!isValidIdleTimeout(record.idleTimeoutMinutes) || typeof record.lockOnStartup !== 'boolean') { + logger.warn('App lock config ignored because its settings are invalid') + return false + } + if (record.passwordHash !== undefined && !isPasswordHash(record.passwordHash)) { + logger.warn('App lock config ignored because its password format is invalid') + return false + } currentSettings = { - idleTimeoutMinutes: isValidIdleTimeout(record.idleTimeoutMinutes) - ? record.idleTimeoutMinutes - : DEFAULT_LOCK_SETTINGS.idleTimeoutMinutes, - lockOnStartup: - typeof record.lockOnStartup === 'boolean' ? record.lockOnStartup : DEFAULT_LOCK_SETTINGS.lockOnStartup, + idleTimeoutMinutes: record.idleTimeoutMinutes, + lockOnStartup: record.lockOnStartup, } - storedPasswordHash = isPasswordHash(record.passwordHash) ? record.passwordHash : null + storedPasswordHash = record.passwordHash ?? null return true } catch (error) { logger.error(`App lock config ignored because it is invalid: ${error instanceof Error ? error.message : error}`) @@ -83,15 +93,22 @@ function loadConfig(): boolean { function saveConfig(settings: LockSettings, passwordHash: PasswordHash | null): boolean { const configPath = getLockConfigPath() + const tempPath = `${configPath}.tmp` try { fs.mkdirSync(getSettingsDir(), { recursive: true }) const record: Record = { ...settings } if (passwordHash) record.passwordHash = passwordHash - fs.writeFileSync(configPath, JSON.stringify(record, null, 2), 'utf-8') + fs.writeFileSync(tempPath, JSON.stringify(record, null, 2), 'utf-8') + fs.renameSync(tempPath, configPath) return true } catch (error) { logger.error(`Failed to save app lock config: ${error instanceof Error ? error.message : error}`) + try { + fs.unlinkSync(tempPath) + } catch { + // 临时文件可能尚未创建。 + } return false } }