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