From 2fac6ebfb890795104e3ac7c65a9b8110379c770 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:13:31 +0200 Subject: [PATCH] test: add characterisation tests for untested integration seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/__tests__/account-state-manager.test.ts | 128 ++++++++++++ lib/__tests__/auth-crypto.test.ts | 95 +++++++++ lib/__tests__/birthday-calendar.test.ts | 90 +++++++++ lib/__tests__/caldav-discover-route.test.ts | 85 ++++++++ lib/__tests__/download-filename.test.ts | 127 ++++++++++++ lib/__tests__/eml-import.test.ts | 51 +++++ lib/__tests__/helpers/factories.ts | 34 ++++ lib/__tests__/mdn.test.ts | 95 +++++++++ lib/__tests__/session-cookie.test.ts | 22 +++ lib/__tests__/subject-prefix.test.ts | 67 +++++++ lib/__tests__/tnef.test.ts | Bin 0 -> 4311 bytes lib/__tests__/unified-mailbox.test.ts | 205 ++++++++++++++++++++ lib/__tests__/webdav-route.test.ts | 133 +++++++++++++ 13 files changed, 1132 insertions(+) create mode 100644 lib/__tests__/account-state-manager.test.ts create mode 100644 lib/__tests__/auth-crypto.test.ts create mode 100644 lib/__tests__/birthday-calendar.test.ts create mode 100644 lib/__tests__/caldav-discover-route.test.ts create mode 100644 lib/__tests__/download-filename.test.ts create mode 100644 lib/__tests__/eml-import.test.ts create mode 100644 lib/__tests__/helpers/factories.ts create mode 100644 lib/__tests__/mdn.test.ts create mode 100644 lib/__tests__/session-cookie.test.ts create mode 100644 lib/__tests__/subject-prefix.test.ts create mode 100644 lib/__tests__/tnef.test.ts create mode 100644 lib/__tests__/unified-mailbox.test.ts create mode 100644 lib/__tests__/webdav-route.test.ts diff --git a/lib/__tests__/account-state-manager.test.ts b/lib/__tests__/account-state-manager.test.ts new file mode 100644 index 00000000..658fe2c5 --- /dev/null +++ b/lib/__tests__/account-state-manager.test.ts @@ -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); + }); +}); diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts new file mode 100644 index 00000000..133aac23 --- /dev/null +++ b/lib/__tests__/auth-crypto.test.ts @@ -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(); + }); +}); diff --git a/lib/__tests__/birthday-calendar.test.ts b/lib/__tests__/birthday-calendar.test.ts new file mode 100644 index 00000000..cc14ad47 --- /dev/null +++ b/lib/__tests__/birthday-calendar.test.ts @@ -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 = {}): 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([]); + }); +}); diff --git a/lib/__tests__/caldav-discover-route.test.ts b/lib/__tests__/caldav-discover-route.test.ts new file mode 100644 index 00000000..488af11e --- /dev/null +++ b/lib/__tests__/caldav-discover-route.test.ts @@ -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[0] { + return { headers: { get: () => null }, json: async () => body } as unknown as Parameters[0]; +} +function read(res: unknown) { + return res as { status: number; json: () => Promise<{ wellKnownUrl: string; accounts: Record }> }; +} +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()); + }); +}); diff --git a/lib/__tests__/download-filename.test.ts b/lib/__tests__/download-filename.test.ts new file mode 100644 index 00000000..455483c9 --- /dev/null +++ b/lib/__tests__/download-filename.test.ts @@ -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 => + ({ 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: '' }); + }); +}); diff --git a/lib/__tests__/eml-import.test.ts b/lib/__tests__/eml-import.test.ts new file mode 100644 index 00000000..cf0b794a --- /dev/null +++ b/lib/__tests__/eml-import.test.ts @@ -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'); + }); +}); diff --git a/lib/__tests__/helpers/factories.ts b/lib/__tests__/helpers/factories.ts new file mode 100644 index 00000000..de075c0d --- /dev/null +++ b/lib/__tests__/helpers/factories.ts @@ -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 => + ({ + 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 => + ({ + 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 => + over as unknown as IJMAPClient; diff --git a/lib/__tests__/mdn.test.ts b/lib/__tests__/mdn.test.ts new file mode 100644 index 00000000..527d2fe1 --- /dev/null +++ b/lib/__tests__/mdn.test.ts @@ -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: $/m); // random token = 'i' + expect(msg).toMatch(/^In-Reply-To: $/m); + expect(msg).toContain('Original-Message-ID: '); + 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+/=]+\?= $/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: $/m, + ); + expect(buildMdnMessage({ ...base, originalMessageId: 'bare@x.com' })).toContain( + 'Original-Message-ID: ', + ); + }); + + 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: $/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); + }); +}); diff --git a/lib/__tests__/session-cookie.test.ts b/lib/__tests__/session-cookie.test.ts new file mode 100644 index 00000000..47752a40 --- /dev/null +++ b/lib/__tests__/session-cookie.test.ts @@ -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'); + }); +}); diff --git a/lib/__tests__/subject-prefix.test.ts b/lib/__tests__/subject-prefix.test.ts new file mode 100644 index 00000000..3fa4b4fe --- /dev/null +++ b/lib/__tests__/subject-prefix.test.ts @@ -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:'); + }); +}); diff --git a/lib/__tests__/tnef.test.ts b/lib/__tests__/tnef.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3e64c453665e7e2acacb7561627bac62260ea0e GIT binary patch literal 4311 zcmb_gZBOGy5bo#viuuyUIUL6chz7Vr4JAF0fJ2Zgst`gOdqY;W*Y0|q7a^qni2lO< zlFsbfv6FDUmsYw9WIZ#x^USl)j1h5~C=JgL@+?qd%v})L1-?jmfInvfUSOh<7@9Mo zd8V634NA3`Z>^=1=E;Iz4H306Nzz0<3sdp%!C&w!S7DfE{A-aKQ&!& zTJY`|oaYAMF>vV+&-0{`JUuKPJ-@hiVJO4;T9lxz^(K=!Q?mYDvvI_$1$c2AFRd1y zqtk)E|N0aEffjNJkB+M0Nsvs6hB}&LA&{Ga3SJlkmL`bHVH=KoNV}pG5 zvk^or%isEn z1Ne){q;&*}>s(1=aRe{q+udJxu32~#xMe=!`1lz57{K`K_P)ZW^6&1~g7x-_Xo}4p zrc{~0{R5>iNfbCiA~OwRNG5;=Et?mfY5ZCu<6J}`7WTQ!QSv!_6jJvGO0lI=fnxT3 zX*6v3(be#m$J_JwSA)^Vn;~NAFM7RQelYp5e^i)U{Ce?tIlR3cytmK&ZvDw%G#dPT zb~(Hn8E3y&K4e;-B;nGo`hKtP`$6s5<>31KS|w>#8tfnV{%*~{-i;gNSjaT1Th+I) zR;P6f+-R~DXC#oYWsq*)@A{?5_08kShtuC4&qkLQ#-QtWSLe7MJ>HIP&adA03*Ros z+hC#NGQdR#DVb@ay}6V3p63ztBfii>86-%0r{$q3{^n#YBy3}n%Rq}n!qnUeXMPBG zwZhH490|5w7bFf6)iHxs`x}KcnBr1#00$LNy?R8!Z<>`^WaUV$}?uExHS+HVH`58r-taSN>HU}q{9|! zj%A{n+&L&^CT&c%i(q0Ft9^qcm)br>sH?i$ob8^*-Nu};2%}RVdq)(_D3-RU;vl720XqvSomp&f^q|p?H=@+Q^Zqbr!QILK)XnG}*U`ycPwD zM(C_IaW;hLzUxBIh24i%tL5or_(#qn=NZN*6;!+^a$&0Dh@_+Zj=U_oB;GX?{ucuq zeSX2Y@t{H?)FOmTJV}NPfNvbxR&<((ufJ!*0^GDvhxKdhQJt!rHd=Zxqdw{qJJSZ#Vs)S(Ji(5;X+N~jdV~{oN18V z%`+ZFiP^SElK|yy@lqJJs`83$g^itu9OY4Asn92UJ=Pk8$}4MbO|Os#_Oh-}R$h$; z6?0I_C{m3K6>buYrqc;5WNT6{B&?JtdzBI20Nkppxv-&H^}Staz8xP=#oNyK_`7d| zbsaCKir2K7fZP!g&0<#{FHC<*Qy#7Zg%cK!ao#icy#m)lrOLj$gA*UM&K)2H_%AqB zlY-&cz})Jxzq@enyWnFrNzpRRaK^6AWN5ce*H@#NE?*FLA|meTg{}bpf&izOBOayD zC>QoDy}pw3)xk4j>KU8y3)B3l>zG_t*P~{7?>~5w#k{;NWdc~yfAxt)%ty;~<;T)A z5&@$Tqmu-h>o&G1&f<+Y)Faj|741(oeyH89O(1IbEYmc+F)R~kg(D4)=%E=qj1qwp zavb6nuGzkijUnJX%usb=R5O~FHpoPE7f(nuD^@Oc8IBrfWX{T4@dPlih>6?>2M4wZ z!CdGm$OIK>86_+@L`JYecUC@LL<3!MSSLn{SJ2 z$WiBxna1%eLl`8Y8&Mq*7$7>~iv-JfTgZ&dOlUFVo4^-j+}y-z)}o?m?~8HPrk7s0Jii=*Mmle5@+VKZKs1_l(ifq&w_Y`q?IUjm znaR-mWT09O<;*l;QO3=rgfqE$<*n%#No + ({ id, receivedAt } as unknown as Email); + +const makeMailbox = (over: Partial & { 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 & { accountId: string }, + clientImpl: Partial = {}, +): 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 => ({ + 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 => ({ + 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 => ({ 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 => { 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 => { 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 => ({ 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 => ({ 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 => ({ 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']); + }); +}); diff --git a/lib/__tests__/webdav-route.test.ts b/lib/__tests__/webdav-route.test.ts new file mode 100644 index 00000000..2aed7ddf --- /dev/null +++ b/lib/__tests__/webdav-route.test.ts @@ -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 }; +let fetchSpy: Mock; + +function makeReq(headers: Record = {}, body: unknown = null): Parameters[0] { + const lc: Record = {}; + 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[0]; +} + +// The route returns either our mocked NextResponse instance or NextResponse.json's object. +function read(res: unknown): { status: number; headers?: Headers; json?: () => Promise; body?: unknown } { + return res as { status: number; headers?: Headers; json?: () => Promise; body?: unknown }; +} + +beforeEach(() => { + mockCreds.mockResolvedValue(CREDS); + fetchSpy = vi.fn(async (): Promise => ({ + status: 207, + headers: new Headers({ 'Content-Type': 'text/plain' }), + body: 'UPSTREAM-BODY', + text: async () => '', + })); + 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(''); + 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', + }), + }), + ); + }); +});