From 624fad67e6c0931c10f50860300fbbcaba85b6d5 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:49:50 +0200 Subject: [PATCH 01/12] fix(storage): unify desktop key access and use atomic writes --- components/ApiKeySection.tsx | 13 ++++----- services/fs/assetFsStore.ts | 8 +++--- services/fs/codexFsStore.ts | 12 ++++++-- services/fs/fsCore.ts | 37 +++++++++++++++++++++++++ services/fs/projectFsStore.ts | 12 ++++++-- services/fs/settingsFsStore.ts | 6 ++-- services/fs/snapshotFsStore.ts | 10 +++++-- src-tauri/capabilities/default.json | 10 ++++++- tests/unit/ApiKeySection.test.tsx | 25 +++++++---------- tests/unit/fileSystemService.test.ts | 1 + tests/unit/services/fs/fsStores.test.ts | 15 +++++++++- 11 files changed, 109 insertions(+), 40 deletions(-) diff --git a/components/ApiKeySection.tsx b/components/ApiKeySection.tsx index 704c2de68..6f2f649cb 100644 --- a/components/ApiKeySection.tsx +++ b/components/ApiKeySection.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from '../hooks/useTranslation'; -import { dbService } from '../services/dbService'; import { generateText, invalidateAiClientCache } from '../services/geminiService'; import { logger } from '../services/logger'; +import { storageService } from '../services/storageService'; import { Button } from './ui/Button'; import { Input } from './ui/Input'; import { Spinner } from './ui/Spinner'; @@ -34,14 +34,11 @@ export const ApiKeySection: FC = () => { const checkKeyStatus = useCallback(async () => { setIsLoading(true); try { - const exists = await dbService.hasGeminiApiKey(); + const exists = Boolean(await storageService.getGeminiApiKey()); setHasKey(exists); // Check if key exists but decryption failed (device change, cleared site data) if (!exists) { - const raw = await dbService.getGeminiApiKey(); - if (raw === 'DECRYPT_FAILED') { - setDecryptFailed(true); - } + setDecryptFailed(false); } } catch (error) { logger.error('Failed to check API key status:', error); @@ -68,7 +65,7 @@ export const ApiKeySection: FC = () => { setMessage(null); try { // QNBS-v3: this only checks syntax and persists — handleTestConnection (auto-triggered below) is what actually confirms the key authenticates; "Active" here means "saved", not "verified working." - await dbService.saveGeminiApiKey(normalizedKey); + await storageService.saveGeminiApiKey(normalizedKey); invalidateAiClientCache(); setApiKey(''); setHasKey(true); @@ -95,7 +92,7 @@ export const ApiKeySection: FC = () => { setMessage(null); setTestResult(null); try { - await dbService.clearGeminiApiKey(); + await storageService.clearGeminiApiKey(); invalidateAiClientCache(); setHasKey(false); setMessage({ type: 'success', text: t('settings.apiKey.removed') }); diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts index 32ce52ca7..55e5833a3 100644 --- a/services/fs/assetFsStore.ts +++ b/services/fs/assetFsStore.ts @@ -6,7 +6,7 @@ import { logger } from '../logger'; import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend'; -import { retryFs, sanitizePathSegment } from './fsCore'; +import { retryFs, sanitizePathSegment, writeFileAtomic, writeTextFileAtomic } from './fsCore'; import { FsSnapshotStore } from './snapshotFsStore'; export class FsAssetStore extends FsSnapshotStore { @@ -23,7 +23,7 @@ export class FsAssetStore extends FsSnapshotStore { const imageFile = await apis.join(imagesPath, `${sanitizePathSegment(id, 'image')}.png`); const cleanBase64 = base64Data.replace(/^data:image\/png;base64,/, ''); - await retryFs(() => apis.writeTextFile(imageFile, cleanBase64)); + await writeTextFileAtomic(apis, imageFile, cleanBase64); } async getImage(id: string): Promise { @@ -88,8 +88,8 @@ export class FsAssetStore extends FsSnapshotStore { const { dir, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId); if (!(await apis.exists(dir))) await apis.mkdir(dir, { recursive: true }); const metaOut: BinderAssetMeta = { ...meta, byteSize: data.byteLength }; - await retryFs(() => apis.writeFile(binFile, new Uint8Array(data))); - await retryFs(() => apis.writeTextFile(metaFile, JSON.stringify(metaOut))); + await writeFileAtomic(apis, binFile, new Uint8Array(data)); + await writeTextFileAtomic(apis, metaFile, JSON.stringify(metaOut)); } async getBinderAsset(projectId: string, assetId: string): Promise { diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts index 2176506a1..b1e6d3736 100644 --- a/services/fs/codexFsStore.ts +++ b/services/fs/codexFsStore.ts @@ -6,7 +6,13 @@ import type { StoryCodex } from '../../types'; import { logger } from '../logger'; -import { compressData, decompressData, retryFs, sanitizePathSegment } from './fsCore'; +import { + compressData, + decompressData, + retryFs, + sanitizePathSegment, + writeTextFileAtomic, +} from './fsCore'; import { FsSettingsStore } from './settingsFsStore'; export class FsCodexStore extends FsSettingsStore { @@ -19,7 +25,7 @@ export class FsCodexStore extends FsSettingsStore { const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex'); if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true }); const codexFile = await apis.join(codexDir, 'codex.snap'); - await retryFs(() => apis.writeTextFile(codexFile, compressData(codex))); + await writeTextFileAtomic(apis, codexFile, compressData(codex)); } async getStoryCodex(projectId: string): Promise { @@ -58,7 +64,7 @@ export class FsCodexStore extends FsSettingsStore { const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex'); if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true }); const vectorsFile = await apis.join(codexDir, 'vectors.snap'); - await retryFs(() => apis.writeTextFile(vectorsFile, compressData(vectors))); + await writeTextFileAtomic(apis, vectorsFile, compressData(vectors)); } async getRagVectors(projectId: string): Promise { diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 9f09f0128..48ef5f5d0 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -16,6 +16,7 @@ export type TauriApis = { exists: (path: string) => Promise; readDir: (path: string) => Promise<{ name?: string; isDirectory?: boolean }[]>; remove: (path: string, opts?: { recursive?: boolean }) => Promise; + rename: (oldPath: string, newPath: string) => Promise; open: (opts?: Record) => Promise; save: (opts?: Record) => Promise; appDataDir: () => Promise; @@ -44,6 +45,7 @@ export async function loadTauriApis(): Promise { exists: fsModule.exists, readDir: fsModule.readDir as TauriApis['readDir'], remove: fsModule.remove, + rename: fsModule.rename, open: dialogModule.open as TauriApis['open'], save: dialogModule.save as TauriApis['save'], appDataDir: pathModule.appDataDir, @@ -78,6 +80,41 @@ export async function retryFs(fn: () => Promise, retries = 2, delayMs = 50 throw lastError; } +function temporaryPath(path: string): string { + const suffix = + typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + return `${path}.tmp-${suffix}`; +} + +async function writeAndReplace( + apis: TauriApis, + path: string, + write: (temporary: string) => Promise, +): Promise { + const temporary = temporaryPath(path); + try { + await retryFs(() => write(temporary)); + await retryFs(() => apis.rename(temporary, path)); + } catch (error) { + await apis.remove(temporary).catch(() => undefined); + throw error; + } +} + +// QNBS-v3: replace authoritative files only after a complete sibling write, preserving the last valid file on interruption. +export function writeTextFileAtomic(apis: TauriApis, path: string, content: string): Promise { + return writeAndReplace(apis, path, (temporary) => apis.writeTextFile(temporary, content)); +} + +// QNBS-v3: binary assets use the same same-directory replace so readers never observe a partial file. +export function writeFileAtomic(apis: TauriApis, path: string, data: Uint8Array): Promise { + return writeAndReplace(apis, path, (temporary) => apis.writeFile(temporary, data)); +} + // --- LZ-String compression (mirrors dbService threshold and prefix) --- const COMPRESS_THRESHOLD = 10_240; diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index cfcb5a086..0f59d5380 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -10,7 +10,13 @@ import { logger } from '../logger'; import { parseImportedProjectJson } from '../projectImportSchema'; import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend'; import { FsAssetStore } from './assetFsStore'; -import { compressData, decompressData, retryFs, sanitizePathSegment } from './fsCore'; +import { + compressData, + decompressData, + retryFs, + sanitizePathSegment, + writeTextFileAtomic, +} from './fsCore'; export class FsProjectStore extends FsAssetStore { async saveProject(project: SaveProjectInput): Promise { @@ -36,7 +42,7 @@ export class FsProjectStore extends FsAssetStore { } const projectFile = await apis.join(projectPath, 'project.json'); - await retryFs(() => apis.writeTextFile(projectFile, compressData(flat))); + await writeTextFileAtomic(apis, projectFile, compressData(flat)); // QNBS-v3 (#332): documented best-effort abort — the project data above already saved; a failed marker write only degrades the next cold-boot's project selection, not worth failing this save over. await this.setActiveProjectId(projectId).catch((error) => { logger.warn('Failed to persist active-project marker (project save itself succeeded)', { @@ -54,7 +60,7 @@ export class FsProjectStore extends FsAssetStore { await apis.mkdir(configPath, { recursive: true }); } const markerFile = await apis.join(configPath, 'active-project-id.txt'); - await retryFs(() => apis.writeTextFile(markerFile, projectId)); + await writeTextFileAtomic(apis, markerFile, projectId); } /** diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index 09be76e44..4a610d508 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -10,7 +10,7 @@ import type { Settings } from '../../types'; import { logger } from '../logger'; import { normalizePersistedSettings } from '../storage/idbProjectStore'; import type { TauriApis } from './fsCore'; -import { decryptText, encryptText, FsCore, retryFs } from './fsCore'; +import { decryptText, encryptText, FsCore, retryFs, writeTextFileAtomic } from './fsCore'; export class FsSettingsStore extends FsCore { async saveSettings(settings: Settings): Promise { @@ -23,7 +23,7 @@ export class FsSettingsStore extends FsCore { } const settingsFile = await apis.join(configPath, 'settings.json'); - await retryFs(() => apis.writeTextFile(settingsFile, JSON.stringify(settings, null, 2))); + await writeTextFileAtomic(apis, settingsFile, JSON.stringify(settings, null, 2)); } async loadSettings(): Promise { @@ -77,7 +77,7 @@ export class FsSettingsStore extends FsCore { `${appDataPath}|${provider}|WorldScriptStudio|v1`, ); const filePath = await apis.join(configPath, `${provider}_key.enc.json`); - await retryFs(() => apis.writeTextFile(filePath, JSON.stringify(encrypted))); + await writeTextFileAtomic(apis, filePath, JSON.stringify(encrypted)); } async getApiKey(provider: string): Promise { diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts index 9574fcac6..c689fc5cb 100644 --- a/services/fs/snapshotFsStore.ts +++ b/services/fs/snapshotFsStore.ts @@ -6,7 +6,13 @@ import type { ProjectSnapshot } from '../../types'; import { logger } from '../logger'; import { FsCodexStore } from './codexFsStore'; -import { compressData, countProjectWords, decompressData, retryFs } from './fsCore'; +import { + compressData, + countProjectWords, + decompressData, + retryFs, + writeTextFileAtomic, +} from './fsCore'; // Envelope stored in each snapshot file — outer shell is plain JSON, `data` field is compressed. interface SnapshotEnvelope { @@ -36,7 +42,7 @@ export class FsSnapshotStore extends FsCodexStore { data: compressData(data), }; const snapshotFile = await apis.join(snapshotsPath, `${id}.json`); - await retryFs(() => apis.writeTextFile(snapshotFile, JSON.stringify(envelope))); + await writeTextFileAtomic(apis, snapshotFile, JSON.stringify(envelope)); return id; } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0f7032d4b..2aae7f50a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -55,6 +55,14 @@ } ] }, + { + "identifier": "fs:allow-rename", + "allow": [ + { + "path": "$APPDATA/**" + } + ] + }, "dialog:default", "dialog:allow-open", "dialog:allow-save", @@ -84,4 +92,4 @@ "core:window:allow-close", "core:window:allow-destroy" ] -} \ No newline at end of file +} diff --git a/tests/unit/ApiKeySection.test.tsx b/tests/unit/ApiKeySection.test.tsx index 74b346137..934f2f102 100644 --- a/tests/unit/ApiKeySection.test.tsx +++ b/tests/unit/ApiKeySection.test.tsx @@ -7,14 +7,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- const { - mockHasGeminiApiKey, mockGetGeminiApiKey, mockSaveGeminiApiKey, mockClearGeminiApiKey, mockGenerateText, mockInvalidateAiClientCache, } = vi.hoisted(() => ({ - mockHasGeminiApiKey: vi.fn(), mockGetGeminiApiKey: vi.fn(), mockSaveGeminiApiKey: vi.fn(), mockClearGeminiApiKey: vi.fn(), @@ -22,9 +20,8 @@ const { mockInvalidateAiClientCache: vi.fn(), })); -vi.mock('../../services/dbService', () => ({ - dbService: { - hasGeminiApiKey: mockHasGeminiApiKey, +vi.mock('../../services/storageService', () => ({ + storageService: { getGeminiApiKey: mockGetGeminiApiKey, saveGeminiApiKey: mockSaveGeminiApiKey, clearGeminiApiKey: mockClearGeminiApiKey, @@ -65,7 +62,6 @@ import ApiKeySection, { isSyntacticallySafeGeminiApiKey } from '../../components describe('ApiKeySection', () => { beforeEach(() => { vi.clearAllMocks(); - mockHasGeminiApiKey.mockResolvedValue(false); mockGetGeminiApiKey.mockResolvedValue(null); mockSaveGeminiApiKey.mockResolvedValue(undefined); mockClearGeminiApiKey.mockResolvedValue(undefined); @@ -83,19 +79,18 @@ describe('ApiKeySection', () => { }); it('shows configured status when key exists', async () => { - mockHasGeminiApiKey.mockResolvedValue(true); + mockGetGeminiApiKey.mockResolvedValue('configured-key'); render(); await waitFor(() => { expect(screen.getByText('settings.apiKey.statusActive')).toBeTruthy(); }); }); - it('shows decryptFailed warning when getGeminiApiKey returns DECRYPT_FAILED', async () => { - mockHasGeminiApiKey.mockResolvedValue(false); - mockGetGeminiApiKey.mockResolvedValue('DECRYPT_FAILED'); + it('keeps the key inactive when the shared storage path cannot read a key', async () => { + mockGetGeminiApiKey.mockResolvedValue(null); render(); await waitFor(() => { - expect(screen.getByText('apiKey.decryptFailed')).toBeTruthy(); + expect(screen.getByText('settings.apiKey.statusInactive')).toBeTruthy(); }); }); @@ -159,7 +154,7 @@ describe('ApiKeySection', () => { }); }); - it('shows save error when dbService throws', async () => { + it('shows save error when shared storage throws', async () => { mockSaveGeminiApiKey.mockRejectedValue(new Error('DB error')); render(); await waitFor(() => screen.getByText('settings.apiKey.statusInactive')); @@ -173,7 +168,7 @@ describe('ApiKeySection', () => { }); it('removes key and shows removed message', async () => { - mockHasGeminiApiKey.mockResolvedValue(true); + mockGetGeminiApiKey.mockResolvedValue('configured-key'); render(); await waitFor(() => screen.getByText('settings.apiKey.statusActive')); @@ -189,7 +184,7 @@ describe('ApiKeySection', () => { }); it('shows test connection result on success', async () => { - mockHasGeminiApiKey.mockResolvedValue(true); + mockGetGeminiApiKey.mockResolvedValue('configured-key'); mockGenerateText.mockResolvedValue('OK'); render(); await waitFor(() => screen.getByText('apiKey.test')); @@ -201,7 +196,7 @@ describe('ApiKeySection', () => { }); it('shows invalid key message when test returns INVALID_API_KEY error', async () => { - mockHasGeminiApiKey.mockResolvedValue(true); + mockGetGeminiApiKey.mockResolvedValue('configured-key'); mockGenerateText.mockRejectedValue(new Error('INVALID_API_KEY error')); render(); await waitFor(() => screen.getByText('apiKey.test')); diff --git a/tests/unit/fileSystemService.test.ts b/tests/unit/fileSystemService.test.ts index e38c6b96e..d5a3f089e 100644 --- a/tests/unit/fileSystemService.test.ts +++ b/tests/unit/fileSystemService.test.ts @@ -17,6 +17,7 @@ vi.mock('@tauri-apps/plugin-fs', () => ({ exists: vi.fn().mockRejectedValue(new Error('Tauri not available')), readDir: vi.fn().mockRejectedValue(new Error('Tauri not available')), remove: vi.fn().mockRejectedValue(new Error('Tauri not available')), + rename: vi.fn().mockRejectedValue(new Error('Tauri not available')), })); vi.mock('@tauri-apps/plugin-dialog', () => ({ diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index 596511f15..a33481ea6 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -27,6 +27,7 @@ vi.mock('@tauri-apps/plugin-fs', () => ({ exists: (p: string) => fsHolder.current.exists(p), readDir: (p: string) => fsHolder.current.readDir(p), remove: (p: string, opts?: { recursive?: boolean }) => fsHolder.current.remove(p, opts), + rename: (from: string, to: string) => fsHolder.current.rename(from, to), })); vi.mock('@tauri-apps/plugin-dialog', () => ({ open: (opts?: Record) => fsHolder.current.open(opts), @@ -95,6 +96,18 @@ function makeFakeFs(): FakeFs { for (const k of [...bin.keys()]) if (k.startsWith(`${p}/`)) bin.delete(k); return Promise.resolve(); }, + rename: (from: string, to: string) => { + if (!text.has(from) && !bin.has(from)) return Promise.reject(new Error(`ENOENT ${from}`)); + text.delete(to); + bin.delete(to); + const textValue = text.get(from); + const binaryValue = bin.get(from); + if (textValue !== undefined) text.set(to, textValue); + if (binaryValue !== undefined) bin.set(to, binaryValue); + text.delete(from); + bin.delete(from); + return Promise.resolve(); + }, readDir: (p: string) => Promise.resolve(under(p).map((name) => ({ name, isDirectory: false }))), open: () => Promise.resolve(null), save: () => Promise.resolve(null), @@ -159,7 +172,7 @@ describe('FsProjectStore — projects', () => { it('still resolves saveProject and logs a warning when the active-project marker write rejects', async () => { const originalWriteTextFile = fake.apis.writeTextFile; fake.apis.writeTextFile = (p: string, c: string) => { - if (p.endsWith('active-project-id.txt')) return Promise.reject(new Error('disk full')); + if (p.includes('active-project-id.txt')) return Promise.reject(new Error('disk full')); return originalWriteTextFile(p, c); }; From 509278660dbe8e5c9b7d54499ea64b7fc5eb11bf Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:56:41 +0200 Subject: [PATCH 02/12] fix(security): fail closed desktop key storage and recovery reset --- README.md | 6 +-- .../settings/EncryptionRecoveryModal.tsx | 39 +++++++++++++++++-- components/settings/IdbUnlockModal.tsx | 20 ++++++++++ docs/SECURITY-THREAT-MODEL.md | 11 +++--- services/factoryResetService.ts | 17 ++++++++ services/fs/settingsFsStore.ts | 31 +++++---------- services/storageService.ts | 19 ++++----- tests/unit/factoryResetService.test.ts | 1 + tests/unit/services/fs/fsStores.test.ts | 37 +++++++----------- .../settings/EncryptionRecoveryModal.test.tsx | 4 +- 10 files changed, 114 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 66401b016..937868341 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ The current primary project, settings, snapshot, image, Codex, RAG, and binder-a - **AES-256-GCM** with a PBKDF2-derived key (600 000 iterations, SHA-256, 32-byte random salt). - Gated behind `featureFlags.enableIdbAtRestEncryption`. When a library is configured but locked, protected reads and writes fail closed rather than falling back to plaintext. - Disable and passphrase rotation are temporarily unavailable until a journaled, cross-store migration protocol can prove recovery after interruption. -- **Web/PWA build only.** The unlock screen (`IdbUnlockModal`) and session-scoped in-memory key protect the IndexedDB-backed storage path used by the browser/PWA build. On the **Tauri desktop build**, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data are written by the filesystem-backed store (`services/fs/*`), which is plaintext (LZ-string compressed, not encrypted) regardless of this setting — enabling it on desktop still shows the same unlock screen (the passphrase sentinel lives in the WebView's IndexedDB) but does not encrypt the actual manuscript files on disk. No `tauri-plugin-stronghold` or equivalent OS-keychain integration ships today — see the API-key encryption note below for the desktop-specific mechanism that does exist. +- **Web/PWA and desktop API-key paths are separate from project-file protection.** The unlock screen (`IdbUnlockModal`) and session-scoped in-memory key protect the IndexedDB-backed storage path. On the **Tauri desktop build**, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data are written by the filesystem-backed store (`services/fs/*`), which is plaintext (LZ-string compressed, not encrypted) regardless of this setting. API keys are kept in the WebView's IndexedDB random-key store; the desktop filesystem never receives API-key ciphertext or derived key material. - At-rest protection reduces disclosure from an extracted browser profile while the library is locked; it does not protect an unlocked renderer, a compromised device, or every persistence surface. ### 🔐 Encrypted Library Backup @@ -333,7 +333,7 @@ different data, with different key material: |------|-----------|-------| | **Browser BYOK API key** | Random, non-extractable AES-256-GCM key generated via `crypto.subtle.generateKey()` — no passphrase, nothing to derive | `services/storage/idbKeyStore.ts` | | **Browser IDB-at-rest data** _(opt-in, B-1)_ | User passphrase → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, non-extractable key | `services/storage/storageEncryptionService.ts` | -| **Desktop (Tauri) BYOK API key** | Install-scoped secret material → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, non-extractable key | `services/fs/fsCore.ts`, `services/fs/settingsFsStore.ts` | +| **Desktop (Tauri) BYOK API key** | Same random, non-extractable AES-256-GCM key store as the browser; no filesystem-derived secret material | `services/storage/idbKeyStore.ts`, `services/storageService.ts` | | **Library backup vault** | User passphrase → PBKDF2 (600 000 iterations, SHA-256) → AES-256-GCM | `services/libraryBackupService.ts` | See [`docs/SECURITY-THREAT-MODEL.md`](docs/SECURITY-THREAT-MODEL.md) for the full threat-model mapping. @@ -573,7 +573,7 @@ WorldScript Studio supports local-only AI (no API key) as well as BYOK cloud pro 1. **Get your key** — e.g. at [Google AI Studio](https://aistudio.google.com/app/apikey) (free tier available) 2. **Open Settings** → AI Provider → select your provider -3. **Enter your API key** — encrypted with AES-256-GCM (web build: stored in your browser's IndexedDB; desktop build: stored on disk, see the encryption breakdown below); never transmitted except to the provider you select +3. **Enter your API key** — encrypted with AES-256-GCM in the local random-key store (browser and desktop WebView); never transmitted except to the provider you select **Security best practices:** - ✅ Your key never leaves your device in plaintext diff --git a/components/settings/EncryptionRecoveryModal.tsx b/components/settings/EncryptionRecoveryModal.tsx index 5b9d94c73..25fd086cf 100644 --- a/components/settings/EncryptionRecoveryModal.tsx +++ b/components/settings/EncryptionRecoveryModal.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from '../../hooks/useTranslation'; +import { wipeAllAppData } from '../../services/factoryResetService'; import { logger } from '../../services/logger'; import type { EncryptionMigrationJournal } from '../../services/storage/encryptionMigrationJournal'; import type { ProtectedStoreMigrationProgress } from '../../services/storage/protectedStoreMigration'; @@ -87,6 +88,12 @@ export const EncryptionRecoveryModal: FC = ({ journal, onRecovered }) => const canSubmit = !busy && sourcePassphrase.length > 0 && (!needsTargetPassphrase || targetPassphrase.length > 0); + const handleFactoryReset = useCallback(async () => { + if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return; + setBusy(true); + await wipeAllAppData(); + }, [t]); + return ( = ({ journal, onRecovered }) =>

{stuck ? ( -

- {t('settings.privacy.encryptionRecoveryStuck')} -

+
+

+ {t('settings.privacy.encryptionRecoveryStuck')} +

+

+ {t('settings.data.dangerZone.factoryReset.modalDescription')} +

+ +
) : ( <>
@@ -196,6 +216,19 @@ export const EncryptionRecoveryModal: FC = ({ journal, onRecovered }) => {t('settings.privacy.encryptionRecoveryResumeButton')}
+
+

+ {t('settings.data.dangerZone.factoryReset.modalDescription')} +

+ +
)} diff --git a/components/settings/IdbUnlockModal.tsx b/components/settings/IdbUnlockModal.tsx index 1397fce62..a1baad375 100644 --- a/components/settings/IdbUnlockModal.tsx +++ b/components/settings/IdbUnlockModal.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from '../../hooks/useTranslation'; +import { wipeAllAppData } from '../../services/factoryResetService'; import { verifyAndInitIdbEncryption } from '../../services/storage/storageEncryptionService'; import { Button } from '../ui/Button'; import { Modal } from '../ui/Modal'; @@ -163,6 +164,12 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => { [handleUnlock], ); + const handleFactoryReset = useCallback(async () => { + if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return; + setBusy(true); + await wipeAllAppData(); + }, [t]); + const errorId = 'idb-unlock-error'; const hasError = error.length > 0; @@ -223,6 +230,19 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => { : t('settings.privacy.encryptionUnlockButton')} +
+

+ {t('settings.data.dangerZone.factoryReset.modalDescription')} +

+ +
); diff --git a/docs/SECURITY-THREAT-MODEL.md b/docs/SECURITY-THREAT-MODEL.md index 96e8b8292..db918bb08 100644 --- a/docs/SECURITY-THREAT-MODEL.md +++ b/docs/SECURITY-THREAT-MODEL.md @@ -39,7 +39,7 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m | Threat | Mitigation | Code Location | |--------|------------|-------------| | API key leakage via logs | StructuredLogger sanitization; never log keys | `services/logger.ts:sanitizeLogContext()` | -| Desktop API key exposure via local file-read access | AES-256-GCM with a PBKDF2-derived key (600 000 iterations, SHA-256, random 32-byte salt per encryption) — fixed 2026-07-29; the prior scheme derived the key from a single unsalted SHA-256 digest of publicly-derivable material (own file's parent path + provider name from the filename + a hardcoded literal), so anyone with read access to `config/_key.enc.json` could reconstruct the key in one hash operation (F-05/F-06). No migration path for pre-fix files by design — a legacy (unsalted) payload is discarded and the user is prompted to re-enter the key. | `services/fs/fsCore.ts:deriveFileSystemCryptoKey()`, `services/fs/settingsFsStore.ts:getApiKey()` | +| Desktop API key exposure via local filesystem read | API keys are not written to the Tauri AppData filesystem. `storageService` uses the IndexedDB key store with a random non-extractable AES-GCM key; legacy filesystem key files are discarded and re-entry is required. | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` | | Manuscript data in IndexedDB | AES-256-GCM at-rest encryption | `services/storage/storageEncryptionService.ts` | | Voice audio to cloud | Web Speech API consent gate | `components/voice/VoicePrivacyConsentModal.tsx` | | DuckDB analytics unencrypted (SEC-6) | **Bounded by design, with one prose column now encrypted:** most persisted fields are local metadata only (titles, loglines, character names, word counts, embeddings) and **nothing leaves the device**. The one column that genuinely holds literal manuscript prose, `codex_mentions.excerpt`, is now cell-level encrypted (AES-256-GCM via `services/duckdb/duckdbEncryption.ts`, reusing the IDB at-rest encryption key) whenever `enableIdbAtRestEncryption` is active: `duckdbCodexWrite()` writes ciphertext into `excerpt_enc BLOB` and nulls the plaintext `excerpt` column; `services/duckdb/codexExcerptEncryptionMigration.ts` backfills any pre-existing plaintext rows once encryption is unlocked. Gated by `enableDuckDbAnalytics` **and** the Settings → Privacy "Analytics" opt-out (`isAnalyticsPersistenceAllowed` in `app/listenerMiddleware.ts`); turning the toggle off stops all DuckDB writes + inference telemetry. Full OPFS file-level encryption remains **infeasible** — DuckDB-WASM owns the OPFS file handle directly, so there is no app-level interception point; the other metadata columns stay intentionally plaintext (bounded-exposure design). | `app/listenerMiddleware.ts:isAnalyticsPersistenceAllowed`, `services/duckdb/duckdbAnalytics.ts:duckdbCodexWrite()`, `services/duckdb/duckdbEncryption.ts`, `services/duckdb/codexExcerptEncryptionMigration.ts` | @@ -123,10 +123,9 @@ Goal: Intercept/decrypt collaboration traffic ``` Goal: Recover a user's cloud-provider API key from the Tauri desktop install -├─ OR: Read config/_key.enc.json directly (local process / malware with user-level FS access) -│ └─ Mitigation: AES-256-GCM with PBKDF2-derived key (600k iter, random 32-byte salt per file) — -│ reading the ciphertext no longer reveals the key material; the pre-2026-07-29 scheme derived -│ the key from data an attacker with file-read access already had (F-05/F-06, fixed) +├─ OR: Read the Tauri AppData filesystem directly (local process / malware with user-level FS access) +│ └─ Mitigation: API keys are not stored there; the filesystem adapter rejects key writes and +│ deletes legacy derived-key files. Runtime key access uses the WebView IDB key store. ├─ OR: Read the IDB-at-rest passphrase sentinel (enableIdbAtRestEncryption) │ └─ Mitigation: same PBKDF2 + non-extractable-key pattern; session-scoped in-memory key, never │ persisted to disk (`services/storage/storageEncryptionService.ts`) @@ -259,4 +258,4 @@ platform dashboard, not in application code. - NIST SP 800-63B Digital Identity Guidelines - CWE-200: Exposure of Sensitive Information - CWE-79: Cross-site Scripting (XSS) -- CWE-89: SQL Injection (N/A - no SQL backend) \ No newline at end of file +- CWE-89: SQL Injection (N/A - no SQL backend) diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts index 992dc8586..62811774e 100644 --- a/services/factoryResetService.ts +++ b/services/factoryResetService.ts @@ -9,6 +9,7 @@ */ import { logger } from './logger'; +import { isTauriRuntime } from './tauriRuntime'; /** All IDB databases the app may have created. */ const KNOWN_DB_NAMES = [ @@ -56,6 +57,21 @@ async function clearServiceWorkerCaches(): Promise { } } +async function clearTauriAppData(): Promise { + if (!isTauriRuntime()) return; + try { + const { loadTauriApis } = await import('./fs/fsCore'); + const apis = await loadTauriApis(); + const appDataPath = await apis.appDataDir(); + if (await apis.exists(appDataPath)) { + await apis.remove(appDataPath, { recursive: true }); + } + } catch (error) { + logger.error('Failed to clear Tauri app data during factory reset:', error); + throw new Error('Factory reset could not clear desktop data'); + } +} + /** * Wipe all app data and reload. * Clears: IDB, localStorage, sessionStorage, SW caches. @@ -65,6 +81,7 @@ export async function wipeAllAppData(): Promise { logger.warn('[factoryReset] Wiping all app data…'); await deleteAllIndexedDBDatabases(); await clearServiceWorkerCaches(); + await clearTauriAppData(); try { localStorage.clear(); sessionStorage.clear(); diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index 4a610d508..0405b82f5 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -1,6 +1,6 @@ /** - * FsSettingsStore — Settings persistence and AES-256-GCM API key encryption on the filesystem. - * ENCRYPTION: AES-256-GCM — API keys stored as `_key.enc.json` in config/. + * FsSettingsStore — Settings persistence on the filesystem. + * API-key persistence is deliberately disabled here; storageService routes keys to the random-key IDB store. * QNBS-v3: Extracted from fileSystemService.ts. */ @@ -10,7 +10,7 @@ import type { Settings } from '../../types'; import { logger } from '../logger'; import { normalizePersistedSettings } from '../storage/idbProjectStore'; import type { TauriApis } from './fsCore'; -import { decryptText, encryptText, FsCore, retryFs, writeTextFileAtomic } from './fsCore'; +import { FsCore, retryFs, writeTextFileAtomic } from './fsCore'; export class FsSettingsStore extends FsCore { async saveSettings(settings: Settings): Promise { @@ -61,23 +61,11 @@ export class FsSettingsStore extends FsCore { return this.clearApiKey('gemini'); } - // Generic provider API key — stored encrypted in app data dir - async saveApiKey(provider: string, apiKey: string): Promise { - if (!apiKey?.trim()) { - throw new Error('API key cannot be empty'); - } - - const apis = await this.getApis(); - const appDataPath = await this.ensureAppDataPath(); - const configPath = await apis.join(appDataPath, 'config'); - if (!(await apis.exists(configPath))) await apis.mkdir(configPath, { recursive: true }); - - const encrypted = await encryptText( - apiKey.trim(), - `${appDataPath}|${provider}|WorldScriptStudio|v1`, + // QNBS-v3: filesystem key persistence is disabled because app-path-derived material is recoverable. + async saveApiKey(provider: string, _apiKey: string): Promise { + throw new Error( + `Desktop filesystem API-key storage is disabled for ${provider}; use storageService instead`, ); - const filePath = await apis.join(configPath, `${provider}_key.enc.json`); - await writeTextFileAtomic(apis, filePath, JSON.stringify(encrypted)); } async getApiKey(provider: string): Promise { @@ -93,9 +81,8 @@ export class FsSettingsStore extends FsCore { const keyFile = await apis.join(appDataPath, 'config', `${provider}_key.enc.json`); keyFileForCleanup = keyFile; if (!(await apis.exists(keyFile))) return null; - const content = await retryFs(() => apis.readTextFile(keyFile)); - const payload = JSON.parse(content) as { iv: string; salt?: string; data: string }; - return await decryptText(payload, `${appDataPath}|${provider}|WorldScriptStudio|v1`); + await retryFs(() => apis.remove(keyFile)); + return null; } catch (error) { // QNBS-v3: pre-2026-07-29 key files used an unsalted single-SHA-256 derivation (F-05/F-06, // fixed in fsCore.ts) and are not migrated (locked decision — discard, not migrate). Remove diff --git a/services/storageService.ts b/services/storageService.ts index 3f16b297c..59f89dedc 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -114,33 +114,28 @@ class StorageManager { } async saveGeminiApiKey(apiKey: string): Promise { - const backend = await this.getBackend(); - return backend.saveGeminiApiKey(apiKey); + // QNBS-v3: API keys stay in the random-key IndexedDB store; filesystem-derived material is not a secret. + return dbService.saveGeminiApiKey(apiKey); } async getGeminiApiKey(): Promise { - const backend = await this.getBackend(); - return backend.getGeminiApiKey(); + return dbService.getGeminiApiKey(); } async clearGeminiApiKey(): Promise { - const backend = await this.getBackend(); - return backend.clearGeminiApiKey(); + return dbService.clearGeminiApiKey(); } async saveApiKey(provider: string, apiKey: string): Promise { - const backend = await this.getBackend(); - return backend.saveApiKey(provider, apiKey); + return dbService.saveApiKey(provider, apiKey); } async getApiKey(provider: string): Promise { - const backend = await this.getBackend(); - return backend.getApiKey(provider); + return dbService.getApiKey(provider); } async clearApiKey(provider: string): Promise { - const backend = await this.getBackend(); - return backend.clearApiKey(provider); + return dbService.clearApiKey(provider); } async saveSnapshot(name: string, data: unknown): Promise { diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts index 38d1a9ff0..237f93327 100644 --- a/tests/unit/factoryResetService.test.ts +++ b/tests/unit/factoryResetService.test.ts @@ -10,6 +10,7 @@ import { logger } from '../../services/logger'; vi.mock('../../services/logger', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, })); +vi.mock('../../services/tauriRuntime', () => ({ isTauriRuntime: vi.fn(() => false) })); function createDb(name: string): Promise { return new Promise((resolve, reject) => { diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index a33481ea6..0bf53c7cd 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -197,31 +197,27 @@ describe('FsSettingsStore — settings + encrypted API keys', () => { expect(await store.loadSettings()).toBeNull(); }); - it('encrypts and decrypts an API key round-trip', async () => { - await store.saveApiKey('openai', 'sk-secret-123'); - expect(await store.getApiKey('openai')).toBe('sk-secret-123'); - await store.clearApiKey('openai'); - expect(await store.getApiKey('openai')).toBeNull(); + it('rejects filesystem API-key persistence', async () => { + await expect(store.saveApiKey('openai', 'sk-secret-123')).rejects.toThrow(/disabled/); }); - it('delegates the Gemini key helpers to provider storage', async () => { - await store.saveGeminiApiKey('gem-key'); - expect(await store.getGeminiApiKey()).toBe('gem-key'); + it('does not read legacy filesystem API-key files', async () => { + const keyFile = '/app/config/openai_key.enc.json'; + fake.text.set(keyFile, JSON.stringify({ iv: 'legacy', data: 'legacy' })); + expect(await store.getApiKey('openai')).toBeNull(); + expect(fake.text.has(keyFile)).toBe(false); }); - it('rejects an empty API key', async () => { - await expect(store.saveApiKey('openai', ' ')).rejects.toThrow(/empty/); + it('rejects filesystem API-key persistence even for an empty key', async () => { + await expect(store.saveApiKey('openai', ' ')).rejects.toThrow(/disabled/); }); - it('returns null when decrypting a missing key', async () => { + it('returns null when no filesystem key exists', async () => { expect(await store.getApiKey('anthropic')).toBeNull(); }); - // QNBS-v3 (F-05/F-06 fix, 2026-07-29): a pre-2026-07-29 key file (unsalted single-SHA-256 - // scheme) is discarded, not migrated (locked decision) — this asserts the discard path returns - // null without throwing, removes the stale file, and surfaces a one-time notification rather - // than failing silently. - it('discards a legacy unsalted key file, removes it, and notifies instead of throwing', async () => { + // QNBS-v3: legacy filesystem key files are discarded because their derivation was recoverable. + it('discards a legacy unsalted key file without notifying or throwing', async () => { const dispatch = vi.fn(); appStoreRef.current = { getState: vi.fn(), dispatch } as never; try { @@ -235,14 +231,7 @@ describe('FsSettingsStore — settings + encrypted API keys', () => { expect(result).toBeNull(); expect(fake.text.has(legacyFile)).toBe(false); - expect(dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - type: 'info', - title: expect.stringContaining('API Key Reset'), - }), - }), - ); + expect(dispatch).not.toHaveBeenCalled(); } finally { appStoreRef.current = null; } diff --git a/tests/unit/settings/EncryptionRecoveryModal.test.tsx b/tests/unit/settings/EncryptionRecoveryModal.test.tsx index 3ee62e965..bc4121a89 100644 --- a/tests/unit/settings/EncryptionRecoveryModal.test.tsx +++ b/tests/unit/settings/EncryptionRecoveryModal.test.tsx @@ -171,7 +171,9 @@ describe('EncryptionRecoveryModal', () => { expect( screen.queryByLabelText('settings.privacy.encryptionPassphrase'), ).not.toBeInTheDocument(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }), + ).toBeInTheDocument(); }); it('disables the resume button until the required fields are filled', async () => { From fa9bf3aaae30798071b7f16a6834d20152e5353c Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:58:23 +0200 Subject: [PATCH 03/12] ci: gate merges on Rust, E2E, and visual checks --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++-- docs/CI.md | 10 +++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8275b21d1..6d6983496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,32 @@ jobs: # flaky, non-gating check. Mutation now runs ONLY via the manual `.github/workflows/ # mutation.yml` (workflow_dispatch). To be re-integrated in a later iteration. # ---------------------------------------------------------- + rust-tauri: + name: 🦀 Tauri Rust Gate + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [security] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + components: rustfmt, clippy + - name: Rust format check + working-directory: src-tauri + run: cargo fmt --check + - name: Rust compile check + working-directory: src-tauri + run: cargo check --locked + - name: Rust clippy + working-directory: src-tauri + run: cargo clippy --locked --all-targets -- -D warnings + - name: Rust tests + working-directory: src-tauri + run: cargo test --locked + build: name: 🏗️ Build runs-on: ubuntu-latest @@ -263,18 +289,24 @@ jobs: name: ✅ CI Success runs-on: ubuntu-latest timeout-minutes: 5 - needs: [security, quality, build] + needs: [security, quality, rust-tauri, build, e2e, vrt] if: always() steps: - name: Verify all required jobs succeeded run: | if [ "${{ needs.security.result }}" != "success" ] || \ [ "${{ needs.quality.result }}" != "success" ] || \ - [ "${{ needs.build.result }}" != "success" ]; then + [ "${{ needs.rust-tauri.result }}" != "success" ] || \ + [ "${{ needs.build.result }}" != "success" ] || \ + [ "${{ needs.e2e.result }}" != "success" ] || \ + [ "${{ needs.vrt.result }}" != "success" ]; then echo "One or more required jobs did not succeed:" echo " security: ${{ needs.security.result }}" echo " quality: ${{ needs.quality.result }}" + echo " rust-tauri: ${{ needs.rust-tauri.result }}" echo " build: ${{ needs.build.result }}" + echo " e2e: ${{ needs.e2e.result }}" + echo " vrt: ${{ needs.vrt.result }}" exit 1 fi echo "All required jobs succeeded." diff --git a/docs/CI.md b/docs/CI.md index 37e39de18..3296232e4 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -77,7 +77,10 @@ security ──► quality ──┬──► build ──┬──► lighthous security ─┬ quality ──┼──► ci-success (required-status aggregator) -build ────┘ +rust ────┤ +build ────┤ +e2e ──────┤ +vrt ──────┘ build (main, non-PR) ──► upload-pages-artifact deploy (main, non-PR) needs: build + e2e ──► GitHub Pages @@ -89,12 +92,13 @@ Mutation testing (Stryker) is **not** in this graph — it runs only via manual |-----|--------|---------| | `security` | — | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` | | `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov (optional token), coverage artifact | +| `rust-tauri` | `security` | Rust `cargo fmt --check`, `cargo check --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked`; compile/lint signal for Tauri changes without building installers on every PR | | `build` | `quality` | Production `pnpm run build`, **`bundle:budget`**, **`analyze`** (upload `bundle-analysis.html`), **`pnpm run smoke:prod`** (headless-Chromium prod-build + CSP-runtime gate — see below), `dist` artifact; on `main` (non-PR): Pages artifact + **SLSA build provenance attestation**. No `if:` on the job itself — `smoke:prod` runs on every PR, not just `main` pushes. | | `e2e` | `quality` | Playwright **Chromium** + **Mobile Chrome** (Pixel 5) — `CI=true`, 2× retries, 50 min timeout; browser cache via `actions/cache@v5`. Firefox optional locally. `PLAYWRIGHT_SKIP_VRT=true` (VRT is its own job). | | `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. | | `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` — see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | | `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium); artifacts uploaded always | -| `ci-success` | `security`, `quality`, `build` | Required-status **aggregator** — `if: always()`, fails if any of its three `needs` didn't resolve to `success` (a matrix job like `quality` only reports `success` once every Node 22/24 leg passes). Exists so branch protection can require **one** context instead of enumerating `security`/`quality (Node 22)`/`quality (Node 24)`/`build` by name; a future required job just joins this job's `needs` list, with no branch-protection settings edit needed. | +| `ci-success` | `security`, `quality`, `rust-tauri`, `build`, `e2e`, `vrt` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook, Lighthouse and deep-E2E remain informational until their stability criteria are met. | | `deploy` | `build`, `e2e` | **Only** `main` push (not PR): `deploy-pages` | > **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above. @@ -195,7 +199,7 @@ once a manual run demonstrates the flakiness is resolved — concretely, three c | **Dependabot** | Weekly (Monday) | PRs for npm deps (dev-tooling grouped) + GitHub Actions SHA bumps (max 5 open PRs) | | **`dependency-review-action`** | PRs only (security job) | Blocks PRs that introduce new high/critical vulnerabilities | | **pnpm v11 build-script policy** | Dependency installation | `pnpm-workspace.yaml` uses the sole supported, default-deny `allowBuilds` map; legacy build-script lists are intentionally absent | -| **Branch protection** | Always | `main` requires 1 approved review, required status checks (security, quality ×2, build), no force-push | +| **Branch protection** | Always | `main` requires 1 approved review, required status checks including the `CI Success` aggregator, no force-push | Only the two reviewed native packages marked `true` in `allowBuilds` (`@swc/core` and `esbuild`) may run dependency lifecycle scripts. `@google/genai`, `core-js`, `onnxruntime-node`, `protobufjs`, From f7c068e2cbc1765ccc66795950434e8ac6f741d7 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:12:20 +0200 Subject: [PATCH 04/12] ci(tauri): install native dependencies and satisfy rustfmt gate --- .github/workflows/ci.yml | 4 + src-tauri/build.rs | 2 +- src-tauri/src/commands/task_supervisor.rs | 4 +- src-tauri/src/lib.rs | 288 +++++++++++----------- src-tauri/src/main.rs | 2 +- 5 files changed, 157 insertions(+), 143 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d6983496..961d61651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,10 @@ jobs: with: toolchain: stable components: rustfmt, clippy + - name: Install Linux Tauri build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libappindicator3-dev librsvg2-dev patchelf - name: Rust format check working-directory: src-tauri run: cargo fmt --check diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7c8..d860e1e6a 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/src-tauri/src/commands/task_supervisor.rs b/src-tauri/src/commands/task_supervisor.rs index 9dfb2b797..467e5196e 100644 --- a/src-tauri/src/commands/task_supervisor.rs +++ b/src-tauri/src/commands/task_supervisor.rs @@ -81,7 +81,9 @@ pub fn worldscript_task_supervisor_ping() -> Result { /// tasks; reserves `Err` for transport-level problems (none currently). This keeps /// the router's fallback logic driven by `result.success`, not by a thrown error. #[tauri::command] -pub fn worldscript_task_supervisor_submit(request: RustTaskRequest) -> Result { +pub fn worldscript_task_supervisor_submit( + request: RustTaskRequest, +) -> Result { let started = Instant::now(); let task_id = request.task_id.clone(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4683af34..df57bb26b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,20 +6,20 @@ use tauri::Emitter; #[cfg(desktop)] fn build_file_menu( - handle: &tauri::AppHandle, + handle: &tauri::AppHandle, ) -> tauri::Result> { - use tauri::menu::{MenuItem, PredefinedMenuItem, Submenu}; - Submenu::with_items( - handle, - "File", - true, - &[ - &MenuItem::with_id(handle, "menu-export", "Export Project", true, None::<&str>)?, - &MenuItem::with_id(handle, "menu-settings", "Settings", true, None::<&str>)?, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::quit(handle, None)?, - ], - ) + use tauri::menu::{MenuItem, PredefinedMenuItem, Submenu}; + Submenu::with_items( + handle, + "File", + true, + &[ + &MenuItem::with_id(handle, "menu-export", "Export Project", true, None::<&str>)?, + &MenuItem::with_id(handle, "menu-settings", "Settings", true, None::<&str>)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::quit(handle, None)?, + ], + ) } // QNBS-v3 (D4): standard Edit menu via predefined items — the OS routes these to the focused @@ -27,165 +27,173 @@ fn build_file_menu( // event emit is needed. Fills the desktop-affordance gap flagged as C-7 in DESKTOP-UI-AUDIT.md. #[cfg(desktop)] fn build_edit_menu( - handle: &tauri::AppHandle, + handle: &tauri::AppHandle, ) -> tauri::Result> { - use tauri::menu::{PredefinedMenuItem, Submenu}; - Submenu::with_items( - handle, - "Edit", - true, - &[ - &PredefinedMenuItem::undo(handle, None)?, - &PredefinedMenuItem::redo(handle, None)?, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::cut(handle, None)?, - &PredefinedMenuItem::copy(handle, None)?, - &PredefinedMenuItem::paste(handle, None)?, - &PredefinedMenuItem::select_all(handle, None)?, - ], - ) + use tauri::menu::{PredefinedMenuItem, Submenu}; + Submenu::with_items( + handle, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(handle, None)?, + &PredefinedMenuItem::redo(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::cut(handle, None)?, + &PredefinedMenuItem::copy(handle, None)?, + &PredefinedMenuItem::paste(handle, None)?, + &PredefinedMenuItem::select_all(handle, None)?, + ], + ) } // QNBS-v3 (D4): View menu — custom "Command Palette" emits "menu-action" → the frontend maps it // to the `global-open-command-palette` command (services/tauriMenuService.ts + App.tsx). #[cfg(desktop)] fn build_view_menu( - handle: &tauri::AppHandle, + handle: &tauri::AppHandle, ) -> tauri::Result> { - use tauri::menu::{MenuItem, Submenu}; - Submenu::with_items( - handle, - "View", - true, - // QNBS-v3: no native accelerator on this item. CmdOrCtrl+K is already a *toggle* in the web - // shortcut layer; binding it here to the open-only menu command would shadow that toggle on - // desktop (the key could open but never close the palette). The menu item still opens it on click. - &[&MenuItem::with_id( - handle, - "menu-command-palette", - "Command Palette", - true, - None::<&str>, - )?], - ) + use tauri::menu::{MenuItem, Submenu}; + Submenu::with_items( + handle, + "View", + true, + // QNBS-v3: no native accelerator on this item. CmdOrCtrl+K is already a *toggle* in the web + // shortcut layer; binding it here to the open-only menu command would shadow that toggle on + // desktop (the key could open but never close the palette). The menu item still opens it on click. + &[&MenuItem::with_id( + handle, + "menu-command-palette", + "Command Palette", + true, + None::<&str>, + )?], + ) } // QNBS-v3 (D4): Window menu via predefined items — OS-native window controls, no wiring needed. #[cfg(desktop)] fn build_window_menu( - handle: &tauri::AppHandle, + handle: &tauri::AppHandle, ) -> tauri::Result> { - use tauri::menu::{PredefinedMenuItem, Submenu}; - Submenu::with_items( - handle, - "Window", - true, - &[ - &PredefinedMenuItem::minimize(handle, None)?, - &PredefinedMenuItem::maximize(handle, None)?, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::fullscreen(handle, None)?, - &PredefinedMenuItem::close_window(handle, None)?, - ], - ) + use tauri::menu::{PredefinedMenuItem, Submenu}; + Submenu::with_items( + handle, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(handle, None)?, + &PredefinedMenuItem::maximize(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::fullscreen(handle, None)?, + &PredefinedMenuItem::close_window(handle, None)?, + ], + ) } #[cfg(desktop)] fn build_help_menu( - handle: &tauri::AppHandle, + handle: &tauri::AppHandle, ) -> tauri::Result> { - use tauri::menu::{MenuItem, Submenu}; - Submenu::with_items( - handle, - "Help", - true, - &[&MenuItem::with_id(handle, "menu-help", "Help Center", true, None::<&str>)?], - ) + use tauri::menu::{MenuItem, Submenu}; + Submenu::with_items( + handle, + "Help", + true, + &[&MenuItem::with_id( + handle, + "menu-help", + "Help Center", + true, + None::<&str>, + )?], + ) } // QNBS-v3: split into per-submenu builders (was one large function) to keep cyclomatic // complexity low — DeepSource RS-R1000 flagged the monolithic version at 26 ("very-high" risk). #[cfg(desktop)] fn install_app_menu(app: &tauri::App) -> tauri::Result<()> { - use tauri::menu::Menu; + use tauri::menu::Menu; - let handle = app.handle(); - let file_menu = build_file_menu(handle)?; - let edit_menu = build_edit_menu(handle)?; - let view_menu = build_view_menu(handle)?; - let window_menu = build_window_menu(handle)?; - let help_menu = build_help_menu(handle)?; - let menu = - Menu::with_items(handle, &[&file_menu, &edit_menu, &view_menu, &window_menu, &help_menu])?; - app.set_menu(menu)?; - Ok(()) + let handle = app.handle(); + let file_menu = build_file_menu(handle)?; + let edit_menu = build_edit_menu(handle)?; + let view_menu = build_view_menu(handle)?; + let window_menu = build_window_menu(handle)?; + let help_menu = build_help_menu(handle)?; + let menu = Menu::with_items( + handle, + &[&file_menu, &edit_menu, &view_menu, &window_menu, &help_menu], + )?; + app.set_menu(menu)?; + Ok(()) } #[cfg(not(desktop))] fn install_app_menu(_app: &tauri::App) -> tauri::Result<()> { - Ok(()) + Ok(()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - // QNBS-v3: Single-instance plugin with deep-link feature handles file associations - // The plugin automatically handles CLI args and emits "deep-link://new-url" event - .plugin(tauri_plugin_single_instance::Builder::new().build()) - .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - // QNBS-v3 (T3): native OS notifications — permission request/gating lives entirely in JS - // (services/desktop/desktopNotifications.ts); the Rust side only registers the plugin. - .plugin(tauri_plugin_notification::init()) - // QNBS-v3 (#332/D3): exposes exit() to JS so tray/menu Quit can flush state before terminating. - .plugin(tauri_plugin_process::init()) - .plugin( - tauri_plugin_window_state::Builder::new() - .with_state_flags(tauri_plugin_window_state::StateFlags::all()) - .build(), - ) - .invoke_handler(tauri::generate_handler![ - pandoc::pandoc_markdown_to_epub, - lora::train_lora, - lora::merge_lora, - lora::abort_lora_training, - lora::generate_ollama_modelfile, - lora::check_lora_environment, - lora::set_lora_python_path, - commands::task_supervisor::worldscript_task_supervisor_ping, - commands::task_supervisor::worldscript_task_supervisor_submit, - ]) - .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } - install_app_menu(app)?; - Ok(()) - }) - .on_menu_event(|app, event| { - let id = event.id().0.clone(); - // QNBS-v3 (#187): only forward the custom ids the frontend actually handles. The predefined - // Edit/Window items (undo/redo/cut/copy/paste/select-all/minimize/…) are handled natively by the - // OS; emitting them too would flood the JS bridge with high-frequency events during editing that - // the frontend just ignores. - if matches!( - id.as_str(), - "menu-export" | "menu-settings" | "menu-help" | "menu-command-palette" - ) { - let _ = app.emit("menu-action", id); - } - }) - // QNBS-v3: RunEvent handlers removed because tauri_plugin_single_instance - // handles SecondInstance/Opened via "deep-link://new-url" events and - // tauri::Builder no longer exposes .on_event() in Tauri v2. - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + tauri::Builder::default() + // QNBS-v3: Single-instance plugin with deep-link feature handles file associations + // The plugin automatically handles CLI args and emits "deep-link://new-url" event + .plugin(tauri_plugin_single_instance::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + // QNBS-v3 (T3): native OS notifications — permission request/gating lives entirely in JS + // (services/desktop/desktopNotifications.ts); the Rust side only registers the plugin. + .plugin(tauri_plugin_notification::init()) + // QNBS-v3 (#332/D3): exposes exit() to JS so tray/menu Quit can flush state before terminating. + .plugin(tauri_plugin_process::init()) + .plugin( + tauri_plugin_window_state::Builder::new() + .with_state_flags(tauri_plugin_window_state::StateFlags::all()) + .build(), + ) + .invoke_handler(tauri::generate_handler![ + pandoc::pandoc_markdown_to_epub, + lora::train_lora, + lora::merge_lora, + lora::abort_lora_training, + lora::generate_ollama_modelfile, + lora::check_lora_environment, + lora::set_lora_python_path, + commands::task_supervisor::worldscript_task_supervisor_ping, + commands::task_supervisor::worldscript_task_supervisor_submit, + ]) + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + install_app_menu(app)?; + Ok(()) + }) + .on_menu_event(|app, event| { + let id = event.id().0.clone(); + // QNBS-v3 (#187): only forward the custom ids the frontend actually handles. The predefined + // Edit/Window items (undo/redo/cut/copy/paste/select-all/minimize/…) are handled natively by the + // OS; emitting them too would flood the JS bridge with high-frequency events during editing that + // the frontend just ignores. + if matches!( + id.as_str(), + "menu-export" | "menu-settings" | "menu-help" | "menu-command-palette" + ) { + let _ = app.emit("menu-action", id); + } + }) + // QNBS-v3: RunEvent handlers removed because tauri_plugin_single_instance + // handles SecondInstance/Opened via "deep-link://new-url" events and + // tauri::Builder no longer exposes .on_event() in Tauri v2. + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ad5fe8399..69c3a72ec 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + app_lib::run(); } From 22ddda24fccfbdadcce34e870a60b1e6bf0ac326 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:14:44 +0200 Subject: [PATCH 05/12] test(storage): cover atomic write failure recovery --- tests/unit/services/fs/fsStores.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index 0bf53c7cd..c25bfe47b 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -183,6 +183,21 @@ describe('FsProjectStore — projects', () => { expect.objectContaining({ error: 'disk full' }), ); }); + + it('keeps the previous project when the replacement write fails', async () => { + await store.saveProject(project as never); + const originalWriteTextFile = fake.apis.writeTextFile; + fake.apis.writeTextFile = (path: string, content: string) => { + if (path.includes('/project.json.tmp-')) return Promise.reject(new Error('disk full')); + return originalWriteTextFile(path, content); + }; + + await expect( + store.saveProject({ ...project, title: 'Should not replace' } as never), + ).rejects.toThrow('disk full'); + expect((await store.loadProject('p1'))?.title).toBe('My Novel'); + expect([...fake.text.keys()].some((path) => path.includes('.tmp-'))).toBe(false); + }); }); describe('FsSettingsStore — settings + encrypted API keys', () => { From 9e8550eca613910fba5ed7f2a24458a9872c0037 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:28:33 +0200 Subject: [PATCH 06/12] ci(tauri): align Linux appindicator dependency --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 961d61651..be301623c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,7 +195,7 @@ jobs: - name: Install Linux Tauri build dependencies run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev patchelf - name: Rust format check working-directory: src-tauri run: cargo fmt --check From b416694c5c519050e7a563662583fe08a69e99fc Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:03:33 +0200 Subject: [PATCH 07/12] test(storage): lock desktop API key routing --- tests/unit/storageService.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/storageService.test.ts b/tests/unit/storageService.test.ts index 5300b49c3..8b275cb7c 100644 --- a/tests/unit/storageService.test.ts +++ b/tests/unit/storageService.test.ts @@ -119,6 +119,22 @@ describe('storageService (IndexedDB backend in browser)', () => { expect(mockDb.clearApiKey).toHaveBeenCalledWith('openai'); }); + it('keeps API keys on IndexedDB when the desktop filesystem backend is active', async () => { + (window as { __TAURI__?: unknown }).__TAURI__ = {}; + vi.resetModules(); + // QNBS-v3: Desktop project storage must not redirect API keys to filesystem persistence. + const desktopStorage = (await import('../../services/storageService')).storageService; + + await desktopStorage.saveApiKey('openai', 'desktop-key'); + await desktopStorage.getApiKey('openai'); + await desktopStorage.clearApiKey('openai'); + + expect(mockDb.saveApiKey).toHaveBeenCalledWith('openai', 'desktop-key'); + expect(mockDb.getApiKey).toHaveBeenCalledWith('openai'); + expect(mockDb.clearApiKey).toHaveBeenCalledWith('openai'); + delete (window as { __TAURI__?: unknown }).__TAURI__; + }); + it('delegates snapshot operations to dbService', async () => { mockDb.saveSnapshot.mockResolvedValueOnce(42); const id = await storageService.saveSnapshot('label', { data: 1 }); From 292106400979d87bffe574c64afbc5c14706bc15 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:26:59 +0200 Subject: [PATCH 08/12] fix(recovery): close review-loop persistence risks --- components/ApiKeySection.tsx | 35 ------------------- .../settings/EncryptionRecoveryModal.tsx | 12 ++++++- components/settings/IdbUnlockModal.tsx | 13 ++++++- services/factoryResetService.ts | 15 ++++++-- services/fs/settingsFsStore.ts | 16 +++++++++ services/storageService.ts | 1 + tests/unit/factoryResetService.test.ts | 2 +- tests/unit/services/fs/fsStores.test.ts | 17 +++++++-- tests/unit/storageService.test.ts | 1 + 9 files changed, 70 insertions(+), 42 deletions(-) diff --git a/components/ApiKeySection.tsx b/components/ApiKeySection.tsx index 6f2f649cb..9464eed2c 100644 --- a/components/ApiKeySection.tsx +++ b/components/ApiKeySection.tsx @@ -29,17 +29,11 @@ export const ApiKeySection: FC = () => { const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [showKey, setShowKey] = useState(false); - const [decryptFailed, setDecryptFailed] = useState(false); - const checkKeyStatus = useCallback(async () => { setIsLoading(true); try { const exists = Boolean(await storageService.getGeminiApiKey()); setHasKey(exists); - // Check if key exists but decryption failed (device change, cleared site data) - if (!exists) { - setDecryptFailed(false); - } } catch (error) { logger.error('Failed to check API key status:', error); } finally { @@ -69,7 +63,6 @@ export const ApiKeySection: FC = () => { invalidateAiClientCache(); setApiKey(''); setHasKey(true); - setDecryptFailed(false); setMessage({ type: 'success', text: t('settings.apiKey.saved') }); setTestResult(null); // QNBS-v3: surface an invalid/unauthenticated key immediately instead of deferring discovery @@ -196,34 +189,6 @@ export const ApiKeySection: FC = () => { - {/* Decrypt Failed Warning */} - {decryptFailed && ( -
-
- {/* QNBS-v3: Decorative icon - hidden from assistive tech */} - -
-

{t('apiKey.decryptFailed')}

-

{t('apiKey.decryptFailedDetail')}

-
-
-
- )} - {/* Key Status / Input */} {hasKey ? (
diff --git a/components/settings/EncryptionRecoveryModal.tsx b/components/settings/EncryptionRecoveryModal.tsx index 25fd086cf..60cefff30 100644 --- a/components/settings/EncryptionRecoveryModal.tsx +++ b/components/settings/EncryptionRecoveryModal.tsx @@ -91,7 +91,17 @@ export const EncryptionRecoveryModal: FC = ({ journal, onRecovered }) => const handleFactoryReset = useCallback(async () => { if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return; setBusy(true); - await wipeAllAppData(); + setError(''); + try { + await wipeAllAppData(); + } catch (err) { + setError(t('settings.privacy.encryptionRecoveryFailed')); + logger.error('Factory reset failed', { + error: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusy(false); + } }, [t]); return ( diff --git a/components/settings/IdbUnlockModal.tsx b/components/settings/IdbUnlockModal.tsx index a1baad375..3b4c7cb1c 100644 --- a/components/settings/IdbUnlockModal.tsx +++ b/components/settings/IdbUnlockModal.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from '../../hooks/useTranslation'; import { wipeAllAppData } from '../../services/factoryResetService'; +import { logger } from '../../services/logger'; import { verifyAndInitIdbEncryption } from '../../services/storage/storageEncryptionService'; import { Button } from '../ui/Button'; import { Modal } from '../ui/Modal'; @@ -167,7 +168,17 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => { const handleFactoryReset = useCallback(async () => { if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return; setBusy(true); - await wipeAllAppData(); + setError(''); + try { + await wipeAllAppData(); + } catch (err) { + setError(t('settings.privacy.encryptionRecoveryFailed')); + logger.error('Factory reset failed', { + error: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusy(false); + } }, [t]); const errorId = 'idb-unlock-error'; diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts index 62811774e..3a18f6db0 100644 --- a/services/factoryResetService.ts +++ b/services/factoryResetService.ts @@ -64,7 +64,17 @@ async function clearTauriAppData(): Promise { const apis = await loadTauriApis(); const appDataPath = await apis.appDataDir(); if (await apis.exists(appDataPath)) { - await apis.remove(appDataPath, { recursive: true }); + // QNBS-v3: keep the capability-scoped AppData root and remove only its contents. + const entries = await apis.readDir(appDataPath); + await Promise.all( + entries + .map((entry) => entry.name) + .filter((name): name is string => Boolean(name)) + .map(async (name) => { + const childPath = await apis.join(appDataPath, name); + await apis.remove(childPath, { recursive: true }); + }), + ); } } catch (error) { logger.error('Failed to clear Tauri app data during factory reset:', error); @@ -79,9 +89,10 @@ async function clearTauriAppData(): Promise { */ export async function wipeAllAppData(): Promise { logger.warn('[factoryReset] Wiping all app data…'); + // QNBS-v3: clear fallible desktop data first so a failed desktop reset never leaves a mixed wipe. + await clearTauriAppData(); await deleteAllIndexedDBDatabases(); await clearServiceWorkerCaches(); - await clearTauriAppData(); try { localStorage.clear(); sessionStorage.clear(); diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index 0405b82f5..1f4507d9d 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -13,6 +13,22 @@ import type { TauriApis } from './fsCore'; import { FsCore, retryFs, writeTextFileAtomic } from './fsCore'; export class FsSettingsStore extends FsCore { + async removeLegacyApiKeyFiles(): Promise { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const configPath = await apis.join(appDataPath, 'config'); + const providers = ['gemini', 'openai', 'anthropic', 'grok', 'openrouter']; + + for (const provider of providers) { + const keyFile = await apis.join(configPath, `${provider}_key.enc.json`); + try { + if (await apis.exists(keyFile)) await retryFs(() => apis.remove(keyFile)); + } catch (error) { + logger.warn(`Failed to remove legacy API key file for provider "${provider}":`, error); + } + } + } + async saveSettings(settings: Settings): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); diff --git a/services/storageService.ts b/services/storageService.ts index 59f89dedc..a1e4a7432 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -49,6 +49,7 @@ class StorageManager { if (isTauriRuntime()) { try { await fileSystemService.initialize(); + await fileSystemService.removeLegacyApiKeyFiles(); this.backend = fileSystemService; logger.debug('Using file system storage backend'); } catch (error) { diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts index 237f93327..27e59f1c9 100644 --- a/tests/unit/factoryResetService.test.ts +++ b/tests/unit/factoryResetService.test.ts @@ -1,6 +1,6 @@ /** * Tests for services/factoryResetService.ts - * QNBS-v3: wipeAllAppData — clears IDB + web storage + SW caches, then reloads. Covers the + * QNBS-v3: [data safety / verify complete reset sequencing / preserves deterministic recovery]. Covers the * native indexedDB.databases() path, the known-list fallback, and the Cache API branch. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index c25bfe47b..c3bc11c07 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -213,7 +213,7 @@ describe('FsSettingsStore — settings + encrypted API keys', () => { }); it('rejects filesystem API-key persistence', async () => { - await expect(store.saveApiKey('openai', 'sk-secret-123')).rejects.toThrow(/disabled/); + await expect(store.saveApiKey('openai', 'test-provider-key')).rejects.toThrow(/disabled/); }); it('does not read legacy filesystem API-key files', async () => { @@ -231,7 +231,20 @@ describe('FsSettingsStore — settings + encrypted API keys', () => { expect(await store.getApiKey('anthropic')).toBeNull(); }); - // QNBS-v3: legacy filesystem key files are discarded because their derivation was recoverable. + it('removes known legacy API-key files during desktop startup cleanup', async () => { + const providers = ['gemini', 'openai', 'anthropic', 'grok', 'openrouter']; + for (const provider of providers) { + fake.text.set(`/app/config/${provider}_key.enc.json`, 'legacy-ciphertext'); + } + + await store.removeLegacyApiKeyFiles(); + + expect( + providers.every((provider) => !fake.text.has(`/app/config/${provider}_key.enc.json`)), + ).toBe(true); + }); + + // QNBS-v3: [security / discard recoverable legacy ciphertext / prevents unsafe migration]. it('discards a legacy unsalted key file without notifying or throwing', async () => { const dispatch = vi.fn(); appStoreRef.current = { getState: vi.fn(), dispatch } as never; diff --git a/tests/unit/storageService.test.ts b/tests/unit/storageService.test.ts index 8b275cb7c..f0b4b4f80 100644 --- a/tests/unit/storageService.test.ts +++ b/tests/unit/storageService.test.ts @@ -18,6 +18,7 @@ const mockDb = { saveApiKey: vi.fn().mockResolvedValue(undefined), getApiKey: vi.fn().mockResolvedValue(null), clearApiKey: vi.fn().mockResolvedValue(undefined), + removeLegacyApiKeyFiles: vi.fn().mockResolvedValue(undefined), saveSnapshot: vi.fn().mockResolvedValue(1), getSnapshotData: vi.fn().mockResolvedValue(null), listSnapshots: vi.fn().mockResolvedValue([]), From b8017a9325668b11a8d927171a8c3093e4121422 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:57:36 +0200 Subject: [PATCH 09/12] fix(storage): serialize atomic writes and harden reset cleanup --- services/factoryResetService.ts | 21 ++++++++++++--------- services/fs/fsCore.ts | 25 +++++++++++++++++++------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts index 3a18f6db0..33a70f54d 100644 --- a/services/factoryResetService.ts +++ b/services/factoryResetService.ts @@ -66,15 +66,18 @@ async function clearTauriAppData(): Promise { if (await apis.exists(appDataPath)) { // QNBS-v3: keep the capability-scoped AppData root and remove only its contents. const entries = await apis.readDir(appDataPath); - await Promise.all( - entries - .map((entry) => entry.name) - .filter((name): name is string => Boolean(name)) - .map(async (name) => { - const childPath = await apis.join(appDataPath, name); - await apis.remove(childPath, { recursive: true }); - }), - ); + let firstError: unknown; + for (const name of entries + .map((entry) => entry.name) + .filter((entryName): entryName is string => Boolean(entryName))) { + try { + const childPath = await apis.join(appDataPath, name); + await apis.remove(childPath, { recursive: true }); + } catch (error) { + firstError ??= error; + } + } + if (firstError) throw firstError; } } catch (error) { logger.error('Failed to clear Tauri app data during factory reset:', error); diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 48ef5f5d0..8163a1846 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -90,18 +90,31 @@ function temporaryPath(path: string): string { return `${path}.tmp-${suffix}`; } +const atomicWriteTails = new Map>(); + async function writeAndReplace( apis: TauriApis, path: string, write: (temporary: string) => Promise, ): Promise { - const temporary = temporaryPath(path); + const previous = atomicWriteTails.get(path); + const current = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => { + const temporary = temporaryPath(path); + try { + await retryFs(() => write(temporary)); + await retryFs(() => apis.rename(temporary, path)); + } catch (error) { + await apis.remove(temporary).catch(() => undefined); + throw error; + } + }); + atomicWriteTails.set(path, current); try { - await retryFs(() => write(temporary)); - await retryFs(() => apis.rename(temporary, path)); - } catch (error) { - await apis.remove(temporary).catch(() => undefined); - throw error; + await current; + } finally { + if (atomicWriteTails.get(path) === current) { + atomicWriteTails.delete(path); + } } } From b3395804e52b1133a0fd8d56519139194dc14860 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:37:00 +0200 Subject: [PATCH 10/12] test(storage): cover Tauri factory-reset and danger-zone patch gaps Adds patch coverage for the new clearTauriAppData branch and the factory-reset danger-zone UI in IdbUnlockModal/EncryptionRecoveryModal (codecov/patch was at 52.75%). Extracts the confirm/wipe/error/busy logic into a shared useFactoryReset hook and FactoryResetDangerZone component, replacing three near-identical copies (CodeAnt duplicate- code finding). Also fixes a bug: the stuck (recovery-required) branch had no error paragraph, so a failed reset there set error state with nothing to render it. --- .../settings/EncryptionRecoveryModal.tsx | 58 ++++------ .../settings/FactoryResetDangerZone.tsx | 39 +++++++ components/settings/IdbUnlockModal.tsx | 34 +----- hooks/useFactoryReset.ts | 31 ++++++ tests/unit/factoryResetService.test.ts | 103 +++++++++++++++++- .../settings/EncryptionRecoveryModal.test.tsx | 79 +++++++++++++- tests/unit/settings/IdbUnlockModal.test.tsx | 53 +++++++++ 7 files changed, 325 insertions(+), 72 deletions(-) create mode 100644 components/settings/FactoryResetDangerZone.tsx create mode 100644 hooks/useFactoryReset.ts diff --git a/components/settings/EncryptionRecoveryModal.tsx b/components/settings/EncryptionRecoveryModal.tsx index 60cefff30..c10501901 100644 --- a/components/settings/EncryptionRecoveryModal.tsx +++ b/components/settings/EncryptionRecoveryModal.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFactoryReset } from '../../hooks/useFactoryReset'; import { useTranslation } from '../../hooks/useTranslation'; -import { wipeAllAppData } from '../../services/factoryResetService'; import { logger } from '../../services/logger'; import type { EncryptionMigrationJournal } from '../../services/storage/encryptionMigrationJournal'; import type { ProtectedStoreMigrationProgress } from '../../services/storage/protectedStoreMigration'; @@ -11,6 +11,7 @@ import { } from '../../services/storage/storageEncryptionService'; import { Button } from '../ui/Button'; import { Modal } from '../ui/Modal'; +import { FactoryResetDangerZone } from './FactoryResetDangerZone'; interface Props { journal: EncryptionMigrationJournal; @@ -88,21 +89,7 @@ export const EncryptionRecoveryModal: FC = ({ journal, onRecovered }) => const canSubmit = !busy && sourcePassphrase.length > 0 && (!needsTargetPassphrase || targetPassphrase.length > 0); - const handleFactoryReset = useCallback(async () => { - if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return; - setBusy(true); - setError(''); - try { - await wipeAllAppData(); - } catch (err) { - setError(t('settings.privacy.encryptionRecoveryFailed')); - logger.error('Factory reset failed', { - error: err instanceof Error ? err.message : String(err), - }); - } finally { - setBusy(false); - } - }, [t]); + const handleFactoryReset = useFactoryReset({ t, setBusy, setError }); return ( = ({ journal, onRecovered }) =>

{t('settings.privacy.encryptionRecoveryStuck')}

-

- {t('settings.data.dangerZone.factoryReset.modalDescription')} -

- + {error} +

+ void handleFactoryReset()} + bordered={false} + descriptionClassName="text-xs text-[var(--sc-text-secondary)]" + />
) : ( <> @@ -226,19 +218,7 @@ export const EncryptionRecoveryModal: FC = ({ journal, onRecovered }) => {t('settings.privacy.encryptionRecoveryResumeButton')} -
-

- {t('settings.data.dangerZone.factoryReset.modalDescription')} -

- -
+ void handleFactoryReset()} /> )} diff --git a/components/settings/FactoryResetDangerZone.tsx b/components/settings/FactoryResetDangerZone.tsx new file mode 100644 index 000000000..264e58f64 --- /dev/null +++ b/components/settings/FactoryResetDangerZone.tsx @@ -0,0 +1,39 @@ +import type { FC } from 'react'; +import { Button } from '../ui/Button'; + +interface Props { + t: (key: string) => string; + busy: boolean; + onReset: () => void; + bordered?: boolean; + descriptionClassName?: string; +} + +/** + * Shared "wipe all app data" danger-zone block for encryption dead-end recovery flows — + * one copy instead of the near-identical block previously repeated across the unlock modal + * and both branches of the recovery modal. + */ +export const FactoryResetDangerZone: FC = ({ + t, + busy, + onReset, + bordered = true, + descriptionClassName = 'text-xs text-[var(--sc-danger-fg)] mb-2', +}) => { + const content = ( + <> +

+ {t('settings.data.dangerZone.factoryReset.modalDescription')} +

+ + + ); + return bordered ? ( +
{content}
+ ) : ( + content + ); +}; diff --git a/components/settings/IdbUnlockModal.tsx b/components/settings/IdbUnlockModal.tsx index 3b4c7cb1c..6ae5d78d7 100644 --- a/components/settings/IdbUnlockModal.tsx +++ b/components/settings/IdbUnlockModal.tsx @@ -1,11 +1,11 @@ import type { FC } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFactoryReset } from '../../hooks/useFactoryReset'; import { useTranslation } from '../../hooks/useTranslation'; -import { wipeAllAppData } from '../../services/factoryResetService'; -import { logger } from '../../services/logger'; import { verifyAndInitIdbEncryption } from '../../services/storage/storageEncryptionService'; import { Button } from '../ui/Button'; import { Modal } from '../ui/Modal'; +import { FactoryResetDangerZone } from './FactoryResetDangerZone'; interface Props { onUnlocked: () => void; @@ -165,21 +165,7 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => { [handleUnlock], ); - const handleFactoryReset = useCallback(async () => { - if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return; - setBusy(true); - setError(''); - try { - await wipeAllAppData(); - } catch (err) { - setError(t('settings.privacy.encryptionRecoveryFailed')); - logger.error('Factory reset failed', { - error: err instanceof Error ? err.message : String(err), - }); - } finally { - setBusy(false); - } - }, [t]); + const handleFactoryReset = useFactoryReset({ t, setBusy, setError }); const errorId = 'idb-unlock-error'; const hasError = error.length > 0; @@ -241,19 +227,7 @@ export const IdbUnlockModal: FC = ({ onUnlocked }) => { : t('settings.privacy.encryptionUnlockButton')} -
-

- {t('settings.data.dangerZone.factoryReset.modalDescription')} -

- -
+ void handleFactoryReset()} /> ); diff --git a/hooks/useFactoryReset.ts b/hooks/useFactoryReset.ts new file mode 100644 index 000000000..7307c5754 --- /dev/null +++ b/hooks/useFactoryReset.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; +import { wipeAllAppData } from '../services/factoryResetService'; +import { logger } from '../services/logger'; + +interface Options { + t: (key: string) => string; + setBusy: (busy: boolean) => void; + setError: (error: string) => void; +} + +/** + * Shared confirm → wipeAllAppData → busy/error handling for the danger-zone factory-reset + * action, used by both the passphrase-unlock modal and the encryption-recovery modal. + */ +export function useFactoryReset({ t, setBusy, setError }: Options): () => Promise { + return useCallback(async () => { + if (!window.confirm(t('settings.data.dangerZone.factoryReset.modalWarning'))) return; + setBusy(true); + setError(''); + try { + await wipeAllAppData(); + } catch (err) { + setError(t('settings.privacy.encryptionRecoveryFailed')); + logger.error('Factory reset failed', { + error: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusy(false); + } + }, [t, setBusy, setError]); +} diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts index 27e59f1c9..af653d29e 100644 --- a/tests/unit/factoryResetService.test.ts +++ b/tests/unit/factoryResetService.test.ts @@ -1,16 +1,25 @@ /** * Tests for services/factoryResetService.ts * QNBS-v3: [data safety / verify complete reset sequencing / preserves deterministic recovery]. Covers the - * native indexedDB.databases() path, the known-list fallback, and the Cache API branch. + * native indexedDB.databases() path, the known-list fallback, the Cache API branch, and the + * Tauri AppData clear branch (exists/missing/partial-failure). */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { wipeAllAppData } from '../../services/factoryResetService'; import { logger } from '../../services/logger'; +const mockIsTauriRuntime = vi.fn(() => false); +const mockLoadTauriApis = vi.fn(); + vi.mock('../../services/logger', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, })); -vi.mock('../../services/tauriRuntime', () => ({ isTauriRuntime: vi.fn(() => false) })); +vi.mock('../../services/tauriRuntime', () => ({ + isTauriRuntime: () => mockIsTauriRuntime(), +})); +vi.mock('../../services/fs/fsCore', () => ({ + loadTauriApis: (...args: unknown[]) => mockLoadTauriApis(...args), +})); function createDb(name: string): Promise { return new Promise((resolve, reject) => { @@ -43,6 +52,7 @@ let originalLocation: Location; beforeEach(() => { vi.clearAllMocks(); + mockIsTauriRuntime.mockReturnValue(false); reloadMock = vi.fn(); originalLocation = window.location; Object.defineProperty(window, 'location', { @@ -101,4 +111,93 @@ describe('wipeAllAppData', () => { expect(del).toHaveBeenCalledWith('dynamic-v1'); expect(reloadMock).toHaveBeenCalledTimes(1); }); + + describe('desktop (Tauri) AppData clearing', () => { + beforeEach(() => { + mockIsTauriRuntime.mockReturnValue(true); + }); + + it('skips readDir/remove when the AppData path does not exist, and still completes the wipe', async () => { + const removeMock = vi.fn().mockResolvedValue(undefined); + mockLoadTauriApis.mockResolvedValue({ + appDataDir: vi.fn().mockResolvedValue('/app/data'), + exists: vi.fn().mockResolvedValue(false), + readDir: vi.fn(), + join: vi.fn(), + remove: removeMock, + }); + + await runWipe(); + + expect(removeMock).not.toHaveBeenCalled(); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it('removes every AppData entry (skipping unnamed entries) and completes the wipe', async () => { + const removeMock = vi.fn().mockResolvedValue(undefined); + const joinMock = vi.fn((base: string, name: string) => Promise.resolve(`${base}/${name}`)); + mockLoadTauriApis.mockResolvedValue({ + appDataDir: vi.fn().mockResolvedValue('/app/data'), + exists: vi.fn().mockResolvedValue(true), + readDir: vi + .fn() + .mockResolvedValue([{ name: 'projects' }, { name: undefined }, { name: 'keys.bin' }]), + join: joinMock, + remove: removeMock, + }); + + await runWipe(); + + expect(removeMock).toHaveBeenCalledWith('/app/data/projects', { recursive: true }); + expect(removeMock).toHaveBeenCalledWith('/app/data/keys.bin', { recursive: true }); + expect(removeMock).toHaveBeenCalledTimes(2); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it('rejects and never reloads when every AppData entry fails to remove', async () => { + mockLoadTauriApis.mockResolvedValue({ + appDataDir: vi.fn().mockResolvedValue('/app/data'), + exists: vi.fn().mockResolvedValue(true), + readDir: vi.fn().mockResolvedValue([{ name: 'locked-file' }]), + join: vi.fn((base: string, name: string) => Promise.resolve(`${base}/${name}`)), + remove: vi.fn().mockRejectedValue(new Error('EBUSY')), + }); + + vi.useFakeTimers(); + try { + await expect(wipeAllAppData()).rejects.toThrow( + 'Factory reset could not clear desktop data', + ); + } finally { + vi.useRealTimers(); + } + + expect(reloadMock).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to clear Tauri app data during factory reset:', + expect.any(Error), + ); + }); + + it('rejects and never reloads when readDir itself throws', async () => { + mockLoadTauriApis.mockResolvedValue({ + appDataDir: vi.fn().mockResolvedValue('/app/data'), + exists: vi.fn().mockResolvedValue(true), + readDir: vi.fn().mockRejectedValue(new Error('permission denied')), + join: vi.fn(), + remove: vi.fn(), + }); + + vi.useFakeTimers(); + try { + await expect(wipeAllAppData()).rejects.toThrow( + 'Factory reset could not clear desktop data', + ); + } finally { + vi.useRealTimers(); + } + + expect(reloadMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/settings/EncryptionRecoveryModal.test.tsx b/tests/unit/settings/EncryptionRecoveryModal.test.tsx index bc4121a89..f7d135e0c 100644 --- a/tests/unit/settings/EncryptionRecoveryModal.test.tsx +++ b/tests/unit/settings/EncryptionRecoveryModal.test.tsx @@ -19,13 +19,22 @@ type OnRecovered = () => void; const mockResumeEncryptionMigration = vi.fn(); const mockLoggerWarn = vi.fn(); +const mockLoggerError = vi.fn(); +const mockWipeAllAppData = vi.fn(); vi.mock('../../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (k: string) => k, language: 'en' }), })); vi.mock('../../../services/logger', () => ({ - logger: { warn: (...args: unknown[]) => mockLoggerWarn(...args) }, + logger: { + warn: (...args: unknown[]) => mockLoggerWarn(...args), + error: (...args: unknown[]) => mockLoggerError(...args), + }, +})); + +vi.mock('../../../services/factoryResetService', () => ({ + wipeAllAppData: (...args: unknown[]) => mockWipeAllAppData(...args), })); // QNBS-v3: IdbWrongPassphraseError must be a real class (not a plain mock) so the component's @@ -99,6 +108,8 @@ describe('EncryptionRecoveryModal', () => { beforeEach(() => { vi.clearAllMocks(); onRecovered = vi.fn(); + mockWipeAllAppData.mockResolvedValue(undefined); + vi.spyOn(window, 'confirm').mockReturnValue(true); }); it('renders the recovery title', () => { @@ -317,4 +328,70 @@ describe('EncryptionRecoveryModal', () => { resolveResume?.(); await waitFor(() => expect(onRecovered).toHaveBeenCalledTimes(1)); }); + + describe('danger-zone factory reset', () => { + it('asks for confirmation and wipes app data from the stuck (recovery-required) state', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }), + ); + expect(window.confirm).toHaveBeenCalledWith( + 'settings.data.dangerZone.factoryReset.modalWarning', + ); + await waitFor(() => expect(mockWipeAllAppData).toHaveBeenCalledTimes(1)); + }); + + it('asks for confirmation and wipes app data from the normal resume state', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }), + ); + await waitFor(() => expect(mockWipeAllAppData).toHaveBeenCalledTimes(1)); + }); + + it('does not wipe app data when the confirmation is dismissed', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }), + ); + expect(mockWipeAllAppData).not.toHaveBeenCalled(); + }); + + it('shows a recovery-failed error and logs it when wipeAllAppData rejects', async () => { + mockWipeAllAppData.mockRejectedValue(new Error('disk full')); + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole('button', { name: 'settings.data.dangerZone.factoryReset.button' }), + ); + await waitFor(() => + expect(screen.getByText('settings.privacy.encryptionRecoveryFailed')).toBeInTheDocument(), + ); + expect(mockLoggerError).toHaveBeenCalledWith('Factory reset failed', { error: 'disk full' }); + }); + }); }); diff --git a/tests/unit/settings/IdbUnlockModal.test.tsx b/tests/unit/settings/IdbUnlockModal.test.tsx index e289b6d2a..e719c8234 100644 --- a/tests/unit/settings/IdbUnlockModal.test.tsx +++ b/tests/unit/settings/IdbUnlockModal.test.tsx @@ -13,6 +13,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- const mockVerifyAndInit = vi.fn(); +const mockWipeAllAppData = vi.fn(); +const mockLoggerError = vi.fn(); const localStorageMock = (() => { let store: Record = {}; @@ -39,6 +41,14 @@ vi.mock('../../../services/storage/storageEncryptionService', () => ({ verifyAndInitIdbEncryption: (...args: unknown[]) => mockVerifyAndInit(...args), })); +vi.mock('../../../services/factoryResetService', () => ({ + wipeAllAppData: (...args: unknown[]) => mockWipeAllAppData(...args), +})); + +vi.mock('../../../services/logger', () => ({ + logger: { error: (...args: unknown[]) => mockLoggerError(...args) }, +})); + vi.mock('../../../components/ui/Button', () => ({ Button: (props: React.ButtonHTMLAttributes) =>