From daa577a2957070fb67f0b283e0d06e8430c8a764 Mon Sep 17 00:00:00 2001 From: garethx Date: Thu, 13 Aug 2026 12:56:13 +0100 Subject: [PATCH] Test the nodes against a real Hookdeck project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit suite drives the nodes against a stub, so it can prove what a node sends but never what Hookdeck does with it. These four suites close that gap: they provision real resources, put real events through the public ingest URL, and feed the resulting requests into the node's own webhook handler. api every provisioning option and all 29 action operations verification Stripe and GitHub signatures, signed and forged delivery real events over `hookdeck listen`, retries, dedup, pause stripe a genuine Stripe webhook, endpoint created and torn down These sit alongside test/integration.test.mjs rather than replacing it: that suite covers source adoption and the CLI destination, which these do not. Each skips with a stated reason when its prerequisites are absent, so the default test run needs no credentials. They run serially, because count assertions are project-wide and cannot be isolated by naming. Running them corrected several assumptions. A platform source reveals nothing about whether it verifies, while a generic one exposes config.auth_type. A forged payload is answered 200 by Stripe and refused outright by GitHub, so the status code is not the verdict in either direction. An automatic retry reports AUTOMATIC, never RETRY. Real Stripe signs with three schemes, not the documented two. Delivery groups are a plan entitlement, so that case skips rather than passing quietly. The live suites run against a project that also carries production sources, so cleanup deletes only names carrying the run's own id — including the one the node derives from the workflow id — and reports anything it could not remove instead of swallowing the failure. `hookdeck listen` is spawned detached and killed by process group: the `hookdeck` on PATH is an npm wrapper around the real binary, and signalling only the wrapper leaves that binary holding the connection open. Co-Authored-By: Claude Opus 5 --- package.json | 6 +- test/live/_harness.mjs | 397 +++++++++++++++++++++++ test/live/api.test.mjs | 538 ++++++++++++++++++++++++++++++++ test/live/delivery.test.mjs | 205 ++++++++++++ test/live/stripe.test.mjs | 142 +++++++++ test/live/verification.test.mjs | 175 +++++++++++ 6 files changed, 1462 insertions(+), 1 deletion(-) create mode 100644 test/live/_harness.mjs create mode 100644 test/live/api.test.mjs create mode 100644 test/live/delivery.test.mjs create mode 100644 test/live/stripe.test.mjs create mode 100644 test/live/verification.test.mjs 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'); + }); +});