Skip to content

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

Package listing

@kody/twilio

src/twilio.test.ts

157 lines · 5.1 KB · TypeScript
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
	mutationPreview,
	nextStepFor,
	resolvePageHref,
	sanitizeAccount,
} from './helpers.ts'
import { isDryRun } from './validation.ts'
import {
	DEFAULT_ACCOUNT_SID_SECRET,
	DEFAULT_AUTH_TOKEN_SECRET,
	accountSidSetupUrl,
	assertE164,
	assertMessagingServiceSid,
	authTokenSetupUrl,
	boundedPageSize,
	resolveAccountSidSecretName,
	resolveAuthTokenSecretName,
} from './validation.ts'

const here = dirname(fileURLToPath(import.meta.url))
const root = join(here, '..')

test('secret names default and suffix accounts', () => {
	assert.equal(resolveAccountSidSecretName(), DEFAULT_ACCOUNT_SID_SECRET)
	assert.equal(resolveAuthTokenSecretName(), DEFAULT_AUTH_TOKEN_SECRET)
	assert.equal(resolveAccountSidSecretName({ account: 'work' }), 'twilioAccountSid-work')
	assert.equal(resolveAuthTokenSecretName({ account: 'work' }), 'twilioAuthToken-work')
	assert.equal(
		resolveAccountSidSecretName({ accountSidSecret: 'twilioAccountSid-live' }),
		'twilioAccountSid-live',
	)
})

test('secret names reject Kent-style aliases', () => {
	assert.throws(
		() => resolveAccountSidSecretName({ accountSidSecret: 'TWILIO_AUTH_HEADER' }),
		/twilioAccountSid/,
	)
	assert.throws(
		() => resolveAuthTokenSecretName({ authTokenSecret: 'twilioToken' }),
		/twilioAuthToken/,
	)
})

test('setup URLs are prefilled for both secrets', () => {
	assert.match(accountSidSetupUrl(), /name=twilioAccountSid/)
	assert.match(accountSidSetupUrl(), /allowedHosts=api\.twilio\.com/)
	assert.match(accountSidSetupUrl(), /scope=user/)
	assert.match(authTokenSetupUrl(), /name=twilioAuthToken/)
	assert.match(authTokenSetupUrl(), /allowedHosts=api\.twilio\.com/)
	assert.match(authTokenSetupUrl('twilioAuthToken-work'), /name=twilioAuthToken-work/)
})

test('send mutations default to dry-run unless confirm is explicit', () => {
	assert.equal(isDryRun({}), true)
	assert.equal(isDryRun({ dryRun: true }), true)
	assert.equal(isDryRun({ confirm: true, dryRun: true }), true)
	assert.equal(isDryRun({ confirm: true }), false)
	assert.equal(isDryRun({ confirm: false }), true)
})

test('mutationPreview returns a body and never needs the network', () => {
	const preview = mutationPreview({}, '/Messages.json', {
		To: '+15555550100',
		Body: 'hello',
	})
	assert.ok(preview)
	assert.equal(preview?.dryRun, true)
	assert.equal(preview?.body.To, '+15555550100')
	assert.equal(preview?.accountSidSecret, 'twilioAccountSid')
})

test('mutationPreview is null only when confirm is true and dryRun is not', () => {
	assert.equal(
		mutationPreview({ confirm: true }, '/Messages.json', { To: '+15555550100' }),
		null,
	)
})

test('E.164 and Messaging Service SID validation', () => {
	assert.equal(assertE164('+15555550100', 'to'), '+15555550100')
	assert.throws(() => assertE164('555-0100', 'to'), /E\.164/)
	assert.throws(() => assertE164('+0123', 'from'), /E\.164/)
	assert.equal(
		assertMessagingServiceSid('MG' + 'a'.repeat(32)),
		'MG' + 'a'.repeat(32),
	)
	assert.throws(() => assertMessagingServiceSid('AC' + 'a'.repeat(32)), /MG/)
})

test('sanitizeAccount drops auth_token and other credential fields', () => {
	const slim = sanitizeAccount({
		sid: 'AC' + '1'.repeat(32),
		friendly_name: 'Demo',
		status: 'active',
		type: 'Full',
		date_created: 'Wed, 01 Jan 2020 00:00:00 +0000',
		date_updated: 'Wed, 01 Jan 2020 00:00:00 +0000',
		owner_account_sid: 'AC' + '1'.repeat(32),
		auth_token: 'should-never-leak',
		uri: '/2010-04-01/Accounts/ACxxx.json',
	})
	assert.equal(slim.sid?.startsWith('AC'), true)
	assert.equal(slim.friendlyName, 'Demo')
	assert.equal('auth_token' in slim, false)
	assert.equal(JSON.stringify(slim).includes('should-never-leak'), false)
})

test('pageUri must stay on the Twilio Accounts API', () => {
	assert.equal(
		resolvePageHref('/2010-04-01/Accounts/ACabc/Messages.json?Page=1'),
		'https://api.twilio.com/2010-04-01/Accounts/ACabc/Messages.json?Page=1',
	)
	assert.throws(() => resolvePageHref('https://example.com/evil'), /Twilio/)
	assert.throws(() => resolvePageHref('/v1/other'), /Twilio/)
})

test('401 next step points at both prefilled secret URLs', () => {
	const step = nextStepFor(401, 'Authenticate', 'twilioAccountSid', 'twilioAuthToken')
	assert.match(step, /twilioAccountSid/)
	assert.match(step, /twilioAuthToken/)
	assert.match(step, /api\.twilio\.com/)
})

test('pageSize is bounded', () => {
	assert.equal(boundedPageSize(undefined), 20)
	assert.equal(boundedPageSize(5), 5)
	assert.throws(() => boundedPageSize(0), /pageSize/)
	assert.throws(() => boundedPageSize(1001), /pageSize/)
})

test('package source has no baked-in personal phone numbers', () => {
	const files = [
		'README.md',
		'src/send-message.ts',
		'src/smoke-test.ts',
		'src/validation.ts',
		'src/index.ts',
		'src/guide.ts',
	]
	for (const file of files) {
		const text = readFileSync(join(root, file), 'utf8')
		const leftovers = text
			.replaceAll('+15555550100', '')
			.replaceAll('+15555550101', '')
		assert.doesNotMatch(
			leftovers,
			/\+\d{8,15}\b/,
			file + ' must not include personal phone numbers',
		)
	}
})