mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
The push preview route now reads the device policy of the login it serves (its scopes worked out from the login's own JMAP session, every non-personal account counted as a group) and under `pushWithoutContent` asks the mail server for the message's ids only, so sender, subject and text never reach the notification. The service worker shows "New mail" for such an answer. A policy that can't be read counts as on. The three existing push preview route tests now mock the config manager with the default policy: without it the route can't read a policy in the test environment and holds the text back, which those tests didn't mean to cover. No assertion changed.
252 lines
10 KiB
TypeScript
252 lines
10 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 {},
|
|
}));
|
|
|
|
// The route reads the device policy (Organisation > Devices); none is set here.
|
|
vi.mock('@/lib/admin/config-manager', async () => {
|
|
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
|
|
return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } };
|
|
});
|
|
|
|
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);
|
|
});
|