Files
webmail/lib/__tests__/account-archive.test.ts
T
lucamzanon db38ad320e feat: optionally keep mailbox passwords in the account list backup
Part 2 of 3 of the account archive agreed in #1198. When the admin turns on
"Account List Backup: Passwords" (off by default), saving the list offers to
include the passwords of the accounts signed in here with a plain password.
They are encrypted in the browser together with the list. On restore, those
accounts are signed in to in the background, one by one, without leaving the
active account; a refused password leaves the account waiting for a sign-in by
hand, as before. OAuth and TOTP accounts are saved without a password.
2026-10-16 05:46:46 +02:00

66 lines
3.7 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest';
import {
archiveIdentity, decryptArchive, encryptArchive, parseArchiveEnvelope, parseArchivedAccounts, type ArchivedAccount,
} from '@/lib/account-archive';
const owner = { username: 'owner@example.com', serverUrl: 'https://mail.example.com' };
const accounts: ArchivedAccount[] = [
{ username: 'owner@example.com', serverUrl: 'https://mail.example.com', label: 'Work', avatarColor: '#2563eb' },
{ username: 'me@other.example', serverUrl: 'https://jmap.other.example/', label: 'Home', avatarColor: '#16a34a' },
];
const password = 'correct horse battery';
describe('account archive encryption', () => {
it('round-trips the account list and leaves only ciphertext in the envelope', async () => {
const envelope = await encryptArchive(owner, accounts, password);
expect(JSON.stringify(envelope)).not.toContain('example');
expect(JSON.stringify(envelope)).not.toContain('Work');
expect(await decryptArchive(owner, envelope, password)).toEqual([
accounts[0], { ...accounts[1], serverUrl: 'https://jmap.other.example' },
]);
});
it('refuses a wrong password and an envelope moved to another owner', async () => {
const envelope = await encryptArchive(owner, accounts, password);
await expect(decryptArchive(owner, envelope, 'wrong password!')).rejects.toThrow('unlock_failed');
await expect(decryptArchive({ ...owner, username: 'thief@example.com' }, envelope, password)).rejects.toThrow('unlock_failed');
});
it('treats the owner username without regard to case', async () => {
const envelope = await encryptArchive({ ...owner, username: 'Owner@Example.com' }, accounts, password);
expect(await decryptArchive(owner, envelope, password)).toHaveLength(2);
expect(archiveIdentity({ ...owner, username: 'OWNER@example.com' })).toBe(archiveIdentity(owner));
expect(archiveIdentity({ ...owner, serverUrl: 'https://mail.example.com/' })).toBe(archiveIdentity(owner));
});
it('demands a password of at least 10 characters', async () => {
await expect(encryptArchive(owner, accounts, 'short')).rejects.toThrow('password_length');
});
it('carries a mailbox password when given one, and no other extra field', async () => {
const withSecrets = [{ ...accounts[0], password: 'mailbox-secret', token: 't' }, { ...accounts[1], token: 't' }];
const envelope = await encryptArchive(owner, withSecrets, password);
expect(JSON.stringify(envelope)).not.toContain('mailbox-secret');
const restored = await decryptArchive(owner, envelope, password);
expect(restored[0].password).toBe('mailbox-secret');
expect(Object.keys(restored[0]).sort()).toEqual(['avatarColor', 'label', 'password', 'serverUrl', 'username']);
expect(Object.keys(restored[1]).sort()).toEqual(['avatarColor', 'label', 'serverUrl', 'username']);
});
it('rejects a password that is empty, too long or not a string', () => {
for (const bad of ['', 'x'.repeat(1025), 42]) {
expect(() => parseArchivedAccounts([{ ...accounts[0], password: bad as string }])).toThrow('invalid_archive');
}
});
it('rejects malformed lists and envelopes', async () => {
expect(() => parseArchivedAccounts([])).toThrow('invalid_archive');
expect(() => parseArchivedAccounts([{ ...accounts[0], avatarColor: 'red' }])).toThrow('invalid_archive');
const envelope = await encryptArchive(owner, accounts, password);
expect(parseArchiveEnvelope({ ...envelope, extra: 'x' })).toEqual(envelope);
expect(() => parseArchiveEnvelope({ ...envelope, iterations: 1000 })).toThrow('invalid_archive');
expect(() => parseArchiveEnvelope({ ...envelope, iv: 'AAAA' })).toThrow('invalid_archive');
});
});