From 65e570ca06a540b5f683768a8f5bdd2d05ffba74 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 2 Jun 2026 10:30:20 +0200 Subject: [PATCH 1/4] feat: add encrypted field CRUD with field-crypto, field-service, and TanStack Query hooks --- CLAUDE.md | 1 + docs/implementation-plan/06-phase-6-data.md | 29 ++- src/app/layouts/PublicLayout.tsx | 4 +- src/features/auth/ui/.gitkeep | 0 src/features/encryption/ui/.gitkeep | 0 .../fields/model/field-crypto.test.ts | 96 +++++++++ src/features/fields/model/field-crypto.ts | 56 +++++ .../fields/model/field-service.test.ts | 196 ++++++++++++++++++ src/features/fields/model/field-service.ts | 59 ++++++ src/features/fields/model/use-field.test.ts | 112 ++++++++++ src/features/fields/model/use-field.ts | 22 ++ .../fields/model/use-save-field.test.ts | 80 +++++++ src/features/fields/model/use-save-field.ts | 20 ++ src/features/fields/ui/.gitkeep | 0 src/features/fields/ui/DashboardPage.tsx | 3 +- src/features/landing/ui/HeroSection.tsx | 2 +- src/features/landing/ui/LandingPage.tsx | 2 +- src/shared/types/entities/field.types.ts | 3 + 18 files changed, 668 insertions(+), 17 deletions(-) delete mode 100644 src/features/auth/ui/.gitkeep delete mode 100644 src/features/encryption/ui/.gitkeep create mode 100644 src/features/fields/model/field-crypto.test.ts create mode 100644 src/features/fields/model/field-crypto.ts create mode 100644 src/features/fields/model/field-service.test.ts create mode 100644 src/features/fields/model/field-service.ts create mode 100644 src/features/fields/model/use-field.test.ts create mode 100644 src/features/fields/model/use-field.ts create mode 100644 src/features/fields/model/use-save-field.test.ts create mode 100644 src/features/fields/model/use-save-field.ts delete mode 100644 src/features/fields/ui/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index 062ec49..477c1c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,6 +172,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan. - Step 22 (Login UI + Vault Unlock) — complete - Step 23 (Non-Extractable Key Vault + Zustand Store Refactor) — complete - Step 24 (Supabase API Adapter) — complete +- Step 25 (Encrypted Field CRUD) — complete ### Implementation Notes diff --git a/docs/implementation-plan/06-phase-6-data.md b/docs/implementation-plan/06-phase-6-data.md index 8e7842f..cdd3fc8 100644 --- a/docs/implementation-plan/06-phase-6-data.md +++ b/docs/implementation-plan/06-phase-6-data.md @@ -28,29 +28,36 @@ --- -## Step 25 — Encrypted Field CRUD +## Step 25 — Encrypted Field CRUD ✅ **Goal:** Encrypt/decrypt all three field types (note, website, email) end-to-end. **Code:** - `src/features/fields/model/field-crypto.ts`: - - `encryptField(plaintext: string, fieldKey: Uint8Array): Promise` + - `encryptField(plaintext: string, fieldKey: CryptoKey, fieldName: FieldName): Promise` - Convert string to Uint8Array (TextEncoder) - Generate random IV - - Encrypt with AES-256-GCM using field key + - Encrypt with AES-256-GCM using non-extractable CryptoKey from KeyVault + - Bind ciphertext to field name + key version via AAD (prevents ciphertext swapping between fields) - Return `{ ciphertext: Uint8Array, iv: Uint8Array }` - - `decryptField(encryptedData: EncryptedFieldData, fieldKey: Uint8Array): Promise` - - Decrypt with AES-256-GCM + - `decryptField(encryptedData: EncryptedFieldData, fieldKey: CryptoKey, fieldName: FieldName): Promise` + - Decrypt with AES-256-GCM, reconstructing AAD from fieldName + version - Convert Uint8Array to string (TextDecoder) - Return plaintext string + - `toSaveFieldData(encryptedData)` — convert binary `EncryptedFieldData` to hex-string `SaveFieldData` for the API + - `toEncryptedFieldData(serverField)` — convert hex-string `ServerEncryptedField` from the API to binary `EncryptedFieldData` - `src/features/fields/model/field-service.ts`: - - `loadField(fieldName: string): Promise` — fetch from server, decrypt, return plaintext - - `saveField(fieldName: string, plaintext: string): Promise` — encrypt, save to server - - `loadAllFields(): Promise>` — load all three fields + - `FieldService` class (singleton `fieldService`) encapsulating auth + key vault access: + - `loadField(fieldName): Promise` — fetch from server, decrypt, return plaintext + - `saveField(fieldName, plaintext): Promise` — encrypt, save to server + - `loadAllFields(): Promise>` — load all three fields in parallel + - Gets user ID from auth store, gets CryptoKey from KeyVault (no separate hook needed) - Wire into TanStack Query hooks: - - `useField(fieldName)` — query + cache decrypted field content. **Must invalidate/purge this cache when vault is locked** (see Step 23). - - `useSaveField(fieldName)` — mutation for saving field content - - `useFieldKey(fieldName)` — get field key from crypto store (hex-decode from store before use) + - `useField(fieldName)` — query + cache decrypted field content, disabled while vault locked or field key not loaded + - `useSaveField(fieldName)` — mutation for saving field content, invalidates field query on success +- `src/shared/types/entities/field.types.ts`: + - `FieldName` type (`'note' | 'website' | 'email'`), `FIELD_NAMES` canonical list + - `EncryptedField` and `DecryptedField` interfaces for server/client representations **Tests:** - Unit: encrypt then decrypt returns original string diff --git a/src/app/layouts/PublicLayout.tsx b/src/app/layouts/PublicLayout.tsx index 157d6ca..18efec4 100644 --- a/src/app/layouts/PublicLayout.tsx +++ b/src/app/layouts/PublicLayout.tsx @@ -3,11 +3,11 @@ import { PublicHeader } from '@/shared/ui/nav/PublicHeader' function PublicLayout() { return ( -
+
-
+
diff --git a/src/features/auth/ui/.gitkeep b/src/features/auth/ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/encryption/ui/.gitkeep b/src/features/encryption/ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/fields/model/field-crypto.test.ts b/src/features/fields/model/field-crypto.test.ts new file mode 100644 index 0000000..3006b09 --- /dev/null +++ b/src/features/fields/model/field-crypto.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { importKey } from '@/shared/crypto/aes-gcm' +import { DecryptionError } from '@/shared/crypto/errors' +import { encryptField, decryptField, toSaveFieldData, toEncryptedFieldData } from '@/features/fields/model/field-crypto' +import type { FieldName } from '@/shared/types/entities/field.types' + +const generateKey = async () => await importKey(crypto.getRandomValues(new Uint8Array(32))) + +const FIELD_NAMES: FieldName[] = ['note', 'website', 'email'] + +describe('encryptField + decryptField', () => { + it('round-trips plaintext for all field names', async () => { + const key = await generateKey() + for (const fieldName of FIELD_NAMES) { + const plaintext = `Hello, ${fieldName}!` + const encrypted = await encryptField(plaintext, key, fieldName) + const decrypted = await decryptField(encrypted, key, fieldName) + expect(decrypted).toBe(plaintext) + } + }) + + it('round-trips an empty string', async () => { + const key = await generateKey() + const encrypted = await encryptField('', key, 'note') + const decrypted = await decryptField(encrypted, key, 'note') + expect(decrypted).toBe('') + }) + + it('round-trips a long string', async () => { + const key = await generateKey() + const plaintext = 'a'.repeat(10_000) + const encrypted = await encryptField(plaintext, key, 'note') + const decrypted = await decryptField(encrypted, key, 'note') + expect(decrypted).toBe(plaintext) + }) + + it('produces different ciphertexts for different IVs (randomness)', async () => { + const key = await generateKey() + const plaintext = 'same content' + const encrypted1 = await encryptField(plaintext, key, 'note') + const encrypted2 = await encryptField(plaintext, key, 'note') + // Random IV means ciphertext should differ each time + expect(encrypted1.ciphertext).not.toEqual(encrypted2.ciphertext) + }) + + it('produces different ciphertexts with different keys', async () => { + const key1 = await generateKey() + const key2 = await generateKey() + const plaintext = 'same content' + const encrypted1 = await encryptField(plaintext, key1, 'note') + const encrypted2 = await encryptField(plaintext, key2, 'note') + expect(encrypted1.ciphertext).not.toEqual(encrypted2.ciphertext) + }) + + it('throws DecryptionError with wrong key', async () => { + const key1 = await generateKey() + const key2 = await generateKey() + const encrypted = await encryptField('secret', key1, 'note') + await expect(decryptField(encrypted, key2, 'note')).rejects.toThrow(DecryptionError) + }) + + it('throws DecryptionError when field name mismatches (AAD binding)', async () => { + const key = await generateKey() + const encrypted = await encryptField('secret note', key, 'note') + // Attempt to decrypt as a different field — AAD won't match + await expect(decryptField(encrypted, key, 'website')).rejects.toThrow(DecryptionError) + }) +}) + +describe('toSaveFieldData + toEncryptedFieldData', () => { + it('round-trips through hex encoding/decoding', async () => { + const key = await generateKey() + const plaintext = 'test data for hex round-trip' + const encrypted = await encryptField(plaintext, key, 'note') + + // Internal binary → hex for API + const saveData = toSaveFieldData(encrypted) + expect(typeof saveData.encryptedBlob).toBe('string') + expect(typeof saveData.iv).toBe('string') + + // Hex from API → internal binary + const serverField = { + fieldName: 'note' as FieldName, + encryptedBlob: saveData.encryptedBlob, + iv: saveData.iv, + updatedAt: '2025-01-01T00:00:00Z', + } + const restored = toEncryptedFieldData(serverField) + expect(restored.ciphertext).toEqual(encrypted.ciphertext) + expect(restored.iv).toEqual(encrypted.iv) + + // Full round-trip: encrypt → toSaveFieldData → toEncryptedFieldData → decrypt + const decrypted = await decryptField(restored, key, 'note') + expect(decrypted).toBe(plaintext) + }) +}) diff --git a/src/features/fields/model/field-crypto.ts b/src/features/fields/model/field-crypto.ts new file mode 100644 index 0000000..553cdc2 --- /dev/null +++ b/src/features/fields/model/field-crypto.ts @@ -0,0 +1,56 @@ +import { decrypt, encrypt } from '@/shared/crypto/aes-gcm' +import { encodeAAD, generateIV, hexDecode, hexEncode } from '@/shared/crypto/crypto-utils' +import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types' +import type { EncryptedFieldData } from '@/shared/types/crypto.types' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { SaveFieldData, ServerEncryptedField } from '@/shared/types/api.types' + +/** + * Encrypt a plaintext field value using AES-256-GCM with the field's CryptoKey. + * + * AAD binds the ciphertext to the field name and key version, preventing ciphertext + * swapping between fields. A fresh random IV is generated each call. + */ +export async function encryptField( + plaintext: string, + fieldKey: CryptoKey, + fieldName: FieldName, +): Promise { + const plaintextBytes = new TextEncoder().encode(plaintext) as Uint8Array + const iv = generateIV() + const aad = encodeAAD(fieldName, FIELD_KEY_VERSION) + const ciphertext = await encrypt(plaintextBytes, fieldKey, { iv, aad }) + return { ciphertext, iv } +} + +/** + * Decrypt an encrypted field value back to a plaintext string. + * + * The AAD is reconstructed from fieldName + FIELD_KEY_VERSION, so the caller + * must pass the same field name used during encryption. + */ +export async function decryptField( + encryptedData: EncryptedFieldData, + fieldKey: CryptoKey, + fieldName: FieldName, +): Promise { + const aad = encodeAAD(fieldName, FIELD_KEY_VERSION) + const plaintextBytes = await decrypt(encryptedData.ciphertext, fieldKey, { iv: encryptedData.iv, aad }) + return new TextDecoder().decode(plaintextBytes) +} + +/** Convert internal binary EncryptedFieldData to hex-string SaveFieldData for the API. */ +export function toSaveFieldData(encryptedData: EncryptedFieldData): SaveFieldData { + return { + encryptedBlob: hexEncode(encryptedData.ciphertext), + iv: hexEncode(encryptedData.iv), + } +} + +/** Convert hex-string ServerEncryptedField from the API to binary EncryptedFieldData. */ +export function toEncryptedFieldData(serverField: ServerEncryptedField): EncryptedFieldData { + return { + ciphertext: hexDecode(serverField.encryptedBlob), + iv: hexDecode(serverField.iv), + } +} diff --git a/src/features/fields/model/field-service.test.ts b/src/features/fields/model/field-service.test.ts new file mode 100644 index 0000000..498ac0f --- /dev/null +++ b/src/features/fields/model/field-service.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { importKey } from '@/shared/crypto/aes-gcm' +import { encryptField, toSaveFieldData } from '@/features/fields/model/field-crypto' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { ServerEncryptedField, SaveFieldData } from '@/shared/types/api.types' + +// --- Hoisted mocks (must be declared before vi.mock factories that reference them) --- + +const { mockFetchField, mockSaveField, mockGetKey, mockUser, mockGetAuthState } = vi.hoisted(() => { + const mockUser = { id: 'user-123', username: 'testuser', createdAt: '2025-01-01T00:00:00Z' } + const mockGetAuthState = vi.fn(() => ({ + user: mockUser, + session: { accessToken: 'test-token', expiresAt: Date.now() + 3600000 }, + isLoading: false, + isRestoringSession: false, + setLoading: vi.fn(), + setAuth: vi.fn(), + setRestoringSession: vi.fn(), + reset: vi.fn(), + setUser: vi.fn(), + setSession: vi.fn(), + })) + + return { + mockFetchField: vi.fn<(...args: [string, string]) => Promise>(), + mockSaveField: vi.fn<(userId: string, fieldName: string, data: SaveFieldData) => Promise>(), + mockGetKey: vi.fn<(id: string) => CryptoKey | undefined>(), + mockUser, + mockGetAuthState, + } +}) + +vi.mock('@/shared/api/supabase-fields', () => ({ + fetchField: mockFetchField, + saveField: mockSaveField, +})) + +vi.mock('@/features/encryption/model/key-vault', () => ({ + keyVault: { getKey: mockGetKey }, +})) + +vi.mock('@/features/auth/model/auth-store', () => ({ + useAuthStore: { + getState: mockGetAuthState, + setState: vi.fn(), + }, +})) + +// --- Import after mocks --- + +import { fieldService } from '@/features/fields/model/field-service' + +async function generateTestKey(): Promise { + return importKey(crypto.getRandomValues(new Uint8Array(32))) +} + +/** Encrypt plaintext and return a ServerEncryptedField mock with proper hex-encoded data. */ +async function encryptForServer( + plaintext: string, + fieldKey: CryptoKey, + fieldName: FieldName, +): Promise { + const encrypted = await encryptField(plaintext, fieldKey, fieldName) + const saveData = toSaveFieldData(encrypted) + return { + fieldName, + encryptedBlob: saveData.encryptedBlob, + iv: saveData.iv, + updatedAt: '2025-01-01T00:00:00Z', + } +} + +describe('FieldService', () => { + let testKey: CryptoKey + + beforeEach(async () => { + vi.clearAllMocks() + // Restore the default auth state after clearAllMocks + mockGetAuthState.mockReturnValue({ + user: mockUser, + session: { accessToken: 'test-token', expiresAt: Date.now() + 3600000 }, + isLoading: false, + isRestoringSession: false, + setLoading: vi.fn(), + setAuth: vi.fn(), + setRestoringSession: vi.fn(), + reset: vi.fn(), + setUser: vi.fn(), + setSession: vi.fn(), + }) + testKey = await generateTestKey() + mockGetKey.mockReturnValue(testKey) + }) + + describe('loadField', () => { + it('returns null when server returns null (field never saved)', async () => { + mockFetchField.mockResolvedValue(null) + const result = await fieldService.loadField('note') + expect(result).toBeNull() + expect(mockFetchField).toHaveBeenCalledWith('user-123', 'note') + }) + + it('decrypts and returns plaintext when server returns field data', async () => { + const plaintext = 'Hello, world!' + const serverField = await encryptForServer(plaintext, testKey, 'note') + mockFetchField.mockResolvedValue(serverField) + + const result = await fieldService.loadField('note') + expect(result).toBe(plaintext) + }) + + it('throws when field key is not available (vault locked)', async () => { + mockGetKey.mockReturnValue(undefined) + await expect(fieldService.loadField('note')).rejects.toThrow('Field key not available for "note"') + }) + + it('throws when user is not authenticated', async () => { + mockGetAuthState.mockReturnValue({ + user: null as unknown as typeof mockUser, + session: null as unknown as { accessToken: string; expiresAt: number }, + isLoading: false, + isRestoringSession: false, + setLoading: vi.fn(), + setAuth: vi.fn(), + setRestoringSession: vi.fn(), + reset: vi.fn(), + setUser: vi.fn(), + setSession: vi.fn(), + }) + + await expect(fieldService.loadField('note')).rejects.toThrow('Not authenticated') + }) + }) + + describe('saveField', () => { + it('encrypts plaintext and calls saveFieldToServer with hex-encoded data', async () => { + mockSaveField.mockResolvedValue(undefined) + + await fieldService.saveField('note', 'My secret note') + + expect(mockSaveField).toHaveBeenCalledWith( + 'user-123', + 'note', + expect.objectContaining({ + encryptedBlob: expect.any(String), + iv: expect.any(String), + }), + ) + const saveData = mockSaveField.mock.calls[0][2] + // Hex strings should contain only hex chars + expect(saveData.encryptedBlob).toMatch(/^[0-9a-f]+$/) + expect(saveData.iv).toMatch(/^[0-9a-f]+$/) + }) + + it('throws when field key is not available (vault locked)', async () => { + mockGetKey.mockReturnValue(undefined) + await expect(fieldService.saveField('note', 'test')).rejects.toThrow('Field key not available for "note"') + }) + }) + + describe('loadAllFields', () => { + it('loads all three fields in parallel and returns null for unsaved fields', async () => { + mockFetchField.mockResolvedValue(null) + const result = await fieldService.loadAllFields() + expect(result).toEqual({ note: null, website: null, email: null }) + expect(mockFetchField).toHaveBeenCalledTimes(3) + }) + + it('loads all three fields with mixed data', async () => { + const noteKey = await generateTestKey() + const websiteKey = await generateTestKey() + const emailKey = await generateTestKey() + + mockGetKey.mockImplementation((id: string) => { + if (id === 'note') return noteKey + if (id === 'website') return websiteKey + if (id === 'email') return emailKey + return undefined + }) + + const noteServerField = await encryptForServer('My note', noteKey, 'note') + const websiteServerField = await encryptForServer('https://example.com', websiteKey, 'website') + + mockFetchField.mockImplementation(async (_userId: string, fieldName: string) => { + if (fieldName === 'note') return noteServerField + if (fieldName === 'website') return websiteServerField + return null // email never saved + }) + + const result = await fieldService.loadAllFields() + expect(result.note).toBe('My note') + expect(result.website).toBe('https://example.com') + expect(result.email).toBeNull() + }) + }) +}) diff --git a/src/features/fields/model/field-service.ts b/src/features/fields/model/field-service.ts new file mode 100644 index 0000000..fa43a08 --- /dev/null +++ b/src/features/fields/model/field-service.ts @@ -0,0 +1,59 @@ +import { keyVault } from '@/features/encryption/model/key-vault' +import { useAuthStore } from '@/features/auth/model/auth-store' +import { fetchField, saveField as saveFieldToServer } from '@/shared/api/supabase-fields' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' +import type { FieldName } from '@/shared/types/entities/field.types' +import { encryptField, decryptField, toEncryptedFieldData, toSaveFieldData } from '@/features/fields/model/field-crypto' + +class FieldService { + private getUserId(): string { + const user = useAuthStore.getState().user + if (!user) throw new Error('Not authenticated') + return user.id + } + + private getFieldKey(fieldName: FieldName): CryptoKey { + const key = keyVault.getKey(fieldName) + if (!key) throw new Error(`Field key not available for "${fieldName}" — vault may be locked`) + return key + } + + /** + * Load and decrypt a single field's content from the server. + * Returns null if the field has never been saved. + */ + async loadField(fieldName: FieldName): Promise { + const userId = this.getUserId() + const fieldKey = this.getFieldKey(fieldName) + + const serverField = await fetchField(userId, fieldName) + if (!serverField) return null + + const encryptedData = toEncryptedFieldData(serverField) + return decryptField(encryptedData, fieldKey, fieldName) + } + + /** + * Encrypt and save a field's content to the server. + * Uses upsert — will create or update the field. + */ + async saveField(fieldName: FieldName, plaintext: string): Promise { + const userId = this.getUserId() + const fieldKey = this.getFieldKey(fieldName) + + const encryptedData = await encryptField(plaintext, fieldKey, fieldName) + const saveData = toSaveFieldData(encryptedData) + await saveFieldToServer(userId, fieldName, saveData) + } + + /** + * Load and decrypt all three fields (note, website, email) in parallel. + * Returns a Record mapping field names to their plaintext content (or null if never saved). + */ + async loadAllFields(): Promise> { + const results = await Promise.all(FIELD_NAMES.map(async (name) => [name, await this.loadField(name)] as const)) + return Object.fromEntries(results) + } +} + +export const fieldService = new FieldService() diff --git a/src/features/fields/model/use-field.test.ts b/src/features/fields/model/use-field.test.ts new file mode 100644 index 0000000..6187611 --- /dev/null +++ b/src/features/fields/model/use-field.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement, type ReactNode } from 'react' +import { useCryptoStore } from '@/features/encryption/model/crypto-store' + +// --- Hoisted mocks --- + +const { mockLoadField } = vi.hoisted(() => ({ + mockLoadField: vi.fn<(fieldName: string) => Promise>(), +})) + +vi.mock('@/features/fields/model/field-service', () => ({ + fieldService: { loadField: mockLoadField }, +})) + +// --- Import after mocks --- + +import { useField } from '@/features/fields/model/use-field' + +const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, +}) + +function wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: testQueryClient }, children) +} + +afterEach(() => { + testQueryClient.clear() +}) + +describe('useField', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: vault unlocked with keys loaded + useCryptoStore.setState({ + loadedFieldKeys: { note: true, website: true, email: true }, + isVaultLocked: false, + lastActivity: Date.now(), + cachedEnvelope: null, + }) + mockLoadField.mockResolvedValue('test content') + }) + + it('fetches and returns decrypted field content when vault is unlocked', async () => { + mockLoadField.mockResolvedValue('My note content') + + const { result } = renderHook(() => useField('note'), { wrapper }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(result.current.data).toBe('My note content') + expect(mockLoadField).toHaveBeenCalledWith('note') + }) + + it('returns null when field has never been saved', async () => { + mockLoadField.mockResolvedValue(null) + + const { result } = renderHook(() => useField('note'), { wrapper }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(result.current.data).toBeNull() + }) + + it('is disabled when vault is locked', () => { + useCryptoStore.setState({ isVaultLocked: true, loadedFieldKeys: {} }) + + const { result } = renderHook(() => useField('note'), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(mockLoadField).not.toHaveBeenCalled() + }) + + it('is disabled when field key is not loaded', () => { + useCryptoStore.setState({ loadedFieldKeys: {} }) + + const { result } = renderHook(() => useField('note'), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(mockLoadField).not.toHaveBeenCalled() + }) + + it('is enabled when vault is unlocked and field key is loaded', async () => { + mockLoadField.mockResolvedValue('hello') + + const { result } = renderHook(() => useField('note'), { wrapper }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(result.current.data).toBe('hello') + }) + + it('sets error state when loadField throws', async () => { + mockLoadField.mockRejectedValue(new Error('Network error')) + + const { result } = renderHook(() => useField('note'), { wrapper }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe('Network error') + }) +}) diff --git a/src/features/fields/model/use-field.ts b/src/features/fields/model/use-field.ts new file mode 100644 index 0000000..379e218 --- /dev/null +++ b/src/features/fields/model/use-field.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query' +import { useCryptoStore } from '@/features/encryption/model/crypto-store' +import { fieldService } from '@/features/fields/model/field-service' +import type { FieldName } from '@/shared/types/entities/field.types' + +/** + * Load and decrypt a single field's content. + * + * The query is disabled while the vault is locked or the field key is not loaded. + * When the vault locks, crypto-store purges all ['field'] queries from the cache, + * so the next unlock triggers a fresh fetch. + */ +export function useField(fieldName: FieldName) { + const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) + const hasFieldKey = useCryptoStore((s) => s.loadedFieldKeys[fieldName] === true) + + return useQuery({ + queryKey: ['field', fieldName], + queryFn: () => fieldService.loadField(fieldName), + enabled: !isVaultLocked && hasFieldKey, + }) +} diff --git a/src/features/fields/model/use-save-field.test.ts b/src/features/fields/model/use-save-field.test.ts new file mode 100644 index 0000000..7b8b11d --- /dev/null +++ b/src/features/fields/model/use-save-field.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement, type ReactNode } from 'react' + +// --- Hoisted mocks --- + +const { mockSaveField } = vi.hoisted(() => ({ + mockSaveField: vi.fn<(fieldName: string, plaintext: string) => Promise>(), +})) + +vi.mock('@/features/fields/model/field-service', () => ({ + fieldService: { saveField: mockSaveField }, +})) + +// --- Import after mocks --- + +import { useSaveField } from '@/features/fields/model/use-save-field' + +const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, +}) + +function wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: testQueryClient }, children) +} + +afterEach(() => { + testQueryClient.clear() +}) + +describe('useSaveField', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSaveField.mockResolvedValue(undefined) + }) + + it('calls fieldService.saveField with the field name and plaintext on mutate', async () => { + const { result } = renderHook(() => useSaveField('note'), { wrapper }) + + result.current.mutate('My note content') + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(mockSaveField).toHaveBeenCalledWith('note', 'My note content') + }) + + it('invalidates the field query on success', async () => { + const invalidateSpy = vi.spyOn(testQueryClient, 'invalidateQueries') + + const { result } = renderHook(() => useSaveField('note'), { wrapper }) + + result.current.mutate('My note content') + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['field', 'note'] }) + }) + + it('sets error state when saveField throws', async () => { + mockSaveField.mockRejectedValue(new Error('Save failed')) + + const { result } = renderHook(() => useSaveField('note'), { wrapper }) + + result.current.mutate('test') + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe('Save failed') + }) +}) diff --git a/src/features/fields/model/use-save-field.ts b/src/features/fields/model/use-save-field.ts new file mode 100644 index 0000000..2346add --- /dev/null +++ b/src/features/fields/model/use-save-field.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { fieldService } from '@/features/fields/model/field-service' +import type { FieldName } from '@/shared/types/entities/field.types' + +/** + * Save (encrypt + upload) a field's content. + * + * On success, invalidates the field query so the next read reflects + * the confirmed server state. + */ +export function useSaveField(fieldName: FieldName) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (plaintext: string) => fieldService.saveField(fieldName, plaintext), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['field', fieldName] }) + }, + }) +} diff --git a/src/features/fields/ui/.gitkeep b/src/features/fields/ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/fields/ui/DashboardPage.tsx b/src/features/fields/ui/DashboardPage.tsx index 7400094..116c230 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -6,11 +6,10 @@ import { FieldCard } from '@/features/fields/ui/FieldCard' import { NoteField } from '@/features/fields/ui/NoteField' import { WebsiteField } from '@/features/fields/ui/WebsiteField' import { EmailField } from '@/features/fields/ui/EmailField' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' import type { FieldName } from '@/shared/types/entities/field.types' import type { ReactNode } from 'react' -const FIELD_NAMES: FieldName[] = ['note', 'website', 'email'] - function getFieldEditor(fieldName: FieldName): ReactNode { switch (fieldName) { case 'note': diff --git a/src/features/landing/ui/HeroSection.tsx b/src/features/landing/ui/HeroSection.tsx index cf2a28d..6fee0c7 100644 --- a/src/features/landing/ui/HeroSection.tsx +++ b/src/features/landing/ui/HeroSection.tsx @@ -7,7 +7,7 @@ function HeroSection() { const { t } = useTranslation('landing') return ( -
+
diff --git a/src/features/landing/ui/LandingPage.tsx b/src/features/landing/ui/LandingPage.tsx index 50d8c1d..6e86834 100644 --- a/src/features/landing/ui/LandingPage.tsx +++ b/src/features/landing/ui/LandingPage.tsx @@ -10,7 +10,7 @@ function LandingPage() { const { t } = useTranslation('landing') return ( -
+
diff --git a/src/shared/types/entities/field.types.ts b/src/shared/types/entities/field.types.ts index 5d7bba6..bb50724 100644 --- a/src/shared/types/entities/field.types.ts +++ b/src/shared/types/entities/field.types.ts @@ -1,6 +1,9 @@ /** The three encrypted fields each user owns. */ export type FieldName = 'note' | 'website' | 'email' +/** Canonical list of all field names — single source of truth. */ +export const FIELD_NAMES: readonly FieldName[] = ['note', 'website', 'email'] as const + /** A field as stored on the server — ciphertext, IV, and metadata. */ export interface EncryptedField { fieldName: FieldName From f58fd7c9097c25d9df3cf4dea15efa31d9949109 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 2 Jun 2026 10:49:36 +0200 Subject: [PATCH 2/4] fix: add smart retry for useField, and correct JSDoc --- src/features/fields/model/field-service.ts | 4 ++-- src/features/fields/model/use-field.test.ts | 25 +++++++++++++++++---- src/features/fields/model/use-field.ts | 10 ++++++--- src/shared/types/entities/field.types.ts | 4 ++-- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/features/fields/model/field-service.ts b/src/features/fields/model/field-service.ts index fa43a08..08f78fe 100644 --- a/src/features/fields/model/field-service.ts +++ b/src/features/fields/model/field-service.ts @@ -50,9 +50,9 @@ class FieldService { * Load and decrypt all three fields (note, website, email) in parallel. * Returns a Record mapping field names to their plaintext content (or null if never saved). */ - async loadAllFields(): Promise> { + async loadAllFields(): Promise> { const results = await Promise.all(FIELD_NAMES.map(async (name) => [name, await this.loadField(name)] as const)) - return Object.fromEntries(results) + return Object.fromEntries(results) as Record } } diff --git a/src/features/fields/model/use-field.test.ts b/src/features/fields/model/use-field.test.ts index 6187611..e31209b 100644 --- a/src/features/fields/model/use-field.test.ts +++ b/src/features/fields/model/use-field.test.ts @@ -98,15 +98,32 @@ describe('useField', () => { expect(result.current.data).toBe('hello') }) - it('sets error state when loadField throws', async () => { - mockLoadField.mockRejectedValue(new Error('Network error')) + it('sets error state immediately for DecryptionError (no retry)', async () => { + const { DecryptionError } = await import('@/shared/crypto/errors') + mockLoadField.mockRejectedValue(new DecryptionError('Decryption failed')) const { result } = renderHook(() => useField('note'), { wrapper }) await waitFor(() => { expect(result.current.isError).toBe(true) }) - expect(result.current.error).toBeInstanceOf(Error) - expect(result.current.error?.message).toBe('Network error') + expect(result.current.error).toBeInstanceOf(DecryptionError) + expect(mockLoadField).toHaveBeenCalledTimes(1) // no retry for crypto errors + }) + + it('retries on non-crypto errors before failing', async () => { + mockLoadField.mockRejectedValue(new Error('Network error')) + + const { result } = renderHook(() => useField('note'), { wrapper }) + + // Allow time for retries with backoff (1s + 2s = ~3s total) + await waitFor( + () => { + expect(result.current.isError).toBe(true) + }, + { timeout: 5000 }, + ) + // retry: (failureCount, error) => failureCount < 2 → 3 total attempts + expect(mockLoadField).toHaveBeenCalledTimes(3) }) }) diff --git a/src/features/fields/model/use-field.ts b/src/features/fields/model/use-field.ts index 379e218..ea3073d 100644 --- a/src/features/fields/model/use-field.ts +++ b/src/features/fields/model/use-field.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { DecryptionError } from '@/shared/crypto/errors' import { useCryptoStore } from '@/features/encryption/model/crypto-store' import { fieldService } from '@/features/fields/model/field-service' import type { FieldName } from '@/shared/types/entities/field.types' @@ -6,9 +7,8 @@ import type { FieldName } from '@/shared/types/entities/field.types' /** * Load and decrypt a single field's content. * - * The query is disabled while the vault is locked or the field key is not loaded. - * When the vault locks, crypto-store purges all ['field'] queries from the cache, - * so the next unlock triggers a fresh fetch. + * The query is disabled while vault is locked or field key missing. On lock, + * crypto-store purges field queries so unlock triggers a fresh fetch. */ export function useField(fieldName: FieldName) { const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) @@ -18,5 +18,9 @@ export function useField(fieldName: FieldName) { queryKey: ['field', fieldName], queryFn: () => fieldService.loadField(fieldName), enabled: !isVaultLocked && hasFieldKey, + retry: (failureCount, error) => { + if (error instanceof DecryptionError) return false + return failureCount < 2 + }, }) } diff --git a/src/shared/types/entities/field.types.ts b/src/shared/types/entities/field.types.ts index bb50724..5aed1ae 100644 --- a/src/shared/types/entities/field.types.ts +++ b/src/shared/types/entities/field.types.ts @@ -7,9 +7,9 @@ export const FIELD_NAMES: readonly FieldName[] = ['note', 'website', 'email'] as /** A field as stored on the server — ciphertext, IV, and metadata. */ export interface EncryptedField { fieldName: FieldName - /** Base64-encoded AES-GCM ciphertext. */ + /** Hex-encoded AES-GCM ciphertext. */ encryptedBlob: string - /** Base64-encoded initialization vector for AES-GCM. */ + /** Hex-encoded initialization vector for AES-GCM. */ iv: string updatedAt: string } From 138b03af349dbf34c23a8ab3c3923fdc94ee3de4 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 2 Jun 2026 11:51:21 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20resolve=20dependency=20direction=20v?= =?UTF-8?q?iolations=20(shared=E2=86=92features,=20feature=E2=86=92feature?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fields/model/field-service.test.ts | 91 ++++++------------- src/features/fields/model/field-service.ts | 24 ++--- src/features/fields/model/use-field.test.ts | 24 ++++- src/features/fields/model/use-field.ts | 8 +- .../fields/model/use-save-field.test.ts | 10 +- src/features/fields/model/use-save-field.ts | 4 +- 6 files changed, 71 insertions(+), 90 deletions(-) diff --git a/src/features/fields/model/field-service.test.ts b/src/features/fields/model/field-service.test.ts index 498ac0f..3a8429d 100644 --- a/src/features/fields/model/field-service.test.ts +++ b/src/features/fields/model/field-service.test.ts @@ -6,27 +6,11 @@ import type { ServerEncryptedField, SaveFieldData } from '@/shared/types/api.typ // --- Hoisted mocks (must be declared before vi.mock factories that reference them) --- -const { mockFetchField, mockSaveField, mockGetKey, mockUser, mockGetAuthState } = vi.hoisted(() => { - const mockUser = { id: 'user-123', username: 'testuser', createdAt: '2025-01-01T00:00:00Z' } - const mockGetAuthState = vi.fn(() => ({ - user: mockUser, - session: { accessToken: 'test-token', expiresAt: Date.now() + 3600000 }, - isLoading: false, - isRestoringSession: false, - setLoading: vi.fn(), - setAuth: vi.fn(), - setRestoringSession: vi.fn(), - reset: vi.fn(), - setUser: vi.fn(), - setSession: vi.fn(), - })) - +const { mockFetchField, mockSaveField, mockGetKey } = vi.hoisted(() => { return { mockFetchField: vi.fn<(...args: [string, string]) => Promise>(), mockSaveField: vi.fn<(userId: string, fieldName: string, data: SaveFieldData) => Promise>(), mockGetKey: vi.fn<(id: string) => CryptoKey | undefined>(), - mockUser, - mockGetAuthState, } }) @@ -35,17 +19,10 @@ vi.mock('@/shared/api/supabase-fields', () => ({ saveField: mockSaveField, })) -vi.mock('@/features/encryption/model/key-vault', () => ({ +vi.mock('@/shared/crypto/key-vault', () => ({ keyVault: { getKey: mockGetKey }, })) -vi.mock('@/features/auth/model/auth-store', () => ({ - useAuthStore: { - getState: mockGetAuthState, - setState: vi.fn(), - }, -})) - // --- Import after mocks --- import { fieldService } from '@/features/fields/model/field-service' @@ -70,34 +47,27 @@ async function encryptForServer( } } +const TEST_USER_ID = 'user-123' + describe('FieldService', () => { let testKey: CryptoKey beforeEach(async () => { vi.clearAllMocks() - // Restore the default auth state after clearAllMocks - mockGetAuthState.mockReturnValue({ - user: mockUser, - session: { accessToken: 'test-token', expiresAt: Date.now() + 3600000 }, - isLoading: false, - isRestoringSession: false, - setLoading: vi.fn(), - setAuth: vi.fn(), - setRestoringSession: vi.fn(), - reset: vi.fn(), - setUser: vi.fn(), - setSession: vi.fn(), - }) testKey = await generateTestKey() mockGetKey.mockReturnValue(testKey) }) describe('loadField', () => { + it('throws when userId is empty', async () => { + await expect(fieldService.loadField('', 'note')).rejects.toThrow('userId is required') + }) + it('returns null when server returns null (field never saved)', async () => { mockFetchField.mockResolvedValue(null) - const result = await fieldService.loadField('note') + const result = await fieldService.loadField(TEST_USER_ID, 'note') expect(result).toBeNull() - expect(mockFetchField).toHaveBeenCalledWith('user-123', 'note') + expect(mockFetchField).toHaveBeenCalledWith(TEST_USER_ID, 'note') }) it('decrypts and returns plaintext when server returns field data', async () => { @@ -105,41 +75,28 @@ describe('FieldService', () => { const serverField = await encryptForServer(plaintext, testKey, 'note') mockFetchField.mockResolvedValue(serverField) - const result = await fieldService.loadField('note') + const result = await fieldService.loadField(TEST_USER_ID, 'note') expect(result).toBe(plaintext) }) it('throws when field key is not available (vault locked)', async () => { mockGetKey.mockReturnValue(undefined) - await expect(fieldService.loadField('note')).rejects.toThrow('Field key not available for "note"') - }) - - it('throws when user is not authenticated', async () => { - mockGetAuthState.mockReturnValue({ - user: null as unknown as typeof mockUser, - session: null as unknown as { accessToken: string; expiresAt: number }, - isLoading: false, - isRestoringSession: false, - setLoading: vi.fn(), - setAuth: vi.fn(), - setRestoringSession: vi.fn(), - reset: vi.fn(), - setUser: vi.fn(), - setSession: vi.fn(), - }) - - await expect(fieldService.loadField('note')).rejects.toThrow('Not authenticated') + await expect(fieldService.loadField(TEST_USER_ID, 'note')).rejects.toThrow('Field key not available for "note"') }) }) describe('saveField', () => { + it('throws when userId is empty', async () => { + await expect(fieldService.saveField('', 'note', 'test')).rejects.toThrow('userId is required') + }) + it('encrypts plaintext and calls saveFieldToServer with hex-encoded data', async () => { mockSaveField.mockResolvedValue(undefined) - await fieldService.saveField('note', 'My secret note') + await fieldService.saveField(TEST_USER_ID, 'note', 'My secret note') expect(mockSaveField).toHaveBeenCalledWith( - 'user-123', + TEST_USER_ID, 'note', expect.objectContaining({ encryptedBlob: expect.any(String), @@ -154,14 +111,20 @@ describe('FieldService', () => { it('throws when field key is not available (vault locked)', async () => { mockGetKey.mockReturnValue(undefined) - await expect(fieldService.saveField('note', 'test')).rejects.toThrow('Field key not available for "note"') + await expect(fieldService.saveField(TEST_USER_ID, 'note', 'test')).rejects.toThrow( + 'Field key not available for "note"', + ) }) }) describe('loadAllFields', () => { + it('throws when userId is empty', async () => { + await expect(fieldService.loadAllFields('')).rejects.toThrow('userId is required') + }) + it('loads all three fields in parallel and returns null for unsaved fields', async () => { mockFetchField.mockResolvedValue(null) - const result = await fieldService.loadAllFields() + const result = await fieldService.loadAllFields(TEST_USER_ID) expect(result).toEqual({ note: null, website: null, email: null }) expect(mockFetchField).toHaveBeenCalledTimes(3) }) @@ -187,7 +150,7 @@ describe('FieldService', () => { return null // email never saved }) - const result = await fieldService.loadAllFields() + const result = await fieldService.loadAllFields(TEST_USER_ID) expect(result.note).toBe('My note') expect(result.website).toBe('https://example.com') expect(result.email).toBeNull() diff --git a/src/features/fields/model/field-service.ts b/src/features/fields/model/field-service.ts index 08f78fe..e286e09 100644 --- a/src/features/fields/model/field-service.ts +++ b/src/features/fields/model/field-service.ts @@ -1,17 +1,10 @@ -import { keyVault } from '@/features/encryption/model/key-vault' -import { useAuthStore } from '@/features/auth/model/auth-store' +import { keyVault } from '@/shared/crypto/key-vault' import { fetchField, saveField as saveFieldToServer } from '@/shared/api/supabase-fields' import { FIELD_NAMES } from '@/shared/types/entities/field.types' import type { FieldName } from '@/shared/types/entities/field.types' import { encryptField, decryptField, toEncryptedFieldData, toSaveFieldData } from '@/features/fields/model/field-crypto' class FieldService { - private getUserId(): string { - const user = useAuthStore.getState().user - if (!user) throw new Error('Not authenticated') - return user.id - } - private getFieldKey(fieldName: FieldName): CryptoKey { const key = keyVault.getKey(fieldName) if (!key) throw new Error(`Field key not available for "${fieldName}" — vault may be locked`) @@ -22,8 +15,8 @@ class FieldService { * Load and decrypt a single field's content from the server. * Returns null if the field has never been saved. */ - async loadField(fieldName: FieldName): Promise { - const userId = this.getUserId() + async loadField(userId: string, fieldName: FieldName): Promise { + if (!userId) throw new Error('userId is required') const fieldKey = this.getFieldKey(fieldName) const serverField = await fetchField(userId, fieldName) @@ -37,8 +30,8 @@ class FieldService { * Encrypt and save a field's content to the server. * Uses upsert — will create or update the field. */ - async saveField(fieldName: FieldName, plaintext: string): Promise { - const userId = this.getUserId() + async saveField(userId: string, fieldName: FieldName, plaintext: string): Promise { + if (!userId) throw new Error('userId is required') const fieldKey = this.getFieldKey(fieldName) const encryptedData = await encryptField(plaintext, fieldKey, fieldName) @@ -50,8 +43,11 @@ class FieldService { * Load and decrypt all three fields (note, website, email) in parallel. * Returns a Record mapping field names to their plaintext content (or null if never saved). */ - async loadAllFields(): Promise> { - const results = await Promise.all(FIELD_NAMES.map(async (name) => [name, await this.loadField(name)] as const)) + async loadAllFields(userId: string): Promise> { + if (!userId) throw new Error('userId is required') + const results = await Promise.all( + FIELD_NAMES.map(async (name) => [name, await this.loadField(userId, name)] as const), + ) return Object.fromEntries(results) as Record } } diff --git a/src/features/fields/model/use-field.test.ts b/src/features/fields/model/use-field.test.ts index e31209b..f3c39d5 100644 --- a/src/features/fields/model/use-field.test.ts +++ b/src/features/fields/model/use-field.test.ts @@ -2,18 +2,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createElement, type ReactNode } from 'react' -import { useCryptoStore } from '@/features/encryption/model/crypto-store' +import { useCryptoStore } from '@/shared/crypto/crypto-store' // --- Hoisted mocks --- -const { mockLoadField } = vi.hoisted(() => ({ - mockLoadField: vi.fn<(fieldName: string) => Promise>(), +const { mockLoadField, mockUseAuth } = vi.hoisted(() => ({ + mockLoadField: vi.fn<(userId: string, fieldName: string) => Promise>(), + mockUseAuth: vi.fn<() => { user: { id: string; username: string } | null }>(), })) vi.mock('@/features/fields/model/field-service', () => ({ fieldService: { loadField: mockLoadField }, })) +vi.mock('@/shared/auth/auth-context', () => ({ + useAuth: mockUseAuth, +})) + // --- Import after mocks --- import { useField } from '@/features/fields/model/use-field' @@ -38,7 +43,8 @@ afterEach(() => { describe('useField', () => { beforeEach(() => { vi.clearAllMocks() - // Default: vault unlocked with keys loaded + // Default: authenticated user, vault unlocked with keys loaded + mockUseAuth.mockReturnValue({ user: { id: 'user-123', username: 'testuser' } }) useCryptoStore.setState({ loadedFieldKeys: { note: true, website: true, email: true }, isVaultLocked: false, @@ -57,7 +63,7 @@ describe('useField', () => { expect(result.current.isSuccess).toBe(true) }) expect(result.current.data).toBe('My note content') - expect(mockLoadField).toHaveBeenCalledWith('note') + expect(mockLoadField).toHaveBeenCalledWith('user-123', 'note') }) it('returns null when field has never been saved', async () => { @@ -71,6 +77,14 @@ describe('useField', () => { expect(result.current.data).toBeNull() }) + it('is disabled when userId is empty (no authenticated user)', () => { + mockUseAuth.mockReturnValue({ user: null }) + + const { result } = renderHook(() => useField('note'), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(mockLoadField).not.toHaveBeenCalled() + }) + it('is disabled when vault is locked', () => { useCryptoStore.setState({ isVaultLocked: true, loadedFieldKeys: {} }) diff --git a/src/features/fields/model/use-field.ts b/src/features/fields/model/use-field.ts index ea3073d..ac5a519 100644 --- a/src/features/fields/model/use-field.ts +++ b/src/features/fields/model/use-field.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { DecryptionError } from '@/shared/crypto/errors' -import { useCryptoStore } from '@/features/encryption/model/crypto-store' +import { useCryptoStore } from '@/shared/crypto/crypto-store' +import { useAuth } from '@/shared/auth/auth-context' import { fieldService } from '@/features/fields/model/field-service' import type { FieldName } from '@/shared/types/entities/field.types' @@ -13,11 +14,12 @@ import type { FieldName } from '@/shared/types/entities/field.types' export function useField(fieldName: FieldName) { const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) const hasFieldKey = useCryptoStore((s) => s.loadedFieldKeys[fieldName] === true) + const userId = useAuth().user?.id ?? '' return useQuery({ queryKey: ['field', fieldName], - queryFn: () => fieldService.loadField(fieldName), - enabled: !isVaultLocked && hasFieldKey, + queryFn: () => fieldService.loadField(userId, fieldName), + enabled: !isVaultLocked && hasFieldKey && !!userId, retry: (failureCount, error) => { if (error instanceof DecryptionError) return false return failureCount < 2 diff --git a/src/features/fields/model/use-save-field.test.ts b/src/features/fields/model/use-save-field.test.ts index 7b8b11d..f01feac 100644 --- a/src/features/fields/model/use-save-field.test.ts +++ b/src/features/fields/model/use-save-field.test.ts @@ -6,13 +6,17 @@ import { createElement, type ReactNode } from 'react' // --- Hoisted mocks --- const { mockSaveField } = vi.hoisted(() => ({ - mockSaveField: vi.fn<(fieldName: string, plaintext: string) => Promise>(), + mockSaveField: vi.fn<(userId: string, fieldName: string, plaintext: string) => Promise>(), })) vi.mock('@/features/fields/model/field-service', () => ({ fieldService: { saveField: mockSaveField }, })) +vi.mock('@/shared/auth/auth-context', () => ({ + useAuth: () => ({ user: { id: 'user-123', username: 'testuser' } }), +})) + // --- Import after mocks --- import { useSaveField } from '@/features/fields/model/use-save-field' @@ -40,7 +44,7 @@ describe('useSaveField', () => { mockSaveField.mockResolvedValue(undefined) }) - it('calls fieldService.saveField with the field name and plaintext on mutate', async () => { + it('calls fieldService.saveField with userId, field name and plaintext on mutate', async () => { const { result } = renderHook(() => useSaveField('note'), { wrapper }) result.current.mutate('My note content') @@ -48,7 +52,7 @@ describe('useSaveField', () => { await waitFor(() => { expect(result.current.isSuccess).toBe(true) }) - expect(mockSaveField).toHaveBeenCalledWith('note', 'My note content') + expect(mockSaveField).toHaveBeenCalledWith('user-123', 'note', 'My note content') }) it('invalidates the field query on success', async () => { diff --git a/src/features/fields/model/use-save-field.ts b/src/features/fields/model/use-save-field.ts index 2346add..78e1f17 100644 --- a/src/features/fields/model/use-save-field.ts +++ b/src/features/fields/model/use-save-field.ts @@ -1,4 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useAuth } from '@/shared/auth/auth-context' import { fieldService } from '@/features/fields/model/field-service' import type { FieldName } from '@/shared/types/entities/field.types' @@ -10,9 +11,10 @@ import type { FieldName } from '@/shared/types/entities/field.types' */ export function useSaveField(fieldName: FieldName) { const queryClient = useQueryClient() + const userId = useAuth().user?.id ?? '' return useMutation({ - mutationFn: (plaintext: string) => fieldService.saveField(fieldName, plaintext), + mutationFn: (plaintext: string) => fieldService.saveField(userId, fieldName, plaintext), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['field', fieldName] }) }, From da7a40555b9fe8abd84a4b8b467af0c9cdd8c613 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 2 Jun 2026 13:16:06 +0200 Subject: [PATCH 4/4] feat: add optimistic updates, fault-isolated field loading, and stronger typing --- .../fields/model/field-crypto.test.ts | 3 +- .../fields/model/field-service.test.ts | 21 +++++ src/features/fields/model/field-service.ts | 21 +++-- src/features/fields/model/use-field.ts | 5 +- .../fields/model/use-save-field.test.ts | 92 +++++++++++++++---- src/features/fields/model/use-save-field.ts | 25 ++++- src/shared/api/supabase-fields.ts | 7 +- src/shared/types/api.types.ts | 4 +- src/shared/types/entities/field.types.ts | 2 +- 9 files changed, 142 insertions(+), 38 deletions(-) diff --git a/src/features/fields/model/field-crypto.test.ts b/src/features/fields/model/field-crypto.test.ts index 3006b09..45565e0 100644 --- a/src/features/fields/model/field-crypto.test.ts +++ b/src/features/fields/model/field-crypto.test.ts @@ -2,12 +2,11 @@ import { describe, it, expect } from 'vitest' import { importKey } from '@/shared/crypto/aes-gcm' import { DecryptionError } from '@/shared/crypto/errors' import { encryptField, decryptField, toSaveFieldData, toEncryptedFieldData } from '@/features/fields/model/field-crypto' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' import type { FieldName } from '@/shared/types/entities/field.types' const generateKey = async () => await importKey(crypto.getRandomValues(new Uint8Array(32))) -const FIELD_NAMES: FieldName[] = ['note', 'website', 'email'] - describe('encryptField + decryptField', () => { it('round-trips plaintext for all field names', async () => { const key = await generateKey() diff --git a/src/features/fields/model/field-service.test.ts b/src/features/fields/model/field-service.test.ts index 3a8429d..8e1e660 100644 --- a/src/features/fields/model/field-service.test.ts +++ b/src/features/fields/model/field-service.test.ts @@ -155,5 +155,26 @@ describe('FieldService', () => { expect(result.website).toBe('https://example.com') expect(result.email).toBeNull() }) + + it('returns null for a field that fails without killing other fields', async () => { + const noteKey = await generateTestKey() + + mockGetKey.mockImplementation((id: string) => { + if (id === 'note') return noteKey + return undefined // website and email keys unavailable + }) + + const noteServerField = await encryptForServer('My note', noteKey, 'note') + mockFetchField.mockImplementation(async (_userId: string, fieldName: string) => { + if (fieldName === 'note') return noteServerField + return null + }) + + const result = await fieldService.loadAllFields(TEST_USER_ID) + expect(result.note).toBe('My note') + // website and email return null because keys are unavailable (throws caught by allSettled) + expect(result.website).toBeNull() + expect(result.email).toBeNull() + }) }) }) diff --git a/src/features/fields/model/field-service.ts b/src/features/fields/model/field-service.ts index e286e09..ad08ca1 100644 --- a/src/features/fields/model/field-service.ts +++ b/src/features/fields/model/field-service.ts @@ -4,6 +4,8 @@ import { FIELD_NAMES } from '@/shared/types/entities/field.types' import type { FieldName } from '@/shared/types/entities/field.types' import { encryptField, decryptField, toEncryptedFieldData, toSaveFieldData } from '@/features/fields/model/field-crypto' +const USER_ID_REQUIRED = 'userId is required' + class FieldService { private getFieldKey(fieldName: FieldName): CryptoKey { const key = keyVault.getKey(fieldName) @@ -16,7 +18,7 @@ class FieldService { * Returns null if the field has never been saved. */ async loadField(userId: string, fieldName: FieldName): Promise { - if (!userId) throw new Error('userId is required') + if (!userId) throw new Error(USER_ID_REQUIRED) const fieldKey = this.getFieldKey(fieldName) const serverField = await fetchField(userId, fieldName) @@ -31,7 +33,7 @@ class FieldService { * Uses upsert — will create or update the field. */ async saveField(userId: string, fieldName: FieldName, plaintext: string): Promise { - if (!userId) throw new Error('userId is required') + if (!userId) throw new Error(USER_ID_REQUIRED) const fieldKey = this.getFieldKey(fieldName) const encryptedData = await encryptField(plaintext, fieldKey, fieldName) @@ -41,14 +43,21 @@ class FieldService { /** * Load and decrypt all three fields (note, website, email) in parallel. - * Returns a Record mapping field names to their plaintext content (or null if never saved). + * If a single field fails (e.g. network error), the others still succeed. + * Returns null for fields that failed or were never saved. */ async loadAllFields(userId: string): Promise> { - if (!userId) throw new Error('userId is required') - const results = await Promise.all( + if (!userId) throw new Error(USER_ID_REQUIRED) + const results = await Promise.allSettled( FIELD_NAMES.map(async (name) => [name, await this.loadField(userId, name)] as const), ) - return Object.fromEntries(results) as Record + return Object.fromEntries( + results.map((result, i) => { + const name = FIELD_NAMES[i] + if (result.status === 'fulfilled') return [name, result.value[1]] as const + return [name, null] as const + }), + ) as Record } } diff --git a/src/features/fields/model/use-field.ts b/src/features/fields/model/use-field.ts index ac5a519..4911b74 100644 --- a/src/features/fields/model/use-field.ts +++ b/src/features/fields/model/use-field.ts @@ -12,14 +12,13 @@ import type { FieldName } from '@/shared/types/entities/field.types' * crypto-store purges field queries so unlock triggers a fresh fetch. */ export function useField(fieldName: FieldName) { - const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) - const hasFieldKey = useCryptoStore((s) => s.loadedFieldKeys[fieldName] === true) const userId = useAuth().user?.id ?? '' + const enabled = useCryptoStore((s) => !s.isVaultLocked && s.loadedFieldKeys[fieldName] === true) && !!userId return useQuery({ queryKey: ['field', fieldName], queryFn: () => fieldService.loadField(userId, fieldName), - enabled: !isVaultLocked && hasFieldKey && !!userId, + enabled, retry: (failureCount, error) => { if (error instanceof DecryptionError) return false return failureCount < 2 diff --git a/src/features/fields/model/use-save-field.test.ts b/src/features/fields/model/use-save-field.test.ts index f01feac..0ac7696 100644 --- a/src/features/fields/model/use-save-field.test.ts +++ b/src/features/fields/model/use-save-field.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createElement, type ReactNode } from 'react' @@ -9,39 +9,42 @@ const { mockSaveField } = vi.hoisted(() => ({ mockSaveField: vi.fn<(userId: string, fieldName: string, plaintext: string) => Promise>(), })) +const { mockUseAuth } = vi.hoisted(() => ({ + mockUseAuth: vi.fn<() => { user: { id: string; username: string } | null }>(), +})) + vi.mock('@/features/fields/model/field-service', () => ({ fieldService: { saveField: mockSaveField }, })) vi.mock('@/shared/auth/auth-context', () => ({ - useAuth: () => ({ user: { id: 'user-123', username: 'testuser' } }), + useAuth: mockUseAuth, })) // --- Import after mocks --- import { useSaveField } from '@/features/fields/model/use-save-field' -const testQueryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, }, - }, -}) + }) +} function wrapper({ children }: { children: ReactNode }) { - return createElement(QueryClientProvider, { client: testQueryClient }, children) + const queryClient = createQueryClient() + return createElement(QueryClientProvider, { client: queryClient }, children) } -afterEach(() => { - testQueryClient.clear() -}) - describe('useSaveField', () => { beforeEach(() => { vi.clearAllMocks() mockSaveField.mockResolvedValue(undefined) + mockUseAuth.mockReturnValue({ user: { id: 'user-123', username: 'testuser' } }) }) it('calls fieldService.saveField with userId, field name and plaintext on mutate', async () => { @@ -55,10 +58,52 @@ describe('useSaveField', () => { expect(mockSaveField).toHaveBeenCalledWith('user-123', 'note', 'My note content') }) - it('invalidates the field query on success', async () => { - const invalidateSpy = vi.spyOn(testQueryClient, 'invalidateQueries') + it('optimistically updates the field query cache on mutate', async () => { + const queryClient = createQueryClient() + // Pre-populate cache with existing data + queryClient.setQueryData(['field', 'note'], 'old content') - const { result } = renderHook(() => useSaveField('note'), { wrapper }) + const localWrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) + + result.current.mutate('new content') + + // Optimistic update should be visible immediately + await waitFor(() => { + expect(queryClient.getQueryData(['field', 'note'])).toBe('new content') + }) + }) + + it('rolls back cache on error', async () => { + mockSaveField.mockRejectedValue(new Error('Save failed')) + + const queryClient = createQueryClient() + queryClient.setQueryData(['field', 'note'], 'original content') + + const localWrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) + + result.current.mutate('new content') + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + // Cache should be rolled back + expect(queryClient.getQueryData(['field', 'note'])).toBe('original content') + }) + + it('invalidates the field query on settled', async () => { + const queryClient = createQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const localWrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) result.current.mutate('My note content') @@ -81,4 +126,17 @@ describe('useSaveField', () => { expect(result.current.error).toBeInstanceOf(Error) expect(result.current.error?.message).toBe('Save failed') }) + + it('throws when userId is empty (no authenticated user)', async () => { + mockUseAuth.mockReturnValue({ user: null }) + + const { result } = renderHook(() => useSaveField('note'), { wrapper }) + + result.current.mutate('test') + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + expect(result.current.error?.message).toBe('useSaveField requires an authenticated user') + }) }) diff --git a/src/features/fields/model/use-save-field.ts b/src/features/fields/model/use-save-field.ts index 78e1f17..2024baa 100644 --- a/src/features/fields/model/use-save-field.ts +++ b/src/features/fields/model/use-save-field.ts @@ -6,17 +6,32 @@ import type { FieldName } from '@/shared/types/entities/field.types' /** * Save (encrypt + upload) a field's content. * - * On success, invalidates the field query so the next read reflects - * the confirmed server state. + * Optimistically updates the cache so the user sees their change immediately, + * then confirms with the server. Rolls back on error. */ export function useSaveField(fieldName: FieldName) { const queryClient = useQueryClient() const userId = useAuth().user?.id ?? '' + const queryKey = ['field', fieldName] as const return useMutation({ - mutationFn: (plaintext: string) => fieldService.saveField(userId, fieldName, plaintext), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['field', fieldName] }) + mutationFn: (plaintext: string) => { + if (!userId) throw new Error('useSaveField requires an authenticated user') + return fieldService.saveField(userId, fieldName, plaintext) + }, + onMutate: async (plaintext) => { + await queryClient.cancelQueries({ queryKey }) + const previousValue = queryClient.getQueryData(queryKey) + queryClient.setQueryData(queryKey, plaintext) + return { previousValue } + }, + onError: (_err, _plaintext, context) => { + if (context?.previousValue !== undefined) { + queryClient.setQueryData(queryKey, context.previousValue) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }) }, }) } diff --git a/src/shared/api/supabase-fields.ts b/src/shared/api/supabase-fields.ts index 5be9e59..3d570bc 100644 --- a/src/shared/api/supabase-fields.ts +++ b/src/shared/api/supabase-fields.ts @@ -1,12 +1,13 @@ import { getSupabase } from '@/shared/api/supabase-client' import { wrapApiError } from '@/shared/api/api-errors' import type { ServerEncryptedField, SaveFieldData } from '@/shared/types/api.types' +import type { FieldName } from '@/shared/types/entities/field.types' /** * Fetch a single encrypted field for a user. * Returns null if the field does not exist. */ -export async function fetchField(userId: string, fieldName: string): Promise { +export async function fetchField(userId: string, fieldName: FieldName): Promise { const supabase = getSupabase() const { data, error } = await supabase .from('encrypted_fields') @@ -19,7 +20,7 @@ export async function fetchField(userId: string, fieldName: string): Promise { +export async function saveField(userId: string, fieldName: FieldName, data: SaveFieldData): Promise { const supabase = getSupabase() const { error } = await supabase.from('encrypted_fields').upsert( { diff --git a/src/shared/types/api.types.ts b/src/shared/types/api.types.ts index ecca713..c4be3d6 100644 --- a/src/shared/types/api.types.ts +++ b/src/shared/types/api.types.ts @@ -1,3 +1,5 @@ +import type { FieldName } from '@/shared/types/entities/field.types' + export interface ServerMasterKeyEnvelope { authSalt: string keySalt: string @@ -17,7 +19,7 @@ export interface ServerFieldKey { } export interface ServerEncryptedField { - fieldName: string + fieldName: FieldName encryptedBlob: string iv: string updatedAt: string diff --git a/src/shared/types/entities/field.types.ts b/src/shared/types/entities/field.types.ts index 5aed1ae..3eb109d 100644 --- a/src/shared/types/entities/field.types.ts +++ b/src/shared/types/entities/field.types.ts @@ -2,7 +2,7 @@ export type FieldName = 'note' | 'website' | 'email' /** Canonical list of all field names — single source of truth. */ -export const FIELD_NAMES: readonly FieldName[] = ['note', 'website', 'email'] as const +export const FIELD_NAMES = ['note', 'website', 'email'] as const /** A field as stored on the server — ciphertext, IV, and metadata. */ export interface EncryptedField {