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',
)
}
})