From 1ad092b1677bba89c22a715e417e4c25f9d54302 Mon Sep 17 00:00:00 2001 From: "yilkimezakka@gmail.com" Date: Mon, 28 Sep 2026 11:26:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(webhooks):=20webhook=20signature=20verific?= =?UTF-8?q?ation=20tests=20=E2=80=94=20Issue=20#807?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive vitest test suite (44 tests) covering: 1. HMAC-SHA256 signature generation and format validation 2. Signature verification success paths (key rotation, keyId filtering) 3. Signature verification failure paths (invalid sig, no secrets, out-of-tolerance timestamps, empty/malformed signatures) 4. Replay protection boundary conditions and custom toleranceSeconds 5. Secret lifecycle management (create, rotate, deactivate, grace period) 6. Event queue management (queue, limit, retry capping at 3, marking processed) 7. Constant-time comparison (timing attack prevention) 8. Multiple provider isolation (stripe/paypal/github/custom) Closes #807 --- ...webhook-verification-comprehensive.test.ts | 777 ++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 backend/src/services/__tests__/webhook-verification-comprehensive.test.ts diff --git a/backend/src/services/__tests__/webhook-verification-comprehensive.test.ts b/backend/src/services/__tests__/webhook-verification-comprehensive.test.ts new file mode 100644 index 00000000..1b68a301 --- /dev/null +++ b/backend/src/services/__tests__/webhook-verification-comprehensive.test.ts @@ -0,0 +1,777 @@ +/** + * Comprehensive Webhook Signature Verification Tests + * Issue #807 — Expanded coverage for webhook signature verification + * + * Covers: + * 1. HMAC signature generation + * 2. Signature verification — success paths + * 3. Signature verification — failure paths + * 4. Replay protection (timestamp tolerance) + * 5. Secret management (create, rotate, deactivate) + * 6. Event queue management (queue, retry, mark processed) + * 7. Constant-time comparison (timing attack prevention) + * 8. Multiple provider isolation + */ + +import { createHmac } from 'node:crypto'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { + generateWebhookSignature, + verifyWebhookSignature, + createWebhookSecret, + getActiveSecretForProvider, + getActiveSecretsForProvider, + rotateWebhookSecret, + deactivateWebhookSecret, + getAllWebhookSecrets, + queueFailedWebhook, + getQueuedWebhooks, + retryWebhook, + markWebhookProcessed, + getWebhookEvents, + clearWebhookEvents, + clearWebhookSecrets, + WebhookProvider, + WebhookSecret, +} from '../webhooks/verification'; + +// --------------------------------------------------------------------------- +// Shared test constants +// --------------------------------------------------------------------------- + +const SECRET = 'whsec_test_comprehensive_secret_key_32_chars_minimum'; +const PAYLOAD = JSON.stringify({ event: 'payment.created', data: { id: 'evt_001', amount: 5000 } }); +const NOW_ISO = new Date().toISOString(); + +/** Returns an ISO timestamp that is `offsetMs` milliseconds away from now. */ +function tsOffset(offsetMs: number): string { + return new Date(Date.now() + offsetMs).toISOString(); +} + +// --------------------------------------------------------------------------- +// 1. HMAC Signature Generation +// --------------------------------------------------------------------------- + +describe('1. HMAC Signature Generation', () => { + beforeEach(() => { + clearWebhookSecrets(); + clearWebhookEvents(); + }); + + it('produces consistent signatures for identical inputs', () => { + const sig1 = generateWebhookSignature(PAYLOAD, SECRET, NOW_ISO); + const sig2 = generateWebhookSignature(PAYLOAD, SECRET, NOW_ISO); + expect(sig1).toBe(sig2); + }); + + it('output is a 64-character lowercase hex string (SHA-256)', () => { + const sig = generateWebhookSignature(PAYLOAD, SECRET, NOW_ISO); + expect(sig).toMatch(/^[a-f0-9]{64}$/); + }); + + it('message format is "{timestamp}.{payload}"', () => { + const timestamp = '2026-01-01T00:00:00.000Z'; + const payload = 'hello_world'; + const expected = createHmac('sha256', SECRET) + .update(`${timestamp}.${payload}`) + .digest('hex'); + expect(generateWebhookSignature(payload, SECRET, timestamp)).toBe(expected); + }); + + it('different payload produces different signature', () => { + const sig1 = generateWebhookSignature(PAYLOAD, SECRET, NOW_ISO); + const sig2 = generateWebhookSignature('different payload', SECRET, NOW_ISO); + expect(sig1).not.toBe(sig2); + }); + + it('different secret produces different signature', () => { + const sig1 = generateWebhookSignature(PAYLOAD, SECRET, NOW_ISO); + const sig2 = generateWebhookSignature(PAYLOAD, 'whsec_another_totally_different_secret_key_xyz', NOW_ISO); + expect(sig1).not.toBe(sig2); + }); + + it('different timestamp produces different signature', () => { + const sig1 = generateWebhookSignature(PAYLOAD, SECRET, NOW_ISO); + const sig2 = generateWebhookSignature(PAYLOAD, SECRET, '2020-06-15T12:00:00.000Z'); + expect(sig1).not.toBe(sig2); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Signature Verification — Success Paths +// --------------------------------------------------------------------------- + +describe('2. Signature Verification — Success Paths', () => { + beforeEach(() => { + clearWebhookSecrets(); + clearWebhookEvents(); + }); + + it('valid signature verifies correctly', () => { + const ts = tsOffset(-5_000); // 5 seconds ago — well within tolerance + const sig = generateWebhookSignature(PAYLOAD, SECRET, ts); + createWebhookSecret('stripe', SECRET); + + const result = verifyWebhookSignature({ + signature: sig, + timestamp: ts, + body: PAYLOAD, + provider: 'stripe', + }); + + expect(result.isValid).toBe(true); + expect(result.provider).toBe('stripe'); + expect(result.error).toBeUndefined(); + expect(result.timestamp).toEqual(new Date(ts)); + }); + + it('key rotation: verifies against each active secret', () => { + const ts = tsOffset(-3_000); + const secret1 = 'whsec_rotation_key_one_at_least_32_characters_long'; + const secret2 = 'whsec_rotation_key_two_at_least_32_characters_long'; + + // Two active secrets simultaneously (overlap window during rotation) + createWebhookSecret('paypal', secret1); + createWebhookSecret('paypal', secret2); + + // Signature made with secret1 should still verify + const sig1 = generateWebhookSignature(PAYLOAD, secret1, ts); + const res1 = verifyWebhookSignature({ signature: sig1, timestamp: ts, body: PAYLOAD, provider: 'paypal' }); + expect(res1.isValid).toBe(true); + + // Signature made with secret2 should also verify + const sig2 = generateWebhookSignature(PAYLOAD, secret2, ts); + const res2 = verifyWebhookSignature({ signature: sig2, timestamp: ts, body: PAYLOAD, provider: 'paypal' }); + expect(res2.isValid).toBe(true); + }); + + it('expired secrets are NOT used for verification', () => { + const ts = tsOffset(-3_000); + // Create a secret that expired in the past + const expiredAt = new Date(Date.now() - 60_000).toISOString(); // expired 1 min ago + createWebhookSecret('github', SECRET, expiredAt); + + // No active (unexpired) secret exists — verification must fail + const sig = generateWebhookSignature(PAYLOAD, SECRET, ts); + const result = verifyWebhookSignature({ signature: sig, timestamp: ts, body: PAYLOAD, provider: 'github' }); + expect(result.isValid).toBe(false); + }); + + it('keyId filtering: only secrets with matching keyId are used', () => { + const ts = tsOffset(-3_000); + const secretA = 'whsec_keyid_secret_A_at_least_32_characters_xxxxx'; + const secretB = 'whsec_keyid_secret_B_at_least_32_characters_xxxxx'; + + createWebhookSecret('custom', secretA, undefined, 'key_A'); + createWebhookSecret('custom', secretB, undefined, 'key_B'); + + // Sign with secretA and specify keyId=key_A + const sigA = generateWebhookSignature(PAYLOAD, secretA, ts); + const resultA = verifyWebhookSignature({ + signature: sigA, + timestamp: ts, + body: PAYLOAD, + provider: 'custom', + keyId: 'key_A', + }); + expect(resultA.isValid).toBe(true); + + // Sign with secretA but request keyId=key_B — must fail + const resultB = verifyWebhookSignature({ + signature: sigA, + timestamp: ts, + body: PAYLOAD, + provider: 'custom', + keyId: 'key_B', + }); + expect(resultB.isValid).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Signature Verification — Failure Paths +// --------------------------------------------------------------------------- + +describe('3. Signature Verification — Failure Paths', () => { + beforeEach(() => { + clearWebhookSecrets(); + clearWebhookEvents(); + }); + + it('invalid signature returns isValid=false with error message', () => { + createWebhookSecret('stripe', SECRET); + + const result = verifyWebhookSignature({ + signature: 'deadbeef00000000000000000000000000000000000000000000000000000000', + timestamp: tsOffset(-1_000), + body: PAYLOAD, + provider: 'stripe', + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('verification failed'); + }); + + it('no active secrets returns error message', () => { + // No secret created for github + const result = verifyWebhookSignature({ + signature: 'a'.repeat(64), + timestamp: tsOffset(-1_000), + body: PAYLOAD, + provider: 'github', + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('No active secret found'); + }); + + it('timestamp far in the past (replay attack) is rejected', () => { + createWebhookSecret('stripe', SECRET); + const oldTs = '2020-01-01T00:00:00.000Z'; + const sig = generateWebhookSignature(PAYLOAD, SECRET, oldTs); + + const result = verifyWebhookSignature({ + signature: sig, + timestamp: oldTs, + body: PAYLOAD, + provider: 'stripe', + toleranceSeconds: 300, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('outside tolerance window'); + }); + + it('timestamp far in the future is rejected', () => { + createWebhookSecret('stripe', SECRET); + const futureTs = tsOffset(10 * 60 * 1000); // 10 minutes ahead + const sig = generateWebhookSignature(PAYLOAD, SECRET, futureTs); + + const result = verifyWebhookSignature({ + signature: sig, + timestamp: futureTs, + body: PAYLOAD, + provider: 'stripe', + toleranceSeconds: 300, // 5 min tolerance + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('outside tolerance window'); + }); + + it('timestamp slightly in the future within tolerance is accepted', () => { + createWebhookSecret('stripe', SECRET); + // 30 seconds ahead — within 5-minute default tolerance + const slightFutureTs = tsOffset(30_000); + const sig = generateWebhookSignature(PAYLOAD, SECRET, slightFutureTs); + + const result = verifyWebhookSignature({ + signature: sig, + timestamp: slightFutureTs, + body: PAYLOAD, + provider: 'stripe', + toleranceSeconds: 300, + }); + + expect(result.isValid).toBe(true); + }); + + it('empty signature string is rejected', () => { + createWebhookSecret('stripe', SECRET); + + const result = verifyWebhookSignature({ + signature: '', + timestamp: tsOffset(-1_000), + body: PAYLOAD, + provider: 'stripe', + }); + + expect(result.isValid).toBe(false); + }); + + it('malformed/short signature string is rejected', () => { + createWebhookSecret('stripe', SECRET); + + const result = verifyWebhookSignature({ + signature: 'abc123', + timestamp: tsOffset(-1_000), + body: PAYLOAD, + provider: 'stripe', + }); + + expect(result.isValid).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Replay Protection — Boundary Conditions +// --------------------------------------------------------------------------- + +describe('4. Replay Protection — Boundary Conditions', () => { + beforeEach(() => { + clearWebhookSecrets(); + clearWebhookEvents(); + }); + + it('timestamp well within tolerance boundary passes', () => { + createWebhookSecret('stripe', SECRET); + const TOLERANCE_S = 300; + // Use a timestamp 1 second inside the boundary to avoid timing flakiness + // (the service checks timeDiff > toleranceMs, so exactly at the boundary + // could fail if a few ms elapse between timestamp creation and check) + const withinTs = tsOffset(-((TOLERANCE_S - 1) * 1000)); + const sig = generateWebhookSignature(PAYLOAD, SECRET, withinTs); + + const result = verifyWebhookSignature({ + signature: sig, + timestamp: withinTs, + body: PAYLOAD, + provider: 'stripe', + toleranceSeconds: TOLERANCE_S, + }); + + // 1s inside boundary is NOT > toleranceMs, so it should pass + expect(result.isValid).toBe(true); + }); + + it('timestamp 1 second beyond tolerance is rejected', () => { + createWebhookSecret('stripe', SECRET); + const TOLERANCE_S = 60; + const beyondTs = tsOffset(-((TOLERANCE_S + 1) * 1000)); + const sig = generateWebhookSignature(PAYLOAD, SECRET, beyondTs); + + const result = verifyWebhookSignature({ + signature: sig, + timestamp: beyondTs, + body: PAYLOAD, + provider: 'stripe', + toleranceSeconds: TOLERANCE_S, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('outside tolerance window'); + }); + + it('custom toleranceSeconds is respected (short window)', () => { + createWebhookSecret('stripe', SECRET); + // 30 seconds ago — fine for 300s tolerance but out of range for 10s tolerance + const ts = tsOffset(-30_000); + const sig = generateWebhookSignature(PAYLOAD, SECRET, ts); + + const tight = verifyWebhookSignature({ + signature: sig, + timestamp: ts, + body: PAYLOAD, + provider: 'stripe', + toleranceSeconds: 10, + }); + expect(tight.isValid).toBe(false); + + const loose = verifyWebhookSignature({ + signature: sig, + timestamp: ts, + body: PAYLOAD, + provider: 'stripe', + toleranceSeconds: 300, + }); + expect(loose.isValid).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Secret Management +// --------------------------------------------------------------------------- + +describe('5. Secret Management', () => { + beforeEach(() => { + clearWebhookSecrets(); + clearWebhookEvents(); + }); + + it('createWebhookSecret stores and returns id, keyId, provider', () => { + const secret = createWebhookSecret('stripe', SECRET); + expect(secret.id).toMatch(/^whs_/); + expect(secret.keyId).toBeDefined(); + expect(secret.provider).toBe('stripe'); + expect(secret.secret).toBe(SECRET); + expect(secret.isActive).toBe(true); + expect(secret.createdAt).toBeDefined(); + }); + + it('getActiveSecretForProvider returns the newest active secret', async () => { + createWebhookSecret('stripe', 'whsec_older_secret_at_least_32_characters_xxxxxxxx'); + // Wait 2ms to guarantee a strictly greater Date.now() for the second secret + await new Promise(resolve => setTimeout(resolve, 2)); + const newer = createWebhookSecret('stripe', 'whsec_newer_secret_at_least_32_characters_xxxxxxxx'); + + const active = getActiveSecretForProvider('stripe'); + expect(active?.id).toBe(newer.id); + }); + + it('getActiveSecretsForProvider returns all active secrets sorted newest first', () => { + const s1 = createWebhookSecret('paypal', 'whsec_paypal_secret_one_minimum_32_characters_xxxx'); + const s2 = createWebhookSecret('paypal', 'whsec_paypal_secret_two_minimum_32_characters_xxxx'); + const s3 = createWebhookSecret('paypal', 'whsec_paypal_secret_three_minimum_32_chars_xxxxxx'); + + const active = getActiveSecretsForProvider('paypal'); + expect(active.length).toBe(3); + // Sorted newest first + expect(new Date(active[0].createdAt).getTime()).toBeGreaterThanOrEqual( + new Date(active[1].createdAt).getTime() + ); + expect(new Date(active[1].createdAt).getTime()).toBeGreaterThanOrEqual( + new Date(active[2].createdAt).getTime() + ); + // All active + expect(active.every(s => s.isActive)).toBe(true); + // IDs present + const ids = active.map(s => s.id); + expect(ids).toContain(s1.id); + expect(ids).toContain(s2.id); + expect(ids).toContain(s3.id); + }); + + it('rotateWebhookSecret deactivates old secrets and creates a new one', () => { + const old = createWebhookSecret('github', SECRET); + const newSecretValue = 'whsec_rotated_github_secret_at_least_32_chars_xxx'; + const rotated = rotateWebhookSecret('github', newSecretValue, 24); + + expect(rotated.isActive).toBe(true); + expect(rotated.secret).toBe(newSecretValue); + expect(rotated.provider).toBe('github'); + + const storedOld = getAllWebhookSecrets().find(s => s.id === old.id); + expect(storedOld?.isActive).toBe(false); + }); + + it('rotated old secret has an expiresAt grace period set', () => { + const old = createWebhookSecret('github', SECRET); + const gracePeriodHours = 12; + rotateWebhookSecret('github', 'whsec_rotated_secret_minimum_32_chars_xxxxxxxxxx', gracePeriodHours); + + const storedOld = getAllWebhookSecrets().find(s => s.id === old.id); + expect(storedOld?.expiresAt).toBeDefined(); + const expiresAt = new Date(storedOld!.expiresAt!).getTime(); + const expectedExpiry = Date.now() + gracePeriodHours * 60 * 60 * 1000; + // Allow ±5 seconds of drift + expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(5_000); + }); + + it('deactivateWebhookSecret marks secret as inactive', () => { + const secret = createWebhookSecret('custom', SECRET); + const success = deactivateWebhookSecret(secret.id); + + expect(success).toBe(true); + const stored = getAllWebhookSecrets().find(s => s.id === secret.id); + expect(stored?.isActive).toBe(false); + + const active = getActiveSecretForProvider('custom'); + expect(active).toBeNull(); + }); + + it('deactivateWebhookSecret returns false for unknown id', () => { + const result = deactivateWebhookSecret('whs_nonexistent_id_00000000'); + expect(result).toBe(false); + }); + + it('getAllWebhookSecrets returns all secrets (active and inactive)', () => { + const s1 = createWebhookSecret('stripe', SECRET); + const s2 = createWebhookSecret('paypal', 'whsec_paypal_all_secrets_test_at_least_32_chars'); + deactivateWebhookSecret(s1.id); + + const all = getAllWebhookSecrets(); + const ids = all.map(s => s.id); + expect(ids).toContain(s1.id); + expect(ids).toContain(s2.id); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Event Queue Management +// --------------------------------------------------------------------------- + +describe('6. Event Queue Management', () => { + beforeEach(() => { + clearWebhookSecrets(); + clearWebhookEvents(); + }); + + it('queueFailedWebhook creates event with retryCount=0', () => { + const event = queueFailedWebhook('stripe', 'charge.failed', { id: 'ch_1' }, 'bad_sig', NOW_ISO, 'Sig mismatch'); + + expect(event.id).toMatch(/^whe_/); + expect(event.provider).toBe('stripe'); + expect(event.eventType).toBe('charge.failed'); + expect(event.payload).toEqual({ id: 'ch_1' }); + expect(event.signature).toBe('bad_sig'); + expect(event.verified).toBe(false); + expect(event.processed).toBe(false); + expect(event.retryCount).toBe(0); + expect(event.error).toBe('Sig mismatch'); + }); + + it('getQueuedWebhooks filters out already-processed events', () => { + const active = queueFailedWebhook('stripe', 'ev.a', {}, 'sig1', NOW_ISO, 'err'); + const done = queueFailedWebhook('stripe', 'ev.b', {}, 'sig2', NOW_ISO, 'err'); + markWebhookProcessed(done.id); + + const queue = getQueuedWebhooks(100); + const ids = queue.map(e => e.id); + expect(ids).toContain(active.id); + expect(ids).not.toContain(done.id); + }); + + it('getQueuedWebhooks respects limit parameter', () => { + for (let i = 0; i < 10; i++) { + queueFailedWebhook('stripe', `ev.${i}`, { i }, 'sig', NOW_ISO, 'err'); + } + + const limited = getQueuedWebhooks(4); + expect(limited.length).toBe(4); + }); + + it('retryWebhook increments retryCount on each call', () => { + createWebhookSecret('paypal', SECRET); + const event = queueFailedWebhook('paypal', 'sale.completed', {}, 'bad_sig', NOW_ISO, 'err'); + + retryWebhook(event.id); + let stored = getAllWebhookSecrets(); // just to keep linter happy + const afterOne = getQueuedWebhooks(100).find(e => e.id === event.id); + // After first retry with bad sig, event is still queued with retryCount=1 + const all1 = getWebhookEvents(100).find(e => e.id === event.id); + expect(all1?.retryCount).toBe(1); + }); + + it('retryWebhook returns null after 3 retries (max attempts reached)', () => { + createWebhookSecret('paypal', SECRET); + const event = queueFailedWebhook('paypal', 'sale.done', {}, 'bad_sig', NOW_ISO, 'err'); + + // Exhaust all 3 retries + retryWebhook(event.id); + retryWebhook(event.id); + retryWebhook(event.id); + + // 4th call — already at max + const result = retryWebhook(event.id); + expect(result).toBeNull(); + + // Verify retryCount is capped at 3 + const stored = getWebhookEvents(100).find(e => e.id === event.id); + expect(stored?.retryCount).toBe(3); + }); + + it('retryWebhook marks event as verified/processed on valid signature', () => { + const ts = tsOffset(-3_000); // 3 seconds ago — well within tolerance + createWebhookSecret('stripe', SECRET); + const payload = { id: 'evt_retry_success', amount: 9900 }; + const payloadStr = JSON.stringify(payload); + const goodSig = generateWebhookSignature(payloadStr, SECRET, ts); + + const event = queueFailedWebhook('stripe', 'payment.success', payload, goodSig, ts, 'original err'); + + const result = retryWebhook(event.id); + expect(result).not.toBeNull(); + expect(result?.isValid).toBe(true); + + const stored = getWebhookEvents(100).find(e => e.id === event.id); + expect(stored?.verified).toBe(true); + expect(stored?.processed).toBe(true); + expect(stored?.processedAt).toBeDefined(); + expect(stored?.error).toBeUndefined(); + }); + + it('markWebhookProcessed sets processed=true and processedAt', () => { + const event = queueFailedWebhook('stripe', 'inv.paid', {}, 'sig', NOW_ISO, 'err'); + + const success = markWebhookProcessed(event.id); + expect(success).toBe(true); + + const stored = getWebhookEvents(100).find(e => e.id === event.id); + expect(stored?.processed).toBe(true); + expect(stored?.processedAt).toBeDefined(); + + // Must no longer appear in getQueuedWebhooks + const queue = getQueuedWebhooks(100); + expect(queue.find(e => e.id === event.id)).toBeUndefined(); + }); + + it('getQueuedWebhooks excludes events that reached max retries', () => { + createWebhookSecret('stripe', SECRET); + const event = queueFailedWebhook('stripe', 'ev.maxretry', {}, 'bad', NOW_ISO, 'err'); + + retryWebhook(event.id); + retryWebhook(event.id); + retryWebhook(event.id); + + // retryCount is now 3 — getQueuedWebhooks filters retryCount < 3 + const queue = getQueuedWebhooks(100); + expect(queue.find(e => e.id === event.id)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Constant-Time Comparison (Timing Attack Prevention) +// --------------------------------------------------------------------------- + +describe('7. Constant-Time Comparison (Timing Attack Prevention)', () => { + beforeEach(() => { + clearWebhookSecrets(); + clearWebhookEvents(); + }); + + /** + * We cannot directly unit-test the private `constantTimeEquals` function, + * but we can verify its observable behavior via `verifyWebhookSignature`: + * + * - A signature of a different length must be rejected. + * - A signature of the same length but wrong bytes must also be rejected. + * + * The internal implementation uses a XOR-accumulation loop that always + * iterates all bytes, providing constant-time guarantees. + */ + + it('wrong-length signature is rejected (length check does not leak via timing)', () => { + createWebhookSecret('stripe', SECRET); + const ts = tsOffset(-1_000); + + // 63 chars — one byte short of 64 + const shortSig = 'a'.repeat(63); + const result = verifyWebhookSignature({ + signature: shortSig, + timestamp: ts, + body: PAYLOAD, + provider: 'stripe', + }); + expect(result.isValid).toBe(false); + }); + + it('correct-length but wrong-content signature is rejected (full byte comparison)', () => { + createWebhookSecret('stripe', SECRET); + const ts = tsOffset(-1_000); + + // All-zero hex, correct length (64 chars) + const wrongSig = '0'.repeat(64); + const result = verifyWebhookSignature({ + signature: wrongSig, + timestamp: ts, + body: PAYLOAD, + provider: 'stripe', + }); + expect(result.isValid).toBe(false); + }); + + it('one-bit flip in signature causes rejection', () => { + createWebhookSecret('stripe', SECRET); + const ts = tsOffset(-1_000); + + const correctSig = generateWebhookSignature(PAYLOAD, SECRET, ts); + // Flip the last hex character + const flippedSig = correctSig.slice(0, -1) + (correctSig.endsWith('0') ? '1' : '0'); + + const result = verifyWebhookSignature({ + signature: flippedSig, + timestamp: ts, + body: PAYLOAD, + provider: 'stripe', + }); + expect(result.isValid).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Multiple Provider Isolation +// --------------------------------------------------------------------------- + +describe('8. Multiple Provider Isolation', () => { + const providers: WebhookProvider[] = ['stripe', 'paypal', 'github', 'custom']; + + beforeEach(() => { + clearWebhookSecrets(); + clearWebhookEvents(); + }); + + it('all four providers work independently with their own secrets', () => { + const ts = tsOffset(-3_000); + + providers.forEach(provider => { + const providerSecret = `whsec_iso_${provider}_secret_at_least_32_chars_xxxxx`; + createWebhookSecret(provider, providerSecret); + const sig = generateWebhookSignature(PAYLOAD, providerSecret, ts); + + const result = verifyWebhookSignature({ + signature: sig, + timestamp: ts, + body: PAYLOAD, + provider, + }); + expect(result.isValid).toBe(true); + }); + }); + + it('secrets from one provider do not affect another', () => { + const ts = tsOffset(-3_000); + + // Create secrets for stripe and paypal with different values + const stripeSecret = 'whsec_stripe_isolation_test_minimum_32_chars_xxx'; + const paypalSecret = 'whsec_paypal_isolation_test_minimum_32_chars_xxx'; + createWebhookSecret('stripe', stripeSecret); + createWebhookSecret('paypal', paypalSecret); + + // Sign with stripe secret but claim it's paypal — must fail + const stripeSig = generateWebhookSignature(PAYLOAD, stripeSecret, ts); + const result = verifyWebhookSignature({ + signature: stripeSig, + timestamp: ts, + body: PAYLOAD, + provider: 'paypal', + }); + expect(result.isValid).toBe(false); + }); + + it('deactivating all secrets for one provider does not affect others', () => { + const ts = tsOffset(-3_000); + const sharedSecret = 'whsec_shared_value_deactivation_test_32_chars_xx'; + + providers.forEach(p => createWebhookSecret(p, sharedSecret)); + + // Deactivate all stripe secrets + getAllWebhookSecrets() + .filter(s => s.provider === 'stripe') + .forEach(s => deactivateWebhookSecret(s.id)); + + // Stripe should fail + const stripeSig = generateWebhookSignature(PAYLOAD, sharedSecret, ts); + const stripeResult = verifyWebhookSignature({ + signature: stripeSig, + timestamp: ts, + body: PAYLOAD, + provider: 'stripe', + }); + expect(stripeResult.isValid).toBe(false); + + // Other providers should still work + (['paypal', 'github', 'custom'] as WebhookProvider[]).forEach(provider => { + const sig = generateWebhookSignature(PAYLOAD, sharedSecret, ts); + const res = verifyWebhookSignature({ signature: sig, timestamp: ts, body: PAYLOAD, provider }); + expect(res.isValid).toBe(true); + }); + }); + + it('getActiveSecretForProvider returns null when provider has no active secret', () => { + // github has no secret at all + const active = getActiveSecretForProvider('github'); + expect(active).toBeNull(); + }); + + it('queued events for different providers are independent', () => { + queueFailedWebhook('stripe', 'charge.failed', {}, 'sig1', NOW_ISO, 'err'); + queueFailedWebhook('paypal', 'sale.failed', {}, 'sig2', NOW_ISO, 'err'); + + const all = getQueuedWebhooks(100); + const stripeEvents = all.filter(e => e.provider === 'stripe'); + const paypalEvents = all.filter(e => e.provider === 'paypal'); + + expect(stripeEvents.length).toBe(1); + expect(paypalEvents.length).toBe(1); + }); +});