361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock IndexedDB storage functions before importing store
|
|
vi.mock('@/lib/smime/key-storage', () => ({
|
|
saveKeyRecord: vi.fn().mockResolvedValue(undefined),
|
|
listKeyRecords: vi.fn().mockResolvedValue([]),
|
|
deleteKeyRecord: vi.fn().mockResolvedValue(undefined),
|
|
savePublicCert: vi.fn().mockResolvedValue(undefined),
|
|
listPublicCerts: vi.fn().mockResolvedValue([]),
|
|
deletePublicCert: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock('@/lib/smime/pkcs12-import', () => ({
|
|
importPkcs12: vi.fn(),
|
|
unlockPrivateKey: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/smime/certificate-utils', () => ({
|
|
parseCertificatePemOrDer: vi.fn(),
|
|
extractCertificateInfo: vi.fn(),
|
|
}));
|
|
|
|
import { useSmimeStore } from '@/stores/smime-store';
|
|
import { listKeyRecords, listPublicCerts, saveKeyRecord, deleteKeyRecord, deletePublicCert } from '@/lib/smime/key-storage';
|
|
import { importPkcs12, unlockPrivateKey } from '@/lib/smime/pkcs12-import';
|
|
import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types';
|
|
|
|
const mockKeyRecord: SmimeKeyRecord = {
|
|
id: 'key-1',
|
|
email: 'user@example.com',
|
|
certificate: new ArrayBuffer(10),
|
|
certificateChain: [],
|
|
encryptedPrivateKey: new ArrayBuffer(32),
|
|
salt: new ArrayBuffer(16),
|
|
iv: new ArrayBuffer(12),
|
|
kdfIterations: 600000,
|
|
issuer: 'CN=Test CA',
|
|
subject: 'CN=Test User',
|
|
serialNumber: '01',
|
|
notBefore: '2024-01-01T00:00:00Z',
|
|
notAfter: '2030-12-31T23:59:59Z',
|
|
fingerprint: 'aa:bb:cc',
|
|
algorithm: 'RSA-2048',
|
|
capabilities: { canSign: true, canEncrypt: true },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
// Reset store state
|
|
useSmimeStore.setState({
|
|
keyRecords: [],
|
|
publicCerts: [],
|
|
unlockedKeys: new Map(),
|
|
unlockedDecryptionKeys: new Map(),
|
|
identityKeyBindings: {},
|
|
defaultSignIdentity: {},
|
|
defaultEncrypt: false,
|
|
autoImportSignerCerts: true,
|
|
accountPreferences: {},
|
|
currentAccountId: null,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('smime-store', () => {
|
|
describe('load', () => {
|
|
it('loads key records and public certs from IndexedDB', async () => {
|
|
const records = [mockKeyRecord];
|
|
const certs: SmimePublicCert[] = [];
|
|
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
|
vi.mocked(listPublicCerts).mockResolvedValue(certs);
|
|
|
|
await useSmimeStore.getState().load();
|
|
|
|
const state = useSmimeStore.getState();
|
|
expect(state.keyRecords).toEqual(records);
|
|
expect(state.publicCerts).toEqual(certs);
|
|
expect(state.isLoading).toBe(false);
|
|
});
|
|
|
|
it('does not auto-unlock keys on load (security: no persisted passphrases)', async () => {
|
|
const records = [mockKeyRecord];
|
|
// Simulate a stale legacy entry written by an older build.
|
|
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
|
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
|
vi.mocked(listPublicCerts).mockResolvedValue([]);
|
|
|
|
await useSmimeStore.getState().load();
|
|
|
|
expect(unlockPrivateKey).not.toHaveBeenCalled();
|
|
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
|
|
});
|
|
|
|
it('sets error on failure', async () => {
|
|
vi.mocked(listKeyRecords).mockRejectedValue(new Error('DB failed'));
|
|
|
|
await useSmimeStore.getState().load();
|
|
|
|
expect(useSmimeStore.getState().error).toBe('DB failed');
|
|
expect(useSmimeStore.getState().isLoading).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('importPKCS12', () => {
|
|
it('imports and adds key record', async () => {
|
|
vi.mocked(importPkcs12).mockResolvedValue({
|
|
keyRecord: mockKeyRecord,
|
|
certInfo: {} as unknown as import('@/lib/smime/types').CertificateInfo,
|
|
});
|
|
|
|
const result = await useSmimeStore.getState().importPKCS12(
|
|
new ArrayBuffer(10),
|
|
'p12pass',
|
|
'storagepass',
|
|
);
|
|
|
|
expect(result.id).toBe('key-1');
|
|
expect(saveKeyRecord).toHaveBeenCalledWith(mockKeyRecord);
|
|
expect(useSmimeStore.getState().keyRecords).toHaveLength(1);
|
|
});
|
|
|
|
it('sets error on import failure', async () => {
|
|
vi.mocked(importPkcs12).mockRejectedValue(new Error('Bad password'));
|
|
|
|
await expect(
|
|
useSmimeStore.getState().importPKCS12(new ArrayBuffer(10), 'wrong', 'pass'),
|
|
).rejects.toThrow('Bad password');
|
|
|
|
expect(useSmimeStore.getState().error).toBe('Bad password');
|
|
});
|
|
});
|
|
|
|
describe('removeKeyRecord', () => {
|
|
it('removes key record and clears bindings', async () => {
|
|
useSmimeStore.setState({
|
|
keyRecords: [mockKeyRecord],
|
|
identityKeyBindings: { 'identity-1': 'key-1' },
|
|
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
|
unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]),
|
|
});
|
|
|
|
await useSmimeStore.getState().removeKeyRecord('key-1');
|
|
|
|
expect(deleteKeyRecord).toHaveBeenCalledWith('key-1');
|
|
expect(useSmimeStore.getState().keyRecords).toHaveLength(0);
|
|
expect(useSmimeStore.getState().identityKeyBindings).toEqual({});
|
|
expect(useSmimeStore.getState().unlockedKeys.has('key-1')).toBe(false);
|
|
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('removePublicCert', () => {
|
|
it('removes public cert', async () => {
|
|
const cert: SmimePublicCert = {
|
|
id: 'cert-1',
|
|
email: 'recipient@example.com',
|
|
certificate: new ArrayBuffer(10),
|
|
issuer: 'CN=CA',
|
|
subject: 'CN=Recipient',
|
|
notBefore: '2024-01-01T00:00:00Z',
|
|
notAfter: '2030-12-31T23:59:59Z',
|
|
fingerprint: 'aa:bb',
|
|
source: 'manual',
|
|
};
|
|
useSmimeStore.setState({ publicCerts: [cert] });
|
|
|
|
await useSmimeStore.getState().removePublicCert('cert-1');
|
|
|
|
expect(deletePublicCert).toHaveBeenCalledWith('cert-1');
|
|
expect(useSmimeStore.getState().publicCerts).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('unlockKey + lockKey', () => {
|
|
it('unlocks a key', async () => {
|
|
const mockSigningKey = {} as CryptoKey;
|
|
const mockDecryptionKey = {} as CryptoKey;
|
|
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey, decryptionKey: mockDecryptionKey });
|
|
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
|
|
|
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
|
|
|
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(true);
|
|
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
|
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
|
|
});
|
|
|
|
it('never persists the passphrase to sessionStorage', async () => {
|
|
const mockSigningKey = {} as CryptoKey;
|
|
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
|
|
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
|
|
|
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
|
|
|
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
|
});
|
|
|
|
it('stores only the signing key when no decryption key is available', async () => {
|
|
const mockSigningKey = {} as CryptoKey;
|
|
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
|
|
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
|
|
|
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
|
|
|
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
|
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
|
});
|
|
|
|
it('throws for non-existent key record', async () => {
|
|
await expect(
|
|
useSmimeStore.getState().unlockKey('non-existent', 'pass'),
|
|
).rejects.toThrow('Key record not found');
|
|
});
|
|
|
|
it('locks a key', () => {
|
|
useSmimeStore.setState({
|
|
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
|
unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]),
|
|
});
|
|
|
|
useSmimeStore.getState().lockKey('key-1');
|
|
|
|
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
|
|
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
|
});
|
|
|
|
it('locks all keys', () => {
|
|
useSmimeStore.setState({
|
|
unlockedKeys: new Map([
|
|
['key-1', {} as CryptoKey],
|
|
['key-2', {} as CryptoKey],
|
|
]),
|
|
unlockedDecryptionKeys: new Map([
|
|
['key-1', {} as CryptoKey],
|
|
['key-2', {} as CryptoKey],
|
|
]),
|
|
});
|
|
|
|
useSmimeStore.getState().lockAllKeys();
|
|
|
|
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
|
|
expect(useSmimeStore.getState().unlockedDecryptionKeys.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('identity bindings', () => {
|
|
it('binds an identity to a key', () => {
|
|
useSmimeStore.getState().bindIdentityToKey('identity-1', 'key-1');
|
|
expect(useSmimeStore.getState().identityKeyBindings['identity-1']).toBe('key-1');
|
|
});
|
|
|
|
it('unbinds an identity', () => {
|
|
useSmimeStore.setState({ identityKeyBindings: { 'identity-1': 'key-1' } });
|
|
useSmimeStore.getState().bindIdentityToKey('identity-1', null);
|
|
expect(useSmimeStore.getState().identityKeyBindings['identity-1']).toBeUndefined();
|
|
});
|
|
|
|
it('getKeyRecordForIdentity returns the bound record', () => {
|
|
useSmimeStore.setState({
|
|
keyRecords: [mockKeyRecord],
|
|
identityKeyBindings: { 'identity-1': 'key-1' },
|
|
});
|
|
|
|
const record = useSmimeStore.getState().getKeyRecordForIdentity('identity-1');
|
|
expect(record?.id).toBe('key-1');
|
|
});
|
|
|
|
it('getKeyRecordForIdentity returns undefined for unbound identity', () => {
|
|
const record = useSmimeStore.getState().getKeyRecordForIdentity('identity-2');
|
|
expect(record).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getPublicCertForEmail', () => {
|
|
it('finds cert by email (case-insensitive)', () => {
|
|
const cert: SmimePublicCert = {
|
|
id: 'c1',
|
|
email: 'bob@example.com',
|
|
certificate: new ArrayBuffer(10),
|
|
issuer: 'CN=CA',
|
|
subject: 'CN=Bob',
|
|
notBefore: '2024-01-01',
|
|
notAfter: '2030-12-31',
|
|
fingerprint: 'ff',
|
|
source: 'manual',
|
|
};
|
|
useSmimeStore.setState({ publicCerts: [cert] });
|
|
|
|
expect(useSmimeStore.getState().getPublicCertForEmail('Bob@Example.COM')?.id).toBe('c1');
|
|
});
|
|
|
|
it('returns undefined when not found', () => {
|
|
expect(useSmimeStore.getState().getPublicCertForEmail('nobody@test.com')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getRecipientCerts', () => {
|
|
it('partitions emails into found and missing', () => {
|
|
const cert: SmimePublicCert = {
|
|
id: 'c1',
|
|
email: 'known@example.com',
|
|
certificate: new ArrayBuffer(10),
|
|
issuer: '',
|
|
subject: '',
|
|
notBefore: '',
|
|
notAfter: '',
|
|
fingerprint: '',
|
|
source: 'manual',
|
|
};
|
|
useSmimeStore.setState({ publicCerts: [cert] });
|
|
|
|
const { found, missing } = useSmimeStore.getState().getRecipientCerts([
|
|
'known@example.com',
|
|
'unknown@example.com',
|
|
]);
|
|
|
|
expect(found).toHaveLength(1);
|
|
expect(found[0].id).toBe('c1');
|
|
expect(missing).toEqual(['unknown@example.com']);
|
|
});
|
|
});
|
|
|
|
describe('preferences', () => {
|
|
it('sets sign default for identity', () => {
|
|
useSmimeStore.getState().setSignDefault('identity-1', true);
|
|
expect(useSmimeStore.getState().defaultSignIdentity['identity-1']).toBe(true);
|
|
});
|
|
|
|
it('sets encrypt default', () => {
|
|
useSmimeStore.getState().setEncryptDefault(true);
|
|
expect(useSmimeStore.getState().defaultEncrypt).toBe(true);
|
|
});
|
|
|
|
it('wipes any legacy persisted passphrases on module load', () => {
|
|
// Module already loaded by the import above; simulate a stale entry and
|
|
// re-import to confirm the cleanup runs. We use the same key the legacy
|
|
// build used and assert it stays absent because the store's module-level
|
|
// cleanup has already executed.
|
|
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
|
});
|
|
|
|
it('sets auto import signer certs', () => {
|
|
useSmimeStore.getState().setAutoImportSignerCerts(true);
|
|
expect(useSmimeStore.getState().autoImportSignerCerts).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('setError', () => {
|
|
it('sets and clears error', () => {
|
|
useSmimeStore.getState().setError('Something went wrong');
|
|
expect(useSmimeStore.getState().error).toBe('Something went wrong');
|
|
|
|
useSmimeStore.getState().setError(null);
|
|
expect(useSmimeStore.getState().error).toBeNull();
|
|
});
|
|
});
|
|
});
|