51a0a60a73
CI / checks (pull_request) Successful in 25m28s
CI failed on a different set of tests each run because bun runs every test file in one process and the file order differs between machines: - the secretsmanager route test replaced the whole ../aws module, so files loaded after it lost awsClients, lambda and defaultAccountId (API Gateway, SES) - the Lightsail tests restored FLOCI_ENDPOINT by assigning undefined, which stores the string "undefined" (runtime diagnostics) - the auth tests used full-strength argon2 for ~30 sign-ins and timed out on slower runners; the OIDC cleanup test cached a discovery document without userinfo_endpoint for the shared issuer Checked with 20 runs under bun test --randomize.
236 lines
8.6 KiB
TypeScript
236 lines
8.6 KiB
TypeScript
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
|
|
// The secretsmanager router imports the `secretsManager` singleton directly from
|
|
// `../aws`, so we replace that export with a stub whose `send()` is driven per
|
|
// test. Bun keeps a module mock for the rest of the run, so every other export
|
|
// stays real: test files that load `../aws` after this one still get it whole. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
|
// the command that was dispatched so we can assert on its input shape.
|
|
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
|
|
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
|
|
|
|
const realAws = await import('../aws')
|
|
mock.module('../aws', () => ({
|
|
...realAws,
|
|
secretsManager: {
|
|
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
|
lastCommand = command
|
|
return responder(command.constructor.name, command)
|
|
},
|
|
},
|
|
}))
|
|
|
|
// Imported after the mock is registered so the route binds to the stubbed client.
|
|
const {default: app} = await import('./secretsmanager')
|
|
|
|
beforeEach(() => {
|
|
lastCommand = null
|
|
responder = () => ({})
|
|
})
|
|
|
|
describe('GET /secrets', () => {
|
|
test('follows NextToken pagination and maps each entry', async () => {
|
|
const pages = [
|
|
{
|
|
SecretList: [{Name: 'a', ARN: 'arn:a', Tags: [{Key: 'env', Value: 'prod'}]}],
|
|
NextToken: 'page2',
|
|
},
|
|
{SecretList: [{Name: 'b', ARN: 'arn:b'}], NextToken: undefined},
|
|
]
|
|
let call = 0
|
|
responder = () => pages[call++]
|
|
|
|
const res = await app.request('/secrets')
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json()
|
|
|
|
// Both pages are merged, proving the do/while followed NextToken.
|
|
expect(call).toBe(2)
|
|
expect(body).toHaveLength(2)
|
|
expect(body[0]).toMatchObject({
|
|
name: 'a',
|
|
arn: 'arn:a',
|
|
rotationEnabled: false,
|
|
tags: [{key: 'env', value: 'prod'}],
|
|
})
|
|
expect(body[1]).toMatchObject({name: 'b', arn: 'arn:b', tags: []})
|
|
})
|
|
})
|
|
|
|
describe('GET /secret', () => {
|
|
test('derives versionIds from VersionIdsToStages and maps tags', async () => {
|
|
responder = () => ({
|
|
Name: 'svc',
|
|
ARN: 'arn:svc',
|
|
Description: 'desc',
|
|
VersionIdsToStages: {v1: ['AWSCURRENT'], v2: ['AWSPREVIOUS']},
|
|
Tags: [{Key: 'team', Value: 'core'}],
|
|
})
|
|
|
|
const res = await app.request('/secret?id=svc')
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json()
|
|
|
|
expect(body.versionIds).toEqual(['v1', 'v2'])
|
|
expect(body.tags).toEqual([{key: 'team', value: 'core'}])
|
|
expect(lastCommand?.input.SecretId).toBe('svc')
|
|
})
|
|
|
|
test('returns 400 when id is missing and never calls AWS', async () => {
|
|
const res = await app.request('/secret')
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('GET /secret/value', () => {
|
|
test('returns SecretString verbatim', async () => {
|
|
responder = () => ({Name: 's', VersionId: 'v1', SecretString: '{"k":"v"}'})
|
|
|
|
const res = await app.request('/secret/value?id=s')
|
|
const body = await res.json()
|
|
|
|
expect(body.secretString).toBe('{"k":"v"}')
|
|
expect(body.secretBinary).toBeUndefined()
|
|
expect(body.versionId).toBe('v1')
|
|
})
|
|
|
|
test('encodes SecretBinary as base64', async () => {
|
|
const bytes = new Uint8Array([1, 2, 3, 4])
|
|
responder = () => ({Name: 's', SecretBinary: bytes})
|
|
|
|
const res = await app.request('/secret/value?id=s')
|
|
const body = await res.json()
|
|
|
|
expect(body.secretString).toBeUndefined()
|
|
expect(body.secretBinary).toBe(Buffer.from(bytes).toString('base64'))
|
|
})
|
|
|
|
test('marks the plaintext response as no-store', async () => {
|
|
responder = () => ({Name: 's', SecretString: 'plain'})
|
|
|
|
const res = await app.request('/secret/value?id=s')
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('cache-control')).toBe('no-store')
|
|
})
|
|
|
|
test('returns 400 when id is missing and never calls AWS', async () => {
|
|
const res = await app.request('/secret/value')
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('POST /secrets', () => {
|
|
test('passes the create input through and echoes the result', async () => {
|
|
responder = (_name, command) => ({Name: command.input.Name, ARN: 'arn:new', VersionId: 'v1'})
|
|
|
|
const res = await app.request('/secrets', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({name: 'newSecret', secretString: 'val', description: 'd'}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(lastCommand?.input).toMatchObject({Name: 'newSecret', SecretString: 'val', Description: 'd'})
|
|
expect(body).toMatchObject({name: 'newSecret', arn: 'arn:new', versionId: 'v1'})
|
|
})
|
|
|
|
test('returns 400 when name is missing and never calls AWS', async () => {
|
|
const res = await app.request('/secrets', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({secretString: 'val'}),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
|
|
test('returns 400 when secretString is missing and never calls AWS', async () => {
|
|
const res = await app.request('/secrets', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({name: 'newSecret'}),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
|
|
test('returns 400 on an invalid JSON body and never calls AWS', async () => {
|
|
const res = await app.request('/secrets', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: 'not json',
|
|
})
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('PUT /secret/value', () => {
|
|
test('writes a new SecretString version', async () => {
|
|
responder = () => ({ARN: 'arn:x', VersionId: 'v2'})
|
|
|
|
const res = await app.request('/secret/value', {
|
|
method: 'PUT',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({id: 'x', secretString: 'updated'}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(lastCommand?.input).toMatchObject({SecretId: 'x', SecretString: 'updated'})
|
|
expect(body).toMatchObject({arn: 'arn:x', versionId: 'v2'})
|
|
})
|
|
|
|
test('returns 400 when id is missing and never calls AWS', async () => {
|
|
const res = await app.request('/secret/value', {
|
|
method: 'PUT',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({secretString: 'updated'}),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('DELETE /secret', () => {
|
|
test('uses the 7-day recovery window by default', async () => {
|
|
await app.request('/secret?id=x', {method: 'DELETE'})
|
|
|
|
expect(lastCommand?.input.RecoveryWindowInDays).toBe(7)
|
|
expect(lastCommand?.input.ForceDeleteWithoutRecovery).toBeUndefined()
|
|
})
|
|
|
|
test('force deletes without recovery when force=true', async () => {
|
|
await app.request('/secret?id=x&force=true', {method: 'DELETE'})
|
|
|
|
expect(lastCommand?.input.ForceDeleteWithoutRecovery).toBe(true)
|
|
expect(lastCommand?.input.RecoveryWindowInDays).toBeUndefined()
|
|
})
|
|
|
|
test('returns 400 when id is missing and never calls AWS', async () => {
|
|
const res = await app.request('/secret', {method: 'DELETE'})
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
|
|
test('returns 400 when secretString is missing and never calls AWS', async () => {
|
|
const res = await app.request('/secret/value', {
|
|
method: 'PUT',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({id: 'x'}),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
|
|
test('returns 400 on an invalid JSON body and never calls AWS', async () => {
|
|
const res = await app.request('/secret/value', {
|
|
method: 'PUT',
|
|
headers: {'content-type': 'application/json'},
|
|
body: 'not json',
|
|
})
|
|
expect(res.status).toBe(400)
|
|
expect(lastCommand).toBeNull()
|
|
})
|
|
})
|