- Add Stalwart API client library (lib/stalwart/client.ts) - Add server-side proxy routes for auth, crypto, password, principal, probe - Add account security Zustand store with full state management - Add Security settings tab with password change, display name, TOTP 2FA, app passwords, and encryption-at-rest controls - Add stalwartFeaturesEnabled config flag (opt-out via STALWART_FEATURES=false) - Add i18n translations for all 8 locales (en, de, es, fr, it, ja, nl, pt) - Add tests for Stalwart client (24 tests) and security store (29 tests)
425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
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<typeof vi.spyOn>;
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|