Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/stripe

src/stripe-core.test.ts

150 lines · 4.8 KB · TypeScript
import assert from 'node:assert/strict'
import test from 'node:test'
import {
	API_KEY_SETUP_URL,
	DEFAULT_API_KEY_SECRET,
	mutationPreview,
	parseAction,
	requireConfirm,
	resolveApiKeySecretName,
	resolveWebhookSecretName,
	secretSetupUrl,
	stripeParams,
} from './stripe-core.ts'
import { createRefund } from './payments.ts'
import { deleteCustomer } from './customers.ts'
import {
	createTestSignatureHeader,
	hmacSha256Hex,
	parseStripeSignatureHeader,
	verifyWebhookSignature,
} from './webhooks.ts'

test('resolveApiKeySecretName defaults and suffixes accounts', () => {
	assert.equal(resolveApiKeySecretName(), DEFAULT_API_KEY_SECRET)
	assert.equal(resolveApiKeySecretName({ account: 'default' }), DEFAULT_API_KEY_SECRET)
	assert.equal(resolveApiKeySecretName({ account: 'work' }), 'stripeApiKey-work')
	assert.equal(resolveApiKeySecretName({ secretName: 'stripeApiKey-live' }), 'stripeApiKey-live')
})

test('resolveApiKeySecretName rejects Kent-style aliases and invalid names', () => {
	assert.throws(() => resolveApiKeySecretName({ secretName: 'stripeSecretKey' }), /stripeApiKey/)
	assert.throws(() => resolveApiKeySecretName({ account: 'youtube-brand!' }), /short label/)
})

test('webhook secret name follows the same account suffix', () => {
	assert.equal(resolveWebhookSecretName(), 'stripeWebhookSecret')
	assert.equal(resolveWebhookSecretName({ account: 'work' }), 'stripeWebhookSecret-work')
})

test('secretSetupUrl keeps the default prefilled link', () => {
	assert.equal(secretSetupUrl(), API_KEY_SETUP_URL)
	assert.match(secretSetupUrl('stripeApiKey-work'), /name=stripeApiKey-work/)
	assert.match(API_KEY_SETUP_URL, /allowedHosts=api\.stripe\.com,files\.stripe\.com/)
})

test('stripeParams serializes nested Stripe form fields', () => {
	const params = stripeParams({
		email: 'ada@example.com',
		metadata: { plan: 'pro' },
		items: [{ price: 'price_123', quantity: 2 }],
	})
	assert.equal(params.get('email'), 'ada@example.com')
	assert.equal(params.get('metadata[plan]'), 'pro')
	assert.equal(params.get('items[][price]'), 'price_123')
	assert.equal(params.get('items[][quantity]'), '2')
})

test('requireConfirm blocks live mutations', () => {
	assert.throws(() => requireConfirm({}, 'refund ch_123'), /confirm: true/)
	requireConfirm({ confirm: true }, 'refund ch_123')
})

test('mutationPreview dryRun skips confirm', () => {
	const preview = mutationPreview(
		{ dryRun: true },
		{
			action: 'refund ch_123',
			method: 'POST',
			path: 'refunds',
			body: { charge: 'ch_123' },
			requireConfirm: true,
		},
	)
	assert.deepEqual(preview, {
		dryRun: true,
		action: 'refund ch_123',
		method: 'POST',
		path: 'refunds',
		body: { charge: 'ch_123' },
	})
})

test('createRefund dryRun does not require confirm or a network call', async () => {
	const preview = await createRefund({
		chargeId: 'ch_123',
		amount: 500,
		dryRun: true,
	})
	assert.equal(preview.dryRun, true)
	assert.equal(preview.path, 'refunds')
	assert.equal(preview.body?.charge, 'ch_123')
})

test('deleteCustomer without confirm or dryRun throws', async () => {
	await assert.rejects(deleteCustomer({ customerId: 'cus_123' }), /confirm: true/)
})

test('parseAction is exhaustive for known unions', () => {
	const allowed = ['list-customers', 'get-customer'] as const
	assert.equal(parseAction(undefined, allowed, 'list-customers', 'customers'), 'list-customers')
	assert.throws(() => parseAction('explode', allowed, 'list-customers', 'customers'), /explode/)
})

test('webhook HMAC verify accepts a matching Stripe-Signature header', async () => {
	const payload = '{"id":"evt_test","object":"event","type":"ping"}'
	const webhookSecret = 'whsec_test'
	const timestamp = 1_700_000_000
	const header = await createTestSignatureHeader({ payload, webhookSecret, timestamp })
	const parsed = parseStripeSignatureHeader(header)
	assert.equal(parsed.timestamp, timestamp)
	const expected = await hmacSha256Hex(webhookSecret, timestamp + '.' + payload)
	assert.equal(parsed.signatures[0], expected)

	const result = await verifyWebhookSignature({
		payload,
		signatureHeader: header,
		webhookSecret,
		nowSeconds: timestamp,
	})
	assert.equal(result.ok, true)
	assert.equal(result.eventId, 'evt_test')
	assert.equal(result.verifiedVia, 'signature')
})

test('webhook HMAC verify rejects a bad signature and stale timestamp', async () => {
	const payload = '{"id":"evt_test"}'
	const header = await createTestSignatureHeader({
		payload,
		webhookSecret: 'whsec_test',
		timestamp: 1_700_000_000,
	})
	await assert.rejects(
		verifyWebhookSignature({
			payload,
			signatureHeader: header,
			webhookSecret: 'whsec_other',
			nowSeconds: 1_700_000_000,
		}),
		/did not match/,
	)
	await assert.rejects(
		verifyWebhookSignature({
			payload,
			signatureHeader: header,
			webhookSecret: 'whsec_test',
			nowSeconds: 1_700_000_000 + 301,
		}),
		/tolerance/,
	)
})