1970 lines
87 KiB
TypeScript
1970 lines
87 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
|
import {Hono} from 'hono'
|
|
import {NotImplementedByRuntimeError, RuntimeError, RuntimeUnavailableError, ValidationError} from '../cloud-spi/errors'
|
|
import type {
|
|
ChildCollection,
|
|
ChildItem,
|
|
CollectionPage,
|
|
DocumentStoreAdapter,
|
|
ItemStoreAdapter,
|
|
} from '../cloud-spi/childCollections'
|
|
import {awsDatabaseSchema, azureDatabaseSchema} from '../cloud-spi/databaseSchema'
|
|
import {awsLogsSchema} from '../cloud-spi/logsSchema'
|
|
import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema'
|
|
import {awsDynamoDbSchema} from '../cloud-spi/dynamodbSchema'
|
|
import {awsEksSchema} from '../cloud-spi/eksSchema'
|
|
import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
|
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
|
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
|
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
|
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
|
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
|
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
|
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
|
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
|
import type {
|
|
AppConfigConfigurationProfile,
|
|
AppConfigDeployment,
|
|
AppConfigDeploymentStrategy,
|
|
AppConfigEnvironment,
|
|
AppConfigHostedConfigurationVersion,
|
|
CloudProvider,
|
|
CloudResource,
|
|
CloudServiceAdapter,
|
|
CosmosContainer,
|
|
CosmosItem,
|
|
CosmosQueryResult,
|
|
CreateDatabaseSnapshotInput,
|
|
CreateResourceInput,
|
|
DatabaseSnapshot,
|
|
LogsInsightsQueryResult,
|
|
} from '../cloud-spi/types'
|
|
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
|
|
import {CloudProxyService} from '../service/CloudProxyService'
|
|
import type {RuntimeProbe} from '../service/runtimeProbe'
|
|
import {createCloudRoutes} from './clouds'
|
|
|
|
function mockAdapter(cloud: CloudProvider, overrides: Partial<CloudServiceAdapter> = {}): CloudServiceAdapter {
|
|
return {
|
|
cloud,
|
|
service: 'storage',
|
|
schema: cloud === 'aws' ? awsStorageSchema : cloud === 'gcp' ? gcpStorageSchema : azureStorageSchema,
|
|
list: async () => [],
|
|
get: async () => null,
|
|
create: async (_input: CreateResourceInput): Promise<CloudResource> => ({
|
|
id: 'created',
|
|
name: 'created',
|
|
cloud,
|
|
service: 'storage',
|
|
type: cloud === 'azure' ? 'container' : 'bucket',
|
|
region: null,
|
|
createdAt: null,
|
|
metadata: {},
|
|
}),
|
|
delete: async () => {},
|
|
listObjects: async (resourceId: string, prefix = '') => ({
|
|
prefix,
|
|
objects: [{
|
|
key: `${resourceId}/object.txt`,
|
|
name: 'object.txt',
|
|
type: 'object',
|
|
size: 12,
|
|
lastModified: null,
|
|
metadata: {},
|
|
}],
|
|
}),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runtime probes make real HTTP calls to the emulator endpoints, so tests inject
|
|
* stubs — otherwise status assertions depend on whether a container happens to
|
|
* be listening, which passes locally and fails in CI.
|
|
*/
|
|
function stubProbes(overrides: Partial<Record<CloudProvider, RuntimeProbe>> = {}): Record<CloudProvider, RuntimeProbe> {
|
|
const reachable: RuntimeProbe = async () => {}
|
|
return {aws: reachable, azure: reachable, gcp: reachable, ...overrides}
|
|
}
|
|
|
|
function unreachable(message: string): RuntimeProbe {
|
|
return async () => {
|
|
throw new RuntimeUnavailableError(message)
|
|
}
|
|
}
|
|
|
|
function appWithRoutes(
|
|
adapters: CloudServiceAdapter[] = [mockAdapter('aws'), mockAdapter('azure')],
|
|
probes: Record<CloudProvider, RuntimeProbe> = stubProbes(),
|
|
) {
|
|
const app = new Hono()
|
|
const registry = new CloudAdapterRegistry(adapters)
|
|
app.route('/api/clouds', createCloudRoutes(new CloudProxyService(registry, probes)))
|
|
return app
|
|
}
|
|
|
|
describe('EventBridge rule routes', () => {
|
|
test('routes rule lifecycle requests through the Events adapter using the event bus resource id', async () => {
|
|
const calls: Array<{operation: string; args: unknown[]}> = []
|
|
const adapter = mockAdapter('aws', {
|
|
service: 'events',
|
|
schema: awsEventBridgeSchema,
|
|
listEventBridgeRules: async (...args) => {
|
|
calls.push({operation: 'list', args})
|
|
return []
|
|
},
|
|
createEventBridgeRule: async (...args) => {
|
|
calls.push({operation: 'create', args})
|
|
return {
|
|
name: 'hourly', busName: 'orders', busId: 'event-bus:orders', arn: null,
|
|
scheduleExpression: 'rate(1 hour)', state: 'ENABLED', targets: [],
|
|
}
|
|
},
|
|
deleteEventBridgeRule: async (...args) => {
|
|
calls.push({operation: 'delete', args})
|
|
},
|
|
})
|
|
const app = appWithRoutes([adapter])
|
|
|
|
const create = await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
name: 'hourly', scheduleExpression: 'rate(1 hour)',
|
|
targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}],
|
|
}),
|
|
})
|
|
expect(create.status).toBe(201)
|
|
await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules')
|
|
const remove = await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules/hourly', {method: 'DELETE'})
|
|
|
|
expect(remove.status).toBe(200)
|
|
expect(calls).toEqual([
|
|
{operation: 'create', args: ['event-bus:orders', {name: 'hourly', scheduleExpression: 'rate(1 hour)', eventPattern: undefined, state: undefined, description: undefined, targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]}]},
|
|
{operation: 'list', args: ['event-bus:orders']},
|
|
{operation: 'delete', args: ['event-bus:orders', 'hourly']},
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('API Gateway CORS routes', () => {
|
|
test('dispatches resource-scoped CORS configuration through the API Gateway adapter', async () => {
|
|
const calls: unknown[] = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'apigateway',
|
|
enableApiGatewayCors: async (apiId, input) => {
|
|
calls.push({apiId, input})
|
|
return {configuredMethods: ['GET'], proxyMethods: ['POST']}
|
|
},
|
|
})])
|
|
|
|
const response = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/cors', {
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST']}),
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(await response.json()).toEqual({configuredMethods: ['GET'], proxyMethods: ['POST']})
|
|
expect(calls).toEqual([{apiId: 'api-123', input: {
|
|
resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'],
|
|
}}])
|
|
})
|
|
})
|
|
|
|
describe('API Gateway method test routes', () => {
|
|
test('dispatches the AWS TestInvokeMethod input without requiring a stage', async () => {
|
|
const calls: unknown[] = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'apigateway',
|
|
testApiGatewayMethod: async (apiId, input) => {
|
|
calls.push({apiId, input})
|
|
return {statusCode: 200, body: '{}', headers: {}, multiValueHeaders: {}, log: 'ok', latency: 4}
|
|
},
|
|
})])
|
|
|
|
const response = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/methods/test', {
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
resourceId: 'resource-456', httpMethod: 'POST', pathWithQueryString: '/orders?dryRun=true', body: '{}',
|
|
headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, stageVariables: {environment: 'test'},
|
|
}),
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(await response.json()).toEqual({statusCode: 200, body: '{}', headers: {}, multiValueHeaders: {}, log: 'ok', latency: 4})
|
|
expect(calls).toEqual([{apiId: 'api-123', input: {
|
|
resourceId: 'resource-456', httpMethod: 'POST', pathWithQueryString: '/orders?dryRun=true', body: '{}',
|
|
headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, stageVariables: {environment: 'test'},
|
|
}}])
|
|
})
|
|
})
|
|
|
|
describe('API Gateway resource routes', () => {
|
|
test('dispatches parent-scoped nested route creation and returns the resource tree', async () => {
|
|
const calls: unknown[] = []
|
|
const tree = {
|
|
resources: [
|
|
{resourceId: 'root-123', parentId: null, path: '/', pathPart: null, methods: []},
|
|
{resourceId: 'orders-123', parentId: 'root-123', path: '/orders', pathPart: 'orders', methods: []},
|
|
],
|
|
routes: [],
|
|
stages: ['dev'],
|
|
}
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'apigateway',
|
|
createApiGatewayLambdaRoute: async (apiId, input) => {
|
|
calls.push({apiId, input})
|
|
return {resourceId: 'order-123', deploymentId: 'deployment-123', invokeUrl: '/orders/{id}'}
|
|
},
|
|
listApiGatewayRoutes: async () => tree,
|
|
})])
|
|
|
|
const create = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/routes', {
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
path: '/orders/{id}', parentResourceId: 'orders-123', pathPart: '{id}',
|
|
method: 'PATCH', stage: 'dev', functionName: 'orders-handler',
|
|
}),
|
|
})
|
|
const list = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/routes')
|
|
|
|
expect(create.status).toBe(201)
|
|
expect(await list.json()).toEqual(tree)
|
|
expect(calls).toEqual([{apiId: 'api-123', input: {
|
|
path: '/orders/{id}', parentResourceId: 'orders-123', pathPart: '{id}',
|
|
method: 'PATCH', stage: 'dev', functionName: 'orders-handler',
|
|
}}])
|
|
})
|
|
})
|
|
|
|
describe('S3 website routes', () => {
|
|
test('routes normalized website configuration lifecycle requests through the storage adapter', async () => {
|
|
const calls: Array<{operation: string; args: unknown[]}> = []
|
|
const adapter = mockAdapter('aws', {
|
|
getS3BucketWebsite: async (...args) => {
|
|
calls.push({operation: 'get', args})
|
|
return {mode: 'index', indexDocument: 'index.html', errorDocument: null}
|
|
},
|
|
putS3BucketWebsite: async (...args) => { calls.push({operation: 'put', args}) },
|
|
deleteS3BucketWebsite: async (...args) => { calls.push({operation: 'delete', args}) },
|
|
})
|
|
const app = appWithRoutes([adapter])
|
|
|
|
const get = await app.request('/api/clouds/aws/services/storage/resources/assets/website')
|
|
const put = await app.request('/api/clouds/aws/services/storage/resources/assets/website', {
|
|
method: 'PUT', headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}),
|
|
})
|
|
const remove = await app.request('/api/clouds/aws/services/storage/resources/assets/website', {method: 'DELETE'})
|
|
|
|
expect(await get.json()).toEqual({mode: 'index', indexDocument: 'index.html', errorDocument: null})
|
|
expect(put.status).toBe(200)
|
|
expect(remove.status).toBe(200)
|
|
expect(calls).toEqual([
|
|
{operation: 'get', args: ['assets']},
|
|
{operation: 'put', args: ['assets', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}]},
|
|
{operation: 'delete', args: ['assets']},
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('SNS subscription routes', () => {
|
|
test('routes subscription lifecycle requests through the Notifications adapter', async () => {
|
|
const calls: Array<{operation: string; args: unknown[]}> = []
|
|
const adapter = mockAdapter('aws', {
|
|
service: 'notifications',
|
|
schema: awsNotificationsSchema,
|
|
createSnsSubscription: async (...args) => {
|
|
calls.push({operation: 'create', args})
|
|
return {arn: 'subscription-1', protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders', status: 'Confirmed', filterPolicy: null}
|
|
},
|
|
listSnsSubscriptions: async (...args) => {
|
|
calls.push({operation: 'list', args})
|
|
return []
|
|
},
|
|
deleteSnsSubscription: async (...args) => {
|
|
calls.push({operation: 'delete', args})
|
|
},
|
|
})
|
|
const app = appWithRoutes([adapter])
|
|
|
|
const create = await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders'}),
|
|
})
|
|
expect(create.status).toBe(201)
|
|
await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions')
|
|
const remove = await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions/subscription-1', {method: 'DELETE'})
|
|
|
|
expect(remove.status).toBe(200)
|
|
expect(calls).toEqual([
|
|
{operation: 'create', args: ['orders', {protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders'}]},
|
|
{operation: 'list', args: ['orders']},
|
|
{operation: 'delete', args: ['subscription-1']},
|
|
])
|
|
})
|
|
})
|
|
|
|
const databaseSnapshot: DatabaseSnapshot = {
|
|
id: 'orders-db-snapshot-1',
|
|
name: 'orders-db-snapshot-1',
|
|
instanceIdentifier: 'orders-db',
|
|
status: 'available',
|
|
engine: 'postgres',
|
|
version: '16.4',
|
|
createdAt: '2026-08-14T09:30:00.000Z',
|
|
metadata: {snapshotType: 'manual'},
|
|
}
|
|
|
|
describe('database snapshot routes', () => {
|
|
test('lists snapshots with the exact optional instance filter', async () => {
|
|
let delegatedIdentifier: string | undefined
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
listDatabaseSnapshots: async (instanceIdentifier) => {
|
|
delegatedIdentifier = instanceIdentifier
|
|
return [databaseSnapshot]
|
|
},
|
|
})])
|
|
|
|
const res = await app.request(
|
|
'/api/clouds/aws/services/database/snapshots?instanceIdentifier=orders-db',
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual([databaseSnapshot])
|
|
expect(delegatedIdentifier).toBe('orders-db')
|
|
})
|
|
|
|
test('creates a snapshot with the raw two-field input', async () => {
|
|
let delegatedInput: CreateDatabaseSnapshotInput | undefined
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
createDatabaseSnapshot: async (input) => {
|
|
delegatedInput = input
|
|
return databaseSnapshot
|
|
},
|
|
})])
|
|
const input = {
|
|
instanceIdentifier: 'orders-db',
|
|
snapshotIdentifier: 'orders-db-snapshot-1',
|
|
}
|
|
|
|
const res = await app.request('/api/clouds/aws/services/database/snapshots', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify(input),
|
|
})
|
|
|
|
expect(res.status).toBe(201)
|
|
expect(await res.json()).toEqual(databaseSnapshot)
|
|
expect(delegatedInput).toEqual(input)
|
|
})
|
|
|
|
test('returns 501 when the database adapter lacks either snapshot method', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
})])
|
|
|
|
const listRes = await app.request('/api/clouds/aws/services/database/snapshots')
|
|
const createRes = await app.request('/api/clouds/aws/services/database/snapshots', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
instanceIdentifier: 'orders-db',
|
|
snapshotIdentifier: 'orders-db-snapshot-1',
|
|
}),
|
|
})
|
|
|
|
expect(listRes.status).toBe(501)
|
|
expect((await listRes.json()).code).toBe('operation_not_supported')
|
|
expect(createRes.status).toBe(501)
|
|
expect((await createRes.json()).code).toBe('operation_not_supported')
|
|
})
|
|
|
|
test('maps adapter snapshot validation errors to 400', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
createDatabaseSnapshot: async () => {
|
|
throw new ValidationError('snapshotIdentifier is required')
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/database/snapshots', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({instanceIdentifier: 'orders-db', snapshotIdentifier: ''}),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
const body = await res.json()
|
|
|
|
expect(body).toMatchObject({
|
|
code: 'invalid_request',
|
|
message: 'snapshotIdentifier is required',
|
|
})
|
|
})
|
|
|
|
test('maps AWS UnsupportedOperation snapshot errors through the shared mapper', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
listDatabaseSnapshots: async () => {
|
|
throw Object.assign(new Error('RDS snapshots are unavailable'), {
|
|
name: 'UnsupportedOperation',
|
|
$fault: 'client',
|
|
$metadata: {httpStatusCode: 500},
|
|
})
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/database/snapshots')
|
|
expect(res.status).toBe(501)
|
|
const body = await res.json()
|
|
|
|
expect(body.code).toBe('operation_not_implemented')
|
|
expect(body.detail).toBe('RDS snapshots are unavailable')
|
|
})
|
|
|
|
test('lists orderable instance classes with engine filter', async () => {
|
|
let delegatedEngine: string | undefined
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
listDatabaseOrderableInstanceClasses: async (engine) => {
|
|
delegatedEngine = engine
|
|
return ['db.t3.micro', 'db.m8g.large']
|
|
},
|
|
})])
|
|
|
|
const res = await app.request(
|
|
'/api/clouds/aws/services/database/orderable-classes?engine=postgres',
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual(['db.t3.micro', 'db.m8g.large'])
|
|
expect(delegatedEngine).toBe('postgres')
|
|
})
|
|
})
|
|
|
|
describe('resource lifecycle routes', () => {
|
|
test('routes start, stop, and reboot through the generic adapter lifecycle operations', async () => {
|
|
const calls: string[] = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
start: async (id) => { calls.push(`start:${id}`) },
|
|
stop: async (id) => { calls.push(`stop:${id}`) },
|
|
reboot: async (id) => { calls.push(`reboot:${id}`) },
|
|
})])
|
|
|
|
for (const action of ['start', 'stop', 'reboot']) {
|
|
const response = await app.request(`/api/clouds/aws/services/database/resources/orders-db/${action}`, {method: 'POST'})
|
|
expect(response.status).toBe(200)
|
|
expect(await response.json()).toEqual({ok: true})
|
|
}
|
|
|
|
expect(calls).toEqual(['start:orders-db', 'stop:orders-db', 'reboot:orders-db'])
|
|
})
|
|
})
|
|
|
|
describe('cloud schema routes', () => {
|
|
test('returns AWS storage schema', async () => {
|
|
const res = await appWithRoutes().request('/api/clouds/aws/services/storage/schema')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.cloud).toBe('aws')
|
|
expect(body.service).toBe('storage')
|
|
expect(body.fields[0].name).toBe('bucketName')
|
|
})
|
|
|
|
test('returns Azure storage schema', async () => {
|
|
const res = await appWithRoutes().request('/api/clouds/azure/services/storage/schema')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.cloud).toBe('azure')
|
|
expect(body.service).toBe('storage')
|
|
expect(body.fields[0].name).toBe('containerName')
|
|
})
|
|
|
|
test('returns Azure database schema when the adapter is registered', async () => {
|
|
const app = appWithRoutes([mockAdapter('azure', {
|
|
service: 'database',
|
|
schema: azureDatabaseSchema,
|
|
})])
|
|
const res = await app.request('/api/clouds/azure/services/database/schema')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.cloud).toBe('azure')
|
|
expect(body.service).toBe('database')
|
|
// Cosmos moved to the nosql category; database now covers Azure SQL and PostgreSQL.
|
|
expect(body.displayName).toBe('Azure Databases')
|
|
})
|
|
|
|
test('returns Azure Cosmos NoSQL schema when the adapter is registered', async () => {
|
|
const app = appWithRoutes([mockAdapter('azure', {
|
|
service: 'nosql',
|
|
schema: azureNoSqlSchema,
|
|
})])
|
|
const res = await app.request('/api/clouds/azure/services/nosql/schema')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.service).toBe('nosql')
|
|
expect(body.displayName).toBe('Azure Cosmos DB NoSQL')
|
|
})
|
|
|
|
test('returns AWS DynamoDB schema for the nosql service', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'nosql',
|
|
schema: awsDynamoDbSchema,
|
|
})])
|
|
const res = await app.request('/api/clouds/aws/services/nosql/schema')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.cloud).toBe('aws')
|
|
expect(body.service).toBe('nosql')
|
|
expect(body.displayName).toBe('DynamoDB')
|
|
})
|
|
|
|
test('returns GCP storage schema when the adapter is registered', async () => {
|
|
const app = appWithRoutes([mockAdapter('gcp', {service: 'storage', schema: gcpStorageSchema})])
|
|
const res = await app.request('/api/clouds/gcp/services/storage/schema')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.cloud).toBe('gcp')
|
|
expect(body.service).toBe('storage')
|
|
expect(body.fields[0].name).toBe('bucketName')
|
|
})
|
|
|
|
// Previously a static schema was served for any known service even with no
|
|
// adapter behind it, so the UI rendered a table that then 501'd on every call.
|
|
test('does not serve a schema for a service with no registered adapter', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws')])
|
|
|
|
for (const path of [
|
|
'/api/clouds/azure/services/k8s/schema',
|
|
'/api/clouds/gcp/services/k8s/schema',
|
|
'/api/clouds/gcp/services/database/schema',
|
|
'/api/clouds/azure/services/compute/schema',
|
|
]) {
|
|
const res = await app.request(path)
|
|
expect(res.status).toBe(404)
|
|
expect((await res.json()).error).toBe('Schema not available')
|
|
}
|
|
})
|
|
|
|
test('rejects a service slug that is not in the catalog', async () => {
|
|
const res = await appWithRoutes().request('/api/clouds/aws/services/queue/schema')
|
|
|
|
expect(res.status).toBe(404)
|
|
expect((await res.json()).error).toBe('Unknown cloud or service')
|
|
})
|
|
|
|
test('returns AWS cloud status', async () => {
|
|
const res = await appWithRoutes().request('/api/clouds/aws/status')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.cloud).toBe('aws')
|
|
expect(body.adapterRegistered).toBe(true)
|
|
expect(body.runtime).toBe('reachable')
|
|
})
|
|
|
|
test('returns GCP runtime status without a registered adapter', async () => {
|
|
const app = appWithRoutes(
|
|
[mockAdapter('aws'), mockAdapter('azure')],
|
|
stubProbes({gcp: unreachable('Cannot reach Floci-GCP at http://localhost:4588')}),
|
|
)
|
|
const res = await app.request('/api/clouds/gcp/status')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.cloud).toBe('gcp')
|
|
expect(body.adapterRegistered).toBe(false)
|
|
expect(body.runtime).toBe('unavailable')
|
|
expect(body.endpoint).toBe('http://localhost:4588')
|
|
expect(body.error).toContain('Cannot reach Floci-GCP')
|
|
})
|
|
|
|
test('cloud status reflects the runtime probe, not one adapter listing', async () => {
|
|
// Previously a cloud whose storage adapter could list reported "reachable"
|
|
// no matter what state the runtime was actually in.
|
|
const app = appWithRoutes(
|
|
[mockAdapter('aws')],
|
|
stubProbes({aws: unreachable('Cannot reach Floci core at http://localhost:4566')}),
|
|
)
|
|
const body = await (await app.request('/api/clouds/aws/status')).json()
|
|
|
|
expect(body.runtime).toBe('unavailable')
|
|
expect(body.adapterRegistered).toBe(true)
|
|
})
|
|
|
|
test('lists storage objects through the cloud adapter', async () => {
|
|
const res = await appWithRoutes().request('/api/clouds/aws/services/storage/resources/demo/objects')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.objects).toHaveLength(1)
|
|
expect(body.objects[0].name).toBe('object.txt')
|
|
})
|
|
|
|
test('lists Cosmos containers through the cloud NoSQL adapter', async () => {
|
|
const app = appWithRoutes([mockAdapter('azure', {
|
|
service: 'nosql',
|
|
schema: azureNoSqlSchema,
|
|
listCosmosContainers: async (databaseId: string): Promise<CosmosContainer[]> => [{
|
|
id: 'items',
|
|
name: 'items',
|
|
databaseId,
|
|
partitionKeyPath: '/id',
|
|
createdAt: null,
|
|
metadata: {},
|
|
}],
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body[0].databaseId).toBe('appdb')
|
|
expect(body[0].name).toBe('items')
|
|
})
|
|
|
|
test('lists DynamoDB records through the generic item adapter', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'nosql',
|
|
schema: awsDynamoDbSchema,
|
|
items: {
|
|
listItems: async (resourceId) => ({items: [{
|
|
id: '{"pk":"item-1"}', collectionId: null, timestamp: null,
|
|
body: {pk: 'item-1', name: 'First item', table: resourceId}, metadata: {key: {pk: 'item-1'}},
|
|
}], nextCursor: null}),
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/nosql/resources/orders/items')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.items[0].metadata.key).toEqual({pk: 'item-1'})
|
|
expect(body.items[0].body.table).toBe('orders')
|
|
})
|
|
|
|
test('puts and deletes DynamoDB records through the generic item adapter with the complete key', async () => {
|
|
const calls: Array<{resourceId: string; document: Record<string, unknown>}> = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'nosql',
|
|
schema: awsDynamoDbSchema,
|
|
items: {
|
|
listItems: async () => ({items: [], nextCursor: null}),
|
|
putItem: async (resourceId, document) => {
|
|
calls.push({resourceId, document})
|
|
return {id: JSON.stringify({pk: document.pk, sk: document.sk}), collectionId: null, timestamp: null, body: document, metadata: {key: {pk: document.pk, sk: document.sk}}}
|
|
},
|
|
deleteItem: async (resourceId, itemId) => {
|
|
calls.push({resourceId, document: {itemId}})
|
|
},
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/nosql/resources/orders/items', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({pk: 'item-1', sk: '2026-09-29', name: 'First item'}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(201)
|
|
expect(body.metadata.key).toEqual({pk: 'item-1', sk: '2026-09-29'})
|
|
expect(calls).toEqual([{resourceId: 'orders', document: {pk: 'item-1', sk: '2026-09-29', name: 'First item'}}])
|
|
|
|
const deleteRes = await app.request('/api/clouds/aws/services/nosql/resources/orders/items/%7B%22pk%22%3A%22item-1%22%2C%22sk%22%3A%222026-09-29%22%7D', {method: 'DELETE'})
|
|
|
|
expect(deleteRes.status).toBe(200)
|
|
expect(calls).toEqual([
|
|
{resourceId: 'orders', document: {pk: 'item-1', sk: '2026-09-29', name: 'First item'}},
|
|
{resourceId: 'orders', document: {itemId: '{"pk":"item-1","sk":"2026-09-29"}'}},
|
|
])
|
|
})
|
|
|
|
test('reads one flat item, answering 404 when it is gone', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'userpools',
|
|
schema: awsUserPoolsSchema,
|
|
items: {
|
|
listItems: async () => ({items: [], nextCursor: null}),
|
|
getItem: async (_poolId, username) => username === 'bob@example.com'
|
|
? {id: 'u-1', collectionId: null, timestamp: null, body: {email: username, groups: ['admins']}, metadata: {}}
|
|
: null,
|
|
},
|
|
})])
|
|
|
|
const found = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/bob%40example.com')
|
|
const missing = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/nobody')
|
|
|
|
expect(found.status).toBe(200)
|
|
expect((await found.json()).body.groups).toEqual(['admins'])
|
|
expect(missing.status).toBe(404)
|
|
})
|
|
|
|
test('routes Cognito user actions, groups and test sign-in to the adapter', async () => {
|
|
const calls: unknown[] = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'userpools',
|
|
schema: awsUserPoolsSchema,
|
|
updateUserPoolUser: async (poolId, username, input) => { calls.push(['action', poolId, username, input]) },
|
|
setUserPoolUserGroups: async (poolId, username, groups) => { calls.push(['groups', poolId, username, groups]) },
|
|
listUserPoolGroups: async () => [{name: 'admins', description: null, precedence: 0, createdAt: null}],
|
|
createUserPoolGroup: async (_poolId, input) => ({name: input.name, description: null, precedence: null, createdAt: null}),
|
|
deleteUserPoolGroup: async (poolId, groupName) => { calls.push(['deleteGroup', poolId, groupName]) },
|
|
testUserPoolSignIn: async () => ({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}),
|
|
})])
|
|
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
|
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x'
|
|
|
|
expect((await app.request(`${base}/users/bob%40example.com/actions`, json('POST', {action: 'disable'}))).status).toBe(200)
|
|
expect((await app.request(`${base}/users/bob%40example.com/groups`, json('PUT', {groups: ['admins']}))).status).toBe(200)
|
|
expect(await (await app.request(`${base}/groups`)).json()).toEqual([{name: 'admins', description: null, precedence: 0, createdAt: null}])
|
|
expect((await app.request(`${base}/groups`, json('POST', {name: 'editors'}))).status).toBe(201)
|
|
expect((await app.request(`${base}/groups/editors`, {method: 'DELETE'})).status).toBe(200)
|
|
|
|
const signIn = await app.request(`${base}/sign-in-test`, json('POST', {clientId: 'c', username: 'bob', password: 'x'}))
|
|
expect(signIn.status).toBe(200)
|
|
expect((await signIn.json()).outcome).toBe('rejected')
|
|
|
|
expect(calls).toEqual([
|
|
['action', 'us-east-1_x', 'bob@example.com', {action: 'disable'}],
|
|
['groups', 'us-east-1_x', 'bob@example.com', ['admins']],
|
|
['deleteGroup', 'us-east-1_x', 'editors'],
|
|
])
|
|
})
|
|
|
|
test('reads and updates an app client\'s OAuth settings', async () => {
|
|
const settings = {clientId: 'c1', clientName: 'web', hasSecret: false, explicitAuthFlows: [], callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
|
|
const calls: unknown[] = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'userpools',
|
|
schema: awsUserPoolsSchema,
|
|
describeUserPoolClient: async () => settings,
|
|
updateUserPoolClientOAuth: async (poolId, clientId, input) => {
|
|
calls.push([poolId, clientId, input])
|
|
return {...settings, ...input}
|
|
},
|
|
})])
|
|
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x/clients/c1'
|
|
const input = {callbackUrls: ['http://localhost:4600/cb'], logoutUrls: [], oauthFlows: ['code'], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
|
|
|
|
expect(await (await app.request(base)).json()).toEqual(settings)
|
|
const updated = await app.request(`${base}/oauth`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify(input)})
|
|
expect(updated.status).toBe(200)
|
|
expect((await updated.json()).oauthFlows).toEqual(['code'])
|
|
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
|
|
})
|
|
|
|
test('routes CloudWatch metrics, history, alarm state and actions to the adapter', async () => {
|
|
const calls: unknown[] = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'monitoring',
|
|
schema: awsMonitoringSchema,
|
|
listMetrics: async (query) => { calls.push(['metrics', query]); return [{namespace: 'App', metricName: 'Errors', dimensions: []}] },
|
|
getMetricSeries: async (query) => { calls.push(['series', query.metricName, query.rangeMinutes]); return {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: []} },
|
|
describeAlarmHistory: async (name) => [{timestamp: null, type: 'StateUpdate', summary: `${name} changed`}],
|
|
setAlarmState: async (name, state, reason) => { calls.push(['state', name, state, reason]) },
|
|
setAlarmActionsEnabled: async (name, enabled) => { calls.push(['actions', name, enabled]) },
|
|
})])
|
|
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
|
const base = '/api/clouds/aws/services/monitoring'
|
|
|
|
expect(await (await app.request(`${base}/metrics?namespace=App`)).json()).toEqual([{namespace: 'App', metricName: 'Errors', dimensions: []}])
|
|
expect((await (await app.request(`${base}/metric-data`, json('POST', {namespace: 'App', metricName: 'Errors', rangeMinutes: 60}))).json()).label).toBe('Errors')
|
|
expect((await (await app.request(`${base}/resources/high-errors/history`)).json())[0].summary).toBe('high-errors changed')
|
|
expect((await app.request(`${base}/resources/high-errors/state`, json('PUT', {state: 'ALARM', reason: 'test'}))).status).toBe(200)
|
|
expect((await app.request(`${base}/resources/high-errors/actions`, json('PUT', {enabled: false}))).status).toBe(200)
|
|
expect(calls).toEqual([
|
|
['metrics', {namespace: 'App', search: undefined}],
|
|
['series', 'Errors', 60],
|
|
['state', 'high-errors', 'ALARM', 'test'],
|
|
['actions', 'high-errors', false],
|
|
])
|
|
})
|
|
|
|
test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
|
|
const calls: unknown[] = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'cdn',
|
|
schema: awsCdnSchema,
|
|
setDistributionEnabled: async (id, enabled) => {
|
|
calls.push(['enabled', id, enabled])
|
|
return {id, name: 'd.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution', region: null, createdAt: null, metadata: {enabled}}
|
|
},
|
|
createDistributionInvalidation: async (id, paths) => {
|
|
calls.push(['invalidate', id, paths])
|
|
return {id: 'I1', status: 'Completed', createdAt: null, paths}
|
|
},
|
|
})])
|
|
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
|
const base = '/api/clouds/aws/services/cdn/resources/E123'
|
|
|
|
const enabled = await app.request(`${base}/enabled`, json('PUT', {enabled: false}))
|
|
expect(enabled.status).toBe(200)
|
|
expect((await enabled.json()).metadata.enabled).toBe(false)
|
|
const invalidated = await app.request(`${base}/invalidations`, json('POST', {paths: ['/*']}))
|
|
expect(invalidated.status).toBe(201)
|
|
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
|
})
|
|
|
|
test('clears the SES mailbox through the email adapter', async () => {
|
|
let cleared = false
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'email',
|
|
schema: awsSesEmailSchema,
|
|
clearEmailInbox: async () => {
|
|
cleared = true
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/email/inbox', {method: 'DELETE'})
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual({ok: true})
|
|
expect(cleared).toBeTrue()
|
|
})
|
|
|
|
test('creates and deletes Cosmos databases through the cloud NoSQL adapter', async () => {
|
|
const deleted: string[] = []
|
|
const app = appWithRoutes([mockAdapter('azure', {
|
|
service: 'nosql',
|
|
schema: azureNoSqlSchema,
|
|
create: async (input: CreateResourceInput): Promise<CloudResource> => ({
|
|
id: String(input.values.databaseName),
|
|
name: String(input.values.databaseName),
|
|
cloud: 'azure',
|
|
service: 'nosql',
|
|
type: 'cosmos-database',
|
|
region: null,
|
|
createdAt: null,
|
|
metadata: {},
|
|
}),
|
|
delete: async (id: string) => {
|
|
deleted.push(id)
|
|
},
|
|
})])
|
|
|
|
const createRes = await app.request('/api/clouds/azure/services/nosql/resources', {
|
|
method: 'POST',
|
|
body: JSON.stringify({databaseName: 'appdb'}),
|
|
})
|
|
const created = await createRes.json()
|
|
const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb', {method: 'DELETE'})
|
|
|
|
expect(createRes.status).toBe(201)
|
|
expect(created.type).toBe('cosmos-database')
|
|
expect(created.name).toBe('appdb')
|
|
expect(deleteRes.status).toBe(200)
|
|
expect(deleted).toEqual(['appdb'])
|
|
})
|
|
|
|
test('creates and deletes Cosmos containers through the cloud NoSQL adapter', async () => {
|
|
const deleted: Array<{databaseId: string; containerId: string}> = []
|
|
const app = appWithRoutes([mockAdapter('azure', {
|
|
service: 'nosql',
|
|
schema: azureNoSqlSchema,
|
|
createCosmosContainer: async (databaseId: string, input: CreateResourceInput): Promise<CosmosContainer> => ({
|
|
id: String(input.values.containerName),
|
|
name: String(input.values.containerName),
|
|
databaseId,
|
|
partitionKeyPath: String(input.values.partitionKeyPath),
|
|
createdAt: null,
|
|
metadata: {},
|
|
}),
|
|
deleteCosmosContainer: async (databaseId: string, containerId: string) => {
|
|
deleted.push({databaseId, containerId})
|
|
},
|
|
})])
|
|
|
|
const createRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers', {
|
|
method: 'POST',
|
|
body: JSON.stringify({containerName: 'items', partitionKeyPath: '/category'}),
|
|
})
|
|
const created = await createRes.json()
|
|
const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items', {method: 'DELETE'})
|
|
|
|
expect(createRes.status).toBe(201)
|
|
expect(created.databaseId).toBe('appdb')
|
|
expect(created.partitionKeyPath).toBe('/category')
|
|
expect(deleteRes.status).toBe(200)
|
|
expect(deleted).toEqual([{databaseId: 'appdb', containerId: 'items'}])
|
|
})
|
|
|
|
test('upserts, deletes, and queries Cosmos items through the cloud NoSQL adapter', async () => {
|
|
const deleted: Array<{databaseId: string; containerId: string; itemId: string; partitionKey?: string | null}> = []
|
|
const app = appWithRoutes([mockAdapter('azure', {
|
|
service: 'nosql',
|
|
schema: azureNoSqlSchema,
|
|
upsertCosmosItem: async (databaseId: string, containerId: string, document: Record<string, unknown>): Promise<CosmosItem> => ({
|
|
id: String(document.id),
|
|
databaseId,
|
|
containerId,
|
|
partitionKey: String(document.category),
|
|
etag: 'etag',
|
|
timestamp: null,
|
|
document,
|
|
}),
|
|
deleteCosmosItem: async (databaseId: string, containerId: string, itemId: string, partitionKey?: string | null) => {
|
|
deleted.push({databaseId, containerId, itemId, partitionKey})
|
|
},
|
|
queryCosmosItems: async (_databaseId: string, _containerId: string, query: string): Promise<CosmosQueryResult> => ({
|
|
items: [{id: 'item-1', query}],
|
|
count: 1,
|
|
}),
|
|
})])
|
|
|
|
const upsertRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/items', {
|
|
method: 'POST',
|
|
body: JSON.stringify({id: 'item-1', category: 'demo'}),
|
|
})
|
|
const upserted = await upsertRes.json()
|
|
const queryRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/query', {
|
|
method: 'POST',
|
|
body: JSON.stringify({query: 'SELECT * FROM c'}),
|
|
})
|
|
const queryBody = await queryRes.json()
|
|
const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/items/item-1?partitionKey=demo', {method: 'DELETE'})
|
|
|
|
expect(upsertRes.status).toBe(201)
|
|
expect(upserted.partitionKey).toBe('demo')
|
|
expect(queryRes.status).toBe(200)
|
|
expect(queryBody.count).toBe(1)
|
|
expect(deleteRes.status).toBe(200)
|
|
expect(deleted).toEqual([{databaseId: 'appdb', containerId: 'items', itemId: 'item-1', partitionKey: 'demo'}])
|
|
})
|
|
|
|
test('runs a Logs Insights query through the route', async () => {
|
|
let received: {logGroupName?: string; queryString?: string; startTime?: number; endTime?: number; limit?: number} = {}
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'logs',
|
|
schema: awsLogsSchema,
|
|
queryLogs: async (logGroupName, input): Promise<LogsInsightsQueryResult> => {
|
|
received = {logGroupName, ...input}
|
|
return {queryId: 'q-1', status: 'Complete', rows: [{'@message': 'hello'}]}
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/query', {
|
|
method: 'POST',
|
|
body: JSON.stringify({queryString: 'fields @message', startTime: 1000, endTime: 2000}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body).toEqual({queryId: 'q-1', status: 'Complete', rows: [{'@message': 'hello'}]})
|
|
expect(received).toEqual({logGroupName: '/floci/probe', queryString: 'fields @message', startTime: 1000, endTime: 2000, limit: undefined})
|
|
})
|
|
|
|
test('rejects a Logs Insights query with a non-string queryString before calling the adapter', async () => {
|
|
let called = false
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'logs',
|
|
schema: awsLogsSchema,
|
|
queryLogs: async (): Promise<LogsInsightsQueryResult> => {
|
|
called = true
|
|
return {queryId: 'q-1', status: 'Complete', rows: []}
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/query', {
|
|
method: 'POST',
|
|
body: JSON.stringify({queryString: 123, startTime: 1000, endTime: 2000}),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(called).toBe(false)
|
|
})
|
|
|
|
test('rejects a Logs Insights query with a missing startTime before calling the adapter', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'logs',
|
|
schema: awsLogsSchema,
|
|
queryLogs: async (): Promise<LogsInsightsQueryResult> => ({queryId: 'q-1', status: 'Complete', rows: []}),
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/query', {
|
|
method: 'POST',
|
|
body: JSON.stringify({queryString: 'fields @message', endTime: 2000}),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
test('filters log events with pagination through the route', async () => {
|
|
let received: Record<string, unknown> = {}
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'logs',
|
|
schema: awsLogsSchema,
|
|
filterLogEvents: async (logGroupName, input, page) => {
|
|
received = {logGroupName, input, page}
|
|
return {items: [{id: 'event-1', collectionId: 'stream-a', timestamp: null, body: {message: 'ERROR'}, metadata: {}}], nextCursor: 'next'}
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/filter?cursor=previous&limit=25', {
|
|
method: 'POST',
|
|
body: JSON.stringify({filterPattern: 'ERROR', logStreamNames: ['stream-a'], startTime: 1000, endTime: 2000}),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toMatchObject({nextCursor: 'next'})
|
|
expect(received).toEqual({
|
|
logGroupName: '/floci/probe',
|
|
input: {filterPattern: 'ERROR', logStreamNames: ['stream-a'], startTime: 1000, endTime: 2000},
|
|
page: {cursor: 'previous', limit: 25},
|
|
})
|
|
})
|
|
|
|
test('rejects invalid log event filters before calling the adapter', async () => {
|
|
let called = false
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'logs',
|
|
schema: awsLogsSchema,
|
|
filterLogEvents: async () => {
|
|
called = true
|
|
return {items: [], nextCursor: null}
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/filter', {
|
|
method: 'POST',
|
|
body: JSON.stringify({logStreamNames: 'stream-a'}),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(called).toBe(false)
|
|
})
|
|
|
|
test('normalizes runtime unavailable errors', async () => {
|
|
const app = appWithRoutes([
|
|
mockAdapter('aws', {
|
|
list: async () => {
|
|
throw new RuntimeUnavailableError('Cannot reach Floci-AZ at http://localhost:4577: connection refused')
|
|
},
|
|
}),
|
|
])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(503)
|
|
expect(body.code).toBe('runtime_unavailable')
|
|
expect(body.message).toBe('Runtime unavailable')
|
|
expect(body.detail).toContain('connection refused')
|
|
})
|
|
|
|
test('normalizes not implemented runtime errors', async () => {
|
|
const app = appWithRoutes([
|
|
mockAdapter('azure', {
|
|
create: async () => {
|
|
throw new NotImplementedByRuntimeError('Azure Blob request failed: HTTP 501')
|
|
},
|
|
}),
|
|
])
|
|
const res = await app.request('/api/clouds/azure/services/storage/resources', {
|
|
method: 'POST',
|
|
body: JSON.stringify({containerName: 'demo'}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(501)
|
|
expect(body.code).toBe('operation_not_implemented')
|
|
expect(body.message).toBe('Operation is not implemented by the selected runtime')
|
|
})
|
|
|
|
test('reports a missing adapter as unsupported rather than a runtime failure', async () => {
|
|
const res = await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/azure/services/storage/resources')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(501)
|
|
expect(body.code).toBe('operation_not_supported')
|
|
expect(body.detail).toContain('No adapter registered for azure/storage')
|
|
})
|
|
|
|
test('maps a validation error to 400 with the adapter message intact', async () => {
|
|
const app = appWithRoutes([
|
|
mockAdapter('aws', {
|
|
create: async () => {
|
|
throw new ValidationError('bucketName is required')
|
|
},
|
|
}),
|
|
])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources', {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(body.code).toBe('invalid_request')
|
|
expect(body.message).toBe('bucketName is required')
|
|
})
|
|
|
|
// Before typed errors these AWS SDK failures all collapsed into a blanket 502.
|
|
const sdkCases: Array<{name: string; status: number; code: string}> = [
|
|
{name: 'BucketAlreadyOwnedByYou', status: 409, code: 'resource_conflict'},
|
|
{name: 'AccessDenied', status: 403, code: 'access_denied'},
|
|
{name: 'ValidationException', status: 400, code: 'invalid_request'},
|
|
{name: 'ThrottlingException', status: 429, code: 'rate_limited'},
|
|
{name: 'NoSuchBucket', status: 404, code: 'resource_not_found'},
|
|
]
|
|
|
|
for (const sdkCase of sdkCases) {
|
|
test(`maps the AWS SDK ${sdkCase.name} error to ${sdkCase.status}`, async () => {
|
|
const app = appWithRoutes([
|
|
mockAdapter('aws', {
|
|
create: async () => {
|
|
const err = new Error(`${sdkCase.name} raised by the runtime`)
|
|
err.name = sdkCase.name
|
|
Object.assign(err, {$metadata: {httpStatusCode: 500}, $fault: 'client'})
|
|
throw err
|
|
},
|
|
}),
|
|
])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources', {
|
|
method: 'POST',
|
|
body: JSON.stringify({name: 'demo'}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(sdkCase.status)
|
|
expect(body.code).toBe(sdkCase.code)
|
|
expect(body.detail ?? body.message).toContain(sdkCase.name)
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('KMS crypto routes', () => {
|
|
test('decodes plaintext and encodes the ciphertext response', async () => {
|
|
let delegatedId = ''
|
|
let delegatedPlaintext: Uint8Array | undefined
|
|
let delegatedContext: Record<string, string> | undefined
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'kms',
|
|
schema: awsKmsSchema,
|
|
encrypt: async (id, input) => {
|
|
delegatedId = id
|
|
delegatedPlaintext = input.plaintext
|
|
delegatedContext = input.encryptionContext
|
|
return {
|
|
ciphertextBlob: new Uint8Array([1, 2, 3]),
|
|
keyId: id,
|
|
encryptionAlgorithm: input.encryptionAlgorithm,
|
|
}
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/encrypt', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
plaintextBase64: Buffer.from('hello').toString('base64'),
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
encryptionContext: {purpose: 'test'},
|
|
}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(delegatedId).toBe('key-1')
|
|
expect(new TextDecoder().decode(delegatedPlaintext)).toBe('hello')
|
|
expect(delegatedContext).toEqual({purpose: 'test'})
|
|
expect(body).toEqual({
|
|
ciphertextBlobBase64: 'AQID',
|
|
keyId: 'key-1',
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
})
|
|
expect(res.headers.get('cache-control')).toBe('no-store')
|
|
})
|
|
|
|
test('decodes ciphertext and encodes the plaintext response', async () => {
|
|
let delegatedCiphertext: Uint8Array | undefined
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'kms',
|
|
schema: awsKmsSchema,
|
|
decrypt: async (id, input) => {
|
|
delegatedCiphertext = input.ciphertextBlob
|
|
return {
|
|
plaintext: new TextEncoder().encode('round trip'),
|
|
keyId: id,
|
|
encryptionAlgorithm: input.encryptionAlgorithm,
|
|
}
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/decrypt', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
ciphertextBlobBase64: 'AQID',
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(delegatedCiphertext).toEqual(new Uint8Array([1, 2, 3]))
|
|
expect(Buffer.from(body.plaintextBase64, 'base64').toString('utf8')).toBe('round trip')
|
|
expect(res.headers.get('cache-control')).toBe('no-store')
|
|
})
|
|
|
|
test('rejects non-canonical base64 before calling the adapter', async () => {
|
|
let calls = 0
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'kms',
|
|
schema: awsKmsSchema,
|
|
encrypt: async () => {
|
|
calls += 1
|
|
throw new Error('must not be called')
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/encrypt', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
plaintextBase64: 'not base64',
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
}),
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(body.code).toBe('invalid_request')
|
|
expect(body.message).toContain('canonical base64')
|
|
expect(calls).toBe(0)
|
|
expect(res.headers.get('cache-control')).toBe('no-store')
|
|
})
|
|
|
|
test('rejects malformed JSON as a caller error', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {service: 'kms', schema: awsKmsSchema})])
|
|
const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/decrypt', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: '{',
|
|
})
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(body.message).toBe('Request body must be valid JSON')
|
|
expect(res.headers.get('cache-control')).toBe('no-store')
|
|
})
|
|
|
|
test('maps provider contract failures to 502 without echoing request data', async () => {
|
|
const secretMarker = 'plaintext-that-must-not-be-returned'
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'kms',
|
|
schema: awsKmsSchema,
|
|
encrypt: async () => {
|
|
throw new RuntimeError('KMS did not return ciphertext')
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/encrypt', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
plaintextBase64: Buffer.from(secretMarker).toString('base64'),
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
}),
|
|
})
|
|
const responseText = await res.text()
|
|
|
|
expect(res.status).toBe(502)
|
|
expect(responseText).not.toContain(secretMarker)
|
|
expect(responseText).not.toContain(Buffer.from(secretMarker).toString('base64'))
|
|
expect(res.headers.get('cache-control')).toBe('no-store')
|
|
})
|
|
|
|
test('sanitizes provider validation text for crypto requests', async () => {
|
|
const secretMarker = 'provider-echoed-sensitive-value'
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'kms',
|
|
schema: awsKmsSchema,
|
|
encrypt: async () => {
|
|
throw Object.assign(new Error(secretMarker), {
|
|
name: 'ValidationException',
|
|
$fault: 'client',
|
|
$metadata: {httpStatusCode: 400},
|
|
})
|
|
},
|
|
})])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/encrypt', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({
|
|
plaintextBase64: 'AQ==',
|
|
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
}),
|
|
})
|
|
const responseText = await res.text()
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(responseText).not.toContain(secretMarker)
|
|
expect(responseText).toContain('KMS rejected the request')
|
|
expect(res.headers.get('cache-control')).toBe('no-store')
|
|
})
|
|
})
|
|
|
|
describe('service descriptors', () => {
|
|
test('every descriptor carries nav metadata for every cloud', async () => {
|
|
for (const cloud of ['aws', 'azure', 'gcp']) {
|
|
const res = await appWithRoutes().request(`/api/clouds/${cloud}/services`)
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body.length).toBeGreaterThan(0)
|
|
for (const descriptor of body) {
|
|
expect(descriptor.cloud).toBe(cloud)
|
|
expect(typeof descriptor.route).toBe('string')
|
|
expect(descriptor.route.length).toBeGreaterThan(0)
|
|
expect(typeof descriptor.iconKey).toBe('string')
|
|
expect(typeof descriptor.group).toBe('string')
|
|
expect(typeof descriptor.order).toBe('number')
|
|
expect(descriptor.displayName.length).toBeGreaterThan(0)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('every unavailable service explains itself', async () => {
|
|
for (const cloud of ['aws', 'azure', 'gcp']) {
|
|
const body = await (await appWithRoutes().request(`/api/clouds/${cloud}/services`)).json()
|
|
const unexplained = body.filter(
|
|
(d: {availability: string; reason?: string}) => d.availability === 'coming_soon' && !d.reason,
|
|
)
|
|
expect(unexplained).toEqual([])
|
|
}
|
|
})
|
|
|
|
test('availability follows adapter registration rather than a hardcoded list', async () => {
|
|
const withK8s = await (await appWithRoutes([
|
|
mockAdapter('gcp', {service: 'k8s'}),
|
|
]).request('/api/clouds/gcp/services')).json()
|
|
const withoutK8s = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/gcp/services')).json()
|
|
|
|
const find = (body: Array<{service: string; reason?: string}>, service: string) =>
|
|
body.find((d) => d.service === service)
|
|
|
|
expect(find(withK8s, 'k8s')).toMatchObject({availability: 'available'})
|
|
expect(find(withoutK8s, 'k8s')).toMatchObject({availability: 'coming_soon'})
|
|
expect(find(withoutK8s, 'k8s')?.reason).toContain('GCP')
|
|
})
|
|
|
|
test('an adapter can report coming_soon when its runtime does not implement it', async () => {
|
|
// floci-az answers 501 for /functions, so a registered adapter must still
|
|
// be able to tell the truth about the runtime behind it.
|
|
const app = appWithRoutes([
|
|
mockAdapter('azure', {
|
|
service: 'serverless',
|
|
descriptorOverride: () => ({
|
|
availability: 'coming_soon',
|
|
reason: 'The Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.',
|
|
}),
|
|
}),
|
|
])
|
|
const body = await (await app.request('/api/clouds/azure/services')).json()
|
|
const serverless = body.find((d: {service: string}) => d.service === 'serverless')
|
|
|
|
expect(serverless.availability).toBe('coming_soon')
|
|
expect(serverless.reason).toContain('501')
|
|
})
|
|
|
|
test('routes AWS secrets to its bespoke page and GCP to the generic explorer', async () => {
|
|
// Availability now comes from adapter registration like every other service,
|
|
// but the absolute route is kept so the card still links to the standalone
|
|
// page instead of Cloud Explorer. Without an adapter it reads coming_soon.
|
|
const withAdapter = appWithRoutes([
|
|
mockAdapter('aws'),
|
|
mockAdapter('aws', {service: 'secrets'}),
|
|
mockAdapter('gcp', {service: 'secrets'}),
|
|
])
|
|
const aws = await (await withAdapter.request('/api/clouds/aws/services')).json()
|
|
const bare = await (await appWithRoutes().request('/api/clouds/aws/services')).json()
|
|
const gcp = await (await withAdapter.request('/api/clouds/gcp/services')).json()
|
|
const secretsFor = (body: Array<{service: string}>) => body.find((d) => d.service === 'secrets')
|
|
|
|
expect(secretsFor(aws)).toMatchObject({availability: 'available', route: '/secretsmanager'})
|
|
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
|
|
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'})
|
|
})
|
|
})
|
|
|
|
describe('AppConfig nested routes', () => {
|
|
test('routes AppConfig operations through the adapter', async () => {
|
|
const calls: string[] = []
|
|
const environment: AppConfigEnvironment = {
|
|
id: 'env-1',
|
|
applicationId: 'app-1',
|
|
name: 'dev',
|
|
description: null,
|
|
state: 'READY_FOR_DEPLOYMENT',
|
|
}
|
|
const profile: AppConfigConfigurationProfile = {
|
|
id: 'profile-1',
|
|
applicationId: 'app-1',
|
|
name: 'settings',
|
|
description: null,
|
|
locationUri: 'hosted',
|
|
type: 'AWS.Freeform',
|
|
}
|
|
const version: AppConfigHostedConfigurationVersion = {
|
|
id: 'profile-1:1',
|
|
applicationId: 'app-1',
|
|
configurationProfileId: 'profile-1',
|
|
versionNumber: 1,
|
|
description: null,
|
|
contentType: 'application/json',
|
|
content: '{"enabled":true}',
|
|
}
|
|
const strategy: AppConfigDeploymentStrategy = {
|
|
id: 'strategy-1',
|
|
name: 'immediate',
|
|
description: null,
|
|
deploymentDurationInMinutes: 0,
|
|
growthType: 'LINEAR',
|
|
growthFactor: 100,
|
|
finalBakeTimeInMinutes: 0,
|
|
replicateTo: 'NONE',
|
|
}
|
|
const deployment: AppConfigDeployment = {
|
|
applicationId: 'app-1',
|
|
environmentId: 'env-1',
|
|
deploymentNumber: 1,
|
|
configurationProfileId: 'profile-1',
|
|
configurationVersion: '1',
|
|
deploymentStrategyId: 'strategy-1',
|
|
state: 'COMPLETE',
|
|
percentageComplete: 100,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
description: null,
|
|
}
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'configuration',
|
|
schema: awsAppConfigSchema,
|
|
listAppConfigEnvironments: async (applicationId) => {
|
|
calls.push(`list-environments:${applicationId}`)
|
|
return [environment]
|
|
},
|
|
createAppConfigEnvironment: async (applicationId, input) => {
|
|
calls.push(`create-environment:${applicationId}:${input.values.name}`)
|
|
return environment
|
|
},
|
|
deleteAppConfigEnvironment: async (applicationId, environmentId) => {
|
|
calls.push(`delete-environment:${applicationId}:${environmentId}`)
|
|
},
|
|
listAppConfigConfigurationProfiles: async (applicationId) => {
|
|
calls.push(`list-profiles:${applicationId}`)
|
|
return [profile]
|
|
},
|
|
createAppConfigConfigurationProfile: async (applicationId, input) => {
|
|
calls.push(`create-profile:${applicationId}:${input.values.name}`)
|
|
return profile
|
|
},
|
|
deleteAppConfigConfigurationProfile: async (applicationId, profileId) => {
|
|
calls.push(`delete-profile:${applicationId}:${profileId}`)
|
|
},
|
|
listAppConfigHostedConfigurationVersions: async (applicationId, profileId) => {
|
|
calls.push(`list-versions:${applicationId}:${profileId}`)
|
|
return [version]
|
|
},
|
|
getAppConfigHostedConfigurationVersion: async (applicationId, profileId, versionNumber) => {
|
|
calls.push(`get-version:${applicationId}:${profileId}:${versionNumber}`)
|
|
return version
|
|
},
|
|
createAppConfigHostedConfigurationVersion: async (applicationId, profileId, input) => {
|
|
calls.push(`create-version:${applicationId}:${profileId}:${input.values.contentType}`)
|
|
return version
|
|
},
|
|
deleteAppConfigHostedConfigurationVersion: async (applicationId, profileId, versionNumber) => {
|
|
calls.push(`delete-version:${applicationId}:${profileId}:${versionNumber}`)
|
|
},
|
|
listAppConfigDeploymentStrategies: async () => {
|
|
calls.push('list-strategies')
|
|
return [strategy]
|
|
},
|
|
createAppConfigDeploymentStrategy: async (input) => {
|
|
calls.push(`create-strategy:${input.values.name}`)
|
|
return strategy
|
|
},
|
|
deleteAppConfigDeploymentStrategy: async (strategyId) => {
|
|
calls.push(`delete-strategy:${strategyId}`)
|
|
},
|
|
startAppConfigDeployment: async (applicationId, environmentId, input) => {
|
|
calls.push(`start-deployment:${applicationId}:${environmentId}:${input.values.configurationVersion}`)
|
|
return deployment
|
|
},
|
|
getAppConfigDeployment: async (applicationId, environmentId, deploymentNumber) => {
|
|
calls.push(`get-deployment:${applicationId}:${environmentId}:${deploymentNumber}`)
|
|
return deployment
|
|
},
|
|
})])
|
|
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments')).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({name: 'dev'}),
|
|
})).status).toBe(201)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments/env-1', {method: 'DELETE'})).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles')).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({name: 'settings'}),
|
|
})).status).toBe(201)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1', {method: 'DELETE'})).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1/hosted-configuration-versions')).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1/hosted-configuration-versions', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({content: '{}', contentType: 'application/json'}),
|
|
})).status).toBe(201)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1/hosted-configuration-versions/1')).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1/hosted-configuration-versions/1', {method: 'DELETE'})).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/deployment-strategies')).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/deployment-strategies', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({name: 'immediate'}),
|
|
})).status).toBe(201)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/deployment-strategies/strategy-1', {method: 'DELETE'})).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments/env-1/deployments', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({configurationVersion: '1'}),
|
|
})).status).toBe(201)
|
|
expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments/env-1/deployments/1')).status).toBe(200)
|
|
|
|
expect(calls).toEqual([
|
|
'list-environments:app-1',
|
|
'create-environment:app-1:dev',
|
|
'delete-environment:app-1:env-1',
|
|
'list-profiles:app-1',
|
|
'create-profile:app-1:settings',
|
|
'delete-profile:app-1:profile-1',
|
|
'list-versions:app-1:profile-1',
|
|
'create-version:app-1:profile-1:application/json',
|
|
'get-version:app-1:profile-1:1',
|
|
'delete-version:app-1:profile-1:1',
|
|
'list-strategies',
|
|
'create-strategy:immediate',
|
|
'delete-strategy:strategy-1',
|
|
'start-deployment:app-1:env-1:1',
|
|
'get-deployment:app-1:env-1:1',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('per-service status', () => {
|
|
test('reports a reachable service with a latency measurement', async () => {
|
|
const res = await appWithRoutes().request('/api/clouds/aws/services/storage/status')
|
|
const body = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(body).toMatchObject({
|
|
cloud: 'aws',
|
|
service: 'storage',
|
|
adapterRegistered: true,
|
|
runtime: 'reachable',
|
|
error: null,
|
|
errorCode: null,
|
|
})
|
|
expect(body.latencyMs).toBeGreaterThanOrEqual(0)
|
|
})
|
|
|
|
test('reports coming_soon for a service with no adapter', async () => {
|
|
const res = await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/gcp/services/storage/status')
|
|
const body = await res.json()
|
|
|
|
expect(body).toMatchObject({runtime: 'coming_soon', adapterRegistered: false, latencyMs: null})
|
|
})
|
|
|
|
test('distinguishes a runtime that does not implement a service from one that is down', async () => {
|
|
// This is the whole point of errorCode: floci-az serves blob storage but
|
|
// answers 501 for functions, and the UI must not call that "offline".
|
|
const app = appWithRoutes([
|
|
mockAdapter('azure', {
|
|
service: 'serverless',
|
|
list: async () => {
|
|
throw new NotImplementedByRuntimeError('HTTP 501 /functions')
|
|
},
|
|
}),
|
|
mockAdapter('gcp', {
|
|
list: async () => {
|
|
throw new RuntimeUnavailableError('Cannot reach Floci-GCP')
|
|
},
|
|
}),
|
|
])
|
|
|
|
const notImplemented = await (await app.request('/api/clouds/azure/services/serverless/status')).json()
|
|
expect(notImplemented.runtime).toBe('unavailable')
|
|
expect(notImplemented.errorCode).toBe('operation_not_implemented')
|
|
|
|
const down = await (await app.request('/api/clouds/gcp/services/storage/status')).json()
|
|
expect(down.errorCode).toBe('runtime_unavailable')
|
|
})
|
|
|
|
test('prefers an adapter health() override to list()', async () => {
|
|
let listCalls = 0
|
|
let healthCalls = 0
|
|
const app = appWithRoutes([
|
|
mockAdapter('aws', {
|
|
list: async () => {
|
|
listCalls += 1
|
|
return []
|
|
},
|
|
health: async () => {
|
|
healthCalls += 1
|
|
},
|
|
}),
|
|
])
|
|
|
|
await app.request('/api/clouds/aws/services/storage/status')
|
|
expect(healthCalls).toBe(1)
|
|
expect(listCalls).toBe(0)
|
|
})
|
|
|
|
test('omits per-service detail from the cloud status by default', async () => {
|
|
const body = await (await appWithRoutes().request('/api/clouds/aws/status')).json()
|
|
expect(body.services).toBeUndefined()
|
|
})
|
|
|
|
test('includes per-service detail only when asked', async () => {
|
|
const body = await (await appWithRoutes().request('/api/clouds/aws/status?services=all')).json()
|
|
|
|
expect(Array.isArray(body.services)).toBe(true)
|
|
expect(body.services[0]).toMatchObject({cloud: 'aws', service: 'storage'})
|
|
})
|
|
|
|
test('caches probes so a polling sidebar does not fan out per request', async () => {
|
|
let probes = 0
|
|
const app = appWithRoutes([
|
|
mockAdapter('aws', {
|
|
list: async () => {
|
|
probes += 1
|
|
return []
|
|
},
|
|
}),
|
|
])
|
|
|
|
for (let i = 0; i < 5; i += 1) {
|
|
await app.request('/api/clouds/aws/services/storage/status')
|
|
}
|
|
expect(probes).toBe(1)
|
|
})
|
|
|
|
test('rejects an unknown service slug', async () => {
|
|
const res = await appWithRoutes().request('/api/clouds/aws/services/queue/status')
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
test('routes nested EKS nodegroup and Fargate actions through the cloud adapter', async () => {
|
|
const calls: string[] = []
|
|
const app = appWithRoutes([mockAdapter('aws', {
|
|
service: 'k8s',
|
|
schema: awsEksSchema,
|
|
listKubernetesNodegroups: async (clusterId) => [{
|
|
id: 'workers', name: 'workers', clusterId, arn: null, status: 'ACTIVE', version: null,
|
|
releaseVersion: null, createdAt: null, modifiedAt: null, capacityType: null,
|
|
instanceTypes: ['t3.medium'], subnets: ['subnet-a'], nodeRole: null,
|
|
scalingConfig: null, labels: {}, tags: {},
|
|
}],
|
|
createKubernetesNodegroup: async (clusterId, input) => {
|
|
calls.push(`create-nodegroup:${clusterId}:${input.name}`)
|
|
return {
|
|
id: input.name, name: input.name, clusterId, arn: null, status: null, version: null,
|
|
releaseVersion: null, createdAt: null, modifiedAt: null, capacityType: null,
|
|
instanceTypes: [], subnets: input.subnets, nodeRole: input.nodeRole,
|
|
scalingConfig: null, labels: {}, tags: {},
|
|
}
|
|
},
|
|
deleteKubernetesNodegroup: async (clusterId, nodegroupId) => {
|
|
calls.push(`delete-nodegroup:${clusterId}:${nodegroupId}`)
|
|
},
|
|
listKubernetesFargateProfiles: async (clusterId) => [{
|
|
id: 'default', name: 'default', clusterId, arn: null, status: 'ACTIVE', createdAt: null,
|
|
podExecutionRoleArn: null, subnets: [], selectors: [], tags: {},
|
|
}],
|
|
createKubernetesFargateProfile: async (clusterId, input) => {
|
|
calls.push(`create-fargate:${clusterId}:${input.name}`)
|
|
return {
|
|
id: input.name, name: input.name, clusterId, arn: null, status: null, createdAt: null,
|
|
podExecutionRoleArn: input.podExecutionRoleArn, subnets: input.subnets ?? [], selectors: [], tags: {},
|
|
}
|
|
},
|
|
deleteKubernetesFargateProfile: async (clusterId, profileId) => {
|
|
calls.push(`delete-fargate:${clusterId}:${profileId}`)
|
|
},
|
|
})])
|
|
|
|
expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/nodegroups')).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/nodegroups', {
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({name: 'workers', nodeRole: 'role', subnets: ['subnet-a']}),
|
|
})).status).toBe(201)
|
|
expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/nodegroups/workers', {method: 'DELETE'})).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/fargate-profiles')).status).toBe(200)
|
|
expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/fargate-profiles', {
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({name: 'default', podExecutionRoleArn: 'role', selectors: [{namespace: 'default'}]}),
|
|
})).status).toBe(201)
|
|
expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/fargate-profiles/default', {method: 'DELETE'})).status).toBe(200)
|
|
|
|
expect(calls).toEqual([
|
|
'create-nodegroup:demo:workers',
|
|
'delete-nodegroup:demo:workers',
|
|
'create-fargate:demo:default',
|
|
'delete-fargate:demo:default',
|
|
])
|
|
})
|
|
|
|
test('routes PATCH to the adapter update method', async () => {
|
|
let updatedId = ''
|
|
let updatedValues: Record<string, unknown> = {}
|
|
const app = appWithRoutes([
|
|
mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
update: async (id, input) => {
|
|
updatedId = id
|
|
updatedValues = input.values
|
|
return {
|
|
id,
|
|
name: id,
|
|
cloud: 'aws',
|
|
service: 'database',
|
|
type: 'db-instance',
|
|
region: 'us-east-1',
|
|
createdAt: null,
|
|
metadata: input.values,
|
|
}
|
|
},
|
|
}),
|
|
])
|
|
|
|
const res = await app.request('/api/clouds/aws/services/database/resources/orders-db', {
|
|
method: 'PATCH',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({autoMinorVersionUpgrade: 'true'}),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(updatedId).toBe('orders-db')
|
|
expect(updatedValues).toEqual({autoMinorVersionUpgrade: 'true'})
|
|
})
|
|
|
|
test('returns 501 when the adapter does not implement update', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {service: 'database', schema: awsDatabaseSchema})])
|
|
const res = await app.request('/api/clouds/aws/services/database/resources/orders-db', {
|
|
method: 'PATCH',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({autoMinorVersionUpgrade: 'true'}),
|
|
})
|
|
expect(res.status).toBe(501)
|
|
})
|
|
|
|
test('maps adapter ValidationError on PATCH to 400', async () => {
|
|
const app = appWithRoutes([
|
|
mockAdapter('aws', {
|
|
service: 'database',
|
|
schema: awsDatabaseSchema,
|
|
update: async () => {
|
|
throw new ValidationError('Invalid password')
|
|
},
|
|
}),
|
|
])
|
|
const res = await app.request('/api/clouds/aws/services/database/resources/orders-db', {
|
|
method: 'PATCH',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({masterUserPassword: 'short'}),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
const body = await res.json()
|
|
expect(body.message).toBe('Invalid password')
|
|
})
|
|
})
|
|
|
|
const stubCollections: CollectionPage<ChildCollection> = {
|
|
items: [{id: 'stream-a', name: 'stream-a', parentId: 'group-1', createdAt: null, metadata: {}}],
|
|
nextCursor: 'cursor-2',
|
|
}
|
|
|
|
const stubItems: CollectionPage<ChildItem> = {
|
|
items: [{id: 'event-1', collectionId: 'stream-a', timestamp: null, body: {message: 'hello'}, metadata: {}}],
|
|
nextCursor: null,
|
|
}
|
|
|
|
const documentsStub: DocumentStoreAdapter = {
|
|
listCollections: async () => stubCollections,
|
|
createCollection: async (resourceId, input) => ({
|
|
id: String(input.values.name),
|
|
name: String(input.values.name),
|
|
parentId: resourceId,
|
|
createdAt: null,
|
|
metadata: {},
|
|
}),
|
|
deleteCollection: async () => {},
|
|
listItems: async () => stubItems,
|
|
}
|
|
|
|
const itemsStub: ItemStoreAdapter = {
|
|
listItems: async () => stubItems,
|
|
}
|
|
|
|
describe('child collection routes', () => {
|
|
test('lists collections and returns the cursor', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections')
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual({
|
|
items: [{id: 'stream-a', name: 'stream-a', parentId: 'group-1', createdAt: null, metadata: {}}],
|
|
nextCursor: 'cursor-2',
|
|
})
|
|
})
|
|
|
|
test('creates a collection', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({name: 'stream-b'}),
|
|
})
|
|
|
|
expect(res.status).toBe(201)
|
|
expect(await res.json()).toMatchObject({id: 'stream-b', parentId: 'group-1'})
|
|
})
|
|
|
|
test('lists items under a collection', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections/stream-a/items')
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual({items: stubItems.items, nextCursor: null})
|
|
})
|
|
|
|
test('lists flat items', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {items: itemsStub})])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/table-1/items')
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual({items: stubItems.items, nextCursor: null})
|
|
})
|
|
|
|
test('passes a structured flat-item query and pagination through the existing query route', async () => {
|
|
let seen: unknown
|
|
const app = appWithRoutes([mockAdapter('aws', {items: {
|
|
...itemsStub,
|
|
queryItems: async (resourceId, query, page) => {
|
|
seen = {resourceId, query, page}
|
|
return {...stubItems, count: 1, scannedCount: 4, consumedCapacity: 0.5}
|
|
},
|
|
}})])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/table-1/query?cursor=opaque&limit=25', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({query: {operation: 'scan', filter: {attribute: 'status', operator: 'EQ', type: 'S', value: 'OPEN'}}}),
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(seen).toEqual({
|
|
resourceId: 'table-1',
|
|
query: {operation: 'scan', filter: {attribute: 'status', operator: 'EQ', type: 'S', value: 'OPEN'}},
|
|
page: {cursor: 'opaque', limit: 25},
|
|
})
|
|
expect(await res.json()).toMatchObject({count: 1, scannedCount: 4, consumedCapacity: 0.5})
|
|
})
|
|
|
|
// A documents-only adapter has no flat shape, and vice versa.
|
|
test('501s when the adapter has the other shape', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/items')
|
|
|
|
expect(res.status).toBe(501)
|
|
})
|
|
|
|
test('501s when the adapter implements no child store', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws')])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections')
|
|
|
|
expect(res.status).toBe(501)
|
|
})
|
|
|
|
test('501s for a write the store does not implement', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections/stream-a/items', {
|
|
method: 'POST',
|
|
headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({message: 'nope'}),
|
|
})
|
|
|
|
expect(res.status).toBe(501)
|
|
})
|
|
|
|
test('rejects an out-of-range limit', async () => {
|
|
const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})])
|
|
const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections?limit=5000')
|
|
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
test('passes cursor and limit through to the adapter', async () => {
|
|
let seen: unknown
|
|
const app = appWithRoutes([
|
|
mockAdapter('aws', {
|
|
documents: {
|
|
...documentsStub,
|
|
listCollections: async (_resourceId, page) => {
|
|
seen = page
|
|
return stubCollections
|
|
},
|
|
},
|
|
}),
|
|
])
|
|
await app.request('/api/clouds/aws/services/storage/resources/group-1/collections?cursor=abc&limit=25')
|
|
|
|
expect(seen).toEqual({cursor: 'abc', limit: 25})
|
|
})
|
|
|
|
// Registration-order regression: the literal `nosql` segment must keep
|
|
// beating the new `:service` param, or the Cosmos panel silently breaks.
|
|
test('the existing Cosmos container routes still resolve', async () => {
|
|
const app = appWithRoutes([
|
|
mockAdapter('azure', {
|
|
service: 'nosql',
|
|
schema: azureNoSqlSchema,
|
|
listCosmosContainers: async (): Promise<CosmosContainer[]> => [{
|
|
id: 'items',
|
|
name: 'items',
|
|
databaseId: 'appdb',
|
|
partitionKeyPath: '/pk',
|
|
createdAt: null,
|
|
metadata: {},
|
|
}],
|
|
}),
|
|
])
|
|
|
|
const res = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers')
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toMatchObject([{id: 'items', databaseId: 'appdb'}])
|
|
})
|
|
})
|