82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
import {
|
|
constantTimeEqual,
|
|
normalizeStripeTier,
|
|
tierFromStripeSubscriptionPrice,
|
|
verifyStripeSignature,
|
|
} from './index.ts'
|
|
|
|
function assert(condition: boolean, message: string): asserts condition {
|
|
if (!condition) throw new Error(message)
|
|
}
|
|
|
|
async function stripeHeader(payload: string, secret: string, timestamp: number): Promise<string> {
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
new TextEncoder().encode(secret),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign'],
|
|
)
|
|
const signature = await crypto.subtle.sign(
|
|
'HMAC',
|
|
key,
|
|
new TextEncoder().encode(`${timestamp}.${payload}`),
|
|
)
|
|
const hex = Array.from(new Uint8Array(signature))
|
|
.map((part) => part.toString(16).padStart(2, '0'))
|
|
.join('')
|
|
return `t=${timestamp},v1=${hex}`
|
|
}
|
|
|
|
Deno.test('Stripe signature verifies exact payload and rejects tampering', async () => {
|
|
const now = 1_800_000_000
|
|
const payload = JSON.stringify({ id: 'evt_test', type: 'customer.subscription.updated' })
|
|
const header = await stripeHeader(payload, 'whsec_test', now)
|
|
assert(await verifyStripeSignature(payload, header, 'whsec_test', 300, now * 1000), 'valid signature')
|
|
assert(
|
|
!(await verifyStripeSignature(`${payload} `, header, 'whsec_test', 300, now * 1000)),
|
|
'payload mutation must fail',
|
|
)
|
|
assert(
|
|
!(await verifyStripeSignature(payload, header, 'different', 300, now * 1000)),
|
|
'wrong secret must fail',
|
|
)
|
|
})
|
|
|
|
Deno.test('Stripe signature rejects stale and ambiguous timestamp headers', async () => {
|
|
const timestamp = 1_800_000_000
|
|
const payload = '{}'
|
|
const header = await stripeHeader(payload, 'whsec_test', timestamp)
|
|
assert(
|
|
!(await verifyStripeSignature(payload, header, 'whsec_test', 300, (timestamp + 301) * 1000)),
|
|
'stale signature must fail',
|
|
)
|
|
assert(
|
|
!(await verifyStripeSignature(payload, `${header},t=${timestamp}`, 'whsec_test', 300, timestamp * 1000)),
|
|
'multiple timestamp fields must fail',
|
|
)
|
|
})
|
|
|
|
Deno.test('Stripe tier is derived from the exact configured subscription price', () => {
|
|
const prices = { pro: 'price_pro', pro_plus: 'price_pro_plus' }
|
|
assert(tierFromStripeSubscriptionPrice({
|
|
items: { data: [{ price: { id: 'price_pro' } }] },
|
|
}, prices) === 'pro', 'pro price must map to pro')
|
|
assert(tierFromStripeSubscriptionPrice({
|
|
items: { data: [{ price: { id: 'price_pro_plus' } }] },
|
|
}, prices) === 'pro_plus', 'pro plus price must map to pro_plus')
|
|
assert(tierFromStripeSubscriptionPrice({
|
|
items: { data: [{ price: { id: 'price_attacker' } }] },
|
|
}, prices) === null, 'unknown price must fail closed')
|
|
assert(tierFromStripeSubscriptionPrice({
|
|
items: { data: [{ price: { id: 'price_pro' } }, { price: { id: 'price_pro_plus' } }] },
|
|
}, prices) === null, 'ambiguous multi-price subscription must fail closed')
|
|
assert(normalizeStripeTier('team') === 'pro_plus', 'legacy team alias maps only to pro_plus')
|
|
assert(normalizeStripeTier('enterprise') === null, 'unknown metadata tier must fail')
|
|
})
|
|
|
|
Deno.test('constant-time comparator rejects length and value mismatch', () => {
|
|
assert(constantTimeEqual('abc', 'abc'), 'equal strings')
|
|
assert(!constantTimeEqual('abc', 'abd'), 'different strings')
|
|
assert(!constantTimeEqual('abc', 'ab'), 'different lengths')
|
|
})
|