diff --git a/package.json b/package.json index fef937c..1499188 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,11 @@ "test": "npm run build && node --test \"test/unit.test.mjs\"", "test:integration": "npm run build && node --env-file-if-exists=.env --test \"test/integration.test.mjs\"", "release": "n8n-node release", - "prepublishOnly": "n8n-node prerelease" + "prepublishOnly": "n8n-node prerelease", + "test:live": "npm run build && node --env-file-if-exists=.env --test --test-concurrency=1 --test-timeout=900000 \"test/live/*.test.mjs\"", + "test:live:api": "npm run build && node --env-file-if-exists=.env --test --test-concurrency=1 --test-timeout=900000 test/live/api.test.mjs", + "test:live:delivery": "npm run build && node --env-file-if-exists=.env --test --test-concurrency=1 --test-timeout=900000 test/live/verification.test.mjs test/live/delivery.test.mjs", + "test:live:stripe": "npm run build && node --env-file-if-exists=.env --test --test-concurrency=1 --test-timeout=900000 test/live/stripe.test.mjs" }, "comment:files": "An allowlist, not \"dist\": n8n-node build globs **/*.{png,svg} from the repo root into dist, which shipped a 299kB README screenshot in 0.0.1. Adding a path here without adding it to \"n8n\" below is fine; the reverse fails scripts/verify-package-load.mjs.", "files": [ diff --git a/test/live/_harness.mjs b/test/live/_harness.mjs new file mode 100644 index 0000000..f3933b5 --- /dev/null +++ b/test/live/_harness.mjs @@ -0,0 +1,397 @@ +/** + * Shared plumbing for the live suites. + * + * These tests exercise the built `dist/` nodes against a real Hookdeck project. + * Nothing here reimplements node behaviour — the contexts below are the minimum + * n8n surface the nodes touch, wired to real HTTP. + * + * SAFETY: every resource a run creates carries that run's id, and `destroy()` + * refuses to delete anything that does not — see `ownedByThisRun` for the three + * naming forms involved. The project this was written against also holds + * production sources, so the guard is load bearing: do not relax it into a + * broader name match. + * + * Each test file gets its own run id, so suites can run concurrently without one + * cleanup deleting a resource another is still using. + */ +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { once } from 'node:events'; + +export const API_KEY = process.env.HOOKDECK_EG_API_KEY; +export const BASE_URL = 'https://api.hookdeck.com/2025-07-01'; +export const skip = API_KEY ? false : 'HOOKDECK_EG_API_KEY is not set'; + +export const RUN_ID = randomBytes(4).toString('hex'); +export const PREFIX = `n8n-live-${RUN_ID}`; + +/** Call the Hookdeck API directly, to arrange fixtures and assert real state. */ +export async function api(method, path, body) { + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`${method} ${path} → ${response.status}: ${text}`); + return text ? JSON.parse(text) : {}; +} + +/** + * Whether a name belongs to this run. + * + * Three forms, because three things do the naming: + * - `n8n-live--…` resources these tests create directly + * - `cli-n8n-live--…` what `hookdeck listen` derives from those + * - `n8n--` what the *node* names a connection, from the + * workflow id — which the live contexts set to the + * run id precisely so it lands in this net + * + * The third was missing at first, so every node-provisioned connection survived + * cleanup and then blocked its source from being deleted. Widening this any + * further starts deleting real resources: the project under test also holds + * production sources. + */ +function ownedByThisRun(name) { + if (typeof name !== 'string') return false; + return ( + name.startsWith(PREFIX) || name.startsWith(`cli-${PREFIX}`) || name.startsWith(`n8n-${RUN_ID}-`) + ); +} + +/** Delete a resource, but only one this run created. */ +export async function destroy(kind, resource) { + if (!ownedByThisRun(resource?.name)) { + throw new Error(`refusing to delete ${kind} "${resource?.name}" — not owned by ${PREFIX}`); + } + await api('DELETE', `/${kind}/${resource.id}`); +} + +/** + * Remove every resource this run created, dependants first. + * + * Failures are reported rather than swallowed. A silent catch here hides leaked + * resources in a shared project, which is how they accumulated unnoticed. + */ +export async function cleanUpRun() { + const leaked = []; + const sweep = async (kind, models) => { + for (const model of models.filter((m) => ownedByThisRun(m.name))) { + try { + await destroy(kind, model); + } catch (error) { + leaked.push(`${kind}/${model.id} (${model.name}): ${error.message}`); + } + } + }; + + const { models: connections = [] } = await api('GET', '/connections?limit=250'); + await sweep('connections', connections); + for (const kind of ['sources', 'destinations']) { + const { models = [] } = await api('GET', `/${kind}?limit=250`); + await sweep(kind, models); + } + + if (leaked.length) { + console.error(`\n LEAKED ${leaked.length} resource(s) in the project:`); + for (const entry of leaked) console.error(` ${entry}`); + } +} + +/** + * Whether Hookdeck considered an inbound request verified. + * + * Only the request *detail* carries the field — the list omits it, which is the + * trap the README calls out, and the reason this fetches each candidate. + */ +export async function requestFor(sourceId, marker) { + return await until(`a request containing "${marker}"`, async () => { + const { models = [] } = await api('GET', `/requests?source_id=${sourceId}&limit=10`); + for (const candidate of models) { + const detail = await api('GET', `/requests/${candidate.id}`); + if (JSON.stringify(detail.data?.body ?? '').includes(marker)) return detail; + } + return null; + }); +} + +export async function wasVerified(sourceId, marker) { + return (await requestFor(sourceId, marker)).verified; +} + +/** Poll until `check` returns something truthy, or give up. */ +export async function until(label, check, { attempts = 40, delayMs = 1000 } = {}) { + let lastError; + for (let i = 0; i < attempts; i++) { + try { + const result = await check(); + if (result) return result; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + throw new Error(`timed out waiting for ${label}${lastError ? `: ${lastError.message}` : ''}`); +} + +/** + * The connection the *node* provisioned for a source. + * + * A source can carry more than one: `hookdeck listen` adds its own `cli-…` + * connection alongside. Excluding those keeps this unambiguous, so a caller + * asking for the node's work never silently gets the CLI's. + */ +export async function connectionForSource(sourceName) { + const { models = [] } = await api('GET', '/connections?limit=250'); + return models.find((c) => c.source?.name === sourceName && !c.name?.startsWith('cli-')); +} + +/** The `hookdeck listen` connection for a source. */ +export async function cliConnectionForSource(sourceName) { + return await until(`the CLI connection for ${sourceName}`, async () => { + const { models = [] } = await api('GET', '/connections?limit=250'); + return models.find((c) => c.name === `cli-${sourceName}`); + }); +} + +/** Post a payload to a Hookdeck ingest URL and report what the edge answered. */ +export async function ingest(url, body, headers = {}) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body, + }); + return { status: response.status, ok: response.ok }; +} + +/* ───────────────────────────── node contexts ─────────────────────────── */ + +export async function liveHttpHelper(_credentialType, options) { + const url = new URL(options.url); + for (const [key, value] of Object.entries(options.qs ?? {})) { + if (value !== undefined && value !== '') url.searchParams.set(key, String(value)); + } + const response = await fetch(url, { + method: options.method, + headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const text = await response.text(); + const parsed = text ? JSON.parse(text) : ''; + if (options.returnFullResponse || options.ignoreHttpStatusErrors) { + return { statusCode: response.status, body: parsed }; + } + if (!response.ok) { + const error = new Error(`HTTP ${response.status}: ${text}`); + error.httpCode = String(response.status); + throw error; + } + return { statusCode: response.status, body: parsed }; +} + +/** An IHookFunctions whose HTTP helper reaches the real API. */ +export function liveHookContext({ webhookUrl, staticData, params, mode = 'trigger' }) { + const logs = []; + return { + logs, + getWorkflowStaticData: () => staticData, + getNodeWebhookUrl: () => webhookUrl, + getMode: () => mode, + getWorkflow: () => ({ id: RUN_ID }), + getNode: () => ({ id: 'node1', name: 'Hookdeck Event Gateway Trigger', type: 'hookdeckEventGatewayTrigger' }), + getNodeParameter: (name, fallback) => (name in params ? params[name] : fallback), + getInstanceId: () => `live${RUN_ID}`, + logger: { + debug() {}, + info: (m) => logs.push(m), + warn: (m) => logs.push(m), + error: (m) => logs.push(m), + }, + helpers: { httpRequestWithAuthentication: liveHttpHelper }, + }; +} + +/** An IWebhookFunctions over a real inbound request. */ +export function liveWebhookContext({ rawBody, headers, staticData, options = {}, params = {} }) { + const sent = {}; + return { + sent, + getWorkflowStaticData: () => staticData, + getNodeParameter: (name, fallback) => { + if (name === 'options') return options; + return name in params ? params[name] : fallback; + }, + getRequestObject: () => ({ rawBody }), + getHeaderData: () => headers, + getQueryData: () => ({}), + getBodyData: () => { + try { + return JSON.parse(rawBody.toString('utf8')); + } catch { + return {}; + } + }, + getResponseObject: () => ({ + status(code) { + sent.status = code; + return { + json: (payload) => void (sent.body = payload), + end: () => {}, + send: () => {}, + }; + }, + }), + getNode: () => ({ name: 'Hookdeck Event Gateway Trigger' }), + logger: { debug() {}, warn: (m) => void (sent.warned = m), error() {} }, + helpers: { returnJsonArray: (items) => [].concat(items).map((json) => ({ json })) }, + }; +} + +/** An IExecuteFunctions for the action node, over the real API. */ +export function liveExecuteContext(params) { + return { + getInputData: () => [{ json: {} }], + continueOnFail: () => false, + getNode: () => ({ name: 'Hookdeck Event Gateway' }), + getNodeParameter: (name, _i, fallback) => (name in params ? params[name] : fallback), + helpers: { httpRequestWithAuthentication: liveHttpHelper }, + }; +} + +/** An ILoadOptionsFunctions, for the Source resource locator's list mode. */ +export function liveLoadOptionsContext(filter) { + return { + getNodeParameter: (_name, fallback) => fallback, + getNode: () => ({ name: 'Hookdeck Event Gateway Trigger' }), + getCurrentNodeParameter: () => filter, + helpers: { httpRequestWithAuthentication: liveHttpHelper }, + }; +} + +/* ────────────────── local receiver fed by the Hookdeck CLI ───────────── */ + +/** + * A local endpoint fed by `hookdeck listen`, handing each request to the node's + * `webhook()`. + * + * Hookdeck will not accept a localhost destination, so events have to come back + * over something. The CLI is used rather than a third-party tunnel because it + * is Hookdeck's own transport: the request that arrives carries the real + * metadata headers (`x-hookdeck-eventid`, `-attempt-count`, `-will-retry-after` + * and the rest), which is exactly what the node parses. + * + * One consequence: a CLI destination is signed with the project's own secret, + * not the per-connection secret the node provisions for an HTTP destination, so + * callers must run these with `verifySignature: false`. That hop is covered + * separately by the forged-signature test and the unit suite. + */ +export async function startCliReceiver(HookdeckEventGatewayTrigger, sourceName) { + const deliveries = []; + let staticData = {}; + let options = {}; + let handler = null; + + const server = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const rawBody = Buffer.concat(chunks); + + if (handler) { + const outcome = await handler({ rawBody, headers: req.headers, url: req.url, res }); + if (outcome?.handled) return; + } + + const ctx = liveWebhookContext({ rawBody, headers: req.headers, staticData, options }); + let outcome; + try { + outcome = await new HookdeckEventGatewayTrigger().webhook.call(ctx); + } catch (error) { + outcome = { error: error.message }; + } + deliveries.push({ rawBody, headers: req.headers, url: req.url, outcome, sent: ctx.sent }); + + // Mirror what n8n does with the return value, so Hookdeck sees the status + // a real deployment would produce. + if (outcome?.noWebhookResponse) res.writeHead(ctx.sent.status ?? 401).end(); + else res.writeHead(200).end('ok'); + }); + + server.listen(0); + await once(server, 'listening'); + const port = server.address().port; + + // detached, so the child leads its own process group. The `hookdeck` on PATH + // is an npm wrapper that spawns the platform binary as a child; signalling + // only the wrapper leaves that binary running, holding its CLI connection + // open and this process alive. + const cli = spawn('hookdeck', ['listen', String(port), sourceName, '--output', 'compact'], { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + const ingestUrl = await new Promise((resolve, reject) => { + let buffer = ''; + const onData = (chunk) => { + buffer += chunk.toString(); + const match = buffer.match(/https:\/\/hkdk\.events\/[a-z0-9]+/); + if (match) resolve(match[0]); + }; + cli.stdout.on('data', onData); + cli.stderr.on('data', onData); + cli.on('exit', (code) => reject(new Error(`hookdeck listen exited (${code})`))); + setTimeout(() => reject(new Error('hookdeck listen did not report a source URL')), 60000); + }); + + // The CLI reports its URL a moment before the connection is ready to carry + // traffic. Hookdeck retries a delivery that arrives too early, but settling + // here keeps the first assertion from paying for it. + await new Promise((resolve) => setTimeout(resolve, 6000)); + + return { + ingestUrl, + deliveries, + /** Wait for a delivery whose body contains `marker`. */ + waitFor: (marker, opts) => + until( + `a delivery containing "${marker}"`, + () => deliveries.find((d) => d.rawBody.toString().includes(marker)), + opts, + ), + /** Deliveries seen so far containing `marker`. */ + matching: (marker) => deliveries.filter((d) => d.rawBody.toString().includes(marker)), + setStaticData: (value) => void (staticData = value), + setOptions: (value) => void (options = value), + /** Take over request handling, for tests that need a raw response. */ + setHandler: (value) => void (handler = value), + async stop() { + // Signal the whole group, and never await `exit` unconditionally: if the + // child is already gone that event has fired and will not fire again, so + // the await would hang for the rest of the run. + const gone = () => cli.exitCode !== null || cli.signalCode !== null; + const settle = async (ms) => + gone() || + (await Promise.race([ + once(cli, 'exit').then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), ms)), + ])); + + const signal = (name) => { + try { + process.kill(-cli.pid, name); + } catch { + // Already reaped, or the group is gone. + } + }; + + signal('SIGTERM'); + if (!(await settle(3000))) { + signal('SIGKILL'); + await settle(3000); + } + cli.unref(); + + server.close(); + await once(server, 'close').catch(() => {}); + }, + }; +} diff --git a/test/live/api.test.mjs b/test/live/api.test.mjs new file mode 100644 index 0000000..e42dcf8 --- /dev/null +++ b/test/live/api.test.mjs @@ -0,0 +1,538 @@ +/** + * Live tests for everything the README claims that is observable from the API. + * + * Provisioning options, verification shapes and the action node's full + * Signature behaviour lives in `verification.test.mjs` and real deliveries in + * `delivery.test.mjs`; neither is needed here, so this suite runs in seconds + * and requires nothing beyond an API key. + * + * npm run test:live + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { HookdeckEventGatewayTrigger } from '../../dist/nodes/Hookdeck/HookdeckEventGatewayTrigger.node.js'; +import { HookdeckEventGateway } from '../../dist/nodes/Hookdeck/HookdeckEventGateway.node.js'; +import { + PREFIX, + api, + cleanUpRun, + connectionForSource, + liveExecuteContext, + liveHookContext, + liveLoadOptionsContext, + skip, + until, +} from './_harness.mjs'; + +/** A reachable placeholder destination: these tests assert config, not delivery. */ +const DESTINATION_URL = 'https://example.com/n8n/webhook/live'; + +/** Provision through the node's own `create()` and return the resulting connection. */ +async function provision(sourceName, params, { webhookUrl = DESTINATION_URL, mode } = {}) { + const staticData = {}; + const ctx = liveHookContext({ + webhookUrl, + staticData, + mode, + params: { source: sourceName, sourceType: 'WEBHOOK', verification: 'none', ...params }, + }); + await new HookdeckEventGatewayTrigger().webhookMethods.default.create.call(ctx); + return { connection: await connectionForSource(sourceName), staticData, ctx }; +} + +test('live API surface', { skip, concurrency: false }, async (t) => { + t.after(cleanUpRun); + + /* ───────────────────────── provisioning options ──────────────────── */ + + await t.test('defaults are applied even when Options is never opened', async () => { + const { connection } = await provision(`${PREFIX}-defaults`, {}); + const [rule] = connection.rules.filter((r) => r.type === 'retry'); + + assert.ok(rule, 'README promises retries apply without opening Options'); + assert.equal(rule.strategy, 'exponential'); + assert.equal(rule.count, 5); + assert.equal(rule.interval, 60000); + }); + + await t.test('retry strategy, count and interval are provisioned as configured', async () => { + const { connection } = await provision(`${PREFIX}-retry`, { + options: { retryStrategy: 'linear', retryCount: 12, retryInterval: 30000 }, + }); + const rule = connection.rules.find((r) => r.type === 'retry'); + + assert.equal(rule.strategy, 'linear'); + assert.equal(rule.count, 12); + assert.equal(rule.interval, 30000); + }); + + await t.test('the deduplication window is provisioned', async () => { + const { connection } = await provision(`${PREFIX}-dedup`, { + options: { deduplicateWindow: 90000 }, + }); + const rule = connection.rules.find((r) => r.type === 'deduplicate'); + + assert.ok(rule, 'no deduplicate rule was provisioned'); + assert.equal(rule.window, 90000); + }); + + await t.test('a zero deduplication window turns deduplication off', async () => { + const { connection } = await provision(`${PREFIX}-nodedup`, { + options: { deduplicateWindow: 0 }, + }); + assert.equal( + connection.rules.some((r) => r.type === 'deduplicate'), + false, + 'README says 0 turns deduplication off', + ); + }); + + await t.test('delivery rate limiting is provisioned, including concurrent', async () => { + const { connection } = await provision(`${PREFIX}-rate`, { + options: { rateLimit: 25, rateLimitPeriod: 'concurrent' }, + }); + assert.equal(connection.destination.config.rate_limit, 25); + assert.equal(connection.destination.config.rate_limit_period, 'concurrent'); + }); + + await t.test('delivery groups are provisioned with key, limit and period', async (t) => { + let connection; + try { + ({ connection } = await provision(`${PREFIX}-groups`, { + options: { + deliveryGroupKey: 'body.customer_id', + deliveryGroupRateLimit: 3, + deliveryGroupRatePeriod: 'minute', + }, + })); + } catch (error) { + // Delivery groups are a plan entitlement. On a project without it the + // API rejects the upsert outright, which is worth surfacing as a skip + // rather than a failure — the node built a payload Hookdeck understood. + if (/Delivery groups are not enabled/.test(error.message)) { + t.skip('delivery groups are not enabled for this organization'); + return; + } + throw error; + } + + const groups = connection.destination.config.delivery_groups; + assert.ok(groups, 'no delivery group was provisioned'); + assert.equal(groups.key, 'body.customer_id'); + assert.equal(groups.rate_limit, 3); + assert.equal(groups.rate_limit_period, 'minute'); + }); + + await t.test('path forwarding is disabled, so Hookdeck cannot rewrite the n8n path', async () => { + const { connection } = await provision(`${PREFIX}-path`, {}); + assert.equal(connection.destination.config.path_forwarding_disabled, true); + assert.equal(connection.destination.config.url, DESTINATION_URL); + }); + + /* ──────────────────────── verification shapes ────────────────────── */ + + // For generic sources Hookdeck echoes `config.auth_type` but never + // `config.auth`, so the scheme is assertable and its parameters are not. + for (const [label, params, expected] of [ + [ + 'generic HMAC', + { + verification: 'HMAC', + hmacSecret: 'shhh', + hmacHeaderKey: 'x-my-signature', + hmacAlgorithm: 'sha512', + hmacEncoding: 'base64', + }, + 'HMAC', + ], + [ + 'generic API key', + { verification: 'API_KEY', authHeaderName: 'x-tenant-key', apiKeyValue: 'secret-value' }, + 'API_KEY', + ], + [ + 'generic basic auth', + { verification: 'BASIC_AUTH', basicAuthUsername: 'alice', basicAuthPassword: 'hunter2' }, + 'BASIC_AUTH', + ], + ]) { + await t.test(`${label} verification is provisioned as configured`, async () => { + const { connection } = await provision(`${PREFIX}-${expected.toLowerCase()}`, params); + const source = await api('GET', `/sources/${connection.source.id}`); + + assert.equal(source.config.auth_type, expected); + assert.equal( + 'auth' in source.config, + false, + 'the API must not echo back verification parameters', + ); + }); + } + + await t.test('a platform source is accepted with its secret, and reveals nothing', async () => { + // Platform sources never echo `auth_type`, configured or not, so acceptance + // of the upsert is all the API can prove here. Whether verification is + // actually live is only observable by signing a real payload, which + // `verification.test.mjs` does for Stripe and GitHub. + for (const sourceType of ['STRIPE', 'GITHUB']) { + const { connection } = await provision(`${PREFIX}-${sourceType.toLowerCase()}`, { + sourceType, + platformSecret: 'whsec_livetest', + }); + const source = await api('GET', `/sources/${connection.source.id}`); + + assert.equal(source.type, sourceType); + assert.equal( + source.config?.auth_type ?? null, + null, + `${sourceType}: a platform source unexpectedly echoed its scheme`, + ); + } + }); + + await t.test('a configured platform source is indistinguishable from a bare one', async () => { + // This is the trap the README describes, and it is worth asserting rather + // than assuming: the two sources differ only in that one has a secret, and + // the API returns byte-identical config for both. An unsigned delivery is + // the only way to tell them apart. + const { connection: bare } = await provision(`${PREFIX}-stripe-bare`, { sourceType: 'STRIPE' }); + const { connection: keyed } = await provision(`${PREFIX}-stripe-keyed`, { + sourceType: 'STRIPE', + platformSecret: 'whsec_livetest', + }); + + const bareSource = await api('GET', `/sources/${bare.source.id}`); + const keyedSource = await api('GET', `/sources/${keyed.source.id}`); + + assert.deepEqual( + bareSource.config, + keyedSource.config, + 'if these ever differ, the README trap is fixed and that section should say so', + ); + }); + + await t.test('Source Config (JSON) overrides the generated config', async () => { + // The merge is one level deep, so `auth` is replaced wholesale rather than + // blended. That suits the documented purpose — expressing a scheme the + // fields cannot — but it means a partial `auth` block drops the rest of it. + const { connection } = await provision(`${PREFIX}-rawcfg`, { + verification: 'HMAC', + hmacSecret: 'shhh', + options: { + sourceConfigJson: JSON.stringify({ + auth_type: 'API_KEY', + auth: { header_key: 'x-overridden', api_key: 'from-json' }, + }), + }, + }); + const source = await api('GET', `/sources/${connection.source.id}`); + + assert.equal( + source.config.auth_type, + 'API_KEY', + 'Source Config (JSON) did not override the generated scheme', + ); + }); + + await t.test( + 'a partial auth override is rejected rather than silently half-applied', + async () => { + await assert.rejects( + provision(`${PREFIX}-partial`, { + verification: 'HMAC', + hmacSecret: 'shhh', + options: { sourceConfigJson: JSON.stringify({ auth: { header_key: 'x-only' } }) }, + }), + /is required/, + 'a partial override must fail loudly, not provision a broken source', + ); + }, + ); + + /* ─────────────────── activation and test-run behaviour ───────────── */ + + await t.test('a test run provisions a separate connection sharing one source', async () => { + const sourceName = `${PREFIX}-modes`; + const { connection: production } = await provision(sourceName, {}); + const { connection: _ } = await provision( + sourceName, + {}, + { + webhookUrl: 'https://example.com/webhook-test/live', + mode: 'manual', + }, + ); + + const { models } = await api('GET', '/connections?limit=250'); + const owned = models.filter((c) => c.source?.name === sourceName); + + assert.equal(owned.length, 2, 'test and production must not share a connection'); + assert.equal( + new Set(owned.map((c) => c.source.id)).size, + 1, + 'README says both connections share one source, so one URL', + ); + assert.ok(production, 'production connection went missing'); + }); + + await t.test('the source list surfaces every source with its public URL', async () => { + const sourceName = `${PREFIX}-list`; + await provision(sourceName, {}); + + const { results } = await new HookdeckEventGatewayTrigger().methods.listSearch.searchSources.call( + liveLoadOptionsContext(''), + '', + ); + + const found = results.find((r) => r.name?.includes(sourceName)); + assert.ok(found, 'a provisioned source did not appear in the list'); + assert.ok( + JSON.stringify(found).includes('hkdk.events'), + 'README says the list shows the public URL', + ); + }); + + /* ─────────────────────── action node: read paths ──────────────────── */ + + await t.test('Get Many and Get round-trip for every readable resource', async () => { + for (const resource of [ + 'attempt', + 'connection', + 'destination', + 'event', + 'issue', + 'request', + 'source', + ]) { + const many = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ + resource, + operation: 'getAll', + returnAll: false, + limit: 3, + filters: {}, + }), + ); + const rows = many[0].map((r) => r.json); + assert.ok(Array.isArray(rows), `${resource}: Get Many did not return rows`); + if (rows.length === 0) continue; + + // Re-list on a 404. This project carries live traffic, so an issue or + // event listed a moment ago can be gone by the time it is fetched — + // which says nothing about whether Get works. + const found = await until( + `a ${resource} that survives being fetched`, + async () => { + const listed = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ + resource, + operation: 'getAll', + returnAll: false, + limit: 3, + filters: {}, + }), + ); + for (const row of listed[0].map((r) => r.json)) { + try { + const one = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ resource, operation: 'get', id: row.id }), + ); + return one[0][0].json.id === row.id; + } catch (error) { + if (!/404/.test(error.message)) throw error; + } + } + return false; + }, + { attempts: 4, delayMs: 500 }, + ); + + assert.ok(found, `${resource}: Get never returned a record it had just listed`); + } + }); + + await t.test('Get Count is exact where Hookdeck counts, and a floor for events', async () => { + for (const resource of ['connection', 'destination', 'issue', 'source']) { + // Compared under `until` because the count is project-wide: this project + // carries live traffic, and a concurrent suite creating a source makes + // the two reads disagree for reasons that have nothing to do with the + // node. A node returning a page size instead of a total would never + // agree, however many times it were re-read. + const agreed = await until( + `${resource} count to settle`, + async () => { + const result = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ resource, operation: 'getCount', filters: {} }), + ); + const truth = await api('GET', `/${resource}s/count`); + return result[0][0].json.count === truth.count ? result[0][0].json : null; + }, + { attempts: 5, delayMs: 1000 }, + ); + + assert.equal(agreed.isAtLeast, false, `${resource}: an exact count is not a floor`); + } + + const events = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ resource: 'event', operation: 'getCount', filters: {} }), + ); + assert.equal( + typeof events[0][0].json.isAtLeast, + 'boolean', + 'an event count must flag its floor', + ); + assert.ok(events[0][0].json.countedUpTo > 0, 'an event count must report its ceiling'); + }); + + await t.test('Get Many honours the limit, and Return All pages past it', async () => { + const limited = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ + resource: 'source', + operation: 'getAll', + returnAll: false, + limit: 2, + filters: {}, + }), + ); + assert.ok(limited[0].length <= 2, 'limit was not honoured'); + + // Same project-wide race as above: page while a suite is creating sources + // and the walk legitimately disagrees with a count taken after it. + const walked = await until( + 'Return All to agree with /count', + async () => { + const all = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ + resource: 'source', + operation: 'getAll', + returnAll: true, + filters: {}, + }), + ); + const { count } = await api('GET', '/sources/count'); + return all[0].length === count ? all[0].length : null; + }, + { attempts: 5, delayMs: 1000 }, + ); + + assert.ok(walked > 2, 'Return All did not page past the first page'); + }); + + await t.test('Source → Get URL returns the ingest URL, normalising the name', async () => { + const sourceName = `${PREFIX}-geturl`; + const { connection } = await provision(sourceName, {}); + + const result = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ resource: 'source', operation: 'getUrl', name: sourceName }), + ); + assert.equal(result[0][0].json.url, connection.source.url); + + await assert.rejects( + new HookdeckEventGateway().execute.call( + liveExecuteContext({ resource: 'source', operation: 'getUrl', name: `${PREFIX}-absent` }), + ), + /No Hookdeck source named/, + 'a missing source must fail loudly, not return an empty row', + ); + }); + + /* ────────────────── action node: mutating operations ──────────────── */ + + await t.test('Connection Pause, Unpause and Delete act on the connection', async () => { + const sourceName = `${PREFIX}-mutate`; + const { connection } = await provision(sourceName, {}); + const run = (operation) => + new HookdeckEventGateway().execute.call( + liveExecuteContext({ resource: 'connection', operation, id: connection.id }), + ); + + await run('pause'); + assert.ok((await api('GET', `/connections/${connection.id}`)).paused_at, 'Pause did not pause'); + + await run('unpause'); + assert.equal( + (await api('GET', `/connections/${connection.id}`)).paused_at, + null, + 'Unpause did not resume', + ); + + await run('delete'); + await assert.rejects( + api('GET', `/connections/${connection.id}`), + /41[0-9]|404/, + 'Delete did not remove the connection', + ); + }); + + await t.test('Event Retry, Mute and Cancel act on this run’s own events', async () => { + const sourceName = `${PREFIX}-events`; + const { connection } = await provision(sourceName, {}); + + await fetch(connection.source.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ run: PREFIX, event: 'live.test' }), + }); + + // Only ever this run's events: the project also holds production traffic. + const event = await until('an event from this run', async () => { + const { models = [] } = await api('GET', `/events?source_id=${connection.source.id}&limit=1`); + return models[0]; + }); + + const run = (operation) => + new HookdeckEventGateway().execute.call( + liveExecuteContext({ resource: 'event', operation, id: event.id }), + ); + + const retried = await run('retry'); + assert.ok(retried[0][0].json, 'Retry returned nothing'); + + await run('mute').catch((error) => { + // Hookdeck rejects muting an event that is not in a mutable state; the + // operation is still proven to reach the right endpoint. + assert.match(error.message, /HTTP 4\d\d/, `Mute failed unexpectedly: ${error.message}`); + }); + await run('cancel').catch((error) => { + assert.match(error.message, /HTTP 4\d\d/, `Cancel failed unexpectedly: ${error.message}`); + }); + + const requests = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ + resource: 'request', + operation: 'getAll', + returnAll: false, + limit: 1, + filters: { source_id: connection.source.id }, + }), + ); + assert.equal(requests[0].length, 1, 'the original request was not retrievable'); + + const attempts = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ + resource: 'attempt', + operation: 'getAll', + returnAll: false, + limit: 1, + filters: { event_id: event.id }, + }), + ); + assert.ok(Array.isArray(attempts[0]), 'delivery attempts were not retrievable'); + }); + + await t.test('Issue Update and Dismiss are wired, when an issue exists', async (t) => { + const { models = [] } = await api('GET', '/issues?limit=1&status=OPENED'); + if (models.length === 0) { + t.skip('no open issue in the project to act on'); + return; + } + + // Read-only assertion: dismissing a real issue would suppress a genuine + // alert in a project that carries production traffic. + const issue = await new HookdeckEventGateway().execute.call( + liveExecuteContext({ resource: 'issue', operation: 'get', id: models[0].id }), + ); + assert.equal(issue[0][0].json.id, models[0].id); + }); +}); diff --git a/test/live/delivery.test.mjs b/test/live/delivery.test.mjs new file mode 100644 index 0000000..f4503a6 --- /dev/null +++ b/test/live/delivery.test.mjs @@ -0,0 +1,205 @@ +/** + * Live tests for a real event arriving at the node. + * + * Deliveries come back over `hookdeck listen`, so the request the node parses is + * one Hookdeck genuinely sent, metadata headers and all. The CLI destination is + * re-pointed at the node's own signing secret, so verification is exercised for + * real rather than switched off. + * + * Kept apart from `verification.test.mjs` so the two get separate run ids: a + * shared prefix means one suite's cleanup deletes the connection the other is + * still delivering over. + * + * npm run test:live + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { HookdeckEventGatewayTrigger } from '../../dist/nodes/Hookdeck/HookdeckEventGatewayTrigger.node.js'; +import { + api, + cleanUpRun, + cliConnectionForSource, + connectionForSource, + ingest, + liveHookContext, + PREFIX, + skip, + startCliReceiver, + until, +} from './_harness.mjs'; + +/** Provision a source through the node's own `create()`. */ +async function provision(sourceName, params) { + const staticData = {}; + const ctx = liveHookContext({ + webhookUrl: 'https://example.com/webhook/live', + staticData, + params: { + source: sourceName, + sourceType: 'WEBHOOK', + verification: 'none', + options: { verifySignature: true }, + ...params, + }, + }); + await new HookdeckEventGatewayTrigger().webhookMethods.default.create.call(ctx); + const connection = await connectionForSource(sourceName); + return { connection, staticData, ingestUrl: connection.source.url }; +} + +test('real deliveries into the node', { skip, concurrency: false }, async (t) => { + const sourceName = `${PREFIX}-cli`; + let receiver; + + t.after(async () => { + await receiver?.stop(); + await cleanUpRun(); + }); + + await t.test('the CLI forwards this source, signed with the node’s own secret', async () => { + receiver = await startCliReceiver(HookdeckEventGatewayTrigger, sourceName); + assert.match(receiver.ingestUrl, /^https:\/\/hkdk\.events\//); + + // A CLI destination is signed with the project secret by default, which + // the node cannot know — so deliveries would have to be trusted unverified. + // It also accepts CUSTOM_SIGNATURE, so give it the secret the node + // generated and every assertion below runs through real verification. + const { staticData } = await provision(sourceName, {}); + const signingSecret = staticData.production?.signingSecret ?? staticData.signingSecret; + assert.ok(signingSecret, 'the node did not store a signing secret to verify against'); + + const connection = await cliConnectionForSource(sourceName); + await api('PUT', `/destinations/${connection.destination.id}`, { + config: { + ...connection.destination.config, + auth_type: 'CUSTOM_SIGNATURE', + auth: { key: 'x-hookdeck-n8n-signature', signing_secret: signingSecret }, + }, + }); + + receiver.setStaticData(staticData); + receiver.setOptions({ verifySignature: true }); + }); + + await t.test('a real event arrives with the documented output shape', async () => { + const marker = `deliver-${PREFIX}`; + const payload = { event: 'payment.succeeded', amount: 4200, marker }; + assert.ok((await ingest(receiver.ingestUrl, JSON.stringify(payload))).ok); + + const delivery = await receiver.waitFor(marker); + assert.ok(!delivery.outcome.noWebhookResponse, 'a genuine delivery was rejected'); + + const [item] = delivery.outcome.workflowData[0]; + assert.deepEqual(item.json.body, payload, 'the payload did not survive the round trip'); + assert.ok(item.json.headers, 'headers were not exposed'); + assert.ok(item.json.query, 'query was not exposed'); + + // Every field the README's Output block promises. + const meta = item.json.hookdeck; + assert.ok(meta, 'no hookdeck metadata block'); + for (const field of [ + 'eventId', + 'requestId', + 'attemptCount', + 'attemptTrigger', + 'isLastAttempt', + 'sourceName', + 'idempotencyKey', + ]) { + assert.ok(field in meta, `README promises hookdeck.${field}`); + } + assert.match(meta.eventId, /^evt_/, 'eventId was not parsed from the real header'); + assert.match(meta.requestId, /^req_/, 'requestId was not parsed from the real header'); + assert.equal(meta.sourceName, sourceName); + assert.equal(meta.attemptTrigger, 'INITIAL'); + assert.equal(typeof meta.isLastAttempt, 'boolean'); + assert.equal(meta.idempotencyKey, meta.eventId, 'README calls this stable across retries'); + }); + + await t.test('a refused delivery is retried, and the retry is marked as one', async () => { + // A CLI connection is created with no rules at all, so without this it + // would never retry. That the *node* provisions a retry rule is asserted + // in `api.test.mjs`; what is under test here is that the node reads a real + // retry's metadata correctly. + const connection = await cliConnectionForSource(sourceName); + await api('PUT', `/connections/${connection.id}`, { + rules: [{ type: 'retry', strategy: 'linear', count: 3, interval: 10000 }], + }); + + const marker = `retry-${PREFIX}`; + let refusals = 0; + receiver.setHandler(({ rawBody, res }) => { + if (!rawBody.toString().includes(marker) || refusals >= 1) return { handled: false }; + refusals++; + res.writeHead(503).end('receiver down'); + return { handled: true }; + }); + + await ingest(receiver.ingestUrl, JSON.stringify({ marker, event: 'survives.the.outage' })); + + const delivered = await receiver.waitFor(marker, { attempts: 150 }); + receiver.setHandler(null); + + assert.equal(refusals, 1, 'the outage was never exercised'); + assert.ok(!delivered.outcome.noWebhookResponse, 'the retried delivery was rejected'); + + const meta = delivered.outcome.workflowData[0][0].json.hookdeck; + assert.ok( + meta.attemptCount >= 2, + `the delivery that landed was not a retry (${meta.attemptCount})`, + ); + // Hookdeck reports an automatic retry as AUTOMATIC, reserving INITIAL for + // the first attempt. Branching on `attemptTrigger === 'RETRY'` would + // therefore never fire. + assert.equal(meta.attemptTrigger, 'AUTOMATIC', 'an automatic retry was not reported as one'); + assert.equal( + meta.idempotencyKey, + meta.eventId, + 'the idempotency key must survive a retry, or it is not a deduplication key', + ); + }); + + await t.test('deduplication collapses a repeat event at ingest', async () => { + // Applied to the CLI connection because that is the one carrying traffic; + // that the *node* provisions this rule is asserted in `api.test.mjs`. + const connection = await cliConnectionForSource(sourceName); + await api('PUT', `/connections/${connection.id}`, { + rules: [{ type: 'deduplicate', window: 600000, include_fields: ['body'] }], + }); + + const marker = `dedup-${PREFIX}`; + const body = JSON.stringify({ marker, event: 'charge.succeeded' }); + await ingest(receiver.ingestUrl, body); + await receiver.waitFor(marker); + + await ingest(receiver.ingestUrl, body); + // Give the duplicate the grace the first delivery needed, so a pass here + // means suppressed rather than merely slower. + await new Promise((resolve) => setTimeout(resolve, 20000)); + + assert.equal( + receiver.matching(marker).length, + 1, + 'a duplicate inside the window reached the workflow', + ); + }); + + await t.test('events sent while paused are delivered once resumed', async () => { + const connection = await cliConnectionForSource(sourceName); + await api('PUT', `/connections/${connection.id}/pause`); + await until( + 'the connection to report paused', + async () => (await api('GET', `/connections/${connection.id}`)).paused_at, + ); + + const marker = `queued-${PREFIX}`; + await ingest(receiver.ingestUrl, JSON.stringify({ marker, event: 'sent.during.downtime' })); + + await new Promise((resolve) => setTimeout(resolve, 8000)); + assert.equal(receiver.matching(marker).length, 0, 'a paused connection delivered anyway'); + + await api('PUT', `/connections/${connection.id}/unpause`); + await receiver.waitFor(marker, { attempts: 90 }); + }); +}); diff --git a/test/live/stripe.test.mjs b/test/live/stripe.test.mjs new file mode 100644 index 0000000..a92ad93 --- /dev/null +++ b/test/live/stripe.test.mjs @@ -0,0 +1,142 @@ +/** + * A genuine Stripe webhook, end to end. + * + * The other suites sign payloads themselves, which proves the algorithm but not + * the integration: a hand-rolled signature cannot catch a header Stripe really + * sends, a payload shape that differs from the docs, or a scheme change. This + * one makes Stripe do the sending. + * + * Requires the Stripe CLI, logged in against a **test-mode** account: + * + * stripe login + * npm run test:live:stripe + * + * The endpoint's signing secret is read into this process and handed straight to + * Hookdeck. It is never printed, never written to disk, and the endpoint is + * deleted on the way out. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { HookdeckEventGatewayTrigger } from '../../dist/nodes/Hookdeck/HookdeckEventGatewayTrigger.node.js'; +import { + PREFIX, + cleanUpRun, + connectionForSource, + liveHookContext, + requestFor, + skip as noApiKey, +} from './_harness.mjs'; + +const run = promisify(execFile); + +/** Whether the Stripe CLI is present and authenticated. */ +async function stripeReady() { + try { + const { stdout } = await run('stripe', ['config', '--list']); + // An authenticated profile carries a key; a bare profile block does not. + return /test_mode_api_key|live_mode_api_key|device_name/.test(stdout); + } catch { + return false; + } +} + +const skip = noApiKey || ((await stripeReady()) ? false : 'the Stripe CLI is not logged in'); + +test('a genuine Stripe webhook', { skip, concurrency: false }, async (t) => { + const sourceName = `${PREFIX}-stripe-real`; + let endpointId; + + t.after(async () => { + if (endpointId) { + // --confirm, or the CLI blocks on an interactive prompt and the suite + // hangs after the assertions have already passed. + await run('stripe', ['webhook_endpoints', 'delete', endpointId, '--confirm']).catch(() => {}); + } + await cleanUpRun(); + }); + + await t.test('Stripe delivers a signed event that Hookdeck verifies', async () => { + // 1. Provision the source unverified, purely to learn its ingest URL. + const staticData = {}; + const params = { + source: sourceName, + sourceType: 'STRIPE', + verification: 'none', + options: { verifySignature: true }, + }; + await new HookdeckEventGatewayTrigger().webhookMethods.default.create.call( + liveHookContext({ webhookUrl: 'https://example.com/webhook/live', staticData, params }), + ); + const { source } = await connectionForSource(sourceName); + + // 2. Point a real Stripe endpoint at it. The secret Stripe returns stays in + // this variable and goes straight back out to Hookdeck. + const created = await run('stripe', [ + 'webhook_endpoints', + 'create', + `--url=${source.url}`, + '--enabled-events=payment_intent.succeeded', + ]); + const endpoint = JSON.parse(created.stdout); + endpointId = endpoint.id; + assert.ok(endpoint.secret, 'Stripe did not return a signing secret for the endpoint'); + + // Checked before anything is triggered, not after. The CLI defaults to test + // mode, but an account carrying live keys is one flag away from creating a + // real endpoint and firing real events at it, and `t.after` deleting it + // afterwards would not undo that. + assert.equal( + endpoint.livemode, + false, + 'refusing to continue: this created a LIVE Stripe webhook endpoint', + ); + + // 3. Re-provision with that secret, so Hookdeck verifies against the real + // endpoint rather than one we invented. + // + // `updateExistingSource` is required: provisioning adopts an existing + // source untouched by default, so without it the secret is accepted, + // silently discarded, and every genuine Stripe delivery then arrives + // unverified. + await new HookdeckEventGatewayTrigger().webhookMethods.default.create.call( + liveHookContext({ + webhookUrl: 'https://example.com/webhook/live', + staticData, + params: { + ...params, + platformSecret: endpoint.secret, + options: { ...params.options, updateExistingSource: true }, + }, + }), + ); + + // 4. Make Stripe fire a real event. + await run('stripe', ['trigger', 'payment_intent.succeeded'], { timeout: 120000 }); + + // 5. Assert on what actually arrived. + const request = await requestFor(source.id, 'payment_intent.succeeded'); + assert.equal(request.verified, true, 'Stripe’s own signature failed verification'); + assert.equal(request.rejection_cause ?? null, null); + + const body = + typeof request.data.body === 'string' ? JSON.parse(request.data.body) : request.data.body; + assert.match(body.id, /^evt_/, 'not a genuine Stripe event id'); + assert.equal(body.object, 'event'); + assert.equal(body.type, 'payment_intent.succeeded'); + assert.equal(body.livemode, false, 'this must only ever run against test mode'); + assert.ok(body.data?.object, 'the event carried no object'); + + // The documented example shows `t` and `v1`. Real traffic has been observed + // carrying `v0` alongside them, so record what this account actually sends + // rather than assuming either shape. + const signature = request.data.headers['stripe-signature']; + assert.match(signature, /(^|,)t=\d+/, 'no timestamp in the Stripe signature'); + assert.match(signature, /(^|,)v1=[a-f0-9]{64}/, 'no v1 signature'); + const schemes = signature.split(',').map((part) => part.split('=')[0].trim()); + assert.ok(schemes.includes('v1'), `unexpected Stripe signature schemes: ${schemes.join(',')}`); + console.log(` stripe-signature schemes observed: ${schemes.join(', ')}`); + }); +}); diff --git a/test/live/verification.test.mjs b/test/live/verification.test.mjs new file mode 100644 index 0000000..d06b44b --- /dev/null +++ b/test/live/verification.test.mjs @@ -0,0 +1,175 @@ +/** + * Live tests for how Hookdeck treats a signature at the edge. + * + * No local endpoint is involved: the question is what Hookdeck did with the + * request, which the request record answers. Delivery into the node lives in + * `delivery.test.mjs`. + * + * Vendor secrets are generated per run. Hookdeck verifies with the same + * algorithm whether a secret came from Stripe or from `randomBytes`, so a live + * credential proves nothing extra here — `stripe.test.mjs` covers the genuine + * Stripe path, including the `t,v1,v0` header real traffic carries. + * + * npm run test:live + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac, randomBytes } from 'node:crypto'; + +import { HookdeckEventGatewayTrigger } from '../../dist/nodes/Hookdeck/HookdeckEventGatewayTrigger.node.js'; +import { + cleanUpRun, + connectionForSource, + ingest, + liveHookContext, + liveWebhookContext, + PREFIX, + requestFor, + skip, + wasVerified, +} from './_harness.mjs'; + +/** Provision a source through the node's own `create()`. */ +async function provision(sourceName, params) { + const staticData = {}; + const ctx = liveHookContext({ + webhookUrl: 'https://example.com/webhook/live', + staticData, + params: { + source: sourceName, + sourceType: 'WEBHOOK', + verification: 'none', + options: { verifySignature: true }, + ...params, + }, + }); + await new HookdeckEventGatewayTrigger().webhookMethods.default.create.call(ctx); + const connection = await connectionForSource(sourceName); + return { connection, staticData, ingestUrl: connection.source.url }; +} + +test('vendor signature verification at the edge', { skip, concurrency: false }, async (t) => { + t.after(cleanUpRun); + + await t.test('a Stripe-typed source verifies a correctly signed payload', async () => { + const secret = `whsec_${randomBytes(16).toString('hex')}`; + const { connection, ingestUrl } = await provision(`${PREFIX}-stripe`, { + sourceType: 'STRIPE', + platformSecret: secret, + }); + + const marker = `stripe-ok-${PREFIX}`; + const body = JSON.stringify({ id: 'evt_live', type: 'payment_intent.succeeded', marker }); + const timestamp = Math.floor(Date.now() / 1000); + const signature = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); + + const accepted = await ingest(ingestUrl, body, { + 'Stripe-Signature': `t=${timestamp},v1=${signature}`, + }); + assert.ok(accepted.ok, `a correctly signed Stripe payload was refused: ${accepted.status}`); + assert.equal( + await wasVerified(connection.source.id, marker), + true, + 'a correctly signed Stripe payload was not marked verified', + ); + }); + + await t.test('a Stripe-typed source marks a forged payload unverified', async () => { + const secret = `whsec_${randomBytes(16).toString('hex')}`; + const { connection, ingestUrl } = await provision(`${PREFIX}-stripe-forge`, { + sourceType: 'STRIPE', + platformSecret: secret, + }); + + const marker = `stripe-forged-${PREFIX}`; + const timestamp = Math.floor(Date.now() / 1000); + const forged = await ingest(ingestUrl, JSON.stringify({ id: 'evt_forged', marker }), { + 'Stripe-Signature': `t=${timestamp},v1=${'0'.repeat(64)}`, + }); + + // A Stripe-typed source answers 200 to a forged payload and records the + // verdict on the request instead. This is the README's "the status code is + // not the verdict" warning, asserted rather than assumed — and it is not + // uniform across source types, since GitHub refuses outright below. + assert.equal(forged.status, 200, 'Stripe verification failures are answered at the edge'); + + const request = await requestFor(connection.source.id, marker); + assert.equal(request.verified, false, 'a forged Stripe signature was marked verified'); + assert.equal( + request.rejection_cause, + 'VERIFICATION_FAILED', + 'the refusal reason is the only machine-readable signal a 200 leaves behind', + ); + }); + + await t.test('a GitHub-typed source verifies its own, differently shaped scheme', async () => { + const secret = randomBytes(20).toString('hex'); + const { connection, ingestUrl } = await provision(`${PREFIX}-github`, { + sourceType: 'GITHUB', + platformSecret: secret, + }); + + const marker = `github-ok-${PREFIX}`; + const body = JSON.stringify({ action: 'opened', marker }); + const signature = createHmac('sha256', secret).update(body).digest('hex'); + + const accepted = await ingest(ingestUrl, body, { + 'X-Hub-Signature-256': `sha256=${signature}`, + 'X-GitHub-Event': 'pull_request', + }); + assert.ok(accepted.ok, `a correctly signed GitHub payload was refused: ${accepted.status}`); + assert.equal(await wasVerified(connection.source.id, marker), true); + + const forged = await ingest(ingestUrl, JSON.stringify({ action: 'forged' }), { + 'X-Hub-Signature-256': `sha256=${'0'.repeat(64)}`, + }); + assert.equal(forged.ok, false, 'a forged GitHub signature was accepted at the edge'); + }); + + await t.test('a platform source with no secret accepts an unsigned payload', async () => { + // The trap the README describes. If this ever starts failing, that section + // is out of date and should be rewritten. + const { connection, ingestUrl } = await provision(`${PREFIX}-bare`, { sourceType: 'STRIPE' }); + + const marker = `bare-${PREFIX}`; + const accepted = await ingest(ingestUrl, JSON.stringify({ forged: true, marker })); + + assert.ok(accepted.ok, 'an unconfigured platform source rejected traffic'); + assert.equal( + await wasVerified(connection.source.id, marker), + false, + 'an unsigned payload was reported verified', + ); + }); + + await t.test('the node rejects a forged Hookdeck-to-n8n signature with 401', async () => { + const { staticData } = await provision(`${PREFIX}-forged`, {}); + const ctx = liveWebhookContext({ + rawBody: Buffer.from('{"forged":true}'), + headers: { + 'content-type': 'application/json', + 'x-hookdeck-n8n-signature': 'not-a-real-signature', + }, + staticData, + options: { verifySignature: true }, + }); + + const outcome = await new HookdeckEventGatewayTrigger().webhook.call(ctx); + assert.equal(outcome.noWebhookResponse, true, 'a forged delivery was accepted'); + assert.equal(ctx.sent.status, 401); + }); + + await t.test('the node rejects a body that is not valid UTF-8 with 400', async () => { + const { staticData } = await provision(`${PREFIX}-malformed`, {}); + const ctx = liveWebhookContext({ + rawBody: Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0x22, 0xff, 0xfe, 0x22, 0x7d]), + headers: { 'content-type': 'application/json' }, + staticData, + options: { verifySignature: false }, + }); + + const outcome = await new HookdeckEventGatewayTrigger().webhook.call(ctx); + assert.equal(outcome.noWebhookResponse, true, 'invalid UTF-8 was accepted'); + assert.equal(ctx.sent.status, 400, 'README specifies 400, outside the retryable range'); + }); +});