Files
Charon/tests/settings/notifications-payload.spec.ts
2026-03-04 18:34:49 +00:00

562 lines
22 KiB
TypeScript

import { test, expect, loginUser } from '../fixtures/auth-fixtures';
import { request as playwrightRequest } from '@playwright/test';
import { waitForLoadingComplete } from '../utils/wait-helpers';
const SETTINGS_FLAGS_ENDPOINT = '/api/v1/settings';
const PROVIDERS_ENDPOINT = '/api/v1/notifications/providers';
function buildDiscordProviderPayload(name: string) {
return {
name,
type: 'discord',
url: 'https://discord.com/api/webhooks/123456789/testtoken',
enabled: true,
notify_proxy_hosts: true,
notify_remote_servers: false,
notify_domains: false,
notify_certs: true,
notify_uptime: false,
notify_security_waf_blocks: false,
notify_security_acl_denies: false,
notify_security_rate_limit_hits: false,
};
}
async function enableNotifyDispatchFlags(page: import('@playwright/test').Page, token: string) {
const keys = [
'feature.notifications.service.gotify.enabled',
'feature.notifications.service.webhook.enabled',
];
for (const key of keys) {
const response = await page.request.post(SETTINGS_FLAGS_ENDPOINT, {
headers: { Authorization: `Bearer ${token}` },
data: {
key,
value: 'true',
category: 'feature',
type: 'bool',
},
});
expect(response.ok()).toBeTruthy();
}
}
test.describe('Notifications Payload Matrix', () => {
test.beforeEach(async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await waitForLoadingComplete(page);
await page.goto('/settings/notifications');
await waitForLoadingComplete(page);
});
test('valid payload flows for discord, gotify, and webhook', async ({ page }) => {
const createdProviders: Array<Record<string, unknown>> = [];
const capturedCreatePayloads: Array<Record<string, unknown>> = [];
await test.step('Mock providers create/list endpoints', async () => {
await page.route('**/api/v1/notifications/providers', async (route, request) => {
if (request.method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(createdProviders),
});
return;
}
if (request.method() === 'POST') {
const payload = (await request.postDataJSON()) as Record<string, unknown>;
capturedCreatePayloads.push(payload);
const created = {
id: `provider-${capturedCreatePayloads.length}`,
...payload,
};
createdProviders.push(created);
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(created),
});
return;
}
await route.continue();
});
});
const scenarios = [
{
type: 'discord',
name: `discord-matrix-${Date.now()}`,
url: 'https://discord.com/api/webhooks/123/discordtoken',
},
{
type: 'gotify',
name: `gotify-matrix-${Date.now()}`,
url: 'https://gotify.example.com/message',
},
{
type: 'webhook',
name: `webhook-matrix-${Date.now()}`,
url: 'https://example.com/notify',
},
] as const;
for (const scenario of scenarios) {
await test.step(`Create ${scenario.type} provider and capture outgoing payload`, async () => {
await page.getByRole('button', { name: /add.*provider/i }).click();
await page.getByTestId('provider-name').fill(scenario.name);
await page.getByTestId('provider-type').selectOption(scenario.type);
await page.getByTestId('provider-url').fill(scenario.url);
if (scenario.type === 'gotify') {
await page.getByTestId('provider-gotify-token').fill(' gotify-secret-token ');
}
await page.getByTestId('provider-save-btn').click();
});
}
await test.step('Verify payload contract per provider type', async () => {
expect(capturedCreatePayloads).toHaveLength(3);
const discordPayload = capturedCreatePayloads.find((payload) => payload.type === 'discord');
expect(discordPayload).toBeTruthy();
expect(discordPayload?.token).toBeUndefined();
expect(discordPayload?.gotify_token).toBeUndefined();
const gotifyPayload = capturedCreatePayloads.find((payload) => payload.type === 'gotify');
expect(gotifyPayload).toBeTruthy();
expect(gotifyPayload?.token).toBe('gotify-secret-token');
expect(gotifyPayload?.gotify_token).toBeUndefined();
const webhookPayload = capturedCreatePayloads.find((payload) => payload.type === 'webhook');
expect(webhookPayload).toBeTruthy();
expect(webhookPayload?.token).toBeUndefined();
expect(typeof webhookPayload?.config).toBe('string');
});
});
test('malformed payload scenarios return sanitized validation errors', async ({ page, adminUser }) => {
await test.step('Malformed JSON to preview endpoint returns INVALID_REQUEST', async () => {
const response = await page.request.post('/api/v1/notifications/providers/preview', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${adminUser.token}`,
},
data: '{"type":',
});
expect(response.status()).toBe(400);
const body = (await response.json()) as Record<string, unknown>;
expect(body.code).toBe('INVALID_REQUEST');
expect(body.category).toBe('validation');
});
await test.step('Malformed template content returns TEMPLATE_PREVIEW_FAILED', async () => {
const response = await page.request.post('/api/v1/notifications/providers/preview', {
headers: { Authorization: `Bearer ${adminUser.token}` },
data: {
type: 'webhook',
url: 'https://example.com/notify',
template: 'custom',
config: '{"message": {{.Message}',
},
});
expect(response.status()).toBe(400);
const body = (await response.json()) as Record<string, unknown>;
expect(body.code).toBe('TEMPLATE_PREVIEW_FAILED');
expect(body.category).toBe('validation');
});
});
test('missing required fields block submit and show validation', async ({ page }) => {
let createCalled = false;
await test.step('Prevent create call from being silently sent', async () => {
await page.route('**/api/v1/notifications/providers', async (route, request) => {
if (request.method() === 'POST') {
createCalled = true;
}
await route.continue();
});
});
await test.step('Submit empty provider form', async () => {
await page.getByRole('button', { name: /add.*provider/i }).click();
await page.getByTestId('provider-save-btn').click();
});
await test.step('Validate required field errors and no outbound create', async () => {
await expect(page.getByTestId('provider-url-error')).toBeVisible();
await expect(page.getByTestId('provider-name')).toHaveAttribute('aria-invalid', 'true');
expect(createCalled).toBeFalsy();
});
});
test('auth/header behavior checks for protected settings endpoint', async ({ page, adminUser }) => {
const providerName = `auth-check-${Date.now()}`;
let providerID = '';
await test.step('Protected settings write rejects invalid bearer token', async () => {
const unauthenticatedRequest = await playwrightRequest.newContext({
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080',
});
try {
const noAuthResponse = await unauthenticatedRequest.post(SETTINGS_FLAGS_ENDPOINT, {
headers: { Authorization: 'Bearer invalid-token' },
data: {
key: 'feature.notifications.service.webhook.enabled',
value: 'true',
category: 'feature',
type: 'bool',
},
});
expect([401, 403]).toContain(noAuthResponse.status());
} finally {
await unauthenticatedRequest.dispose();
}
});
await test.step('Create provider with bearer token succeeds', async () => {
const authResponse = await page.request.post(PROVIDERS_ENDPOINT, {
headers: { Authorization: `Bearer ${adminUser.token}` },
data: buildDiscordProviderPayload(providerName),
});
expect(authResponse.status()).toBe(201);
const created = (await authResponse.json()) as Record<string, unknown>;
providerID = String(created.id ?? '');
expect(providerID.length).toBeGreaterThan(0);
});
await test.step('Cleanup created provider', async () => {
const deleteResponse = await page.request.delete(`${PROVIDERS_ENDPOINT}/${providerID}`, {
headers: { Authorization: `Bearer ${adminUser.token}` },
});
expect(deleteResponse.ok()).toBeTruthy();
});
});
test('provider-specific transformation strips gotify token from test and preview payloads', async ({ page }) => {
let capturedPreviewPayload: Record<string, unknown> | null = null;
let capturedTestPayload: Record<string, unknown> | null = null;
await test.step('Mock preview and test endpoints to capture payloads', async () => {
await page.route('**/api/v1/notifications/providers/preview', async (route, request) => {
capturedPreviewPayload = (await request.postDataJSON()) as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ rendered: '{"ok":true}', parsed: { ok: true } }),
});
});
await page.route('**/api/v1/notifications/providers/test', async (route, request) => {
capturedTestPayload = (await request.postDataJSON()) as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ message: 'Test notification sent' }),
});
});
});
await test.step('Fill gotify form with write-only token', async () => {
await page.getByRole('button', { name: /add.*provider/i }).click();
await page.getByTestId('provider-type').selectOption('gotify');
await page.getByTestId('provider-name').fill(`gotify-transform-${Date.now()}`);
await page.getByTestId('provider-url').fill('https://gotify.example.com/message');
await page.getByTestId('provider-gotify-token').fill('super-secret-token');
});
await test.step('Trigger preview and test calls', async () => {
await page.getByTestId('provider-preview-btn').click();
await page.getByTestId('provider-test-btn').click();
});
await test.step('Assert token is not sent on preview/test payloads', async () => {
expect(capturedPreviewPayload).toBeTruthy();
expect(capturedPreviewPayload?.type).toBe('gotify');
expect(capturedPreviewPayload?.token).toBeUndefined();
expect(capturedPreviewPayload?.gotify_token).toBeUndefined();
expect(capturedTestPayload).toBeTruthy();
expect(capturedTestPayload?.type).toBe('gotify');
expect(capturedTestPayload?.token).toBeUndefined();
expect(capturedTestPayload?.gotify_token).toBeUndefined();
});
});
test('security: SSRF redirect/internal target, query-token, and oversized payload are blocked', async ({ page, adminUser }) => {
await test.step('Enable gotify and webhook dispatch feature flags', async () => {
await enableNotifyDispatchFlags(page, adminUser.token);
});
await test.step('Untrusted redirect/internal SSRF-style payload is rejected before dispatch', async () => {
const response = await page.request.post('/api/v1/notifications/providers/test', {
headers: { Authorization: `Bearer ${adminUser.token}` },
data: {
type: 'webhook',
name: 'ssrf-test',
url: 'https://127.0.0.1/internal',
template: 'custom',
config: '{"message":"{{.Message}}"}',
},
});
expect(response.status()).toBe(400);
const body = (await response.json()) as Record<string, unknown>;
expect(body.code).toBe('MISSING_PROVIDER_ID');
expect(body.category).toBe('validation');
expect(String(body.error ?? '')).not.toContain('127.0.0.1');
});
await test.step('Gotify query-token URL is rejected with sanitized error', async () => {
const queryToken = 's3cr3t-query-token';
const response = await page.request.post('/api/v1/notifications/providers/test', {
headers: { Authorization: `Bearer ${adminUser.token}` },
data: {
type: 'gotify',
name: 'query-token-test',
url: `https://gotify.example.com/message?token=${queryToken}`,
template: 'custom',
config: '{"message":"{{.Message}}"}',
},
});
expect(response.status()).toBe(400);
const body = (await response.json()) as Record<string, unknown>;
expect(body.code).toBe('MISSING_PROVIDER_ID');
expect(body.category).toBe('validation');
const responseText = JSON.stringify(body);
expect(responseText).not.toContain(queryToken);
expect(responseText.toLowerCase()).not.toContain('token=');
});
await test.step('Oversized payload/template is rejected', async () => {
const oversizedTemplate = `{"message":"${'x'.repeat(12_500)}"}`;
const response = await page.request.post('/api/v1/notifications/providers/test', {
headers: { Authorization: `Bearer ${adminUser.token}` },
data: {
type: 'webhook',
name: 'oversized-template-test',
url: 'https://example.com/webhook',
template: 'custom',
config: oversizedTemplate,
},
});
expect(response.status()).toBe(400);
const body = (await response.json()) as Record<string, unknown>;
expect(body.code).toBe('MISSING_PROVIDER_ID');
expect(body.category).toBe('validation');
});
});
test('security: DNS-rebinding-observable hostname path is blocked with sanitized response', async ({ page, adminUser }) => {
await test.step('Enable gotify and webhook dispatch feature flags', async () => {
await enableNotifyDispatchFlags(page, adminUser.token);
});
await test.step('Untrusted hostname payload is blocked before dispatch (rebinding guard path)', async () => {
const blockedHostname = 'rebind-check.127.0.0.1.nip.io';
const response = await page.request.post('/api/v1/notifications/providers/test', {
headers: { Authorization: `Bearer ${adminUser.token}` },
data: {
type: 'webhook',
name: 'dns-rebinding-observable',
url: `https://${blockedHostname}/notify`,
template: 'custom',
config: '{"message":"{{.Message}}"}',
},
});
expect(response.status()).toBe(400);
const body = (await response.json()) as Record<string, unknown>;
expect(body.code).toBe('MISSING_PROVIDER_ID');
expect(body.category).toBe('validation');
const responseText = JSON.stringify(body);
expect(responseText).not.toContain(blockedHostname);
expect(responseText).not.toContain('127.0.0.1');
});
});
test('security: retry split distinguishes retryable and non-retryable failures with deterministic response semantics', async ({ page }) => {
const capturedTestPayloads: Array<Record<string, unknown>> = [];
let nonRetryableBody: Record<string, unknown> | null = null;
let retryableBody: Record<string, unknown> | null = null;
await test.step('Stub provider test endpoint with deterministic retry split contract', async () => {
await page.route('**/api/v1/notifications/providers/test', async (route, request) => {
const payload = (await request.postDataJSON()) as Record<string, unknown>;
capturedTestPayloads.push(payload);
const scenarioName = String(payload.name ?? '');
const isRetryable = scenarioName.includes('retryable') && !scenarioName.includes('non-retryable');
const requestID = isRetryable ? 'stub-request-retryable' : 'stub-request-non-retryable';
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
code: 'PROVIDER_TEST_FAILED',
category: 'dispatch',
error: 'Provider test failed',
request_id: requestID,
retryable: isRetryable,
}),
});
});
});
await test.step('Open provider form and execute deterministic non-retryable test call', async () => {
await page.getByRole('button', { name: /add.*provider/i }).click();
await page.getByTestId('provider-type').selectOption('webhook');
await page.getByTestId('provider-name').fill('retry-split-non-retryable');
await page.getByTestId('provider-url').fill('https://non-retryable.example.invalid/notify');
const nonRetryableResponsePromise = page.waitForResponse(
(response) =>
/\/api\/v1\/notifications\/providers\/test$/.test(response.url())
&& response.request().method() === 'POST'
&& (response.request().postData() ?? '').includes('retry-split-non-retryable')
);
await page.getByTestId('provider-test-btn').click();
const nonRetryableResponse = await nonRetryableResponsePromise;
nonRetryableBody = (await nonRetryableResponse.json()) as Record<string, unknown>;
expect(nonRetryableResponse.status()).toBe(400);
expect(nonRetryableBody.code).toBe('PROVIDER_TEST_FAILED');
expect(nonRetryableBody.category).toBe('dispatch');
expect(nonRetryableBody.error).toBe('Provider test failed');
expect(nonRetryableBody.retryable).toBe(false);
expect(nonRetryableBody.request_id).toBe('stub-request-non-retryable');
});
await test.step('Execute deterministic retryable test call on the same contract endpoint', async () => {
await page.getByTestId('provider-name').fill('retry-split-retryable');
await page.getByTestId('provider-url').fill('https://retryable.example.invalid/notify');
const retryableResponsePromise = page.waitForResponse(
(response) =>
/\/api\/v1\/notifications\/providers\/test$/.test(response.url())
&& response.request().method() === 'POST'
&& (response.request().postData() ?? '').includes('retry-split-retryable')
);
await page.getByTestId('provider-test-btn').click();
const retryableResponse = await retryableResponsePromise;
retryableBody = (await retryableResponse.json()) as Record<string, unknown>;
expect(retryableResponse.status()).toBe(400);
expect(retryableBody.code).toBe('PROVIDER_TEST_FAILED');
expect(retryableBody.category).toBe('dispatch');
expect(retryableBody.error).toBe('Provider test failed');
expect(retryableBody.retryable).toBe(true);
expect(retryableBody.request_id).toBe('stub-request-retryable');
});
await test.step('Assert stable split distinction and sanitized API contract shape', async () => {
expect(capturedTestPayloads).toHaveLength(2);
expect(capturedTestPayloads[0]?.name).toBe('retry-split-non-retryable');
expect(capturedTestPayloads[1]?.name).toBe('retry-split-retryable');
expect(nonRetryableBody).toMatchObject({
code: 'PROVIDER_TEST_FAILED',
category: 'dispatch',
error: 'Provider test failed',
retryable: false,
});
expect(retryableBody).toMatchObject({
code: 'PROVIDER_TEST_FAILED',
category: 'dispatch',
error: 'Provider test failed',
retryable: true,
});
test.info().annotations.push({
type: 'retry-split-semantics',
description: 'non-retryable and retryable contracts are validated via deterministic route-stubbed /providers/test responses',
});
});
});
test('security: token does not leak in list and visible edit surfaces', async ({ page, adminUser }) => {
const name = `gotify-redaction-${Date.now()}`;
let providerID = '';
await test.step('Create gotify provider with token on write path', async () => {
const createResponse = await page.request.post(PROVIDERS_ENDPOINT, {
headers: { Authorization: `Bearer ${adminUser.token}` },
data: {
...buildDiscordProviderPayload(name),
type: 'gotify',
url: 'https://gotify.example.com/message',
token: 'write-only-secret-token',
config: '{"message":"{{.Message}}"}',
},
});
expect(createResponse.status()).toBe(201);
const created = (await createResponse.json()) as Record<string, unknown>;
providerID = String(created.id ?? '');
expect(providerID.length).toBeGreaterThan(0);
});
await test.step('List providers does not expose token fields', async () => {
const listResponse = await page.request.get(PROVIDERS_ENDPOINT, {
headers: { Authorization: `Bearer ${adminUser.token}` },
});
expect(listResponse.ok()).toBeTruthy();
const providers = (await listResponse.json()) as Array<Record<string, unknown>>;
const gotify = providers.find((provider) => provider.id === providerID);
expect(gotify).toBeTruthy();
expect(gotify?.token).toBeUndefined();
expect(gotify?.gotify_token).toBeUndefined();
});
await test.step('Edit form does not pre-fill token in visible surface', async () => {
await page.reload();
await waitForLoadingComplete(page);
const row = page.getByTestId(`provider-row-${providerID}`);
await expect(row).toBeVisible({ timeout: 10000 });
const testButton = row.getByRole('button', { name: /send test notification/i });
await expect(testButton).toBeVisible();
await testButton.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
const tokenInput = page.getByTestId('provider-gotify-token');
await expect(tokenInput).toBeVisible();
await expect(tokenInput).toHaveValue('');
const pageText = await page.locator('main').innerText();
expect(pageText).not.toContain('write-only-secret-token');
});
await test.step('Cleanup created provider', async () => {
const deleteResponse = await page.request.delete(`${PROVIDERS_ENDPOINT}/${providerID}`, {
headers: { Authorization: `Bearer ${adminUser.token}` },
});
expect(deleteResponse.ok()).toBeTruthy();
});
});
});