Build API v2

This commit is contained in:
NeilClack
2026-08-07 14:06:20 -05:00
parent b2091deae0
commit 64f7070ad7
50 changed files with 25534 additions and 26502 deletions
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
computeSignature,
buildCanonicalRequest,
verifyRequestSignature,
_clearNonces
} from './v2-signature';
const SECRET = 'test-onboarding-secret';
function signedRequest(opts: {
now?: Date;
timestampOffsetSec?: number;
tamper?: Partial<Record<'signature' | 'nonce' | 'timestamp' | 'body', string>>;
}) {
const method = 'POST';
const path = '/api/v2/automations/create/account';
const timestamp = String(Math.floor((opts.now ?? new Date()).getTime() / 1000));
const nonce = 'a48c0f2e-5f34-4b0b-9a12-3c7d6b8e1f00';
const body = '{"email":"user@example.com","subscription_level":"Basic"}';
let ts = timestamp;
let nonceValue = nonce;
if (opts.tamper?.timestamp) ts = opts.tamper.timestamp;
if (opts.tamper?.nonce) nonceValue = opts.tamper.nonce;
let sig = computeSignature(SECRET, buildCanonicalRequest(method, path, ts, nonceValue, body));
if (opts.tamper?.body)
sig = computeSignature(
SECRET,
buildCanonicalRequest(method, path, ts, nonceValue, opts.tamper.body)
);
if (opts.tamper?.signature) sig = opts.tamper.signature;
const headers = new Headers({
'content-type': 'application/json',
'x-timestamp': ts,
'x-nonce': nonceValue,
'x-signature': sig
});
return { method, path, headers, rawBody: body };
}
describe('verifyRequestSignature', () => {
beforeEach(() => {
_clearNonces();
});
it('accepts a correctly signed request', () => {
const { method, path, headers, rawBody } = signedRequest({});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(result.ok).toBe(true);
});
it('rejects a request with a wrong signature', () => {
const { method, path, headers, rawBody } = signedRequest({
tamper: { signature: 'deadbeef'.repeat(8) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe('invalid signature');
});
it('rejects a request signed with the wrong secret', () => {
const { method, path, headers, rawBody } = signedRequest({});
const result = verifyRequestSignature({
method,
path,
headers,
rawBody,
secret: 'other-secret'
});
expect(result.ok).toBe(false);
});
it('rejects missing signature headers', () => {
const { method, path, rawBody } = signedRequest({});
const result = verifyRequestSignature({
method,
path,
headers: new Headers(),
rawBody,
secret: SECRET
});
expect(result.ok).toBe(false);
});
it('rejects an expired timestamp (>300s skew)', () => {
const now = new Date();
const { method, path, headers, rawBody } = signedRequest({
tamper: { timestamp: String(Math.floor(now.getTime() / 1000) - 600) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET, now });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe('expired timestamp');
});
it('accepts a timestamp within the 300s skew', () => {
const now = new Date();
const { method, path, headers, rawBody } = signedRequest({
tamper: { timestamp: String(Math.floor(now.getTime() / 1000) - 120) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET, now });
expect(result.ok).toBe(true);
});
it('rejects a replayed nonce', () => {
const { method, path, headers, rawBody } = signedRequest({});
expect(verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET }).ok).toBe(
true
);
const replay = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(replay.ok).toBe(false);
if (!replay.ok) expect(replay.reason).toBe('replayed nonce');
});
});