Golden-master tests pinning the CURRENT behavior of high-value modules that had no coverage — integration seams, security helpers, two API route handlers, and complex pure utils. 111 tests across 12 files. New tests: - auth-crypto / session-cookie: AES-256-GCM session encryption roundtrip, tamper/version/missing-secret handling; cookie-slot naming. - unified-mailbox: multi-account fan-out, sort, totals, per-account error isolation, personal-vs-shared JMAP target resolution, counts/roles. - account-state-manager: snapshot/restore across the six real Zustand stores; clearAllStores reset shape; evict. - mdn: RFC 5322 MDN assembly (CRLF, RFC2047, base64 wrap, headers). - tnef: winmail.dat binary parsing from hand-built fixtures. - download-filename / subject-prefix / birthday-calendar / eml-import: filename templating, multilingual prefix stripping, birthday event generation, .eml/.zip import. - webdav / caldav-discover route handlers: auth guards, path validation, upstream URL construction, candidate probing. - helpers/factories.ts: shared makeEmail/makeMailbox/makeFakeJmapClient. Tests follow the repo's existing patterns (route-import, fake IJMAPClient, fetch spy, real store singletons). Where current behavior looks buggy it is pinned and flagged with a // CHARACTERISATION: comment (see PR for the suspected-bugs list); no production code is changed.
96 lines
3.5 KiB
TypeScript
96 lines
3.5 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import {
|
|
encryptSession,
|
|
decryptSession,
|
|
encryptPayload,
|
|
decryptPayload,
|
|
} from '@/lib/auth/crypto';
|
|
|
|
// crypto.ts derives its key solely from getSessionSecret(); mock that one seam
|
|
// so we control the secret without touching configManager / env-file lookups.
|
|
const { secretRef } = vi.hoisted(() => ({ secretRef: { value: 'x'.repeat(32) } }));
|
|
vi.mock('@/lib/auth/session-secret', () => ({
|
|
getSessionSecret: () => secretRef.value,
|
|
hasSessionSecret: () => secretRef.value.length > 0,
|
|
}));
|
|
vi.mock('@/lib/logger', () => ({
|
|
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
|
|
}));
|
|
|
|
const SECRET = 'x'.repeat(32);
|
|
|
|
beforeEach(() => {
|
|
secretRef.value = SECRET;
|
|
});
|
|
|
|
describe('encryptSession / decryptSession', () => {
|
|
it('round-trips a session', () => {
|
|
const token = encryptSession('https://mail.example.com', 'alice', 's3cret');
|
|
expect(decryptSession(token)).toEqual({
|
|
serverUrl: 'https://mail.example.com',
|
|
username: 'alice',
|
|
password: 's3cret',
|
|
});
|
|
});
|
|
|
|
it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => {
|
|
const a = encryptSession('https://x', 'u', 'p');
|
|
const b = encryptSession('https://x', 'u', 'p');
|
|
expect(a).not.toBe(b);
|
|
expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64
|
|
expect(decryptSession(a)).toEqual(decryptSession(b));
|
|
});
|
|
|
|
it('returns null (not throw) on a tampered auth tag', () => {
|
|
const token = encryptSession('https://x', 'u', 'p');
|
|
const buf = Buffer.from(token, 'base64');
|
|
buf[13] ^= 0xff; // flip a byte inside the GCM tag region (bytes 12..28)
|
|
expect(decryptSession(buf.toString('base64'))).toBeNull();
|
|
});
|
|
|
|
it('returns null on a token shorter than IV+TAG', () => {
|
|
expect(decryptSession(Buffer.alloc(10).toString('base64'))).toBeNull();
|
|
});
|
|
|
|
it('returns null when the version is not 1', () => {
|
|
const token = encryptPayload({ v: 2, serverUrl: 'https://x', username: 'u', password: 'p' });
|
|
expect(decryptSession(token)).toBeNull();
|
|
});
|
|
|
|
it('returns null when a required field is missing', () => {
|
|
const token = encryptPayload({ v: 1, serverUrl: 'https://x', username: 'u' });
|
|
expect(decryptSession(token)).toBeNull();
|
|
});
|
|
|
|
it('throws when no secret is configured', () => {
|
|
secretRef.value = '';
|
|
expect(() => encryptSession('https://x', 'u', 'p')).toThrow('SESSION_SECRET not configured');
|
|
});
|
|
|
|
it('throws when the secret is shorter than 32 characters', () => {
|
|
secretRef.value = 'tooshort';
|
|
expect(() => encryptSession('https://x', 'u', 'p')).toThrow(/at least 32 characters/);
|
|
});
|
|
});
|
|
|
|
describe('encryptPayload / decryptPayload', () => {
|
|
it('round-trips an arbitrary object', () => {
|
|
const token = encryptPayload({ a: 1, b: 'two', c: { nested: true } });
|
|
expect(decryptPayload(token)).toEqual({ a: 1, b: 'two', c: { nested: true } });
|
|
});
|
|
|
|
it('does NOT enforce the version/field guard that decryptSession applies', () => {
|
|
// CHARACTERISATION: decryptPayload returns whatever JSON parsed, with no
|
|
// v===1 / required-field validation (unlike decryptSession).
|
|
const token = encryptPayload({ v: 2, anything: 'goes' });
|
|
expect(decryptPayload(token)).toEqual({ v: 2, anything: 'goes' });
|
|
});
|
|
|
|
it('returns null on a tampered token', () => {
|
|
const token = encryptPayload({ a: 1 });
|
|
const buf = Buffer.from(token, 'base64');
|
|
buf[20] ^= 0xff;
|
|
expect(decryptPayload(buf.toString('base64'))).toBeNull();
|
|
});
|
|
});
|