import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useAccountSecurityStore } from '../account-security-store'; function mockFetchResponse(status: number, body?: unknown): Response { return new Response(body ? JSON.stringify(body) : null, { status, headers: { 'Content-Type': 'application/json' }, }); } const defaultState = { isStalwart: null, isProbing: false, otpEnabled: false, appPasswords: [], isLoadingAuth: false, encryptionType: 'disabled', isLoadingCrypto: false, displayName: '', emails: [], quota: 0, roles: [], isLoadingPrincipal: false, isSaving: false, error: null, }; describe('AccountSecurityStore', () => { let fetchSpy: ReturnType; beforeEach(() => { useAccountSecurityStore.setState(defaultState); fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { fetchSpy.mockRestore(); }); describe('probe', () => { it('sets isStalwart to true when probe succeeds', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: true })); const result = await useAccountSecurityStore.getState().probe(); expect(result).toBe(true); expect(useAccountSecurityStore.getState().isStalwart).toBe(true); expect(useAccountSecurityStore.getState().isProbing).toBe(false); }); it('sets isStalwart to false when probe returns false', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: false })); const result = await useAccountSecurityStore.getState().probe(); expect(result).toBe(false); expect(useAccountSecurityStore.getState().isStalwart).toBe(false); }); it('sets isStalwart to false on network error', async () => { fetchSpy.mockRejectedValueOnce(new TypeError('Network error')); const result = await useAccountSecurityStore.getState().probe(); expect(result).toBe(false); expect(useAccountSecurityStore.getState().isStalwart).toBe(false); expect(useAccountSecurityStore.getState().isProbing).toBe(false); }); }); describe('fetchAuthInfo', () => { it('populates auth info on success', async () => { fetchSpy.mockResolvedValueOnce( mockFetchResponse(200, { data: { otpEnabled: true, appPasswords: ['app1', 'app2'] } }) ); await useAccountSecurityStore.getState().fetchAuthInfo(); const state = useAccountSecurityStore.getState(); expect(state.otpEnabled).toBe(true); expect(state.appPasswords).toEqual(['app1', 'app2']); expect(state.isLoadingAuth).toBe(false); expect(state.error).toBeNull(); }); it('sets defaults when data fields are missing', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} })); await useAccountSecurityStore.getState().fetchAuthInfo(); const state = useAccountSecurityStore.getState(); expect(state.otpEnabled).toBe(false); expect(state.appPasswords).toEqual([]); }); it('sets error on HTTP failure', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(500)); await useAccountSecurityStore.getState().fetchAuthInfo(); const state = useAccountSecurityStore.getState(); expect(state.isLoadingAuth).toBe(false); expect(state.error).toBe('HTTP 500'); }); it('sets error on network failure', async () => { fetchSpy.mockRejectedValueOnce(new Error('Connection refused')); await useAccountSecurityStore.getState().fetchAuthInfo(); const state = useAccountSecurityStore.getState(); expect(state.isLoadingAuth).toBe(false); expect(state.error).toBe('Connection refused'); }); }); describe('fetchCryptoInfo', () => { it('populates crypto info on success', async () => { fetchSpy.mockResolvedValueOnce( mockFetchResponse(200, { data: { type: 'pgp' } }) ); await useAccountSecurityStore.getState().fetchCryptoInfo(); const state = useAccountSecurityStore.getState(); expect(state.encryptionType).toBe('pgp'); expect(state.isLoadingCrypto).toBe(false); }); it('defaults to disabled when type is missing', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} })); await useAccountSecurityStore.getState().fetchCryptoInfo(); expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled'); }); it('sets error on failure', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(403)); await useAccountSecurityStore.getState().fetchCryptoInfo(); expect(useAccountSecurityStore.getState().error).toBe('HTTP 403'); }); }); describe('fetchPrincipal', () => { it('populates principal info on success', async () => { fetchSpy.mockResolvedValueOnce( mockFetchResponse(200, { data: { description: 'John Doe', emails: ['john@example.com', 'doe@example.com'], quota: 5000000, roles: ['user', 'admin'], }, }) ); await useAccountSecurityStore.getState().fetchPrincipal(); const state = useAccountSecurityStore.getState(); expect(state.displayName).toBe('John Doe'); expect(state.emails).toEqual(['john@example.com', 'doe@example.com']); expect(state.quota).toBe(5000000); expect(state.roles).toEqual(['user', 'admin']); expect(state.isLoadingPrincipal).toBe(false); }); it('handles single email string as array', async () => { fetchSpy.mockResolvedValueOnce( mockFetchResponse(200, { data: { description: 'User', emails: 'single@example.com', quota: 0, roles: [] }, }) ); await useAccountSecurityStore.getState().fetchPrincipal(); expect(useAccountSecurityStore.getState().emails).toEqual(['single@example.com']); }); it('handles missing emails gracefully', async () => { fetchSpy.mockResolvedValueOnce( mockFetchResponse(200, { data: { description: 'User' } }) ); await useAccountSecurityStore.getState().fetchPrincipal(); expect(useAccountSecurityStore.getState().emails).toEqual([]); }); it('sets defaults when fields are missing', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} })); await useAccountSecurityStore.getState().fetchPrincipal(); const state = useAccountSecurityStore.getState(); expect(state.displayName).toBe(''); expect(state.emails).toEqual([]); expect(state.quota).toBe(0); expect(state.roles).toEqual([]); }); }); describe('fetchAll', () => { it('calls all three fetch methods in parallel', async () => { fetchSpy .mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } })) .mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'smime' } })) .mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'Test', emails: [], quota: 0, roles: [] } })); await useAccountSecurityStore.getState().fetchAll(); const state = useAccountSecurityStore.getState(); expect(state.encryptionType).toBe('smime'); expect(state.displayName).toBe('Test'); expect(state.isLoadingAuth).toBe(false); expect(state.isLoadingCrypto).toBe(false); expect(state.isLoadingPrincipal).toBe(false); }); it('continues even if one fetch fails', async () => { fetchSpy .mockResolvedValueOnce(mockFetchResponse(500)) // auth fails .mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'pgp' } })) .mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'OK', emails: [], quota: 0, roles: [] } })); await useAccountSecurityStore.getState().fetchAll(); const state = useAccountSecurityStore.getState(); expect(state.encryptionType).toBe('pgp'); expect(state.displayName).toBe('OK'); }); }); describe('changePassword', () => { it('sends POST with currentPassword and newPassword', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { ok: true })); await useAccountSecurityStore.getState().changePassword('oldpass', 'newpass123'); expect(fetchSpy).toHaveBeenCalledWith('/api/account/stalwart/password', expect.objectContaining({ method: 'POST', body: JSON.stringify({ currentPassword: 'oldpass', newPassword: 'newpass123' }), })); expect(useAccountSecurityStore.getState().isSaving).toBe(false); }); it('throws and sets error on failure', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { error: 'Current password is incorrect' })); await expect( useAccountSecurityStore.getState().changePassword('wrong', 'newpass123') ).rejects.toThrow('Current password is incorrect'); expect(useAccountSecurityStore.getState().isSaving).toBe(false); expect(useAccountSecurityStore.getState().error).toBe('Current password is incorrect'); }); }); describe('updateDisplayName', () => { it('sends PATCH and updates local state on success', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null })); await useAccountSecurityStore.getState().updateDisplayName('New Name'); const state = useAccountSecurityStore.getState(); expect(state.displayName).toBe('New Name'); expect(state.isSaving).toBe(false); const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]); }); it('throws and sets error on failure', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server error' })); await expect( useAccountSecurityStore.getState().updateDisplayName('Name') ).rejects.toThrow('Server error'); expect(useAccountSecurityStore.getState().isSaving).toBe(false); }); }); describe('enableTotp', () => { it('sends enableOtpAuth and returns TOTP URL', async () => { const totpUrl = 'otpauth://totp/user@example.com?secret=ABC'; fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl })); const result = await useAccountSecurityStore.getState().enableTotp(); expect(result).toBe(totpUrl); expect(useAccountSecurityStore.getState().otpEnabled).toBe(true); expect(useAccountSecurityStore.getState().isSaving).toBe(false); const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); expect(body).toEqual([{ type: 'enableOtpAuth' }]); }); it('throws and preserves otpEnabled=false on failure', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'TOTP error' })); await expect( useAccountSecurityStore.getState().enableTotp() ).rejects.toThrow('TOTP error'); expect(useAccountSecurityStore.getState().otpEnabled).toBe(false); }); }); describe('disableTotp', () => { it('sends disableOtpAuth and sets otpEnabled to false', async () => { useAccountSecurityStore.setState({ otpEnabled: true }); fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null })); await useAccountSecurityStore.getState().disableTotp(); expect(useAccountSecurityStore.getState().otpEnabled).toBe(false); expect(useAccountSecurityStore.getState().isSaving).toBe(false); }); }); describe('addAppPassword', () => { it('sends addAppPassword and refreshes auth info', async () => { // First call: POST addAppPassword fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null })); // Second call: fetchAuthInfo refresh fetchSpy.mockResolvedValueOnce( mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['Thunderbird'] } }) ); await useAccountSecurityStore.getState().addAppPassword('Thunderbird', 'secret'); const state = useAccountSecurityStore.getState(); expect(state.appPasswords).toEqual(['Thunderbird']); expect(state.isSaving).toBe(false); const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); expect(body).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret' }]); }); it('throws on failure', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server down' })); await expect( useAccountSecurityStore.getState().addAppPassword('App', 'pass') ).rejects.toThrow('Server down'); }); }); describe('removeAppPassword', () => { it('sends removeAppPassword and refreshes auth info', async () => { useAccountSecurityStore.setState({ appPasswords: ['Thunderbird', 'iPhone'] }); // First call: POST removeAppPassword fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null })); // Second call: fetchAuthInfo refresh fetchSpy.mockResolvedValueOnce( mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['iPhone'] } }) ); await useAccountSecurityStore.getState().removeAppPassword('Thunderbird'); expect(useAccountSecurityStore.getState().appPasswords).toEqual(['iPhone']); const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); expect(body).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]); }); }); describe('updateEncryption', () => { it('sends crypto settings and updates local encryptionType', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null })); await useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' }); expect(useAccountSecurityStore.getState().encryptionType).toBe('pgp'); expect(useAccountSecurityStore.getState().isSaving).toBe(false); }); it('throws on failure without changing encryptionType', async () => { useAccountSecurityStore.setState({ encryptionType: 'disabled' }); fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Encryption error' })); await expect( useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' }) ).rejects.toThrow('Encryption error'); expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled'); }); }); describe('clearState', () => { it('resets all state to defaults', () => { useAccountSecurityStore.setState({ isStalwart: true, otpEnabled: true, appPasswords: ['app1'], encryptionType: 'pgp', displayName: 'Test User', emails: ['test@example.com'], quota: 5000000, roles: ['admin'], error: 'some error', }); useAccountSecurityStore.getState().clearState(); const state = useAccountSecurityStore.getState(); expect(state.isStalwart).toBeNull(); expect(state.isProbing).toBe(false); expect(state.otpEnabled).toBe(false); expect(state.appPasswords).toEqual([]); expect(state.encryptionType).toBe('disabled'); expect(state.displayName).toBe(''); expect(state.emails).toEqual([]); expect(state.quota).toBe(0); expect(state.roles).toEqual([]); expect(state.isSaving).toBe(false); expect(state.error).toBeNull(); }); }); });