second first commit
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* ACM integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
ACMClient,
|
||||
RequestCertificateCommand,
|
||||
DescribeCertificateCommand,
|
||||
GetCertificateCommand,
|
||||
ListCertificatesCommand,
|
||||
DeleteCertificateCommand,
|
||||
ImportCertificateCommand,
|
||||
ExportCertificateCommand,
|
||||
AddTagsToCertificateCommand,
|
||||
ListTagsForCertificateCommand,
|
||||
RemoveTagsFromCertificateCommand,
|
||||
PutAccountConfigurationCommand,
|
||||
GetAccountConfigurationCommand,
|
||||
} from '@aws-sdk/client-acm';
|
||||
import selfsigned from 'selfsigned';
|
||||
import { makeClient, uniqueName, ACCOUNT, REGION } from './setup';
|
||||
|
||||
async function generateSelfSignedCert(): Promise<{ cert: Buffer; key: Buffer }> {
|
||||
const attrs = [{ name: 'commonName', value: 'test.example.com' }];
|
||||
const pems = await selfsigned.generate(attrs);
|
||||
return { cert: Buffer.from(pems.cert), key: Buffer.from(pems.private) };
|
||||
}
|
||||
|
||||
describe('ACM Certificate Lifecycle', () => {
|
||||
let acm: ACMClient;
|
||||
let certificateArn: string;
|
||||
|
||||
beforeAll(() => {
|
||||
acm = makeClient(ACMClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (certificateArn) {
|
||||
await acm.send(new DeleteCertificateCommand({ CertificateArn: certificateArn }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should request a certificate', async () => {
|
||||
const domain = `${uniqueName('cert')}.example.com`;
|
||||
const response = await acm.send(
|
||||
new RequestCertificateCommand({ DomainName: domain })
|
||||
);
|
||||
certificateArn = response.CertificateArn!;
|
||||
expect(certificateArn).toMatch(
|
||||
new RegExp(`^arn:aws:acm:${REGION}:${ACCOUNT}:certificate/.+`)
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe a certificate', async () => {
|
||||
const response = await acm.send(
|
||||
new DescribeCertificateCommand({ CertificateArn: certificateArn })
|
||||
);
|
||||
expect(response.Certificate?.DomainName).toBeTruthy();
|
||||
expect(response.Certificate?.Status).toBe('ISSUED');
|
||||
});
|
||||
|
||||
it('should get certificate', async () => {
|
||||
const response = await acm.send(
|
||||
new GetCertificateCommand({ CertificateArn: certificateArn })
|
||||
);
|
||||
expect(response.Certificate).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should list certificates', async () => {
|
||||
const response = await acm.send(new ListCertificatesCommand({}));
|
||||
expect(
|
||||
response.CertificateSummaryList?.some((c) => c.CertificateArn === certificateArn)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete a certificate', async () => {
|
||||
await acm.send(new DeleteCertificateCommand({ CertificateArn: certificateArn }));
|
||||
|
||||
await expect(
|
||||
acm.send(new DescribeCertificateCommand({ CertificateArn: certificateArn }))
|
||||
).rejects.toThrow();
|
||||
|
||||
// Already deleted; prevent afterAll from trying again
|
||||
certificateArn = '';
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACM Import/Export', () => {
|
||||
let acm: ACMClient;
|
||||
const arnsToClean: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
acm = makeClient(ACMClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const arn of arnsToClean) {
|
||||
try {
|
||||
await acm.send(new DeleteCertificateCommand({ CertificateArn: arn }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should import a certificate', async () => {
|
||||
const { cert, key } = await generateSelfSignedCert();
|
||||
const response = await acm.send(
|
||||
new ImportCertificateCommand({
|
||||
Certificate: cert,
|
||||
PrivateKey: key,
|
||||
})
|
||||
);
|
||||
const arn = response.CertificateArn!;
|
||||
arnsToClean.push(arn);
|
||||
expect(arn).toMatch(
|
||||
new RegExp(`^arn:aws:acm:${REGION}:${ACCOUNT}:certificate/.+`)
|
||||
);
|
||||
});
|
||||
|
||||
it('should get imported certificate', async () => {
|
||||
const { cert, key } = await generateSelfSignedCert();
|
||||
const importResp = await acm.send(
|
||||
new ImportCertificateCommand({
|
||||
Certificate: cert,
|
||||
PrivateKey: key,
|
||||
})
|
||||
);
|
||||
const arn = importResp.CertificateArn!;
|
||||
arnsToClean.push(arn);
|
||||
|
||||
const getResp = await acm.send(
|
||||
new GetCertificateCommand({ CertificateArn: arn })
|
||||
);
|
||||
expect(getResp.Certificate).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should export imported certificate', async () => {
|
||||
const { cert, key } = await generateSelfSignedCert();
|
||||
const importResp = await acm.send(
|
||||
new ImportCertificateCommand({
|
||||
Certificate: cert,
|
||||
PrivateKey: key,
|
||||
})
|
||||
);
|
||||
const arn = importResp.CertificateArn!;
|
||||
arnsToClean.push(arn);
|
||||
|
||||
const passphrase = Buffer.from('test-passphrase');
|
||||
const exportResp = await acm.send(
|
||||
new ExportCertificateCommand({
|
||||
CertificateArn: arn,
|
||||
Passphrase: passphrase,
|
||||
})
|
||||
);
|
||||
expect(exportResp.Certificate).toBeTruthy();
|
||||
expect(exportResp.PrivateKey).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should fail to export requested certificate', async () => {
|
||||
const domain = `${uniqueName('exp')}.example.com`;
|
||||
const reqResp = await acm.send(
|
||||
new RequestCertificateCommand({ DomainName: domain })
|
||||
);
|
||||
const arn = reqResp.CertificateArn!;
|
||||
arnsToClean.push(arn);
|
||||
|
||||
await expect(
|
||||
acm.send(
|
||||
new ExportCertificateCommand({
|
||||
CertificateArn: arn,
|
||||
Passphrase: Buffer.from('test-passphrase'),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACM Tagging', () => {
|
||||
let acm: ACMClient;
|
||||
let certificateArn: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
acm = makeClient(ACMClient);
|
||||
const domain = `${uniqueName('tag')}.example.com`;
|
||||
const response = await acm.send(
|
||||
new RequestCertificateCommand({ DomainName: domain })
|
||||
);
|
||||
certificateArn = response.CertificateArn!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (certificateArn) {
|
||||
await acm.send(new DeleteCertificateCommand({ CertificateArn: certificateArn }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should add and list tags', async () => {
|
||||
await acm.send(
|
||||
new AddTagsToCertificateCommand({
|
||||
CertificateArn: certificateArn,
|
||||
Tags: [
|
||||
{ Key: 'Environment', Value: 'test' },
|
||||
{ Key: 'Project', Value: 'floci' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const response = await acm.send(
|
||||
new ListTagsForCertificateCommand({ CertificateArn: certificateArn })
|
||||
);
|
||||
const tags = response.Tags ?? [];
|
||||
expect(tags.some((t) => t.Key === 'Environment' && t.Value === 'test')).toBe(true);
|
||||
expect(tags.some((t) => t.Key === 'Project' && t.Value === 'floci')).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove tags', async () => {
|
||||
// Ensure tags exist
|
||||
await acm.send(
|
||||
new AddTagsToCertificateCommand({
|
||||
CertificateArn: certificateArn,
|
||||
Tags: [{ Key: 'ToRemove', Value: 'yes' }],
|
||||
})
|
||||
);
|
||||
|
||||
await acm.send(
|
||||
new RemoveTagsFromCertificateCommand({
|
||||
CertificateArn: certificateArn,
|
||||
Tags: [{ Key: 'ToRemove', Value: 'yes' }],
|
||||
})
|
||||
);
|
||||
|
||||
const response = await acm.send(
|
||||
new ListTagsForCertificateCommand({ CertificateArn: certificateArn })
|
||||
);
|
||||
const tags = response.Tags ?? [];
|
||||
expect(tags.some((t) => t.Key === 'ToRemove')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACM Account Configuration', () => {
|
||||
let acm: ACMClient;
|
||||
|
||||
beforeAll(() => {
|
||||
acm = makeClient(ACMClient);
|
||||
});
|
||||
|
||||
it('should put and get account configuration', async () => {
|
||||
await acm.send(
|
||||
new PutAccountConfigurationCommand({
|
||||
ExpiryEvents: { DaysBeforeExpiry: 45 },
|
||||
IdempotencyToken: uniqueName('idem'),
|
||||
})
|
||||
);
|
||||
|
||||
const response = await acm.send(new GetAccountConfigurationCommand({}));
|
||||
expect(response.ExpiryEvents?.DaysBeforeExpiry).toBe(45);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACM Error Handling', () => {
|
||||
let acm: ACMClient;
|
||||
const arnsToClean: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
acm = makeClient(ACMClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const arn of arnsToClean) {
|
||||
try {
|
||||
await acm.send(new DeleteCertificateCommand({ CertificateArn: arn }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw for non-existent certificate', async () => {
|
||||
const fakeArn = `arn:aws:acm:${REGION}:${ACCOUNT}:certificate/00000000-0000-0000-0000-000000000000`;
|
||||
await expect(
|
||||
acm.send(new DescribeCertificateCommand({ CertificateArn: fakeArn }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle request with SANs', async () => {
|
||||
const domain = `${uniqueName('san')}.example.com`;
|
||||
const san = `alt.${domain}`;
|
||||
const response = await acm.send(
|
||||
new RequestCertificateCommand({
|
||||
DomainName: domain,
|
||||
SubjectAlternativeNames: [domain, san],
|
||||
})
|
||||
);
|
||||
const arn = response.CertificateArn!;
|
||||
arnsToClean.push(arn);
|
||||
expect(arn).toBeTruthy();
|
||||
|
||||
const descResp = await acm.send(
|
||||
new DescribeCertificateCommand({ CertificateArn: arn })
|
||||
);
|
||||
const sans = descResp.Certificate?.SubjectAlternativeNames ?? [];
|
||||
expect(sans).toContain(san);
|
||||
});
|
||||
|
||||
it('should fail to import invalid PEM', async () => {
|
||||
await expect(
|
||||
acm.send(
|
||||
new ImportCertificateCommand({
|
||||
Certificate: Buffer.from('not-a-valid-pem'),
|
||||
PrivateKey: Buffer.from('also-not-valid'),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,951 @@
|
||||
/**
|
||||
* API Gateway v1 (REST API) compatibility tests.
|
||||
*
|
||||
* Covers management-plane CRUD (RestApis, Resources, Methods, Integrations,
|
||||
* Authorizers, Deployments, Stages) and execute-api data-plane behaviour,
|
||||
* including the REQUEST authorizer full-event shape.
|
||||
*
|
||||
* Uses @aws-sdk/client-api-gateway for management-plane calls and the native
|
||||
* fetch API for execute-api invocations (which are plain HTTP, not SDK calls).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
APIGatewayClient,
|
||||
CreateRestApiCommand,
|
||||
GetRestApiCommand,
|
||||
GetRestApisCommand,
|
||||
DeleteRestApiCommand,
|
||||
GetResourcesCommand,
|
||||
CreateAuthorizerCommand,
|
||||
GetAuthorizerCommand,
|
||||
GetAuthorizersCommand,
|
||||
AuthorizerType,
|
||||
} from '@aws-sdk/client-api-gateway';
|
||||
import {
|
||||
LambdaClient,
|
||||
CreateFunctionCommand,
|
||||
DeleteFunctionCommand,
|
||||
} from '@aws-sdk/client-lambda';
|
||||
import { makeClient, uniqueName, ENDPOINT, ACCOUNT, REGION, buildMinimalZip } from './setup';
|
||||
|
||||
// ──────────────────────────── helpers ────────────────────────────
|
||||
|
||||
/** POST a raw JSON body to the Floci management plane (no SDK needed for simple calls). */
|
||||
async function apigwFetch(path: string, method = 'GET', body?: unknown): Promise<Response> {
|
||||
return fetch(`${ENDPOINT}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoke the execute-api data plane and return the raw Response. */
|
||||
async function executeApi(
|
||||
apiId: string,
|
||||
stage: string,
|
||||
resourcePath: string,
|
||||
opts: { method?: string; headers?: Record<string, string>; query?: string } = {}
|
||||
): Promise<Response> {
|
||||
const { method = 'GET', headers = {}, query = '' } = opts;
|
||||
const qs = query ? `?${query}` : '';
|
||||
return fetch(`${ENDPOINT}/execute-api/${apiId}/${stage}${resourcePath}${qs}`, {
|
||||
method,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a Node.js Lambda function with the given inline handler source. */
|
||||
async function createLambda(lambda: LambdaClient, name: string, handlerSrc: string): Promise<void> {
|
||||
const zip = buildMinimalZip('index.js', Buffer.from(handlerSrc));
|
||||
await lambda.send(new CreateFunctionCommand({
|
||||
FunctionName: name,
|
||||
Runtime: 'nodejs20.x',
|
||||
Role: `arn:aws:iam::${ACCOUNT}:role/lambda-role`,
|
||||
Handler: 'index.handler',
|
||||
Code: { ZipFile: zip },
|
||||
Timeout: 30,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Delete a Lambda function, ignoring 404. */
|
||||
async function deleteLambda(lambda: LambdaClient, name: string): Promise<void> {
|
||||
try { await lambda.send(new DeleteFunctionCommand({ FunctionName: name })); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ──────────────────────────── Management-plane CRUD ────────────────────────────
|
||||
|
||||
describe('API Gateway v1 — management plane', () => {
|
||||
let gw: APIGatewayClient;
|
||||
|
||||
beforeAll(() => {
|
||||
gw = makeClient(APIGatewayClient);
|
||||
});
|
||||
|
||||
describe('RestApi lifecycle', () => {
|
||||
let apiId: string;
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (apiId) await gw.send(new DeleteRestApiCommand({ restApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create a REST API', async () => {
|
||||
const res = await gw.send(new CreateRestApiCommand({ name: uniqueName('rest-api') }));
|
||||
apiId = res.id!;
|
||||
expect(apiId).toBeTruthy();
|
||||
expect(res.name).toContain('rest-api');
|
||||
});
|
||||
|
||||
it('should get the REST API', async () => {
|
||||
const res = await gw.send(new GetRestApiCommand({ restApiId: apiId }));
|
||||
expect(res.id).toBe(apiId);
|
||||
});
|
||||
|
||||
it('should list REST APIs including the created one', async () => {
|
||||
const res = await gw.send(new GetRestApisCommand({}));
|
||||
expect(res.items?.some(a => a.id === apiId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete the REST API', async () => {
|
||||
await gw.send(new DeleteRestApiCommand({ restApiId: apiId }));
|
||||
await expect(gw.send(new GetRestApiCommand({ restApiId: apiId }))).rejects.toThrow();
|
||||
apiId = '';
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorizer CRUD', () => {
|
||||
let apiId: string;
|
||||
let authorizerId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await gw.send(new CreateRestApiCommand({ name: uniqueName('auth-crud') }));
|
||||
apiId = res.id!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await gw.send(new DeleteRestApiCommand({ restApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create a TOKEN authorizer', async () => {
|
||||
const res = await gw.send(new CreateAuthorizerCommand({
|
||||
restApiId: apiId,
|
||||
name: 'token-auth',
|
||||
type: AuthorizerType.TOKEN,
|
||||
authorizerUri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:dummy/invocations`,
|
||||
identitySource: 'method.request.header.Authorization',
|
||||
authorizerResultTtlInSeconds: 0,
|
||||
}));
|
||||
authorizerId = res.id!;
|
||||
expect(authorizerId).toBeTruthy();
|
||||
expect(res.type).toBe(AuthorizerType.TOKEN);
|
||||
});
|
||||
|
||||
it('should get the authorizer', async () => {
|
||||
const res = await gw.send(new GetAuthorizerCommand({ restApiId: apiId, authorizerId }));
|
||||
expect(res.id).toBe(authorizerId);
|
||||
expect(res.type).toBe(AuthorizerType.TOKEN);
|
||||
});
|
||||
|
||||
it('should list authorizers', async () => {
|
||||
const res = await gw.send(new GetAuthorizersCommand({ restApiId: apiId }));
|
||||
expect(res.items?.some(a => a.id === authorizerId)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Execute-API data plane ────────────────────────────
|
||||
|
||||
describe('API Gateway v1 — execute-api data plane', () => {
|
||||
let gw: APIGatewayClient;
|
||||
let lambda: LambdaClient;
|
||||
|
||||
// Shared API wired up in beforeAll
|
||||
let apiId: string;
|
||||
let itemResourceId: string;
|
||||
let secureResourceId: string;
|
||||
let openResourceId: string;
|
||||
|
||||
// Lambda function names
|
||||
const echoAuthFn = uniqueName('req-auth-echo');
|
||||
const tokenAuthFn = uniqueName('tok-auth-echo');
|
||||
const denyAuthFn = uniqueName('deny-auth');
|
||||
const proxyFn = uniqueName('proxy-echo');
|
||||
const simpleFn = uniqueName('simple-fn');
|
||||
|
||||
beforeAll(async () => {
|
||||
gw = makeClient(APIGatewayClient);
|
||||
lambda = makeClient(LambdaClient);
|
||||
|
||||
// ── Lambda: REQUEST authorizer — echoes received event into policy context ──
|
||||
await createLambda(lambda, echoAuthFn, `
|
||||
exports.handler = async (event) => ({
|
||||
principalId: 'user',
|
||||
policyDocument: {
|
||||
Version: '2012-10-17',
|
||||
Statement: [{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: event.methodArn }]
|
||||
},
|
||||
context: { receivedEvent: JSON.stringify(event) }
|
||||
});
|
||||
`);
|
||||
|
||||
// ── Lambda: TOKEN authorizer — echoes received event into policy context ──
|
||||
await createLambda(lambda, tokenAuthFn, `
|
||||
exports.handler = async (event) => ({
|
||||
principalId: 'user',
|
||||
policyDocument: {
|
||||
Version: '2012-10-17',
|
||||
Statement: [{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: event.methodArn }]
|
||||
},
|
||||
context: { receivedEvent: JSON.stringify(event) }
|
||||
});
|
||||
`);
|
||||
|
||||
// ── Lambda: REQUEST authorizer — always Deny ──
|
||||
await createLambda(lambda, denyAuthFn, `
|
||||
exports.handler = async (event) => ({
|
||||
principalId: 'user',
|
||||
policyDocument: {
|
||||
Version: '2012-10-17',
|
||||
Statement: [{ Action: 'execute-api:Invoke', Effect: 'Deny', Resource: event.methodArn }]
|
||||
}
|
||||
});
|
||||
`);
|
||||
|
||||
// ── Lambda: proxy integration — returns authorizer context so tests can inspect it ──
|
||||
await createLambda(lambda, proxyFn, `
|
||||
exports.handler = async (event) => ({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
authorizer: event.requestContext?.authorizer ?? null
|
||||
})
|
||||
});
|
||||
`);
|
||||
|
||||
// ── Lambda: simple integration — returns { invoked: true } ──
|
||||
await createLambda(lambda, simpleFn, `
|
||||
exports.handler = async (event) => ({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({ invoked: true })
|
||||
});
|
||||
`);
|
||||
|
||||
// ── Build the REST API ──
|
||||
const api = await gw.send(new CreateRestApiCommand({ name: uniqueName('exec-api-test') }));
|
||||
apiId = api.id!;
|
||||
|
||||
const resources = await gw.send(new GetResourcesCommand({ restApiId: apiId }));
|
||||
const rootId = resources.items![0].id!;
|
||||
|
||||
// /items
|
||||
const itemsRes = await apigwFetch(`/restapis/${apiId}/resources/${rootId}`, 'POST', { pathPart: 'items' });
|
||||
const itemsId = (await itemsRes.json() as { id: string }).id;
|
||||
|
||||
// /items/{id}
|
||||
const itemRes = await apigwFetch(`/restapis/${apiId}/resources/${itemsId}`, 'POST', { pathPart: '{id}' });
|
||||
itemResourceId = (await itemRes.json() as { id: string }).id;
|
||||
|
||||
// /secure (TOKEN authorizer)
|
||||
const secureRes = await apigwFetch(`/restapis/${apiId}/resources/${rootId}`, 'POST', { pathPart: 'secure' });
|
||||
secureResourceId = (await secureRes.json() as { id: string }).id;
|
||||
|
||||
// /open (NONE authorizer)
|
||||
const openRes = await apigwFetch(`/restapis/${apiId}/resources/${rootId}`, 'POST', { pathPart: 'open' });
|
||||
openResourceId = (await openRes.json() as { id: string }).id;
|
||||
|
||||
// ── REQUEST authorizer ──
|
||||
const reqAuthRes = await apigwFetch(`/restapis/${apiId}/authorizers`, 'POST', {
|
||||
name: 'req-echo-auth',
|
||||
type: 'REQUEST',
|
||||
authorizerUri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${echoAuthFn}/invocations`,
|
||||
identitySource: 'method.request.header.Authorization',
|
||||
authorizerResultTtlInSeconds: 0,
|
||||
});
|
||||
const reqAuthId = (await reqAuthRes.json() as { id: string }).id;
|
||||
|
||||
// ── TOKEN authorizer ──
|
||||
const tokAuthRes = await apigwFetch(`/restapis/${apiId}/authorizers`, 'POST', {
|
||||
name: 'tok-echo-auth',
|
||||
type: 'TOKEN',
|
||||
authorizerUri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${tokenAuthFn}/invocations`,
|
||||
identitySource: 'method.request.header.Authorization',
|
||||
authorizerResultTtlInSeconds: 0,
|
||||
});
|
||||
const tokAuthId = (await tokAuthRes.json() as { id: string }).id;
|
||||
|
||||
const proxyUri = `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${proxyFn}/invocations`;
|
||||
const simpleUri = `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${simpleFn}/invocations`;
|
||||
|
||||
// GET /items/{id} — REQUEST authorizer + proxy integration
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${itemResourceId}/methods/GET`, 'PUT', {
|
||||
authorizationType: 'CUSTOM',
|
||||
authorizerId: reqAuthId,
|
||||
});
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${itemResourceId}/methods/GET/integration`, 'PUT', {
|
||||
type: 'AWS_PROXY',
|
||||
httpMethod: 'POST',
|
||||
uri: proxyUri,
|
||||
});
|
||||
|
||||
// GET /secure — TOKEN authorizer + proxy integration
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${secureResourceId}/methods/GET`, 'PUT', {
|
||||
authorizationType: 'CUSTOM',
|
||||
authorizerId: tokAuthId,
|
||||
});
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${secureResourceId}/methods/GET/integration`, 'PUT', {
|
||||
type: 'AWS_PROXY',
|
||||
httpMethod: 'POST',
|
||||
uri: proxyUri,
|
||||
});
|
||||
|
||||
// GET /open — NONE authorizer + simple integration
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${openResourceId}/methods/GET`, 'PUT', {
|
||||
authorizationType: 'NONE',
|
||||
});
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${openResourceId}/methods/GET/integration`, 'PUT', {
|
||||
type: 'AWS_PROXY',
|
||||
httpMethod: 'POST',
|
||||
uri: simpleUri,
|
||||
});
|
||||
|
||||
// ── Deploy ──
|
||||
const depRes = await apigwFetch(`/restapis/${apiId}/deployments`, 'POST', { description: 'compat-test' });
|
||||
const depId = (await depRes.json() as { id: string }).id;
|
||||
await apigwFetch(`/restapis/${apiId}/stages`, 'POST', { stageName: 'prod', deploymentId: depId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (apiId) await gw.send(new DeleteRestApiCommand({ restApiId: apiId })); } catch { /* ignore */ }
|
||||
await deleteLambda(lambda, echoAuthFn);
|
||||
await deleteLambda(lambda, tokenAuthFn);
|
||||
await deleteLambda(lambda, denyAuthFn);
|
||||
await deleteLambda(lambda, proxyFn);
|
||||
await deleteLambda(lambda, simpleFn);
|
||||
});
|
||||
|
||||
// ──────────────────────────── REQUEST authorizer event shape ────────────────────────────
|
||||
|
||||
describe('REQUEST authorizer — full event shape', () => {
|
||||
let receivedEvent: Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await executeApi(apiId, 'prod', '/items/42', {
|
||||
headers: { Authorization: 'Bearer test-token', 'X-Custom-Header': 'hello' },
|
||||
query: 'foo=bar&baz=qux',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { authorizer: { receivedEvent: string } };
|
||||
receivedEvent = JSON.parse(body.authorizer.receivedEvent) as Record<string, unknown>;
|
||||
});
|
||||
|
||||
it('should include type=REQUEST and methodArn', () => {
|
||||
expect(receivedEvent.type).toBe('REQUEST');
|
||||
expect(typeof receivedEvent.methodArn).toBe('string');
|
||||
expect(receivedEvent.methodArn as string).toContain(apiId);
|
||||
});
|
||||
|
||||
it('should include the matched resource path template', () => {
|
||||
expect(receivedEvent.resource).toBe('/items/{id}');
|
||||
});
|
||||
|
||||
it('should include the actual request path', () => {
|
||||
expect(receivedEvent.path).toBe('/items/42');
|
||||
});
|
||||
|
||||
it('should include the HTTP method', () => {
|
||||
expect(receivedEvent.httpMethod).toBe('GET');
|
||||
});
|
||||
|
||||
it('should include a non-null headers map', () => {
|
||||
expect(receivedEvent.headers).toBeTruthy();
|
||||
expect(typeof receivedEvent.headers).toBe('object');
|
||||
const headers = receivedEvent.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer test-token');
|
||||
expect(headers['X-Custom-Header']).toBe('hello');
|
||||
});
|
||||
|
||||
it('should include a multiValueHeaders map', () => {
|
||||
expect(receivedEvent.multiValueHeaders).toBeTruthy();
|
||||
const mvh = receivedEvent.multiValueHeaders as Record<string, string[]>;
|
||||
expect(Array.isArray(mvh['Authorization'])).toBe(true);
|
||||
expect(mvh['Authorization'][0]).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('should include queryStringParameters with all query params', () => {
|
||||
expect(receivedEvent.queryStringParameters).toBeTruthy();
|
||||
const qsp = receivedEvent.queryStringParameters as Record<string, string>;
|
||||
expect(qsp.foo).toBe('bar');
|
||||
expect(qsp.baz).toBe('qux');
|
||||
});
|
||||
|
||||
it('should include multiValueQueryStringParameters', () => {
|
||||
expect(receivedEvent.multiValueQueryStringParameters).toBeTruthy();
|
||||
const mqsp = receivedEvent.multiValueQueryStringParameters as Record<string, string[]>;
|
||||
expect(Array.isArray(mqsp.foo)).toBe(true);
|
||||
expect(mqsp.foo[0]).toBe('bar');
|
||||
});
|
||||
|
||||
it('should include pathParameters with the id segment', () => {
|
||||
expect(receivedEvent.pathParameters).toBeTruthy();
|
||||
const pp = receivedEvent.pathParameters as Record<string, string>;
|
||||
expect(pp.id).toBe('42');
|
||||
});
|
||||
|
||||
it('should include stageVariables as null', () => {
|
||||
expect(receivedEvent.stageVariables).toBeNull();
|
||||
});
|
||||
|
||||
it('should include a requestContext with requestId', () => {
|
||||
expect(receivedEvent.requestContext).toBeTruthy();
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
expect(typeof ctx.requestId).toBe('string');
|
||||
expect((ctx.requestId as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include requestContext.identity.sourceIp', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
const identity = ctx.identity as Record<string, string>;
|
||||
expect(identity.sourceIp).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('should include requestContext.resourcePath', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
expect(ctx.resourcePath).toBe('/items/{id}');
|
||||
});
|
||||
|
||||
it('should include requestContext.httpMethod', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
expect(ctx.httpMethod).toBe('GET');
|
||||
});
|
||||
|
||||
it('should include requestContext.stage', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
expect(ctx.stage).toBe('prod');
|
||||
});
|
||||
|
||||
it('should include requestContext.accountId', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
expect(typeof ctx.accountId).toBe('string');
|
||||
expect((ctx.accountId as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include requestContext.apiId', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
expect(ctx.apiId).toBe(apiId);
|
||||
});
|
||||
|
||||
it('should include requestContext.resourceId as a non-empty string', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
expect(typeof ctx.resourceId).toBe('string');
|
||||
expect((ctx.resourceId as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include requestContext.path equal to the actual request path', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
expect(ctx.path).toBe('/items/42');
|
||||
});
|
||||
|
||||
it('should include requestContext.identity.apiKey as null when no usage plan key matches', () => {
|
||||
const ctx = receivedEvent.requestContext as Record<string, unknown>;
|
||||
const identity = ctx.identity as Record<string, unknown>;
|
||||
expect(Object.prototype.hasOwnProperty.call(identity, 'apiKey')).toBe(true);
|
||||
expect(identity.apiKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('REQUEST authorizer — no query params', () => {
|
||||
it('should set queryStringParameters to null when no query string is present', async () => {
|
||||
const res = await executeApi(apiId, 'prod', '/items/99', {
|
||||
headers: { Authorization: 'Bearer test' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { authorizer: { receivedEvent: string } };
|
||||
const event = JSON.parse(body.authorizer.receivedEvent) as Record<string, unknown>;
|
||||
expect(event.queryStringParameters).toBeNull();
|
||||
expect(event.multiValueQueryStringParameters).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── TOKEN authorizer preservation ────────────────────────────
|
||||
|
||||
describe('TOKEN authorizer — event shape preserved', () => {
|
||||
let receivedEvent: Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await executeApi(apiId, 'prod', '/secure', {
|
||||
headers: { Authorization: 'Bearer my-secret-token' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { authorizer: { receivedEvent: string } };
|
||||
receivedEvent = JSON.parse(body.authorizer.receivedEvent) as Record<string, unknown>;
|
||||
});
|
||||
|
||||
it('should include type=TOKEN', () => {
|
||||
expect(receivedEvent.type).toBe('TOKEN');
|
||||
});
|
||||
|
||||
it('should include methodArn', () => {
|
||||
expect(typeof receivedEvent.methodArn).toBe('string');
|
||||
expect((receivedEvent.methodArn as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include authorizationToken equal to the Authorization header value', () => {
|
||||
expect(receivedEvent.authorizationToken).toBe('Bearer my-secret-token');
|
||||
});
|
||||
|
||||
it('should NOT include headers, queryStringParameters, or requestContext', () => {
|
||||
// TOKEN authorizer event must contain exactly type, methodArn, authorizationToken
|
||||
expect(receivedEvent.headers == null).toBe(true);
|
||||
expect(receivedEvent.queryStringParameters == null).toBe(true);
|
||||
expect(receivedEvent.requestContext == null).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── NONE authorizer preservation ────────────────────────────
|
||||
|
||||
describe('NONE authorizer — skips authorizer invocation', () => {
|
||||
it('should invoke the integration directly without an Authorization header', async () => {
|
||||
const res = await executeApi(apiId, 'prod', '/open');
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { invoked: boolean };
|
||||
expect(body.invoked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Allow / Deny policy ────────────────────────────
|
||||
|
||||
describe('REQUEST authorizer — Allow policy forwards to integration', () => {
|
||||
it('should return HTTP 200 when the authorizer returns Allow', async () => {
|
||||
const res = await executeApi(apiId, 'prod', '/items/1', {
|
||||
headers: { Authorization: 'Bearer allow-me' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('REQUEST authorizer — Deny policy returns 403', () => {
|
||||
let denyApiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Build a separate API with only the deny authorizer to avoid interfering with other tests
|
||||
const api = await gw.send(new CreateRestApiCommand({ name: uniqueName('deny-api') }));
|
||||
denyApiId = api.id!;
|
||||
|
||||
const resources = await gw.send(new GetResourcesCommand({ restApiId: denyApiId }));
|
||||
const rootId = resources.items![0].id!;
|
||||
|
||||
const protectedRes = await apigwFetch(`/restapis/${denyApiId}/resources/${rootId}`, 'POST', { pathPart: 'protected' });
|
||||
const protectedId = (await protectedRes.json() as { id: string }).id;
|
||||
|
||||
const denyAuthRes = await apigwFetch(`/restapis/${denyApiId}/authorizers`, 'POST', {
|
||||
name: 'deny-auth',
|
||||
type: 'REQUEST',
|
||||
authorizerUri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${denyAuthFn}/invocations`,
|
||||
identitySource: 'method.request.header.Authorization',
|
||||
authorizerResultTtlInSeconds: 0,
|
||||
});
|
||||
const denyAuthId = (await denyAuthRes.json() as { id: string }).id;
|
||||
|
||||
await apigwFetch(`/restapis/${denyApiId}/resources/${protectedId}/methods/GET`, 'PUT', {
|
||||
authorizationType: 'CUSTOM',
|
||||
authorizerId: denyAuthId,
|
||||
});
|
||||
await apigwFetch(`/restapis/${denyApiId}/resources/${protectedId}/methods/GET/integration`, 'PUT', {
|
||||
type: 'MOCK',
|
||||
requestTemplates: { 'application/json': '{"statusCode": 200}' },
|
||||
});
|
||||
await apigwFetch(`/restapis/${denyApiId}/resources/${protectedId}/methods/GET/responses/200`, 'PUT', {
|
||||
responseParameters: {},
|
||||
});
|
||||
await apigwFetch(`/restapis/${denyApiId}/resources/${protectedId}/methods/GET/integration/responses/200`, 'PUT', {
|
||||
selectionPattern: '',
|
||||
responseTemplates: { 'application/json': '{"message":"ok"}' },
|
||||
});
|
||||
|
||||
const depRes = await apigwFetch(`/restapis/${denyApiId}/deployments`, 'POST', { description: 'deny-test' });
|
||||
const depId = (await depRes.json() as { id: string }).id;
|
||||
await apigwFetch(`/restapis/${denyApiId}/stages`, 'POST', { stageName: 'prod', deploymentId: depId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (denyApiId) await gw.send(new DeleteRestApiCommand({ restApiId: denyApiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should return HTTP 403 when the authorizer returns Deny', async () => {
|
||||
const res = await executeApi(denyApiId, 'prod', '/protected', {
|
||||
headers: { Authorization: 'Bearer any-token' },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Stage Variables ────────────────────────────
|
||||
|
||||
describe('Stage variables — populated in authorizer and proxy events', () => {
|
||||
let svApiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const svAuthName = uniqueName('sv-auth');
|
||||
const svProxyName = uniqueName('sv-proxy');
|
||||
await createLambda(lambda, svAuthName, `
|
||||
exports.handler = async (event) => ({
|
||||
principalId: 'user',
|
||||
policyDocument: { Version: '2012-10-17', Statement: [{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: event.methodArn }] },
|
||||
context: { receivedEvent: JSON.stringify(event) }
|
||||
});
|
||||
`);
|
||||
await createLambda(lambda, svProxyName, `
|
||||
exports.handler = async (event) => ({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({ stageVariables: event.stageVariables, authorizer: event.requestContext?.authorizer ?? null })
|
||||
});
|
||||
`);
|
||||
|
||||
const api = await gw.send(new CreateRestApiCommand({ name: uniqueName('sv-api') }));
|
||||
svApiId = api.id!;
|
||||
const resources = await gw.send(new GetResourcesCommand({ restApiId: svApiId }));
|
||||
const rootId = resources.items![0].id!;
|
||||
const pingRes = await apigwFetch(`/restapis/${svApiId}/resources/${rootId}`, 'POST', { pathPart: 'ping' });
|
||||
const pingId = (await pingRes.json() as { id: string }).id;
|
||||
|
||||
const authRes = await apigwFetch(`/restapis/${svApiId}/authorizers`, 'POST', {
|
||||
name: 'sv-auth', type: 'REQUEST',
|
||||
authorizerUri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${svAuthName}/invocations`,
|
||||
identitySource: 'method.request.header.Authorization', authorizerResultTtlInSeconds: 0,
|
||||
});
|
||||
const svAuthId = (await authRes.json() as { id: string }).id;
|
||||
|
||||
await apigwFetch(`/restapis/${svApiId}/resources/${pingId}/methods/GET`, 'PUT', { authorizationType: 'CUSTOM', authorizerId: svAuthId });
|
||||
await apigwFetch(`/restapis/${svApiId}/resources/${pingId}/methods/GET/integration`, 'PUT', {
|
||||
type: 'AWS_PROXY', httpMethod: 'POST',
|
||||
uri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${svProxyName}/invocations`,
|
||||
});
|
||||
|
||||
const depRes = await apigwFetch(`/restapis/${svApiId}/deployments`, 'POST', { description: 'sv-test' });
|
||||
const depId = (await depRes.json() as { id: string }).id;
|
||||
// Create stage WITH variables
|
||||
await apigwFetch(`/restapis/${svApiId}/stages`, 'POST', {
|
||||
stageName: 'prod', deploymentId: depId,
|
||||
variables: { env: 'test', version: 'v1' },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (svApiId) await gw.send(new DeleteRestApiCommand({ restApiId: svApiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should populate stageVariables in the proxy event from the stage configuration', async () => {
|
||||
const res = await executeApi(svApiId, 'prod', '/ping', { headers: { Authorization: 'Bearer t' } });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { stageVariables: Record<string, string>; authorizer: { receivedEvent: string } };
|
||||
expect(body.stageVariables).toBeTruthy();
|
||||
expect(body.stageVariables.env).toBe('test');
|
||||
expect(body.stageVariables.version).toBe('v1');
|
||||
});
|
||||
|
||||
it('should populate stageVariables in the REQUEST authorizer event', async () => {
|
||||
const res = await executeApi(svApiId, 'prod', '/ping', { headers: { Authorization: 'Bearer t' } });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { authorizer: { receivedEvent: string } };
|
||||
const event = JSON.parse(body.authorizer.receivedEvent) as Record<string, unknown>;
|
||||
const sv = event.stageVariables as Record<string, string>;
|
||||
expect(sv).toBeTruthy();
|
||||
expect(sv.env).toBe('test');
|
||||
expect(sv.version).toBe('v1');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── API Key Resolution ────────────────────────────
|
||||
|
||||
describe('API key — identity.apiKey resolved from usage plan', () => {
|
||||
let akApiId: string;
|
||||
const testKeyValue = uniqueName('ak');
|
||||
|
||||
beforeAll(async () => {
|
||||
const akAuthName = uniqueName('ak-auth');
|
||||
const akProxyName = uniqueName('ak-proxy');
|
||||
await createLambda(lambda, akAuthName, `
|
||||
exports.handler = async (event) => ({
|
||||
principalId: 'user',
|
||||
policyDocument: { Version: '2012-10-17', Statement: [{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: event.methodArn }] },
|
||||
context: { receivedEvent: JSON.stringify(event) }
|
||||
});
|
||||
`);
|
||||
await createLambda(lambda, akProxyName, `
|
||||
exports.handler = async (event) => ({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({ authorizer: event.requestContext?.authorizer ?? null })
|
||||
});
|
||||
`);
|
||||
|
||||
const api = await gw.send(new CreateRestApiCommand({ name: uniqueName('ak-api') }));
|
||||
akApiId = api.id!;
|
||||
const resources = await gw.send(new GetResourcesCommand({ restApiId: akApiId }));
|
||||
const rootId = resources.items![0].id!;
|
||||
const secRes = await apigwFetch(`/restapis/${akApiId}/resources/${rootId}`, 'POST', { pathPart: 'secure' });
|
||||
const secId = (await secRes.json() as { id: string }).id;
|
||||
|
||||
const authRes = await apigwFetch(`/restapis/${akApiId}/authorizers`, 'POST', {
|
||||
name: 'ak-auth', type: 'REQUEST',
|
||||
authorizerUri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${akAuthName}/invocations`,
|
||||
identitySource: 'method.request.header.Authorization', authorizerResultTtlInSeconds: 0,
|
||||
});
|
||||
const akAuthId = (await authRes.json() as { id: string }).id;
|
||||
|
||||
await apigwFetch(`/restapis/${akApiId}/resources/${secId}/methods/GET`, 'PUT', { authorizationType: 'CUSTOM', authorizerId: akAuthId });
|
||||
await apigwFetch(`/restapis/${akApiId}/resources/${secId}/methods/GET/integration`, 'PUT', {
|
||||
type: 'AWS_PROXY', httpMethod: 'POST',
|
||||
uri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${akProxyName}/invocations`,
|
||||
});
|
||||
|
||||
const depRes = await apigwFetch(`/restapis/${akApiId}/deployments`, 'POST', { description: 'ak-test' });
|
||||
const depId = (await depRes.json() as { id: string }).id;
|
||||
await apigwFetch(`/restapis/${akApiId}/stages`, 'POST', { stageName: 'prod', deploymentId: depId });
|
||||
|
||||
// Create API key + usage plan linked to (akApiId, prod)
|
||||
const keyRes = await apigwFetch('/apikeys', 'POST', { name: uniqueName('key'), value: testKeyValue, enabled: true });
|
||||
const keyId = (await keyRes.json() as { id: string }).id;
|
||||
const planRes = await apigwFetch('/usageplans', 'POST', { name: uniqueName('plan'), apiStages: [{ apiId: akApiId, stage: 'prod' }] });
|
||||
const planId = (await planRes.json() as { id: string }).id;
|
||||
await apigwFetch(`/usageplans/${planId}/keys`, 'POST', { keyId, keyType: 'API_KEY' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (akApiId) await gw.send(new DeleteRestApiCommand({ restApiId: akApiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should populate identity.apiKey when x-api-key header matches a usage plan key', async () => {
|
||||
const res = await executeApi(akApiId, 'prod', '/secure', {
|
||||
headers: { Authorization: 'Bearer t', 'x-api-key': testKeyValue },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { authorizer: { receivedEvent: string } };
|
||||
const event = JSON.parse(body.authorizer.receivedEvent) as Record<string, unknown>;
|
||||
const identity = (event.requestContext as Record<string, unknown>).identity as Record<string, unknown>;
|
||||
expect(identity.apiKey).toBe(testKeyValue);
|
||||
});
|
||||
|
||||
it('should set identity.apiKey to null when x-api-key header is absent', async () => {
|
||||
const res = await executeApi(akApiId, 'prod', '/secure', { headers: { Authorization: 'Bearer t' } });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { authorizer: { receivedEvent: string } };
|
||||
const event = JSON.parse(body.authorizer.receivedEvent) as Record<string, unknown>;
|
||||
const identity = (event.requestContext as Record<string, unknown>).identity as Record<string, unknown>;
|
||||
expect(identity.apiKey).toBeNull();
|
||||
});
|
||||
|
||||
it('should set identity.apiKey to null when x-api-key does not match any plan key', async () => {
|
||||
const res = await executeApi(akApiId, 'prod', '/secure', {
|
||||
headers: { Authorization: 'Bearer t', 'x-api-key': 'wrong-key' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { authorizer: { receivedEvent: string } };
|
||||
const event = JSON.parse(body.authorizer.receivedEvent) as Record<string, unknown>;
|
||||
const identity = (event.requestContext as Record<string, unknown>).identity as Record<string, unknown>;
|
||||
expect(identity.apiKey).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Query string parameter parsing ────────────────────────────
|
||||
|
||||
/**
|
||||
* How a REST API fills queryStringParameters / multiValueQueryStringParameters.
|
||||
*
|
||||
* Captured against a real REST API in us-west-2 with a Lambda proxy integration:
|
||||
* ?x=1&x=2&x=3 yields {"x":"3"} and {"x":["1","2","3"]} — the LAST value wins in the
|
||||
* single-value map, as it does for duplicate headers. A valueless parameter arrives as "",
|
||||
* an absent query string makes both maps null, and a JSON:API style filter[status]=open
|
||||
* keeps its brackets.
|
||||
*/
|
||||
describe('API Gateway v1 — query string parameters', () => {
|
||||
let gw: APIGatewayClient;
|
||||
let lambda: LambdaClient;
|
||||
let apiId: string;
|
||||
const echoFn = uniqueName('qs-echo');
|
||||
|
||||
beforeAll(async () => {
|
||||
gw = makeClient(APIGatewayClient);
|
||||
lambda = makeClient(LambdaClient);
|
||||
|
||||
await createLambda(lambda, echoFn, `
|
||||
exports.handler = async (event) => ({
|
||||
statusCode: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
qsp: event.queryStringParameters,
|
||||
mvqsp: event.multiValueQueryStringParameters
|
||||
})
|
||||
});
|
||||
`);
|
||||
|
||||
const api = await gw.send(new CreateRestApiCommand({ name: uniqueName('qs-test') }));
|
||||
apiId = api.id!;
|
||||
|
||||
const resources = await gw.send(new GetResourcesCommand({ restApiId: apiId }));
|
||||
const rootId = resources.items![0].id!;
|
||||
|
||||
const echoRes = await apigwFetch(`/restapis/${apiId}/resources/${rootId}`, 'POST', { pathPart: 'echo' });
|
||||
const echoId = (await echoRes.json() as { id: string }).id;
|
||||
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${echoId}/methods/GET`, 'PUT', {
|
||||
authorizationType: 'NONE',
|
||||
});
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${echoId}/methods/GET/integration`, 'PUT', {
|
||||
type: 'AWS_PROXY',
|
||||
httpMethod: 'POST',
|
||||
uri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/arn:aws:lambda:${REGION}:${ACCOUNT}:function:${echoFn}/invocations`,
|
||||
});
|
||||
|
||||
const depRes = await apigwFetch(`/restapis/${apiId}/deployments`, 'POST', { description: 'compat-test' });
|
||||
const depId = (await depRes.json() as { id: string }).id;
|
||||
await apigwFetch(`/restapis/${apiId}/stages`, 'POST', { stageName: 'prod', deploymentId: depId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (apiId) await gw.send(new DeleteRestApiCommand({ restApiId: apiId })); } catch { /* ignore */ }
|
||||
await deleteLambda(lambda, echoFn);
|
||||
});
|
||||
|
||||
async function echo(query: string): Promise<{ qsp: Record<string, string> | null; mvqsp: Record<string, string[]> | null }> {
|
||||
const res = await executeApi(apiId, 'prod', '/echo', { query });
|
||||
expect(res.status).toBe(200);
|
||||
return res.json() as Promise<{ qsp: Record<string, string> | null; mvqsp: Record<string, string[]> | null }>;
|
||||
}
|
||||
|
||||
it('should take the last value of a repeated parameter', async () => {
|
||||
const { qsp, mvqsp } = await echo('x=1&x=2&x=3');
|
||||
expect(qsp).toEqual({ x: '3' });
|
||||
expect(mvqsp).toEqual({ x: ['1', '2', '3'] });
|
||||
});
|
||||
|
||||
it('should keep a trailing empty value as the winner', async () => {
|
||||
const { qsp } = await echo('x=1&x=');
|
||||
expect(qsp).toEqual({ x: '' });
|
||||
});
|
||||
|
||||
it('should represent a valueless parameter as an empty string', async () => {
|
||||
const { qsp } = await echo('a&b=1');
|
||||
expect(qsp).toEqual({ a: '', b: '1' });
|
||||
});
|
||||
|
||||
it('should preserve a bracketed parameter name', async () => {
|
||||
const { qsp, mvqsp } = await echo('filter%5Bstatus%5D=open');
|
||||
expect(qsp).toEqual({ 'filter[status]': 'open' });
|
||||
expect(mvqsp).toEqual({ 'filter[status]': ['open'] });
|
||||
});
|
||||
|
||||
it('should null both maps when there is no query string', async () => {
|
||||
const { qsp, mvqsp } = await echo('');
|
||||
expect(qsp).toBeNull();
|
||||
expect(mvqsp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Resource/method resolution precedence ────────────────────────────
|
||||
|
||||
/**
|
||||
* AWS selects the most specific resource that can serve the requested method, searching
|
||||
* literal, then path parameter, then greedy {proxy+}. A resource that declares methods but
|
||||
* not the requested one yields to a less specific sibling that declares it.
|
||||
*
|
||||
* Captured against a real REST API in us-west-2 (MOCK integrations, no authorizer): with
|
||||
* /users/{userId} GET and a literal /users/me PATCH deployed, GET /users/me is served by
|
||||
* /users/{userId} with userId="me", and with /things/real GET and /things/{proxy+} ANY,
|
||||
* POST /things/real is served by /things/{proxy+}.
|
||||
*/
|
||||
describe('API Gateway v1 — resource/method resolution precedence', () => {
|
||||
let gw: APIGatewayClient;
|
||||
let apiId: string;
|
||||
|
||||
/** Wire a MOCK method that echoes which resource served the request. */
|
||||
async function mockMethod(resourceId: string, httpMethod: string, marker: string): Promise<void> {
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${resourceId}/methods/${httpMethod}`, 'PUT', {
|
||||
authorizationType: 'NONE',
|
||||
});
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${resourceId}/methods/${httpMethod}/responses/200`, 'PUT', {});
|
||||
await apigwFetch(`/restapis/${apiId}/resources/${resourceId}/methods/${httpMethod}/integration`, 'PUT', {
|
||||
type: 'MOCK',
|
||||
requestTemplates: { 'application/json': '{"statusCode": 200}' },
|
||||
});
|
||||
await apigwFetch(
|
||||
`/restapis/${apiId}/resources/${resourceId}/methods/${httpMethod}/integration/responses/200`,
|
||||
'PUT',
|
||||
{ selectionPattern: '', responseTemplates: { 'application/json': `{"servedBy":"${marker}"}` } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a child resource and return its id. */
|
||||
async function child(parentId: string, pathPart: string): Promise<string> {
|
||||
const res = await apigwFetch(`/restapis/${apiId}/resources/${parentId}`, 'POST', { pathPart });
|
||||
return (await res.json() as { id: string }).id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
gw = makeClient(APIGatewayClient);
|
||||
|
||||
const api = await gw.send(new CreateRestApiCommand({ name: uniqueName('routing-precedence') }));
|
||||
apiId = api.id!;
|
||||
|
||||
const resources = await gw.send(new GetResourcesCommand({ restApiId: apiId }));
|
||||
const rootId = resources.items![0].id!;
|
||||
|
||||
// /users/{userId} GET alongside a literal /users/me carrying only PATCH.
|
||||
const usersId = await child(rootId, 'users');
|
||||
await mockMethod(await child(usersId, '{userId}'), 'GET', 'users-by-id');
|
||||
await mockMethod(await child(usersId, 'me'), 'PATCH', 'users-me');
|
||||
|
||||
// /things/real carrying only GET alongside a greedy /things/{proxy+} ANY.
|
||||
const thingsId = await child(rootId, 'things');
|
||||
await mockMethod(await child(thingsId, 'real'), 'GET', 'things-real');
|
||||
await mockMethod(await child(thingsId, '{proxy+}'), 'ANY', 'things-proxy');
|
||||
|
||||
const depRes = await apigwFetch(`/restapis/${apiId}/deployments`, 'POST', { description: 'compat-test' });
|
||||
const depId = (await depRes.json() as { id: string }).id;
|
||||
await apigwFetch(`/restapis/${apiId}/stages`, 'POST', { stageName: 'prod', deploymentId: depId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (apiId) await gw.send(new DeleteRestApiCommand({ restApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
async function servedBy(path: string, method: string): Promise<string> {
|
||||
const res = await executeApi(apiId, 'prod', path, { method });
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json() as { servedBy: string }).servedBy;
|
||||
}
|
||||
|
||||
it('should serve GET /users/me from the parameterised sibling', async () => {
|
||||
expect(await servedBy('/users/me', 'GET')).toBe('users-by-id');
|
||||
});
|
||||
|
||||
it('should keep the literal resource for the method it declares', async () => {
|
||||
expect(await servedBy('/users/me', 'PATCH')).toBe('users-me');
|
||||
});
|
||||
|
||||
it('should serve an ordinary id from the parameterised resource', async () => {
|
||||
expect(await servedBy('/users/abc123', 'GET')).toBe('users-by-id');
|
||||
});
|
||||
|
||||
it('should keep the literal resource for GET /things/real', async () => {
|
||||
expect(await servedBy('/things/real', 'GET')).toBe('things-real');
|
||||
});
|
||||
|
||||
it('should serve POST /things/real from the greedy proxy', async () => {
|
||||
expect(await servedBy('/things/real', 'POST')).toBe('things-proxy');
|
||||
});
|
||||
|
||||
// AWS resolves path and method together, so "no such path" and "a path whose resources
|
||||
// declare no usable method" are one failure: 403 Missing Authentication Token, not 404/405.
|
||||
it('should answer 403 when no candidate resource declares the method', async () => {
|
||||
const res = await executeApi(apiId, 'prod', '/users/me', { method: 'POST' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.headers.get('x-amzn-errortype')).toBe('MissingAuthenticationTokenException');
|
||||
expect(await res.json()).toEqual({ message: 'Missing Authentication Token' });
|
||||
});
|
||||
|
||||
it('should answer 403 for a path that matches no resource', async () => {
|
||||
const res = await executeApi(apiId, 'prod', '/zzz', { method: 'GET' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toEqual({ message: 'Missing Authentication Token' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* API Gateway v2 execute-api host compatibility tests.
|
||||
*
|
||||
* Validates AWS-style virtual-host addressing for WebSocket APIs. Floci accepts both a
|
||||
* region-bearing host (mirroring AWS's `{apiId}.execute-api.{region}.amazonaws.com`, locally
|
||||
* `{apiId}.execute-api.{region}.localhost:4566`) and its regionless built-in suffix
|
||||
* (`{apiId}.execute-api.localhost.floci.io:4566`):
|
||||
*
|
||||
* - #1846: the `@connections` Management API reaches the API Gateway controller
|
||||
* via the execute-api host instead of being swallowed by the S3 controller.
|
||||
* - #1871: a WebSocket `$connect` addressed via the execute-api host resolves an API
|
||||
* created in a non-default region (from the host region label when present, else a
|
||||
* cross-region apiId lookup) so it no longer returns 403.
|
||||
* - The regionless built-in suffix must NOT be mis-parsed as region `localhost`
|
||||
* (the defect that consolidating onto `ApiGatewayExecuteApiHostFilter` fixes).
|
||||
*
|
||||
* Routing is exercised deterministically by overriding the `Host` header to the
|
||||
* execute-api form while the connection still targets the real test endpoint — this
|
||||
* avoids depending on wildcard `*.localhost` DNS resolution.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
ApiGatewayV2Client,
|
||||
CreateApiCommand,
|
||||
DeleteApiCommand,
|
||||
CreateIntegrationCommand,
|
||||
CreateRouteCommand,
|
||||
CreateRouteResponseCommand,
|
||||
CreateStageCommand,
|
||||
CreateDeploymentCommand,
|
||||
} from '@aws-sdk/client-apigatewayv2';
|
||||
import {
|
||||
ApiGatewayManagementApiClient,
|
||||
PostToConnectionCommand,
|
||||
GetConnectionCommand,
|
||||
DeleteConnectionCommand,
|
||||
} from '@aws-sdk/client-apigatewaymanagementapi';
|
||||
import { LambdaClient, CreateFunctionCommand, DeleteFunctionCommand } from '@aws-sdk/client-lambda';
|
||||
import WebSocket from 'ws';
|
||||
import { makeClient, uniqueName, ENDPOINT, REGION, ACCOUNT, buildMinimalZip, sleep } from './setup';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The local endpoint authority, e.g. `localhost:4566`. */
|
||||
function authority(): string {
|
||||
return ENDPOINT.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Floci's advertised local execute-api domain, e.g. `{apiId}.execute-api.localhost.floci.io:4566`
|
||||
* (regionless — see docs/services/api-gateway.md and the TLS SANs). This is the host form these
|
||||
* end-to-end tests exercise: it is what floci actually serves/routes, and the region is recovered
|
||||
* by a cross-region apiId lookup rather than a host label. The region-bearing AWS shape
|
||||
* (`{apiId}.execute-api.{region}.amazonaws.com`) is covered by the Java filter/resolver unit tests.
|
||||
*/
|
||||
function builtinSuffixHost(apiId: string): string {
|
||||
const port = authority().includes(':') ? `:${authority().split(':')[1]}` : '';
|
||||
return `${apiId}.execute-api.localhost.floci.io${port}`;
|
||||
}
|
||||
|
||||
function configuredHostnameHost(apiId: string, region: string): string {
|
||||
const endpoint = new URL(ENDPOINT);
|
||||
const port = endpoint.port ? `:${endpoint.port}` : '';
|
||||
return `${apiId}.execute-api.${region}.${endpoint.hostname}${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Management API client that presents an execute-api Host header while still connecting to the
|
||||
* real test endpoint. A build-step middleware overrides the `Host` header (before SigV4 signing)
|
||||
* to the given execute-api host; the request path stays `/{stage}/@connections/{id}`. This is
|
||||
* exactly what floci's `ApiGatewayExecuteApiHostFilter` keys on, so it proves host-based routing
|
||||
* without relying on `*.localhost` DNS.
|
||||
*/
|
||||
function managementClientForHost(host: string, stage: string, region: string): ApiGatewayManagementApiClient {
|
||||
const client = makeClient(ApiGatewayManagementApiClient, {
|
||||
endpoint: `${ENDPOINT}/${stage}`,
|
||||
region,
|
||||
});
|
||||
client.middlewareStack.add(
|
||||
(next) => async (args) => {
|
||||
const req = args.request as { headers?: Record<string, string> };
|
||||
if (req && req.headers) {
|
||||
req.headers['host'] = host;
|
||||
}
|
||||
return next(args);
|
||||
},
|
||||
{ step: 'build', name: 'setExecuteApiHost', priority: 'high' }
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function subdomainManagementClient(apiId: string, stage: string, region: string): ApiGatewayManagementApiClient {
|
||||
return managementClientForHost(builtinSuffixHost(apiId), stage, region);
|
||||
}
|
||||
|
||||
/** Connect a WebSocket to the real endpoint but with the execute-api Host header. */
|
||||
function connectWsViaSubdomain(apiId: string, stage: string): Promise<WebSocket> {
|
||||
const url = `ws://${authority()}/${stage}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, { headers: { Host: builtinSuffixHost(apiId) } });
|
||||
ws.on('open', () => resolve(ws));
|
||||
ws.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMessage(ws: WebSocket, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timeout waiting for message')), timeoutMs);
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timer);
|
||||
resolve(data.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ROLE_ARN = `arn:aws:iam::${ACCOUNT}:role/lambda-role`;
|
||||
|
||||
// Echo handler that can report its own connectionId (mirrors the data-plane suite).
|
||||
const ECHO_HANDLER = `
|
||||
exports.handler = async (event) => {
|
||||
const body = event.body || '';
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.action === 'getConnectionId') {
|
||||
return { statusCode: 200, body: JSON.stringify({ connectionId: event.requestContext.connectionId }) };
|
||||
}
|
||||
} catch (e) {}
|
||||
return { statusCode: 200, body: body || 'echo' };
|
||||
};
|
||||
`;
|
||||
|
||||
// ── Test suite ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Gateway v2 execute-api subdomain routing', () => {
|
||||
const createdApis: Array<{ apiId: string; region: string }> = [];
|
||||
const createdFunctions: string[] = [];
|
||||
let lambda: LambdaClient;
|
||||
|
||||
beforeAll(() => {
|
||||
lambda = makeClient(LambdaClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const fnName of createdFunctions) {
|
||||
try { await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName })); } catch { /* ignore */ }
|
||||
}
|
||||
for (const { apiId, region } of createdApis) {
|
||||
try {
|
||||
const gw = makeClient(ApiGatewayV2Client, { region });
|
||||
await gw.send(new DeleteApiCommand({ ApiId: apiId }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
async function createWsApiWithStage(name: string, region: string, stage: string): Promise<string> {
|
||||
const gw = makeClient(ApiGatewayV2Client, { region });
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName(name),
|
||||
ProtocolType: 'WEBSOCKET',
|
||||
RouteSelectionExpression: '$request.body.action',
|
||||
}));
|
||||
const apiId = res.ApiId!;
|
||||
createdApis.push({ apiId, region });
|
||||
const deploy = await gw.send(new CreateDeploymentCommand({ ApiId: apiId }));
|
||||
await gw.send(new CreateStageCommand({ ApiId: apiId, StageName: stage, DeploymentId: deploy.DeploymentId! }));
|
||||
return apiId;
|
||||
}
|
||||
|
||||
// ── #1846: @connections Management API via subdomain host ──────────────────
|
||||
|
||||
it('routes @connections via the subdomain host to the API (not S3): unknown connection -> GoneException', async () => {
|
||||
const stage = 'prod';
|
||||
const apiId = await createWsApiWithStage('subdomain-conn', REGION, stage);
|
||||
const mgmt = subdomainManagementClient(apiId, stage, REGION);
|
||||
|
||||
// A GET for a non-existent connection must reach the API Gateway controller and
|
||||
// return GoneException (410). Before the fix the S3 controller intercepted this
|
||||
// host, so it would NOT surface as GoneException.
|
||||
await expect(
|
||||
mgmt.send(new GetConnectionCommand({ ConnectionId: 'does-not-exist' }))
|
||||
).rejects.toMatchObject({ name: 'GoneException' });
|
||||
|
||||
await expect(
|
||||
mgmt.send(new DeleteConnectionCommand({ ConnectionId: 'does-not-exist' }))
|
||||
).rejects.toMatchObject({ name: 'GoneException' });
|
||||
|
||||
await expect(
|
||||
mgmt.send(new PostToConnectionCommand({ ConnectionId: 'does-not-exist', Data: Buffer.from('hi') }))
|
||||
).rejects.toMatchObject({ name: 'GoneException' });
|
||||
});
|
||||
|
||||
it('routes @connections through the region-bearing configured hostname (not S3)', async () => {
|
||||
const region = 'ap-northeast-2';
|
||||
const stage = 'prod';
|
||||
const apiId = await createWsApiWithStage('configured-host-conn', region, stage);
|
||||
const host = configuredHostnameHost(apiId, region);
|
||||
const mgmt = managementClientForHost(host, stage, region);
|
||||
|
||||
// The Node compatibility container connects to the Compose service at floci:4566, while
|
||||
// FLOCI_HOSTNAME=floci. Present the AWS-shaped region-bearing virtual host through the SDK's
|
||||
// signed Host header so this proves configured-host routing without wildcard DNS.
|
||||
await expect(
|
||||
mgmt.send(new GetConnectionCommand({ ConnectionId: 'does-not-exist' }))
|
||||
).rejects.toMatchObject({ name: 'GoneException' });
|
||||
});
|
||||
|
||||
it('delivers a message to a live connection via the subdomain PostToConnection endpoint', async () => {
|
||||
const stage = 'prod';
|
||||
const region = REGION;
|
||||
const gw = makeClient(ApiGatewayV2Client, { region });
|
||||
const fnName = uniqueName('subdomain-echo');
|
||||
await lambda.send(new CreateFunctionCommand({
|
||||
FunctionName: fnName,
|
||||
Runtime: 'nodejs22.x',
|
||||
Role: ROLE_ARN,
|
||||
Handler: 'index.handler',
|
||||
Code: { ZipFile: buildMinimalZip('index.js', Buffer.from(ECHO_HANDLER)) },
|
||||
}));
|
||||
createdFunctions.push(fnName);
|
||||
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('subdomain-live'),
|
||||
ProtocolType: 'WEBSOCKET',
|
||||
RouteSelectionExpression: '$request.body.action',
|
||||
}));
|
||||
const apiId = res.ApiId!;
|
||||
createdApis.push({ apiId, region });
|
||||
const integ = await gw.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'AWS_PROXY',
|
||||
IntegrationUri: `arn:aws:lambda:${region}:${ACCOUNT}:function:${fnName}`,
|
||||
}));
|
||||
// $default with a route response so the echo reply is delivered back to the
|
||||
// client (matches the data-plane suite; without a route response no frame is sent).
|
||||
const route = await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${integ.IntegrationId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
await gw.send(new CreateRouteResponseCommand({
|
||||
ApiId: apiId,
|
||||
RouteId: route.RouteId!,
|
||||
RouteResponseKey: '$default',
|
||||
}));
|
||||
const deploy = await gw.send(new CreateDeploymentCommand({ ApiId: apiId }));
|
||||
await gw.send(new CreateStageCommand({ ApiId: apiId, StageName: stage, DeploymentId: deploy.DeploymentId! }));
|
||||
|
||||
const ws = await connectWsViaSubdomain(apiId, stage);
|
||||
try {
|
||||
// Learn our own connectionId via the echo integration (first invoke is a
|
||||
// Lambda cold start, so allow extra headroom here).
|
||||
ws.send(JSON.stringify({ action: 'getConnectionId' }));
|
||||
const idResponse = await waitForMessage(ws, 15000);
|
||||
const connectionId = JSON.parse(idResponse).connectionId as string;
|
||||
expect(connectionId).toBeTruthy();
|
||||
|
||||
// Push a server-initiated message through the subdomain Management endpoint.
|
||||
const mgmt = subdomainManagementClient(apiId, stage, region);
|
||||
const pushed = waitForMessage(ws);
|
||||
await mgmt.send(new PostToConnectionCommand({ ConnectionId: connectionId, Data: Buffer.from('via-subdomain') }));
|
||||
expect(await pushed).toBe('via-subdomain');
|
||||
} finally {
|
||||
if (ws.readyState !== WebSocket.CLOSED) ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ── #1871: WebSocket $connect via subdomain host resolves region ───────────
|
||||
|
||||
it('connects a WebSocket in a non-default region via the subdomain host (no 403)', async () => {
|
||||
const region = 'ap-northeast-2';
|
||||
const stage = 'prod';
|
||||
const apiId = await createWsApiWithStage('subdomain-region', region, stage);
|
||||
|
||||
// The execute-api host (regionless built-in suffix) carries no region and a handshake has no
|
||||
// Authorization header, so the API is found by a cross-region apiId lookup. Before the fix a
|
||||
// connect to a non-default-region WS API returned 403.
|
||||
const ws = await connectWsViaSubdomain(apiId, stage);
|
||||
try {
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
} finally {
|
||||
if (ws.readyState !== WebSocket.CLOSED) ws.close();
|
||||
}
|
||||
|
||||
// Sanity: the region-less PATH form (/ws/{apiId}/{stage}) resolves only the default region and
|
||||
// must NOT connect to this ap-northeast-2 API — confirming the execute-api host is what routed it.
|
||||
await expect(new Promise<WebSocket>((resolve, reject) => {
|
||||
const bad = new WebSocket(`ws://${authority()}/ws/${apiId}/${stage}`);
|
||||
bad.on('open', () => resolve(bad));
|
||||
bad.on('error', (err) => reject(err));
|
||||
})).rejects.toBeTruthy();
|
||||
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
// ── Regionless built-in suffix must not be mis-parsed as region "localhost" ──
|
||||
|
||||
it('routes @connections via the regionless built-in suffix host for a non-default-region API', async () => {
|
||||
const region = 'ap-northeast-2';
|
||||
const stage = 'prod';
|
||||
const apiId = await createWsApiWithStage('builtin-suffix', region, stage);
|
||||
|
||||
// Host is {apiId}.execute-api.localhost.floci.io:4566 — NO region label. The old parser
|
||||
// read "localhost" as the region and the lookup missed; the consolidated filter resolves
|
||||
// the API by id across regions and still routes @connections to the controller (410 Gone),
|
||||
// not S3.
|
||||
const mgmt = managementClientForHost(builtinSuffixHost(apiId), stage, region);
|
||||
await expect(
|
||||
mgmt.send(new GetConnectionCommand({ ConnectionId: 'does-not-exist' }))
|
||||
).rejects.toMatchObject({ name: 'GoneException' });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* API Gateway v2 WebSocket over TLS compatibility tests.
|
||||
*
|
||||
* Validates WebSocket functionality over WSS (TLS): connect, send, receive,
|
||||
* disconnect, route selection, @connections API, and server-initiated close —
|
||||
* mirroring the plain-text WebSocket tests but using the HTTPS/WSS endpoint.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Floci must be started with FLOCI_TLS_ENABLED=true
|
||||
* - NODE_TLS_REJECT_UNAUTHORIZED=0 must be set in the test environment
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
ApiGatewayV2Client,
|
||||
CreateApiCommand,
|
||||
DeleteApiCommand,
|
||||
CreateRouteCommand,
|
||||
CreateIntegrationCommand,
|
||||
CreateStageCommand,
|
||||
CreateAuthorizerCommand,
|
||||
CreateDeploymentCommand,
|
||||
} from '@aws-sdk/client-apigatewayv2';
|
||||
import {
|
||||
ApiGatewayManagementApiClient,
|
||||
PostToConnectionCommand,
|
||||
GetConnectionCommand,
|
||||
DeleteConnectionCommand,
|
||||
} from '@aws-sdk/client-apigatewaymanagementapi';
|
||||
import {
|
||||
LambdaClient,
|
||||
CreateFunctionCommand,
|
||||
DeleteFunctionCommand,
|
||||
} from '@aws-sdk/client-lambda';
|
||||
import WebSocket from 'ws';
|
||||
import { uniqueName, ENDPOINT, REGION, ACCOUNT, CREDS, buildMinimalZip, buildBundledZip, sleep } from './setup';
|
||||
|
||||
// ── TLS Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const HTTPS_ENDPOINT = ENDPOINT.replace(/^http:\/\//, 'https://');
|
||||
|
||||
const TLS_CLIENT_CONFIG = {
|
||||
endpoint: HTTPS_ENDPOINT,
|
||||
region: REGION,
|
||||
credentials: CREDS,
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
function makeTlsClient<T>(
|
||||
ClientClass: new (config: typeof TLS_CLIENT_CONFIG) => T,
|
||||
extra: Record<string, unknown> = {},
|
||||
): T {
|
||||
return new ClientClass({ ...TLS_CLIENT_CONFIG, ...extra });
|
||||
}
|
||||
|
||||
function wssUrl(apiId: string, stage: string): string {
|
||||
// Convert http endpoint to wss — strip scheme and trailing slashes
|
||||
const host = ENDPOINT.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
return `wss://${host}/ws/${apiId}/${stage}`;
|
||||
}
|
||||
|
||||
function managementClientTls(apiId: string, stage: string): ApiGatewayManagementApiClient {
|
||||
return makeTlsClient(ApiGatewayManagementApiClient, {
|
||||
endpoint: `${HTTPS_ENDPOINT}/execute-api/${apiId}/${stage}`,
|
||||
});
|
||||
}
|
||||
|
||||
function connectWss(url: string): Promise<WebSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, { rejectUnauthorized: false });
|
||||
ws.on('open', () => resolve(ws));
|
||||
ws.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMessage(ws: WebSocket, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timeout waiting for message')), timeoutMs);
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timer);
|
||||
resolve(data.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForClose(ws: WebSocket, timeoutMs = 5000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (ws.readyState === WebSocket.CLOSED) { resolve(); return; }
|
||||
const timer = setTimeout(() => reject(new Error('Timeout waiting for close')), timeoutMs);
|
||||
ws.once('close', () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
const ROLE_ARN = `arn:aws:iam::${ACCOUNT}:role/lambda-role`;
|
||||
|
||||
// ── Lambda handler source code ───────────────────────────────────────────────
|
||||
|
||||
const ECHO_HANDLER = `
|
||||
exports.handler = async (event) => {
|
||||
const body = event.body || '';
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.action === 'getConnectionId') {
|
||||
return { statusCode: 200, body: JSON.stringify({ connectionId: event.requestContext.connectionId }) };
|
||||
}
|
||||
} catch (e) {}
|
||||
return { statusCode: 200, body: body || 'echo' };
|
||||
};
|
||||
`;
|
||||
|
||||
const PING_HANDLER = `
|
||||
exports.handler = async (event) => {
|
||||
return { statusCode: 200, body: 'pong' };
|
||||
};
|
||||
`;
|
||||
|
||||
const SEND_MESSAGE_HANDLER = `
|
||||
exports.handler = async (event) => {
|
||||
const body = JSON.parse(event.body);
|
||||
return { statusCode: 200, body: 'received: ' + body.data };
|
||||
};
|
||||
`;
|
||||
|
||||
const DEFAULT_HANDLER = `
|
||||
exports.handler = async (event) => {
|
||||
return { statusCode: 200, body: 'default-route' };
|
||||
};
|
||||
`;
|
||||
|
||||
const BROADCAST_HANDLER = `
|
||||
const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require('@aws-sdk/client-apigatewaymanagementapi');
|
||||
exports.handler = async (event) => {
|
||||
const apiId = event.requestContext.apiId;
|
||||
const stage = event.requestContext.stage;
|
||||
const endpoint = process.env.FLOCI_ENDPOINT + '/execute-api/' + apiId + '/' + stage;
|
||||
const client = new ApiGatewayManagementApiClient({ endpoint, region: 'us-east-1' });
|
||||
const body = JSON.parse(event.body);
|
||||
for (const connId of body.targets) {
|
||||
await client.send(new PostToConnectionCommand({
|
||||
ConnectionId: connId,
|
||||
Data: Buffer.from(body.message),
|
||||
}));
|
||||
}
|
||||
return { statusCode: 200 };
|
||||
};
|
||||
`;
|
||||
|
||||
const AUTHORIZER_HANDLER = `
|
||||
exports.handler = async (event) => {
|
||||
const token = event.queryStringParameters && event.queryStringParameters.token;
|
||||
const effect = token === 'allow' ? 'Allow' : 'Deny';
|
||||
return {
|
||||
principalId: 'user',
|
||||
policyDocument: {
|
||||
Version: '2012-10-17',
|
||||
Statement: [{ Action: 'execute-api:Invoke', Effect: effect, Resource: event.methodArn || '*' }],
|
||||
},
|
||||
};
|
||||
};
|
||||
`;
|
||||
|
||||
// ── Helper to get connection ID ──────────────────────────────────────────────
|
||||
|
||||
async function getConnectionIdFromServer(ws: WebSocket): Promise<string | null> {
|
||||
ws.send(JSON.stringify({ action: 'getConnectionId' }));
|
||||
try {
|
||||
const response = await waitForMessage(ws, 3000);
|
||||
const parsed = JSON.parse(response);
|
||||
return parsed.connectionId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test suites ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('WebSocket over TLS (WSS)', () => {
|
||||
let gw: ApiGatewayV2Client;
|
||||
let lambda: LambdaClient;
|
||||
|
||||
const createdFunctions: string[] = [];
|
||||
const createdApis: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
gw = makeTlsClient(ApiGatewayV2Client);
|
||||
lambda = makeTlsClient(LambdaClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const fnName of createdFunctions) {
|
||||
try { await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName })); } catch { /* ignore */ }
|
||||
}
|
||||
for (const apiId of createdApis) {
|
||||
try { await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
async function createLambda(name: string, code: string, environment?: Record<string, string>, bundledZip?: boolean): Promise<string> {
|
||||
const fnName = uniqueName(name);
|
||||
const zipBuffer = bundledZip ? buildBundledZip(code) : buildMinimalZip('index.js', Buffer.from(code));
|
||||
await lambda.send(new CreateFunctionCommand({
|
||||
FunctionName: fnName,
|
||||
Runtime: 'nodejs22.x',
|
||||
Role: ROLE_ARN,
|
||||
Handler: 'index.handler',
|
||||
Code: { ZipFile: zipBuffer },
|
||||
...(environment && { Environment: { Variables: environment } }),
|
||||
}));
|
||||
createdFunctions.push(fnName);
|
||||
return fnName;
|
||||
}
|
||||
|
||||
async function createWsApi(name: string): Promise<string> {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName(name),
|
||||
ProtocolType: 'WEBSOCKET',
|
||||
RouteSelectionExpression: '$request.body.action',
|
||||
}));
|
||||
createdApis.push(res.ApiId!);
|
||||
return res.ApiId!;
|
||||
}
|
||||
|
||||
async function createLambdaIntegration(apiId: string, fnName: string): Promise<string> {
|
||||
const res = await gw.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'AWS_PROXY',
|
||||
IntegrationUri: `arn:aws:lambda:${REGION}:${ACCOUNT}:function:${fnName}`,
|
||||
}));
|
||||
return res.IntegrationId!;
|
||||
}
|
||||
|
||||
async function setupStage(apiId: string, stageName: string): Promise<void> {
|
||||
const deploy = await gw.send(new CreateDeploymentCommand({ ApiId: apiId }));
|
||||
await gw.send(new CreateStageCommand({
|
||||
ApiId: apiId,
|
||||
StageName: stageName,
|
||||
DeploymentId: deploy.DeploymentId!,
|
||||
}));
|
||||
}
|
||||
|
||||
// ──────────────────────────── Basic WSS flow ────────────────────────────
|
||||
|
||||
describe('Basic WSS connection flow', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const echoFn = await createLambda('tls-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-basic');
|
||||
|
||||
const integId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${integId}`,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${integId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should connect over WSS, send message, receive response, and disconnect', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
ws.send(JSON.stringify({ action: 'test', body: 'hello-tls' }));
|
||||
const response = await waitForMessage(ws);
|
||||
expect(response).toBeTruthy();
|
||||
ws.close();
|
||||
await waitForClose(ws);
|
||||
} finally {
|
||||
if (ws.readyState !== WebSocket.CLOSED) ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should establish WSS connection with valid readyState', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Chat-style broadcast over WSS ────────────────────────────
|
||||
|
||||
describe('Chat-style broadcast over WSS', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const broadcastFn = await createLambda('tls-broadcast', BROADCAST_HANDLER, {
|
||||
FLOCI_ENDPOINT: 'https://host.docker.internal:4566',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0',
|
||||
}, true);
|
||||
const echoFn = await createLambda('tls-bc-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-broadcast');
|
||||
|
||||
const broadcastIntegId = await createLambdaIntegration(apiId, broadcastFn);
|
||||
const echoIntegId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${echoIntegId}`,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${echoIntegId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: 'broadcast',
|
||||
Target: `integrations/${broadcastIntegId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should broadcast message to multiple WSS clients', async () => {
|
||||
const ws1 = await connectWss(wssUrl(apiId, 'test'));
|
||||
const ws2 = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
await sleep(200);
|
||||
|
||||
const connId1 = await getConnectionIdFromServer(ws1);
|
||||
const connId2 = await getConnectionIdFromServer(ws2);
|
||||
|
||||
expect(connId1).toBeTruthy();
|
||||
expect(connId2).toBeTruthy();
|
||||
|
||||
const msg1Promise = waitForMessage(ws1);
|
||||
const msg2Promise = waitForMessage(ws2);
|
||||
|
||||
ws1.send(JSON.stringify({
|
||||
action: 'broadcast',
|
||||
targets: [connId1, connId2],
|
||||
message: 'hello-all-tls',
|
||||
}));
|
||||
|
||||
const [r1, r2] = await Promise.all([msg1Promise, msg2Promise]);
|
||||
expect(r1).toBe('hello-all-tls');
|
||||
expect(r2).toBe('hello-all-tls');
|
||||
} finally {
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── $connect authorization over WSS ────────────────────────────
|
||||
|
||||
describe('$connect authorization over WSS', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const authFn = await createLambda('tls-authorizer', AUTHORIZER_HANDLER);
|
||||
const echoFn = await createLambda('tls-auth-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-auth');
|
||||
|
||||
const echoIntegId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
const authRes = await gw.send(new CreateAuthorizerCommand({
|
||||
ApiId: apiId,
|
||||
AuthorizerType: 'REQUEST',
|
||||
Name: 'wss-auth',
|
||||
AuthorizerUri: `arn:aws:lambda:${REGION}:${ACCOUNT}:function:${authFn}`,
|
||||
IdentitySource: ['route.request.querystring.token'],
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${echoIntegId}`,
|
||||
AuthorizationType: 'CUSTOM',
|
||||
AuthorizerId: authRes.AuthorizerId!,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${echoIntegId}`,
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should allow WSS connection with valid token', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test') + '?token=allow');
|
||||
try {
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should deny WSS connection with invalid token', async () => {
|
||||
await expect(connectWss(wssUrl(apiId, 'test') + '?token=deny')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Route selection over WSS ────────────────────────────
|
||||
|
||||
describe('Route selection over WSS', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const pingFn = await createLambda('tls-ping', PING_HANDLER);
|
||||
const sendMsgFn = await createLambda('tls-sendmsg', SEND_MESSAGE_HANDLER);
|
||||
const defaultFn = await createLambda('tls-default', DEFAULT_HANDLER);
|
||||
const echoFn = await createLambda('tls-rs-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-route-sel');
|
||||
|
||||
const pingIntegId = await createLambdaIntegration(apiId, pingFn);
|
||||
const sendMsgIntegId = await createLambdaIntegration(apiId, sendMsgFn);
|
||||
const defaultIntegId = await createLambdaIntegration(apiId, defaultFn);
|
||||
const echoIntegId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${echoIntegId}`,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: 'ping',
|
||||
Target: `integrations/${pingIntegId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: 'sendMessage',
|
||||
Target: `integrations/${sendMsgIntegId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${defaultIntegId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should route "ping" action over WSS', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
ws.send(JSON.stringify({ action: 'ping' }));
|
||||
const response = await waitForMessage(ws);
|
||||
expect(response).toBe('pong');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should route "sendMessage" action over WSS', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
ws.send(JSON.stringify({ action: 'sendMessage', data: 'tls-data' }));
|
||||
const response = await waitForMessage(ws);
|
||||
expect(response).toBe('received: tls-data');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should route unknown action to $default over WSS', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
ws.send(JSON.stringify({ action: 'unknownAction' }));
|
||||
const response = await waitForMessage(ws);
|
||||
expect(response).toBe('default-route');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── @connections API over TLS ────────────────────────────
|
||||
|
||||
describe('@connections API over TLS', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const echoFn = await createLambda('tls-conn-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-connections');
|
||||
|
||||
const integId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${integId}`,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${integId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should POST message to a WSS connection via @connections', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
await sleep(200);
|
||||
const connId = await getConnectionIdFromServer(ws);
|
||||
expect(connId).toBeTruthy();
|
||||
|
||||
const mgmt = managementClientTls(apiId, 'test');
|
||||
const msgPromise = waitForMessage(ws);
|
||||
|
||||
await mgmt.send(new PostToConnectionCommand({
|
||||
ConnectionId: connId!,
|
||||
Data: Buffer.from('tls-server-push'),
|
||||
}));
|
||||
|
||||
const received = await msgPromise;
|
||||
expect(received).toBe('tls-server-push');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should GET connection info over TLS', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
await sleep(200);
|
||||
const connId = await getConnectionIdFromServer(ws);
|
||||
expect(connId).toBeTruthy();
|
||||
|
||||
const mgmt = managementClientTls(apiId, 'test');
|
||||
const info = await mgmt.send(new GetConnectionCommand({
|
||||
ConnectionId: connId!,
|
||||
}));
|
||||
|
||||
expect(info.ConnectedAt).toBeDefined();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should DELETE (disconnect) a WSS connection', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
await sleep(200);
|
||||
const connId = await getConnectionIdFromServer(ws);
|
||||
expect(connId).toBeTruthy();
|
||||
|
||||
const mgmt = managementClientTls(apiId, 'test');
|
||||
await mgmt.send(new DeleteConnectionCommand({
|
||||
ConnectionId: connId!,
|
||||
}));
|
||||
|
||||
await waitForClose(ws);
|
||||
expect(ws.readyState).toBe(WebSocket.CLOSED);
|
||||
} finally {
|
||||
if (ws.readyState !== WebSocket.CLOSED) ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Disconnect cleanup over WSS ────────────────────────────
|
||||
|
||||
describe('Disconnect cleanup over WSS', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const echoFn = await createLambda('tls-dc-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-disconnect');
|
||||
|
||||
const integId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${integId}`,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${integId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should return 410 when posting to a disconnected WSS connection', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
await sleep(200);
|
||||
const connId = await getConnectionIdFromServer(ws);
|
||||
expect(connId).toBeTruthy();
|
||||
|
||||
ws.close();
|
||||
await waitForClose(ws);
|
||||
await sleep(300);
|
||||
|
||||
const mgmt = managementClientTls(apiId, 'test');
|
||||
await expect(
|
||||
mgmt.send(new PostToConnectionCommand({
|
||||
ConnectionId: connId!,
|
||||
Data: Buffer.from('should-fail'),
|
||||
}))
|
||||
).rejects.toThrow(/410|Gone/);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Server-initiated close over WSS ────────────────────────────
|
||||
|
||||
describe('Server-initiated close via DELETE over WSS', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const echoFn = await createLambda('tls-del-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-server-close');
|
||||
|
||||
const integId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${integId}`,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${integId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should disconnect WSS client and return 410 on subsequent POST', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
await sleep(200);
|
||||
const connId = await getConnectionIdFromServer(ws);
|
||||
expect(connId).toBeTruthy();
|
||||
|
||||
const mgmt = managementClientTls(apiId, 'test');
|
||||
await mgmt.send(new DeleteConnectionCommand({
|
||||
ConnectionId: connId!,
|
||||
}));
|
||||
|
||||
await waitForClose(ws);
|
||||
await sleep(300);
|
||||
|
||||
await expect(
|
||||
mgmt.send(new PostToConnectionCommand({
|
||||
ConnectionId: connId!,
|
||||
Data: Buffer.from('should-fail'),
|
||||
}))
|
||||
).rejects.toThrow(/410|Gone/);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Binary frames over WSS ────────────────────────────
|
||||
|
||||
describe('Binary frame support over WSS', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const echoFn = await createLambda('tls-bin-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-binary');
|
||||
|
||||
const integId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${integId}`,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${integId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should handle binary frames over WSS', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
const binaryData = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
ws.send(binaryData);
|
||||
|
||||
const response = await waitForMessage(ws);
|
||||
expect(response).toBeTruthy();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Payload size limit over WSS ────────────────────────────
|
||||
|
||||
describe('Payload size limit over WSS', () => {
|
||||
let apiId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const echoFn = await createLambda('tls-pl-echo', ECHO_HANDLER);
|
||||
apiId = await createWsApi('tls-payload');
|
||||
|
||||
const integId = await createLambdaIntegration(apiId, echoFn);
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$connect',
|
||||
Target: `integrations/${integId}`,
|
||||
}));
|
||||
|
||||
await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
Target: `integrations/${integId}`,
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
|
||||
await setupStage(apiId, 'test');
|
||||
});
|
||||
|
||||
it('should reject messages exceeding 128 KB over WSS', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
const oversizeMessage = 'x'.repeat(128 * 1024 + 1);
|
||||
ws.send(oversizeMessage);
|
||||
const response = await waitForMessage(ws);
|
||||
expect(response).toContain('Message too long');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept messages at exactly 128 KB over WSS', async () => {
|
||||
const ws = await connectWss(wssUrl(apiId, 'test'));
|
||||
try {
|
||||
const maxMessage = JSON.stringify({ action: 'test', data: 'x'.repeat(128 * 1024 - 50) });
|
||||
if (maxMessage.length <= 128 * 1024) {
|
||||
ws.send(maxMessage);
|
||||
const response = await waitForMessage(ws);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).not.toContain('Message too long');
|
||||
}
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* API Gateway v2 (HTTP & WebSocket APIs) compatibility tests.
|
||||
*
|
||||
* Validates management-plane CRUD for APIs, Routes, Integrations, Authorizers,
|
||||
* Stages, Deployments, Route Responses, Integration Responses, Models, and Tags
|
||||
* using the AWS SDK v3 ApiGatewayV2Client — the same client real applications use.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
ApiGatewayV2Client,
|
||||
CreateApiCommand,
|
||||
GetApiCommand,
|
||||
GetApisCommand,
|
||||
UpdateApiCommand,
|
||||
DeleteApiCommand,
|
||||
CreateRouteCommand,
|
||||
GetRouteCommand,
|
||||
GetRoutesCommand,
|
||||
UpdateRouteCommand,
|
||||
DeleteRouteCommand,
|
||||
CreateIntegrationCommand,
|
||||
GetIntegrationCommand,
|
||||
GetIntegrationsCommand,
|
||||
UpdateIntegrationCommand,
|
||||
DeleteIntegrationCommand,
|
||||
CreateAuthorizerCommand,
|
||||
GetAuthorizerCommand,
|
||||
GetAuthorizersCommand,
|
||||
UpdateAuthorizerCommand,
|
||||
DeleteAuthorizerCommand,
|
||||
CreateStageCommand,
|
||||
GetStageCommand,
|
||||
GetStagesCommand,
|
||||
UpdateStageCommand,
|
||||
DeleteStageCommand,
|
||||
CreateDeploymentCommand,
|
||||
GetDeploymentCommand,
|
||||
GetDeploymentsCommand,
|
||||
UpdateDeploymentCommand,
|
||||
DeleteDeploymentCommand,
|
||||
CreateRouteResponseCommand,
|
||||
GetRouteResponseCommand,
|
||||
GetRouteResponsesCommand,
|
||||
UpdateRouteResponseCommand,
|
||||
DeleteRouteResponseCommand,
|
||||
CreateModelCommand,
|
||||
GetModelCommand,
|
||||
GetModelsCommand,
|
||||
UpdateModelCommand,
|
||||
DeleteModelCommand,
|
||||
TagResourceCommand,
|
||||
UntagResourceCommand,
|
||||
GetTagsCommand,
|
||||
} from '@aws-sdk/client-apigatewayv2';
|
||||
import { makeClient, uniqueName, REGION, ACCOUNT } from './setup';
|
||||
|
||||
describe('API Gateway v2', () => {
|
||||
let gw: ApiGatewayV2Client;
|
||||
|
||||
beforeAll(() => {
|
||||
gw = makeClient(ApiGatewayV2Client);
|
||||
});
|
||||
|
||||
// ──────────────────────────── HTTP API lifecycle ────────────────────────────
|
||||
|
||||
describe('HTTP API lifecycle', () => {
|
||||
let apiId: string;
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (apiId) await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create an HTTP API with correct defaults', async () => {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('http-api'),
|
||||
ProtocolType: 'HTTP',
|
||||
}));
|
||||
apiId = res.ApiId!;
|
||||
expect(apiId).toBeTruthy();
|
||||
expect(res.ProtocolType).toBe('HTTP');
|
||||
expect(res.ApiEndpoint).toContain('https://');
|
||||
expect(res.RouteSelectionExpression).toBe('${request.method} ${request.path}');
|
||||
expect(res.ApiKeySelectionExpression).toBe('$request.header.x-api-key');
|
||||
});
|
||||
|
||||
it('should get the API', async () => {
|
||||
const res = await gw.send(new GetApiCommand({ ApiId: apiId }));
|
||||
expect(res.ApiId).toBe(apiId);
|
||||
expect(res.ProtocolType).toBe('HTTP');
|
||||
expect(res.RouteSelectionExpression).toBe('${request.method} ${request.path}');
|
||||
expect(res.ApiKeySelectionExpression).toBe('$request.header.x-api-key');
|
||||
});
|
||||
|
||||
it('should list APIs including the created one', async () => {
|
||||
const res = await gw.send(new GetApisCommand({}));
|
||||
expect(res.Items!.some(a => a.ApiId === apiId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update the API', async () => {
|
||||
const res = await gw.send(new UpdateApiCommand({
|
||||
ApiId: apiId,
|
||||
Description: 'updated-description',
|
||||
}));
|
||||
expect(res.Description).toBe('updated-description');
|
||||
expect(res.ProtocolType).toBe('HTTP');
|
||||
});
|
||||
|
||||
it('should delete the API', async () => {
|
||||
await gw.send(new DeleteApiCommand({ ApiId: apiId }));
|
||||
await expect(gw.send(new GetApiCommand({ ApiId: apiId }))).rejects.toThrow();
|
||||
apiId = '';
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── WebSocket API lifecycle ────────────────────────────
|
||||
|
||||
describe('WebSocket API lifecycle', () => {
|
||||
let wsApiId: string;
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (wsApiId) await gw.send(new DeleteApiCommand({ ApiId: wsApiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create a WebSocket API', async () => {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('ws-api'),
|
||||
ProtocolType: 'WEBSOCKET',
|
||||
RouteSelectionExpression: '$request.body.action',
|
||||
Description: 'WS compat test',
|
||||
ApiKeySelectionExpression: '$request.header.x-api-key',
|
||||
}));
|
||||
wsApiId = res.ApiId!;
|
||||
expect(wsApiId).toBeTruthy();
|
||||
expect(res.ProtocolType).toBe('WEBSOCKET');
|
||||
expect(res.ApiEndpoint).toContain('wss://');
|
||||
expect(res.RouteSelectionExpression).toBe('$request.body.action');
|
||||
expect(res.Description).toBe('WS compat test');
|
||||
});
|
||||
|
||||
it('should reject WebSocket API without RouteSelectionExpression', async () => {
|
||||
await expect(gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('ws-no-rse'),
|
||||
ProtocolType: 'WEBSOCKET',
|
||||
}))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should update a WebSocket API preserving unmodified fields', async () => {
|
||||
const res = await gw.send(new UpdateApiCommand({
|
||||
ApiId: wsApiId,
|
||||
Name: 'ws-updated',
|
||||
}));
|
||||
expect(res.Name).toBe('ws-updated');
|
||||
expect(res.ProtocolType).toBe('WEBSOCKET');
|
||||
expect(res.RouteSelectionExpression).toBe('$request.body.action');
|
||||
expect(res.ApiEndpoint).toContain('wss://');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Routes ────────────────────────────
|
||||
|
||||
describe('Routes', () => {
|
||||
let apiId: string;
|
||||
let routeId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('route-test'),
|
||||
ProtocolType: 'WEBSOCKET',
|
||||
RouteSelectionExpression: '$request.body.action',
|
||||
}));
|
||||
apiId = res.ApiId!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create a route with routeResponseSelectionExpression', async () => {
|
||||
const res = await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
AuthorizationType: 'NONE',
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
routeId = res.RouteId!;
|
||||
expect(routeId).toBeTruthy();
|
||||
expect(res.RouteKey).toBe('$default');
|
||||
expect(res.RouteResponseSelectionExpression).toBe('$default');
|
||||
});
|
||||
|
||||
it('should get the route', async () => {
|
||||
const res = await gw.send(new GetRouteCommand({ ApiId: apiId, RouteId: routeId }));
|
||||
expect(res.RouteId).toBe(routeId);
|
||||
expect(res.RouteResponseSelectionExpression).toBe('$default');
|
||||
});
|
||||
|
||||
it('should list routes', async () => {
|
||||
const res = await gw.send(new GetRoutesCommand({ ApiId: apiId }));
|
||||
expect(res.Items!.some(r => r.RouteId === routeId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update a route preserving unmodified fields', async () => {
|
||||
const res = await gw.send(new UpdateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteId: routeId,
|
||||
Target: 'integrations/fake-id',
|
||||
}));
|
||||
expect(res.Target).toBe('integrations/fake-id');
|
||||
expect(res.RouteKey).toBe('$default');
|
||||
expect(res.RouteResponseSelectionExpression).toBe('$default');
|
||||
});
|
||||
|
||||
it('should delete the route', async () => {
|
||||
await gw.send(new DeleteRouteCommand({ ApiId: apiId, RouteId: routeId }));
|
||||
await expect(gw.send(new GetRouteCommand({ ApiId: apiId, RouteId: routeId }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Integrations ────────────────────────────
|
||||
|
||||
describe('Integrations', () => {
|
||||
let apiId: string;
|
||||
let integrationId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('integ-test'),
|
||||
ProtocolType: 'HTTP',
|
||||
}));
|
||||
apiId = res.ApiId!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create an integration', async () => {
|
||||
const res = await gw.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'HTTP_PROXY',
|
||||
IntegrationUri: 'https://example.com',
|
||||
PayloadFormatVersion: '2.0',
|
||||
}));
|
||||
integrationId = res.IntegrationId!;
|
||||
expect(integrationId).toBeTruthy();
|
||||
expect(res.IntegrationType).toBe('HTTP_PROXY');
|
||||
});
|
||||
|
||||
it('should get the integration', async () => {
|
||||
const res = await gw.send(new GetIntegrationCommand({ ApiId: apiId, IntegrationId: integrationId }));
|
||||
expect(res.IntegrationId).toBe(integrationId);
|
||||
expect(res.IntegrationUri).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('should list integrations', async () => {
|
||||
const res = await gw.send(new GetIntegrationsCommand({ ApiId: apiId }));
|
||||
expect(res.Items!.some(i => i.IntegrationId === integrationId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update an integration preserving unmodified fields', async () => {
|
||||
const res = await gw.send(new UpdateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationId: integrationId,
|
||||
IntegrationUri: 'https://updated.example.com',
|
||||
}));
|
||||
expect(res.IntegrationUri).toBe('https://updated.example.com');
|
||||
expect(res.IntegrationType).toBe('HTTP_PROXY');
|
||||
expect(res.PayloadFormatVersion).toBe('2.0');
|
||||
});
|
||||
|
||||
it('should delete the integration', async () => {
|
||||
await gw.send(new DeleteIntegrationCommand({ ApiId: apiId, IntegrationId: integrationId }));
|
||||
await expect(gw.send(new GetIntegrationCommand({ ApiId: apiId, IntegrationId: integrationId }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Authorizers ────────────────────────────
|
||||
|
||||
describe('Authorizers', () => {
|
||||
let apiId: string;
|
||||
let authorizerId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('auth-test'),
|
||||
ProtocolType: 'HTTP',
|
||||
}));
|
||||
apiId = res.ApiId!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create a JWT authorizer', async () => {
|
||||
const res = await gw.send(new CreateAuthorizerCommand({
|
||||
ApiId: apiId,
|
||||
AuthorizerType: 'JWT',
|
||||
Name: 'test-jwt-auth',
|
||||
IdentitySource: '$request.header.Authorization',
|
||||
JwtConfiguration: {
|
||||
Issuer: 'https://issuer.example.com',
|
||||
Audience: ['my-audience'],
|
||||
},
|
||||
}));
|
||||
authorizerId = res.AuthorizerId!;
|
||||
expect(authorizerId).toBeTruthy();
|
||||
expect(res.Name).toBe('test-jwt-auth');
|
||||
});
|
||||
|
||||
it('should get the authorizer', async () => {
|
||||
const res = await gw.send(new GetAuthorizerCommand({ ApiId: apiId, AuthorizerId: authorizerId }));
|
||||
expect(res.AuthorizerId).toBe(authorizerId);
|
||||
expect(res.AuthorizerType).toBe('JWT');
|
||||
});
|
||||
|
||||
it('should list authorizers', async () => {
|
||||
const res = await gw.send(new GetAuthorizersCommand({ ApiId: apiId }));
|
||||
expect(res.Items!.some(a => a.AuthorizerId === authorizerId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update an authorizer preserving unmodified fields', async () => {
|
||||
const res = await gw.send(new UpdateAuthorizerCommand({
|
||||
ApiId: apiId,
|
||||
AuthorizerId: authorizerId,
|
||||
Name: 'updated-auth',
|
||||
}));
|
||||
expect(res.Name).toBe('updated-auth');
|
||||
expect(res.AuthorizerType).toBe('JWT');
|
||||
});
|
||||
|
||||
it('should delete the authorizer', async () => {
|
||||
await gw.send(new DeleteAuthorizerCommand({ ApiId: apiId, AuthorizerId: authorizerId }));
|
||||
await expect(gw.send(new GetAuthorizerCommand({ ApiId: apiId, AuthorizerId: authorizerId }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Stages & Deployments ────────────────────────────
|
||||
|
||||
describe('Stages & Deployments', () => {
|
||||
let apiId: string;
|
||||
let deploymentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('stage-test'),
|
||||
ProtocolType: 'HTTP',
|
||||
}));
|
||||
apiId = res.ApiId!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await gw.send(new DeleteStageCommand({ ApiId: apiId, StageName: 'dev' })); } catch { /* ignore */ }
|
||||
try { await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create a deployment', async () => {
|
||||
const res = await gw.send(new CreateDeploymentCommand({
|
||||
ApiId: apiId,
|
||||
Description: 'initial',
|
||||
}));
|
||||
deploymentId = res.DeploymentId!;
|
||||
expect(deploymentId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get the deployment', async () => {
|
||||
const res = await gw.send(new GetDeploymentCommand({ ApiId: apiId, DeploymentId: deploymentId }));
|
||||
expect(res.DeploymentId).toBe(deploymentId);
|
||||
});
|
||||
|
||||
it('should list deployments', async () => {
|
||||
const res = await gw.send(new GetDeploymentsCommand({ ApiId: apiId }));
|
||||
expect(res.Items!.some(d => d.DeploymentId === deploymentId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update a deployment', async () => {
|
||||
const res = await gw.send(new UpdateDeploymentCommand({
|
||||
ApiId: apiId,
|
||||
DeploymentId: deploymentId,
|
||||
Description: 'updated',
|
||||
}));
|
||||
expect(res.Description).toBe('updated');
|
||||
});
|
||||
|
||||
it('should create a stage', async () => {
|
||||
const res = await gw.send(new CreateStageCommand({
|
||||
ApiId: apiId,
|
||||
StageName: 'dev',
|
||||
DeploymentId: deploymentId,
|
||||
AutoDeploy: false,
|
||||
}));
|
||||
expect(res.StageName).toBe('dev');
|
||||
expect(res.DeploymentId).toBe(deploymentId);
|
||||
});
|
||||
|
||||
it('should get the stage', async () => {
|
||||
const res = await gw.send(new GetStageCommand({ ApiId: apiId, StageName: 'dev' }));
|
||||
expect(res.StageName).toBe('dev');
|
||||
});
|
||||
|
||||
it('should list stages', async () => {
|
||||
const res = await gw.send(new GetStagesCommand({ ApiId: apiId }));
|
||||
expect(res.Items!.some(s => s.StageName === 'dev')).toBe(true);
|
||||
});
|
||||
|
||||
it('should update a stage preserving unmodified fields', async () => {
|
||||
const res = await gw.send(new UpdateStageCommand({
|
||||
ApiId: apiId,
|
||||
StageName: 'dev',
|
||||
AutoDeploy: true,
|
||||
}));
|
||||
expect(res.AutoDeploy).toBe(true);
|
||||
expect(res.DeploymentId).toBe(deploymentId);
|
||||
});
|
||||
|
||||
it('should delete the deployment', async () => {
|
||||
await gw.send(new DeleteDeploymentCommand({ ApiId: apiId, DeploymentId: deploymentId }));
|
||||
await expect(gw.send(new GetDeploymentCommand({ ApiId: apiId, DeploymentId: deploymentId }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Route Responses ────────────────────────────
|
||||
|
||||
describe('Route Responses', () => {
|
||||
let apiId: string;
|
||||
let routeId: string;
|
||||
let routeResponseId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const api = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('rr-test'),
|
||||
ProtocolType: 'WEBSOCKET',
|
||||
RouteSelectionExpression: '$request.body.action',
|
||||
}));
|
||||
apiId = api.ApiId!;
|
||||
const route = await gw.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: '$default',
|
||||
RouteResponseSelectionExpression: '$default',
|
||||
}));
|
||||
routeId = route.RouteId!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create a route response', async () => {
|
||||
const res = await gw.send(new CreateRouteResponseCommand({
|
||||
ApiId: apiId,
|
||||
RouteId: routeId,
|
||||
RouteResponseKey: '$default',
|
||||
ModelSelectionExpression: '$default',
|
||||
}));
|
||||
routeResponseId = res.RouteResponseId!;
|
||||
expect(routeResponseId).toBeTruthy();
|
||||
expect(res.RouteResponseKey).toBe('$default');
|
||||
});
|
||||
|
||||
it('should get the route response', async () => {
|
||||
const res = await gw.send(new GetRouteResponseCommand({
|
||||
ApiId: apiId, RouteId: routeId, RouteResponseId: routeResponseId,
|
||||
}));
|
||||
expect(res.RouteResponseId).toBe(routeResponseId);
|
||||
expect(res.RouteResponseKey).toBe('$default');
|
||||
});
|
||||
|
||||
it('should list route responses', async () => {
|
||||
const res = await gw.send(new GetRouteResponsesCommand({ ApiId: apiId, RouteId: routeId }));
|
||||
expect(res.Items!.some(rr => rr.RouteResponseId === routeResponseId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update a route response', async () => {
|
||||
const res = await gw.send(new UpdateRouteResponseCommand({
|
||||
ApiId: apiId,
|
||||
RouteId: routeId,
|
||||
RouteResponseId: routeResponseId,
|
||||
RouteResponseKey: '$updated',
|
||||
}));
|
||||
expect(res.RouteResponseKey).toBe('$updated');
|
||||
});
|
||||
|
||||
it('should delete the route response', async () => {
|
||||
await gw.send(new DeleteRouteResponseCommand({
|
||||
ApiId: apiId, RouteId: routeId, RouteResponseId: routeResponseId,
|
||||
}));
|
||||
await expect(gw.send(new GetRouteResponseCommand({
|
||||
ApiId: apiId, RouteId: routeId, RouteResponseId: routeResponseId,
|
||||
}))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Models ────────────────────────────
|
||||
|
||||
describe('Models', () => {
|
||||
let apiId: string;
|
||||
let modelId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('model-test'),
|
||||
ProtocolType: 'HTTP',
|
||||
}));
|
||||
apiId = res.ApiId!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create a model', async () => {
|
||||
const res = await gw.send(new CreateModelCommand({
|
||||
ApiId: apiId,
|
||||
Name: 'PetModel',
|
||||
Schema: '{"$schema":"http://json-schema.org/draft-04/schema#","title":"Pet","type":"object"}',
|
||||
ContentType: 'application/json',
|
||||
Description: 'A pet schema',
|
||||
}));
|
||||
modelId = res.ModelId!;
|
||||
expect(modelId).toBeTruthy();
|
||||
expect(res.Name).toBe('PetModel');
|
||||
});
|
||||
|
||||
it('should get the model', async () => {
|
||||
const res = await gw.send(new GetModelCommand({ ApiId: apiId, ModelId: modelId }));
|
||||
expect(res.ModelId).toBe(modelId);
|
||||
expect(res.Name).toBe('PetModel');
|
||||
expect(res.ContentType).toBe('application/json');
|
||||
});
|
||||
|
||||
it('should list models', async () => {
|
||||
const res = await gw.send(new GetModelsCommand({ ApiId: apiId }));
|
||||
expect(res.Items!.some(m => m.ModelId === modelId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should update a model preserving unmodified fields', async () => {
|
||||
const res = await gw.send(new UpdateModelCommand({
|
||||
ApiId: apiId,
|
||||
ModelId: modelId,
|
||||
Description: 'updated description',
|
||||
}));
|
||||
expect(res.Description).toBe('updated description');
|
||||
expect(res.Name).toBe('PetModel');
|
||||
expect(res.ContentType).toBe('application/json');
|
||||
});
|
||||
|
||||
it('should delete the model', async () => {
|
||||
await gw.send(new DeleteModelCommand({ ApiId: apiId, ModelId: modelId }));
|
||||
await expect(gw.send(new GetModelCommand({ ApiId: apiId, ModelId: modelId }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Tagging ────────────────────────────
|
||||
|
||||
describe('Tagging', () => {
|
||||
let apiId: string;
|
||||
let apiArn: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('tag-test'),
|
||||
ProtocolType: 'HTTP',
|
||||
Tags: { initial: 'tag' },
|
||||
}));
|
||||
apiId = res.ApiId!;
|
||||
apiArn = `arn:aws:apigateway:${REGION}::/apis/${apiId}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await gw.send(new DeleteApiCommand({ ApiId: apiId })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create API with tags', async () => {
|
||||
const res = await gw.send(new GetApiCommand({ ApiId: apiId }));
|
||||
expect(res.Tags?.initial).toBe('tag');
|
||||
});
|
||||
|
||||
it('should add tags via TagResource', async () => {
|
||||
await gw.send(new TagResourceCommand({
|
||||
ResourceArn: apiArn,
|
||||
Tags: { env: 'production', team: 'platform' },
|
||||
}));
|
||||
const res = await gw.send(new GetTagsCommand({ ResourceArn: apiArn }));
|
||||
expect(res.Tags?.env).toBe('production');
|
||||
expect(res.Tags?.team).toBe('platform');
|
||||
expect(res.Tags?.initial).toBe('tag');
|
||||
});
|
||||
|
||||
it('should remove tags via UntagResource', async () => {
|
||||
await gw.send(new UntagResourceCommand({
|
||||
ResourceArn: apiArn,
|
||||
TagKeys: ['initial', 'team'],
|
||||
}));
|
||||
const res = await gw.send(new GetTagsCommand({ ResourceArn: apiArn }));
|
||||
expect(res.Tags?.env).toBe('production');
|
||||
expect(res.Tags?.initial).toBeUndefined();
|
||||
expect(res.Tags?.team).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return empty tags for untagged API', async () => {
|
||||
const fresh = await gw.send(new CreateApiCommand({
|
||||
Name: uniqueName('no-tags'),
|
||||
ProtocolType: 'HTTP',
|
||||
}));
|
||||
const freshArn = `arn:aws:apigateway:${REGION}::/apis/${fresh.ApiId}`;
|
||||
const res = await gw.send(new GetTagsCommand({ ResourceArn: freshArn }));
|
||||
expect(Object.keys(res.Tags ?? {}).length).toBe(0);
|
||||
await gw.send(new DeleteApiCommand({ ApiId: fresh.ApiId! }));
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────── Not-found errors ────────────────────────────
|
||||
|
||||
describe('Not-found errors', () => {
|
||||
it('should 404 on GetApi with non-existent ID', async () => {
|
||||
await expect(gw.send(new GetApiCommand({ ApiId: 'nonexistent999' }))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should 404 on UpdateApi with non-existent ID', async () => {
|
||||
await expect(gw.send(new UpdateApiCommand({
|
||||
ApiId: 'nonexistent999',
|
||||
Name: 'ghost',
|
||||
}))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* CloudFormation naming integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
CloudFormationClient,
|
||||
CreateStackCommand,
|
||||
DescribeStacksCommand,
|
||||
DescribeStackResourcesCommand,
|
||||
DeleteStackCommand,
|
||||
} from '@aws-sdk/client-cloudformation';
|
||||
import { makeClient, uniqueName, sleep } from './setup';
|
||||
|
||||
describe('CloudFormation Naming', () => {
|
||||
let cfn: CloudFormationClient;
|
||||
let autoStackName: string;
|
||||
let explicitStackName: string;
|
||||
const token = Date.now().toString(36);
|
||||
|
||||
beforeAll(() => {
|
||||
cfn = makeClient(CloudFormationClient);
|
||||
autoStackName = `cfn-auto-naming-${token}`;
|
||||
explicitStackName = `cfn-explicit-naming-${token}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const stackName of [autoStackName, explicitStackName]) {
|
||||
try {
|
||||
await cfn.send(new DeleteStackCommand({ StackName: stackName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForStackTerminalState(stackName: string, expectedSuccess = true) {
|
||||
const successStates = new Set(['CREATE_COMPLETE', 'UPDATE_COMPLETE']);
|
||||
const failureStates = new Set([
|
||||
'CREATE_FAILED',
|
||||
'ROLLBACK_IN_PROGRESS',
|
||||
'ROLLBACK_FAILED',
|
||||
'ROLLBACK_COMPLETE',
|
||||
'DELETE_FAILED',
|
||||
'DELETE_COMPLETE',
|
||||
]);
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const resp = await cfn.send(new DescribeStacksCommand({ StackName: stackName }));
|
||||
const status = resp.Stacks?.[0]?.StackStatus;
|
||||
if (successStates.has(status!)) {
|
||||
return { ok: expectedSuccess, status };
|
||||
}
|
||||
if (failureStates.has(status!)) {
|
||||
return { ok: !expectedSuccess, status };
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
return { ok: false, status: 'TIMEOUT' };
|
||||
}
|
||||
|
||||
async function getResources(stackName: string) {
|
||||
const resp = await cfn.send(new DescribeStackResourcesCommand({ StackName: stackName }));
|
||||
return resp.StackResources || [];
|
||||
}
|
||||
|
||||
function physicalId(resources: { LogicalResourceId?: string; PhysicalResourceId?: string }[], logicalId: string) {
|
||||
return resources.find((r) => r.LogicalResourceId === logicalId)?.PhysicalResourceId;
|
||||
}
|
||||
|
||||
describe('Auto-generated names', () => {
|
||||
it('should create stack with auto-generated names', async () => {
|
||||
const template = {
|
||||
Resources: {
|
||||
AutoBucket: { Type: 'AWS::S3::Bucket' },
|
||||
AutoQueue: { Type: 'AWS::SQS::Queue' },
|
||||
AutoTopic: { Type: 'AWS::SNS::Topic' },
|
||||
AutoParameter: {
|
||||
Type: 'AWS::SSM::Parameter',
|
||||
Properties: { Type: 'String', Value: 'v1' },
|
||||
},
|
||||
CrossRefQueue: {
|
||||
Type: 'AWS::SQS::Queue',
|
||||
Properties: { QueueName: { 'Fn::Sub': '${AutoBucket}-cross' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await cfn.send(
|
||||
new CreateStackCommand({
|
||||
StackName: autoStackName,
|
||||
TemplateBody: JSON.stringify(template),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reach CREATE_COMPLETE', async () => {
|
||||
const result = await waitForStackTerminalState(autoStackName, true);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('should have valid auto-generated resource names', async () => {
|
||||
const resources = await getResources(autoStackName);
|
||||
expect(resources.length).toBeGreaterThan(0);
|
||||
|
||||
const autoBucket = physicalId(resources, 'AutoBucket');
|
||||
const autoQueue = physicalId(resources, 'AutoQueue');
|
||||
const autoTopic = physicalId(resources, 'AutoTopic');
|
||||
const autoParam = physicalId(resources, 'AutoParameter');
|
||||
const crossQueue = physicalId(resources, 'CrossRefQueue');
|
||||
|
||||
// S3 bucket constraints
|
||||
expect(autoBucket).toBeTruthy();
|
||||
expect(autoBucket!.length).toBeGreaterThanOrEqual(3);
|
||||
expect(autoBucket!.length).toBeLessThanOrEqual(63);
|
||||
expect(/^[a-z0-9.-]+$/.test(autoBucket!)).toBe(true);
|
||||
expect(autoBucket).toBe(autoBucket!.toLowerCase());
|
||||
|
||||
// SQS queue constraints
|
||||
expect(autoQueue).toBeTruthy();
|
||||
const queueName = autoQueue!.includes('/') ? autoQueue!.slice(autoQueue!.lastIndexOf('/') + 1) : autoQueue!;
|
||||
expect(queueName.length).toBeGreaterThan(0);
|
||||
expect(queueName.length).toBeLessThanOrEqual(80);
|
||||
|
||||
// SNS topic constraints
|
||||
expect(autoTopic).toBeTruthy();
|
||||
const topicName = autoTopic!.includes(':') ? autoTopic!.slice(autoTopic!.lastIndexOf(':') + 1) : autoTopic!;
|
||||
expect(topicName.length).toBeGreaterThan(0);
|
||||
expect(topicName.length).toBeLessThanOrEqual(256);
|
||||
|
||||
// SSM parameter constraints
|
||||
expect(autoParam).toBeTruthy();
|
||||
expect(autoParam!.length).toBeLessThanOrEqual(2048);
|
||||
|
||||
// Cross-reference queue uses bucket name
|
||||
expect(crossQueue).toBeTruthy();
|
||||
const crossQueueName = crossQueue!.includes('/') ? crossQueue!.slice(crossQueue!.lastIndexOf('/') + 1) : crossQueue!;
|
||||
expect(crossQueueName.startsWith(`${autoBucket}-cross`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete auto stack', async () => {
|
||||
await cfn.send(new DeleteStackCommand({ StackName: autoStackName }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Explicit names', () => {
|
||||
const explicitBucket = `cfn-explicit-${token}`;
|
||||
const explicitQueue = `cfn-explicit-${token}`;
|
||||
const explicitTopic = `cfn-explicit-${token}`;
|
||||
const explicitParam = `/cfn-explicit/${token}`;
|
||||
|
||||
it('should create stack with explicit names', async () => {
|
||||
const template = {
|
||||
Resources: {
|
||||
NamedBucket: {
|
||||
Type: 'AWS::S3::Bucket',
|
||||
Properties: { BucketName: explicitBucket },
|
||||
},
|
||||
NamedQueue: {
|
||||
Type: 'AWS::SQS::Queue',
|
||||
Properties: { QueueName: explicitQueue },
|
||||
},
|
||||
NamedTopic: {
|
||||
Type: 'AWS::SNS::Topic',
|
||||
Properties: { TopicName: explicitTopic },
|
||||
},
|
||||
NamedParameter: {
|
||||
Type: 'AWS::SSM::Parameter',
|
||||
Properties: { Name: explicitParam, Type: 'String', Value: 'explicit' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await cfn.send(
|
||||
new CreateStackCommand({
|
||||
StackName: explicitStackName,
|
||||
TemplateBody: JSON.stringify(template),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reach CREATE_COMPLETE', async () => {
|
||||
const result = await waitForStackTerminalState(explicitStackName, true);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect explicit names', async () => {
|
||||
const resources = await getResources(explicitStackName);
|
||||
|
||||
const actualBucket = physicalId(resources, 'NamedBucket');
|
||||
const actualQueue = physicalId(resources, 'NamedQueue');
|
||||
const actualTopic = physicalId(resources, 'NamedTopic');
|
||||
const actualParam = physicalId(resources, 'NamedParameter');
|
||||
|
||||
expect(actualBucket).toBe(explicitBucket);
|
||||
expect(actualQueue).toContain(explicitQueue);
|
||||
expect(actualTopic).toContain(explicitTopic);
|
||||
expect(actualParam).toBe(explicitParam);
|
||||
});
|
||||
|
||||
it('should delete explicit stack', async () => {
|
||||
await cfn.send(new DeleteStackCommand({ StackName: explicitStackName }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* CloudWatch Metrics integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
CloudWatchClient,
|
||||
PutMetricDataCommand,
|
||||
GetMetricStatisticsCommand,
|
||||
ListMetricsCommand,
|
||||
PutMetricAlarmCommand,
|
||||
DescribeAlarmsCommand,
|
||||
DeleteAlarmsCommand,
|
||||
} from '@aws-sdk/client-cloudwatch';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('CloudWatch Metrics', () => {
|
||||
let cw: CloudWatchClient;
|
||||
let namespace: string;
|
||||
let alarmName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
cw = makeClient(CloudWatchClient);
|
||||
namespace = `Floci/Test/${uniqueName()}`;
|
||||
alarmName = `test-alarm-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await cw.send(new DeleteAlarmsCommand({ AlarmNames: [alarmName] }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should put metric data', async () => {
|
||||
await cw.send(
|
||||
new PutMetricDataCommand({
|
||||
Namespace: namespace,
|
||||
MetricData: [{ MetricName: 'TestMetric', Value: 42.0, Unit: 'Count' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should put metric data batch', async () => {
|
||||
await cw.send(
|
||||
new PutMetricDataCommand({
|
||||
Namespace: namespace,
|
||||
MetricData: [
|
||||
{ MetricName: 'Latency', Value: 100, Unit: 'Milliseconds' },
|
||||
{ MetricName: 'Errors', Value: 0, Unit: 'Count' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should list metrics', async () => {
|
||||
const response = await cw.send(new ListMetricsCommand({ Namespace: namespace }));
|
||||
expect(response.Metrics?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should get metric statistics', async () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 3600000);
|
||||
|
||||
const response = await cw.send(
|
||||
new GetMetricStatisticsCommand({
|
||||
Namespace: namespace,
|
||||
MetricName: 'TestMetric',
|
||||
StartTime: start,
|
||||
EndTime: now,
|
||||
Period: 3600,
|
||||
Statistics: ['Sum', 'Average', 'Maximum'],
|
||||
})
|
||||
);
|
||||
expect(response.Datapoints).toBeDefined();
|
||||
});
|
||||
|
||||
it('should put metric data with statistic values', async () => {
|
||||
const start = new Date(Date.now() - 3600000);
|
||||
|
||||
// Put metric data with pre-calculated statistics
|
||||
await cw.send(
|
||||
new PutMetricDataCommand({
|
||||
Namespace: namespace,
|
||||
MetricData: [
|
||||
{
|
||||
MetricName: 'AggregatedMetric',
|
||||
StatisticValues: {
|
||||
SampleCount: 5,
|
||||
Sum: 150,
|
||||
Minimum: 20,
|
||||
Maximum: 40,
|
||||
},
|
||||
Unit: 'Count',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Query back the statistics
|
||||
const end = new Date();
|
||||
const response = await cw.send(
|
||||
new GetMetricStatisticsCommand({
|
||||
Namespace: namespace,
|
||||
MetricName: 'AggregatedMetric',
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Period: 3600,
|
||||
Statistics: ['Sum', 'Average', 'Minimum', 'Maximum', 'SampleCount'],
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.Datapoints).toBeDefined();
|
||||
expect(response.Datapoints?.length).toBeGreaterThan(0);
|
||||
|
||||
const dp = response.Datapoints?.[0];
|
||||
expect(dp?.Sum).toBe(150);
|
||||
expect(dp?.SampleCount).toBe(5);
|
||||
expect(dp?.Minimum).toBe(20);
|
||||
expect(dp?.Maximum).toBe(40);
|
||||
expect(dp?.Average).toBe(30); // sum / sampleCount
|
||||
});
|
||||
|
||||
it('should put metric alarm', async () => {
|
||||
await cw.send(
|
||||
new PutMetricAlarmCommand({
|
||||
AlarmName: alarmName,
|
||||
MetricName: 'TestMetric',
|
||||
Namespace: namespace,
|
||||
Statistic: 'Average',
|
||||
Period: 60,
|
||||
EvaluationPeriods: 1,
|
||||
Threshold: 100,
|
||||
ComparisonOperator: 'GreaterThanThreshold',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe alarms', async () => {
|
||||
const response = await cw.send(
|
||||
new DescribeAlarmsCommand({ AlarmNames: [alarmName] })
|
||||
);
|
||||
expect(response.MetricAlarms?.some((a) => a.AlarmName === alarmName)).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete alarms', async () => {
|
||||
await cw.send(new DeleteAlarmsCommand({ AlarmNames: [alarmName] }));
|
||||
alarmName = '';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Cognito IDP compatibility tests for bug fixes:
|
||||
* #218 — RS256 JWT signing + JWKS signature verification
|
||||
* #220 — AdminGetUser accepts sub UUID and email alias as Username
|
||||
* #228 — AccessToken contains client_id claim
|
||||
* #229 — InitiateAuth rejects auth when no password hash is set
|
||||
* #233 — ListUsers respects Filter parameter
|
||||
* #234 — GetTokensFromRefreshToken returns new tokens (JS SDK v3 only)
|
||||
* #235 — AdminSetUserPassword(Permanent=false) changes the password
|
||||
*/
|
||||
|
||||
import { createPublicKey, createVerify } from 'node:crypto';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
CognitoIdentityProviderClient,
|
||||
CreateUserPoolCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
AdminCreateUserCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
AdminGetUserCommand,
|
||||
InitiateAuthCommand,
|
||||
ListUsersCommand,
|
||||
GetTokensFromRefreshTokenCommand,
|
||||
DescribeUserPoolCommand,
|
||||
DeleteUserPoolCommand,
|
||||
MessageActionType,
|
||||
ExplicitAuthFlowsType,
|
||||
AuthFlowType,
|
||||
ChallengeNameType,
|
||||
} from '@aws-sdk/client-cognito-identity-provider';
|
||||
import { makeClient, uniqueName, ENDPOINT } from './setup';
|
||||
|
||||
// ── JWT helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function decodeJwtPart(token: string, index: number): any {
|
||||
const part = token.split('.')[index];
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
async function fetchJwk(poolId: string, kid: string): Promise<any> {
|
||||
const resp = await fetch(`${ENDPOINT}/${poolId}/.well-known/jwks.json`);
|
||||
const json = await resp.json();
|
||||
return json.keys?.find((k: any) => k.kid === kid) ?? null;
|
||||
}
|
||||
|
||||
function verifyRs256(token: string, jwk: any): boolean {
|
||||
const [headerB64, payloadB64, sigB64] = token.split('.');
|
||||
const key = createPublicKey({ key: jwk, format: 'jwk' });
|
||||
const verifier = createVerify('RSA-SHA256');
|
||||
verifier.update(`${headerB64}.${payloadB64}`);
|
||||
return verifier.verify(key, Buffer.from(sigB64, 'base64url'));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Cognito features (#218 #220 #228 #229 #233 #234 #235)', () => {
|
||||
let cognito: CognitoIdentityProviderClient;
|
||||
let poolId: string;
|
||||
let clientId: string;
|
||||
const username = `compat-${uniqueName()}@example.com`;
|
||||
const password = 'CompatPass1!';
|
||||
let userSub: string;
|
||||
|
||||
beforeAll(() => {
|
||||
cognito = makeClient(CognitoIdentityProviderClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (poolId) await cognito.send(new DeleteUserPoolCommand({ UserPoolId: poolId }));
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// ── Setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it('creates pool', async () => {
|
||||
const resp = await cognito.send(
|
||||
new CreateUserPoolCommand({ PoolName: `compat-pool-${uniqueName()}` })
|
||||
);
|
||||
poolId = resp.UserPool!.Id!;
|
||||
expect(poolId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates client', async () => {
|
||||
const resp = await cognito.send(
|
||||
new CreateUserPoolClientCommand({
|
||||
UserPoolId: poolId,
|
||||
ClientName: `compat-client-${uniqueName()}`,
|
||||
ExplicitAuthFlows: [
|
||||
ExplicitAuthFlowsType.ALLOW_USER_PASSWORD_AUTH,
|
||||
ExplicitAuthFlowsType.ALLOW_REFRESH_TOKEN_AUTH,
|
||||
],
|
||||
})
|
||||
);
|
||||
clientId = resp.UserPoolClient!.ClientId!;
|
||||
expect(clientId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates user with permanent password', async () => {
|
||||
await cognito.send(
|
||||
new AdminCreateUserCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: username,
|
||||
UserAttributes: [
|
||||
{ Name: 'email', Value: username },
|
||||
{ Name: 'email_verified', Value: 'true' },
|
||||
],
|
||||
MessageAction: MessageActionType.SUPPRESS,
|
||||
})
|
||||
);
|
||||
await cognito.send(
|
||||
new AdminSetUserPasswordCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Permanent: true,
|
||||
})
|
||||
);
|
||||
|
||||
const user = await cognito.send(
|
||||
new AdminGetUserCommand({ UserPoolId: poolId, Username: username })
|
||||
);
|
||||
userSub = user.UserAttributes?.find((a) => a.Name === 'sub')?.Value ?? '';
|
||||
expect(userSub).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Issue #229 — InitiateAuth rejects when no password hash set ────────────
|
||||
|
||||
it('#229: rejects auth when user has no password set', async () => {
|
||||
const noHashUser = `no-hash-${uniqueName()}@example.com`;
|
||||
await cognito.send(
|
||||
new AdminCreateUserCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: noHashUser,
|
||||
UserAttributes: [{ Name: 'email', Value: noHashUser }],
|
||||
MessageAction: MessageActionType.SUPPRESS,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: noHashUser, PASSWORD: 'anything' },
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('#229: rejects wrong password', async () => {
|
||||
await expect(
|
||||
cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: 'WrongPass1!' },
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ── Issue #235 — AdminSetUserPassword(Permanent=false) changes the password ─
|
||||
|
||||
it('#235: Permanent=false changes password and sets FORCE_CHANGE_PASSWORD status', async () => {
|
||||
const tempPass = 'TempPass1!';
|
||||
|
||||
await cognito.send(
|
||||
new AdminSetUserPasswordCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: username,
|
||||
Password: tempPass,
|
||||
Permanent: false,
|
||||
})
|
||||
);
|
||||
|
||||
// Old password is now rejected
|
||||
await expect(
|
||||
cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
// New temp password triggers NEW_PASSWORD_REQUIRED, not tokens
|
||||
const challengeResp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: tempPass },
|
||||
})
|
||||
);
|
||||
expect(challengeResp.ChallengeName).toBe(ChallengeNameType.NEW_PASSWORD_REQUIRED);
|
||||
|
||||
// Restore permanent password for subsequent tests
|
||||
await cognito.send(
|
||||
new AdminSetUserPasswordCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Permanent: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ── Issue #228 — AccessToken contains client_id claim ─────────────────────
|
||||
|
||||
it('#228: AccessToken contains client_id claim matching the ClientId', async () => {
|
||||
const resp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
const payload = decodeJwtPart(resp.AuthenticationResult!.AccessToken!, 1);
|
||||
expect(payload.client_id).toBe(clientId);
|
||||
});
|
||||
|
||||
it('#228: IdToken does not contain client_id claim', async () => {
|
||||
const resp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
const payload = decodeJwtPart(resp.AuthenticationResult!.IdToken!, 1);
|
||||
expect(payload.client_id).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Issue #218 — RS256 JWT signing + JWKS verification ────────────────────
|
||||
|
||||
it('#218: AccessToken header declares alg=RS256 with a kid', async () => {
|
||||
const resp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
const header = decodeJwtPart(resp.AuthenticationResult!.AccessToken!, 0);
|
||||
expect(header.alg).toBe('RS256');
|
||||
expect(header.kid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('#218: AccessToken RS256 signature verifies against JWKS public key', async () => {
|
||||
const resp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
const token = resp.AuthenticationResult!.AccessToken!;
|
||||
const kid = decodeJwtPart(token, 0).kid;
|
||||
const jwk = await fetchJwk(poolId, kid);
|
||||
expect(jwk).toBeTruthy();
|
||||
expect(verifyRs256(token, jwk)).toBe(true);
|
||||
});
|
||||
|
||||
// ── Issue #220 — AdminGetUser accepts sub UUID and email alias ────────────
|
||||
|
||||
it('#220: AdminGetUser resolves by sub UUID', async () => {
|
||||
expect(userSub).toBeTruthy();
|
||||
const resp = await cognito.send(
|
||||
new AdminGetUserCommand({ UserPoolId: poolId, Username: userSub })
|
||||
);
|
||||
expect(resp.Username).toBe(username);
|
||||
});
|
||||
|
||||
it('#220: AdminGetUser resolves by email alias', async () => {
|
||||
const resp = await cognito.send(
|
||||
new AdminGetUserCommand({ UserPoolId: poolId, Username: username })
|
||||
);
|
||||
expect(resp.Username).toBe(username);
|
||||
});
|
||||
|
||||
it('#220: AdminSetUserPassword works with sub UUID as Username', async () => {
|
||||
await cognito.send(
|
||||
new AdminSetUserPasswordCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: userSub,
|
||||
Password: password,
|
||||
Permanent: true,
|
||||
})
|
||||
);
|
||||
const resp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
expect(resp.AuthenticationResult?.AccessToken).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Issue #233 — ListUsers respects Filter parameter ─────────────────────
|
||||
|
||||
it('#233: ListUsers without filter returns the user', async () => {
|
||||
const resp = await cognito.send(new ListUsersCommand({ UserPoolId: poolId }));
|
||||
expect(resp.Users?.some((u) => u.Username === username)).toBe(true);
|
||||
});
|
||||
|
||||
it('#233: ListUsers with email exact-match filter returns only matching user', async () => {
|
||||
const resp = await cognito.send(
|
||||
new ListUsersCommand({
|
||||
UserPoolId: poolId,
|
||||
Filter: `email = "${username}"`,
|
||||
})
|
||||
);
|
||||
expect(resp.Users).toHaveLength(1);
|
||||
expect(resp.Users![0].Username).toBe(username);
|
||||
});
|
||||
|
||||
it('#233: ListUsers with email starts-with filter returns matching users', async () => {
|
||||
const resp = await cognito.send(
|
||||
new ListUsersCommand({
|
||||
UserPoolId: poolId,
|
||||
Filter: `email ^= "compat-"`,
|
||||
})
|
||||
);
|
||||
expect(resp.Users?.some((u) => u.Username === username)).toBe(true);
|
||||
});
|
||||
|
||||
it('#233: ListUsers with sub exact-match filter returns only that user', async () => {
|
||||
expect(userSub).toBeTruthy();
|
||||
const resp = await cognito.send(
|
||||
new ListUsersCommand({
|
||||
UserPoolId: poolId,
|
||||
Filter: `sub = "${userSub}"`,
|
||||
})
|
||||
);
|
||||
expect(resp.Users).toHaveLength(1);
|
||||
expect(resp.Users![0].Username).toBe(username);
|
||||
});
|
||||
|
||||
it('#233: ListUsers with non-matching filter returns empty list', async () => {
|
||||
const resp = await cognito.send(
|
||||
new ListUsersCommand({
|
||||
UserPoolId: poolId,
|
||||
Filter: `email = "nobody@nowhere.invalid"`,
|
||||
})
|
||||
);
|
||||
expect(resp.Users).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Issue #234 — GetTokensFromRefreshToken ────────────────────────────────
|
||||
|
||||
it('#234: InitiateAuth returns a structured refresh token', async () => {
|
||||
const resp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
expect(resp.AuthenticationResult?.RefreshToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it('#234: GetTokensFromRefreshToken returns new AccessToken and IdToken', async () => {
|
||||
const loginResp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
const refreshToken = loginResp.AuthenticationResult!.RefreshToken!;
|
||||
|
||||
const refreshResp = await cognito.send(
|
||||
new GetTokensFromRefreshTokenCommand({
|
||||
ClientId: clientId,
|
||||
RefreshToken: refreshToken,
|
||||
})
|
||||
);
|
||||
|
||||
expect(refreshResp.AuthenticationResult?.AccessToken).toBeTruthy();
|
||||
expect(refreshResp.AuthenticationResult?.IdToken).toBeTruthy();
|
||||
// Per AWS spec, GetTokensFromRefreshToken does not return a new RefreshToken
|
||||
expect(refreshResp.AuthenticationResult?.RefreshToken).toBeFalsy();
|
||||
});
|
||||
|
||||
it('#234: GetTokensFromRefreshToken returns token with correct client_id claim', async () => {
|
||||
const loginResp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
const refreshToken = loginResp.AuthenticationResult!.RefreshToken!;
|
||||
|
||||
const refreshResp = await cognito.send(
|
||||
new GetTokensFromRefreshTokenCommand({
|
||||
ClientId: clientId,
|
||||
RefreshToken: refreshToken,
|
||||
})
|
||||
);
|
||||
|
||||
const payload = decodeJwtPart(refreshResp.AuthenticationResult!.AccessToken!, 1);
|
||||
expect(payload.client_id).toBe(clientId);
|
||||
});
|
||||
|
||||
it('#234: GetTokensFromRefreshToken rejects invalid refresh token', async () => {
|
||||
await expect(
|
||||
cognito.send(
|
||||
new GetTokensFromRefreshTokenCommand({
|
||||
ClientId: clientId,
|
||||
RefreshToken: 'not-a-valid-token',
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('DescribeUserPool returns all 20 standard SchemaAttributes', async () => {
|
||||
const resp = await cognito.send(new DescribeUserPoolCommand({ UserPoolId: poolId }));
|
||||
const schema = resp.UserPool?.SchemaAttributes ?? [];
|
||||
expect(schema).toHaveLength(20);
|
||||
const names = schema.map((a) => a.Name);
|
||||
const expected = [
|
||||
'sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname',
|
||||
'preferred_username', 'profile', 'picture', 'website', 'email',
|
||||
'email_verified', 'gender', 'birthdate', 'zoneinfo', 'locale',
|
||||
'phone_number', 'phone_number_verified', 'address', 'updated_at',
|
||||
];
|
||||
for (const attr of expected) {
|
||||
expect(names).toContain(attr);
|
||||
}
|
||||
const sub = schema.find((a) => a.Name === 'sub');
|
||||
expect(sub?.Required).toBe(true);
|
||||
expect(sub?.Mutable).toBe(false);
|
||||
});
|
||||
|
||||
it('#234: REFRESH_TOKEN_AUTH flow also works with structured token', async () => {
|
||||
const loginResp = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
|
||||
AuthParameters: { USERNAME: username, PASSWORD: password },
|
||||
})
|
||||
);
|
||||
const refreshToken = loginResp.AuthenticationResult!.RefreshToken!;
|
||||
|
||||
const refreshed = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: AuthFlowType.REFRESH_TOKEN_AUTH,
|
||||
AuthParameters: { REFRESH_TOKEN: refreshToken },
|
||||
})
|
||||
);
|
||||
|
||||
expect(refreshed.AuthenticationResult?.AccessToken).toBeTruthy();
|
||||
expect(refreshed.AuthenticationResult?.IdToken).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Cognito identity provider configuration compatibility tests.
|
||||
*
|
||||
* The response shapes asserted here were measured against the live Cognito API:
|
||||
* `IdpIdentifiers` is echoed by CreateIdentityProvider and UpdateIdentityProvider
|
||||
* only when the request supplied the member, whatever the stored value, while
|
||||
* DescribeIdentityProvider always returns it. Members a request omits are
|
||||
* preserved rather than cleared.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
CognitoIdentityProviderClient,
|
||||
CreateUserPoolCommand,
|
||||
DeleteUserPoolCommand,
|
||||
CreateIdentityProviderCommand,
|
||||
DescribeIdentityProviderCommand,
|
||||
ListIdentityProvidersCommand,
|
||||
UpdateIdentityProviderCommand,
|
||||
DeleteIdentityProviderCommand,
|
||||
IdentityProviderTypeType,
|
||||
} from '@aws-sdk/client-cognito-identity-provider';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
const OIDC_DETAILS = {
|
||||
client_id: 'node-compat-client',
|
||||
client_secret: 'node-compat-secret',
|
||||
attributes_request_method: 'GET',
|
||||
oidc_issuer: 'https://issuer.example.com',
|
||||
authorize_scopes: 'openid',
|
||||
};
|
||||
|
||||
describe('Cognito identity providers', () => {
|
||||
let cognito: CognitoIdentityProviderClient;
|
||||
let poolId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
cognito = makeClient(CognitoIdentityProviderClient);
|
||||
const pool = await cognito.send(
|
||||
new CreateUserPoolCommand({ PoolName: uniqueName('idp-pool') })
|
||||
);
|
||||
poolId = pool.UserPool!.Id!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (poolId) {
|
||||
await cognito.send(new DeleteUserPoolCommand({ UserPoolId: poolId }));
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults AttributeMapping and omits IdpIdentifiers on create', async () => {
|
||||
const created = await cognito.send(
|
||||
new CreateIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidc',
|
||||
ProviderType: IdentityProviderTypeType.OIDC,
|
||||
ProviderDetails: { ...OIDC_DETAILS },
|
||||
})
|
||||
);
|
||||
|
||||
expect(created.IdentityProvider!.AttributeMapping).toEqual({ username: 'sub' });
|
||||
expect(created.IdentityProvider!.IdpIdentifiers).toBeUndefined();
|
||||
|
||||
const described = await cognito.send(
|
||||
new DescribeIdentityProviderCommand({ UserPoolId: poolId, ProviderName: 'NodeOidc' })
|
||||
);
|
||||
expect(described.IdentityProvider!.IdpIdentifiers).toEqual([]);
|
||||
expect(described.IdentityProvider!.ProviderDetails!.client_id).toBe('node-compat-client');
|
||||
|
||||
await cognito.send(
|
||||
new DeleteIdentityProviderCommand({ UserPoolId: poolId, ProviderName: 'NodeOidc' })
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves members the update request omits', async () => {
|
||||
await cognito.send(
|
||||
new CreateIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidcAliased',
|
||||
ProviderType: IdentityProviderTypeType.OIDC,
|
||||
ProviderDetails: { ...OIDC_DETAILS },
|
||||
AttributeMapping: { email: 'email', username: 'sub' },
|
||||
IdpIdentifiers: ['node-alias'],
|
||||
})
|
||||
);
|
||||
|
||||
const updated = await cognito.send(
|
||||
new UpdateIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidcAliased',
|
||||
ProviderDetails: { ...OIDC_DETAILS },
|
||||
})
|
||||
);
|
||||
expect(updated.IdentityProvider!.IdpIdentifiers).toBeUndefined();
|
||||
|
||||
const described = await cognito.send(
|
||||
new DescribeIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidcAliased',
|
||||
})
|
||||
);
|
||||
expect(described.IdentityProvider!.IdpIdentifiers).toEqual(['node-alias']);
|
||||
expect(described.IdentityProvider!.AttributeMapping).toEqual({
|
||||
email: 'email',
|
||||
username: 'sub',
|
||||
});
|
||||
|
||||
await cognito.send(
|
||||
new DeleteIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidcAliased',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('lists providers as summaries without provider details', async () => {
|
||||
await cognito.send(
|
||||
new CreateIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidcListed',
|
||||
ProviderType: IdentityProviderTypeType.OIDC,
|
||||
ProviderDetails: { ...OIDC_DETAILS },
|
||||
})
|
||||
);
|
||||
|
||||
const listed = await cognito.send(new ListIdentityProvidersCommand({ UserPoolId: poolId }));
|
||||
|
||||
expect(listed.Providers).toHaveLength(1);
|
||||
expect(listed.Providers![0].ProviderName).toBe('NodeOidcListed');
|
||||
expect(listed.Providers![0].ProviderType).toBe('OIDC');
|
||||
expect(listed.Providers![0].CreationDate).toBeInstanceOf(Date);
|
||||
expect(listed.Providers![0]).not.toHaveProperty('ProviderDetails');
|
||||
|
||||
await cognito.send(
|
||||
new DeleteIdentityProviderCommand({ UserPoolId: poolId, ProviderName: 'NodeOidcListed' })
|
||||
);
|
||||
const empty = await cognito.send(new ListIdentityProvidersCommand({ UserPoolId: poolId }));
|
||||
expect(empty.Providers).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a duplicate provider name and an unknown provider type', async () => {
|
||||
await cognito.send(
|
||||
new CreateIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidcDuplicate',
|
||||
ProviderType: IdentityProviderTypeType.OIDC,
|
||||
ProviderDetails: { ...OIDC_DETAILS },
|
||||
})
|
||||
);
|
||||
|
||||
// The SDK maps the wire `__type` onto `error.name` and puts the AWS text in
|
||||
// `message`, so the exception has to be matched on the name.
|
||||
await expect(
|
||||
cognito.send(
|
||||
new CreateIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidcDuplicate',
|
||||
ProviderType: IdentityProviderTypeType.OIDC,
|
||||
ProviderDetails: { ...OIDC_DETAILS },
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ name: 'DuplicateProviderException' });
|
||||
|
||||
await expect(
|
||||
cognito.send(
|
||||
new CreateIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeBogus',
|
||||
ProviderType: 'NotARealType' as IdentityProviderTypeType,
|
||||
ProviderDetails: { client_id: 'x' },
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
name: 'InvalidParameterException',
|
||||
message: expect.stringContaining('Member must satisfy enum value set'),
|
||||
});
|
||||
|
||||
await cognito.send(
|
||||
new DeleteIdentityProviderCommand({
|
||||
UserPoolId: poolId,
|
||||
ProviderName: 'NodeOidcDuplicate',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Cognito OAuth/Resource Server integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createPublicKey, createVerify } from 'node:crypto';
|
||||
import {
|
||||
CognitoIdentityProviderClient,
|
||||
CreateUserPoolCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
CreateResourceServerCommand,
|
||||
DescribeResourceServerCommand,
|
||||
ListResourceServersCommand,
|
||||
UpdateResourceServerCommand,
|
||||
DeleteResourceServerCommand,
|
||||
DeleteUserPoolClientCommand,
|
||||
DeleteUserPoolCommand,
|
||||
} from '@aws-sdk/client-cognito-identity-provider';
|
||||
import { makeClient, uniqueName, ENDPOINT } from './setup';
|
||||
|
||||
// Helper functions
|
||||
function decodeJwtPart(token: string, index: number): any {
|
||||
const parts = token.split('.');
|
||||
const part = parts[index];
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
function scopeContains(actual: string | undefined, expected: string): boolean {
|
||||
if (!actual) return false;
|
||||
return actual.split(/\s+/).includes(expected);
|
||||
}
|
||||
|
||||
async function fetchJwk(jwksUri: string, kid: string): Promise<any> {
|
||||
const resp = await fetch(jwksUri);
|
||||
const json = await resp.json();
|
||||
return json.keys?.find((k: any) => k.kid === kid);
|
||||
}
|
||||
|
||||
function verifyRs256(token: string, jwk: any): boolean {
|
||||
const [headerB64, payloadB64, signatureB64] = token.split('.');
|
||||
const signature = Buffer.from(signatureB64, 'base64url');
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = createPublicKey({ key: jwk, format: 'jwk' });
|
||||
const verifier = createVerify('RSA-SHA256');
|
||||
verifier.update(data);
|
||||
return verifier.verify(key, signature);
|
||||
}
|
||||
|
||||
async function discoverOpenIdConfiguration(poolId: string) {
|
||||
const resp = await fetch(`${ENDPOINT}/${poolId}/.well-known/openid-configuration`);
|
||||
const json = await resp.json();
|
||||
return {
|
||||
tokenEndpoint: json.token_endpoint as string,
|
||||
jwksUri: json.jwks_uri as string,
|
||||
issuer: json.issuer as string,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestConfidentialClientToken(
|
||||
tokenEndpoint: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scope: string
|
||||
): Promise<{ status: number; json: any; body: string }> {
|
||||
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
const resp = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: `grant_type=client_credentials&scope=${encodeURIComponent(scope)}`,
|
||||
});
|
||||
const body = await resp.text();
|
||||
let json = {};
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { status: resp.status, json, body };
|
||||
}
|
||||
|
||||
async function requestPublicClientToken(
|
||||
tokenEndpoint: string,
|
||||
clientId: string,
|
||||
scope: string
|
||||
): Promise<{ status: number; json: any; body: string }> {
|
||||
const resp = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `grant_type=client_credentials&client_id=${clientId}&scope=${encodeURIComponent(scope)}`,
|
||||
});
|
||||
const body = await resp.text();
|
||||
let json = {};
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { status: resp.status, json, body };
|
||||
}
|
||||
|
||||
function isPublicClientRejectionError(e: any): boolean {
|
||||
const msg = e.message || '';
|
||||
return (
|
||||
msg.includes('public client') ||
|
||||
msg.includes('GenerateSecret') ||
|
||||
msg.includes('client_credentials')
|
||||
);
|
||||
}
|
||||
|
||||
describe('Cognito OAuth', () => {
|
||||
let cognito: CognitoIdentityProviderClient;
|
||||
let poolId: string;
|
||||
let resourceServerId: string;
|
||||
let readScope: string;
|
||||
let adminScope: string;
|
||||
let confidentialClientId: string;
|
||||
let confidentialClientSecret: string;
|
||||
let publicClientId: string;
|
||||
let publicClientRejectedAtCreate = false;
|
||||
let discovery: { tokenEndpoint: string; jwksUri: string; issuer: string } | null = null;
|
||||
let accessToken: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
cognito = makeClient(CognitoIdentityProviderClient);
|
||||
const suffix = uniqueName();
|
||||
resourceServerId = `https://compat.floci.test/resource/${suffix}`;
|
||||
readScope = `${resourceServerId}/read`;
|
||||
adminScope = `${resourceServerId}/admin`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (confidentialClientId) {
|
||||
await cognito.send(
|
||||
new DeleteUserPoolClientCommand({ UserPoolId: poolId, ClientId: confidentialClientId })
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (publicClientId) {
|
||||
await cognito.send(
|
||||
new DeleteUserPoolClientCommand({ UserPoolId: poolId, ClientId: publicClientId })
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (resourceServerId && poolId) {
|
||||
await cognito.send(
|
||||
new DeleteResourceServerCommand({ UserPoolId: poolId, Identifier: resourceServerId })
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (poolId) {
|
||||
await cognito.send(new DeleteUserPoolCommand({ UserPoolId: poolId }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create user pool for OAuth', async () => {
|
||||
const response = await cognito.send(
|
||||
new CreateUserPoolCommand({ PoolName: `floci-oauth-pool-${uniqueName()}` })
|
||||
);
|
||||
poolId = response.UserPool!.Id!;
|
||||
expect(poolId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should create resource server', async () => {
|
||||
const response = await cognito.send(
|
||||
new CreateResourceServerCommand({
|
||||
UserPoolId: poolId,
|
||||
Identifier: resourceServerId,
|
||||
Name: 'compat-resource-server',
|
||||
Scopes: [
|
||||
{ ScopeName: 'read', ScopeDescription: 'Read access' },
|
||||
{ ScopeName: 'write', ScopeDescription: 'Write access' },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(response.ResourceServer?.Identifier).toBe(resourceServerId);
|
||||
expect(response.ResourceServer?.Scopes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should describe resource server', async () => {
|
||||
const response = await cognito.send(
|
||||
new DescribeResourceServerCommand({
|
||||
UserPoolId: poolId,
|
||||
Identifier: resourceServerId,
|
||||
})
|
||||
);
|
||||
expect(response.ResourceServer?.Name).toBe('compat-resource-server');
|
||||
const scopes = response.ResourceServer?.Scopes?.map((s) => s.ScopeName) || [];
|
||||
expect(scopes).toContain('read');
|
||||
expect(scopes).toContain('write');
|
||||
});
|
||||
|
||||
it('should list resource servers', async () => {
|
||||
const response = await cognito.send(
|
||||
new ListResourceServersCommand({ UserPoolId: poolId, MaxResults: 60 })
|
||||
);
|
||||
expect(
|
||||
response.ResourceServers?.some((s) => s.Identifier === resourceServerId)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should update resource server', async () => {
|
||||
await cognito.send(
|
||||
new UpdateResourceServerCommand({
|
||||
UserPoolId: poolId,
|
||||
Identifier: resourceServerId,
|
||||
Name: 'compat-resource-server-updated',
|
||||
Scopes: [
|
||||
{ ScopeName: 'read', ScopeDescription: 'Read access updated' },
|
||||
{ ScopeName: 'admin', ScopeDescription: 'Admin access' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const response = await cognito.send(
|
||||
new DescribeResourceServerCommand({
|
||||
UserPoolId: poolId,
|
||||
Identifier: resourceServerId,
|
||||
})
|
||||
);
|
||||
expect(response.ResourceServer?.Name).toBe('compat-resource-server-updated');
|
||||
const scopes = response.ResourceServer?.Scopes?.map((s) => s.ScopeName) || [];
|
||||
expect(scopes).toContain('read');
|
||||
expect(scopes).toContain('admin');
|
||||
expect(scopes).not.toContain('write');
|
||||
});
|
||||
|
||||
it('should create confidential client', async () => {
|
||||
const response = await cognito.send(
|
||||
new CreateUserPoolClientCommand({
|
||||
UserPoolId: poolId,
|
||||
ClientName: `compat-confidential-client-${uniqueName()}`,
|
||||
GenerateSecret: true,
|
||||
AllowedOAuthFlowsUserPoolClient: true,
|
||||
AllowedOAuthFlows: ['client_credentials'],
|
||||
AllowedOAuthScopes: [readScope, adminScope],
|
||||
})
|
||||
);
|
||||
confidentialClientId = response.UserPoolClient?.ClientId!;
|
||||
confidentialClientSecret = response.UserPoolClient?.ClientSecret!;
|
||||
expect(confidentialClientId).toBeTruthy();
|
||||
expect(confidentialClientSecret).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle public client creation (rejected or accepted)', async () => {
|
||||
try {
|
||||
const response = await cognito.send(
|
||||
new CreateUserPoolClientCommand({
|
||||
UserPoolId: poolId,
|
||||
ClientName: `compat-public-client-${uniqueName()}`,
|
||||
AllowedOAuthFlowsUserPoolClient: true,
|
||||
AllowedOAuthFlows: ['client_credentials'],
|
||||
AllowedOAuthScopes: [readScope, adminScope],
|
||||
})
|
||||
);
|
||||
publicClientId = response.UserPoolClient?.ClientId!;
|
||||
expect(publicClientId).toBeTruthy();
|
||||
expect(response.UserPoolClient?.ClientSecret).toBeFalsy();
|
||||
} catch (e: any) {
|
||||
publicClientRejectedAtCreate = isPublicClientRejectionError(e);
|
||||
expect(publicClientRejectedAtCreate).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should discover OIDC configuration', async () => {
|
||||
discovery = await discoverOpenIdConfiguration(poolId);
|
||||
expect(discovery.tokenEndpoint).toContain('/oauth2/token');
|
||||
expect(discovery.jwksUri).toContain('/.well-known/jwks.json');
|
||||
expect(discovery.issuer).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should obtain access token via client_credentials', async () => {
|
||||
if (!discovery || !confidentialClientId || !confidentialClientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await requestConfidentialClientToken(
|
||||
discovery.tokenEndpoint,
|
||||
confidentialClientId,
|
||||
confidentialClientSecret,
|
||||
readScope
|
||||
);
|
||||
|
||||
expect(resp.status).toBe(200);
|
||||
accessToken = resp.json.access_token;
|
||||
expect(accessToken).toBeTruthy();
|
||||
expect(resp.json.token_type?.toLowerCase()).toBe('bearer');
|
||||
expect(Number(resp.json.expires_in)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should validate JWT structure and claims', async () => {
|
||||
if (!accessToken || !discovery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = decodeJwtPart(accessToken, 0);
|
||||
const payload = decodeJwtPart(accessToken, 1);
|
||||
|
||||
expect(header.alg).toBe('RS256');
|
||||
expect(header.kid).toBeTruthy();
|
||||
expect(payload.iss).toBe(discovery.issuer);
|
||||
expect(payload.client_id).toBe(confidentialClientId);
|
||||
expect(scopeContains(payload.scope, readScope)).toBe(true);
|
||||
});
|
||||
|
||||
it('should verify RS256 signature against JWKS', async () => {
|
||||
if (!accessToken || !discovery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kid = decodeJwtPart(accessToken, 0).kid;
|
||||
const jwk = await fetchJwk(discovery.jwksUri, kid);
|
||||
expect(verifyRs256(accessToken, jwk)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject public client token request', async () => {
|
||||
if (publicClientRejectedAtCreate || !discovery || !publicClientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await requestPublicClientToken(discovery.tokenEndpoint, publicClientId, readScope);
|
||||
expect(resp.status).toBeGreaterThanOrEqual(400);
|
||||
expect(resp.status).toBeLessThan(500);
|
||||
expect(['invalid_client', 'unauthorized_client']).toContain(resp.json.error);
|
||||
});
|
||||
|
||||
it('should reject unknown scope', async () => {
|
||||
if (!discovery || !confidentialClientId || !confidentialClientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await requestConfidentialClientToken(
|
||||
discovery.tokenEndpoint,
|
||||
confidentialClientId,
|
||||
confidentialClientSecret,
|
||||
`${resourceServerId}/unknown`
|
||||
);
|
||||
expect(resp.status).toBe(400);
|
||||
expect(resp.json.error).toBe('invalid_scope');
|
||||
});
|
||||
|
||||
it('should delete resource server', async () => {
|
||||
await cognito.send(
|
||||
new DeleteResourceServerCommand({
|
||||
UserPoolId: poolId,
|
||||
Identifier: resourceServerId,
|
||||
})
|
||||
);
|
||||
resourceServerId = '';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Cognito USER_SRP_AUTH round-trip via amazon-cognito-identity-js.
|
||||
*
|
||||
* The existing CognitoSrpTest (Java) only validates that the PASSWORD_VERIFIER
|
||||
* challenge is *returned*. This test goes further and exercises the full
|
||||
* round-trip — the client computes the PASSWORD_CLAIM_SIGNATURE from SRP_B
|
||||
* + SALT + SECRET_BLOCK and sends RespondToAuthChallenge — using the
|
||||
* canonical JS SRP impl (`amazon-cognito-identity-js`, which is what AWS
|
||||
* Amplify v6 wraps internally).
|
||||
*
|
||||
* Validates that Floci's server-side SRP-6a math (k, U, S, HKDF, HMAC byte
|
||||
* layouts) matches AWS's "Caldera" SRP spec well enough for a real SDK
|
||||
* client to authenticate end-to-end.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
CognitoIdentityProviderClient,
|
||||
CreateUserPoolCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
AdminCreateUserCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
DeleteUserPoolCommand,
|
||||
MessageActionType,
|
||||
ExplicitAuthFlowsType,
|
||||
} from '@aws-sdk/client-cognito-identity-provider';
|
||||
import {
|
||||
CognitoUserPool,
|
||||
CognitoUser,
|
||||
AuthenticationDetails,
|
||||
} from 'amazon-cognito-identity-js';
|
||||
import { makeClient, ENDPOINT } from './setup';
|
||||
|
||||
describe('Cognito USER_SRP_AUTH end-to-end (amazon-cognito-identity-js)', () => {
|
||||
let cognito: CognitoIdentityProviderClient;
|
||||
let poolId: string;
|
||||
let clientId: string;
|
||||
const username = `srp-roundtrip-${Date.now()}`;
|
||||
const password = 'SrpRoundTrip1!';
|
||||
|
||||
beforeAll(async () => {
|
||||
cognito = makeClient(CognitoIdentityProviderClient);
|
||||
|
||||
const pool = await cognito.send(new CreateUserPoolCommand({ PoolName: 'srp-roundtrip-pool' }));
|
||||
poolId = pool.UserPool!.Id!;
|
||||
|
||||
const client = await cognito.send(
|
||||
new CreateUserPoolClientCommand({
|
||||
UserPoolId: poolId,
|
||||
ClientName: 'srp-roundtrip-client',
|
||||
ExplicitAuthFlows: [ExplicitAuthFlowsType.ALLOW_USER_SRP_AUTH],
|
||||
}),
|
||||
);
|
||||
clientId = client.UserPoolClient!.ClientId!;
|
||||
|
||||
await cognito.send(
|
||||
new AdminCreateUserCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: username,
|
||||
MessageAction: MessageActionType.SUPPRESS,
|
||||
}),
|
||||
);
|
||||
await cognito.send(
|
||||
new AdminSetUserPasswordCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Permanent: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (poolId) {
|
||||
try { await cognito.send(new DeleteUserPoolCommand({ UserPoolId: poolId })); } catch {}
|
||||
}
|
||||
cognito.destroy();
|
||||
});
|
||||
|
||||
it('authenticates via SRP and returns valid tokens', async () => {
|
||||
const userPool = new CognitoUserPool({
|
||||
UserPoolId: poolId,
|
||||
ClientId: clientId,
|
||||
endpoint: ENDPOINT,
|
||||
});
|
||||
const cognitoUser = new CognitoUser({ Username: username, Pool: userPool });
|
||||
const authDetails = new AuthenticationDetails({ Username: username, Password: password });
|
||||
|
||||
const session = await new Promise<{
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>((resolve, reject) => {
|
||||
cognitoUser.authenticateUser(authDetails, {
|
||||
onSuccess: (s) =>
|
||||
resolve({
|
||||
idToken: s.getIdToken().getJwtToken(),
|
||||
accessToken: s.getAccessToken().getJwtToken(),
|
||||
refreshToken: s.getRefreshToken().getToken(),
|
||||
}),
|
||||
onFailure: (err) => reject(err),
|
||||
});
|
||||
});
|
||||
|
||||
// JWT tokens have three base64 segments separated by dots.
|
||||
expect(session.idToken.split('.')).toHaveLength(3);
|
||||
expect(session.accessToken.split('.')).toHaveLength(3);
|
||||
expect(session.refreshToken.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Cognito Identity Provider integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
CognitoIdentityProviderClient,
|
||||
CreateUserPoolCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
ExplicitAuthFlowsType,
|
||||
AdminCreateUserCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
InitiateAuthCommand,
|
||||
AdminGetUserCommand,
|
||||
ListUsersCommand,
|
||||
DeleteUserPoolCommand,
|
||||
SignUpCommand,
|
||||
ConfirmSignUpCommand,
|
||||
CreateGroupCommand,
|
||||
GetGroupCommand,
|
||||
ListGroupsCommand,
|
||||
DeleteGroupCommand,
|
||||
AdminAddUserToGroupCommand,
|
||||
AdminRemoveUserFromGroupCommand,
|
||||
AdminListGroupsForUserCommand,
|
||||
SetUserPoolMfaConfigCommand,
|
||||
GetUserPoolMfaConfigCommand,
|
||||
} from '@aws-sdk/client-cognito-identity-provider';
|
||||
import { makeClient, uniqueName, ENDPOINT, fetchLatestSesVerificationCode } from './setup';
|
||||
|
||||
describe('Cognito', () => {
|
||||
let cognito: CognitoIdentityProviderClient;
|
||||
let poolId: string;
|
||||
let clientId: string;
|
||||
|
||||
beforeAll(() => {
|
||||
cognito = makeClient(CognitoIdentityProviderClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (poolId) {
|
||||
await cognito.send(new DeleteUserPoolCommand({ UserPoolId: poolId }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create user pool', async () => {
|
||||
const response = await cognito.send(
|
||||
new CreateUserPoolCommand({
|
||||
PoolName: `test-pool-${uniqueName()}`,
|
||||
AutoVerifiedAttributes: ['email'],
|
||||
})
|
||||
);
|
||||
poolId = response.UserPool!.Id!;
|
||||
expect(poolId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should create user pool client', async () => {
|
||||
const response = await cognito.send(
|
||||
new CreateUserPoolClientCommand({
|
||||
UserPoolId: poolId,
|
||||
ClientName: `test-client-${uniqueName()}`,
|
||||
ExplicitAuthFlows: [ExplicitAuthFlowsType.ALLOW_USER_PASSWORD_AUTH],
|
||||
})
|
||||
);
|
||||
clientId = response.UserPoolClient!.ClientId!;
|
||||
expect(clientId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should admin create user', async () => {
|
||||
const response = await cognito.send(
|
||||
new AdminCreateUserCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: 'testuser',
|
||||
TemporaryPassword: 'Temp123!',
|
||||
UserAttributes: [{ Name: 'email', Value: 'testuser@example.com' }],
|
||||
})
|
||||
);
|
||||
expect(response.User?.Username).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should admin set user password', async () => {
|
||||
await cognito.send(
|
||||
new AdminSetUserPasswordCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: 'testuser',
|
||||
Password: 'Perm456!',
|
||||
Permanent: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should admin get user', async () => {
|
||||
const response = await cognito.send(
|
||||
new AdminGetUserCommand({ UserPoolId: poolId, Username: 'testuser' })
|
||||
);
|
||||
expect(response.UserStatus).toBe('CONFIRMED');
|
||||
});
|
||||
|
||||
it('should list users', async () => {
|
||||
const response = await cognito.send(new ListUsersCommand({ UserPoolId: poolId }));
|
||||
expect(response.Users?.some((u) => u.Username === 'testuser')).toBe(true);
|
||||
});
|
||||
|
||||
it('should initiate auth USER_PASSWORD_AUTH', async () => {
|
||||
const response = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
AuthFlow: 'USER_PASSWORD_AUTH',
|
||||
AuthParameters: { USERNAME: 'testuser', PASSWORD: 'Perm456!' },
|
||||
ClientId: clientId,
|
||||
})
|
||||
);
|
||||
expect(response.AuthenticationResult?.AccessToken).toBeTruthy();
|
||||
expect(response.AuthenticationResult?.IdToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should sign up and confirm user', async () => {
|
||||
const email = 'testuser2@example.com';
|
||||
await cognito.send(
|
||||
new SignUpCommand({
|
||||
ClientId: clientId,
|
||||
Username: 'testuser2',
|
||||
Password: 'Pass789!',
|
||||
UserAttributes: [{ Name: 'email', Value: email }],
|
||||
})
|
||||
);
|
||||
const confirmationCode = await fetchLatestSesVerificationCode(email);
|
||||
await cognito.send(
|
||||
new ConfirmSignUpCommand({
|
||||
ClientId: clientId,
|
||||
Username: 'testuser2',
|
||||
ConfirmationCode: confirmationCode,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should create group', async () => {
|
||||
const response = await cognito.send(
|
||||
new CreateGroupCommand({
|
||||
UserPoolId: poolId,
|
||||
GroupName: 'test-group',
|
||||
Description: 'Test group',
|
||||
Precedence: 1,
|
||||
})
|
||||
);
|
||||
expect(response.Group?.GroupName).toBe('test-group');
|
||||
});
|
||||
|
||||
it('should get group', async () => {
|
||||
const response = await cognito.send(
|
||||
new GetGroupCommand({ UserPoolId: poolId, GroupName: 'test-group' })
|
||||
);
|
||||
expect(response.Group?.GroupName).toBe('test-group');
|
||||
expect(response.Group?.Description).toBe('Test group');
|
||||
expect(response.Group?.Precedence).toBe(1);
|
||||
});
|
||||
|
||||
it('should fail to create duplicate group', async () => {
|
||||
await expect(
|
||||
cognito.send(
|
||||
new CreateGroupCommand({ UserPoolId: poolId, GroupName: 'test-group' })
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should list groups', async () => {
|
||||
const response = await cognito.send(new ListGroupsCommand({ UserPoolId: poolId }));
|
||||
expect(response.Groups?.some((g) => g.GroupName === 'test-group')).toBe(true);
|
||||
});
|
||||
|
||||
it('should add user to group', async () => {
|
||||
await cognito.send(
|
||||
new AdminAddUserToGroupCommand({
|
||||
UserPoolId: poolId,
|
||||
GroupName: 'test-group',
|
||||
Username: 'testuser',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should list groups for user', async () => {
|
||||
const response = await cognito.send(
|
||||
new AdminListGroupsForUserCommand({ UserPoolId: poolId, Username: 'testuser' })
|
||||
);
|
||||
expect(response.Groups?.some((g) => g.GroupName === 'test-group')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return groups in JWT', async () => {
|
||||
const response = await cognito.send(
|
||||
new InitiateAuthCommand({
|
||||
AuthFlow: 'USER_PASSWORD_AUTH',
|
||||
AuthParameters: { USERNAME: 'testuser', PASSWORD: 'Perm456!' },
|
||||
ClientId: clientId,
|
||||
})
|
||||
);
|
||||
const token = response.AuthenticationResult?.AccessToken!;
|
||||
const parts = token.split('.');
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
expect(Array.isArray(payload['cognito:groups'])).toBe(true);
|
||||
expect(payload['cognito:groups']?.includes('test-group')).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove user from group', async () => {
|
||||
await cognito.send(
|
||||
new AdminRemoveUserFromGroupCommand({
|
||||
UserPoolId: poolId,
|
||||
GroupName: 'test-group',
|
||||
Username: 'testuser',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should verify user has no groups', async () => {
|
||||
const response = await cognito.send(
|
||||
new AdminListGroupsForUserCommand({ UserPoolId: poolId, Username: 'testuser' })
|
||||
);
|
||||
expect(response.Groups?.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete group', async () => {
|
||||
await cognito.send(
|
||||
new DeleteGroupCommand({ UserPoolId: poolId, GroupName: 'test-group' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail to get deleted group', async () => {
|
||||
await expect(
|
||||
cognito.send(new GetGroupCommand({ UserPoolId: poolId, GroupName: 'test-group' }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should access JWKS endpoint', async () => {
|
||||
const resp = await fetch(`${ENDPOINT}/${poolId}/.well-known/jwks.json`);
|
||||
expect(resp.ok).toBe(true);
|
||||
const json = await resp.json();
|
||||
expect(Array.isArray(json.keys)).toBe(true);
|
||||
expect(json.keys.length).toBeGreaterThan(0);
|
||||
expect(json.keys[0].kty).toBe('RSA');
|
||||
expect(json.keys[0].alg).toBe('RS256');
|
||||
});
|
||||
|
||||
it('should delete user pool', async () => {
|
||||
await cognito.send(new DeleteUserPoolCommand({ UserPoolId: poolId }));
|
||||
poolId = '';
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cognito MFA configuration', () => {
|
||||
let cognito: CognitoIdentityProviderClient;
|
||||
let poolId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
cognito = makeClient(CognitoIdentityProviderClient);
|
||||
const pool = await cognito.send(
|
||||
new CreateUserPoolCommand({ PoolName: `mfa-${uniqueName()}` })
|
||||
);
|
||||
poolId = pool.UserPool!.Id!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (poolId) {
|
||||
await cognito
|
||||
.send(new DeleteUserPoolCommand({ UserPoolId: poolId }))
|
||||
.catch((error) => console.warn(`failed to delete MFA test pool ${poolId}`, error));
|
||||
}
|
||||
});
|
||||
|
||||
it('should report OFF and omit software-token config before it is set', async () => {
|
||||
const config = await cognito.send(new GetUserPoolMfaConfigCommand({ UserPoolId: poolId }));
|
||||
expect(config.MfaConfiguration).toBe('OFF');
|
||||
// The live service returns only the factors that have been configured.
|
||||
expect(config.SoftwareTokenMfaConfiguration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set and round-trip MFA configuration', async () => {
|
||||
const set = await cognito.send(
|
||||
new SetUserPoolMfaConfigCommand({
|
||||
UserPoolId: poolId,
|
||||
MfaConfiguration: 'OPTIONAL',
|
||||
SoftwareTokenMfaConfiguration: { Enabled: true },
|
||||
})
|
||||
);
|
||||
expect(set.MfaConfiguration).toBe('OPTIONAL');
|
||||
expect(set.SoftwareTokenMfaConfiguration?.Enabled).toBe(true);
|
||||
|
||||
// What the Terraform provider reads back to decide whether
|
||||
// mfa_configuration / software_token_mfa_configuration have drifted.
|
||||
const got = await cognito.send(new GetUserPoolMfaConfigCommand({ UserPoolId: poolId }));
|
||||
expect(got.MfaConfiguration).toBe('OPTIONAL');
|
||||
expect(got.SoftwareTokenMfaConfiguration?.Enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject an invalid MfaConfiguration', async () => {
|
||||
await expect(
|
||||
cognito.send(
|
||||
new SetUserPoolMfaConfigCommand({
|
||||
UserPoolId: poolId,
|
||||
MfaConfiguration: 'SOMETIMES' as never,
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,802 @@
|
||||
/**
|
||||
* DynamoDB conformance change tests.
|
||||
*
|
||||
* Covers:
|
||||
* - Phase 1: ListTables (alphabetical order, Limit pagination, Limit validation)
|
||||
* + ValidationException rename for invalid table names
|
||||
* - Phase 2: ProjectionExpression on GetItem, Query, Scan, BatchGetItem
|
||||
* - Phase 3: Batch limits (BatchWrite >25, BatchGet >100), empty PK rejection,
|
||||
* TransactWriteItems duplicate key + idempotency token,
|
||||
* ReturnItemCollectionMetrics=SIZE on LSI table
|
||||
* - Phase 4: Select=COUNT on Query/Scan, parallel scan, attribute_type() function,
|
||||
* parenthesized key condition, missing key condition validation
|
||||
* - Phase 7: TagResource invalid ARN, ListTagsOfResource non-existent ARN
|
||||
* - Phase 8: ReturnValuesOnConditionCheckFailure on PutItem/UpdateItem
|
||||
* - Phase 9: Reserved word rejection in expressions; alias passes
|
||||
* - Phase 10: AttributesToGet, AttributeUpdates, QueryFilter, ScanFilter (legacy API)
|
||||
* - Phase 11: Enum validation fires before table lookup
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
DynamoDBClient,
|
||||
BatchGetItemCommand,
|
||||
BatchWriteItemCommand,
|
||||
CreateTableCommand,
|
||||
DeleteItemCommand,
|
||||
DeleteTableCommand,
|
||||
GetItemCommand,
|
||||
ListTablesCommand,
|
||||
ListTagsOfResourceCommand,
|
||||
PutItemCommand,
|
||||
QueryCommand,
|
||||
ScanCommand,
|
||||
TagResourceCommand,
|
||||
TransactWriteItemsCommand,
|
||||
UpdateItemCommand,
|
||||
} from '@aws-sdk/client-dynamodb';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function s(v: string) {
|
||||
return { S: v };
|
||||
}
|
||||
function n(v: number) {
|
||||
return { N: String(v) };
|
||||
}
|
||||
|
||||
async function expectError(promise: Promise<unknown>, expectedName: string): Promise<void> {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await promise;
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught, `expected ${expectedName} to be thrown`).toBeDefined();
|
||||
expect((caught as Error).name).toBe(expectedName);
|
||||
}
|
||||
|
||||
// ─── Phase 1: ListTables ────────────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 1: ListTables', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const tables: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
// Create three tables with deterministic alphabetical names
|
||||
for (const suffix of ['aaa', 'bbb', 'ccc']) {
|
||||
const name = `lt-${suffix}-${uniqueName()}`;
|
||||
tables.push(name);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: name,
|
||||
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' }],
|
||||
AttributeDefinitions: [{ AttributeName: 'pk', AttributeType: 'S' }],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
}
|
||||
tables.sort(); // ensure our expectation baseline is sorted
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const t of tables) {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: t })); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('returns tables in alphabetical order', async () => {
|
||||
const resp = await ddb.send(new ListTablesCommand({}));
|
||||
const names = resp.TableNames ?? [];
|
||||
const sorted = [...names].sort();
|
||||
expect(names).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('respects Limit and returns LastEvaluatedTableName', async () => {
|
||||
const first = await ddb.send(new ListTablesCommand({ Limit: 1 }));
|
||||
expect(first.TableNames).toHaveLength(1);
|
||||
expect(first.LastEvaluatedTableName).toBeDefined();
|
||||
|
||||
const second = await ddb.send(new ListTablesCommand({
|
||||
Limit: 1,
|
||||
ExclusiveStartTableName: first.LastEvaluatedTableName,
|
||||
}));
|
||||
expect(second.TableNames).toHaveLength(1);
|
||||
expect(second.TableNames![0]).not.toBe(first.TableNames![0]);
|
||||
});
|
||||
|
||||
it('rejects Limit=0 with ValidationException', async () => {
|
||||
await expectError(ddb.send(new ListTablesCommand({ Limit: 0 })), 'ValidationException');
|
||||
});
|
||||
|
||||
it('rejects Limit=101 with ValidationException', async () => {
|
||||
await expectError(ddb.send(new ListTablesCommand({ Limit: 101 })), 'ValidationException');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 1: ValidationException for invalid table names ───────────────────
|
||||
|
||||
describe('DynamoDB — Phase 1: ValidationException rename', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
|
||||
beforeAll(() => { ddb = makeClient(DynamoDBClient); });
|
||||
|
||||
it('rejects a 2-char table name with ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new PutItemCommand({ TableName: 'ab', Item: { pk: s('x') } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty table name with ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new GetItemCommand({ TableName: '', Key: { pk: s('x') } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 2: ProjectionExpression ──────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 2: ProjectionExpression', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `proj-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('p1'), sk: s(`s${i}`), name: s(`Item-${i}`), count: n(i * 10), status: s('active') },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('GetItem: returns only projected attributes', async () => {
|
||||
const resp = await ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
ProjectionExpression: '#n',
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
}));
|
||||
expect(resp.Item).toHaveProperty('name');
|
||||
expect(resp.Item).not.toHaveProperty('count');
|
||||
expect(resp.Item).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('Query: returns only projected attributes', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
ProjectionExpression: 'sk, #n',
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
}));
|
||||
expect(resp.Items!.length).toBeGreaterThan(0);
|
||||
for (const item of resp.Items!) {
|
||||
expect(item).toHaveProperty('sk');
|
||||
expect(item).toHaveProperty('name');
|
||||
expect(item).not.toHaveProperty('count');
|
||||
}
|
||||
});
|
||||
|
||||
it('Scan: returns only projected attributes', async () => {
|
||||
const resp = await ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
ProjectionExpression: 'pk, sk',
|
||||
}));
|
||||
expect(resp.Items!.length).toBeGreaterThan(0);
|
||||
for (const item of resp.Items!) {
|
||||
expect(item).toHaveProperty('pk');
|
||||
expect(item).toHaveProperty('sk');
|
||||
expect(item).not.toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
|
||||
it('BatchGetItem: returns only projected attributes', async () => {
|
||||
const resp = await ddb.send(new BatchGetItemCommand({
|
||||
RequestItems: {
|
||||
[table]: {
|
||||
Keys: [{ pk: s('p1'), sk: s('s0') }],
|
||||
ProjectionExpression: '#n',
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
},
|
||||
},
|
||||
}));
|
||||
const items = resp.Responses?.[table] ?? [];
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items[0]).toHaveProperty('name');
|
||||
expect(items[0]).not.toHaveProperty('count');
|
||||
});
|
||||
|
||||
it('rejects AttributesToGet combined with ProjectionExpression', async () => {
|
||||
await expectError(
|
||||
ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
AttributesToGet: ['name'],
|
||||
ProjectionExpression: 'name',
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 3: Validation limits ─────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 3: Batch limits & key validation', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `val-${uniqueName()}`;
|
||||
const lsiTable = `val-lsi-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: lsiTable,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
{ AttributeName: 'lsi_sk', AttributeType: 'S' },
|
||||
],
|
||||
LocalSecondaryIndexes: [{
|
||||
IndexName: 'lsi-by-lsi-sk',
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'lsi_sk', KeyType: 'RANGE' },
|
||||
],
|
||||
Projection: { ProjectionType: 'ALL' },
|
||||
}],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: lsiTable })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('BatchWriteItem rejects more than 25 items', async () => {
|
||||
const writes = Array.from({ length: 26 }, (_, i) => ({
|
||||
PutRequest: { Item: { pk: s(`excess-${i}`), sk: s('s') } },
|
||||
}));
|
||||
await expectError(
|
||||
ddb.send(new BatchWriteItemCommand({ RequestItems: { [table]: writes } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('BatchGetItem rejects more than 100 keys', async () => {
|
||||
const keys = Array.from({ length: 101 }, (_, i) => ({ pk: s(`k${i}`), sk: s('s') }));
|
||||
await expectError(
|
||||
ddb.send(new BatchGetItemCommand({ RequestItems: { [table]: { Keys: keys } } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('PutItem rejects empty string as PK', async () => {
|
||||
await expectError(
|
||||
ddb.send(new PutItemCommand({ TableName: table, Item: { pk: s(''), sk: s('s') } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('TransactWriteItems rejects duplicate keys', async () => {
|
||||
await expectError(
|
||||
ddb.send(new TransactWriteItemsCommand({
|
||||
TransactItems: [
|
||||
{ Put: { TableName: table, Item: { pk: s('dup'), sk: s('s') } } },
|
||||
{ Put: { TableName: table, Item: { pk: s('dup'), sk: s('s') } } },
|
||||
],
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('TransactWriteItems idempotency token conflict throws IdempotentParameterMismatchException', async () => {
|
||||
const token = uniqueName('tok');
|
||||
// First call succeeds
|
||||
await ddb.send(new TransactWriteItemsCommand({
|
||||
ClientRequestToken: token,
|
||||
TransactItems: [{ Put: { TableName: table, Item: { pk: s('idem-node'), sk: s('s') } } }],
|
||||
}));
|
||||
// Same token, different payload
|
||||
await expectError(
|
||||
ddb.send(new TransactWriteItemsCommand({
|
||||
ClientRequestToken: token,
|
||||
TransactItems: [{ Put: { TableName: table, Item: { pk: s('idem-other'), sk: s('s') } } }],
|
||||
})),
|
||||
'IdempotentParameterMismatchException',
|
||||
);
|
||||
});
|
||||
|
||||
it('PutItem with ReturnItemCollectionMetrics=SIZE returns metrics on LSI table', async () => {
|
||||
const resp = await ddb.send(new PutItemCommand({
|
||||
TableName: lsiTable,
|
||||
Item: { pk: s('m1'), sk: s('sk-a'), lsi_sk: s('l1') },
|
||||
ReturnItemCollectionMetrics: 'SIZE',
|
||||
}));
|
||||
expect(resp.ItemCollectionMetrics).toBeDefined();
|
||||
expect(resp.ItemCollectionMetrics!.SizeEstimateRangeGB).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 4: Query/Scan behaviour ──────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 4: Query/Scan behaviour', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `qscan-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('p1'), sk: s(`s${i}`), name: s(`Item-${i}`), count: n(i) },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('Query Select=COUNT returns Count without Items', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
Select: 'COUNT',
|
||||
}));
|
||||
expect(resp.Count).toBe(5);
|
||||
expect(resp.Items).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Scan Select=COUNT returns Count without Items', async () => {
|
||||
const resp = await ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
Select: 'COUNT',
|
||||
}));
|
||||
expect(resp.Count).toBeGreaterThan(0);
|
||||
expect(resp.Items).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parallel scan partitions with no overlap and full coverage', async () => {
|
||||
const totalSegments = 2;
|
||||
const [seg0, seg1] = await Promise.all([
|
||||
ddb.send(new ScanCommand({ TableName: table, Segment: 0, TotalSegments: totalSegments })),
|
||||
ddb.send(new ScanCommand({ TableName: table, Segment: 1, TotalSegments: totalSegments })),
|
||||
]);
|
||||
|
||||
const keys0 = new Set((seg0.Items ?? []).map(i => i.sk.S!));
|
||||
const keys1 = new Set((seg1.Items ?? []).map(i => i.sk.S!));
|
||||
|
||||
// No overlap
|
||||
for (const k of keys0) expect(keys1.has(k)).toBe(false);
|
||||
// Full coverage
|
||||
expect(keys0.size + keys1.size).toBe(5);
|
||||
});
|
||||
|
||||
it('parallel scan requires both Segment and TotalSegments', async () => {
|
||||
await expectError(
|
||||
ddb.send(new ScanCommand({ TableName: table, Segment: 0 })),
|
||||
'ValidationException',
|
||||
);
|
||||
await expectError(
|
||||
ddb.send(new ScanCommand({ TableName: table, TotalSegments: 2 })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('attribute_type() function in FilterExpression', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
FilterExpression: 'attribute_type(#n, :t)',
|
||||
ExpressionAttributeValues: { ':pk': s('p1'), ':t': s('S') },
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
}));
|
||||
expect(resp.Count).toBe(5);
|
||||
});
|
||||
|
||||
it('parenthesized key condition expression works', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: '(pk = :pk AND sk = :sk)',
|
||||
ExpressionAttributeValues: { ':pk': s('p1'), ':sk': s('s0') },
|
||||
}));
|
||||
expect(resp.Count).toBe(1);
|
||||
expect(resp.Items![0].sk.S).toBe('s0');
|
||||
});
|
||||
|
||||
it('Query without KeyConditionExpression throws ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new QueryCommand({ TableName: table })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 7: Tags API errors ────────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 7: Tags API errors', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
|
||||
beforeAll(() => { ddb = makeClient(DynamoDBClient); });
|
||||
|
||||
it('TagResource with invalid ARN throws ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new TagResourceCommand({
|
||||
ResourceArn: 'not-an-arn',
|
||||
Tags: [{ Key: 'k', Value: 'v' }],
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('ListTagsOfResource with valid-format non-existent ARN throws AccessDeniedException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new ListTagsOfResourceCommand({
|
||||
ResourceArn: 'arn:aws:dynamodb:us-east-1:000000000000:table/does-not-exist',
|
||||
})),
|
||||
'AccessDeniedException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 8: ReturnValuesOnConditionCheckFailure ────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 8: ReturnValuesOnConditionCheckFailure', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `cond-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('existing'), sk: s('s'), val: s('old') },
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('PutItem condition failure throws ConditionalCheckFailedException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('existing'), sk: s('s'), val: s('new') },
|
||||
ConditionExpression: 'attribute_not_exists(pk)',
|
||||
ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
|
||||
})),
|
||||
'ConditionalCheckFailedException',
|
||||
);
|
||||
});
|
||||
|
||||
it('UpdateItem condition failure throws ConditionalCheckFailedException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new UpdateItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('existing'), sk: s('s') },
|
||||
UpdateExpression: 'SET val = :new',
|
||||
ConditionExpression: 'val = :expected',
|
||||
ExpressionAttributeValues: { ':new': s('new'), ':expected': s('wrong') },
|
||||
ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
|
||||
})),
|
||||
'ConditionalCheckFailedException',
|
||||
);
|
||||
});
|
||||
|
||||
it('DeleteItem condition failure throws ConditionalCheckFailedException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new DeleteItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('existing'), sk: s('s') },
|
||||
ConditionExpression: 'val = :expected',
|
||||
ExpressionAttributeValues: { ':expected': s('wrong') },
|
||||
ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
|
||||
})),
|
||||
'ConditionalCheckFailedException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 9: Reserved words ─────────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 9: Reserved words', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `rw-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('bare reserved word in ConditionExpression throws ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('rw1'), sk: s('s') },
|
||||
// 'status' is a DynamoDB reserved word
|
||||
ConditionExpression: 'attribute_not_exists(status)',
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('bare reserved word in FilterExpression throws ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: 'name = :v',
|
||||
ExpressionAttributeValues: { ':v': s('x') },
|
||||
// 'name' is a reserved word used bare
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('reserved word aliased via ExpressionAttributeNames passes', async () => {
|
||||
// Using #st alias for the reserved word 'status'. The alias must be referenced
|
||||
// by the expression: AWS rejects ExpressionAttributeNames entries left unused.
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('rw-ok'), sk: s('s'), status: s('active') },
|
||||
ConditionExpression: 'attribute_not_exists(#st)',
|
||||
ExpressionAttributeNames: { '#st': 'status' },
|
||||
}));
|
||||
|
||||
const resp = await ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: '#st = :v',
|
||||
ExpressionAttributeNames: { '#st': 'status' },
|
||||
ExpressionAttributeValues: { ':v': s('active') },
|
||||
}));
|
||||
expect(resp.Count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 10: Legacy API ────────────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 10: Legacy API', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `legacy-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('p1'), sk: s(`s${i}`), name: s(`Item-${i}`), color: s('red') },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('AttributesToGet returns only listed attributes (no auto-key-include)', async () => {
|
||||
const resp = await ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
AttributesToGet: ['name', 'color'],
|
||||
}));
|
||||
expect(resp.Item).toHaveProperty('name');
|
||||
expect(resp.Item).toHaveProperty('color');
|
||||
expect(resp.Item).not.toHaveProperty('pk');
|
||||
expect(resp.Item).not.toHaveProperty('sk');
|
||||
});
|
||||
|
||||
it('AttributeUpdates (legacy) applies PUT action', async () => {
|
||||
await ddb.send(new UpdateItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
AttributeUpdates: {
|
||||
color: { Value: s('blue'), Action: 'PUT' },
|
||||
},
|
||||
}));
|
||||
const resp = await ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
}));
|
||||
expect(resp.Item!.color.S).toBe('blue');
|
||||
});
|
||||
|
||||
it('AttributeUpdates (legacy) applies DELETE action to remove attribute', async () => {
|
||||
await ddb.send(new UpdateItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s1') },
|
||||
AttributeUpdates: {
|
||||
color: { Action: 'DELETE' },
|
||||
},
|
||||
}));
|
||||
const resp = await ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s1') },
|
||||
}));
|
||||
expect(resp.Item).not.toHaveProperty('color');
|
||||
});
|
||||
|
||||
it('QueryFilter (legacy) filters query results', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditions: {
|
||||
pk: { ComparisonOperator: 'EQ', AttributeValueList: [s('p1')] },
|
||||
},
|
||||
QueryFilter: {
|
||||
name: { ComparisonOperator: 'EQ', AttributeValueList: [s('Item-0')] },
|
||||
},
|
||||
}));
|
||||
expect(resp.Count).toBe(1);
|
||||
expect(resp.Items![0].name.S).toBe('Item-0');
|
||||
});
|
||||
|
||||
it('QueryFilter and KeyConditionExpression are mutually exclusive', async () => {
|
||||
const err = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
QueryFilter: {
|
||||
name: { ComparisonOperator: 'EQ', AttributeValueList: [s('Item-0')] },
|
||||
},
|
||||
})).catch(e => e);
|
||||
|
||||
expect(err.name).toBe('ValidationException');
|
||||
expect(err.message).toBe(
|
||||
'Can not use both expression and non-expression parameters in the same request: '
|
||||
+ 'Non-expression parameters: {QueryFilter} Expression parameters: {KeyConditionExpression}',
|
||||
);
|
||||
});
|
||||
|
||||
it('ScanFilter (legacy) filters scan results', async () => {
|
||||
const resp = await ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
ScanFilter: {
|
||||
name: { ComparisonOperator: 'EQ', AttributeValueList: [s('Item-2')] },
|
||||
},
|
||||
}));
|
||||
expect(resp.Count).toBe(1);
|
||||
expect(resp.Items![0].name.S).toBe('Item-2');
|
||||
});
|
||||
|
||||
it('KeyConditions and KeyConditionExpression are mutually exclusive', async () => {
|
||||
await expectError(
|
||||
ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
KeyConditions: { pk: { ComparisonOperator: 'EQ', AttributeValueList: [s('p1')] } },
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 11: Enum validation ordering ──────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 11: Enum validation before table lookup', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
|
||||
beforeAll(() => { ddb = makeClient(DynamoDBClient); });
|
||||
|
||||
it('invalid ReturnValues on PutItem throws ValidationException even for non-existent table', async () => {
|
||||
const err = await ddb.send(new PutItemCommand({
|
||||
TableName: 'nonexistent-table-xyz',
|
||||
Item: { pk: s('x') },
|
||||
ReturnValues: 'UPDATED_NEW', // invalid for PutItem
|
||||
})).catch(e => e);
|
||||
|
||||
expect(err.name).toBe('ValidationException');
|
||||
// Must NOT be ResourceNotFoundException
|
||||
expect(err.name).not.toBe('ResourceNotFoundException');
|
||||
});
|
||||
|
||||
it('invalid ReturnValues on DeleteItem throws ValidationException even for non-existent table', async () => {
|
||||
const err = await ddb.send(new DeleteItemCommand({
|
||||
TableName: 'nonexistent-table-xyz',
|
||||
Key: { pk: s('x'), sk: s('y') },
|
||||
ReturnValues: 'UPDATED_NEW', // invalid for DeleteItem
|
||||
})).catch(e => e);
|
||||
|
||||
expect(err.name).toBe('ValidationException');
|
||||
});
|
||||
|
||||
it('invalid ReturnConsumedCapacity throws ValidationException even for non-existent table', async () => {
|
||||
const err = await ddb.send(new GetItemCommand({
|
||||
TableName: 'nonexistent-table-xyz',
|
||||
Key: { pk: s('x'), sk: s('y') },
|
||||
// @ts-expect-error invalid enum value
|
||||
ReturnConsumedCapacity: 'INVALID_VALUE',
|
||||
})).catch(e => e);
|
||||
|
||||
expect(err.name).toBe('ValidationException');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* DynamoDB integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
DynamoDBClient,
|
||||
CreateTableCommand,
|
||||
PutItemCommand,
|
||||
GetItemCommand,
|
||||
DeleteItemCommand,
|
||||
ScanCommand,
|
||||
QueryCommand,
|
||||
UpdateItemCommand,
|
||||
DeleteTableCommand,
|
||||
ListTablesCommand,
|
||||
DescribeTableCommand,
|
||||
} from '@aws-sdk/client-dynamodb';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('DynamoDB', () => {
|
||||
let dynamo: DynamoDBClient;
|
||||
let tableName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
dynamo = makeClient(DynamoDBClient);
|
||||
tableName = `test-table-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await dynamo.send(new DeleteTableCommand({ TableName: tableName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create table', async () => {
|
||||
await dynamo.send(
|
||||
new CreateTableCommand({
|
||||
TableName: tableName,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe table', async () => {
|
||||
const response = await dynamo.send(new DescribeTableCommand({ TableName: tableName }));
|
||||
expect(response.Table?.TableStatus).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('should list tables', async () => {
|
||||
const response = await dynamo.send(new ListTablesCommand({}));
|
||||
expect(response.TableNames?.includes(tableName)).toBe(true);
|
||||
});
|
||||
|
||||
it('should put item', async () => {
|
||||
await dynamo.send(
|
||||
new PutItemCommand({
|
||||
TableName: tableName,
|
||||
Item: {
|
||||
pk: { S: 'user#1' },
|
||||
sk: { S: 'profile' },
|
||||
name: { S: 'Alice' },
|
||||
age: { N: '30' },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should get item', async () => {
|
||||
const response = await dynamo.send(
|
||||
new GetItemCommand({
|
||||
TableName: tableName,
|
||||
Key: { pk: { S: 'user#1' }, sk: { S: 'profile' } },
|
||||
})
|
||||
);
|
||||
expect(response.Item?.name?.S).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should update item', async () => {
|
||||
await dynamo.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: tableName,
|
||||
Key: { pk: { S: 'user#1' }, sk: { S: 'profile' } },
|
||||
UpdateExpression: 'SET #n = :v',
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
ExpressionAttributeValues: { ':v': { S: 'Bob' } },
|
||||
})
|
||||
);
|
||||
|
||||
const response = await dynamo.send(
|
||||
new GetItemCommand({
|
||||
TableName: tableName,
|
||||
Key: { pk: { S: 'user#1' }, sk: { S: 'profile' } },
|
||||
})
|
||||
);
|
||||
expect(response.Item?.name?.S).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should put second item', async () => {
|
||||
await dynamo.send(
|
||||
new PutItemCommand({
|
||||
TableName: tableName,
|
||||
Item: {
|
||||
pk: { S: 'user#2' },
|
||||
sk: { S: 'profile' },
|
||||
name: { S: 'Carol' },
|
||||
age: { N: '25' },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should scan table', async () => {
|
||||
const response = await dynamo.send(new ScanCommand({ TableName: tableName }));
|
||||
expect(response.Count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should query table', async () => {
|
||||
const response = await dynamo.send(
|
||||
new QueryCommand({
|
||||
TableName: tableName,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': { S: 'user#1' } },
|
||||
})
|
||||
);
|
||||
expect(response.Count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should delete items', async () => {
|
||||
await dynamo.send(
|
||||
new DeleteItemCommand({
|
||||
TableName: tableName,
|
||||
Key: { pk: { S: 'user#1' }, sk: { S: 'profile' } },
|
||||
})
|
||||
);
|
||||
await dynamo.send(
|
||||
new DeleteItemCommand({
|
||||
TableName: tableName,
|
||||
Key: { pk: { S: 'user#2' }, sk: { S: 'profile' } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete table', async () => {
|
||||
await dynamo.send(new DeleteTableCommand({ TableName: tableName }));
|
||||
tableName = '';
|
||||
});
|
||||
});
|
||||
|
||||
describe('DynamoDB GSI/LSI', () => {
|
||||
let dynamo: DynamoDBClient;
|
||||
let tableName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
dynamo = makeClient(DynamoDBClient);
|
||||
tableName = `test-gsi-table-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await dynamo.send(new DeleteTableCommand({ TableName: tableName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create table with GSI and LSI', async () => {
|
||||
await dynamo.send(
|
||||
new CreateTableCommand({
|
||||
TableName: tableName,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
{ AttributeName: 'gsiPk', AttributeType: 'S' },
|
||||
{ AttributeName: 'lsiSk', AttributeType: 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes: [
|
||||
{
|
||||
IndexName: 'gsi-1',
|
||||
KeySchema: [
|
||||
{ AttributeName: 'gsiPk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
Projection: { ProjectionType: 'ALL' },
|
||||
ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
|
||||
},
|
||||
],
|
||||
LocalSecondaryIndexes: [
|
||||
{
|
||||
IndexName: 'lsi-1',
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'lsiSk', KeyType: 'RANGE' },
|
||||
],
|
||||
Projection: { ProjectionType: 'KEYS_ONLY' },
|
||||
},
|
||||
],
|
||||
ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should verify indexes via DescribeTable', async () => {
|
||||
const desc = await dynamo.send(new DescribeTableCommand({ TableName: tableName }));
|
||||
const gsis = desc.Table?.GlobalSecondaryIndexes || [];
|
||||
const lsis = desc.Table?.LocalSecondaryIndexes || [];
|
||||
|
||||
expect(gsis.length).toBe(1);
|
||||
expect(gsis[0]?.IndexName).toBe('gsi-1');
|
||||
expect(gsis[0]?.Projection?.ProjectionType).toBe('ALL');
|
||||
expect(lsis.length).toBe(1);
|
||||
expect(lsis[0]?.IndexName).toBe('lsi-1');
|
||||
expect(lsis[0]?.Projection?.ProjectionType).toBe('KEYS_ONLY');
|
||||
});
|
||||
|
||||
it('should put items with GSI attributes', async () => {
|
||||
await dynamo.send(
|
||||
new PutItemCommand({
|
||||
TableName: tableName,
|
||||
Item: {
|
||||
pk: { S: 'item-1' },
|
||||
sk: { S: 'rev-1' },
|
||||
gsiPk: { S: 'group-A' },
|
||||
lsiSk: { S: '2024-01-01' },
|
||||
},
|
||||
})
|
||||
);
|
||||
await dynamo.send(
|
||||
new PutItemCommand({
|
||||
TableName: tableName,
|
||||
Item: {
|
||||
pk: { S: 'item-2' },
|
||||
sk: { S: 'rev-1' },
|
||||
gsiPk: { S: 'group-A' },
|
||||
lsiSk: { S: '2024-01-02' },
|
||||
},
|
||||
})
|
||||
);
|
||||
await dynamo.send(
|
||||
new PutItemCommand({
|
||||
TableName: tableName,
|
||||
Item: { pk: { S: 'item-3' }, sk: { S: 'rev-1' }, data: { S: 'no-gsi-attrs' } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should query GSI', async () => {
|
||||
const resp = await dynamo.send(
|
||||
new QueryCommand({
|
||||
TableName: tableName,
|
||||
IndexName: 'gsi-1',
|
||||
KeyConditionExpression: 'gsiPk = :gpk',
|
||||
ExpressionAttributeValues: { ':gpk': { S: 'group-A' } },
|
||||
})
|
||||
);
|
||||
expect(resp.Count).toBe(2);
|
||||
const pks = new Set(resp.Items?.map((i) => i.pk.S));
|
||||
expect(pks.has('item-3')).toBe(false);
|
||||
});
|
||||
|
||||
it('should query LSI', async () => {
|
||||
const resp = await dynamo.send(
|
||||
new QueryCommand({
|
||||
TableName: tableName,
|
||||
IndexName: 'lsi-1',
|
||||
KeyConditionExpression: 'pk = :pk AND lsiSk > :d',
|
||||
ExpressionAttributeValues: { ':pk': { S: 'item-1' }, ':d': { S: '2024-01-00' } },
|
||||
})
|
||||
);
|
||||
expect(resp.Count).toBe(1);
|
||||
});
|
||||
|
||||
it('should delete table', async () => {
|
||||
await dynamo.send(new DeleteTableCommand({ TableName: tableName }));
|
||||
tableName = '';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* ECR control-plane compatibility tests.
|
||||
*
|
||||
* Test-first: this file is committed before the server-side ECR implementation
|
||||
* lands. With ECR unimplemented, every test below should fail.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
ECRClient,
|
||||
CreateRepositoryCommand,
|
||||
DescribeRepositoriesCommand,
|
||||
DeleteRepositoryCommand,
|
||||
GetAuthorizationTokenCommand,
|
||||
ListImagesCommand,
|
||||
PutImageTagMutabilityCommand,
|
||||
PutLifecyclePolicyCommand,
|
||||
GetLifecyclePolicyCommand,
|
||||
SetRepositoryPolicyCommand,
|
||||
GetRepositoryPolicyCommand,
|
||||
ImageTagMutability,
|
||||
RepositoryAlreadyExistsException,
|
||||
RepositoryNotFoundException,
|
||||
} from '@aws-sdk/client-ecr';
|
||||
import { makeClient } from './setup';
|
||||
|
||||
const REPO_NAME = 'floci-it/app-node';
|
||||
|
||||
describe('ECR Repository Lifecycle', () => {
|
||||
let ecr: ECRClient;
|
||||
|
||||
beforeAll(() => {
|
||||
ecr = makeClient(ECRClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await ecr.send(new DeleteRepositoryCommand({ repositoryName: REPO_NAME, force: true }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('CreateRepository returns a repositoryUri pointing at loopback', async () => {
|
||||
const resp = await ecr.send(new CreateRepositoryCommand({ repositoryName: REPO_NAME }));
|
||||
const repo = resp.repository!;
|
||||
expect(repo.repositoryName).toBe(REPO_NAME);
|
||||
expect(repo.repositoryArn).toMatch(new RegExp(`^arn:aws:ecr:.*:.*:repository/${REPO_NAME}$`));
|
||||
expect(repo.repositoryUri).toContain(`/${REPO_NAME}`);
|
||||
expect(repo.repositoryUri).toContain('localhost:');
|
||||
});
|
||||
|
||||
it('CreateRepository on duplicate name throws RepositoryAlreadyExistsException', async () => {
|
||||
await expect(
|
||||
ecr.send(new CreateRepositoryCommand({ repositoryName: REPO_NAME })),
|
||||
).rejects.toBeInstanceOf(RepositoryAlreadyExistsException);
|
||||
});
|
||||
|
||||
it('DescribeRepositories returns the created repository', async () => {
|
||||
const resp = await ecr.send(
|
||||
new DescribeRepositoriesCommand({ repositoryNames: [REPO_NAME] }),
|
||||
);
|
||||
expect(resp.repositories?.[0]?.repositoryName).toBe(REPO_NAME);
|
||||
});
|
||||
|
||||
it('GetAuthorizationToken returns a usable docker login token', async () => {
|
||||
const resp = await ecr.send(new GetAuthorizationTokenCommand({}));
|
||||
expect(resp.authorizationData).toBeTruthy();
|
||||
const data = resp.authorizationData![0];
|
||||
expect(data.authorizationToken).toBeTruthy();
|
||||
expect(data.proxyEndpoint).toMatch(/^https?:\/\//);
|
||||
expect(data.expiresAt).toBeDefined();
|
||||
const decoded = Buffer.from(data.authorizationToken!, 'base64').toString('utf-8');
|
||||
expect(decoded).toMatch(/^AWS:/);
|
||||
});
|
||||
|
||||
it('ListImages on an empty repository returns no image identifiers', async () => {
|
||||
const resp = await ecr.send(new ListImagesCommand({ repositoryName: REPO_NAME }));
|
||||
expect(resp.imageIds ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('PutImageTagMutability round-trips IMMUTABLE', async () => {
|
||||
const resp = await ecr.send(
|
||||
new PutImageTagMutabilityCommand({
|
||||
repositoryName: REPO_NAME,
|
||||
imageTagMutability: ImageTagMutability.IMMUTABLE,
|
||||
}),
|
||||
);
|
||||
expect(resp.imageTagMutability).toBe(ImageTagMutability.IMMUTABLE);
|
||||
});
|
||||
|
||||
it('PutLifecyclePolicy round-trips the policy text', async () => {
|
||||
const policy =
|
||||
'{"rules":[{"rulePriority":1,"selection":{"tagStatus":"untagged","countType":"imageCountMoreThan","countNumber":5},"action":{"type":"expire"}}]}';
|
||||
await ecr.send(
|
||||
new PutLifecyclePolicyCommand({ repositoryName: REPO_NAME, lifecyclePolicyText: policy }),
|
||||
);
|
||||
const got = await ecr.send(new GetLifecyclePolicyCommand({ repositoryName: REPO_NAME }));
|
||||
expect(got.lifecyclePolicyText).toBe(policy);
|
||||
});
|
||||
|
||||
it('SetRepositoryPolicy round-trips the policy text', async () => {
|
||||
const policy =
|
||||
'{"Version":"2012-10-17","Statement":[{"Sid":"AllowAll","Effect":"Allow","Principal":"*","Action":"ecr:*"}]}';
|
||||
await ecr.send(
|
||||
new SetRepositoryPolicyCommand({ repositoryName: REPO_NAME, policyText: policy }),
|
||||
);
|
||||
const got = await ecr.send(new GetRepositoryPolicyCommand({ repositoryName: REPO_NAME }));
|
||||
expect(got.policyText).toBe(policy);
|
||||
});
|
||||
|
||||
it('DeleteRepository force=true removes the repository', async () => {
|
||||
await ecr.send(new DeleteRepositoryCommand({ repositoryName: REPO_NAME, force: true }));
|
||||
await expect(
|
||||
ecr.send(new DescribeRepositoriesCommand({ repositoryNames: [REPO_NAME] })),
|
||||
).rejects.toBeInstanceOf(RepositoryNotFoundException);
|
||||
});
|
||||
|
||||
it('DescribeRepositories on missing name throws RepositoryNotFoundException', async () => {
|
||||
await expect(
|
||||
ecr.send(
|
||||
new DescribeRepositoriesCommand({ repositoryNames: ['does-not-exist-node'] }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(RepositoryNotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* EventBridge integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
EventBridgeClient,
|
||||
CreateEventBusCommand,
|
||||
DeleteEventBusCommand,
|
||||
DescribeEventBusCommand,
|
||||
ListEventBusesCommand,
|
||||
PutRuleCommand,
|
||||
DeleteRuleCommand,
|
||||
DescribeRuleCommand,
|
||||
ListRulesCommand,
|
||||
EnableRuleCommand,
|
||||
DisableRuleCommand,
|
||||
PutTargetsCommand,
|
||||
RemoveTargetsCommand,
|
||||
ListTargetsByRuleCommand,
|
||||
PutEventsCommand,
|
||||
ListTagsForResourceCommand,
|
||||
} from '@aws-sdk/client-eventbridge';
|
||||
import {
|
||||
SQSClient,
|
||||
CreateQueueCommand,
|
||||
DeleteQueueCommand,
|
||||
GetQueueAttributesCommand,
|
||||
ReceiveMessageCommand,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('EventBridge', () => {
|
||||
let eb: EventBridgeClient;
|
||||
let sqs: SQSClient;
|
||||
let ruleName: string;
|
||||
let busName: string;
|
||||
let ruleArn: string;
|
||||
let sinkQueueUrl: string;
|
||||
let sinkQueueArn: string;
|
||||
let transformerQueueUrl: string;
|
||||
let transformerQueueArn: string;
|
||||
|
||||
beforeAll(() => {
|
||||
eb = makeClient(EventBridgeClient);
|
||||
sqs = makeClient(SQSClient);
|
||||
ruleName = uniqueName('eb-rule');
|
||||
busName = uniqueName('eb-bus');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await eb.send(new RemoveTargetsCommand({ Rule: ruleName, Ids: ['sqs-target', 'transformer-target'] }));
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
await eb.send(new DeleteRuleCommand({ Name: ruleName }));
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
await eb.send(new DeleteEventBusCommand({ Name: busName }));
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
if (sinkQueueUrl) await sqs.send(new DeleteQueueCommand({ QueueUrl: sinkQueueUrl }));
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
if (transformerQueueUrl) await sqs.send(new DeleteQueueCommand({ QueueUrl: transformerQueueUrl }));
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// ──────────────────────────── Event Buses ────────────────────────────
|
||||
|
||||
it('should create an event bus', async () => {
|
||||
const response = await eb.send(new CreateEventBusCommand({ Name: busName }));
|
||||
expect(response.EventBusArn).toContain(busName);
|
||||
});
|
||||
|
||||
it('should describe an event bus', async () => {
|
||||
const response = await eb.send(new DescribeEventBusCommand({ Name: busName }));
|
||||
expect(response.Name).toBe(busName);
|
||||
expect(response.Arn).toContain(busName);
|
||||
});
|
||||
|
||||
it('should list event buses including default', async () => {
|
||||
const response = await eb.send(new ListEventBusesCommand({}));
|
||||
const names = response.EventBuses!.map(b => b.Name);
|
||||
expect(names).toContain('default');
|
||||
expect(names).toContain(busName);
|
||||
});
|
||||
|
||||
// ──────────────────────────── Rules ────────────────────────────
|
||||
|
||||
it('should create a rule', async () => {
|
||||
const response = await eb.send(new PutRuleCommand({
|
||||
Name: ruleName,
|
||||
EventPattern: JSON.stringify({ source: ['com.myapp'] }),
|
||||
State: 'ENABLED',
|
||||
Description: 'Test rule',
|
||||
}));
|
||||
ruleArn = response.RuleArn!;
|
||||
expect(ruleArn).toContain(ruleName);
|
||||
});
|
||||
|
||||
it('should describe the rule', async () => {
|
||||
const response = await eb.send(new DescribeRuleCommand({ Name: ruleName }));
|
||||
expect(response.Name).toBe(ruleName);
|
||||
expect(response.State).toBe('ENABLED');
|
||||
expect(response.EventPattern).toContain('com.myapp');
|
||||
});
|
||||
|
||||
it('should list rules', async () => {
|
||||
const response = await eb.send(new ListRulesCommand({}));
|
||||
const names = response.Rules!.map(r => r.Name);
|
||||
expect(names).toContain(ruleName);
|
||||
});
|
||||
|
||||
it('should disable and re-enable a rule', async () => {
|
||||
await eb.send(new DisableRuleCommand({ Name: ruleName }));
|
||||
expect((await eb.send(new DescribeRuleCommand({ Name: ruleName }))).State).toBe('DISABLED');
|
||||
|
||||
await eb.send(new EnableRuleCommand({ Name: ruleName }));
|
||||
expect((await eb.send(new DescribeRuleCommand({ Name: ruleName }))).State).toBe('ENABLED');
|
||||
});
|
||||
|
||||
// ──────────────────────────── Targets + PutEvents ────────────────────────────
|
||||
|
||||
it('should create sink SQS queue', async () => {
|
||||
sinkQueueUrl = (await sqs.send(new CreateQueueCommand({ QueueName: uniqueName('eb-sink') }))).QueueUrl!;
|
||||
const attrs = await sqs.send(new GetQueueAttributesCommand({
|
||||
QueueUrl: sinkQueueUrl, AttributeNames: ['QueueArn'],
|
||||
}));
|
||||
sinkQueueArn = attrs.Attributes!['QueueArn'];
|
||||
expect(sinkQueueArn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should put SQS target on rule', async () => {
|
||||
const response = await eb.send(new PutTargetsCommand({
|
||||
Rule: ruleName,
|
||||
Targets: [{ Id: 'sqs-target', Arn: sinkQueueArn }],
|
||||
}));
|
||||
expect(response.FailedEntryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should list targets by rule', async () => {
|
||||
const response = await eb.send(new ListTargetsByRuleCommand({ Rule: ruleName }));
|
||||
const ids = response.Targets!.map(t => t.Id);
|
||||
expect(ids).toContain('sqs-target');
|
||||
});
|
||||
|
||||
it('should deliver matching event to SQS target', async () => {
|
||||
await eb.send(new PutEventsCommand({
|
||||
Entries: [{
|
||||
Source: 'com.myapp',
|
||||
DetailType: 'OrderPlaced',
|
||||
Detail: JSON.stringify({ orderId: '123' }),
|
||||
}],
|
||||
}));
|
||||
|
||||
const msg = await sqs.send(new ReceiveMessageCommand({
|
||||
QueueUrl: sinkQueueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
WaitTimeSeconds: 2,
|
||||
}));
|
||||
expect(msg.Messages).toHaveLength(1);
|
||||
expect(msg.Messages![0].Body).toContain('com.myapp');
|
||||
expect(msg.Messages![0].Body).toContain('OrderPlaced');
|
||||
});
|
||||
|
||||
it('should not deliver non-matching event', async () => {
|
||||
await eb.send(new PutEventsCommand({
|
||||
Entries: [{
|
||||
Source: 'other.app',
|
||||
DetailType: 'Ignored',
|
||||
Detail: '{}',
|
||||
}],
|
||||
}));
|
||||
|
||||
const msg = await sqs.send(new ReceiveMessageCommand({
|
||||
QueueUrl: sinkQueueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
}));
|
||||
expect(msg.Messages ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ──────────────────────────── InputTransformer ────────────────────────────
|
||||
|
||||
it('should create transformer SQS queue', async () => {
|
||||
transformerQueueUrl = (await sqs.send(new CreateQueueCommand({ QueueName: uniqueName('eb-xform') }))).QueueUrl!;
|
||||
const attrs = await sqs.send(new GetQueueAttributesCommand({
|
||||
QueueUrl: transformerQueueUrl, AttributeNames: ['QueueArn'],
|
||||
}));
|
||||
transformerQueueArn = attrs.Attributes!['QueueArn'];
|
||||
expect(transformerQueueArn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should put target with InputTransformer', async () => {
|
||||
const response = await eb.send(new PutTargetsCommand({
|
||||
Rule: ruleName,
|
||||
Targets: [{
|
||||
Id: 'transformer-target',
|
||||
Arn: transformerQueueArn,
|
||||
InputTransformer: {
|
||||
InputPathsMap: { src: '$.source', type: '$.detail-type' },
|
||||
InputTemplate: '{"source":"<src>","type":"<type>"}',
|
||||
},
|
||||
}],
|
||||
}));
|
||||
expect(response.FailedEntryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should store InputTransformer correctly on target', async () => {
|
||||
const response = await eb.send(new ListTargetsByRuleCommand({ Rule: ruleName }));
|
||||
const xformTarget = response.Targets!.find(t => t.Id === 'transformer-target');
|
||||
expect(xformTarget).toBeDefined();
|
||||
expect(xformTarget!.InputTransformer).toBeDefined();
|
||||
expect(xformTarget!.InputTransformer!.InputPathsMap).toHaveProperty('src');
|
||||
expect(xformTarget!.InputTransformer!.InputTemplate).toContain('<src>');
|
||||
});
|
||||
|
||||
it('should transform event payload before delivery', async () => {
|
||||
// Drain any prior messages
|
||||
await sqs.send(new ReceiveMessageCommand({
|
||||
QueueUrl: transformerQueueUrl, MaxNumberOfMessages: 10,
|
||||
}));
|
||||
|
||||
await eb.send(new PutEventsCommand({
|
||||
Entries: [{
|
||||
Source: 'com.myapp',
|
||||
DetailType: 'OrderShipped',
|
||||
Detail: JSON.stringify({ orderId: '456' }),
|
||||
}],
|
||||
}));
|
||||
|
||||
const msg = await sqs.send(new ReceiveMessageCommand({
|
||||
QueueUrl: transformerQueueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
WaitTimeSeconds: 2,
|
||||
}));
|
||||
expect(msg.Messages).toHaveLength(1);
|
||||
const body = msg.Messages![0].Body!;
|
||||
expect(body).toContain('com.myapp');
|
||||
expect(body).toContain('OrderShipped');
|
||||
expect(body).not.toContain('orderId');
|
||||
});
|
||||
|
||||
// ──────────────────────────── Tags ────────────────────────────
|
||||
|
||||
it('should list tags for a rule resource', async () => {
|
||||
const response = await eb.send(new ListTagsForResourceCommand({ ResourceARN: ruleArn }));
|
||||
expect(response.Tags).toBeDefined();
|
||||
});
|
||||
|
||||
// ──────────────────────────── Cleanup ────────────────────────────
|
||||
|
||||
it('should remove targets', async () => {
|
||||
const response = await eb.send(new RemoveTargetsCommand({
|
||||
Rule: ruleName,
|
||||
Ids: ['sqs-target', 'transformer-target'],
|
||||
}));
|
||||
expect(response.FailedEntryCount).toBe(0);
|
||||
|
||||
const list = await eb.send(new ListTargetsByRuleCommand({ Rule: ruleName }));
|
||||
expect(list.Targets ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should delete the rule', async () => {
|
||||
await eb.send(new DeleteRuleCommand({ Name: ruleName }));
|
||||
await expect(eb.send(new DescribeRuleCommand({ Name: ruleName })))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should delete the event bus', async () => {
|
||||
await eb.send(new DeleteEventBusCommand({ Name: busName }));
|
||||
const list = await eb.send(new ListEventBusesCommand({}));
|
||||
expect(list.EventBuses!.map(b => b.Name)).not.toContain(busName);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* IAM integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
IAMClient,
|
||||
CreateRoleCommand,
|
||||
GetRoleCommand,
|
||||
DeleteRoleCommand,
|
||||
ListRolesCommand,
|
||||
CreatePolicyCommand,
|
||||
DeletePolicyCommand,
|
||||
AttachRolePolicyCommand,
|
||||
DetachRolePolicyCommand,
|
||||
} from '@aws-sdk/client-iam';
|
||||
import { makeClient, uniqueName, ENDPOINT } from './setup';
|
||||
|
||||
describe('IAM', () => {
|
||||
let iam: IAMClient;
|
||||
let roleName: string;
|
||||
let policyArn: string;
|
||||
|
||||
beforeAll(() => {
|
||||
iam = makeClient(IAMClient, { endpoint: ENDPOINT });
|
||||
roleName = `test-role-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (policyArn) {
|
||||
await iam.send(new DetachRolePolicyCommand({ RoleName: roleName, PolicyArn: policyArn }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (policyArn) {
|
||||
await iam.send(new DeletePolicyCommand({ PolicyArn: policyArn }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await iam.send(new DeleteRoleCommand({ RoleName: roleName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create role', async () => {
|
||||
const policyDoc = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { Service: 'lambda.amazonaws.com' },
|
||||
Action: 'sts:AssumeRole',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await iam.send(
|
||||
new CreateRoleCommand({ RoleName: roleName, AssumeRolePolicyDocument: policyDoc })
|
||||
);
|
||||
expect(response.Role?.Arn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get role', async () => {
|
||||
const response = await iam.send(new GetRoleCommand({ RoleName: roleName }));
|
||||
expect(response.Role?.RoleName).toBe(roleName);
|
||||
});
|
||||
|
||||
it('should list roles', async () => {
|
||||
const response = await iam.send(new ListRolesCommand({}));
|
||||
expect(response.Roles?.some((r) => r.RoleName === roleName)).toBe(true);
|
||||
});
|
||||
|
||||
it('should create policy', async () => {
|
||||
const response = await iam.send(
|
||||
new CreatePolicyCommand({
|
||||
PolicyName: `test-policy-${uniqueName()}`,
|
||||
PolicyDocument: JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [{ Effect: 'Allow', Action: 's3:GetObject', Resource: '*' }],
|
||||
}),
|
||||
})
|
||||
);
|
||||
policyArn = response.Policy!.Arn!;
|
||||
expect(policyArn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should attach role policy', async () => {
|
||||
await iam.send(
|
||||
new AttachRolePolicyCommand({ RoleName: roleName, PolicyArn: policyArn })
|
||||
);
|
||||
});
|
||||
|
||||
it('should detach role policy', async () => {
|
||||
await iam.send(
|
||||
new DetachRolePolicyCommand({ RoleName: roleName, PolicyArn: policyArn })
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete policy', async () => {
|
||||
await iam.send(new DeletePolicyCommand({ PolicyArn: policyArn }));
|
||||
policyArn = '';
|
||||
});
|
||||
|
||||
it('should delete role', async () => {
|
||||
await iam.send(new DeleteRoleCommand({ RoleName: roleName }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Kinesis integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
KinesisClient,
|
||||
CreateStreamCommand,
|
||||
DescribeStreamCommand,
|
||||
PutRecordCommand,
|
||||
GetShardIteratorCommand,
|
||||
GetRecordsCommand,
|
||||
DeleteStreamCommand,
|
||||
ListStreamsCommand,
|
||||
} from '@aws-sdk/client-kinesis';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('Kinesis', () => {
|
||||
let kinesis: KinesisClient;
|
||||
let streamName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
kinesis = makeClient(KinesisClient);
|
||||
streamName = `test-stream-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await kinesis.send(new DeleteStreamCommand({ StreamName: streamName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create stream', async () => {
|
||||
await kinesis.send(new CreateStreamCommand({ StreamName: streamName, ShardCount: 1 }));
|
||||
});
|
||||
|
||||
it('should describe stream', async () => {
|
||||
const response = await kinesis.send(new DescribeStreamCommand({ StreamName: streamName }));
|
||||
expect(['ACTIVE', 'CREATING']).toContain(response.StreamDescription?.StreamStatus);
|
||||
});
|
||||
|
||||
it('should list streams', async () => {
|
||||
const response = await kinesis.send(new ListStreamsCommand({}));
|
||||
expect(response.StreamNames?.includes(streamName)).toBe(true);
|
||||
});
|
||||
|
||||
it('should put record', async () => {
|
||||
const response = await kinesis.send(
|
||||
new PutRecordCommand({
|
||||
StreamName: streamName,
|
||||
Data: Buffer.from('kinesis-test-record'),
|
||||
PartitionKey: 'pk1',
|
||||
})
|
||||
);
|
||||
expect(response.SequenceNumber).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get records', async () => {
|
||||
const descr = await kinesis.send(new DescribeStreamCommand({ StreamName: streamName }));
|
||||
const shardId = descr.StreamDescription!.Shards![0].ShardId!;
|
||||
|
||||
const iterResult = await kinesis.send(
|
||||
new GetShardIteratorCommand({
|
||||
StreamName: streamName,
|
||||
ShardId: shardId,
|
||||
ShardIteratorType: 'TRIM_HORIZON',
|
||||
})
|
||||
);
|
||||
|
||||
const recordsResult = await kinesis.send(
|
||||
new GetRecordsCommand({
|
||||
ShardIterator: iterResult.ShardIterator!,
|
||||
Limit: 10,
|
||||
})
|
||||
);
|
||||
expect(recordsResult.Records?.length).toBeGreaterThan(0);
|
||||
const data = Buffer.from(recordsResult.Records![0].Data!).toString();
|
||||
expect(data).toBe('kinesis-test-record');
|
||||
});
|
||||
|
||||
it('should delete stream', async () => {
|
||||
await kinesis.send(new DeleteStreamCommand({ StreamName: streamName }));
|
||||
streamName = '';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Compatibility tests for KMS fixes:
|
||||
* #269 — CreateKey applies Tags at creation time
|
||||
* #258 — GetKeyPolicy returns the stored policy
|
||||
* #259 — PutKeyPolicy updates the key policy
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
KMSClient,
|
||||
CreateKeyCommand,
|
||||
GetKeyPolicyCommand,
|
||||
PutKeyPolicyCommand,
|
||||
ListResourceTagsCommand,
|
||||
ScheduleKeyDeletionCommand,
|
||||
} from '@aws-sdk/client-kms';
|
||||
import { makeClient } from './setup';
|
||||
|
||||
const kms = makeClient(KMSClient);
|
||||
const createdKeyIds: string[] = [];
|
||||
|
||||
async function createKey(opts: Parameters<typeof CreateKeyCommand>[0] = {}) {
|
||||
const resp = await kms.send(new CreateKeyCommand(opts));
|
||||
const keyId = resp.KeyMetadata!.KeyId!;
|
||||
createdKeyIds.push(keyId);
|
||||
return resp;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
// Schedule deletion for all keys created in the test
|
||||
while (createdKeyIds.length > 0) {
|
||||
const keyId = createdKeyIds.pop()!;
|
||||
try {
|
||||
await kms.send(new ScheduleKeyDeletionCommand({ KeyId: keyId, PendingWindowInDays: 7 }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Issue #269 — CreateKey applies Tags ───────────────────────────────────────
|
||||
|
||||
describe('KMS features (#258 #259 #269)', () => {
|
||||
|
||||
it('#269: CreateKey with Tags stores tags immediately', async () => {
|
||||
const resp = await createKey({
|
||||
Description: 'tagged-key',
|
||||
Tags: [
|
||||
{ TagKey: 'env', TagValue: 'prod' },
|
||||
{ TagKey: 'team', TagValue: 'platform' },
|
||||
],
|
||||
});
|
||||
const keyId = resp.KeyMetadata!.KeyId!;
|
||||
|
||||
const tags = await kms.send(new ListResourceTagsCommand({ KeyId: keyId }));
|
||||
const tagMap = Object.fromEntries(tags.Tags!.map(t => [t.TagKey!, t.TagValue!]));
|
||||
|
||||
expect(tagMap['env']).toBe('prod');
|
||||
expect(tagMap['team']).toBe('platform');
|
||||
});
|
||||
|
||||
it('#269: CreateKey without Tags has empty tag list', async () => {
|
||||
const resp = await createKey({ Description: 'no-tags-key' });
|
||||
const keyId = resp.KeyMetadata!.KeyId!;
|
||||
|
||||
const tags = await kms.send(new ListResourceTagsCommand({ KeyId: keyId }));
|
||||
expect(tags.Tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Issue #258 — GetKeyPolicy ───────────────────────────────────────────────
|
||||
|
||||
it('#258: CreateKey without Policy returns a non-empty default policy', async () => {
|
||||
const resp = await createKey({ Description: 'default-policy-key' });
|
||||
const keyId = resp.KeyMetadata!.KeyId!;
|
||||
|
||||
const policyResp = await kms.send(new GetKeyPolicyCommand({ KeyId: keyId }));
|
||||
expect(policyResp.Policy).toBeTruthy();
|
||||
expect(policyResp.PolicyName).toBe('default');
|
||||
expect(policyResp.Policy).toContain('kms:*');
|
||||
});
|
||||
|
||||
it('#258: CreateKey with Policy stores and returns that policy', async () => {
|
||||
const customPolicy = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Sid: 'Custom',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::000000000000:root' },
|
||||
Action: 'kms:*',
|
||||
Resource: '*',
|
||||
}],
|
||||
});
|
||||
|
||||
const resp = await createKey({ Description: 'custom-policy-key', Policy: customPolicy });
|
||||
const keyId = resp.KeyMetadata!.KeyId!;
|
||||
|
||||
const policyResp = await kms.send(new GetKeyPolicyCommand({ KeyId: keyId }));
|
||||
expect(policyResp.Policy).toBe(customPolicy);
|
||||
expect(policyResp.PolicyName).toBe('default');
|
||||
});
|
||||
|
||||
// ── Issue #259 — PutKeyPolicy ───────────────────────────────────────────────
|
||||
|
||||
it('#259: PutKeyPolicy updates the key policy', async () => {
|
||||
const resp = await createKey({ Description: 'put-policy-key' });
|
||||
const keyId = resp.KeyMetadata!.KeyId!;
|
||||
|
||||
const newPolicy = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Sid: 'Updated',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::000000000000:root' },
|
||||
Action: 'kms:Decrypt',
|
||||
Resource: '*',
|
||||
}],
|
||||
});
|
||||
|
||||
await kms.send(new PutKeyPolicyCommand({ KeyId: keyId, Policy: newPolicy }));
|
||||
|
||||
const policyResp = await kms.send(new GetKeyPolicyCommand({ KeyId: keyId }));
|
||||
expect(policyResp.Policy).toBe(newPolicy);
|
||||
});
|
||||
|
||||
it('#259: PutKeyPolicy round-trip — get, change, verify', async () => {
|
||||
const resp = await createKey({ Description: 'round-trip-key' });
|
||||
const keyId = resp.KeyMetadata!.KeyId!;
|
||||
|
||||
const initial = (await kms.send(new GetKeyPolicyCommand({ KeyId: keyId }))).Policy!;
|
||||
expect(initial).toBeTruthy();
|
||||
|
||||
const updated = JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Sid: 'RoundTrip',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::000000000000:root' },
|
||||
Action: 'kms:*',
|
||||
Resource: '*',
|
||||
}],
|
||||
});
|
||||
|
||||
await kms.send(new PutKeyPolicyCommand({ KeyId: keyId, Policy: updated }));
|
||||
|
||||
const after = (await kms.send(new GetKeyPolicyCommand({ KeyId: keyId }))).Policy!;
|
||||
expect(after).toBe(updated);
|
||||
expect(after).not.toBe(initial);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* KMS integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import {
|
||||
KMSClient,
|
||||
CreateKeyCommand,
|
||||
ListKeysCommand,
|
||||
DescribeKeyCommand,
|
||||
EncryptCommand,
|
||||
DecryptCommand,
|
||||
GenerateDataKeyCommand,
|
||||
GenerateRandomCommand,
|
||||
} from '@aws-sdk/client-kms';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('KMS', () => {
|
||||
let kms: KMSClient;
|
||||
let keyId: string;
|
||||
let ciphertext: Uint8Array;
|
||||
|
||||
beforeAll(() => {
|
||||
kms = makeClient(KMSClient);
|
||||
});
|
||||
|
||||
it('should create key', async () => {
|
||||
const response = await kms.send(
|
||||
new CreateKeyCommand({ Description: `test-key-${uniqueName()}` })
|
||||
);
|
||||
keyId = response.KeyMetadata!.KeyId!;
|
||||
expect(keyId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should describe key', async () => {
|
||||
const response = await kms.send(new DescribeKeyCommand({ KeyId: keyId }));
|
||||
expect(response.KeyMetadata?.Enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should list keys', async () => {
|
||||
const response = await kms.send(new ListKeysCommand({}));
|
||||
expect(response.Keys?.some((k) => k.KeyId === keyId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should encrypt data', async () => {
|
||||
const response = await kms.send(
|
||||
new EncryptCommand({
|
||||
KeyId: keyId,
|
||||
Plaintext: Buffer.from('hello from test'),
|
||||
})
|
||||
);
|
||||
ciphertext = response.CiphertextBlob!;
|
||||
expect(ciphertext).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should decrypt data', async () => {
|
||||
const response = await kms.send(new DecryptCommand({ CiphertextBlob: ciphertext }));
|
||||
const plaintext = Buffer.from(response.Plaintext!).toString();
|
||||
expect(plaintext).toBe('hello from test');
|
||||
});
|
||||
|
||||
it('should generate data key', async () => {
|
||||
const response = await kms.send(
|
||||
new GenerateDataKeyCommand({ KeyId: keyId, KeySpec: 'AES_256' })
|
||||
);
|
||||
expect(response.Plaintext).toBeTruthy();
|
||||
expect(response.CiphertextBlob).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should generate random bytes', async () => {
|
||||
const response = await kms.send(new GenerateRandomCommand({ NumberOfBytes: 32 }));
|
||||
expect(response.Plaintext).toBeTruthy();
|
||||
expect(response.Plaintext!.byteLength).toBe(32);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Lambda Layers compatibility tests.
|
||||
*
|
||||
* Verifies that the real @aws-sdk/client-lambda can round-trip through
|
||||
* Floci's layer endpoints without wire-format issues.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
LambdaClient,
|
||||
PublishLayerVersionCommand,
|
||||
GetLayerVersionCommand,
|
||||
ListLayerVersionsCommand,
|
||||
ListLayersCommand,
|
||||
DeleteLayerVersionCommand,
|
||||
CreateFunctionCommand,
|
||||
GetFunctionConfigurationCommand,
|
||||
UpdateFunctionConfigurationCommand,
|
||||
DeleteFunctionCommand,
|
||||
} from '@aws-sdk/client-lambda';
|
||||
import { makeClient, uniqueName, ACCOUNT, buildMinimalZip } from './setup';
|
||||
|
||||
describe('Lambda Layers', () => {
|
||||
let lambda: LambdaClient;
|
||||
let layerName: string;
|
||||
let layerArn: string;
|
||||
let layerVersionArn1: string;
|
||||
let layerVersionArn2: string;
|
||||
|
||||
beforeAll(() => {
|
||||
lambda = makeClient(LambdaClient);
|
||||
layerName = `compat-layer-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up any remaining layer versions
|
||||
try {
|
||||
await lambda.send(new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 1 }));
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
await lambda.send(new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 2 }));
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should publish layer version 1', async () => {
|
||||
const layerContent = Buffer.from('module.exports.util = () => "v1";');
|
||||
const zipBuffer = buildMinimalZip('nodejs/node_modules/shared/index.js', layerContent);
|
||||
|
||||
const response = await lambda.send(
|
||||
new PublishLayerVersionCommand({
|
||||
LayerName: layerName,
|
||||
Content: { ZipFile: zipBuffer },
|
||||
CompatibleRuntimes: ['nodejs20.x', 'nodejs22.x'],
|
||||
CompatibleArchitectures: ['x86_64'],
|
||||
Description: 'Shared utilities v1',
|
||||
LicenseInfo: 'MIT',
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.Version).toBe(1);
|
||||
expect(response.LayerArn).toContain(`:layer:${layerName}`);
|
||||
expect(response.LayerVersionArn).toContain(`:layer:${layerName}:1`);
|
||||
expect(response.Description).toBe('Shared utilities v1');
|
||||
expect(response.LicenseInfo).toBe('MIT');
|
||||
expect(response.CompatibleRuntimes).toContain('nodejs20.x');
|
||||
expect(response.CompatibleRuntimes).toContain('nodejs22.x');
|
||||
expect(response.CompatibleArchitectures).toContain('x86_64');
|
||||
expect(response.Content?.CodeSize).toBeGreaterThan(0);
|
||||
expect(response.Content?.CodeSha256).toBeTruthy();
|
||||
expect(response.CreatedDate).toBeTruthy();
|
||||
|
||||
layerArn = response.LayerArn!;
|
||||
layerVersionArn1 = response.LayerVersionArn!;
|
||||
});
|
||||
|
||||
it('should publish layer version 2', async () => {
|
||||
const layerContent = Buffer.from('module.exports.util = () => "v2";');
|
||||
const zipBuffer = buildMinimalZip('nodejs/node_modules/shared/index.js', layerContent);
|
||||
|
||||
const response = await lambda.send(
|
||||
new PublishLayerVersionCommand({
|
||||
LayerName: layerName,
|
||||
Content: { ZipFile: zipBuffer },
|
||||
CompatibleRuntimes: ['nodejs22.x'],
|
||||
Description: 'Shared utilities v2',
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.Version).toBe(2);
|
||||
expect(response.LayerVersionArn).toContain(`:layer:${layerName}:2`);
|
||||
expect(response.Description).toBe('Shared utilities v2');
|
||||
|
||||
layerVersionArn2 = response.LayerVersionArn!;
|
||||
});
|
||||
|
||||
it('should get layer version 1', async () => {
|
||||
const response = await lambda.send(
|
||||
new GetLayerVersionCommand({
|
||||
LayerName: layerName,
|
||||
VersionNumber: 1,
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.Version).toBe(1);
|
||||
expect(response.LayerArn).toBe(layerArn);
|
||||
expect(response.LayerVersionArn).toBe(layerVersionArn1);
|
||||
expect(response.Description).toBe('Shared utilities v1');
|
||||
expect(response.Content?.CodeSha256).toBeTruthy();
|
||||
expect(response.Content?.CodeSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fail to get non-existent layer version', async () => {
|
||||
await expect(
|
||||
lambda.send(new GetLayerVersionCommand({ LayerName: layerName, VersionNumber: 99 }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should list layer versions', async () => {
|
||||
const response = await lambda.send(
|
||||
new ListLayerVersionsCommand({ LayerName: layerName })
|
||||
);
|
||||
|
||||
expect(response.LayerVersions).toHaveLength(2);
|
||||
expect(response.LayerVersions![0].Version).toBe(1);
|
||||
expect(response.LayerVersions![1].Version).toBe(2);
|
||||
});
|
||||
|
||||
it('should list layers and include our layer', async () => {
|
||||
const response = await lambda.send(new ListLayersCommand({}));
|
||||
|
||||
const ourLayer = response.Layers?.find((l) => l.LayerName === layerName);
|
||||
expect(ourLayer).toBeDefined();
|
||||
expect(ourLayer!.LayerArn).toBe(layerArn);
|
||||
expect(ourLayer!.LatestMatchingVersion?.Version).toBe(2);
|
||||
});
|
||||
|
||||
it('should delete layer version 1', async () => {
|
||||
// DeleteLayerVersion returns nothing on success (204)
|
||||
await lambda.send(
|
||||
new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 1 })
|
||||
);
|
||||
|
||||
// Verify it's gone
|
||||
await expect(
|
||||
lambda.send(new GetLayerVersionCommand({ LayerName: layerName, VersionNumber: 1 }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should list layer versions after deletion', async () => {
|
||||
const response = await lambda.send(
|
||||
new ListLayerVersionsCommand({ LayerName: layerName })
|
||||
);
|
||||
|
||||
expect(response.LayerVersions).toHaveLength(1);
|
||||
expect(response.LayerVersions![0].Version).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle delete of non-existent version gracefully', async () => {
|
||||
// AWS returns 204 even for non-existent versions
|
||||
await lambda.send(
|
||||
new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 99 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lambda Layer Attachment', () => {
|
||||
let lambda: LambdaClient;
|
||||
let layerName: string;
|
||||
let layerVersionArn: string;
|
||||
let fnName: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
lambda = makeClient(LambdaClient);
|
||||
layerName = `attach-layer-${uniqueName()}`;
|
||||
fnName = `layer-fn-${uniqueName()}`;
|
||||
|
||||
// Publish a layer to attach
|
||||
const layerContent = Buffer.from('module.exports.dep = () => "shared";');
|
||||
const zipBuffer = buildMinimalZip('nodejs/node_modules/dep/index.js', layerContent);
|
||||
|
||||
const layerResp = await lambda.send(
|
||||
new PublishLayerVersionCommand({
|
||||
LayerName: layerName,
|
||||
Content: { ZipFile: zipBuffer },
|
||||
CompatibleRuntimes: ['nodejs20.x'],
|
||||
})
|
||||
);
|
||||
layerVersionArn = layerResp.LayerVersionArn!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName }));
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
await lambda.send(new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 1 }));
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('should create function with layer attached', async () => {
|
||||
const handlerCode = "exports.handler = async () => ({ statusCode: 200 });";
|
||||
const zipBuffer = buildMinimalZip('index.js', Buffer.from(handlerCode));
|
||||
|
||||
const response = await lambda.send(
|
||||
new CreateFunctionCommand({
|
||||
FunctionName: fnName,
|
||||
Runtime: 'nodejs20.x',
|
||||
Role: `arn:aws:iam::${ACCOUNT}:role/lambda-role`,
|
||||
Handler: 'index.handler',
|
||||
Code: { ZipFile: zipBuffer },
|
||||
Layers: [layerVersionArn],
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.Layers).toHaveLength(1);
|
||||
expect(response.Layers![0].Arn).toBe(layerVersionArn);
|
||||
});
|
||||
|
||||
it('should show layers in function configuration', async () => {
|
||||
const response = await lambda.send(
|
||||
new GetFunctionConfigurationCommand({ FunctionName: fnName })
|
||||
);
|
||||
|
||||
expect(response.Layers).toHaveLength(1);
|
||||
expect(response.Layers![0].Arn).toBe(layerVersionArn);
|
||||
});
|
||||
|
||||
it('should update function to remove layers', async () => {
|
||||
const response = await lambda.send(
|
||||
new UpdateFunctionConfigurationCommand({
|
||||
FunctionName: fnName,
|
||||
Layers: [],
|
||||
})
|
||||
);
|
||||
|
||||
// After removing layers, the field should be empty or absent
|
||||
expect(response.Layers ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should update function to add layers back', async () => {
|
||||
const response = await lambda.send(
|
||||
new UpdateFunctionConfigurationCommand({
|
||||
FunctionName: fnName,
|
||||
Layers: [layerVersionArn],
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.Layers).toHaveLength(1);
|
||||
expect(response.Layers![0].Arn).toBe(layerVersionArn);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lambda Layers listing parameters', () => {
|
||||
let lambda: LambdaClient;
|
||||
let layerName: string;
|
||||
|
||||
const zip = (marker: string) =>
|
||||
buildMinimalZip('nodejs/node_modules/shared/index.js', Buffer.from(marker));
|
||||
|
||||
beforeAll(async () => {
|
||||
lambda = makeClient(LambdaClient);
|
||||
layerName = `compat-layer-params-${uniqueName()}`;
|
||||
|
||||
// v1 nodejs20.x/x86_64, v2 python3.13/arm64, v3 nodejs20.x/arm64: every filter
|
||||
// combination below then selects a different subset of the same layer.
|
||||
await lambda.send(
|
||||
new PublishLayerVersionCommand({
|
||||
LayerName: layerName,
|
||||
Content: { ZipFile: zip('v1') },
|
||||
CompatibleRuntimes: ['nodejs20.x'],
|
||||
CompatibleArchitectures: ['x86_64'],
|
||||
})
|
||||
);
|
||||
await lambda.send(
|
||||
new PublishLayerVersionCommand({
|
||||
LayerName: layerName,
|
||||
Content: { ZipFile: zip('v2') },
|
||||
CompatibleRuntimes: ['python3.13'],
|
||||
CompatibleArchitectures: ['arm64'],
|
||||
})
|
||||
);
|
||||
await lambda.send(
|
||||
new PublishLayerVersionCommand({
|
||||
LayerName: layerName,
|
||||
Content: { ZipFile: zip('v3') },
|
||||
CompatibleRuntimes: ['nodejs20.x'],
|
||||
CompatibleArchitectures: ['arm64'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const VersionNumber of [1, 2, 3]) {
|
||||
try {
|
||||
await lambda.send(new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('filters listLayerVersions by CompatibleRuntime', async () => {
|
||||
const response = await lambda.send(
|
||||
new ListLayerVersionsCommand({ LayerName: layerName, CompatibleRuntime: 'python3.13' })
|
||||
);
|
||||
|
||||
expect(response.LayerVersions?.map((v) => v.Version)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('filters listLayerVersions by CompatibleArchitecture', async () => {
|
||||
const response = await lambda.send(
|
||||
new ListLayerVersionsCommand({ LayerName: layerName, CompatibleArchitecture: 'arm64' })
|
||||
);
|
||||
|
||||
expect(response.LayerVersions?.map((v) => v.Version).sort()).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it('applies both filters together', async () => {
|
||||
const response = await lambda.send(
|
||||
new ListLayerVersionsCommand({
|
||||
LayerName: layerName,
|
||||
CompatibleRuntime: 'nodejs20.x',
|
||||
CompatibleArchitecture: 'arm64',
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.LayerVersions?.map((v) => v.Version)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('pages listLayerVersions with MaxItems and resumes from NextMarker', async () => {
|
||||
const seen: number[] = [];
|
||||
let marker: string | undefined;
|
||||
let pages = 0;
|
||||
|
||||
do {
|
||||
const page = await lambda.send(
|
||||
new ListLayerVersionsCommand({ LayerName: layerName, MaxItems: 1, Marker: marker })
|
||||
);
|
||||
expect(page.LayerVersions).toHaveLength(1);
|
||||
seen.push(page.LayerVersions![0].Version!);
|
||||
marker = page.NextMarker;
|
||||
pages += 1;
|
||||
} while (marker && pages < 5);
|
||||
|
||||
// The marker walks every version exactly once and clears on the last page.
|
||||
expect(marker).toBeFalsy();
|
||||
expect(seen.sort()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('reports the newest matching version as LatestMatchingVersion, not the newest overall', async () => {
|
||||
const python = await lambda.send(
|
||||
new ListLayersCommand({ CompatibleRuntime: 'python3.13' })
|
||||
);
|
||||
const node = await lambda.send(new ListLayersCommand({ CompatibleRuntime: 'nodejs20.x' }));
|
||||
|
||||
expect(
|
||||
python.Layers?.find((l) => l.LayerName === layerName)?.LatestMatchingVersion?.Version
|
||||
).toBe(2);
|
||||
expect(
|
||||
node.Layers?.find((l) => l.LayerName === layerName)?.LatestMatchingVersion?.Version
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('drops a layer with no matching version out of listLayers', async () => {
|
||||
const response = await lambda.send(new ListLayersCommand({ CompatibleRuntime: 'java21' }));
|
||||
|
||||
expect(response.Layers?.some((l) => l.LayerName === layerName)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown CompatibleRuntime with ValidationException', async () => {
|
||||
await expect(
|
||||
lambda.send(new ListLayersCommand({ CompatibleRuntime: 'bogus9.x' }))
|
||||
).rejects.toMatchObject({ name: 'ValidationException' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Lambda integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
LambdaClient,
|
||||
CreateFunctionCommand,
|
||||
GetFunctionCommand,
|
||||
GetFunctionConfigurationCommand,
|
||||
UpdateFunctionConfigurationCommand,
|
||||
ListFunctionsCommand,
|
||||
DeleteFunctionCommand,
|
||||
CreateAliasCommand,
|
||||
GetAliasCommand,
|
||||
ListAliasesCommand,
|
||||
UpdateAliasCommand,
|
||||
DeleteAliasCommand,
|
||||
PublishVersionCommand,
|
||||
ListVersionsByFunctionCommand,
|
||||
UpdateFunctionCodeCommand,
|
||||
} from '@aws-sdk/client-lambda';
|
||||
import { makeClient, uniqueName, ACCOUNT, buildMinimalZip } from './setup';
|
||||
|
||||
describe('Lambda', () => {
|
||||
let lambda: LambdaClient;
|
||||
let fnName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
lambda = makeClient(LambdaClient);
|
||||
fnName = `test-fn-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await lambda.send(new DeleteAliasCommand({ FunctionName: fnName, Name: 'live' }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create function', async () => {
|
||||
const handlerCode = "exports.handler = async (event) => ({ statusCode: 200, body: 'ok' });";
|
||||
const zipBuffer = buildMinimalZip('index.js', Buffer.from(handlerCode));
|
||||
|
||||
await lambda.send(
|
||||
new CreateFunctionCommand({
|
||||
FunctionName: fnName,
|
||||
Runtime: 'nodejs18.x',
|
||||
Role: `arn:aws:iam::${ACCOUNT}:role/lambda-role`,
|
||||
Handler: 'index.handler',
|
||||
Code: { ZipFile: zipBuffer },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should get function', async () => {
|
||||
const response = await lambda.send(new GetFunctionCommand({ FunctionName: fnName }));
|
||||
expect(response.Configuration?.FunctionName).toBe(fnName);
|
||||
});
|
||||
|
||||
it('should list functions', async () => {
|
||||
const response = await lambda.send(new ListFunctionsCommand({}));
|
||||
expect(response.Functions?.some((f) => f.FunctionName === fnName)).toBe(true);
|
||||
});
|
||||
|
||||
it('should publish version', async () => {
|
||||
const response = await lambda.send(
|
||||
new PublishVersionCommand({ FunctionName: fnName, Description: 'v1' })
|
||||
);
|
||||
expect(response.Version).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should create alias', async () => {
|
||||
const response = await lambda.send(
|
||||
new CreateAliasCommand({
|
||||
FunctionName: fnName,
|
||||
Name: 'live',
|
||||
FunctionVersion: '$LATEST',
|
||||
Description: 'live alias',
|
||||
})
|
||||
);
|
||||
expect(response.AliasArn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get alias', async () => {
|
||||
const response = await lambda.send(
|
||||
new GetAliasCommand({ FunctionName: fnName, Name: 'live' })
|
||||
);
|
||||
expect(response.Name).toBe('live');
|
||||
});
|
||||
|
||||
it('should list aliases', async () => {
|
||||
const response = await lambda.send(new ListAliasesCommand({ FunctionName: fnName }));
|
||||
expect(response.Aliases?.some((a) => a.Name === 'live')).toBe(true);
|
||||
});
|
||||
|
||||
it('should update alias', async () => {
|
||||
const response = await lambda.send(
|
||||
new UpdateAliasCommand({
|
||||
FunctionName: fnName,
|
||||
Name: 'live',
|
||||
Description: 'updated description',
|
||||
})
|
||||
);
|
||||
expect(response.Description).toBe('updated description');
|
||||
});
|
||||
|
||||
it('should delete alias', async () => {
|
||||
await lambda.send(new DeleteAliasCommand({ FunctionName: fnName, Name: 'live' }));
|
||||
});
|
||||
|
||||
it('should fail to get deleted alias', async () => {
|
||||
await expect(
|
||||
lambda.send(new GetAliasCommand({ FunctionName: fnName, Name: 'live' }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should delete function', async () => {
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName }));
|
||||
fnName = '';
|
||||
});
|
||||
|
||||
it('should fail to get deleted function', async () => {
|
||||
await expect(
|
||||
lambda.send(new GetFunctionCommand({ FunctionName: `test-fn-${uniqueName()}` }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lambda ImageConfig.WorkingDirectory', () => {
|
||||
let lambda: LambdaClient;
|
||||
const IMAGE_URI = '000000000000.dkr.ecr.us-east-1.amazonaws.com/fake-repo:latest';
|
||||
const ROLE = 'arn:aws:iam::000000000000:role/lambda-role';
|
||||
|
||||
beforeAll(() => {
|
||||
lambda = makeClient(LambdaClient);
|
||||
});
|
||||
|
||||
it('round-trips WorkingDirectory through create and get', async () => {
|
||||
const fnName = `test-imgwd-${uniqueName()}`;
|
||||
try {
|
||||
const createResp = await lambda.send(new CreateFunctionCommand({
|
||||
FunctionName: fnName,
|
||||
PackageType: 'Image',
|
||||
Role: ROLE,
|
||||
Code: { ImageUri: IMAGE_URI },
|
||||
ImageConfig: { WorkingDirectory: '/app' },
|
||||
}));
|
||||
|
||||
expect(createResp.ImageConfigResponse?.ImageConfig?.WorkingDirectory).toBe('/app');
|
||||
|
||||
const getResp = await lambda.send(new GetFunctionConfigurationCommand({
|
||||
FunctionName: fnName,
|
||||
}));
|
||||
|
||||
expect(getResp.ImageConfigResponse?.ImageConfig?.WorkingDirectory).toBe('/app');
|
||||
} finally {
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('updates WorkingDirectory via updateFunctionConfiguration', async () => {
|
||||
const fnName = `test-imgwd-upd-${uniqueName()}`;
|
||||
try {
|
||||
await lambda.send(new CreateFunctionCommand({
|
||||
FunctionName: fnName,
|
||||
PackageType: 'Image',
|
||||
Role: ROLE,
|
||||
Code: { ImageUri: IMAGE_URI },
|
||||
ImageConfig: { WorkingDirectory: '/initial' },
|
||||
}));
|
||||
|
||||
const updateResp = await lambda.send(new UpdateFunctionConfigurationCommand({
|
||||
FunctionName: fnName,
|
||||
ImageConfig: { WorkingDirectory: '/updated' },
|
||||
}));
|
||||
|
||||
expect(updateResp.ImageConfigResponse?.ImageConfig?.WorkingDirectory).toBe('/updated');
|
||||
} finally {
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName })).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lambda Publish flag', () => {
|
||||
let lambda: LambdaClient;
|
||||
let fnName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
lambda = makeClient(LambdaClient);
|
||||
fnName = `test-publish-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName })).catch(() => {});
|
||||
});
|
||||
|
||||
it('should publish version 1 on create and keep the ARN unqualified', async () => {
|
||||
const response = await lambda.send(new CreateFunctionCommand({
|
||||
FunctionName: fnName,
|
||||
Runtime: 'nodejs20.x',
|
||||
Role: `arn:aws:iam::${ACCOUNT}:role/lambda-role`,
|
||||
Handler: 'index.handler',
|
||||
Code: { ZipFile: buildMinimalZip('index.js', Buffer.from('exports.handler = async () => 1;')) },
|
||||
Publish: true,
|
||||
}));
|
||||
|
||||
expect(response.Version).toBe('1');
|
||||
expect(response.FunctionArn).toMatch(new RegExp(`:function:${fnName}$`));
|
||||
|
||||
const listed = await lambda.send(new ListVersionsByFunctionCommand({ FunctionName: fnName }));
|
||||
expect((listed.Versions ?? []).map((v) => v.Version).sort()).toEqual(['$LATEST', '1']);
|
||||
});
|
||||
|
||||
it('should publish the next version on code update and return a qualified ARN', async () => {
|
||||
const response = await lambda.send(new UpdateFunctionCodeCommand({
|
||||
FunctionName: fnName,
|
||||
ZipFile: buildMinimalZip('index.js', Buffer.from('exports.handler = async () => 2;')),
|
||||
Publish: true,
|
||||
}));
|
||||
|
||||
expect(response.Version).toBe('2');
|
||||
expect(response.FunctionArn).toMatch(new RegExp(`:function:${fnName}:2$`));
|
||||
});
|
||||
|
||||
it('should create no version when Publish is not set', async () => {
|
||||
const response = await lambda.send(new UpdateFunctionCodeCommand({
|
||||
FunctionName: fnName,
|
||||
ZipFile: buildMinimalZip('index.js', Buffer.from('exports.handler = async () => 3;')),
|
||||
}));
|
||||
|
||||
expect(response.Version).toBe('$LATEST');
|
||||
|
||||
const listed = await lambda.send(new ListVersionsByFunctionCommand({ FunctionName: fnName }));
|
||||
expect((listed.Versions ?? []).map((v) => v.Version).sort()).toEqual(['$LATEST', '1', '2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lambda version deletion', () => {
|
||||
let lambda: LambdaClient;
|
||||
let fnName: string;
|
||||
|
||||
const versions = async () => {
|
||||
const r = await lambda.send(new ListVersionsByFunctionCommand({ FunctionName: fnName }));
|
||||
return (r.Versions ?? []).map((v) => v.Version).sort();
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
lambda = makeClient(LambdaClient);
|
||||
fnName = `test-delver-${uniqueName()}`;
|
||||
|
||||
await lambda.send(new CreateFunctionCommand({
|
||||
FunctionName: fnName,
|
||||
Runtime: 'nodejs20.x',
|
||||
Role: `arn:aws:iam::${ACCOUNT}:role/lambda-role`,
|
||||
Handler: 'index.handler',
|
||||
Code: { ZipFile: buildMinimalZip('index.js', Buffer.from('exports.handler = async () => 1;')) },
|
||||
}));
|
||||
await lambda.send(new PublishVersionCommand({ FunctionName: fnName }));
|
||||
await lambda.send(new UpdateFunctionCodeCommand({
|
||||
FunctionName: fnName,
|
||||
ZipFile: buildMinimalZip('index.js', Buffer.from('exports.handler = async () => 2;')),
|
||||
}));
|
||||
await lambda.send(new PublishVersionCommand({ FunctionName: fnName }));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName })).catch(() => {});
|
||||
});
|
||||
|
||||
it('should delete only the qualified version, leaving the function intact', async () => {
|
||||
expect(await versions()).toEqual(['$LATEST', '1', '2']);
|
||||
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName, Qualifier: '2' }));
|
||||
|
||||
// The function survives — before this was fixed, the whole function was deleted.
|
||||
const fn = await lambda.send(new GetFunctionCommand({ FunctionName: fnName }));
|
||||
expect(fn.Configuration?.FunctionName).toBe(fnName);
|
||||
expect(await versions()).toEqual(['$LATEST', '1']);
|
||||
});
|
||||
|
||||
it('should refuse to delete $LATEST by qualifier', async () => {
|
||||
await expect(
|
||||
lambda.send(new DeleteFunctionCommand({ FunctionName: fnName, Qualifier: '$LATEST' }))
|
||||
).rejects.toThrow(/cannot be deleted without deleting the function/);
|
||||
expect(await versions()).toEqual(['$LATEST', '1']);
|
||||
});
|
||||
|
||||
it('should refuse to delete a version an alias references', async () => {
|
||||
await lambda.send(new CreateAliasCommand({
|
||||
FunctionName: fnName,
|
||||
Name: 'pinned',
|
||||
FunctionVersion: '1',
|
||||
}));
|
||||
|
||||
await expect(
|
||||
lambda.send(new DeleteFunctionCommand({ FunctionName: fnName, Qualifier: '1' }))
|
||||
).rejects.toThrow(/aliases reference it/);
|
||||
expect(await versions()).toEqual(['$LATEST', '1']);
|
||||
});
|
||||
|
||||
it('should treat a version that does not exist as a no-op', async () => {
|
||||
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName, Qualifier: '99' }));
|
||||
expect(await versions()).toEqual(['$LATEST', '1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Neptune cluster and instance integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
NeptuneClient,
|
||||
CreateDBClusterCommand,
|
||||
DescribeDBClustersCommand,
|
||||
ModifyDBClusterCommand,
|
||||
DeleteDBClusterCommand,
|
||||
CreateDBInstanceCommand,
|
||||
DescribeDBInstancesCommand,
|
||||
ModifyDBInstanceCommand,
|
||||
DeleteDBInstanceCommand,
|
||||
} from '@aws-sdk/client-neptune';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
const neptune = makeClient(NeptuneClient);
|
||||
|
||||
async function deleteCluster(id: string) {
|
||||
try {
|
||||
await neptune.send(new DeleteDBClusterCommand({ DBClusterIdentifier: id, SkipFinalSnapshot: true }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function deleteInstance(id: string) {
|
||||
try {
|
||||
await neptune.send(new DeleteDBInstanceCommand({ DBInstanceIdentifier: id }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
describe('Neptune Clusters', () => {
|
||||
it('should create a cluster', async () => {
|
||||
const clusterId = `node-${uniqueName()}`;
|
||||
|
||||
try {
|
||||
const response = await neptune.send(new CreateDBClusterCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
Engine: 'neptune',
|
||||
}));
|
||||
const cluster = response.DBCluster!;
|
||||
expect(cluster.DBClusterIdentifier).toBe(clusterId);
|
||||
expect(cluster.Engine).toBe('neptune');
|
||||
expect(cluster.Status).toBe('available');
|
||||
expect(cluster.DBClusterArn).toMatch(/^arn:aws:neptune:/);
|
||||
expect(cluster.Port).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await deleteCluster(clusterId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail on duplicate cluster creation', async () => {
|
||||
const clusterId = `node-${uniqueName()}`;
|
||||
|
||||
await neptune.send(new CreateDBClusterCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
Engine: 'neptune',
|
||||
}));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
neptune.send(new CreateDBClusterCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
Engine: 'neptune',
|
||||
}))
|
||||
).rejects.toMatchObject({
|
||||
name: 'DBClusterAlreadyExistsFault',
|
||||
});
|
||||
} finally {
|
||||
await deleteCluster(clusterId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should describe cluster by ID', async () => {
|
||||
const clusterId = `node-${uniqueName()}`;
|
||||
|
||||
await neptune.send(new CreateDBClusterCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
Engine: 'neptune',
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await neptune.send(new DescribeDBClustersCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
}));
|
||||
expect(response.DBClusters).toHaveLength(1);
|
||||
expect(response.DBClusters![0].DBClusterIdentifier).toBe(clusterId);
|
||||
} finally {
|
||||
await deleteCluster(clusterId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should modify cluster IAM auth', async () => {
|
||||
const clusterId = `node-${uniqueName()}`;
|
||||
|
||||
await neptune.send(new CreateDBClusterCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
Engine: 'neptune',
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await neptune.send(new ModifyDBClusterCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
EnableIAMDatabaseAuthentication: true,
|
||||
}));
|
||||
expect(response.DBCluster!.IAMDatabaseAuthenticationEnabled).toBe(true);
|
||||
} finally {
|
||||
await deleteCluster(clusterId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Neptune Instances', () => {
|
||||
let clusterId: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (clusterId) {
|
||||
await deleteCluster(clusterId);
|
||||
clusterId = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('should create and describe an instance', async () => {
|
||||
clusterId = `node-cl-${uniqueName()}`;
|
||||
const instanceId = `node-inst-${uniqueName()}`;
|
||||
|
||||
await neptune.send(new CreateDBClusterCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
Engine: 'neptune',
|
||||
}));
|
||||
|
||||
try {
|
||||
const createResp = await neptune.send(new CreateDBInstanceCommand({
|
||||
DBInstanceIdentifier: instanceId,
|
||||
DBClusterIdentifier: clusterId,
|
||||
DBInstanceClass: 'db.r5.large',
|
||||
Engine: 'neptune',
|
||||
}));
|
||||
const instance = createResp.DBInstance!;
|
||||
expect(instance.DBInstanceIdentifier).toBe(instanceId);
|
||||
expect(instance.DBClusterIdentifier).toBe(clusterId);
|
||||
expect(instance.DBInstanceStatus).toBe('available');
|
||||
|
||||
const descResp = await neptune.send(new DescribeDBInstancesCommand({
|
||||
DBInstanceIdentifier: instanceId,
|
||||
}));
|
||||
expect(descResp.DBInstances).toHaveLength(1);
|
||||
} finally {
|
||||
await deleteInstance(instanceId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should modify instance class', async () => {
|
||||
clusterId = `node-cl-${uniqueName()}`;
|
||||
const instanceId = `node-inst-${uniqueName()}`;
|
||||
|
||||
await neptune.send(new CreateDBClusterCommand({
|
||||
DBClusterIdentifier: clusterId,
|
||||
Engine: 'neptune',
|
||||
}));
|
||||
|
||||
try {
|
||||
await neptune.send(new CreateDBInstanceCommand({
|
||||
DBInstanceIdentifier: instanceId,
|
||||
DBClusterIdentifier: clusterId,
|
||||
DBInstanceClass: 'db.r5.large',
|
||||
Engine: 'neptune',
|
||||
}));
|
||||
|
||||
const response = await neptune.send(new ModifyDBInstanceCommand({
|
||||
DBInstanceIdentifier: instanceId,
|
||||
DBInstanceClass: 'db.r5.xlarge',
|
||||
}));
|
||||
expect(response.DBInstance!.DBInstanceClass).toBe('db.r5.xlarge');
|
||||
} finally {
|
||||
await deleteInstance(instanceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* EventBridge Pipes integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
PipesClient,
|
||||
CreatePipeCommand,
|
||||
DescribePipeCommand,
|
||||
ListPipesCommand,
|
||||
UpdatePipeCommand,
|
||||
DeletePipeCommand,
|
||||
StartPipeCommand,
|
||||
StopPipeCommand,
|
||||
RequestedPipeState,
|
||||
} from '@aws-sdk/client-pipes';
|
||||
import {
|
||||
SQSClient,
|
||||
CreateQueueCommand,
|
||||
DeleteQueueCommand,
|
||||
SendMessageCommand,
|
||||
ReceiveMessageCommand,
|
||||
GetQueueUrlCommand,
|
||||
GetQueueAttributesCommand,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import { makeClient, uniqueName, ACCOUNT, REGION, sleep } from './setup';
|
||||
|
||||
const ROLE_ARN = `arn:aws:iam::${ACCOUNT}:role/pipe-role`;
|
||||
|
||||
const sqsArn = (queueName: string) =>
|
||||
`arn:aws:sqs:${REGION}:${ACCOUNT}:${queueName}`;
|
||||
|
||||
const getQueueUrl = async (sqs: SQSClient, name: string): Promise<string> => {
|
||||
try {
|
||||
const r = await sqs.send(new GetQueueUrlCommand({ QueueName: name }));
|
||||
return r.QueueUrl ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
describe('Pipes CRUD', () => {
|
||||
let pipes: PipesClient;
|
||||
let sqs: SQSClient;
|
||||
let pipeName: string;
|
||||
let srcQueue: string;
|
||||
let tgtQueue: string;
|
||||
|
||||
beforeAll(() => {
|
||||
pipes = makeClient(PipesClient);
|
||||
sqs = makeClient(SQSClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup is handled per-test
|
||||
});
|
||||
|
||||
it('should create a pipe in STOPPED state', async () => {
|
||||
pipeName = `node-pipe-create-${uniqueName()}`;
|
||||
srcQueue = `node-pipe-src-create-${uniqueName()}`;
|
||||
tgtQueue = `node-pipe-tgt-create-${uniqueName()}`;
|
||||
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
|
||||
try {
|
||||
const response = await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.STOPPED,
|
||||
})
|
||||
);
|
||||
expect(response.CurrentState).toBe('STOPPED');
|
||||
expect(response.Arn).toContain(pipeName);
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, srcQueue) })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, tgtQueue) })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should describe a pipe', async () => {
|
||||
pipeName = `node-pipe-describe-${uniqueName()}`;
|
||||
srcQueue = `node-pipe-src-describe-${uniqueName()}`;
|
||||
tgtQueue = `node-pipe-tgt-describe-${uniqueName()}`;
|
||||
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.STOPPED,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await pipes.send(new DescribePipeCommand({ Name: pipeName }));
|
||||
expect(response.Name).toBe(pipeName);
|
||||
expect(response.Source).toBe(sqsArn(srcQueue));
|
||||
expect(response.Target).toBe(sqsArn(tgtQueue));
|
||||
expect(response.CurrentState).toBe('STOPPED');
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, srcQueue) })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, tgtQueue) })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should list pipes', async () => {
|
||||
pipeName = `node-pipe-list-${uniqueName()}`;
|
||||
srcQueue = `node-pipe-src-list-${uniqueName()}`;
|
||||
tgtQueue = `node-pipe-tgt-list-${uniqueName()}`;
|
||||
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.STOPPED,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await pipes.send(new ListPipesCommand({}));
|
||||
const names = response.Pipes?.map((p) => p.Name) ?? [];
|
||||
expect(names).toContain(pipeName);
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, srcQueue) })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, tgtQueue) })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should update a pipe', async () => {
|
||||
pipeName = `node-pipe-update-${uniqueName()}`;
|
||||
srcQueue = `node-pipe-src-update-${uniqueName()}`;
|
||||
tgtQueue = `node-pipe-tgt-update-${uniqueName()}`;
|
||||
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.STOPPED,
|
||||
})
|
||||
);
|
||||
|
||||
await pipes.send(
|
||||
new UpdatePipeCommand({
|
||||
Name: pipeName,
|
||||
RoleArn: ROLE_ARN,
|
||||
Description: 'updated via SDK',
|
||||
DesiredState: RequestedPipeState.STOPPED,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await pipes.send(new DescribePipeCommand({ Name: pipeName }));
|
||||
expect(response.Description).toBe('updated via SDK');
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, srcQueue) })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, tgtQueue) })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should delete a pipe', async () => {
|
||||
pipeName = `node-pipe-delete-${uniqueName()}`;
|
||||
srcQueue = `node-pipe-src-delete-${uniqueName()}`;
|
||||
tgtQueue = `node-pipe-tgt-delete-${uniqueName()}`;
|
||||
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.STOPPED,
|
||||
})
|
||||
);
|
||||
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName }));
|
||||
|
||||
await expect(
|
||||
pipes.send(new DescribePipeCommand({ Name: pipeName }))
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, srcQueue) })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, tgtQueue) })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error for non-existent pipe', async () => {
|
||||
await expect(
|
||||
pipes.send(new DescribePipeCommand({ Name: 'nonexistent-pipe' }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pipes Lifecycle', () => {
|
||||
let pipes: PipesClient;
|
||||
let sqs: SQSClient;
|
||||
|
||||
beforeAll(() => {
|
||||
pipes = makeClient(PipesClient);
|
||||
sqs = makeClient(SQSClient);
|
||||
});
|
||||
|
||||
it('should start and stop a pipe', async () => {
|
||||
const pipeName = `node-pipe-lifecycle-${uniqueName()}`;
|
||||
const srcQueue = `node-pipe-src-lifecycle-${uniqueName()}`;
|
||||
const tgtQueue = `node-pipe-tgt-lifecycle-${uniqueName()}`;
|
||||
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.STOPPED,
|
||||
})
|
||||
);
|
||||
|
||||
const startResponse = await pipes.send(new StartPipeCommand({ Name: pipeName }));
|
||||
expect(startResponse.CurrentState).toBe('RUNNING');
|
||||
|
||||
const stopResponse = await pipes.send(new StopPipeCommand({ Name: pipeName }));
|
||||
expect(stopResponse.CurrentState).toBe('STOPPED');
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, srcQueue) })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: await getQueueUrl(sqs, tgtQueue) })).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pipes Polling', () => {
|
||||
let pipes: PipesClient;
|
||||
let sqs: SQSClient;
|
||||
|
||||
beforeAll(() => {
|
||||
pipes = makeClient(PipesClient);
|
||||
sqs = makeClient(SQSClient);
|
||||
});
|
||||
|
||||
it('should forward SQS messages to SQS target', async () => {
|
||||
const pipeName = `node-pipe-fwd-${uniqueName()}`;
|
||||
const srcQueue = `node-pipe-src-fwd-${uniqueName()}`;
|
||||
const tgtQueue = `node-pipe-tgt-fwd-${uniqueName()}`;
|
||||
|
||||
const srcResp = await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
const srcUrl = srcResp.QueueUrl!;
|
||||
const tgtResp = await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
const tgtUrl = tgtResp.QueueUrl!;
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.RUNNING,
|
||||
})
|
||||
);
|
||||
|
||||
await sqs.send(
|
||||
new SendMessageCommand({ QueueUrl: srcUrl, MessageBody: 'hello from pipes' })
|
||||
);
|
||||
|
||||
let found = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await sleep(1000);
|
||||
const r = await sqs.send(
|
||||
new ReceiveMessageCommand({
|
||||
QueueUrl: tgtUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
WaitTimeSeconds: 1,
|
||||
})
|
||||
);
|
||||
if (r.Messages?.length && r.Messages[0].Body?.includes('hello from pipes')) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(found).toBe(true);
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: srcUrl })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: tgtUrl })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter messages with FilterCriteria', async () => {
|
||||
const pipeName = `node-pipe-filter-${uniqueName()}`;
|
||||
const srcQueue = `node-pipe-src-filter-${uniqueName()}`;
|
||||
const tgtQueue = `node-pipe-tgt-filter-${uniqueName()}`;
|
||||
|
||||
const srcResp = await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
const srcUrl = srcResp.QueueUrl!;
|
||||
const tgtResp = await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
const tgtUrl = tgtResp.QueueUrl!;
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.RUNNING,
|
||||
SourceParameters: {
|
||||
FilterCriteria: {
|
||||
Filters: [
|
||||
{ Pattern: '{"body": {"status": ["active"]}}' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await sqs.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: srcUrl,
|
||||
MessageBody: JSON.stringify({ status: 'active', id: 'match-1' }),
|
||||
})
|
||||
);
|
||||
await sqs.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: srcUrl,
|
||||
MessageBody: JSON.stringify({ status: 'inactive', id: 'no-match' }),
|
||||
})
|
||||
);
|
||||
|
||||
let found = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await sleep(1000);
|
||||
const r = await sqs.send(
|
||||
new ReceiveMessageCommand({
|
||||
QueueUrl: tgtUrl,
|
||||
MaxNumberOfMessages: 10,
|
||||
WaitTimeSeconds: 1,
|
||||
})
|
||||
);
|
||||
if (r.Messages?.some((m) => m.Body?.includes('match-1'))) {
|
||||
const hasNonMatch = r.Messages.some((m) => m.Body?.includes('no-match'));
|
||||
expect(hasNonMatch).toBe(false);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(found).toBe(true);
|
||||
|
||||
// Source queue should be drained (non-matching messages deleted per AWS behavior)
|
||||
const attrs = await sqs.send(
|
||||
new GetQueueAttributesCommand({
|
||||
QueueUrl: srcUrl,
|
||||
AttributeNames: ['ApproximateNumberOfMessages'],
|
||||
})
|
||||
);
|
||||
expect(attrs.Attributes?.ApproximateNumberOfMessages).toBe('0');
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: srcUrl })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: tgtUrl })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect BatchSize in SourceParameters', async () => {
|
||||
const pipeName = `node-pipe-batch-${uniqueName()}`;
|
||||
const srcQueue = `node-pipe-src-batch-${uniqueName()}`;
|
||||
const tgtQueue = `node-pipe-tgt-batch-${uniqueName()}`;
|
||||
|
||||
const srcResp = await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
const srcUrl = srcResp.QueueUrl!;
|
||||
const tgtResp = await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
const tgtUrl = tgtResp.QueueUrl!;
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.RUNNING,
|
||||
SourceParameters: {
|
||||
SqsQueueParameters: {
|
||||
BatchSize: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await sqs.send(
|
||||
new SendMessageCommand({ QueueUrl: srcUrl, MessageBody: `batch-msg-${i}` })
|
||||
);
|
||||
}
|
||||
|
||||
const foundMessages = new Set<string>();
|
||||
for (let i = 0; i < 20 && foundMessages.size < 3; i++) {
|
||||
await sleep(1000);
|
||||
const r = await sqs.send(
|
||||
new ReceiveMessageCommand({
|
||||
QueueUrl: tgtUrl,
|
||||
MaxNumberOfMessages: 10,
|
||||
WaitTimeSeconds: 1,
|
||||
})
|
||||
);
|
||||
for (const msg of r.Messages ?? []) {
|
||||
for (let j = 1; j <= 3; j++) {
|
||||
if (msg.Body?.includes(`batch-msg-${j}`)) {
|
||||
foundMessages.add(`batch-msg-${j}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(foundMessages.size).toBe(3);
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: srcUrl })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: tgtUrl })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should not forward messages when pipe is stopped', async () => {
|
||||
const pipeName = `node-pipe-nofwd-${uniqueName()}`;
|
||||
const srcQueue = `node-pipe-src-nofwd-${uniqueName()}`;
|
||||
const tgtQueue = `node-pipe-tgt-nofwd-${uniqueName()}`;
|
||||
|
||||
const srcResp = await sqs.send(new CreateQueueCommand({ QueueName: srcQueue }));
|
||||
const srcUrl = srcResp.QueueUrl!;
|
||||
const tgtResp = await sqs.send(new CreateQueueCommand({ QueueName: tgtQueue }));
|
||||
const tgtUrl = tgtResp.QueueUrl!;
|
||||
|
||||
try {
|
||||
await pipes.send(
|
||||
new CreatePipeCommand({
|
||||
Name: pipeName,
|
||||
Source: sqsArn(srcQueue),
|
||||
Target: sqsArn(tgtQueue),
|
||||
RoleArn: ROLE_ARN,
|
||||
DesiredState: RequestedPipeState.STOPPED,
|
||||
})
|
||||
);
|
||||
|
||||
await sqs.send(
|
||||
new SendMessageCommand({ QueueUrl: srcUrl, MessageBody: 'should not forward' })
|
||||
);
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
const attrs = await sqs.send(
|
||||
new GetQueueAttributesCommand({
|
||||
QueueUrl: srcUrl,
|
||||
AttributeNames: ['ApproximateNumberOfMessages'],
|
||||
})
|
||||
);
|
||||
expect(attrs.Attributes?.ApproximateNumberOfMessages).toBe('1');
|
||||
|
||||
const r = await sqs.send(
|
||||
new ReceiveMessageCommand({
|
||||
QueueUrl: tgtUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
WaitTimeSeconds: 1,
|
||||
})
|
||||
);
|
||||
expect(r.Messages ?? []).toHaveLength(0);
|
||||
} finally {
|
||||
await pipes.send(new DeletePipeCommand({ Name: pipeName })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: srcUrl })).catch(() => {});
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: tgtUrl })).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* S3 CORS enforcement integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteBucketCommand,
|
||||
PutBucketCorsCommand,
|
||||
DeleteBucketCorsCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { makeClient, uniqueName, ENDPOINT } from './setup';
|
||||
|
||||
describe('S3 CORS Enforcement', () => {
|
||||
let s3: S3Client;
|
||||
let bucketName: string;
|
||||
|
||||
async function rawRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
extraHeaders: Record<string, string> = {}
|
||||
) {
|
||||
const url = `${ENDPOINT}/${bucketName}${path}`;
|
||||
const resp = await fetch(url, { method, headers: extraHeaders });
|
||||
const headers: Record<string, string> = {};
|
||||
resp.headers.forEach((v, k) => {
|
||||
headers[k.toLowerCase()] = v;
|
||||
});
|
||||
return { status: resp.status, headers };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
s3 = makeClient(S3Client, { forcePathStyle: true });
|
||||
bucketName = `cors-test-bucket-${uniqueName()}`;
|
||||
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||
await s3.send(
|
||||
new PutObjectCommand({ Bucket: bucketName, Key: 'cors-test.txt', Body: 'hello cors' })
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await s3.send(new DeleteBucketCorsCommand({ Bucket: bucketName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: 'cors-test.txt' }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: bucketName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject preflight without CORS config', async () => {
|
||||
const { status } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'http://localhost:3000',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('should set wildcard CORS config', async () => {
|
||||
await s3.send(
|
||||
new PutBucketCorsCommand({
|
||||
Bucket: bucketName,
|
||||
CORSConfiguration: {
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ['*'],
|
||||
AllowedMethods: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'],
|
||||
AllowedHeaders: ['*'],
|
||||
ExposeHeaders: ['ETag'],
|
||||
MaxAgeSeconds: 3000,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow wildcard preflight', async () => {
|
||||
const { status, headers } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'http://localhost:3000',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(headers['access-control-allow-origin']).toBe('*');
|
||||
expect(headers['access-control-max-age']).toBe('3000');
|
||||
expect(headers['access-control-allow-methods']?.toUpperCase()).toContain('GET');
|
||||
});
|
||||
|
||||
it('should return CORS headers on actual GET with Origin', async () => {
|
||||
const { headers } = await rawRequest('GET', '/cors-test.txt', {
|
||||
Origin: 'http://localhost:3000',
|
||||
});
|
||||
expect(headers['access-control-allow-origin']).toBe('*');
|
||||
expect(headers['vary']?.toLowerCase()).toContain('origin');
|
||||
expect(headers['access-control-expose-headers']).toContain('ETag');
|
||||
});
|
||||
|
||||
it('should not return CORS headers without Origin', async () => {
|
||||
const { headers } = await rawRequest('GET', '/cors-test.txt');
|
||||
expect(headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set specific origin CORS config', async () => {
|
||||
await s3.send(
|
||||
new PutBucketCorsCommand({
|
||||
Bucket: bucketName,
|
||||
CORSConfiguration: {
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ['https://example.com'],
|
||||
AllowedMethods: ['GET', 'PUT'],
|
||||
AllowedHeaders: ['Content-Type', 'Authorization'],
|
||||
ExposeHeaders: ['ETag', 'x-amz-request-id'],
|
||||
MaxAgeSeconds: 600,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow matching origin preflight', async () => {
|
||||
const { status, headers } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'https://example.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'Content-Type',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(headers['access-control-allow-origin']).toBe('https://example.com');
|
||||
expect(headers['access-control-max-age']).toBe('600');
|
||||
});
|
||||
|
||||
it('should reject non-matching origin', async () => {
|
||||
const { status } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'https://attacker.evil.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('should reject non-matching method', async () => {
|
||||
const { status } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'https://example.com',
|
||||
'Access-Control-Request-Method': 'DELETE',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('should echo origin for matching actual GET', async () => {
|
||||
const { headers } = await rawRequest('GET', '/cors-test.txt', {
|
||||
Origin: 'https://example.com',
|
||||
});
|
||||
expect(headers['access-control-allow-origin']).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('should not include CORS headers for non-matching origin GET', async () => {
|
||||
const { headers } = await rawRequest('GET', '/cors-test.txt', {
|
||||
Origin: 'https://not-allowed.com',
|
||||
});
|
||||
expect(headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject preflight after CORS config deleted', async () => {
|
||||
await s3.send(new DeleteBucketCorsCommand({ Bucket: bucketName }));
|
||||
|
||||
const { status } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'http://localhost:3000',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('should support subdomain wildcard pattern', async () => {
|
||||
await s3.send(
|
||||
new PutBucketCorsCommand({
|
||||
Bucket: bucketName,
|
||||
CORSConfiguration: {
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ['http://*.example.com'],
|
||||
AllowedMethods: ['GET'],
|
||||
AllowedHeaders: ['*'],
|
||||
MaxAgeSeconds: 120,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { status, headers } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'http://app.example.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(headers['access-control-allow-origin']).toBe('http://app.example.com');
|
||||
});
|
||||
|
||||
it('should reject https with http wildcard pattern', async () => {
|
||||
const { status } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'https://app.example.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('should reject different domain with subdomain wildcard', async () => {
|
||||
const { status } = await rawRequest('OPTIONS', '/cors-test.txt', {
|
||||
Origin: 'http://app.other.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Multipart uploads through @aws-sdk/lib-storage, the high-level uploader of the JavaScript SDK.
|
||||
* S3 stores a COMPOSITE checksum for SHA algorithms (the algorithm applied to the concatenated part
|
||||
* checksums, suffixed with "-<parts>"), reports it on HeadObject and drops the suffix on
|
||||
* GetObjectAttributes; a copy is written as a single object with a FULL_OBJECT checksum.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { crc32 } from 'node:zlib';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
GetObjectCommand,
|
||||
GetObjectAttributesCommand,
|
||||
CopyObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const PART_SIZE = 5 * MIB;
|
||||
|
||||
/** Deterministic pseudo-random payload (xorshift32), so every run computes the same checksums. */
|
||||
function payloadOf(size: number): Buffer {
|
||||
const out = Buffer.alloc(size);
|
||||
let x = 20260903;
|
||||
for (let i = 0; i < size; i += 4) {
|
||||
x ^= x << 13; x >>>= 0;
|
||||
x ^= x >>> 17;
|
||||
x ^= x << 5; x >>>= 0;
|
||||
out.writeUInt32LE(x, i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parts(data: Buffer, size = PART_SIZE): Buffer[] {
|
||||
const result: Buffer[] = [];
|
||||
for (let start = 0; start < data.length; start += size) {
|
||||
result.push(data.subarray(start, Math.min(start + size, data.length)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const sha256 = (data: Buffer) => createHash('sha256').update(data).digest();
|
||||
const md5Hex = (data: Buffer) => createHash('md5').update(data).digest('hex');
|
||||
function crc32Bytes(data: Buffer): Buffer {
|
||||
const out = Buffer.alloc(4);
|
||||
out.writeUInt32BE(crc32(data) >>> 0);
|
||||
return out;
|
||||
}
|
||||
const composite = (digest: (b: Buffer) => Buffer, pieces: Buffer[]) =>
|
||||
`${digest(Buffer.concat(pieces.map(digest))).toString('base64')}-${pieces.length}`;
|
||||
|
||||
/** CRC-64/NVME (reflected polynomial 0x9a6c9329ac4bc9b5), what S3 attaches when no algorithm is declared. */
|
||||
function crc64Nvme(data: Buffer): Buffer {
|
||||
const poly = 0x9a6c9329ac4bc9b5n;
|
||||
const mask = 0xffffffffffffffffn;
|
||||
const table: bigint[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = BigInt(i);
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = c & 1n ? (c >> 1n) ^ poly : c >> 1n;
|
||||
}
|
||||
table.push(c);
|
||||
}
|
||||
let c = mask;
|
||||
for (const byte of data) {
|
||||
c = table[Number((c ^ BigInt(byte)) & 0xffn)] ^ (c >> 8n);
|
||||
}
|
||||
c = (~c) & mask;
|
||||
const out = Buffer.alloc(8);
|
||||
out.writeBigUInt64BE(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('S3 multipart checksums', () => {
|
||||
const s3 = makeClient(S3Client);
|
||||
const bucket = uniqueName('node-multipart-checksums');
|
||||
const payload = payloadOf(12 * MIB);
|
||||
const pieces = parts(payload);
|
||||
const keys = { sha256: 'sha256.bin', byDefault: 'default.bin', copy: 'sha256-copy.bin' };
|
||||
|
||||
const head = (Key: string) =>
|
||||
s3.send(new HeadObjectCommand({ Bucket: bucket, Key, ChecksumMode: 'ENABLED' }));
|
||||
|
||||
async function upload(Key: string, ChecksumAlgorithm?: 'SHA256') {
|
||||
await new Upload({
|
||||
client: s3,
|
||||
params: { Bucket: bucket, Key, Body: payload, ...(ChecksumAlgorithm ? { ChecksumAlgorithm } : {}) },
|
||||
partSize: PART_SIZE,
|
||||
queueSize: 2,
|
||||
}).done();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const Key of Object.values(keys)) {
|
||||
await s3
|
||||
.send(new DeleteObjectCommand({ Bucket: bucket, Key }))
|
||||
.catch((e) => console.warn(`cleanup: could not delete s3://${bucket}/${Key}: ${e}`));
|
||||
}
|
||||
await s3
|
||||
.send(new DeleteBucketCommand({ Bucket: bucket }))
|
||||
.catch((e) => console.warn(`cleanup: could not delete bucket ${bucket}: ${e}`));
|
||||
});
|
||||
|
||||
it('reports the composite SHA256 checksum of a multipart upload', async () => {
|
||||
await upload(keys.sha256, 'SHA256');
|
||||
const expected = composite(sha256, pieces);
|
||||
expect(expected.endsWith('-3')).toBe(true);
|
||||
|
||||
const h = await head(keys.sha256);
|
||||
expect(h.ChecksumSHA256).toBe(expected);
|
||||
expect(h.ChecksumType).toBe('COMPOSITE');
|
||||
expect(h.ETag?.endsWith('-3"')).toBe(true);
|
||||
|
||||
const attrs = await s3.send(
|
||||
new GetObjectAttributesCommand({
|
||||
Bucket: bucket,
|
||||
Key: keys.sha256,
|
||||
ObjectAttributes: ['Checksum', 'ObjectParts', 'ETag'],
|
||||
})
|
||||
);
|
||||
expect(attrs.Checksum?.ChecksumSHA256).toBe(expected.replace(/-3$/, ''));
|
||||
expect(attrs.Checksum?.ChecksumType).toBe('COMPOSITE');
|
||||
expect(attrs.ObjectParts?.TotalPartsCount).toBe(3);
|
||||
expect(attrs.ObjectParts?.Parts?.map((p) => p.ChecksumSHA256)).toEqual(
|
||||
pieces.map((p) => sha256(p).toString('base64'))
|
||||
);
|
||||
// GetObjectAttributes returns the ETag without the quotes HeadObject carries
|
||||
expect(attrs.ETag).toBe(h.ETag?.replace(/"/g, ''));
|
||||
});
|
||||
|
||||
it('stores what the uploader sends by default and validates the download', async () => {
|
||||
await upload(keys.byDefault);
|
||||
|
||||
// Which algorithm and type the SDK picks depends on its version; whatever it sent, the stored
|
||||
// value must be the one S3 would compute for that combination.
|
||||
const h = await head(keys.byDefault);
|
||||
if (h.ChecksumCRC32 && h.ChecksumType === 'COMPOSITE') {
|
||||
expect(h.ChecksumCRC32).toBe(composite(crc32Bytes, pieces));
|
||||
} else if (h.ChecksumCRC32 && h.ChecksumType === 'FULL_OBJECT') {
|
||||
expect(h.ChecksumCRC32).toBe(crc32Bytes(payload).toString('base64'));
|
||||
} else if (h.ChecksumCRC64NVME) {
|
||||
expect(h.ChecksumType).toBe('FULL_OBJECT');
|
||||
expect(h.ChecksumCRC64NVME).toBe(crc64Nvme(payload).toString('base64'));
|
||||
} else {
|
||||
throw new Error(`unexpected checksum on HeadObject: ${JSON.stringify(h)}`);
|
||||
}
|
||||
|
||||
const got = await s3.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: keys.byDefault, ChecksumMode: 'ENABLED' })
|
||||
);
|
||||
const body = Buffer.from(await got.Body!.transformToByteArray());
|
||||
expect(body.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it('writes a copy of the multipart object as a single full object', async () => {
|
||||
const source = await head(keys.sha256);
|
||||
const copied = await s3.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: keys.copy,
|
||||
CopySource: `${bucket}/${keys.sha256}`,
|
||||
})
|
||||
);
|
||||
const expectedETag = `"${md5Hex(payload)}"`;
|
||||
expect(copied.CopyObjectResult?.ChecksumSHA256).toBe(sha256(payload).toString('base64'));
|
||||
expect(copied.CopyObjectResult?.ChecksumType).toBe('FULL_OBJECT');
|
||||
expect(copied.CopyObjectResult?.ETag).toBe(expectedETag);
|
||||
expect(copied.CopyObjectResult?.ETag).not.toBe(source.ETag);
|
||||
|
||||
const h = await head(keys.copy);
|
||||
expect(h.ChecksumSHA256).toBe(sha256(payload).toString('base64'));
|
||||
expect(h.ChecksumType).toBe('FULL_OBJECT');
|
||||
expect(h.ETag).toBe(expectedETag);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* S3 Notifications integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteBucketCommand,
|
||||
PutBucketNotificationConfigurationCommand,
|
||||
GetBucketNotificationConfigurationCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
SQSClient,
|
||||
CreateQueueCommand,
|
||||
GetQueueAttributesCommand,
|
||||
DeleteQueueCommand,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import { SNSClient, CreateTopicCommand, DeleteTopicCommand } from '@aws-sdk/client-sns';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('S3 Notifications', () => {
|
||||
let s3: S3Client;
|
||||
let sqs: SQSClient;
|
||||
let sns: SNSClient;
|
||||
let bucketName: string;
|
||||
let queueUrl: string;
|
||||
let queueArn: string;
|
||||
let topicArn: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
s3 = makeClient(S3Client, { forcePathStyle: true });
|
||||
sqs = makeClient(SQSClient);
|
||||
sns = makeClient(SNSClient);
|
||||
|
||||
bucketName = `notif-test-bucket-${uniqueName()}`;
|
||||
|
||||
// Create SQS queue
|
||||
const queueResult = await sqs.send(
|
||||
new CreateQueueCommand({ QueueName: `notif-queue-${uniqueName()}` })
|
||||
);
|
||||
queueUrl = queueResult.QueueUrl!;
|
||||
const attrs = await sqs.send(
|
||||
new GetQueueAttributesCommand({ QueueUrl: queueUrl, AttributeNames: ['QueueArn'] })
|
||||
);
|
||||
queueArn = attrs.Attributes!.QueueArn!;
|
||||
|
||||
// Create SNS topic
|
||||
const topicResult = await sns.send(
|
||||
new CreateTopicCommand({ Name: `notif-topic-${uniqueName()}` })
|
||||
);
|
||||
topicArn = topicResult.TopicArn!;
|
||||
|
||||
// Create S3 bucket
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: 'test-object.txt' }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: bucketName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: queueUrl }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await sns.send(new DeleteTopicCommand({ TopicArn: topicArn }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should put bucket notification configuration', async () => {
|
||||
await s3.send(
|
||||
new PutBucketNotificationConfigurationCommand({
|
||||
Bucket: bucketName,
|
||||
NotificationConfiguration: {
|
||||
QueueConfigurations: [
|
||||
{
|
||||
Id: 'sqs-filtered',
|
||||
QueueArn: queueArn,
|
||||
Events: ['s3:ObjectCreated:*'],
|
||||
Filter: {
|
||||
Key: {
|
||||
FilterRules: [
|
||||
{ Name: 'prefix', Value: 'incoming/' },
|
||||
{ Name: 'suffix', Value: '.csv' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
TopicConfigurations: [
|
||||
{
|
||||
Id: 'sns-filtered',
|
||||
TopicArn: topicArn,
|
||||
Events: ['s3:ObjectRemoved:*'],
|
||||
Filter: {
|
||||
Key: {
|
||||
FilterRules: [
|
||||
{ Name: 'prefix', Value: '' },
|
||||
{ Name: 'suffix', Value: '.txt' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should get bucket notification configuration', async () => {
|
||||
const response = await s3.send(
|
||||
new GetBucketNotificationConfigurationCommand({ Bucket: bucketName })
|
||||
);
|
||||
|
||||
// Verify SQS configuration
|
||||
const sqsConfig = response.QueueConfigurations?.find((c) => c.Id === 'sqs-filtered');
|
||||
expect(sqsConfig).toBeTruthy();
|
||||
expect(sqsConfig?.QueueArn).toBe(queueArn);
|
||||
expect(sqsConfig?.Events).toContain('s3:ObjectCreated:*');
|
||||
|
||||
const sqsRules = sqsConfig?.Filter?.Key?.FilterRules || [];
|
||||
expect(sqsRules.some((r) => r.Name === 'prefix' && r.Value === 'incoming/')).toBe(true);
|
||||
expect(sqsRules.some((r) => r.Name === 'suffix' && r.Value === '.csv')).toBe(true);
|
||||
|
||||
// Verify SNS configuration
|
||||
const snsConfig = response.TopicConfigurations?.find((c) => c.Id === 'sns-filtered');
|
||||
expect(snsConfig).toBeTruthy();
|
||||
expect(snsConfig?.TopicArn).toBe(topicArn);
|
||||
expect(snsConfig?.Events).toContain('s3:ObjectRemoved:*');
|
||||
|
||||
const snsRules = snsConfig?.Filter?.Key?.FilterRules || [];
|
||||
expect(snsRules.some((r) => r.Name === 'suffix' && r.Value === '.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear notification configuration', async () => {
|
||||
await s3.send(
|
||||
new PutBucketNotificationConfigurationCommand({
|
||||
Bucket: bucketName,
|
||||
NotificationConfiguration: {},
|
||||
})
|
||||
);
|
||||
|
||||
const response = await s3.send(
|
||||
new GetBucketNotificationConfigurationCommand({ Bucket: bucketName })
|
||||
);
|
||||
expect(response.QueueConfigurations?.length || 0).toBe(0);
|
||||
expect(response.TopicConfigurations?.length || 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* S3 integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
DeleteBucketCommand,
|
||||
HeadObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListBucketsCommand,
|
||||
CopyObjectCommand,
|
||||
GetBucketLocationCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCopyCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { makeClient, uniqueName, CLIENT_CONFIG } from './setup';
|
||||
|
||||
describe('S3', () => {
|
||||
let s3: S3Client;
|
||||
let euS3: S3Client;
|
||||
let bucketName: string;
|
||||
let euBucketName: string;
|
||||
let signedRegionBucket: string;
|
||||
|
||||
beforeAll(() => {
|
||||
s3 = makeClient(S3Client, { forcePathStyle: true });
|
||||
euS3 = new S3Client({ ...CLIENT_CONFIG, region: 'eu-central-1', forcePathStyle: true });
|
||||
bucketName = `test-bucket-${uniqueName()}`;
|
||||
euBucketName = `test-bucket-eu-${uniqueName()}`;
|
||||
signedRegionBucket = `test-bucket-signed-${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup buckets
|
||||
for (const bucket of [bucketName, euBucketName, signedRegionBucket]) {
|
||||
try {
|
||||
const list = await s3.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
for (const obj of list.Contents || []) {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: obj.Key }));
|
||||
}
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should create bucket', async () => {
|
||||
const response = await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||
expect(response.Location).toBe(`/${bucketName}`);
|
||||
});
|
||||
|
||||
it('should create bucket with LocationConstraint', async () => {
|
||||
const response = await s3.send(
|
||||
new CreateBucketCommand({
|
||||
Bucket: euBucketName,
|
||||
CreateBucketConfiguration: { LocationConstraint: 'eu-central-1' },
|
||||
})
|
||||
);
|
||||
expect(response.Location).toBe(`/${euBucketName}`);
|
||||
});
|
||||
|
||||
it('should get bucket location', async () => {
|
||||
const response = await s3.send(new GetBucketLocationCommand({ Bucket: euBucketName }));
|
||||
expect(response.LocationConstraint).toBe('eu-central-1');
|
||||
});
|
||||
|
||||
it('should create bucket using signing region when body empty', async () => {
|
||||
await euS3.send(new CreateBucketCommand({ Bucket: signedRegionBucket }));
|
||||
const head = await euS3.send(new HeadBucketCommand({ Bucket: signedRegionBucket }));
|
||||
expect(head.BucketRegion).toBe('eu-central-1');
|
||||
const loc = await euS3.send(new GetBucketLocationCommand({ Bucket: signedRegionBucket }));
|
||||
expect(loc.LocationConstraint).toBe('eu-central-1');
|
||||
});
|
||||
|
||||
it('should reject explicit us-east-1 LocationConstraint', async () => {
|
||||
await expect(
|
||||
s3.send(
|
||||
new CreateBucketCommand({
|
||||
Bucket: `invalid-loc-${uniqueName()}`,
|
||||
CreateBucketConfiguration: { LocationConstraint: 'us-east-1' },
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should list buckets', async () => {
|
||||
const response = await s3.send(new ListBucketsCommand({}));
|
||||
expect(response.Buckets?.some((b) => b.Name === bucketName)).toBe(true);
|
||||
});
|
||||
|
||||
it('should put object', async () => {
|
||||
await s3.send(
|
||||
new PutObjectCommand({ Bucket: bucketName, Key: 'test.txt', Body: 'hello from test' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should head object', async () => {
|
||||
const response = await s3.send(
|
||||
new HeadObjectCommand({ Bucket: bucketName, Key: 'test.txt' })
|
||||
);
|
||||
expect(response.ContentLength).toBeGreaterThan(0);
|
||||
expect(response.LastModified).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should get object', async () => {
|
||||
const response = await s3.send(
|
||||
new GetObjectCommand({ Bucket: bucketName, Key: 'test.txt' })
|
||||
);
|
||||
const body = await response.Body?.transformToString();
|
||||
expect(body).toBe('hello from test');
|
||||
});
|
||||
|
||||
it('should copy object', async () => {
|
||||
await s3.send(
|
||||
new CopyObjectCommand({
|
||||
CopySource: `${bucketName}/test.txt`,
|
||||
Bucket: bucketName,
|
||||
Key: 'test-copy.txt',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy object with non-ASCII key', async () => {
|
||||
const nonAsciiKey = 'src/テスト画像.png';
|
||||
const nonAsciiDst = 'dst/テスト画像.png';
|
||||
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: nonAsciiKey,
|
||||
Body: Buffer.from('non-ascii content'),
|
||||
})
|
||||
);
|
||||
await s3.send(
|
||||
new CopyObjectCommand({
|
||||
CopySource: `${bucketName}/${encodeURIComponent(nonAsciiKey)}`,
|
||||
Bucket: bucketName,
|
||||
Key: nonAsciiDst,
|
||||
})
|
||||
);
|
||||
const response = await s3.send(
|
||||
new GetObjectCommand({ Bucket: bucketName, Key: nonAsciiDst })
|
||||
);
|
||||
const body = await response.Body?.transformToString();
|
||||
expect(body).toBe('non-ascii content');
|
||||
|
||||
// Cleanup
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: nonAsciiKey }));
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: nonAsciiDst }));
|
||||
});
|
||||
|
||||
it('should multipart copy object with non-ASCII key', async () => {
|
||||
const srcKey = 'src/テスト画像.bin';
|
||||
const dstKey = 'dst/テスト画像.bin';
|
||||
const body = Buffer.alloc(6 * 1024 * 1024, 'a');
|
||||
let uploadId: string | undefined;
|
||||
|
||||
try {
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: srcKey,
|
||||
Body: body,
|
||||
})
|
||||
);
|
||||
|
||||
const create = await s3.send(
|
||||
new CreateMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: dstKey,
|
||||
})
|
||||
);
|
||||
uploadId = create.UploadId;
|
||||
expect(uploadId).toBeTruthy();
|
||||
|
||||
const part = await s3.send(
|
||||
new UploadPartCopyCommand({
|
||||
Bucket: bucketName,
|
||||
Key: dstKey,
|
||||
UploadId: uploadId,
|
||||
PartNumber: 1,
|
||||
CopySource: `${bucketName}/${encodeURIComponent(srcKey)}`,
|
||||
})
|
||||
);
|
||||
expect(part.CopyPartResult?.ETag).toBeTruthy();
|
||||
|
||||
await s3.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: dstKey,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: [
|
||||
{
|
||||
ETag: part.CopyPartResult?.ETag,
|
||||
PartNumber: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
uploadId = undefined;
|
||||
|
||||
const response = await s3.send(
|
||||
new GetObjectCommand({ Bucket: bucketName, Key: dstKey })
|
||||
);
|
||||
const copied = await response.Body?.transformToByteArray();
|
||||
expect(copied && Buffer.from(copied)).toEqual(body);
|
||||
} finally {
|
||||
if (uploadId) {
|
||||
await s3.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: dstKey,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
).catch(() => {});
|
||||
}
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: srcKey })).catch(() => {});
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: dstKey })).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('should list objects', async () => {
|
||||
const response = await s3.send(new ListObjectsV2Command({ Bucket: bucketName }));
|
||||
expect(response.KeyCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should upload large object (25 MB)', async () => {
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: 'large-object-25mb.bin',
|
||||
Body: Buffer.alloc(25 * 1024 * 1024),
|
||||
ContentType: 'application/octet-stream',
|
||||
})
|
||||
);
|
||||
|
||||
const response = await s3.send(
|
||||
new HeadObjectCommand({ Bucket: bucketName, Key: 'large-object-25mb.bin' })
|
||||
);
|
||||
expect(response.ContentLength).toBe(25 * 1024 * 1024);
|
||||
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({ Bucket: bucketName, Key: 'large-object-25mb.bin' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete object', async () => {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: 'test.txt' }));
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: 'test-copy.txt' }));
|
||||
});
|
||||
|
||||
it('should fail for missing object', async () => {
|
||||
await expect(
|
||||
s3.send(new GetObjectCommand({ Bucket: bucketName, Key: 'missing.txt' }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should delete bucket', async () => {
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: bucketName }));
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: euBucketName }));
|
||||
await euS3.send(new DeleteBucketCommand({ Bucket: signedRegionBucket }));
|
||||
bucketName = '';
|
||||
euBucketName = '';
|
||||
signedRegionBucket = '';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ConflictException,
|
||||
CreateNamespaceCommand,
|
||||
CreateTableBucketCommand,
|
||||
CreateTableCommand,
|
||||
DeleteNamespaceCommand,
|
||||
DeleteTableBucketCommand,
|
||||
DeleteTableBucketPolicyCommand,
|
||||
DeleteTableCommand,
|
||||
DeleteTablePolicyCommand,
|
||||
GetNamespaceCommand,
|
||||
GetTableBucketCommand,
|
||||
GetTableBucketMaintenanceConfigurationCommand,
|
||||
GetTableBucketPolicyCommand,
|
||||
GetTableCommand,
|
||||
GetTableMaintenanceConfigurationCommand,
|
||||
GetTableMetadataLocationCommand,
|
||||
GetTablePolicyCommand,
|
||||
ListNamespacesCommand,
|
||||
ListTableBucketsCommand,
|
||||
ListTablesCommand,
|
||||
NotFoundException,
|
||||
PutTableBucketMaintenanceConfigurationCommand,
|
||||
PutTableBucketPolicyCommand,
|
||||
PutTableMaintenanceConfigurationCommand,
|
||||
PutTablePolicyCommand,
|
||||
RenameTableCommand,
|
||||
S3TablesClient,
|
||||
UpdateTableMetadataLocationCommand,
|
||||
} from '@aws-sdk/client-s3tables';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('S3 Tables lifecycle', () => {
|
||||
let client: S3TablesClient;
|
||||
let bucketArn: string;
|
||||
const bucketName = uniqueName('table-bucket');
|
||||
const namespace = 'analytics';
|
||||
let tableName = uniqueName('events');
|
||||
let versionToken: string;
|
||||
|
||||
beforeAll(() => {
|
||||
client = makeClient(S3TablesClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!bucketArn) return;
|
||||
await client.send(new DeleteTableCommand({ tableBucketARN: bucketArn, namespace, name: tableName })).catch(() => undefined);
|
||||
await client.send(new DeleteNamespaceCommand({ tableBucketARN: bucketArn, namespace })).catch(() => undefined);
|
||||
await client.send(new DeleteTableBucketCommand({ tableBucketARN: bucketArn })).catch(() => undefined);
|
||||
});
|
||||
|
||||
it('creates a table bucket, namespace, and Iceberg table', async () => {
|
||||
const bucket = await client.send(new CreateTableBucketCommand({
|
||||
name: bucketName,
|
||||
tags: { suite: 'sdk-node' },
|
||||
encryptionConfiguration: { sseAlgorithm: 'AES256' },
|
||||
storageClassConfiguration: { storageClass: 'STANDARD' },
|
||||
}));
|
||||
bucketArn = bucket.arn!;
|
||||
expect(bucketArn).toContain(`bucket/${bucketName}`);
|
||||
|
||||
const createdNamespace = await client.send(new CreateNamespaceCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
namespace: [namespace],
|
||||
}));
|
||||
expect(createdNamespace.namespace).toEqual([namespace]);
|
||||
|
||||
const table = await client.send(new CreateTableCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
namespace,
|
||||
name: tableName,
|
||||
format: 'ICEBERG',
|
||||
metadata: { iceberg: { properties: { 'format-version': '2' } } },
|
||||
encryptionConfiguration: { sseAlgorithm: 'AES256' },
|
||||
storageClassConfiguration: { storageClass: 'STANDARD' },
|
||||
tags: { suite: 'sdk-node' },
|
||||
}));
|
||||
versionToken = table.versionToken!;
|
||||
expect(table.tableARN).toBe(`${bucketArn}/table/${tableName}`);
|
||||
});
|
||||
|
||||
it('gets resources and applies list prefixes', async () => {
|
||||
const bucket = await client.send(new GetTableBucketCommand({ tableBucketARN: bucketArn }));
|
||||
expect(bucket.name).toBe(bucketName);
|
||||
|
||||
const buckets = await client.send(new ListTableBucketsCommand({ prefix: 'table-bucket' }));
|
||||
expect(buckets.tableBuckets?.some((item) => item.name === bucketName)).toBe(true);
|
||||
|
||||
const fetchedNamespace = await client.send(new GetNamespaceCommand({ tableBucketARN: bucketArn, namespace }));
|
||||
expect(fetchedNamespace.namespace).toEqual([namespace]);
|
||||
const namespaces = await client.send(new ListNamespacesCommand({ tableBucketARN: bucketArn, prefix: 'anal' }));
|
||||
expect(namespaces.namespaces?.some((item) => item.namespace?.includes(namespace))).toBe(true);
|
||||
|
||||
const tables = await client.send(new ListTablesCommand({ tableBucketARN: bucketArn, namespace, prefix: 'events' }));
|
||||
expect(tables.tables?.some((table) => table.name === tableName)).toBe(true);
|
||||
const table = await client.send(new GetTableCommand({ tableBucketARN: bucketArn, namespace, name: tableName }));
|
||||
expect(table.tableARN).toBe(`${bucketArn}/table/${tableName}`);
|
||||
});
|
||||
|
||||
it('round-trips policies and metadata version tokens', async () => {
|
||||
const policy = '{"Version":"2012-10-17","Statement":[]}';
|
||||
await client.send(new PutTableBucketPolicyCommand({ tableBucketARN: bucketArn, resourcePolicy: policy }));
|
||||
expect((await client.send(new GetTableBucketPolicyCommand({ tableBucketARN: bucketArn }))).resourcePolicy).toBe(policy);
|
||||
await client.send(new PutTablePolicyCommand({ tableBucketARN: bucketArn, namespace, name: tableName, resourcePolicy: policy }));
|
||||
expect((await client.send(new GetTablePolicyCommand({ tableBucketARN: bucketArn, namespace, name: tableName }))).resourcePolicy).toBe(policy);
|
||||
|
||||
const updated = await client.send(new UpdateTableMetadataLocationCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
namespace,
|
||||
name: tableName,
|
||||
metadataLocation: `s3://warehouse/${tableName}/v2.metadata.json`,
|
||||
versionToken,
|
||||
}));
|
||||
expect(updated.versionToken).not.toBe(versionToken);
|
||||
expect((await client.send(new GetTableMetadataLocationCommand({
|
||||
tableBucketARN: bucketArn, namespace, name: tableName,
|
||||
}))).metadataLocation).toBe(updated.metadataLocation);
|
||||
|
||||
await expect(client.send(new UpdateTableMetadataLocationCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
namespace,
|
||||
name: tableName,
|
||||
metadataLocation: `s3://warehouse/${tableName}/stale.metadata.json`,
|
||||
versionToken,
|
||||
}))).rejects.toBeInstanceOf(ConflictException);
|
||||
versionToken = updated.versionToken!;
|
||||
});
|
||||
|
||||
it('round-trips typed maintenance configuration maps', async () => {
|
||||
await client.send(new PutTableBucketMaintenanceConfigurationCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
type: 'icebergUnreferencedFileRemoval',
|
||||
value: { status: 'enabled' },
|
||||
}));
|
||||
const bucketMaintenance = await client.send(new GetTableBucketMaintenanceConfigurationCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
}));
|
||||
expect(bucketMaintenance.configuration?.icebergUnreferencedFileRemoval?.status).toBe('enabled');
|
||||
|
||||
await client.send(new PutTableMaintenanceConfigurationCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
namespace,
|
||||
name: tableName,
|
||||
type: 'icebergCompaction',
|
||||
value: { status: 'enabled' },
|
||||
}));
|
||||
const tableMaintenance = await client.send(new GetTableMaintenanceConfigurationCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
namespace,
|
||||
name: tableName,
|
||||
}));
|
||||
expect(tableMaintenance.configuration?.icebergCompaction?.status).toBe('enabled');
|
||||
});
|
||||
|
||||
it('renames a table and rotates its version token', async () => {
|
||||
const originalToken = versionToken;
|
||||
const renamedTableName = `${tableName}-renamed`;
|
||||
await client.send(new RenameTableCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
namespace,
|
||||
name: tableName,
|
||||
newName: renamedTableName,
|
||||
versionToken: originalToken,
|
||||
}));
|
||||
|
||||
const renamed = await client.send(new GetTableCommand({
|
||||
tableBucketARN: bucketArn,
|
||||
namespace,
|
||||
name: renamedTableName,
|
||||
}));
|
||||
expect(renamed.name).toBe(renamedTableName);
|
||||
expect(renamed.versionToken).not.toBe(originalToken);
|
||||
await expect(client.send(new GetTableCommand({ tableBucketARN: bucketArn, namespace, name: tableName })))
|
||||
.rejects.toBeInstanceOf(NotFoundException);
|
||||
tableName = renamedTableName;
|
||||
versionToken = renamed.versionToken!;
|
||||
});
|
||||
|
||||
it('deletes policies and the resource hierarchy', async () => {
|
||||
await client.send(new DeleteTablePolicyCommand({ tableBucketARN: bucketArn, namespace, name: tableName }));
|
||||
await expect(client.send(new GetTablePolicyCommand({ tableBucketARN: bucketArn, namespace, name: tableName })))
|
||||
.rejects.toBeInstanceOf(NotFoundException);
|
||||
await client.send(new DeleteTableBucketPolicyCommand({ tableBucketARN: bucketArn }));
|
||||
await expect(client.send(new GetTableBucketPolicyCommand({ tableBucketARN: bucketArn })))
|
||||
.rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
await client.send(new DeleteTableCommand({ tableBucketARN: bucketArn, namespace, name: tableName }));
|
||||
await expect(client.send(new GetTableCommand({ tableBucketARN: bucketArn, namespace, name: tableName })))
|
||||
.rejects.toBeInstanceOf(NotFoundException);
|
||||
await client.send(new DeleteNamespaceCommand({ tableBucketARN: bucketArn, namespace }));
|
||||
await expect(client.send(new GetNamespaceCommand({ tableBucketARN: bucketArn, namespace })))
|
||||
.rejects.toBeInstanceOf(NotFoundException);
|
||||
await client.send(new DeleteTableBucketCommand({ tableBucketARN: bucketArn }));
|
||||
await expect(client.send(new GetTableBucketCommand({ tableBucketARN: bucketArn })))
|
||||
.rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Secrets Manager integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
SecretsManagerClient,
|
||||
CreateSecretCommand,
|
||||
GetSecretValueCommand,
|
||||
UpdateSecretCommand,
|
||||
DeleteSecretCommand,
|
||||
ListSecretsCommand,
|
||||
} from '@aws-sdk/client-secrets-manager';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('Secrets Manager', () => {
|
||||
let sm: SecretsManagerClient;
|
||||
let secretName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
sm = makeClient(SecretsManagerClient);
|
||||
secretName = `test/secret/${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await sm.send(
|
||||
new DeleteSecretCommand({ SecretId: secretName, ForceDeleteWithoutRecovery: true })
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create secret', async () => {
|
||||
const response = await sm.send(
|
||||
new CreateSecretCommand({ Name: secretName, SecretString: '{"key":"value"}' })
|
||||
);
|
||||
expect(response.ARN).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get secret value', async () => {
|
||||
const response = await sm.send(new GetSecretValueCommand({ SecretId: secretName }));
|
||||
expect(response.SecretString).toBe('{"key":"value"}');
|
||||
});
|
||||
|
||||
it('should update secret', async () => {
|
||||
await sm.send(
|
||||
new UpdateSecretCommand({ SecretId: secretName, SecretString: '{"key":"updated"}' })
|
||||
);
|
||||
|
||||
const response = await sm.send(new GetSecretValueCommand({ SecretId: secretName }));
|
||||
expect(response.SecretString).toBe('{"key":"updated"}');
|
||||
});
|
||||
|
||||
it('should list secrets', async () => {
|
||||
const response = await sm.send(new ListSecretsCommand({}));
|
||||
expect(response.SecretList?.some((s) => s.Name === secretName)).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete secret', async () => {
|
||||
await sm.send(
|
||||
new DeleteSecretCommand({ SecretId: secretName, ForceDeleteWithoutRecovery: true })
|
||||
);
|
||||
secretName = '';
|
||||
});
|
||||
|
||||
it('should fail to get deleted secret', async () => {
|
||||
await expect(
|
||||
sm.send(new GetSecretValueCommand({ SecretId: `test/secret/${uniqueName()}` }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Common test setup and utilities for floci SDK tests.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { buildSync } from 'esbuild';
|
||||
|
||||
export const ENDPOINT = process.env.FLOCI_ENDPOINT || 'http://localhost:4566';
|
||||
export const REGION = process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
||||
export const ACCOUNT = '000000000000';
|
||||
export const CREDS = {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'test',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'test',
|
||||
};
|
||||
|
||||
export const CLIENT_CONFIG = {
|
||||
endpoint: ENDPOINT,
|
||||
region: REGION,
|
||||
credentials: CREDS,
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
export function makeClient<T>(
|
||||
ClientClass: new (config: typeof CLIENT_CONFIG) => T,
|
||||
extra: Record<string, unknown> = {}
|
||||
): T {
|
||||
return new ClientClass({ ...CLIENT_CONFIG, ...extra });
|
||||
}
|
||||
|
||||
export function uniqueName(prefix = 'test'): string {
|
||||
return `${prefix}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
const SIX_DIGIT_CODE = /\b(\d{6})\b/;
|
||||
|
||||
export async function fetchLatestSesVerificationCode(recipient: string): Promise<string> {
|
||||
const response = await fetch(
|
||||
`${ENDPOINT}/_aws/ses?email=${encodeURIComponent(recipient)}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch SES messages: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
messages?: Array<{ Body?: { text_part?: string } }>;
|
||||
};
|
||||
const firstMessage = payload.messages?.[0];
|
||||
const body =
|
||||
firstMessage?.Body?.text_part ??
|
||||
(firstMessage?.Body as { html_part?: string } | undefined)?.html_part ??
|
||||
'';
|
||||
const match = body.match(SIX_DIGIT_CODE);
|
||||
if (!match) {
|
||||
throw new Error('verification code email should contain a 6-digit code');
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// Build a minimal ZIP file for Lambda functions
|
||||
export function buildMinimalZip(filename: string, content: Buffer): Buffer {
|
||||
const filenameBytes = Buffer.from(filename);
|
||||
const crc = crc32(content);
|
||||
|
||||
// Local file header
|
||||
const localHeader = Buffer.alloc(30 + filenameBytes.length);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0); // signature
|
||||
localHeader.writeUInt16LE(20, 4); // version needed
|
||||
localHeader.writeUInt16LE(0, 6); // flags
|
||||
localHeader.writeUInt16LE(0, 8); // compression: store
|
||||
localHeader.writeUInt16LE(0, 10); // mod time
|
||||
localHeader.writeUInt16LE(0, 12); // mod date
|
||||
localHeader.writeUInt32LE(crc, 14); // crc32
|
||||
localHeader.writeUInt32LE(content.length, 18); // compressed size
|
||||
localHeader.writeUInt32LE(content.length, 22); // uncompressed size
|
||||
localHeader.writeUInt16LE(filenameBytes.length, 26); // filename len
|
||||
localHeader.writeUInt16LE(0, 28); // extra field len
|
||||
filenameBytes.copy(localHeader, 30);
|
||||
|
||||
const centralDir = Buffer.alloc(46 + filenameBytes.length);
|
||||
centralDir.writeUInt32LE(0x02014b50, 0); // signature
|
||||
centralDir.writeUInt16LE(20, 4); // version made by
|
||||
centralDir.writeUInt16LE(20, 6); // version needed
|
||||
centralDir.writeUInt16LE(0, 8); // flags
|
||||
centralDir.writeUInt16LE(0, 10); // compression
|
||||
centralDir.writeUInt16LE(0, 12); // mod time
|
||||
centralDir.writeUInt16LE(0, 14); // mod date
|
||||
centralDir.writeUInt32LE(crc, 16); // crc32
|
||||
centralDir.writeUInt32LE(content.length, 20); // compressed size
|
||||
centralDir.writeUInt32LE(content.length, 24); // uncompressed size
|
||||
centralDir.writeUInt16LE(filenameBytes.length, 28); // filename len
|
||||
centralDir.writeUInt16LE(0, 30); // extra
|
||||
centralDir.writeUInt16LE(0, 32); // comment
|
||||
centralDir.writeUInt16LE(0, 34); // disk start
|
||||
centralDir.writeUInt16LE(0, 36); // int attributes
|
||||
centralDir.writeUInt32LE(0, 38); // ext attributes
|
||||
centralDir.writeUInt32LE(0, 42); // local header offset
|
||||
filenameBytes.copy(centralDir, 46);
|
||||
|
||||
const centralDirOffset = localHeader.length + content.length;
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0); // signature
|
||||
eocd.writeUInt16LE(0, 4); // disk num
|
||||
eocd.writeUInt16LE(0, 6); // disk with cd
|
||||
eocd.writeUInt16LE(1, 8); // total entries on disk
|
||||
eocd.writeUInt16LE(1, 10); // total entries
|
||||
eocd.writeUInt32LE(centralDir.length, 12); // cd size
|
||||
eocd.writeUInt32LE(centralDirOffset, 16); // cd offset
|
||||
eocd.writeUInt16LE(0, 20); // comment len
|
||||
|
||||
return Buffer.concat([localHeader, content, centralDir, eocd]);
|
||||
}
|
||||
|
||||
function makeCrcTable(): Uint32Array {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[i] = c;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
const crcTable = makeCrcTable();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
crc = (crc >>> 8) ^ crcTable[(crc ^ buf[i]) & 0xff];
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Lambda ZIP with all dependencies bundled via esbuild.
|
||||
*
|
||||
* Use this when the handler code requires npm packages (e.g. @aws-sdk/client-apigatewaymanagementapi).
|
||||
* esbuild resolves imports from the test project's node_modules and inlines everything into a single
|
||||
* CommonJS file, which is then zipped using buildMinimalZip.
|
||||
*
|
||||
* This avoids maintaining a separate folder with its own package.json/node_modules.
|
||||
*/
|
||||
export function buildBundledZip(handlerCode: string): Buffer {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'lambda-bundle-'));
|
||||
const entryFile = join(tmpDir, 'index.js');
|
||||
const outFile = join(tmpDir, 'bundle.js');
|
||||
|
||||
// Resolve node_modules from the test project root (where this file lives)
|
||||
const projectRoot = join(import.meta.dirname, '..');
|
||||
const nodeModulesPath = join(projectRoot, 'node_modules');
|
||||
|
||||
try {
|
||||
writeFileSync(entryFile, handlerCode);
|
||||
|
||||
buildSync({
|
||||
entryPoints: [entryFile],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
outfile: outFile,
|
||||
format: 'cjs',
|
||||
minify: false,
|
||||
nodePaths: [nodeModulesPath],
|
||||
});
|
||||
|
||||
const bundled = readFileSync(outFile);
|
||||
return buildMinimalZip('index.js', bundled);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* SNS integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
SNSClient,
|
||||
CreateTopicCommand,
|
||||
SubscribeCommand,
|
||||
PublishCommand,
|
||||
ListTopicsCommand,
|
||||
ListSubscriptionsByTopicCommand,
|
||||
UnsubscribeCommand,
|
||||
DeleteTopicCommand,
|
||||
GetSubscriptionAttributesCommand,
|
||||
SetSubscriptionAttributesCommand,
|
||||
PublishBatchCommand,
|
||||
} from '@aws-sdk/client-sns';
|
||||
import {
|
||||
SQSClient,
|
||||
CreateQueueCommand,
|
||||
GetQueueAttributesCommand,
|
||||
DeleteQueueCommand,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('SNS', () => {
|
||||
let sns: SNSClient;
|
||||
let sqs: SQSClient;
|
||||
let topicArn: string;
|
||||
let subscriptionArn: string;
|
||||
let queueUrl: string;
|
||||
let queueArn: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
sns = makeClient(SNSClient);
|
||||
sqs = makeClient(SQSClient);
|
||||
|
||||
// Create backing SQS queue for subscription
|
||||
const queueResult = await sqs.send(
|
||||
new CreateQueueCommand({ QueueName: `sns-target-${uniqueName()}` })
|
||||
);
|
||||
queueUrl = queueResult.QueueUrl!;
|
||||
const queueAttr = await sqs.send(
|
||||
new GetQueueAttributesCommand({ QueueUrl: queueUrl, AttributeNames: ['QueueArn'] })
|
||||
);
|
||||
queueArn = queueAttr.Attributes!.QueueArn!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (subscriptionArn) {
|
||||
await sns.send(new UnsubscribeCommand({ SubscriptionArn: subscriptionArn }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (topicArn) {
|
||||
await sns.send(new DeleteTopicCommand({ TopicArn: topicArn }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (queueUrl) {
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: queueUrl }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create a topic', async () => {
|
||||
const response = await sns.send(
|
||||
new CreateTopicCommand({ Name: `test-topic-${uniqueName()}` })
|
||||
);
|
||||
topicArn = response.TopicArn!;
|
||||
expect(topicArn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should list topics', async () => {
|
||||
const response = await sns.send(new ListTopicsCommand({}));
|
||||
expect(response.Topics?.some((t) => t.TopicArn === topicArn)).toBe(true);
|
||||
});
|
||||
|
||||
it('should subscribe SQS to topic', async () => {
|
||||
const response = await sns.send(
|
||||
new SubscribeCommand({
|
||||
TopicArn: topicArn,
|
||||
Protocol: 'sqs',
|
||||
Endpoint: queueArn,
|
||||
})
|
||||
);
|
||||
subscriptionArn = response.SubscriptionArn!;
|
||||
expect(subscriptionArn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should list subscriptions by topic', async () => {
|
||||
const response = await sns.send(
|
||||
new ListSubscriptionsByTopicCommand({ TopicArn: topicArn })
|
||||
);
|
||||
expect(response.Subscriptions?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should get subscription attributes', async () => {
|
||||
const response = await sns.send(
|
||||
new GetSubscriptionAttributesCommand({ SubscriptionArn: subscriptionArn })
|
||||
);
|
||||
expect(response.Attributes?.Protocol).toBe('sqs');
|
||||
expect(response.Attributes?.TopicArn).toBe(topicArn);
|
||||
});
|
||||
|
||||
it('should set subscription attributes', async () => {
|
||||
await sns.send(
|
||||
new SetSubscriptionAttributesCommand({
|
||||
SubscriptionArn: subscriptionArn,
|
||||
AttributeName: 'RawMessageDelivery',
|
||||
AttributeValue: 'true',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should publish message', async () => {
|
||||
const response = await sns.send(
|
||||
new PublishCommand({ TopicArn: topicArn, Message: 'test-sns-message' })
|
||||
);
|
||||
expect(response.MessageId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should publish batch messages', async () => {
|
||||
const response = await sns.send(
|
||||
new PublishBatchCommand({
|
||||
TopicArn: topicArn,
|
||||
PublishBatchRequestEntries: [
|
||||
{ Id: '1', Message: 'batch-msg-1' },
|
||||
{ Id: '2', Message: 'batch-msg-2' },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(response.Successful?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should unsubscribe', async () => {
|
||||
await sns.send(new UnsubscribeCommand({ SubscriptionArn: subscriptionArn }));
|
||||
subscriptionArn = '';
|
||||
});
|
||||
|
||||
it('should delete topic', async () => {
|
||||
await sns.send(new DeleteTopicCommand({ TopicArn: topicArn }));
|
||||
topicArn = '';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* SQS integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
SQSClient,
|
||||
CreateQueueCommand,
|
||||
GetQueueUrlCommand,
|
||||
SendMessageCommand,
|
||||
ReceiveMessageCommand,
|
||||
DeleteMessageCommand,
|
||||
GetQueueAttributesCommand,
|
||||
DeleteQueueCommand,
|
||||
SendMessageBatchCommand,
|
||||
SetQueueAttributesCommand,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import { makeClient, uniqueName, ENDPOINT, ACCOUNT } from './setup';
|
||||
|
||||
describe('SQS', () => {
|
||||
let sqs: SQSClient;
|
||||
let queueName: string;
|
||||
let queueUrl: string;
|
||||
let fifoQueueName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
sqs = makeClient(SQSClient);
|
||||
queueName = `test-queue-${uniqueName()}`;
|
||||
fifoQueueName = `test-fifo-${uniqueName()}.fifo`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: queueUrl }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await sqs.send(
|
||||
new DeleteQueueCommand({ QueueUrl: `${ENDPOINT}/${ACCOUNT}/${fifoQueueName}` })
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should create a queue', async () => {
|
||||
const response = await sqs.send(new CreateQueueCommand({ QueueName: queueName }));
|
||||
queueUrl = response.QueueUrl!;
|
||||
expect(queueUrl).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get queue URL', async () => {
|
||||
const response = await sqs.send(new GetQueueUrlCommand({ QueueName: queueName }));
|
||||
expect(response.QueueUrl).toBe(queueUrl);
|
||||
});
|
||||
|
||||
it('should get queue attributes', async () => {
|
||||
const response = await sqs.send(
|
||||
new GetQueueAttributesCommand({ QueueUrl: queueUrl, AttributeNames: ['All'] })
|
||||
);
|
||||
expect(response.Attributes?.QueueArn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should send and receive message', async () => {
|
||||
await sqs.send(
|
||||
new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: 'test-message' })
|
||||
);
|
||||
|
||||
const response = await sqs.send(
|
||||
new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
WaitTimeSeconds: 1,
|
||||
})
|
||||
);
|
||||
expect(response.Messages?.length).toBeGreaterThan(0);
|
||||
expect(response.Messages?.[0].Body).toBe('test-message');
|
||||
|
||||
// Delete the message
|
||||
await sqs.send(
|
||||
new DeleteMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
ReceiptHandle: response.Messages![0].ReceiptHandle!,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should send batch messages', async () => {
|
||||
await sqs.send(
|
||||
new SendMessageBatchCommand({
|
||||
QueueUrl: queueUrl,
|
||||
Entries: [
|
||||
{ Id: '1', MessageBody: 'batch-msg-1' },
|
||||
{ Id: '2', MessageBody: 'batch-msg-2' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should create FIFO queue', async () => {
|
||||
const response = await sqs.send(
|
||||
new CreateQueueCommand({
|
||||
QueueName: fifoQueueName,
|
||||
Attributes: { FifoQueue: 'true', ContentBasedDeduplication: 'true' },
|
||||
})
|
||||
);
|
||||
expect(response.QueueUrl).toContain('.fifo');
|
||||
});
|
||||
|
||||
it('should set queue attributes', async () => {
|
||||
await sqs.send(
|
||||
new SetQueueAttributesCommand({
|
||||
QueueUrl: queueUrl,
|
||||
Attributes: { VisibilityTimeout: '60' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete queue', async () => {
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: queueUrl }));
|
||||
// Reset for cleanup
|
||||
queueUrl = '';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* SSM Parameter Store integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
SSMClient,
|
||||
PutParameterCommand,
|
||||
GetParameterCommand,
|
||||
DeleteParameterCommand,
|
||||
GetParametersByPathCommand,
|
||||
DescribeParametersCommand,
|
||||
} from '@aws-sdk/client-ssm';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
describe('SSM', () => {
|
||||
let ssm: SSMClient;
|
||||
let paramPath: string;
|
||||
|
||||
beforeAll(() => {
|
||||
ssm = makeClient(SSMClient);
|
||||
paramPath = `/floci/test/${uniqueName()}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup any remaining parameters
|
||||
try {
|
||||
await ssm.send(new DeleteParameterCommand({ Name: `${paramPath}/p1` }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await ssm.send(new DeleteParameterCommand({ Name: `${paramPath}/secret` }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should put and get a String parameter', async () => {
|
||||
const name = `${paramPath}/p1`;
|
||||
await ssm.send(
|
||||
new PutParameterCommand({ Name: name, Value: 'hello', Type: 'String' })
|
||||
);
|
||||
|
||||
const response = await ssm.send(new GetParameterCommand({ Name: name }));
|
||||
expect(response.Parameter?.Value).toBe('hello');
|
||||
});
|
||||
|
||||
it('should put and get a SecureString parameter', async () => {
|
||||
const name = `${paramPath}/secret`;
|
||||
await ssm.send(
|
||||
new PutParameterCommand({ Name: name, Value: 's3cr3t', Type: 'SecureString' })
|
||||
);
|
||||
|
||||
const response = await ssm.send(
|
||||
new GetParameterCommand({ Name: name, WithDecryption: true })
|
||||
);
|
||||
expect(response.Parameter?.Value).toBe('s3cr3t');
|
||||
});
|
||||
|
||||
it('should get parameters by path', async () => {
|
||||
const response = await ssm.send(
|
||||
new GetParametersByPathCommand({ Path: paramPath, Recursive: true })
|
||||
);
|
||||
expect(response.Parameters?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should describe parameters', async () => {
|
||||
const response = await ssm.send(new DescribeParametersCommand({}));
|
||||
expect(response.Parameters?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should overwrite parameter', async () => {
|
||||
const name = `${paramPath}/p1`;
|
||||
await ssm.send(
|
||||
new PutParameterCommand({
|
||||
Name: name,
|
||||
Value: 'updated',
|
||||
Type: 'String',
|
||||
Overwrite: true,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await ssm.send(new GetParameterCommand({ Name: name }));
|
||||
expect(response.Parameter?.Value).toBe('updated');
|
||||
});
|
||||
|
||||
it('should fail for missing parameter', async () => {
|
||||
await expect(
|
||||
ssm.send(new GetParameterCommand({ Name: `${paramPath}/missing` }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should delete parameter', async () => {
|
||||
const name = `${paramPath}/p1`;
|
||||
await ssm.send(new DeleteParameterCommand({ Name: name }));
|
||||
|
||||
await expect(ssm.send(new GetParameterCommand({ Name: name }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* STS integration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { STSClient, GetCallerIdentityCommand, AssumeRoleCommand } from '@aws-sdk/client-sts';
|
||||
import { makeClient, ACCOUNT } from './setup';
|
||||
|
||||
describe('STS', () => {
|
||||
let sts: STSClient;
|
||||
|
||||
beforeAll(() => {
|
||||
sts = makeClient(STSClient);
|
||||
});
|
||||
|
||||
it('should get caller identity', async () => {
|
||||
const response = await sts.send(new GetCallerIdentityCommand({}));
|
||||
expect(response.Account).toBeTruthy();
|
||||
expect(response.UserId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should assume role', async () => {
|
||||
const response = await sts.send(
|
||||
new AssumeRoleCommand({
|
||||
RoleArn: `arn:aws:iam::${ACCOUNT}:role/test-role`,
|
||||
RoleSessionName: 'test-session',
|
||||
})
|
||||
);
|
||||
expect(response.Credentials?.AccessKeyId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* TLS/HTTPS connectivity tests for floci.
|
||||
*
|
||||
* Verifies that AWS SDK v3 clients can interact with floci over HTTPS
|
||||
* when TLS is enabled with a self-signed certificate.
|
||||
*
|
||||
* These tests run within the existing test suite. Since Floci serves HTTP
|
||||
* and HTTPS simultaneously, existing HTTP tests are unaffected. The HTTPS
|
||||
* endpoint is derived from the HTTP endpoint by swapping the scheme.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Floci must be started with FLOCI_TLS_ENABLED=true
|
||||
* - NODE_TLS_REJECT_UNAUTHORIZED=0 must be set in the test environment
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
||||
import {
|
||||
SQSClient,
|
||||
CreateQueueCommand,
|
||||
SendMessageCommand,
|
||||
DeleteQueueCommand,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import {
|
||||
SSMClient,
|
||||
PutParameterCommand,
|
||||
GetParameterCommand,
|
||||
DeleteParameterCommand,
|
||||
} from '@aws-sdk/client-ssm';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteBucketCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { ENDPOINT, uniqueName, ACCOUNT, REGION, CREDS } from './setup';
|
||||
|
||||
// Derive the HTTPS endpoint from the HTTP endpoint by swapping the scheme.
|
||||
// When TLS is enabled, Floci serves HTTPS on port 4566 (same port as HTTP without TLS).
|
||||
const HTTPS_ENDPOINT = ENDPOINT.replace(/^http:\/\//, 'https://');
|
||||
|
||||
const TLS_CLIENT_CONFIG = {
|
||||
endpoint: HTTPS_ENDPOINT,
|
||||
region: REGION,
|
||||
credentials: CREDS,
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
function makeTlsClient<T>(
|
||||
ClientClass: new (config: typeof TLS_CLIENT_CONFIG) => T,
|
||||
): T {
|
||||
return new ClientClass(TLS_CLIENT_CONFIG);
|
||||
}
|
||||
|
||||
describe('TLS — STS over HTTPS', () => {
|
||||
let sts: STSClient;
|
||||
|
||||
beforeAll(() => {
|
||||
sts = makeTlsClient(STSClient);
|
||||
});
|
||||
|
||||
it('should call GetCallerIdentity over HTTPS', async () => {
|
||||
const response = await sts.send(new GetCallerIdentityCommand({}));
|
||||
expect(response.Account).toBe(ACCOUNT);
|
||||
expect(response.Arn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TLS — SQS over HTTPS', () => {
|
||||
let sqs: SQSClient;
|
||||
let queueUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
sqs = makeTlsClient(SQSClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (queueUrl) {
|
||||
try {
|
||||
await sqs.send(new DeleteQueueCommand({ QueueUrl: queueUrl }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should create a queue and get HTTPS queue URL', async () => {
|
||||
const name = uniqueName('tls-sqs');
|
||||
const response = await sqs.send(new CreateQueueCommand({ QueueName: name }));
|
||||
queueUrl = response.QueueUrl!;
|
||||
expect(queueUrl).toMatch(/^https:\/\//);
|
||||
expect(queueUrl).toContain(name);
|
||||
});
|
||||
|
||||
it('should send a message over HTTPS', async () => {
|
||||
const response = await sqs.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MessageBody: 'hello-tls',
|
||||
})
|
||||
);
|
||||
expect(response.MessageId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TLS — SSM over HTTPS', () => {
|
||||
let ssm: SSMClient;
|
||||
const paramName = `/${uniqueName('tls-param')}`;
|
||||
|
||||
beforeAll(() => {
|
||||
ssm = makeTlsClient(SSMClient);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await ssm.send(new DeleteParameterCommand({ Name: paramName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should put and get a parameter over HTTPS', async () => {
|
||||
await ssm.send(
|
||||
new PutParameterCommand({
|
||||
Name: paramName,
|
||||
Value: 'tls-value',
|
||||
Type: 'String',
|
||||
})
|
||||
);
|
||||
|
||||
const response = await ssm.send(
|
||||
new GetParameterCommand({ Name: paramName })
|
||||
);
|
||||
expect(response.Parameter?.Value).toBe('tls-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TLS — S3 over HTTPS', () => {
|
||||
let s3: S3Client;
|
||||
const bucketName = uniqueName('tls-s3');
|
||||
const key = 'test-object.txt';
|
||||
|
||||
beforeAll(async () => {
|
||||
s3 = makeTlsClient(S3Client);
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }));
|
||||
await s3.send(new DeleteBucketCommand({ Bucket: bucketName }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should put and get an object over HTTPS', async () => {
|
||||
const body = 'hello from TLS';
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Body: body,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await s3.send(
|
||||
new GetObjectCommand({ Bucket: bucketName, Key: key })
|
||||
);
|
||||
const text = await response.Body!.transformToString();
|
||||
expect(text).toBe(body);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user