Skip to content

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

Package listing

@kody/aws

src/aws.test.ts

171 lines · 5.4 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 } from './helpers.ts'
import { looksLikeCredentialText, redactSecrets } from './redact.ts'
import { assertAmazonAwsHost, serviceHost, signAwsRequest } from './sign.ts'
import {
	DEFAULT_ACCESS_KEY_SECRET,
	DEFAULT_SECRET_KEY_SECRET,
	accessKeySetupUrl,
	assertBucket,
	assertRegion,
	boundedPageSize,
	isDryRun,
	resolveAccessKeySecretName,
	resolveSecretKeySecretName,
	secretKeySetupUrl,
} from './validation.ts'

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

test('secret names default and suffix accounts', () => {
	assert.equal(resolveAccessKeySecretName(), DEFAULT_ACCESS_KEY_SECRET)
	assert.equal(resolveSecretKeySecretName(), DEFAULT_SECRET_KEY_SECRET)
	assert.equal(
		resolveAccessKeySecretName({ account: 'work' }),
		'awsAccessKeyId-work',
	)
	assert.equal(
		resolveSecretKeySecretName({ account: 'work' }),
		'awsSecretAccessKey-work',
	)
})

test('secret names reject Kent-style aliases', () => {
	assert.throws(
		() => resolveAccessKeySecretName({ accessKeySecret: 'KENT_AWS_KEY' }),
		/awsAccessKeyId/,
	)
	assert.throws(
		() =>
			resolveSecretKeySecretName({ secretKeySecret: 'awsSecret' }),
		/awsSecretAccessKey/,
	)
})

test('setup URLs are prefilled for both required secrets', () => {
	assert.match(accessKeySetupUrl(), /name=awsAccessKeyId/)
	assert.match(accessKeySetupUrl(), /allowedHosts=/)
	assert.match(accessKeySetupUrl(), /sts\.amazonaws\.com/)
	assert.match(accessKeySetupUrl(), /scope=user/)
	assert.match(secretKeySetupUrl(), /name=awsSecretAccessKey/)
	assert.match(secretKeySetupUrl(), /s3\.amazonaws\.com/)
})

test('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 never needs the network', () => {
	const preview = mutationPreview({}, {
		method: 'PUT',
		service: 's3',
		region: 'us-east-1',
		host: 's3.us-east-1.amazonaws.com',
		path: '/example-bucket/readme.txt',
		body: { bodyLength: 12 },
	})
	assert.ok(preview)
	assert.equal(preview?.dryRun, true)
	assert.equal(preview?.accessKeySecret, 'awsAccessKeyId')
	assert.equal(preview?.body?.bodyLength, 12)
})

test('region and bucket validation stay generic', () => {
	assert.equal(assertRegion('eu-west-1'), 'eu-west-1')
	assert.throws(() => assertRegion('US-EAST-1'), /region/)
	assert.equal(assertBucket('example-bucket'), 'example-bucket')
	assert.throws(() => assertBucket('MyBucket'), /bucket/)
	assert.throws(() => assertBucket('a'), /bucket/)
})

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('service hosts stay on amazonaws.com', () => {
	assert.equal(serviceHost('sts', 'us-east-1'), 'sts.amazonaws.com')
	assert.equal(serviceHost('logs', 'eu-west-1'), 'logs.eu-west-1.amazonaws.com')
	assert.doesNotThrow(() => assertAmazonAwsHost('s3.us-west-2.amazonaws.com'))
	assert.throws(() => assertAmazonAwsHost('example.com'), /amazonaws/)
})

test('SigV4 signs without echoing the secret key', async () => {
	const signed = await signAwsRequest({
		method: 'GET',
		url: 'https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15',
		region: 'us-east-1',
		service: 'sts',
		accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
		secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
		now: new Date('2015-08-30T12:36:00.000Z'),
	})
	assert.match(signed.headers.authorization, /^AWS4-HMAC-SHA256 Credential=/)
	assert.match(signed.headers.authorization, /AKIAIOSFODNN7EXAMPLE/)
	assert.equal(
		JSON.stringify(signed).includes('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'),
		false,
	)
})

test('redaction strips access keys and private key material', () => {
	const dirty =
		'user AKIAIOSFODNN7EXAMPLE aws_secret_access_key=abcdEFGHijklMNOPqrstUVWX0123456789+abc {{secret:awsSecretAccessKey}}'
	assert.equal(looksLikeCredentialText(dirty), true)
	const clean = redactSecrets(dirty)
	assert.doesNotMatch(clean, /AKIAIOSFODNN7EXAMPLE/)
	assert.doesNotMatch(clean, /abcdEFGHijkl/)
	assert.match(clean, /\[redacted-access-key\]/)
	assert.match(clean, /\{\{secret:<name>\}\}/)
})

test('401 next step points at prefilled secret URLs', () => {
	const step = nextStepFor(
		403,
		'SignatureDoesNotMatch',
		'awsAccessKeyId',
		'awsSecretAccessKey',
		'sts.amazonaws.com',
	)
	assert.match(step, /awsAccessKeyId/)
	assert.match(step, /awsSecretAccessKey/)
	assert.match(step, /sts\.amazonaws\.com/)
})

test('package source has no baked-in account ids or personal buckets', () => {
	const files = [
		'README.md',
		'package.json',
		'src/index.ts',
		'src/guide.ts',
		'src/smoke-test.ts',
		'src/validation.ts',
		'src/s3.ts',
		'src/identity.ts',
		'src/config.ts',
	]
	for (const file of files) {
		const text = readFileSync(join(root, file), 'utf8')
		assert.doesNotMatch(
			text,
			/\b\d{12}\b/,
			file + ' must not include AWS account ids',
		)
		assert.doesNotMatch(
			text,
			/kentcdodds|epicweb|kent-c-dodds/i,
			file + ' must stay account-agnostic',
		)
	}
})