bb5ca85a88
CI / checks (pull_request) Successful in 26m49s
API Gateway v2 is a separate AWS API from REST APIs, so HTTP and WebSocket APIs made with the SDK, SAM, CDK or Terraform never showed up. The API Gateway page now lists all three kinds with a Type column, creates each from the same form, and opens HTTP and WebSocket APIs in their own panel: - Quick create: an HTTP API with a Lambda gets a catch-all route on an auto-deployed $default stage, plus the Lambda permission. - Routes to Lambda (payload 2.0) or HTTP backends; deleting a route removes the integration and Lambda permission only it used. - Stages with or without automatic deployment, and deploy on demand. - JWT authorizers filled in from a Cognito pool's OpenID Connect discovery document and an app client, or from any issuer; protect routes with them. - CORS configuration. - A tester that sends a real request to a stage, and for WebSocket APIs one that connects, sends a message and collects the replies. Capabilities are advertised under a new httpApiActions group, covered by the cloudProxy guard test. Verified end to end against TheCloud's runtime.
127 lines
7.8 KiB
TypeScript
127 lines
7.8 KiB
TypeScript
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
|
import {render, screen, within} from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import {beforeEach, expect, test, vi} from 'vitest'
|
|
import {HttpApiPanel} from './HttpApiPanel'
|
|
import type {HttpApiOverview} from '@/api/cloudProxyClient'
|
|
import type {CloudResource} from '@/types/resource'
|
|
|
|
const api = vi.hoisted(() => ({
|
|
describeHttpApi: vi.fn(),
|
|
listCloudResources: vi.fn(),
|
|
createHttpApiRoute: vi.fn(),
|
|
deleteHttpApiRoute: vi.fn(),
|
|
setHttpApiRouteAuthorizer: vi.fn(),
|
|
createHttpApiStage: vi.fn(),
|
|
deleteHttpApiStage: vi.fn(),
|
|
deployHttpApi: vi.fn(),
|
|
createHttpApiAuthorizer: vi.fn(),
|
|
deleteHttpApiAuthorizer: vi.fn(),
|
|
updateHttpApiCors: vi.fn(),
|
|
testApiGatewayRoute: vi.fn(),
|
|
testWebSocketApi: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/api/cloudProxyClient', () => api)
|
|
|
|
const httpResource: CloudResource = {
|
|
id: 'h1', name: 'notes-api', cloud: 'aws', service: 'apigateway', type: 'http-api', region: null, createdAt: null,
|
|
metadata: {apigatewayService: 'http', protocol: 'HTTP'},
|
|
}
|
|
|
|
const httpOverview: HttpApiOverview = {
|
|
apiId: 'h1', protocol: 'HTTP', apiEndpoint: null, routeSelectionExpression: null,
|
|
routes: [
|
|
{routeId: 'r1', routeKey: 'GET /notes/{id}', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: false},
|
|
{routeId: 'r2', routeKey: 'GET /me', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'JWT', authorizerId: 'a1', authorizationScopes: [], returnsResponse: false},
|
|
],
|
|
stages: [{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}],
|
|
authorizers: [{authorizerId: 'a1', name: 'cognito', type: 'JWT', identitySource: ['$request.header.Authorization'], issuer: 'http://floci:4566/us-east-1_pool', audience: ['client1']}],
|
|
cors: null,
|
|
}
|
|
|
|
function renderPanel(resource: CloudResource = httpResource) {
|
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
|
return render(<QueryClientProvider client={client}><HttpApiPanel cloud="aws" resource={resource} runtimeReachable runtimeEndpoint="http://localhost:4566"/></QueryClientProvider>)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
api.describeHttpApi.mockResolvedValue(httpOverview)
|
|
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => service === 'serverless'
|
|
? [{id: 'notes', name: 'notes', type: 'lambda', cloud: 'aws', service: 'serverless', region: null, createdAt: null, metadata: {}}]
|
|
: [{id: 'us-east-1_pool', name: 'notes-users', type: 'user-pool', cloud: 'aws', service: 'userpools', region: null, createdAt: null, metadata: {appClients: [{clientId: 'client1', clientName: 'web'}]}}])
|
|
api.createHttpApiRoute.mockResolvedValue(httpOverview.routes[0])
|
|
api.setHttpApiRouteAuthorizer.mockResolvedValue(httpOverview.routes[0])
|
|
api.createHttpApiAuthorizer.mockResolvedValue(httpOverview.authorizers[0])
|
|
api.updateHttpApiCors.mockResolvedValue({allowOrigins: ['*'], allowMethods: ['GET'], allowHeaders: [], exposeHeaders: [], maxAgeSeconds: null, allowCredentials: false})
|
|
api.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '{"ok":true}', durationMs: 12})
|
|
})
|
|
|
|
test('shows the stage URL, routes with their targets and who may call them', async () => {
|
|
renderPanel()
|
|
|
|
expect(await screen.findByText('http://localhost:4566/execute-api/h1/$default')).toBeInTheDocument()
|
|
const routes = within(screen.getByRole('region', {name: 'Routes'}))
|
|
expect(routes.getByText('GET /notes/{id}')).toBeInTheDocument()
|
|
expect(routes.getAllByText('Lambda: notes')).toHaveLength(2)
|
|
expect(routes.getByRole('combobox', {name: 'Access for GET /me'})).toHaveValue('a1')
|
|
expect(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'})).toHaveValue('')
|
|
})
|
|
|
|
test('adds a Lambda route and protects an existing one', async () => {
|
|
renderPanel()
|
|
const routes = within(await screen.findByRole('region', {name: 'Routes'}))
|
|
|
|
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Method'}), 'POST')
|
|
await userEvent.clear(routes.getByRole('textbox', {name: 'Path'}))
|
|
await userEvent.type(routes.getByRole('textbox', {name: 'Path'}), '/notes')
|
|
await routes.findByRole('option', {name: 'notes'})
|
|
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Lambda function'}), 'notes')
|
|
await userEvent.click(routes.getByRole('button', {name: 'Add route'}))
|
|
expect(api.createHttpApiRoute).toHaveBeenCalledWith('aws', 'h1', {routeKey: 'POST /notes', functionName: 'notes', httpUrl: undefined, authorizerId: undefined, returnsResponse: undefined})
|
|
|
|
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'}), 'a1')
|
|
expect(api.setHttpApiRouteAuthorizer).toHaveBeenCalledWith('aws', 'h1', 'r1', 'a1')
|
|
})
|
|
|
|
test('creates a JWT authorizer from a Cognito pool and app client', async () => {
|
|
renderPanel()
|
|
const security = within(await screen.findByRole('region', {name: 'Security'}))
|
|
|
|
await security.findByRole('option', {name: 'notes-users'})
|
|
await userEvent.selectOptions(security.getByRole('combobox', {name: 'User pool'}), 'us-east-1_pool')
|
|
await userEvent.selectOptions(security.getByRole('combobox', {name: 'App client'}), 'client1')
|
|
await userEvent.click(security.getByRole('button', {name: 'Add authorizer'}))
|
|
expect(api.createHttpApiAuthorizer).toHaveBeenCalledWith('aws', 'h1', {name: 'cognito', userPoolId: 'us-east-1_pool', userPoolClientId: 'client1'})
|
|
})
|
|
|
|
test('sends a request to the stage and shows the response', async () => {
|
|
renderPanel()
|
|
const tester = within(await screen.findByRole('region', {name: 'Send a request'}))
|
|
|
|
expect(tester.getByRole('textbox', {name: 'Request path'})).toHaveValue('/notes/1')
|
|
await userEvent.click(tester.getByRole('button', {name: 'Send request'}))
|
|
expect(api.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'h1', expect.objectContaining({stage: '$default', method: 'GET', path: '/notes/1'}))
|
|
expect(await tester.findByText('200')).toBeInTheDocument()
|
|
})
|
|
|
|
test('a WebSocket API shows its wss URL and sends a message, without HTTP-only security', async () => {
|
|
api.describeHttpApi.mockResolvedValue({
|
|
apiId: 'w1', protocol: 'WEBSOCKET', apiEndpoint: null, routeSelectionExpression: '$request.body.action',
|
|
routes: [{routeId: 'r1', routeKey: 'sendMessage', integration: null, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: true}],
|
|
stages: [{stageName: 'dev', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/ws/w1/dev'}],
|
|
authorizers: [], cors: null,
|
|
} satisfies HttpApiOverview)
|
|
api.testWebSocketApi.mockResolvedValue({connected: true, messages: ['{"echo":"hello"}'], closeCode: null, error: null, durationMs: 40})
|
|
renderPanel({...httpResource, id: 'w1', type: 'websocket-api', metadata: {apigatewayService: 'websocket', protocol: 'WEBSOCKET'}})
|
|
|
|
expect(await screen.findByText('ws://localhost:4566/ws/w1/dev')).toBeInTheDocument()
|
|
expect(screen.queryByRole('region', {name: 'Security'})).not.toBeInTheDocument()
|
|
const tester = within(screen.getByRole('region', {name: 'Send a message'}))
|
|
expect(tester.getByRole('textbox', {name: 'Message'})).toHaveValue(JSON.stringify({action: 'sendMessage', data: 'hello'}, null, 2))
|
|
await userEvent.click(tester.getByRole('button', {name: 'Connect and send'}))
|
|
expect(api.testWebSocketApi).toHaveBeenCalledWith('aws', 'w1', expect.objectContaining({stage: 'dev'}))
|
|
expect(await tester.findByText('Connected')).toBeInTheDocument()
|
|
})
|