mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
414 lines
18 KiB
TypeScript
414 lines
18 KiB
TypeScript
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
|
|
// Mock the environment variable
|
|
beforeAll(() => {
|
|
vi.stubEnv('DEV_MOCK_JMAP', 'true');
|
|
// Inlined from DEV_MOCK_JMAP at build time in a real build.
|
|
vi.stubEnv('NEXT_PUBLIC_DEV_MOCK_JMAP', 'true');
|
|
});
|
|
|
|
// We test via dynamic import to get a fresh module with env set
|
|
async function loadRoute() {
|
|
// Clear module cache to reset mock email state between test suites
|
|
const mod = await import('../../app/api/dev-jmap/[...path]/route');
|
|
return mod;
|
|
}
|
|
|
|
function makeRequest(url: string, options?: globalThis.RequestInit): Request {
|
|
return new Request(url, options);
|
|
}
|
|
|
|
describe('dev-jmap mock server', () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let GET: (request: any, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let POST: (request: any, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
|
|
|
beforeAll(async () => {
|
|
const mod = await loadRoute();
|
|
GET = mod.GET as typeof GET;
|
|
POST = mod.POST as typeof POST;
|
|
});
|
|
|
|
describe('GET /.well-known/jmap', () => {
|
|
it('should return session object with capabilities', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/.well-known/jmap', {
|
|
headers: { host: 'localhost:3000' },
|
|
});
|
|
const res = await GET(req, { params: Promise.resolve({ path: ['.well-known', 'jmap'] }) });
|
|
const data = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(data.capabilities).toBeDefined();
|
|
expect(data.capabilities['urn:ietf:params:jmap:core']).toBeDefined();
|
|
expect(data.accounts).toBeDefined();
|
|
expect(data.apiUrl).toContain('/api/dev-jmap/api');
|
|
expect(data.downloadUrl).toContain('/download/');
|
|
expect(data.eventSourceUrl).toContain('/eventsource');
|
|
});
|
|
});
|
|
|
|
describe('GET /download', () => {
|
|
it('should return a response with attachment disposition', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/download/dev-account-001/blob-att-001/Q1-Bericht.pdf?accept=application/pdf', {
|
|
headers: { host: 'localhost:3000' },
|
|
});
|
|
const res = await GET(req, { params: Promise.resolve({ path: ['download', 'dev-account-001', 'blob-att-001', 'Q1-Bericht.pdf'] }) });
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Content-Type')).toBe('application/pdf');
|
|
expect(res.headers.get('Content-Disposition')).toContain('Q1-Bericht.pdf');
|
|
});
|
|
});
|
|
|
|
describe('GET /eventsource', () => {
|
|
it('should return text/event-stream content type', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/eventsource?types=*&ping=30', {
|
|
headers: { host: 'localhost:3000' },
|
|
});
|
|
const res = await GET(req, { params: Promise.resolve({ path: ['eventsource'] }) });
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Content-Type')).toBe('text/event-stream');
|
|
expect(res.headers.get('Cache-Control')).toBe('no-cache');
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Mailbox/get', () => {
|
|
it('should return list of mailboxes', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Mailbox/get', { accountId: 'dev-account-001' }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses).toBeDefined();
|
|
expect(data.methodResponses[0][0]).toBe('Mailbox/get');
|
|
expect(data.methodResponses[0][1].list.length).toBeGreaterThan(0);
|
|
const inbox = data.methodResponses[0][1].list.find((m: { role: string }) => m.role === 'inbox');
|
|
expect(inbox).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('POST /api - back-references', () => {
|
|
it('resolves a wildcard path across a list (RFC 8620 §3.7)', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [
|
|
['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' }, limit: 3 }, '0'],
|
|
['Email/get', { accountId: 'dev-account-001', '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['threadId'] }, '1'],
|
|
['Thread/get', { accountId: 'dev-account-001', '#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' } }, '2'],
|
|
],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
const threadIds = data.methodResponses[1][1].list.map((e: { threadId: string }) => e.threadId);
|
|
expect(threadIds.length).toBeGreaterThan(0);
|
|
expect(data.methodResponses[2][0]).toBe('Thread/get');
|
|
expect(data.methodResponses[2][1].list.map((t: { id: string }) => t.id).sort()).toEqual([...new Set<string>(threadIds)].sort());
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Email/query', () => {
|
|
it('should filter by mailbox', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' } }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('Email/query');
|
|
expect(data.methodResponses[0][1].ids.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should filter by text search', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/query', { accountId: 'dev-account-001', filter: { text: 'willkommen' } }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][1].ids.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Email/get', () => {
|
|
it('should return emails by ids', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/get', { accountId: 'dev-account-001', ids: ['email-001'] }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('Email/get');
|
|
expect(data.methodResponses[0][1].list).toHaveLength(1);
|
|
expect(data.methodResponses[0][1].list[0].subject).toContain('Willkommen');
|
|
});
|
|
|
|
it('should filter properties when specified', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/get', { accountId: 'dev-account-001', ids: ['email-001'], properties: ['id', 'subject'] }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
const email = data.methodResponses[0][1].list[0];
|
|
expect(email.id).toBe('email-001');
|
|
expect(email.subject).toBeDefined();
|
|
expect(email.bodyValues).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Email/set', () => {
|
|
it('should update email keywords', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/set', { accountId: 'dev-account-001', update: { 'email-001': { 'keywords/$seen': true } } }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('Email/set');
|
|
expect(data.methodResponses[0][1].updated['email-001']).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Identity/get', () => {
|
|
it('should return identities', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Identity/get', { accountId: 'dev-account-001' }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('Identity/get');
|
|
expect(data.methodResponses[0][1].list.length).toBeGreaterThan(0);
|
|
expect(data.methodResponses[0][1].list[0].email).toBe('dev@localhost');
|
|
});
|
|
});
|
|
|
|
describe('POST /api - unknown method', () => {
|
|
it('should return error for unknown methods', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['FakeMethod/get', {}, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('error');
|
|
expect(data.methodResponses[0][1].type).toBe('unknownMethod');
|
|
});
|
|
});
|
|
|
|
describe('POST /api - PushSubscription lifecycle', () => {
|
|
it('creates, verifies, lists, and destroys a push subscription', async () => {
|
|
const relayFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(null, { status: 202 }),
|
|
);
|
|
|
|
try {
|
|
const createReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['PushSubscription/set', {
|
|
create: {
|
|
new: {
|
|
deviceClientId: 'browser-device-1',
|
|
url: 'https://notifications.relay.bulwarkmail.org/api/push/jmap/browser-device-1',
|
|
types: ['Email'],
|
|
expires: '2030-01-01T00:00:00.000Z',
|
|
},
|
|
},
|
|
}, 'c0']],
|
|
}),
|
|
});
|
|
const createRes = await POST(createReq, { params: Promise.resolve({ path: ['api'] }) });
|
|
const createData = await createRes.json();
|
|
const id = createData.methodResponses[0][1].created.new.id as string;
|
|
const verificationBody = JSON.parse(
|
|
String(relayFetch.mock.calls[0]?.[1]?.body),
|
|
) as { verificationCode: string };
|
|
|
|
expect(id).toMatch(/^push-/);
|
|
expect(relayFetch).toHaveBeenCalledOnce();
|
|
|
|
const verifyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['PushSubscription/set', {
|
|
update: { [id]: { verificationCode: verificationBody.verificationCode } },
|
|
}, 'v0']],
|
|
}),
|
|
});
|
|
const verifyRes = await POST(verifyReq, { params: Promise.resolve({ path: ['api'] }) });
|
|
const verifyData = await verifyRes.json();
|
|
expect(verifyData.methodResponses[0][1].updated[id]).toBeNull();
|
|
|
|
const getReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['PushSubscription/get', { ids: [id] }, 'g0']],
|
|
}),
|
|
});
|
|
const getRes = await POST(getReq, { params: Promise.resolve({ path: ['api'] }) });
|
|
const getData = await getRes.json();
|
|
expect(getData.methodResponses[0][1].list).toEqual([
|
|
expect.objectContaining({ id, deviceClientId: 'browser-device-1' }),
|
|
]);
|
|
|
|
const destroyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['PushSubscription/set', { destroy: [id] }, 'd0']],
|
|
}),
|
|
});
|
|
const destroyRes = await POST(destroyReq, { params: Promise.resolve({ path: ['api'] }) });
|
|
const destroyData = await destroyRes.json();
|
|
expect(destroyData.methodResponses[0][1].destroyed).toEqual([id]);
|
|
} finally {
|
|
relayFetch.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('POST /api - back-references', () => {
|
|
it('should resolve #ids from Email/query result', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [
|
|
['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' }, limit: 2 }, 'q0'],
|
|
['Email/get', { accountId: 'dev-account-001', '#ids': { resultOf: 'q0', name: 'Email/query', path: 'ids' }, properties: ['id', 'subject'] }, 'g0'],
|
|
],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses).toHaveLength(2);
|
|
expect(data.methodResponses[1][0]).toBe('Email/get');
|
|
// The get should have resolved ids from the query
|
|
expect(data.methodResponses[1][1].list.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('POST /api - invalid request', () => {
|
|
it('should return 400 for missing methodCalls', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('POST /api - request limits', () => {
|
|
it('should refuse a request with more method calls than it advertises', async () => {
|
|
const methodCalls = Array.from({ length: 17 }, (_, i) => [
|
|
'Email/query',
|
|
{ accountId: 'dev-account-001', limit: 0, calculateTotal: true },
|
|
`c${i}`,
|
|
]);
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls }),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(res.status).toBe(400);
|
|
expect(data.type).toBe('urn:ietf:params:jmap:error:limit');
|
|
expect(data.limit).toBe('maxCallsInRequest');
|
|
});
|
|
|
|
it('should reject an over-sized /set with requestTooLarge', async () => {
|
|
const destroy = Array.from({ length: 501 }, (_, i) => `email-${i}`);
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls: [['Email/set', { accountId: 'dev-account-001', destroy }, '0']] }),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(data.methodResponses[0][0]).toBe('error');
|
|
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
|
|
});
|
|
|
|
it('should reject an over-sized /get with requestTooLarge', async () => {
|
|
const ids = Array.from({ length: 501 }, (_, i) => `email-${i}`);
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls: [['Email/get', { accountId: 'dev-account-001', ids }, '0']] }),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('error');
|
|
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
|
|
});
|
|
});
|
|
|
|
describe('POST /upload', () => {
|
|
it('should return a fake blob response', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'image/png', 'Content-Length': '1024', host: 'localhost:3000' },
|
|
body: 'fake-data',
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['upload', 'dev-account-001'] }) });
|
|
const data = await res.json();
|
|
expect(data.accountId).toBe('dev-account-001');
|
|
expect(data.blobId).toBeDefined();
|
|
expect(data.type).toBe('image/png');
|
|
});
|
|
});
|
|
});
|
|
|
|
// The mock accepts any password; a build made without DEV_MOCK_JMAP (every
|
|
// release image) must not be able to switch it on at runtime.
|
|
describe('dev-jmap mock in a build made without it', () => {
|
|
it('stays off even when DEV_MOCK_JMAP is set at runtime', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_DEV_MOCK_JMAP', '');
|
|
try {
|
|
const { GET } = await loadRoute();
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/.well-known/jmap', {
|
|
headers: { host: 'localhost:3000' },
|
|
});
|
|
const res = await GET(req as never, { params: Promise.resolve({ path: ['.well-known', 'jmap'] }) });
|
|
expect(res.status).toBe(404);
|
|
} finally {
|
|
vi.stubEnv('NEXT_PUBLIC_DEV_MOCK_JMAP', 'true');
|
|
}
|
|
});
|
|
});
|