Files
floci-ui/packages/api/src/adapter-aws/AwsMonitoringAdapter.test.ts
T
2026-10-24 22:48:41 -04:00

148 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {describe, expect, test} from 'bun:test'
import {
DeleteAlarmsCommand,
DescribeAlarmHistoryCommand,
DescribeAlarmsCommand,
DisableAlarmActionsCommand,
EnableAlarmActionsCommand,
GetMetricDataCommand,
ListMetricsCommand,
PutCompositeAlarmCommand,
PutMetricAlarmCommand,
SetAlarmStateCommand,
type CloudWatchClient,
} from '@aws-sdk/client-cloudwatch'
import {AwsMonitoringAdapter} from './AwsMonitoringAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubCloudWatch(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as CloudWatchClient
return {adapter: new AwsMonitoringAdapter(client), sent}
}
const UPDATED = new Date('2026-10-25T10:00:00Z')
const metricAlarm = {
AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors',
StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED,
ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [],
Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
}
const compositeAlarm = {
AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy',
StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED,
}
describe('AwsMonitoringAdapter alarms', () => {
test('lists metric and composite alarms with a readable condition', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand
? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]}
: {})
const alarms = await adapter.list()
expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm'])
expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']])
expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300})
expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false})
expect(await adapter.list({search: 'errors'})).toHaveLength(1)
})
test('creates a metric alarm that notifies an SNS topic both ways', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {})
await adapter.create({values: {
alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n',
statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2',
topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts',
}})
expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({
AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'],
})
})
test('creates a composite alarm from its rule', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {})
const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}})
expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []})
expect(created.metadata.alarmType).toBe('composite')
})
test('checks the alarm before creating it', async () => {
const {adapter, sent} = stubCloudWatch(() => ({}))
const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'}
await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/)
await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/)
await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/)
await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/)
await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError)
await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/)
expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false)
})
test('deletes, sets the state and toggles the actions of an alarm', async () => {
const {adapter, sent} = stubCloudWatch(() => ({}))
await adapter.delete('high-errors')
await adapter.setAlarmState('high-errors', 'ALARM', '')
await adapter.setAlarmActionsEnabled('high-errors', false)
await adapter.setAlarmActionsEnabled('high-errors', true)
expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']})
expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'})
expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud')
expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand)
expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand)
await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/)
})
test('reads the alarm history newest first', async () => {
const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]}))
expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'})
})
})
describe('AwsMonitoringAdapter metrics', () => {
test('lists metrics sorted by namespace and filters them', async () => {
const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand
? {Metrics: [
{Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]},
{Namespace: 'App', MetricName: 'Errors', Dimensions: []},
]}
: {})
expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent'])
expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1)
})
test('charts a metric with a period that suits the range', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand
? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]}
: {})
const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]})
expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]})
const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0]
expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'})
await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60})
expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300)
await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/)
await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/)
})
})