import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { request } from 'node:http';
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createInbox, createReceiver, verify } from './receiver.mjs';
import { fixtureBody, fixtureSecret, fixtureTimestamp, signedHeaders } from './fixture.mjs';
const now = Number(fixtureTimestamp);
const fixtureId = JSON.parse(fixtureBody).id;
const headers = () => signedHeaders(fixtureBody, fixtureSecret, fixtureTimestamp);
const rawRequest = values => ({ rawHeaders: Object.entries(values).flat() });
const signedAnyBody = body => ({ ...headers(), 'X-TheFaithApp-Signature': 'v1=' +
createHmac('sha256', fixtureSecret).update(fixtureTimestamp + '.').update(body).digest('hex') });
const verifyBody = (body = fixtureBody, values = headers(), time = now) =>
verify(rawRequest(values), Buffer.from(body), fixtureSecret, time);
async function harness(t, options = {}) {
const root = await mkdtemp(join(tmpdir(), 'tfa-webhook-example-'));
const directory = join(root, 'inbox');
const inbox = await createInbox(directory);
const server = createReceiver({ secret: fixtureSecret, inbox, now: () => now, ...options });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
await rm(root, { recursive: true, force: true });
});
async function send(body = fixtureBody, values = headers(), { method = 'POST', path = '/webhooks/thefaithapp' } = {}) {
return new Promise((resolve, reject) => {
const wireHeaders = Array.isArray(values) ? [...values, 'Host', '127.0.0.1', 'Content-Length', String(Buffer.byteLength(body))] : values;
const req = request({ hostname: '127.0.0.1', port: server.address().port, path, method, headers: wireHeaders }, response => {
let text = '';
response.setEncoding('utf8').on('data', chunk => { text += chunk; });
response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(text) }));
});
req.on('error', reject);
req.end(body);
});
}
return { directory, inbox, send };
}
test('fixed PHP-compatible Unicode/slash signing vector and exact bytes', () => {
assert.equal(headers()['X-TheFaithApp-Signature'], 'v1=dd29fa30b814fa96065cefd2b31c27bf4a643d770a609416e4ee905d5368a8a9');
assert.equal(verifyBody().rawBody, fixtureBody);
assert.throws(() => verifyBody(fixtureBody + '\n'), /Invalid signature/);
assert.throws(() => verifyBody(fixtureBody.replace('café', 'cafe')), /Invalid signature/);
});
test('missing, repeated and malformed signing headers are rejected', () => {
for (const name of ['X-TheFaithApp-Event', 'X-TheFaithApp-Event-Id', 'X-TheFaithApp-Timestamp', 'X-TheFaithApp-Signature']) {
const missing = headers(); delete missing[name];
assert.throws(() => verifyBody(fixtureBody, missing), /Missing or repeated/);
const repeated = rawRequest(headers()); repeated.rawHeaders.push(name.toLowerCase(), headers()[name]);
assert.throws(() => verify(repeated, Buffer.from(fixtureBody), fixtureSecret, now), /Missing or repeated/);
}
for (const [name, value] of [
['X-TheFaithApp-Signature', 'v1=00'], ['X-TheFaithApp-Signature', 'v2=' + '0'.repeat(64)],
['X-TheFaithApp-Timestamp', '1e9'], ['X-TheFaithApp-Timestamp', '+' + fixtureTimestamp],
['X-TheFaithApp-Event-Id', '../escape'], ['X-TheFaithApp-Event', 'Event Updated'],
]) assert.throws(() => verifyBody(fixtureBody, { ...headers(), [name]: value }), /Malformed/);
});
test('five-minute replay policy rejects stale and future timestamps, including on duplicates', async t => {
assert.equal(verifyBody(fixtureBody, headers(), now + 300).id, fixtureId);
for (const time of [now + 301, now - 301]) assert.throws(() => verifyBody(fixtureBody, headers(), time), /replay window/);
let clock = now;
const { send } = await harness(t, { now: () => clock });
assert.equal((await send()).status, 202);
clock += 301;
assert.equal((await send()).status, 401);
assert.equal((await send(fixtureBody, signedHeaders(fixtureBody, fixtureSecret, String(clock)))).status, 200);
});
test('verified JSON must have a matching envelope; no reserialization before HMAC', () => {
for (const payload of [null, [], {}, { ...JSON.parse(fixtureBody), data: null },
{ ...JSON.parse(fixtureBody), created_at: 'invalid' }]) {
const body = JSON.stringify(payload);
assert.throws(() => verifyBody(body, signedAnyBody(body)), /Invalid event envelope/);
}
for (const body of [Buffer.from('{truncated'), Buffer.from([0xff])]) {
assert.throws(() => verifyBody(body, signedAnyBody(body)), /Invalid UTF-8 JSON/);
}
const body = fixtureBody.replace(fixtureId, '12fdcf7e-822d-4ce8-b35e-cb9f28ad56d2');
const values = signedHeaders(body, fixtureSecret, fixtureTimestamp);
values['X-TheFaithApp-Event-Id'] = fixtureId;
assert.throws(() => verifyBody(body, values), /Invalid event envelope/);
const pretty = JSON.stringify(JSON.parse(fixtureBody), null, 2);
assert.equal(verifyBody(pretty, signedHeaders(pretty, fixtureSecret, fixtureTimestamp)).rawBody, pretty);
});
test('accepted event is complete on disk; repeat and fresh-timestamp retry survive restart', async t => {
const { directory, send } = await harness(t);
assert.deepEqual(await send(), { status: 202, body: { accepted: true, duplicate: false } });
assert.deepEqual(JSON.parse(await readFile(join(directory, fixtureId + '.json'), 'utf8')), verifyBody());
assert.deepEqual(await send(), { status: 200, body: { accepted: true, duplicate: true } });
// Constructing a new store has no in-memory seen-ID cache.
assert.equal(await (await createInbox(directory)).accept(verifyBody()), false);
assert.equal((await readdir(directory)).filter(name => name.endsWith('.json')).length, 1);
});
test('twenty simultaneous duplicate requests produce one accepted inbox record', async t => {
const { directory, send } = await harness(t);
const results = await Promise.all(Array.from({ length: 20 }, () => send()));
assert.equal(results.filter(result => result.status === 202).length, 1);
assert.equal(results.filter(result => result.status === 200).length, 19);
assert.deepEqual(await readdir(directory), [fixtureId + '.json']);
});
test('same event ID with changed signed content is a conflict and cannot overwrite the first event', async t => {
const { directory, send } = await harness(t);
await send();
const changed = fixtureBody.replace('Fictional', 'Changed');
assert.equal((await send(changed, signedHeaders(changed, fixtureSecret, fixtureTimestamp))).status, 409);
assert.equal(JSON.parse(await readFile(join(directory, fixtureId + '.json'), 'utf8')).rawBody, fixtureBody);
});
test('storage failure returns non-2xx; retry can recover without a poisoned queue', async t => {
const { directory, send } = await harness(t);
await rm(directory, { recursive: true });
assert.deepEqual(await send(), { status: 503, body: { error: 'Inbox unavailable; retry later' } });
await createInbox(directory);
assert.equal((await send()).status, 202);
});
test('corrupt saved event fails closed; no overwrite or successful acknowledgement', async t => {
const { directory, send } = await harness(t);
const target = join(directory, fixtureId + '.json');
await writeFile(target, '{truncated');
assert.equal((await send()).status, 503);
assert.equal(await readFile(target, 'utf8'), '{truncated');
});
test('HTTP path, method, content type, body limit and duplicate wire headers are checked', async t => {
const { send } = await harness(t);
assert.equal((await send('', headers(), { path: '/different' })).status, 404);
assert.equal((await send('', headers(), { method: 'GET' })).status, 405);
assert.equal((await send(fixtureBody, { ...headers(), 'Content-Type': 'text/plain' })).status, 415);
assert.equal((await send(fixtureBody, [...Object.entries(headers()), ['x-thefaithapp-event-id', fixtureId]].flat())).status, 400);
const limited = await harness(t, { bodyLimit: 8 });
assert.equal((await limited.send()).status, 413);
});
test('no 2xx before the inbox finishes durable acceptance', async t => {
let release;
const persisted = new Promise(resolve => { release = resolve; });
let reached;
const started = new Promise(resolve => { reached = resolve; });
const { send } = await harness(t, { inbox: { async accept() { reached(); await persisted; return true; } } });
let returned = false;
const response = send().then(value => { returned = true; return value; });
await started;
assert.equal(returned, false);
release();
assert.equal((await response).status, 202);
});