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:
Stefan Hildebrandt
2026-06-19 23:52:42 +02:00
committed by Linus Rath
parent dda9fd1433
commit 2fac6ebfb8
13 changed files with 1132 additions and 0 deletions
+128
View File
@@ -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);
});
});
+95
View File
@@ -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();
});
});
+90
View File
@@ -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());
});
});
+127
View File
@@ -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: '' });
});
});
+51
View File
@@ -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');
});
});
+34
View File
@@ -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;
+95
View File
@@ -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);
});
});
+22
View File
@@ -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');
});
});
+67
View File
@@ -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.
+205
View File
@@ -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']);
});
});
+133
View File
@@ -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',
}),
}),
);
});
});