Files
textbee/web/e2e/webhooks.spec.ts
isra el f65af5003b fix: give every webhook its own signing secret
The create dialog is mounted for the whole session, so its
defaultValues, and the uuid inside them, were evaluated exactly once.
A bare form.reset() then restored that same object, so every webhook
created without reloading the page shared one signing secret.

The secret is what proves a delivery genuinely came from us, so sharing
it across endpoints means compromising any one of them compromises the
rest.

Build the defaults fresh on each use, and reset with a new secret both
when the dialog opens and after a successful create.

The e2e guard was confirmed to fail against the previous code, with
both creates submitting an identical secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:42:17 +03:00

91 lines
3.1 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
test.describe('webhooks (mocked API, no real backend)', () => {
test('subscriptions view renders with route tabs and mocked webhook', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/webhooks')
await expect(
page.getByRole('heading', { name: 'Webhooks', level: 2 })
).toBeVisible()
const nav = page.getByRole('navigation', { name: 'Section navigation' })
await expect(
nav.getByRole('link', { name: 'Webhooks', exact: true })
).toHaveAttribute('aria-current', 'page')
// Mocked webhook subscription from the fixtures.
await expect(
page.getByText('https://example.com/webhooks/textbee').first()
).toBeVisible()
})
// The dialog stays mounted for the whole session, so its defaultValues (and
// the uuid inside them) were evaluated exactly once. A bare form.reset()
// then restored that same secret, and every webhook created in one session
// shared it. Compromising one endpoint's secret would expose them all.
test('each created webhook gets its own signing secret', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
const secrets: string[] = []
await page.route('**/api/v1/webhooks', (route) => {
if (route.request().method() !== 'POST') return route.fallback()
secrets.push(route.request().postDataJSON()?.signingSecret)
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ data: { _id: `wh_${secrets.length}` } }),
})
})
await page.goto('/dashboard/webhooks')
for (const url of [
'https://example.com/hook-one',
'https://example.com/hook-two',
]) {
await page.getByRole('button', { name: 'Create Webhook' }).first().click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByText('Create Webhook')).toBeVisible()
await dialog.getByLabel('Delivery URL').fill(url)
await dialog.getByRole('button', { name: 'Create' }).click()
await expect(dialog).toBeHidden()
}
expect(secrets).toHaveLength(2)
expect(secrets[0]).toBeTruthy()
expect(secrets[1]).not.toBe(secrets[0])
})
test('deliveries deep link renders filters (refresh survival)', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/webhooks/deliveries')
const nav = page.getByRole('navigation', { name: 'Section navigation' })
await expect(nav.getByRole('link', { name: 'Deliveries' })).toHaveAttribute(
'aria-current',
'page'
)
await expect(
page.getByRole('heading', { name: 'Device', exact: true })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Date Range', exact: true })
).toBeVisible()
// Never fell through to an error boundary.
await expect(page.getByText('This page couldn')).toHaveCount(0)
})
})