second first commit

This commit is contained in:
Zakaria
2026-09-22 08:57:00 -04:00
parent e88d7e2ff3
commit 0aef7fb2d8
4766 changed files with 1236986 additions and 0 deletions
@@ -0,0 +1,15 @@
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm install --no-fund --no-audit
COPY tsconfig.json vitest.config.ts ./
COPY tests/ tests/
ENV FLOCI_ENDPOINT=http://floci:4566
ENV NODE_TLS_REJECT_UNAUTHORIZED=0
ENV TEST_RESULTS_DIR=/results
RUN mkdir -p /results
ENTRYPOINT ["npm", "test"]
@@ -0,0 +1,88 @@
# sdk-test-node
Compatibility tests for [Floci](https://github.com/hectorvent/floci) using the **AWS SDK for JavaScript v3 (3.1003.0)**.
## Services Covered
| Group | Description |
| ----------------------- | -------------------------------------------------------------------------------------------------- |
| `ssm` | Parameter Store — put, get, label, history, path, tags |
| `sqs` | Queues, send/receive/delete, DLQ, visibility |
| `sns` | Topics, subscriptions, publish, SQS delivery |
| `s3` | Buckets, objects, tagging, copy, batch delete |
| `s3-cors` | CORS configuration |
| `s3-notifications` | S3 → SQS and S3 → SNS event notifications |
| `dynamodb` | Tables, CRUD, batch, TTL, tags |
| `lambda` | Create/invoke/update/delete functions |
| `iam` | Users, roles, policies, access keys |
| `sts` | GetCallerIdentity, AssumeRole, GetSessionToken |
| `secretsmanager` | Create/get/put/list/delete secrets, versioning, tags |
| `kms` | Keys, aliases, encrypt/decrypt, data keys, sign/verify |
| `kinesis` | Streams, shards, PutRecord/GetRecords |
| `cloudwatch` | PutMetricData, ListMetrics, GetMetricStatistics, alarms |
| `cloudformation-naming` | Auto physical name generation, explicit name precedence, cross-reference |
| `cognito` | User pools, clients, AdminCreateUser, InitiateAuth, GetUser |
| `cognito-oauth` | Resource server CRUD, confidential clients, `/oauth2/token`, OIDC discovery, JWKS/JWT verification |
| `apigatewayv2` | HTTP & WebSocket API lifecycle, routes, integrations, authorizers, stages, deployments, route responses, models, tagging |
## Requirements
- Node.js 20+
- npm
## Running
```bash
npm install
# All groups
npm test
# Via just (from compatibility-tests/)
just test-typescript
```
## Configuration
| Variable | Default | Description |
| ---------------- | ----------------------- | ----------------------- |
| `FLOCI_ENDPOINT` | `http://localhost:4566` | Floci emulator endpoint |
AWS credentials are always `test` / `test` / `us-east-1`.
## Docker
```bash
docker build -t floci-sdk-node .
docker run --rm --network host floci-sdk-node
# Custom endpoint (macOS/Windows)
docker run --rm -e FLOCI_ENDPOINT=http://host.docker.internal:4566 floci-sdk-node
```
## TLS Tests
The `tls.test.ts` file tests HTTPS connectivity with AWS SDK v3 clients.
The `apigatewayv2-websocket-tls.test.ts` file tests WebSocket over TLS (WSS),
covering connect/disconnect, route selection, broadcast, authorization,
`@connections` API, binary frames, and payload limits.
These tests always run as part of the suite — they derive the HTTPS/WSS endpoint
from the HTTP endpoint by swapping the scheme (`http://floci:4566` → `https://floci:4566`,
`ws://` → `wss://`).
Since Floci serves HTTP and HTTPS simultaneously when TLS is enabled, existing HTTP
tests are unaffected.
The Dockerfile sets `NODE_TLS_REJECT_UNAUTHORIZED=0` so self-signed certs are accepted.
The CI workflow starts Floci with `FLOCI_TLS_ENABLED=true`.
To run locally:
```bash
# 1. Start Floci with TLS enabled
FLOCI_TLS_ENABLED=true ./mvnw quarkus:dev
# 2. Run all tests (including TLS)
NODE_TLS_REJECT_UNAUTHORIZED=0 npm test
```
@@ -0,0 +1,45 @@
{
"name": "floci-compatibility-tests-typescript",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@aws-sdk/client-acm": "^3.500.0",
"@aws-sdk/client-api-gateway": "^3.500.0",
"@aws-sdk/client-apigatewaymanagementapi": "^3.500.0",
"@aws-sdk/client-apigatewayv2": "^3.500.0",
"@aws-sdk/client-cloudformation": "^3.500.0",
"@aws-sdk/client-cloudwatch": "^3.500.0",
"@aws-sdk/client-cognito-identity-provider": "^3.500.0",
"@aws-sdk/client-dynamodb": "^3.500.0",
"@aws-sdk/client-ecr": "^3.500.0",
"@aws-sdk/client-eventbridge": "^3.500.0",
"@aws-sdk/client-iam": "^3.500.0",
"@aws-sdk/client-kinesis": "^3.500.0",
"@aws-sdk/client-kms": "^3.500.0",
"@aws-sdk/client-lambda": "^3.500.0",
"@aws-sdk/client-neptune": "^3.500.0",
"@aws-sdk/client-pipes": "^3.500.0",
"@aws-sdk/client-s3": "^3.500.0",
"@aws-sdk/client-s3tables": "3.1111.0",
"@aws-sdk/client-secrets-manager": "^3.500.0",
"@aws-sdk/client-sns": "^3.500.0",
"@aws-sdk/client-sqs": "^3.500.0",
"@aws-sdk/client-ssm": "^3.500.0",
"@aws-sdk/client-sts": "^3.500.0",
"@aws-sdk/lib-storage": "^3.500.0",
"selfsigned": "^5.5.0",
"ws": "^8.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/ws": "^8.0.0",
"amazon-cognito-identity-js": "^6.3.16",
"esbuild": "^0.21.0",
"typescript": "^5.4.0",
"vitest": "^2.0.0"
}
}
@@ -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);
});
});
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": ".",
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
const resultsDir = process.env.TEST_RESULTS_DIR || 'test-results';
export default defineConfig({
test: {
environment: 'node',
globals: true,
testTimeout: 60000,
hookTimeout: 30000,
include: ['tests/**/*.test.ts'],
reporters: ['default', 'junit'],
outputFile: {
junit: `${resultsDir}/junit.xml`,
},
pool: 'forks',
poolOptions: {
forks: {
singleFork: true,
},
},
},
});