mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
246 lines
9.8 KiB
TypeScript
246 lines
9.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { endlessResponse } from './helpers/endless-body';
|
|
|
|
vi.mock('next/server', () => ({
|
|
NextResponse: {
|
|
json: (data: unknown, init?: { status?: number }) => ({
|
|
json: async () => data,
|
|
status: init?.status ?? 200,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('next/headers', () => ({
|
|
cookies: async () => ({ get: () => undefined }),
|
|
}));
|
|
|
|
vi.mock('@/lib/logger', () => ({
|
|
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 2 }));
|
|
|
|
vi.mock('@/lib/stalwart/auth-context', () => ({
|
|
readStalwartAuthContextFromStore: (_store: unknown, slot: number) =>
|
|
slot === 0 ? { serverUrl: 'https://mail.example.com/', authHeader: 'Bearer tok' } : null,
|
|
}));
|
|
|
|
vi.mock('@/lib/stalwart/credentials', () => ({
|
|
getStalwartCredentials: vi.fn(),
|
|
}));
|
|
|
|
const fetchJmapServer = vi.fn();
|
|
vi.mock('@/lib/stalwart/server-fetch', () => ({
|
|
fetchJmapServer: (...args: unknown[]) => fetchJmapServer(...args),
|
|
isTrustedJmapServerUrl: async () => true,
|
|
}));
|
|
|
|
vi.mock('@/lib/security/url-guard', () => ({
|
|
DisallowedUrlError: class DisallowedUrlError extends Error {},
|
|
}));
|
|
|
|
function jsonResponse(data: unknown) {
|
|
return { ok: true, json: async () => data };
|
|
}
|
|
|
|
const SESSION = {
|
|
apiUrl: 'https://mail.example.com/jmap',
|
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' },
|
|
accounts: { a: { name: 'me@example.com' }, g: { name: 'group@example.com' } },
|
|
};
|
|
|
|
function mockJmap() {
|
|
fetchJmapServer.mockImplementation(async (url: string, init?: { body?: string }) => {
|
|
if (url.endsWith('/.well-known/jmap')) return jsonResponse(SESSION);
|
|
const body = JSON.parse(init?.body ?? '{}') as { methodCalls: [string, Record<string, unknown>, string][] };
|
|
const first = body.methodCalls[0];
|
|
if (first[0] === 'Mailbox/query') {
|
|
return jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] });
|
|
}
|
|
return jsonResponse({
|
|
methodResponses: [
|
|
['Email/query', { ids: ['e1'], total: 1 }, 'eq'],
|
|
['Email/get', { list: [{ id: 'e1', threadId: 't1', subject: 'Hi' }] }, 'eg'],
|
|
],
|
|
});
|
|
});
|
|
}
|
|
|
|
async function callRoute(accountId: string, emailId: string | null = null) {
|
|
const { GET } = await import('@/app/api/push/preview/route');
|
|
const request = {
|
|
headers: new Headers(),
|
|
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? emailId : null) } },
|
|
};
|
|
const res = (await GET(request as unknown as Parameters<typeof GET>[0])) as unknown as {
|
|
status: number;
|
|
json: () => Promise<Record<string, unknown>>;
|
|
};
|
|
return { status: res.status, body: await res.json() };
|
|
}
|
|
|
|
describe('push preview route account resolution', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockJmap();
|
|
});
|
|
|
|
it('resolves the primary mail account', async () => {
|
|
const { status } = await callRoute('a');
|
|
expect(status).toBe(200);
|
|
});
|
|
|
|
it('resolves a shared/group account listed in session.accounts', async () => {
|
|
const { status, body } = await callRoute('g');
|
|
|
|
expect(status).not.toBe(401);
|
|
expect(status).toBe(200);
|
|
expect(body.email).toMatchObject({ id: 'e1' });
|
|
|
|
// The Inbox lookup must be scoped to the shared account, not the primary.
|
|
const mailboxQuery = fetchJmapServer.mock.calls
|
|
.map(([, init]) => (init as { body?: string })?.body)
|
|
.filter((b): b is string => typeof b === 'string')
|
|
.map((b) => JSON.parse(b) as { methodCalls: [string, Record<string, unknown>, string][] })
|
|
.find((b) => b.methodCalls[0][0] === 'Mailbox/query');
|
|
expect(mailboxQuery?.methodCalls[0][1].accountId).toBe('g');
|
|
});
|
|
|
|
it('names the login (cookie slot) the account belongs to', async () => {
|
|
// The worker only knows the JMAP account id, which can repeat across
|
|
// servers; the click must open the message in this login.
|
|
const { body } = await callRoute('a');
|
|
expect(body.slot).toBe(0);
|
|
});
|
|
|
|
it('rejects an account the session does not know', async () => {
|
|
const { status } = await callRoute('stranger');
|
|
expect(status).toBe(401);
|
|
});
|
|
});
|
|
|
|
|
|
describe('push preview JMAP failures', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockJmap();
|
|
});
|
|
|
|
it.each([
|
|
['error', { type: 'unsupportedFilter' }, 'mb'],
|
|
['Mailbox/query', {}, 'mb'],
|
|
])('does not silence a push when mailbox lookup fails (%s)', async (method, body, id) => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [[method, body, id]] }));
|
|
expect((await callRoute('a')).status).toBe(502);
|
|
});
|
|
|
|
it.each([
|
|
[['error', { type: 'serverFail' }, 'eq'], ['error', { type: 'resultReference' }, 'eg']],
|
|
[['Email/query', { ids: ['e1'], total: 1 }, 'eq'], ['error', { type: 'serverFail' }, 'eg']],
|
|
])('does not report zero unread on an email method failure', async (query, get) => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [query, get] }));
|
|
expect((await callRoute('a')).status).toBe(502);
|
|
});
|
|
|
|
it('still reports genuinely empty unread results', async () => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['Email/query', { ids: [], total: 0 }, 'eq'],
|
|
['Email/get', { list: [] }, 'eg'],
|
|
] }));
|
|
expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } });
|
|
});
|
|
|
|
// `total` is optional in a JMAP Email/query response.
|
|
it('counts the ids when the server leaves out the total', async () => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['Email/query', { ids: [] }, 'eq'],
|
|
['Email/get', { list: [] }, 'eg'],
|
|
] }));
|
|
expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } });
|
|
});
|
|
|
|
it('previews the named message even when the Inbox lookups fail', async () => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['error', { type: 'serverFail' }, 'eq'],
|
|
['error', { type: 'resultReference' }, 'eg'],
|
|
['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'],
|
|
] }));
|
|
expect(await callRoute('a', 'd')).toEqual({
|
|
status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0 },
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
it('previews a delivered message outside an empty Inbox', async () => {
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['Email/query', { ids: [], total: 0 }, 'eq'],
|
|
['Email/get', { list: [] }, 'eg'],
|
|
['Email/get', { list: [{ id: 'filed', threadId: 't2' }] }, 'delivered'],
|
|
] }));
|
|
expect(await callRoute('a', 'filed')).toEqual({
|
|
status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0 },
|
|
});
|
|
});
|
|
|
|
it('names no message when the delivered one cannot be read, rather than another', async () => {
|
|
// The push named a message - deleted since, or filed where this login
|
|
// cannot see it - and the Inbox happens to hold an older unread one. That
|
|
// older message is not what arrived.
|
|
fetchJmapServer.mockReset()
|
|
.mockResolvedValueOnce(jsonResponse(SESSION))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }))
|
|
.mockResolvedValueOnce(jsonResponse({ methodResponses: [
|
|
['Email/query', { ids: ['stale'], total: 3 }, 'eq'],
|
|
['Email/get', { list: [{ id: 'stale', threadId: 't9' }] }, 'eg'],
|
|
['Email/get', { list: [] }, 'delivered'],
|
|
] }));
|
|
expect(await callRoute('a', 'gone')).toEqual({
|
|
status: 200, body: { email: null, unreadTotal: 3, slot: 0 },
|
|
});
|
|
});
|
|
|
|
it('sends the method calls to the server address it was configured with, not the public one', async () => {
|
|
// Stalwart advertises its own public hostname in apiUrl, which the webmail
|
|
// may not reach when it talks to the server over an internal address (#627).
|
|
fetchJmapServer.mockReset();
|
|
mockJmap();
|
|
const session = { ...SESSION, apiUrl: 'https://public.example.org/jmap/' };
|
|
const reply = fetchJmapServer.getMockImplementation()!;
|
|
fetchJmapServer.mockImplementation(async (url: string, init?: { body?: string }) =>
|
|
url.endsWith('/.well-known/jmap') ? jsonResponse(session) : reply(url, init));
|
|
|
|
expect((await callRoute('a')).status).toBe(200);
|
|
const posted = fetchJmapServer.mock.calls.map(([url]) => url).filter((url) => !url.endsWith('/.well-known/jmap'));
|
|
expect(posted).toEqual(['https://mail.example.com/jmap/', 'https://mail.example.com/jmap/']);
|
|
});
|
|
|
|
it('answers 502 instead of buffering a mailbox answer that never ends', async () => {
|
|
fetchJmapServer.mockReset();
|
|
mockJmap();
|
|
const reply = fetchJmapServer.getMockImplementation()!;
|
|
const endless = endlessResponse(500);
|
|
fetchJmapServer.mockImplementation(async (url: string, init?: { body?: string }) =>
|
|
url.endsWith('/.well-known/jmap') ? reply(url, init) : endless.response);
|
|
|
|
expect((await callRoute('a')).status).toBe(502);
|
|
expect(endless.pulled()).toBeLessThan(10);
|
|
});
|