test: add characterisation tests for untested integration seams
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.
This commit is contained in:
committed by
Linus Rath
parent
dda9fd1433
commit
2fac6ebfb8
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
snapshotAccount,
|
||||||
|
restoreAccount,
|
||||||
|
clearAllStores,
|
||||||
|
evictAccount,
|
||||||
|
evictAll,
|
||||||
|
} from '@/lib/account-state-manager';
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { useContactStore } from '@/stores/contact-store';
|
||||||
|
import { useCalendarStore } from '@/stores/calendar-store';
|
||||||
|
import { useFilterStore } from '@/stores/filter-store';
|
||||||
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
|
import { useVacationStore } from '@/stores/vacation-store';
|
||||||
|
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||||
|
import { makeEmail, makeMailbox } from './helpers/factories';
|
||||||
|
|
||||||
|
// The store singletons and the module-level snapshot cache persist across
|
||||||
|
// tests; reset both around every test.
|
||||||
|
beforeEach(() => evictAll());
|
||||||
|
afterEach(() => evictAll());
|
||||||
|
|
||||||
|
describe('snapshotAccount / restoreAccount', () => {
|
||||||
|
it('round-trips the captured fields across all six stores', () => {
|
||||||
|
useEmailStore.setState({
|
||||||
|
emails: [makeEmail({ id: 'a1' })],
|
||||||
|
mailboxes: [makeMailbox({ id: 'a-in' })],
|
||||||
|
selectedMailbox: 'a-in',
|
||||||
|
searchQuery: 'queryA',
|
||||||
|
quota: { used: 1, total: 2 },
|
||||||
|
});
|
||||||
|
useContactStore.setState({ supportsSync: true });
|
||||||
|
useCalendarStore.setState({ viewMode: 'week', supportsCalendar: true });
|
||||||
|
useFilterStore.setState({ isSupported: true });
|
||||||
|
useIdentityStore.setState({ preferredPrimaryId: 'idA' });
|
||||||
|
useVacationStore.setState({ isEnabled: true });
|
||||||
|
|
||||||
|
snapshotAccount('A');
|
||||||
|
|
||||||
|
// Mutate everything to "account B" values.
|
||||||
|
useEmailStore.setState({ emails: [], selectedMailbox: 'b-in', searchQuery: 'queryB', quota: null });
|
||||||
|
useContactStore.setState({ supportsSync: false });
|
||||||
|
useCalendarStore.setState({ viewMode: 'month', supportsCalendar: false });
|
||||||
|
useFilterStore.setState({ isSupported: false });
|
||||||
|
useIdentityStore.setState({ preferredPrimaryId: 'idB' });
|
||||||
|
useVacationStore.setState({ isEnabled: false });
|
||||||
|
|
||||||
|
expect(restoreAccount('A')).toBe(true);
|
||||||
|
|
||||||
|
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a1']);
|
||||||
|
expect(useEmailStore.getState().selectedMailbox).toBe('a-in');
|
||||||
|
expect(useEmailStore.getState().searchQuery).toBe('queryA');
|
||||||
|
expect(useEmailStore.getState().quota).toEqual({ used: 1, total: 2 });
|
||||||
|
expect(useContactStore.getState().supportsSync).toBe(true);
|
||||||
|
expect(useCalendarStore.getState().viewMode).toBe('week');
|
||||||
|
expect(useFilterStore.getState().isSupported).toBe(true);
|
||||||
|
expect(useIdentityStore.getState().preferredPrimaryId).toBe('idA');
|
||||||
|
expect(useVacationStore.getState().isEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CHARACTERISATION: only snapshotted fields are restored; others survive', () => {
|
||||||
|
// isLoading is NOT part of the email snapshot subset.
|
||||||
|
useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false });
|
||||||
|
snapshotAccount('A');
|
||||||
|
useEmailStore.setState({ selectedMailbox: 'b-in', isLoading: true });
|
||||||
|
|
||||||
|
restoreAccount('A');
|
||||||
|
|
||||||
|
expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // restored
|
||||||
|
expect(useEmailStore.getState().isLoading).toBe(true); // NOT restored (merge)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CHARACTERISATION: snapshot stores array references, not deep clones', () => {
|
||||||
|
const arr = [makeEmail({ id: '1' })];
|
||||||
|
useEmailStore.setState({ emails: arr });
|
||||||
|
snapshotAccount('A');
|
||||||
|
arr.push(makeEmail({ id: '2' })); // mutate the same array after snapshot
|
||||||
|
useEmailStore.setState({ emails: [] });
|
||||||
|
|
||||||
|
restoreAccount('A');
|
||||||
|
// The post-snapshot mutation leaked into the snapshot.
|
||||||
|
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and leaves stores untouched for an unknown account', () => {
|
||||||
|
useEmailStore.setState({ searchQuery: 'keep' });
|
||||||
|
expect(restoreAccount('nope')).toBe(false);
|
||||||
|
expect(useEmailStore.getState().searchQuery).toBe('keep');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearAllStores', () => {
|
||||||
|
it('resets the email store to fresh empty collections', () => {
|
||||||
|
useEmailStore.setState({
|
||||||
|
emails: [makeEmail({ id: 'x' })],
|
||||||
|
selectedEmailIds: new Set(['x']),
|
||||||
|
searchQuery: 'q',
|
||||||
|
tagCounts: { a: { total: 1, unread: 0 } },
|
||||||
|
threadEmailsCache: new Map([['t', []]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
clearAllStores();
|
||||||
|
|
||||||
|
const s = useEmailStore.getState();
|
||||||
|
expect(s.emails).toEqual([]);
|
||||||
|
expect(s.searchQuery).toBe('');
|
||||||
|
expect(s.selectedEmailIds.size).toBe(0);
|
||||||
|
expect(s.threadEmailsCache.size).toBe(0);
|
||||||
|
expect(s.tagCounts).toEqual({});
|
||||||
|
expect(s.searchFilters).toEqual(DEFAULT_SEARCH_FILTERS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evictAccount / evictAll', () => {
|
||||||
|
it('evictAccount drops a single snapshot', () => {
|
||||||
|
snapshotAccount('A');
|
||||||
|
evictAccount('A');
|
||||||
|
expect(restoreAccount('A')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evictAll drops every snapshot', () => {
|
||||||
|
snapshotAccount('B');
|
||||||
|
snapshotAccount('C');
|
||||||
|
evictAll();
|
||||||
|
expect(restoreAccount('B')).toBe(false);
|
||||||
|
expect(restoreAccount('C')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { ContactCard } from '@/lib/jmap/types';
|
||||||
|
import {
|
||||||
|
createBirthdayCalendar,
|
||||||
|
generateBirthdayEvents,
|
||||||
|
BIRTHDAY_CALENDAR_ID,
|
||||||
|
BIRTHDAY_CALENDAR_COLOR,
|
||||||
|
} from '@/lib/birthday-calendar';
|
||||||
|
|
||||||
|
const contact = (over: Record<string, unknown> = {}): ContactCard =>
|
||||||
|
({
|
||||||
|
id: 'c1',
|
||||||
|
'@type': 'Card',
|
||||||
|
name: { full: 'Alice Smith' },
|
||||||
|
anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '1990-05-15' } },
|
||||||
|
...over,
|
||||||
|
} as unknown as ContactCard);
|
||||||
|
|
||||||
|
describe('createBirthdayCalendar', () => {
|
||||||
|
it('returns the virtual calendar with defaults', () => {
|
||||||
|
const cal = createBirthdayCalendar();
|
||||||
|
expect(cal).toMatchObject({
|
||||||
|
id: BIRTHDAY_CALENDAR_ID,
|
||||||
|
name: 'Birthdays',
|
||||||
|
color: BIRTHDAY_CALENDAR_COLOR,
|
||||||
|
isSubscribed: true,
|
||||||
|
myRights: { mayReadItems: true, mayWriteAll: false, mayDelete: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours name/color overrides', () => {
|
||||||
|
expect(createBirthdayCalendar('My BDays', '#fff')).toMatchObject({ name: 'My BDays', color: '#fff' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateBirthdayEvents', () => {
|
||||||
|
it('emits one event per year in range, with age and stable ids', () => {
|
||||||
|
const events = generateBirthdayEvents([contact()], '2020-01-01', '2022-12-31');
|
||||||
|
expect(events.map((e) => e.id)).toEqual([
|
||||||
|
'birthday-c1-b1-2020',
|
||||||
|
'birthday-c1-b1-2021',
|
||||||
|
'birthday-c1-b1-2022',
|
||||||
|
]);
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
uid: 'birthday-c1-b1',
|
||||||
|
title: '🎂 Alice Smith (30)',
|
||||||
|
start: '2020-05-15T00:00:00',
|
||||||
|
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
|
||||||
|
showWithoutTime: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the age when the birthday has no year (partial date)', () => {
|
||||||
|
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '--05-15' } } });
|
||||||
|
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].title).toBe('🎂 Alice Smith');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a Timestamp anniversary date', () => {
|
||||||
|
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: { '@type': 'Timestamp', utc: '1985-03-10T00:00:00Z' } } } });
|
||||||
|
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
|
||||||
|
expect(events[0].start).toBe('2021-03-10T00:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a PartialDate anniversary date', () => {
|
||||||
|
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: { month: 7, day: 4 } } } });
|
||||||
|
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
|
||||||
|
expect(events[0].start).toBe('2021-07-04T00:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps Feb 29 to Feb 28 in a non-leap year', () => {
|
||||||
|
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '2000-02-29' } } });
|
||||||
|
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
|
||||||
|
expect(events[0].start).toBe('2021-02-28T00:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes occurrences outside the range', () => {
|
||||||
|
expect(generateBirthdayEvents([contact()], '2021-01-01', '2021-02-28')).toEqual([]); // May birthday
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for an invalid range', () => {
|
||||||
|
expect(generateBirthdayEvents([contact()], 'not-a-date', '2021-12-31')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips non-birth anniversaries', () => {
|
||||||
|
const c = contact({ anniversaries: { w1: { '@type': 'Anniversary', kind: 'wedding', date: '2010-06-01' } } });
|
||||||
|
expect(generateBirthdayEvents([c], '2021-01-01', '2021-12-31')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next/server', () => ({
|
||||||
|
NextResponse: {
|
||||||
|
json: (data: unknown, init?: { status?: number }) => ({ status: init?.status ?? 200, json: async () => data }),
|
||||||
|
},
|
||||||
|
NextRequest: class {},
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {} } }));
|
||||||
|
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
|
||||||
|
|
||||||
|
import { POST } from '@/app/api/caldav/discover/route';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
const mockCreds = getStalwartCredentials as unknown as Mock;
|
||||||
|
const CREDS = { serverUrl: 'https://mail.example.com', username: 'u', authHeader: 'Basic abc' };
|
||||||
|
|
||||||
|
let fetchSpy: Mock;
|
||||||
|
|
||||||
|
function makeReq(body: unknown): Parameters<typeof POST>[0] {
|
||||||
|
return { headers: { get: () => null }, json: async () => body } as unknown as Parameters<typeof POST>[0];
|
||||||
|
}
|
||||||
|
function read(res: unknown) {
|
||||||
|
return res as { status: number; json: () => Promise<{ wellKnownUrl: string; accounts: Record<string, { url: string | null; resolvedAccount: string | null }> }> };
|
||||||
|
}
|
||||||
|
const target = (c: string) => `https://mail.example.com/dav/cal/${c}`;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreds.mockResolvedValue(CREDS);
|
||||||
|
fetchSpy = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/caldav/discover', () => {
|
||||||
|
it('401 without credentials', async () => {
|
||||||
|
mockCreds.mockResolvedValue(null);
|
||||||
|
const res = read(await POST(makeReq({ accounts: [] })));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the .well-known url and resolves the first 207 candidate, skipping the rest', async () => {
|
||||||
|
fetchSpy.mockResolvedValue({ status: 207, headers: new Headers() });
|
||||||
|
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
expect(data.wellKnownUrl).toBe('https://mail.example.com/.well-known/caldav');
|
||||||
|
expect(data.accounts.A).toEqual({ url: target('c1'), resolvedAccount: 'c1' });
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1); // c2 never probed
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(target('c1'), expect.objectContaining({ method: 'PROPFIND' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a redirect Location relative to the probe URL', async () => {
|
||||||
|
fetchSpy.mockResolvedValue({ status: 302, headers: new Headers({ Location: '/dav/cal/real-home' }) });
|
||||||
|
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1'] }] })));
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.accounts.A).toEqual({ url: 'https://mail.example.com/dav/cal/real-home', resolvedAccount: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null url when every candidate fails, but still 200', async () => {
|
||||||
|
fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() });
|
||||||
|
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).accounts.A).toEqual({ url: null, resolvedAccount: null });
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches a probe error and continues to the next candidate', async () => {
|
||||||
|
fetchSpy
|
||||||
|
.mockRejectedValueOnce(new Error('boom'))
|
||||||
|
.mockResolvedValueOnce({ status: 207, headers: new Headers() });
|
||||||
|
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
|
||||||
|
expect((await res.json()).accounts.A).toEqual({ url: target('c2'), resolvedAccount: 'c2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-duplicates and trims candidates before probing', async () => {
|
||||||
|
fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() });
|
||||||
|
await POST(makeReq({ accounts: [{ key: 'A', candidates: [' c1 ', 'c1', '', 'c1'] }] }));
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1); // collapsed to a single "c1"
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(target('c1'), expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Pin TZ so the local-time date rendering in dateParts is deterministic.
|
||||||
|
process.env.TZ = 'UTC';
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { Email } from '@/lib/jmap/types';
|
||||||
|
import {
|
||||||
|
emailExportFilename,
|
||||||
|
attachmentDownloadFilename,
|
||||||
|
bundleExportFilename,
|
||||||
|
emailVars,
|
||||||
|
attachmentVars,
|
||||||
|
buildSampleEmail,
|
||||||
|
} from '@/lib/download-filename';
|
||||||
|
|
||||||
|
const makeEmail = (over: Partial<Email>): Email =>
|
||||||
|
({ id: 'e', receivedAt: '2026-05-22T14:05:33Z', from: [], to: [], subject: '', ...over } as unknown as Email);
|
||||||
|
|
||||||
|
describe('emailExportFilename', () => {
|
||||||
|
it('renders the default template from the sample email (UTC)', () => {
|
||||||
|
expect(emailExportFilename(buildSampleEmail())).toBe(
|
||||||
|
'2026-05-22 14.05.33 (Alice Sender-Bob Recipient) Benachrichtigung von Ihrem Gerät.eml',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies lowercase + stripDiacritics + underscore-spaces transforms', () => {
|
||||||
|
expect(
|
||||||
|
emailExportFilename(buildSampleEmail(), {
|
||||||
|
template: '{from_name}-{subject}',
|
||||||
|
lowercase: true,
|
||||||
|
stripDiacritics: true,
|
||||||
|
spaceReplacement: 'underscore',
|
||||||
|
}),
|
||||||
|
).toBe('alice_sender-benachrichtigung_von_ihrem_gerat.eml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "no subject" for an empty subject', () => {
|
||||||
|
expect(emailExportFilename(makeEmail({ subject: '' }), '{subject}')).toBe('no subject.eml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "email" when the template renders empty', () => {
|
||||||
|
expect(emailExportFilename(makeEmail({}), '{unknown_token}')).toBe('email.eml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CHARACTERISATION: per-token sanitise caps each value at 80 chars', () => {
|
||||||
|
// sanitizePart defaults to maxLen=80, applied per {token} during render —
|
||||||
|
// so a single long {subject} is truncated to 80 well before the 200 cap.
|
||||||
|
const out = emailExportFilename(makeEmail({ subject: 'a'.repeat(300) }), '{subject}');
|
||||||
|
expect(out).toBe('a'.repeat(80) + '.eml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('attachmentDownloadFilename', () => {
|
||||||
|
it('email===null: sanitises the raw attachment name, applying transforms', () => {
|
||||||
|
expect(attachmentDownloadFilename(null, { name: 'Report.PDF' })).toBe('Report.PDF');
|
||||||
|
expect(attachmentDownloadFilename(null, { name: 'Report.PDF' }, { lowercase: true })).toBe('report.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('{filename} token preserves the original extension', () => {
|
||||||
|
expect(
|
||||||
|
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.pdf' }, '{filename}'),
|
||||||
|
).toBe('My Report.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('template without {ext}/{filename} appends the attachment extension', () => {
|
||||||
|
expect(
|
||||||
|
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, '{name}'),
|
||||||
|
).toBe('My Report.PDF');
|
||||||
|
expect(
|
||||||
|
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, { template: '{name}', lowercase: true }),
|
||||||
|
).toBe('my report.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attachment with no extension yields no trailing dot', () => {
|
||||||
|
expect(attachmentDownloadFilename(buildSampleEmail(), { name: 'noext' }, '{name}')).toBe('noext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitises path-traversal characters out of the name', () => {
|
||||||
|
const out = attachmentDownloadFilename(null, { name: '../../etc/passwd' });
|
||||||
|
expect(out).not.toContain('/');
|
||||||
|
// CHARACTERISATION: slashes → "_", then the leading "._-" run is stripped,
|
||||||
|
// so "../../etc/passwd" collapses to "etc_passwd".
|
||||||
|
expect(out).toBe('etc_passwd');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bundleExportFilename', () => {
|
||||||
|
it('substitutes {count} and appends .zip', () => {
|
||||||
|
expect(bundleExportFilename(3, '{count}-emails', '2026-05-22T14:05:33Z')).toBe('3-emails.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the default template', () => {
|
||||||
|
expect(bundleExportFilename(5, {}, '2026-05-22T14:05:33Z')).toBe('emails-5.zip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emailVars (date + address labels)', () => {
|
||||||
|
it('returns the invalid-date sentinel for an unparseable date', () => {
|
||||||
|
const v = emailVars(makeEmail({ receivedAt: 'not-a-date', sentAt: undefined }));
|
||||||
|
expect(v.date).toBe('0000-00-00 00.00.00');
|
||||||
|
expect(v.date_short).toBe('0000-00-00');
|
||||||
|
expect(v.time).toBe('00.00.00');
|
||||||
|
expect(v.year).toBe('0000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addrLabel falls back name → email user-part → "unknown"', () => {
|
||||||
|
expect(emailVars(makeEmail({ from: [{ email: 'alice@example.com' }] }) ).from).toBe('alice');
|
||||||
|
expect(emailVars(makeEmail({ from: [] })).from).toBe('unknown');
|
||||||
|
expect(emailVars(makeEmail({ from: [{ name: ' ', email: 'x@y.com' }] })).from).toBe('x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('attachmentVars (extension split)', () => {
|
||||||
|
it('splits name and ext on the last dot', () => {
|
||||||
|
const v = attachmentVars(buildSampleEmail(), { name: 'doc.tar.gz' });
|
||||||
|
expect(v).toMatchObject({ filename: 'doc.tar.gz', name: 'doc.tar', ext: 'gz' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a dotless name as having no extension', () => {
|
||||||
|
const v = attachmentVars(buildSampleEmail(), { name: 'noext' });
|
||||||
|
expect(v).toMatchObject({ name: 'noext', ext: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults a missing name to "attachment"', () => {
|
||||||
|
const v = attachmentVars(buildSampleEmail(), {});
|
||||||
|
expect(v).toMatchObject({ filename: 'attachment', ext: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { expandImportableEmails, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
|
||||||
|
|
||||||
|
const emlFile = (name: string, content = 'raw email', type = 'message/rfc822') =>
|
||||||
|
new File([content], name, { type });
|
||||||
|
|
||||||
|
describe('expandImportableEmails', () => {
|
||||||
|
it('wraps a .eml file as a message/rfc822 blob, keeping its name', async () => {
|
||||||
|
const out = await expandImportableEmails([emlFile('msg.eml')]);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].name).toBe('msg.eml');
|
||||||
|
expect(out[0].blob.type).toBe('message/rfc822');
|
||||||
|
await expect(out[0].blob.text()).resolves.toBe('raw email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CHARACTERISATION: wraps a non-.eml, non-zip file as rfc822 too', async () => {
|
||||||
|
const out = await expandImportableEmails([emlFile('note.txt', 'hi', 'text/plain')]);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]).toMatchObject({ name: 'note.txt' });
|
||||||
|
expect(out[0].blob.type).toBe('message/rfc822');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts only .eml entries from a .zip, stripping path prefixes', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('a.eml', 'A');
|
||||||
|
zip.file('sub/b.eml', 'B');
|
||||||
|
zip.file('c.txt', 'C'); // skipped (not .eml)
|
||||||
|
zip.folder('emptydir'); // skipped (directory)
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const file = new File([blob], 'archive.zip', { type: 'application/zip' });
|
||||||
|
|
||||||
|
const out = await expandImportableEmails([file]);
|
||||||
|
expect(out.map((e) => e.name).sort()).toEqual(['a.eml', 'b.eml']);
|
||||||
|
expect(out.every((e) => e.blob.type === 'message/rfc822')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the zip path when the MIME type is application/zip even without a .zip name', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('only.eml', 'X');
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
const file = new File([blob], 'archive-no-ext', { type: 'application/zip' });
|
||||||
|
|
||||||
|
const out = await expandImportableEmails([file]);
|
||||||
|
expect(out.map((e) => e.name)).toEqual(['only.eml']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the accept string for the file picker', () => {
|
||||||
|
expect(EML_IMPORT_ACCEPT).toBe('.eml,.zip,message/rfc822,application/zip');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Shared test factories. Additive only — existing tests keep their inline
|
||||||
|
// factories; new tests can import these to avoid re-declaring the large
|
||||||
|
// Email/Mailbox literals. Keep minimal and cast through `unknown` so callers
|
||||||
|
// only specify the fields they assert on.
|
||||||
|
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
|
export const makeEmail = (over: Partial<Email> = {}): Email =>
|
||||||
|
({
|
||||||
|
id: 'e1',
|
||||||
|
threadId: 't1',
|
||||||
|
receivedAt: '2026-01-01T00:00:00Z',
|
||||||
|
subject: '',
|
||||||
|
from: [],
|
||||||
|
to: [],
|
||||||
|
cc: [],
|
||||||
|
keywords: {},
|
||||||
|
mailboxIds: {},
|
||||||
|
...over,
|
||||||
|
} as unknown as Email);
|
||||||
|
|
||||||
|
export const makeMailbox = (over: Partial<Mailbox> = {}): Mailbox =>
|
||||||
|
({
|
||||||
|
id: 'mb1',
|
||||||
|
name: 'Inbox',
|
||||||
|
role: 'inbox',
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalEmails: 0,
|
||||||
|
...over,
|
||||||
|
} as unknown as Mailbox);
|
||||||
|
|
||||||
|
/** A bare fake JMAP client: only the methods you pass exist. */
|
||||||
|
export const makeFakeJmapClient = (over: Partial<IJMAPClient> = {}): IJMAPClient =>
|
||||||
|
over as unknown as IJMAPClient;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { buildMdnMessage } from '@/lib/mdn';
|
||||||
|
|
||||||
|
// buildMdnMessage pulls in Date / Date.now / Math.random for the Date header,
|
||||||
|
// Message-ID and MIME boundary. Pin all three so the output is reproducible.
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-28T14:23:00Z'));
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0.5); // (0.5).toString(36).slice(2) === 'i'
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
to: 'sender@other.com',
|
||||||
|
fromEmail: 'me@example.com',
|
||||||
|
originalSubject: 'Hello',
|
||||||
|
originalMessageId: 'orig@other.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildMdnMessage — structure', () => {
|
||||||
|
it('emits the expected headers and a manual disposition by default', () => {
|
||||||
|
const msg = buildMdnMessage(base);
|
||||||
|
expect(msg).toContain('Date: Thu, 28 May 2026 14:23:00 +0000'); // rfc5322 UTC
|
||||||
|
expect(msg).toMatch(/^From: me@example\.com$/m);
|
||||||
|
expect(msg).toMatch(/^To: sender@other\.com$/m);
|
||||||
|
expect(msg).toMatch(/^Subject: Read: Hello$/m); // default subject
|
||||||
|
expect(msg).toMatch(/^Message-ID: <mdn\.[0-9a-z]+\.i@example\.com>$/m); // random token = 'i'
|
||||||
|
expect(msg).toMatch(/^In-Reply-To: <orig@other\.com>$/m);
|
||||||
|
expect(msg).toContain('Original-Message-ID: <orig@other.com>');
|
||||||
|
expect(msg).toContain('Disposition: manual-action/MDN-sent-manually; displayed');
|
||||||
|
expect(msg).toContain('Final-Recipient: rfc822;me@example.com');
|
||||||
|
expect(msg).not.toContain('Original-Recipient:');
|
||||||
|
expect(msg).toContain('Reporting-UA: example.com; Bulwark Webmail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses CRLF line endings everywhere', () => {
|
||||||
|
const msg = buildMdnMessage(base);
|
||||||
|
expect(msg).toContain('\r\n');
|
||||||
|
expect(msg).not.toMatch(/[^\r]\n/); // no bare LF
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an automatic action when automatic:true', () => {
|
||||||
|
expect(buildMdnMessage({ ...base, automatic: true })).toContain(
|
||||||
|
'Disposition: automatic-action/MDN-sent-automatically; displayed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMdnMessage — header encoding & normalisation', () => {
|
||||||
|
it('RFC2047-encodes non-ASCII From name and Subject', () => {
|
||||||
|
const msg = buildMdnMessage({ ...base, fromName: 'Müller', subject: 'Übersicht' });
|
||||||
|
expect(msg).toMatch(/^From: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?= <me@example\.com>$/m);
|
||||||
|
expect(msg).toMatch(/^Subject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises Message-ID from a string[] and adds missing angle brackets', () => {
|
||||||
|
expect(buildMdnMessage({ ...base, originalMessageId: ['arr@x.com'] })).toMatch(
|
||||||
|
/^In-Reply-To: <arr@x\.com>$/m,
|
||||||
|
);
|
||||||
|
expect(buildMdnMessage({ ...base, originalMessageId: 'bare@x.com' })).toContain(
|
||||||
|
'Original-Message-ID: <bare@x.com>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits In-Reply-To / Original-Message-ID when no original id is given', () => {
|
||||||
|
const msg = buildMdnMessage({ to: base.to, fromEmail: base.fromEmail });
|
||||||
|
expect(msg).not.toContain('In-Reply-To:');
|
||||||
|
expect(msg).not.toContain('Original-Message-ID:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds Original-Recipient and uses it as Final-Recipient when supplied', () => {
|
||||||
|
const msg = buildMdnMessage({ ...base, originalRecipient: 'alias@example.com' });
|
||||||
|
expect(msg).toContain('Original-Recipient: rfc822;alias@example.com');
|
||||||
|
expect(msg).toContain('Final-Recipient: rfc822;alias@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the localhost domain when fromEmail has no @', () => {
|
||||||
|
const msg = buildMdnMessage({ to: base.to, fromEmail: 'invalid' });
|
||||||
|
expect(msg).toMatch(/^Message-ID: <mdn\.[0-9a-z]+\.i@localhost>$/m);
|
||||||
|
expect(msg).toContain('Reporting-UA: localhost; Bulwark Webmail');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMdnMessage — body', () => {
|
||||||
|
it('base64-encodes the human-readable part wrapped at 76 columns', () => {
|
||||||
|
const msg = buildMdnMessage({ ...base, humanText: 'A'.repeat(100) });
|
||||||
|
const lines = msg.split('\r\n');
|
||||||
|
// A 100-char ASCII body → 136 base64 chars → a 76-char line + a 60-char line.
|
||||||
|
expect(lines.some((l) => l.length === 76 && /^[A-Za-z0-9+/]+$/.test(l))).toBe(true);
|
||||||
|
expect(lines.every((l) => !/^[A-Za-z0-9+/]+={0,2}$/.test(l) || l.length <= 76)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
SESSION_COOKIE,
|
||||||
|
SESSION_COOKIE_MAX_AGE,
|
||||||
|
sessionCookieName,
|
||||||
|
} from '@/lib/auth/session-cookie';
|
||||||
|
|
||||||
|
describe('session-cookie', () => {
|
||||||
|
it('exposes the legacy cookie name and 30-day max-age', () => {
|
||||||
|
expect(SESSION_COOKIE).toBe('jmap_session');
|
||||||
|
expect(SESSION_COOKIE_MAX_AGE).toBe(2592000); // 30 * 24 * 60 * 60
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the bare legacy name for slot 0 (no suffix)', () => {
|
||||||
|
expect(sessionCookieName(0)).toBe('jmap_session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suffixes the slot number for slots > 0', () => {
|
||||||
|
expect(sessionCookieName(1)).toBe('jmap_session_1');
|
||||||
|
expect(sessionCookieName(49)).toBe('jmap_session_49');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
stripSubjectPrefixes,
|
||||||
|
buildReplySubject,
|
||||||
|
buildForwardSubject,
|
||||||
|
} from '@/lib/subject-prefix';
|
||||||
|
|
||||||
|
describe('stripSubjectPrefixes', () => {
|
||||||
|
it('strips a chain of mixed-language prefixes', () => {
|
||||||
|
expect(stripSubjectPrefixes('Re: AW: WG: foo')).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips the Outlook [N] counter and Eudora *N counter', () => {
|
||||||
|
expect(stripSubjectPrefixes('Re[2]: foo')).toBe('foo');
|
||||||
|
expect(stripSubjectPrefixes('Re*3: foo')).toBe('foo');
|
||||||
|
expect(stripSubjectPrefixes('Re*: foo')).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive and idempotent', () => {
|
||||||
|
expect(stripSubjectPrefixes('RE: Re: foo')).toBe('foo');
|
||||||
|
expect(stripSubjectPrefixes(stripSubjectPrefixes('RE: Re: foo'))).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a Cyrillic reply token', () => {
|
||||||
|
expect(stripSubjectPrefixes('Ответ: foo')).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a Chinese token followed by an ASCII colon', () => {
|
||||||
|
expect(stripSubjectPrefixes('回复: foo')).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CHARACTERISATION: does NOT strip a token followed by a full-width colon', () => {
|
||||||
|
// The colon in the regex is ASCII ":"; a full-width ":" (U+FF1A), as some
|
||||||
|
// CJK mail clients emit, is left untouched. Likely a bug — see follow-ups.
|
||||||
|
expect(stripSubjectPrefixes('回复:foo')).toBe('回复:foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT strip a bare single-letter "R:" (would eat real subjects)', () => {
|
||||||
|
expect(stripSubjectPrefixes('R: budget 2024')).toBe('R: budget 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" for empty / null / undefined', () => {
|
||||||
|
expect(stripSubjectPrefixes('')).toBe('');
|
||||||
|
expect(stripSubjectPrefixes(null)).toBe('');
|
||||||
|
expect(stripSubjectPrefixes(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a prefix-free subject untouched', () => {
|
||||||
|
expect(stripSubjectPrefixes('foo')).toBe('foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildReplySubject / buildForwardSubject', () => {
|
||||||
|
it('replaces an existing prefix chain with the given prefix', () => {
|
||||||
|
expect(buildReplySubject('AW: WG: foo', 'Re:')).toBe('Re: foo');
|
||||||
|
expect(buildForwardSubject('Re: foo', 'Fwd:')).toBe('Fwd: foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends the prefix to a prefix-free subject', () => {
|
||||||
|
expect(buildReplySubject('foo', 'AW:')).toBe('AW: foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns just the bare prefix for an empty subject', () => {
|
||||||
|
expect(buildReplySubject('', 'AW:')).toBe('AW:');
|
||||||
|
expect(buildForwardSubject(null, 'Fwd:')).toBe('Fwd:');
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import {
|
||||||
|
findMailboxByRole,
|
||||||
|
fetchUnifiedEmails,
|
||||||
|
searchUnifiedEmails,
|
||||||
|
advancedSearchUnifiedEmails,
|
||||||
|
fetchUnifiedMailboxCounts,
|
||||||
|
getUnifiedRoles,
|
||||||
|
type UnifiedAccountClient,
|
||||||
|
} from '@/lib/unified-mailbox';
|
||||||
|
|
||||||
|
// ── factories ────────────────────────────────────────────────────────────────
|
||||||
|
const makeEmail = (id: string, receivedAt: string): Email =>
|
||||||
|
({ id, receivedAt } as unknown as Email);
|
||||||
|
|
||||||
|
const makeMailbox = (over: Partial<Mailbox> & { role: string }): Mailbox =>
|
||||||
|
({ id: `mb-${over.role}`, unreadEmails: 0, totalEmails: 0, ...over } as unknown as Mailbox);
|
||||||
|
|
||||||
|
type FetchResult = { emails: Email[]; total: number; hasMore: boolean };
|
||||||
|
|
||||||
|
function makeAccount(
|
||||||
|
over: Partial<UnifiedAccountClient> & { accountId: string },
|
||||||
|
clientImpl: Partial<IJMAPClient> = {},
|
||||||
|
): UnifiedAccountClient {
|
||||||
|
return {
|
||||||
|
accountLabel: over.accountId,
|
||||||
|
mailboxes: [],
|
||||||
|
client: clientImpl as unknown as IJMAPClient,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('findMailboxByRole', () => {
|
||||||
|
it('returns the first mailbox matching the role', () => {
|
||||||
|
const a = makeMailbox({ role: 'inbox', id: 'a' });
|
||||||
|
const b = makeMailbox({ role: 'inbox', id: 'b' });
|
||||||
|
expect(findMailboxByRole([a, b], 'inbox')).toBe(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when no mailbox has the role', () => {
|
||||||
|
expect(findMailboxByRole([makeMailbox({ role: 'sent' })], 'inbox')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchUnifiedEmails', () => {
|
||||||
|
it('merges across accounts and sorts by receivedAt descending, decorating each email', async () => {
|
||||||
|
const acc1 = makeAccount(
|
||||||
|
{ accountId: 'A', accountLabel: 'Account A', mailboxes: [makeMailbox({ role: 'inbox', id: 'a-in' })] },
|
||||||
|
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({
|
||||||
|
emails: [makeEmail('a1', '2026-01-01T10:00:00Z'), makeEmail('a2', '2026-01-03T10:00:00Z')],
|
||||||
|
total: 5, hasMore: false,
|
||||||
|
})) },
|
||||||
|
);
|
||||||
|
const acc2 = makeAccount(
|
||||||
|
{ accountId: 'B', accountLabel: 'Account B', mailboxes: [makeMailbox({ role: 'inbox', id: 'b-in' })] },
|
||||||
|
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({
|
||||||
|
emails: [makeEmail('b1', '2026-01-02T10:00:00Z')],
|
||||||
|
total: 3, hasMore: true,
|
||||||
|
})) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchUnifiedEmails([acc1, acc2], 'inbox', 20, 0);
|
||||||
|
|
||||||
|
expect(result.emails.map((e) => e.id)).toEqual(['a2', 'b1', 'a1']); // newest first
|
||||||
|
expect(result.total).toBe(8); // sum of per-account totals, not merged length
|
||||||
|
expect(result.hasMore).toBe(true); // OR across accounts
|
||||||
|
expect(result.errors.size).toBe(0);
|
||||||
|
// decoration
|
||||||
|
const a2 = result.emails.find((e) => e.id === 'a2')!;
|
||||||
|
expect(a2.accountId).toBe('A');
|
||||||
|
expect(a2.accountLabel).toBe('Account A');
|
||||||
|
// getEmails called with (mailboxId, accountId=undefined for personal, limit, position)
|
||||||
|
expect(acc1.client.getEmails).toHaveBeenCalledWith('a-in', undefined, 20, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates per-account errors and still returns the rest', async () => {
|
||||||
|
const ok = makeAccount(
|
||||||
|
{ accountId: 'OK', mailboxes: [makeMailbox({ role: 'inbox', id: 'ok-in' })] },
|
||||||
|
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [makeEmail('x', '2026-01-01T00:00:00Z')], total: 1, hasMore: false })) },
|
||||||
|
);
|
||||||
|
const boom = makeAccount(
|
||||||
|
{ accountId: 'BOOM', mailboxes: [makeMailbox({ role: 'inbox', id: 'boom-in' })] },
|
||||||
|
{ getEmails: vi.fn(async (): Promise<FetchResult> => { throw new Error('network down'); }) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchUnifiedEmails([ok, boom], 'inbox', 20, 0);
|
||||||
|
expect(result.emails.map((e) => e.id)).toEqual(['x']);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.errors.get('BOOM')).toBe('network down');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stringifies a non-Error rejection', async () => {
|
||||||
|
const acc = makeAccount(
|
||||||
|
{ accountId: 'S', mailboxes: [makeMailbox({ role: 'inbox' })] },
|
||||||
|
{ getEmails: vi.fn(async (): Promise<FetchResult> => { throw 'boom-string'; }) },
|
||||||
|
);
|
||||||
|
const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||||
|
expect(result.errors.get('S')).toBe('boom-string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips accounts that have no mailbox for the role (no error recorded)', async () => {
|
||||||
|
const getEmails = vi.fn(async (): Promise<FetchResult> => ({ emails: [], total: 0, hasMore: false }));
|
||||||
|
const acc = makeAccount(
|
||||||
|
{ accountId: 'NOROLE', mailboxes: [makeMailbox({ role: 'sent' })] },
|
||||||
|
{ getEmails },
|
||||||
|
);
|
||||||
|
const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||||
|
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
|
||||||
|
expect(getEmails).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty result for no accounts', async () => {
|
||||||
|
const result = await fetchUnifiedEmails([], 'inbox', 20, 0);
|
||||||
|
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CHARACTERISATION: mutates the source email objects in place (shared reference)', async () => {
|
||||||
|
const original = makeEmail('m1', '2026-01-01T00:00:00Z');
|
||||||
|
const acc = makeAccount(
|
||||||
|
{ accountId: 'A', accountLabel: 'Label A', mailboxes: [makeMailbox({ role: 'inbox' })] },
|
||||||
|
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [original], total: 1, hasMore: false })) },
|
||||||
|
);
|
||||||
|
await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||||
|
// The very object passed back by the client was mutated, not a copy.
|
||||||
|
expect(original.accountId).toBe('A');
|
||||||
|
expect(original.accountLabel).toBe('Label A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveJmapTarget (via searchUnifiedEmails / advancedSearchUnifiedEmails)', () => {
|
||||||
|
const empty = async (): Promise<FetchResult> => ({ emails: [], total: 0, hasMore: false });
|
||||||
|
|
||||||
|
it('personal account: uses mailbox.id and undefined accountId', async () => {
|
||||||
|
const searchEmails = vi.fn(empty);
|
||||||
|
const acc = makeAccount(
|
||||||
|
{ accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'real-id' })] },
|
||||||
|
{ searchEmails },
|
||||||
|
);
|
||||||
|
await searchUnifiedEmails([acc], 'inbox', 'hello', 10, 0);
|
||||||
|
expect(searchEmails).toHaveBeenCalledWith('hello', 'real-id', undefined, 10, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared account: uses mailbox.originalId and the owner accountId', async () => {
|
||||||
|
const searchEmails = vi.fn(empty);
|
||||||
|
const acc = makeAccount(
|
||||||
|
{ accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'OWNER:orig', originalId: 'orig' })] },
|
||||||
|
{ searchEmails },
|
||||||
|
);
|
||||||
|
await searchUnifiedEmails([acc], 'inbox', 'q', 10, 5);
|
||||||
|
expect(searchEmails).toHaveBeenCalledWith('q', 'orig', 'OWNER', 10, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared account without originalId: falls back to mailbox.id', async () => {
|
||||||
|
const searchEmails = vi.fn(empty);
|
||||||
|
const acc = makeAccount(
|
||||||
|
{ accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'just-id' })] },
|
||||||
|
{ searchEmails },
|
||||||
|
);
|
||||||
|
await searchUnifiedEmails([acc], 'inbox', 'q', 10, 0);
|
||||||
|
expect(searchEmails).toHaveBeenCalledWith('q', 'just-id', 'OWNER', 10, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advancedSearch: builds the filter from the resolved mailbox id and forwards accountId', async () => {
|
||||||
|
const advancedSearchEmails = vi.fn(empty);
|
||||||
|
const acc = makeAccount(
|
||||||
|
{ accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'mbx' })] },
|
||||||
|
{ advancedSearchEmails },
|
||||||
|
);
|
||||||
|
const filterFor = vi.fn((mailboxId: string) => ({ inMailbox: mailboxId, from: 'x' }));
|
||||||
|
await advancedSearchUnifiedEmails([acc], 'inbox', filterFor, 10, 0);
|
||||||
|
expect(filterFor).toHaveBeenCalledWith('mbx');
|
||||||
|
expect(advancedSearchEmails).toHaveBeenCalledWith({ inMailbox: 'mbx', from: 'x' }, undefined, 10, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchUnifiedMailboxCounts', () => {
|
||||||
|
it('aggregates counts per role across accounts, in ALL_UNIFIED_ROLES order, omitting absent roles', () => {
|
||||||
|
const acc1 = makeAccount({ accountId: 'A', mailboxes: [
|
||||||
|
makeMailbox({ role: 'inbox', unreadEmails: 2, totalEmails: 10 }),
|
||||||
|
makeMailbox({ role: 'sent', unreadEmails: 0, totalEmails: 4 }),
|
||||||
|
] });
|
||||||
|
const acc2 = makeAccount({ accountId: 'B', mailboxes: [
|
||||||
|
makeMailbox({ role: 'inbox', unreadEmails: 3, totalEmails: 7 }),
|
||||||
|
] });
|
||||||
|
|
||||||
|
expect(fetchUnifiedMailboxCounts([acc1, acc2])).toEqual([
|
||||||
|
{ role: 'inbox', unreadEmails: 5, totalEmails: 17 },
|
||||||
|
{ role: 'sent', unreadEmails: 0, totalEmails: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no accounts have mailboxes', () => {
|
||||||
|
expect(fetchUnifiedMailboxCounts([makeAccount({ accountId: 'A' })])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUnifiedRoles', () => {
|
||||||
|
it('lists roles present in at least one account once, in canonical order', () => {
|
||||||
|
const acc1 = makeAccount({ accountId: 'A', mailboxes: [makeMailbox({ role: 'drafts' }), makeMailbox({ role: 'inbox' })] });
|
||||||
|
const acc2 = makeAccount({ accountId: 'B', mailboxes: [makeMailbox({ role: 'inbox' }), makeMailbox({ role: 'trash' })] });
|
||||||
|
expect(getUnifiedRoles([acc1, acc2])).toEqual(['inbox', 'drafts', 'trash']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||||
|
|
||||||
|
// ── module mocks (hoisted) ───────────────────────────────────────────────────
|
||||||
|
vi.mock('next/server', () => {
|
||||||
|
class NextResponse {
|
||||||
|
body: unknown;
|
||||||
|
status: number;
|
||||||
|
headers: Headers;
|
||||||
|
constructor(body: unknown, init?: { status?: number; headers?: Headers }) {
|
||||||
|
this.body = body;
|
||||||
|
this.status = init?.status ?? 200;
|
||||||
|
this.headers = init?.headers ?? new Headers();
|
||||||
|
}
|
||||||
|
static json(data: unknown, init?: { status?: number }) {
|
||||||
|
return { status: init?.status ?? 200, headers: new Headers(), json: async () => data };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { NextResponse, NextRequest: class {} };
|
||||||
|
});
|
||||||
|
vi.mock('@/lib/logger', () => ({ logger: { error: () => {}, debug: () => {} } }));
|
||||||
|
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
|
||||||
|
|
||||||
|
import { POST } from '@/app/api/webdav/route';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
const mockCreds = getStalwartCredentials as unknown as Mock;
|
||||||
|
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user@example.com', authHeader: 'Basic abc' };
|
||||||
|
|
||||||
|
type Resp = { status: number; headers: Headers; body?: unknown; text?: () => Promise<string> };
|
||||||
|
let fetchSpy: Mock;
|
||||||
|
|
||||||
|
function makeReq(headers: Record<string, string> = {}, body: unknown = null): Parameters<typeof POST>[0] {
|
||||||
|
const lc: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
|
||||||
|
return {
|
||||||
|
headers: { get: (n: string) => lc[n.toLowerCase()] ?? null },
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
body,
|
||||||
|
} as unknown as Parameters<typeof POST>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The route returns either our mocked NextResponse instance or NextResponse.json's object.
|
||||||
|
function read(res: unknown): { status: number; headers?: Headers; json?: () => Promise<unknown>; body?: unknown } {
|
||||||
|
return res as { status: number; headers?: Headers; json?: () => Promise<unknown>; body?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreds.mockResolvedValue(CREDS);
|
||||||
|
fetchSpy = vi.fn(async (): Promise<Resp> => ({
|
||||||
|
status: 207,
|
||||||
|
headers: new Headers({ 'Content-Type': 'text/plain' }),
|
||||||
|
body: 'UPSTREAM-BODY',
|
||||||
|
text: async () => '<xml/>',
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/webdav — guards', () => {
|
||||||
|
it('401 when there are no credentials', async () => {
|
||||||
|
mockCreds.mockResolvedValue(null);
|
||||||
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'GET' })));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
await expect(res.json!()).resolves.toEqual({ error: 'Not authenticated' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 for a missing or disallowed method', async () => {
|
||||||
|
expect(read(await POST(makeReq({}))).status).toBe(400);
|
||||||
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'BOGUS' })));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV method' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 on a path-traversal segment', async () => {
|
||||||
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': '../etc' })));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path segment' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 on bad percent-encoding in the path', async () => {
|
||||||
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PUT', 'X-WebDAV-Path': '%zz' })));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path encoding' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/webdav — proxying', () => {
|
||||||
|
it('GET builds the upstream URL, forwards auth, and streams the body back', async () => {
|
||||||
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'get', 'X-WebDAV-Path': 'file.txt' })));
|
||||||
|
const target = 'https://mail.example.com/dav/file/user%40example.com/file.txt';
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
target,
|
||||||
|
expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: 'Basic abc' }) }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(207);
|
||||||
|
expect(res.body).toBe('UPSTREAM-BODY');
|
||||||
|
expect(res.headers!.get('Content-Type')).toBe('text/plain');
|
||||||
|
expect(res.headers!.get('X-WebDAV-Request-URI')).toBe(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PROPFIND forwards Depth and returns XML', async () => {
|
||||||
|
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': 'dir', Depth: '1' })));
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
'https://mail.example.com/dav/file/user%40example.com/dir',
|
||||||
|
expect.objectContaining({ method: 'PROPFIND', headers: expect.objectContaining({ Depth: '1' }) }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(207);
|
||||||
|
expect(res.body).toBe('<xml/>');
|
||||||
|
expect(res.headers!.get('Content-Type')).toBe('application/xml; charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MOVE rebuilds the Destination URL and forwards Overwrite', async () => {
|
||||||
|
await POST(makeReq({
|
||||||
|
'X-WebDAV-Method': 'MOVE',
|
||||||
|
'X-WebDAV-Path': 'old.txt',
|
||||||
|
'X-WebDAV-Destination': 'sub/new.txt',
|
||||||
|
Overwrite: 'F',
|
||||||
|
}));
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
'https://mail.example.com/dav/file/user%40example.com/old.txt',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'MOVE',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Destination: 'https://mail.example.com/dav/file/user%40example.com/sub/new.txt',
|
||||||
|
Overwrite: 'F',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user