import {afterEach, describe, expect, test} from 'bun:test' import { CreateApiCommand, CreateAuthorizerCommand, CreateDeploymentCommand, CreateIntegrationCommand, CreateRouteCommand, CreateRouteResponseCommand, CreateStageCommand, DeleteApiCommand, DeleteCorsConfigurationCommand, DeleteIntegrationCommand, DeleteRouteCommand, GetApiCommand, GetApisCommand, GetAuthorizersCommand, GetIntegrationsCommand, GetRoutesCommand, GetStageCommand, GetStagesCommand, UpdateApiCommand, UpdateRouteCommand, type ApiGatewayV2Client, } from '@aws-sdk/client-apigatewayv2' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' import {AwsHttpApiOperations, httpRouteKey, webSocketRouteKey} from './AwsHttpApiOperations' import {ValidationError} from '../cloud-spi/errors' const CREATED = new Date('2026-10-25T10:00:00Z') const httpApi = {ApiId: 'h1', Name: 'notes', ProtocolType: 'HTTP', ApiEndpoint: 'https://h1.execute-api.us-east-1.amazonaws.com', CreatedDate: CREATED} const webSocketApi = {ApiId: 'w1', Name: 'chat', ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action', CreatedDate: CREATED} const functionArn = 'arn:aws:lambda:us-east-1:000000000000:function:notes' interface Runtime { apis?: Record routes?: unknown[] integrations?: unknown[] stages?: unknown[] authorizers?: unknown[] reply?: (command: object) => unknown } function stubRuntime(runtime: Runtime = {}) { const sent: object[] = [] const lambdaSent: object[] = [] const apis = runtime.apis ?? {h1: httpApi, w1: webSocketApi} const client = { async send(command: object) { sent.push(command) const custom = runtime.reply?.(command) if (custom !== undefined) return custom if (command instanceof GetApisCommand) return {Items: Object.values(apis)} if (command instanceof GetApiCommand) { const api = apis[command.input.ApiId!] if (!api) throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}}) return api } if (command instanceof GetRoutesCommand) return {Items: runtime.routes ?? []} if (command instanceof GetIntegrationsCommand) return {Items: runtime.integrations ?? []} if (command instanceof GetStagesCommand) return {Items: runtime.stages ?? []} if (command instanceof GetAuthorizersCommand) return {Items: runtime.authorizers ?? []} if (command instanceof CreateIntegrationCommand) return {IntegrationId: 'new-integration', ...command.input} if (command instanceof CreateRouteCommand) return {RouteId: 'new-route', ...command.input} if (command instanceof CreateApiCommand) return {ApiId: 'new-api', CreatedDate: CREATED, ...command.input} if (command instanceof CreateStageCommand) return {...command.input} return {} }, } as unknown as ApiGatewayV2Client const lambda = { async send(command: object) { lambdaSent.push(command) if (command instanceof GetFunctionCommand) return {Configuration: {FunctionArn: functionArn}} return {} }, } as unknown as LambdaClient const discoveries: string[] = [] const fetchJson = async (url: string) => { discoveries.push(url) return {issuer: 'http://floci:4566/us-east-1_pool1'} } const operations = new AwsHttpApiOperations(client, lambda, '000000000000', 'us-east-1', fetchJson) const of = (type: new (...args: never[]) => T): T[] => sent.filter((command) => command instanceof type) as T[] const lambdaOf = (type: new (...args: never[]) => T): T[] => lambdaSent.filter((command) => command instanceof type) as T[] return {operations, sent, of, lambdaOf, discoveries} } describe('AwsHttpApiOperations APIs', () => { test('lists HTTP and WebSocket APIs with their protocol', async () => { const {operations} = stubRuntime() const apis = await operations.list() expect(apis.map((api) => [api.id, api.type, api.metadata.protocol, api.metadata.apigatewayService])).toEqual([ ['h1', 'http-api', 'HTTP', 'http'], ['w1', 'websocket-api', 'WEBSOCKET', 'websocket'], ]) expect(await operations.get('rest-id')).toBeNull() }) test('quick-creates an HTTP API that sends every request to a Lambda', async () => { const {operations, of, lambdaOf} = stubRuntime() await operations.create({name: 'notes', functionName: 'notes'}, 'HTTP') expect(of(CreateApiCommand)[0].input).toMatchObject({Name: 'notes', ProtocolType: 'HTTP', Target: functionArn}) expect(of(CreateStageCommand)).toHaveLength(0) expect(lambdaOf(AddPermissionCommand)[0].input).toMatchObject({ FunctionName: 'notes', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:new-api/*', }) }) test('gives an HTTP API without a target an auto-deployed $default stage', async () => { const {operations, of} = stubRuntime() await operations.create({name: 'empty'}, 'HTTP') expect(of(CreateStageCommand)[0].input).toEqual({ApiId: 'new-api', StageName: '$default', AutoDeploy: true}) }) test('creates a WebSocket API with its route selection expression and a stage', async () => { const {operations, of} = stubRuntime() await operations.create({name: 'chat', stageName: 'prod'}, 'WEBSOCKET') expect(of(CreateApiCommand)[0].input).toMatchObject({ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action'}) expect(of(CreateStageCommand)[0].input).toMatchObject({StageName: 'prod', AutoDeploy: true}) await expect(operations.create({name: 'chat', routeSelectionExpression: 'action'}, 'WEBSOCKET')).rejects.toThrow(/routeSelectionExpression/) }) test('deletes an API and the Lambda permissions it granted', async () => { const {operations, of, lambdaOf} = stubRuntime({integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}]}) await operations.delete('h1') expect(of(DeleteApiCommand)[0].input).toEqual({ApiId: 'h1'}) expect(lambdaOf(RemovePermissionCommand)[0].input).toEqual({FunctionName: 'notes', StatementId: 'apigatewayv2-h1'}) }) }) describe('AwsHttpApiOperations routes', () => { test('describes routes with their integration, stages, authorizers and CORS', async () => { const {operations} = stubRuntime({ apis: {h1: {...httpApi, CorsConfiguration: {AllowOrigins: ['*'], AllowMethods: ['GET'], MaxAge: 300}}}, routes: [ {RouteId: 'r2', RouteKey: 'POST /notes', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'}, {RouteId: 'r1', RouteKey: 'GET /notes', Target: 'integrations/i1', AuthorizationType: 'NONE', AuthorizerId: 'a1'}, ], integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn, PayloadFormatVersion: '2.0'}], stages: [{StageName: '$default', AutoDeploy: true}], authorizers: [{AuthorizerId: 'a1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}}], }) const overview = await operations.describe('h1') expect(overview.routes.map((route) => [route.routeKey, route.integration?.functionName, route.authorizerId])).toEqual([ ['GET /notes', 'notes', null], ['POST /notes', 'notes', 'a1'], ]) expect(overview.stages).toEqual([{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}]) expect(overview.authorizers[0]).toMatchObject({name: 'cognito', issuer: 'http://floci:4566/us-east-1_pool1', audience: ['client1']}) expect(overview.cors).toMatchObject({allowOrigins: ['*'], allowMethods: ['GET'], maxAgeSeconds: 300}) }) test('adds a Lambda route, reusing the integration the function already has', async () => { const {operations, of, lambdaOf} = stubRuntime({ integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}], }) const route = await operations.createRoute('h1', {routeKey: 'get /notes/{id}', functionName: 'notes', authorizerId: 'a1'}) expect(of(CreateIntegrationCommand)).toHaveLength(0) expect(of(CreateRouteCommand)[0].input).toMatchObject({RouteKey: 'GET /notes/{id}', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'}) expect(lambdaOf(AddPermissionCommand)).toHaveLength(1) expect(route.integration?.functionName).toBe('notes') }) test('adds an HTTP proxy route with a payload 1.0 integration', async () => { const {operations, of} = stubRuntime() await operations.createRoute('h1', {routeKey: 'ANY /legacy/{proxy+}', httpUrl: 'https://example.com/{proxy}'}) expect(of(CreateIntegrationCommand)[0].input).toMatchObject({IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com/{proxy}', IntegrationMethod: 'ANY', PayloadFormatVersion: '1.0'}) }) test('a two-way WebSocket route gets a route response and an invocation-URI integration', async () => { const {operations, of} = stubRuntime() await operations.createRoute('w1', {routeKey: 'sendMessage', functionName: 'notes', returnsResponse: true}) expect(of(CreateIntegrationCommand)[0].input.IntegrationUri).toBe(`arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${functionArn}/invocations`) expect(of(CreateRouteCommand)[0].input.RouteResponseSelectionExpression).toBe('$default') expect(of(CreateRouteResponseCommand)[0].input).toEqual({ApiId: 'w1', RouteId: 'new-route', RouteResponseKey: '$default'}) }) test('checks a route before creating anything', async () => { const {operations, sent} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /notes'}]}) const before = sent.length await expect(operations.createRoute('h1', {routeKey: 'GET /notes', functionName: 'notes'})).rejects.toThrow(/already has/) await expect(operations.createRoute('h1', {routeKey: 'GET /notes/x', functionName: 'notes', httpUrl: 'https://example.com'})).rejects.toThrow(/either/) await expect(operations.createRoute('h1', {routeKey: 'FETCH /notes', functionName: 'notes'})).rejects.toThrow(ValidationError) await expect(operations.createRoute('w1', {routeKey: 'send', httpUrl: 'https://example.com'})).rejects.toThrow(/Lambda functions only/) await expect(operations.createRoute('w1', {routeKey: 'send', functionName: 'notes', authorizerId: 'a1'})).rejects.toThrow(/Lambda authorizers/) expect(sent.slice(before).some((command) => command instanceof CreateRouteCommand || command instanceof CreateIntegrationCommand)).toBe(false) }) test('deleting a route removes the integration and permission only it used', async () => { const integrations = [ {IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}, {IntegrationId: 'i2', IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com'}, ] const shared = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}, {RouteId: 'r2', Target: 'integrations/i1'}], integrations}) await shared.operations.deleteRoute('h1', 'r1') expect(shared.of(DeleteRouteCommand)).toHaveLength(1) expect(shared.of(DeleteIntegrationCommand)).toHaveLength(0) const last = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}], integrations}) await last.operations.deleteRoute('h1', 'r1') expect(last.of(DeleteIntegrationCommand)[0].input).toEqual({ApiId: 'h1', IntegrationId: 'i1'}) expect(last.lambdaOf(RemovePermissionCommand)[0].input.FunctionName).toBe('notes') }) test('protects a route with an authorizer and removes it again', async () => { const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateRouteCommand ? {RouteKey: 'GET /me', ...command.input} : undefined}) expect((await operations.setRouteAuthorizer('h1', 'r1', 'a1')).authorizerId).toBe('a1') expect((await operations.setRouteAuthorizer('h1', 'r1', null)).authorizerId).toBeNull() expect(of(UpdateRouteCommand).map((command) => command.input)).toEqual([ {ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'JWT', AuthorizerId: 'a1'}, {ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'NONE', AuthorizerId: undefined}, ]) }) }) describe('AwsHttpApiOperations stages, authorizers and CORS', () => { test('a stage without auto-deploy is deployed once when created', async () => { const {operations, of} = stubRuntime({reply: (command) => command instanceof GetStageCommand ? {StageName: 'prod', DeploymentId: 'd1'} : undefined}) const stage = await operations.createStage('h1', {stageName: 'prod', autoDeploy: false}) expect(of(CreateDeploymentCommand)[0].input).toMatchObject({ApiId: 'h1', StageName: 'prod'}) expect(stage).toMatchObject({stageName: 'prod', deploymentId: 'd1', invokePath: '/execute-api/h1/prod'}) await expect(operations.createStage('h1', {stageName: 'pro d', autoDeploy: true})).rejects.toThrow(/stage/) }) test('fills a JWT authorizer from a Cognito pool discovery document', async () => { const {operations, of, discoveries} = stubRuntime({reply: (command) => command instanceof CreateAuthorizerCommand ? {AuthorizerId: 'a1', ...command.input} : undefined}) const authorizer = await operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1', userPoolClientId: 'client1'}) expect(discoveries[0]).toEndWith('/us-east-1_pool1/.well-known/openid-configuration') expect(of(CreateAuthorizerCommand)[0].input).toEqual({ ApiId: 'h1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}, }) expect(authorizer.issuer).toBe('http://floci:4566/us-east-1_pool1') await expect(operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1'})).rejects.toThrow(/app client/) await expect(operations.createAuthorizer('h1', {name: 'other', issuer: 'https://issuer.example.com', audience: []})).rejects.toThrow(/audience/) await expect(operations.createAuthorizer('w1', {name: 'x', issuer: 'https://issuer.example.com', audience: ['a']})).rejects.toThrow(/HTTP APIs/) }) test('will not delete an authorizer that still protects a route', async () => { const {operations} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /me', AuthorizationType: 'JWT', AuthorizerId: 'a1'}]}) await expect(operations.deleteAuthorizer('h1', 'a1')).rejects.toThrow('Remove the authorizer from GET /me first') }) test('sets and removes the CORS configuration', async () => { const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateApiCommand ? {...httpApi, CorsConfiguration: command.input.CorsConfiguration} : undefined}) const cors = {allowOrigins: ['https://app.example.com'], allowMethods: ['get', 'POST'], allowHeaders: ['content-type'], exposeHeaders: [], maxAgeSeconds: 600, allowCredentials: true} expect(await operations.updateCors('h1', cors)).toEqual({...cors, allowMethods: ['GET', 'POST']}) expect(await operations.updateCors('h1', null)).toBeNull() expect(of(DeleteCorsConfigurationCommand)).toHaveLength(1) await expect(operations.updateCors('h1', {...cors, allowOrigins: ['*']})).rejects.toThrow(/Credentials/) await expect(operations.updateCors('h1', {...cors, allowOrigins: ['app.example.com']})).rejects.toThrow(/origin/) await expect(operations.updateCors('w1', cors)).rejects.toThrow(/HTTP APIs only/) }) }) describe('AwsHttpApiOperations WebSocket test', () => { const endpoint = process.env.FLOCI_ENDPOINT afterEach(() => { if (endpoint === undefined) delete process.env.FLOCI_ENDPOINT else process.env.FLOCI_ENDPOINT = endpoint }) test('connects to the stage, sends the message and collects the replies', async () => { const paths: string[] = [] const server = Bun.serve({ port: 0, fetch(request, upgrade) { paths.push(new URL(request.url).pathname + new URL(request.url).search) return upgrade.upgrade(request) ? undefined : new Response('no', {status: 400}) }, websocket: {message(socket, message) { socket.send(`echo ${message}`) }}, }) process.env.FLOCI_ENDPOINT = `http://localhost:${server.port}` try { const {operations} = stubRuntime() const result = await operations.testWebSocket('w1', {stage: 'dev', message: '{"action":"ping"}', query: '?token=abc'}) expect(result).toMatchObject({connected: true, messages: ['echo {"action":"ping"}'], error: null}) expect(paths).toEqual(['/ws/w1/dev?token=abc']) } finally { server.stop(true) } }) test('reports a refused connection', async () => { process.env.FLOCI_ENDPOINT = 'http://127.0.0.1:1' const {operations} = stubRuntime() const result = await operations.testWebSocket('w1', {stage: 'dev', message: 'hi'}, 2000) expect(result.connected).toBe(false) expect(result.error).toBeTruthy() await expect(operations.testWebSocket('h1', {stage: 'dev', message: 'hi'})).rejects.toThrow(/not a WebSocket API/) }) }) describe('route keys', () => { test('HTTP route keys are $default or a method and a path', () => { expect(httpRouteKey('$default')).toBe('$default') expect(httpRouteKey(' any /files/{proxy+} ')).toBe('ANY /files/{proxy+}') expect(httpRouteKey('GET /')).toBe('GET /') for (const bad of ['GET', '/notes', 'GET notes', 'GET /{proxy+}/more', 'GET /a b', 'GET /notes/']) { expect(() => httpRouteKey(bad), bad).toThrow(ValidationError) } }) test('WebSocket route keys are the system routes or an action name', () => { expect(webSocketRouteKey('$connect')).toBe('$connect') expect(webSocketRouteKey('sendMessage')).toBe('sendMessage') expect(() => webSocketRouteKey('$other')).toThrow(ValidationError) expect(() => webSocketRouteKey('send message')).toThrow(ValidationError) }) })