/** * 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 { 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; query?: string } = {} ): Promise { 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 { 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 { 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; 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; }); 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; 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; 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; expect(qsp.foo).toBe('bar'); expect(qsp.baz).toBe('qux'); }); it('should include multiValueQueryStringParameters', () => { expect(receivedEvent.multiValueQueryStringParameters).toBeTruthy(); const mqsp = receivedEvent.multiValueQueryStringParameters as Record; 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; 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; 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; const identity = ctx.identity as Record; expect(identity.sourceIp).toBe('127.0.0.1'); }); it('should include requestContext.resourcePath', () => { const ctx = receivedEvent.requestContext as Record; expect(ctx.resourcePath).toBe('/items/{id}'); }); it('should include requestContext.httpMethod', () => { const ctx = receivedEvent.requestContext as Record; expect(ctx.httpMethod).toBe('GET'); }); it('should include requestContext.stage', () => { const ctx = receivedEvent.requestContext as Record; expect(ctx.stage).toBe('prod'); }); it('should include requestContext.accountId', () => { const ctx = receivedEvent.requestContext as Record; 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; expect(ctx.apiId).toBe(apiId); }); it('should include requestContext.resourceId as a non-empty string', () => { const ctx = receivedEvent.requestContext as Record; 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; 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; const identity = ctx.identity as Record; 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; expect(event.queryStringParameters).toBeNull(); expect(event.multiValueQueryStringParameters).toBeNull(); }); }); // ──────────────────────────── TOKEN authorizer preservation ──────────────────────────── describe('TOKEN authorizer — event shape preserved', () => { let receivedEvent: Record; 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; }); 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; 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; const sv = event.stageVariables as Record; 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; const identity = (event.requestContext as Record).identity as Record; 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; const identity = (event.requestContext as Record).identity as Record; 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; const identity = (event.requestContext as Record).identity as Record; 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 | null; mvqsp: Record | null }> { const res = await executeApi(apiId, 'prod', '/echo', { query }); expect(res.status).toBe(200); return res.json() as Promise<{ qsp: Record | null; mvqsp: Record | 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 { 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 { 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 { 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' }); }); });