mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-11 18:10:01 +08:00
feat: add webhook preset templates for Message Pusher, Bark, and ntfy (#877)
This commit is contained in:
@@ -11,32 +11,39 @@ test.describe('Inbox Browser Flow', () => {
|
||||
test('login via JWT, view inbox, open email', async ({ page }) => {
|
||||
// Create API context for setup
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
const { jwt, address } = await createTestAddress(api, 'inbox-browser');
|
||||
try {
|
||||
const created = await createTestAddress(api, 'inbox-browser');
|
||||
jwt = created.jwt;
|
||||
const address = created.address;
|
||||
|
||||
// Seed an email
|
||||
const subject = `Browser Test ${Date.now()}`;
|
||||
await seedTestMail(api, address, {
|
||||
subject,
|
||||
html: '<h1>Welcome</h1><p>This is a <b>browser test</b> email.</p>',
|
||||
});
|
||||
// Seed an email
|
||||
const subject = `Browser Test ${Date.now()}`;
|
||||
await seedTestMail(api, address, {
|
||||
subject,
|
||||
html: '<h1>Welcome</h1><p>This is a <b>browser test</b> email.</p>',
|
||||
});
|
||||
|
||||
// Login via JWT query param with /en/ path to force English locale
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
// Login via JWT query param with /en/ path to force English locale
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// The mail subject should be visible in the inbox list item
|
||||
const mailItem = page.getByRole('listitem').getByText(subject);
|
||||
await expect(mailItem).toBeVisible({ timeout: 10_000 });
|
||||
// The mail subject should be visible in the inbox list item
|
||||
const mailItem = page.getByRole('listitem').getByText(subject);
|
||||
await expect(mailItem).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click to open the email
|
||||
await mailItem.click();
|
||||
// Click to open the email
|
||||
await mailItem.click();
|
||||
|
||||
// Verify the email detail panel shows the subject as a heading
|
||||
// (n-card-header wraps n-card-header__main, both match heading role — use .first())
|
||||
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Cleanup
|
||||
await deleteAddress(api, jwt);
|
||||
await api.dispose();
|
||||
// Verify the email detail panel shows the subject as a heading
|
||||
// (n-card-header wraps n-card-header__main, both match heading role — use .first())
|
||||
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 5_000 });
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,86 +12,94 @@ import { request as apiRequest } from '@playwright/test';
|
||||
test.describe('Reply HTML & XSS Sanitization', () => {
|
||||
test('reply to HTML email — XSS payloads stripped, HTML preserved', async ({ page }) => {
|
||||
const api = await apiRequest.newContext();
|
||||
await deleteAllMailpitMessages(api);
|
||||
let jwt: string | undefined;
|
||||
|
||||
const { jwt, address } = await createTestAddress(api, 'reply-xss');
|
||||
try {
|
||||
await deleteAllMailpitMessages(api);
|
||||
|
||||
// Request send access so Reply can navigate to compose form
|
||||
await requestSendAccess(api, jwt);
|
||||
const created = await createTestAddress(api, 'reply-xss');
|
||||
jwt = created.jwt;
|
||||
const address = created.address;
|
||||
|
||||
// Seed email with XSS payloads embedded in HTML
|
||||
const xssHtml = [
|
||||
'<div>',
|
||||
' <h1>Important Message</h1>',
|
||||
' <p>Please review this content.</p>',
|
||||
' <script>alert("xss")</script>',
|
||||
' <img src=x onerror="alert(1)">',
|
||||
' <a href="javascript:alert(2)">click me</a>',
|
||||
' <p style="color:red">Styled paragraph</p>',
|
||||
'</div>',
|
||||
].join('\n');
|
||||
// Request send access so Reply can navigate to compose form
|
||||
await requestSendAccess(api, jwt);
|
||||
|
||||
await seedTestMail(api, address, {
|
||||
subject: 'XSS Test Email',
|
||||
html: xssHtml,
|
||||
from: 'attacker@test.example.com',
|
||||
});
|
||||
// Seed email with XSS payloads embedded in HTML
|
||||
const xssHtml = [
|
||||
'<div>',
|
||||
' <h1>Important Message</h1>',
|
||||
' <p>Please review this content.</p>',
|
||||
' <script>alert("xss")</script>',
|
||||
' <img src=x onerror="alert(1)">',
|
||||
' <a href="javascript:alert(2)">click me</a>',
|
||||
' <p style="color:red">Styled paragraph</p>',
|
||||
'</div>',
|
||||
].join('\n');
|
||||
|
||||
// Single dialog handler with phase tracking.
|
||||
// During email rendering, the mail viewer uses an unsandboxed iframe so
|
||||
// inline event handlers like onerror may fire — we dismiss those.
|
||||
// After clicking Reply, any dialog means the compose path failed to sanitize.
|
||||
let inComposePhase = false;
|
||||
let composeDialogAppeared = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
if (inComposePhase) composeDialogAppeared = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await seedTestMail(api, address, {
|
||||
subject: 'XSS Test Email',
|
||||
html: xssHtml,
|
||||
from: 'attacker@test.example.com',
|
||||
});
|
||||
|
||||
// Login with English locale
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
// Single dialog handler with phase tracking.
|
||||
// During email rendering, the mail viewer uses an unsandboxed iframe so
|
||||
// inline event handlers like onerror may fire — we dismiss those.
|
||||
// After clicking Reply, any dialog means the compose path failed to sanitize.
|
||||
let inComposePhase = false;
|
||||
let composeDialogAppeared = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
if (inComposePhase) composeDialogAppeared = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
|
||||
// Open the email (use listitem to avoid strict mode violation
|
||||
// when detail panel also shows the subject)
|
||||
const mailItem = page.getByRole('listitem').getByText('XSS Test Email');
|
||||
await expect(mailItem).toBeVisible({ timeout: 10_000 });
|
||||
await mailItem.click();
|
||||
// Login with English locale
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// Wait for Reply button to appear — signals email content has rendered
|
||||
const replyButton = page.locator('button').filter({ hasText: /Reply/i }).first();
|
||||
await expect(replyButton).toBeVisible({ timeout: 10_000 });
|
||||
// Open the email (use listitem to avoid strict mode violation
|
||||
// when detail panel also shows the subject)
|
||||
const mailItem = page.getByRole('listitem').getByText('XSS Test Email');
|
||||
await expect(mailItem).toBeVisible({ timeout: 10_000 });
|
||||
await mailItem.click();
|
||||
|
||||
// Click Reply — from here on, dialogs indicate sanitization failure (#857)
|
||||
inComposePhase = true;
|
||||
await replyButton.click();
|
||||
// Wait for Reply button to appear — signals email content has rendered
|
||||
const replyButton = page.locator('button').filter({ hasText: /Reply/i }).first();
|
||||
await expect(replyButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// In the reply compose area, check that the forwarded HTML is sanitized:
|
||||
// - <script> tags should be removed
|
||||
// - onerror attributes should be removed
|
||||
// - javascript: URLs should be removed
|
||||
const composeArea = page.locator('.ql-editor, [contenteditable], textarea').first();
|
||||
await expect(composeArea).toBeVisible({ timeout: 5_000 });
|
||||
// Click Reply — from here on, dialogs indicate sanitization failure (#857)
|
||||
inComposePhase = true;
|
||||
await replyButton.click();
|
||||
|
||||
// Use inputValue() for <textarea> (Vue v-model sets .value, not innerHTML),
|
||||
// fall back to innerHTML() for contenteditable elements
|
||||
const tagName = await composeArea.evaluate(el => el.tagName.toLowerCase());
|
||||
const content = tagName === 'textarea'
|
||||
? await composeArea.inputValue()
|
||||
: await composeArea.innerHTML();
|
||||
// In the reply compose area, check that the forwarded HTML is sanitized:
|
||||
// - <script> tags should be removed
|
||||
// - onerror attributes should be removed
|
||||
// - javascript: URLs should be removed
|
||||
const composeArea = page.locator('.ql-editor, [contenteditable], textarea').first();
|
||||
await expect(composeArea).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Verify content is non-empty (guard against vacuous pass)
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
// Use inputValue() for <textarea> (Vue v-model sets .value, not innerHTML),
|
||||
// fall back to innerHTML() for contenteditable elements
|
||||
const tagName = await composeArea.evaluate(el => el.tagName.toLowerCase());
|
||||
const content = tagName === 'textarea'
|
||||
? await composeArea.inputValue()
|
||||
: await composeArea.innerHTML();
|
||||
|
||||
// XSS vectors must be stripped
|
||||
expect(content).not.toContain('<script>');
|
||||
expect(content).not.toContain('onerror');
|
||||
expect(content).not.toContain('javascript:');
|
||||
// Verify content is non-empty (guard against vacuous pass)
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
// No XSS dialog should have fired in the compose area
|
||||
expect(composeDialogAppeared).toBe(false);
|
||||
// XSS vectors must be stripped
|
||||
expect(content).not.toContain('<script>');
|
||||
expect(content).not.toContain('onerror');
|
||||
expect(content).not.toContain('javascript:');
|
||||
|
||||
// Cleanup
|
||||
await deleteAddress(api, jwt);
|
||||
await api.dispose();
|
||||
// No XSS dialog should have fired in the compose area
|
||||
expect(composeDialogAppeared).toBe(false);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
110
e2e/tests/browser/webhook-presets.spec.ts
Normal file
110
e2e/tests/browser/webhook-presets.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { FRONTEND_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
|
||||
test.describe('Webhook Presets', () => {
|
||||
test('selecting each preset fills valid settings', async ({ page, context }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
// Block popups (presets open doc URLs in new tabs)
|
||||
context.on('page', (p) => p.close());
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'webhook-preset');
|
||||
jwt = created.jwt;
|
||||
|
||||
// Login via JWT
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// Click "Webhook Settings" in the sidebar menu
|
||||
const webhookMenu = page.getByText('Webhook Settings');
|
||||
await expect(webhookMenu).toBeVisible({ timeout: 10_000 });
|
||||
await webhookMenu.click();
|
||||
|
||||
// Verify presets button is visible
|
||||
const presetsBtn = page.getByRole('button', { name: 'Presets' });
|
||||
await expect(presetsBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Helper to get form field value by label text
|
||||
const getFieldValue = async (label: string): Promise<string> => {
|
||||
// Find the label, then get the sibling textbox in the same form row
|
||||
const row = page.locator('div', { hasText: new RegExp(`^${label}$`) }).locator('..');
|
||||
const textbox = row.getByRole('textbox');
|
||||
return textbox.inputValue();
|
||||
};
|
||||
|
||||
// Define expected presets and their key fields
|
||||
const expectedPresets = [
|
||||
{
|
||||
name: 'Message Pusher',
|
||||
urlPattern: 'msgpusher.com',
|
||||
bodyKeys: ['token', 'title', 'description', 'content'],
|
||||
},
|
||||
{
|
||||
name: 'Bark',
|
||||
urlPattern: 'api.day.app',
|
||||
bodyKeys: ['title', 'body', 'group'],
|
||||
},
|
||||
{
|
||||
name: 'ntfy',
|
||||
urlPattern: 'ntfy.sh',
|
||||
bodyKeys: ['topic', 'title', 'message', 'tags'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const preset of expectedPresets) {
|
||||
// Open dropdown and select preset
|
||||
await presetsBtn.click();
|
||||
const option = page.locator('.n-dropdown-option', { hasText: preset.name });
|
||||
await expect(option).toBeVisible({ timeout: 5_000 });
|
||||
await option.click();
|
||||
// Wait for dropdown to close, then for URL field to contain preset pattern
|
||||
await expect(option).toBeHidden({ timeout: 5_000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const allTextboxes = page.getByRole('textbox');
|
||||
const count = await allTextboxes.count();
|
||||
|
||||
// Find URL, HEADERS, BODY values by reading all textboxes
|
||||
let urlValue = '';
|
||||
let headersValue = '';
|
||||
let bodyValue = '';
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const val = await allTextboxes.nth(i).inputValue();
|
||||
if (val.includes(preset.urlPattern)) {
|
||||
urlValue = val;
|
||||
} else if (val.includes('Content-Type')) {
|
||||
headersValue = val;
|
||||
} else if (val.includes('${subject}')) {
|
||||
bodyValue = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify URL
|
||||
expect(urlValue, `${preset.name}: URL should contain ${preset.urlPattern}`).toContain(preset.urlPattern);
|
||||
|
||||
// Verify HEADERS is valid JSON with Content-Type
|
||||
expect(headersValue, `${preset.name}: HEADERS should not be empty`).toBeTruthy();
|
||||
const headers = JSON.parse(headersValue);
|
||||
expect(headers, `${preset.name}: headers should have Content-Type`).toHaveProperty('Content-Type', 'application/json');
|
||||
|
||||
// Verify BODY is valid JSON with expected keys
|
||||
expect(bodyValue, `${preset.name}: BODY should not be empty`).toBeTruthy();
|
||||
const body = JSON.parse(bodyValue);
|
||||
for (const key of preset.bodyKeys) {
|
||||
expect(body, `${preset.name}: body should have key "${key}"`).toHaveProperty(key);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user