Files
cloudflare_temp_email/e2e/tests/api/webhook-attachments.spec.ts
T

186 lines
10 KiB
TypeScript

import { test, expect } from '@playwright/test';
import http from 'node:http';
import { createHmac, randomUUID } from 'node:crypto';
import { createTestAddress } from '../../fixtures/test-helpers';
const variants = [
{ name: 'on', url: process.env.WORKER_URL!, removeAll: false, removeLarge: true },
{ name: 'gzip', url: process.env.WORKER_GZIP_URL!, removeAll: false, removeLarge: false },
{ name: 'off', url: process.env.WORKER_URL_ENV_OFF!, removeAll: true, removeLarge: false },
];
for (const variant of variants) {
test.describe(`Webhook attachments: ${variant.name}`, () => {
test('links, expiry, ownership binding, deletion and removal settings', async ({ request }) => {
test.setTimeout(60_000);
expect(variant.url, 'Worker variant must be configured in CI').toBeTruthy();
const { jwt, address, address_id } = await createTestAddress(request, 'attachments', undefined, variant.url);
const jwtSecret = variant.removeAll ? 'e2e-test-secret-key-env-off' : 'e2e-test-secret-key';
const headers = { Authorization: `Bearer ${jwt}` };
const payloads: any[] = [];
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
try {
payloads.push(JSON.parse(Buffer.concat(chunks).toString()));
res.writeHead(200).end();
} catch {
res.writeHead(400).end();
}
});
});
await new Promise<void>(resolve => server.listen(0, '0.0.0.0', resolve));
const port = (server.address() as import('node:net').AddressInfo).port;
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
try {
const settings = {
enabled: true, url: `http://${hostname}:${port}`, method: 'POST',
headers: '{"Content-Type":"application/json"}',
body: '{"id":"${id}","ai":${aiExtract},"attachments":${attachments},"text":"${parsedText}","links":"${attachmentLinks}","markdownLinks":"${attachmentMarkdownLinks}"}',
};
expect((await request.post(`${variant.url}/api/webhook/settings`, { headers, data: settings })).ok()).toBe(true);
const receive = async (
files: { name: string, type: string, content: string }[], large = false,
messageId: string | null = `<${randomUUID()}@test>`, waitForWebhook = true,
) => {
const boundary = randomUUID();
const raw = [
'From: sender@test.example.com', `To: ${address}`, 'Subject: Attachment coverage',
...(messageId === null ? [] : [`Message-ID: ${messageId}`]), 'MIME-Version: 1.0',
`Content-Type: multipart/mixed; boundary="${boundary}"`, '',
`--${boundary}`, 'Content-Type: text/plain; charset=utf-8', '',
'Preserved body: "quoted" \\path $& ${from}' + (large ? 'x'.repeat(2 * 1024 * 1024) : ''),
...files.flatMap(file => [
`--${boundary}`, `Content-Type: ${file.type}`,
`Content-Disposition: attachment; filename="${file.name}"`,
'Content-Transfer-Encoding: base64', '', Buffer.from(file.content).toString('base64'),
]), `--${boundary}--`,
].join('\r\n');
const count = payloads.length;
const result = await request.post(`${variant.url}/__test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw },
});
expect(result.ok()).toBe(true);
expect((await result.json()).success).toBe(true);
if (!waitForWebhook) return;
await expect.poll(() => payloads.length).toBe(count + 1);
return payloads[count];
};
const files = [
{ name: 'first.png', type: 'image/png', content: 'first bytes' },
{ name: "报告 (copy)'!.txt", type: 'text/plain', content: 'second bytes' },
{ name: 'third.svg', type: 'image/svg+xml', content: '<svg onload="alert(1)"/>' },
];
const payload = await receive(files);
expect(payload.text).toContain('Preserved body: "quoted" \\path $& ${from}');
expect(payload.attachments).toHaveLength(variant.removeAll ? 0 : files.length);
expect(payload.links).toBe(payload.attachments.map((a: any) => a.url).join('\n'));
const markdownNames = ['first.png', "报告 \\(copy\\)'\\!.txt", 'third.svg'];
expect(payload.markdownLinks).toBe(payload.attachments.map((a: any, index: number) => `[${markdownNames[index]}](${a.url})`).join('\n'));
for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) {
const count = payloads.length;
expect((await request.post(`${variant.url}${endpoint}`, { headers, data: settings })).ok()).toBe(true);
await expect.poll(() => payloads.length).toBe(count + 1);
const preview = payloads[count];
expect(preview.ai).toBeNull();
expect(Array.isArray(preview.attachments)).toBe(true);
if (endpoint.startsWith('/api/')) {
expect(preview.attachments).toHaveLength(variant.removeAll ? 0 : files.length);
}
for (const attachment of preview.attachments) {
expect((await request.get(attachment.url)).status()).toBe(200);
}
}
const detail = await request.get(`${variant.url}/api/mail/${payload.id}`, { headers });
expect(detail.ok()).toBe(true);
const row = await detail.json();
const now = Math.floor(Date.now() / 1000);
const sign = (index: number, expires = now + 600, id = Number(payload.id), recipient = address, created = row.created_at, secret = jwtSecret) => {
const signature = createHmac('sha256', secret).update(JSON.stringify([
'webhook-attachment-v1', id, recipient, created, expires, index,
])).digest('base64url');
return `/open_api/a/${id}/${index}/${expires}/${signature}`;
};
const get = (path: string) => request.get(new URL(path, variant.url).href);
for (const [index, attachment] of payload.attachments.entries()) {
expect(attachment.filename).toBe(files[index].name);
expect(attachment.mimeType).toBe(files[index].type);
expect(new URL(attachment.url).origin).toBe(new URL(variant.url).origin);
const response = await get(attachment.url);
expect(response.status()).toBe(200);
expect(await response.text()).toBe(files[index].content);
expect(response.headers()['cache-control']).toBe('no-store');
expect(response.headers()['x-content-type-options']).toBe('nosniff');
const filename = encodeURIComponent(files[index].name).replace(/[!'()*]/g,
character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
expect(response.headers()['content-disposition']).toBe(index === 0 ? 'inline' : `attachment; filename*=UTF-8''${filename}`);
expect(response.headers()['content-type']).toBe(index === 0 ? 'image/png' : 'application/octet-stream');
}
expect((await get(sign(0))).status()).toBe(variant.removeAll ? 404 : 200);
for (const path of [
sign(0, now - 1), sign(0, now + 86460), sign(-1), sign(999),
sign(0, now + 600, Number(payload.id), 'other@test.example.com'),
sign(0, now + 600, Number(payload.id), address, '2000-01-01 00:00:00'),
sign(0, now + 600, Number(payload.id), address, row.created_at, 'wrong-secret'),
sign(0, now + 600, Number.MAX_SAFE_INTEGER),
`/open_api/a/${payload.id}/invalid/${now + 600}/${'A'.repeat(43)}`,
`/open_api/a/${payload.id}/0/${now + 600}/short`,
]) {
expect((await get(path)).status(), path).toBe(404);
}
const empty = await receive([]);
expect(empty.attachments).toEqual([]);
expect(empty.links).toBe('');
expect(empty.markdownLinks).toBe('');
const emptyRow = await (await request.get(`${variant.url}/api/mail/${empty.id}`, { headers })).json();
expect((await get(sign(0, now + 600, Number(empty.id), address, emptyRow.created_at))).status()).toBe(404);
const deleted = await request.delete(`${variant.url}/admin/mails/${payload.id}`);
expect(deleted.ok()).toBe(true);
expect((await get(sign(0))).status()).toBe(404);
if (variant.removeLarge) {
const large = await receive(files, true);
expect(large.attachments).toEqual([]);
expect(large.text).toContain('Preserved body');
const largeRow = await (await request.get(`${variant.url}/api/mail/${large.id}`, { headers })).json();
expect((await get(sign(0, now + 600, Number(large.id), address, largeRow.created_at))).status()).toBe(404);
}
if (!variant.removeAll) {
for (const messageId of [`<${randomUUID()}@duplicate.test>`, null]) {
const count = payloads.length;
const names = Array.from({ length: 4 }, () => `${randomUUID()}.txt`);
await Promise.all(names.map(name => receive([
{ name, type: 'text/plain', content: name },
], false, messageId, false)));
await expect.poll(() => payloads.length).toBe(count + names.length);
const received = payloads.slice(count);
expect(new Set(received.map(mail => mail.id)).size).toBe(names.length);
expect(received.map(mail => mail.attachments[0].filename).sort()).toEqual([...names].sort());
for (const mail of received) {
expect(mail.attachments).toHaveLength(1);
const attachment = mail.attachments[0];
const response = await request.get(attachment.url);
expect(response.status()).toBe(200);
expect(await response.text()).toBe(attachment.filename);
expect(new URL(attachment.url).pathname.split('/')[3]).toBe(String(mail.id));
}
}
}
} finally {
await request.delete(`${variant.url}/admin/delete_address/${address_id}`);
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
});
}