Files
floci-ui/packages/api/src/adapter-aws/AwsLogsAdapter.test.ts
T
2026-09-30 17:43:42 -04:00

400 lines
17 KiB
TypeScript

import {describe, expect, test} from 'bun:test'
import {AwsLogsAdapter} from './AwsLogsAdapter'
/**
* Stubs the SDK client's `send`, dispatching on the command's constructor name.
* Matching the real SDK's *name* rather than the wire shape is deliberate — see
* .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md.
*/
function stubClient(handlers: Record<string, (input: Record<string, unknown>) => unknown>) {
const calls: Array<{command: string; input: Record<string, unknown>}> = []
const client = {
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
const name = command.constructor.name
calls.push({command: name, input: command.input})
const handler = handlers[name]
if (!handler) throw new Error(`unexpected command ${name}`)
return handler(command.input)
},
}
return {client: client as never, calls}
}
describe('AwsLogsAdapter resources', () => {
test('normalizes log groups as cloud resources', async () => {
const {client} = stubClient({
DescribeLogGroupsCommand: () => ({
logGroups: [{
logGroupName: '/floci/probe',
creationTime: 1785260691820,
arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe',
storedBytes: 0,
metricFilterCount: 0,
}],
}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.list()).resolves.toMatchObject([{
id: '/floci/probe',
name: '/floci/probe',
cloud: 'aws',
service: 'logs',
type: 'log-group',
createdAt: new Date(1785260691820).toISOString(),
}])
})
// The runtime filters natively by prefix, so `search` must not be a
// client-side contains — that would silently disagree with the field label.
test('search is sent as logGroupNamePrefix', async () => {
const {client, calls} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})})
const adapter = new AwsLogsAdapter(client)
await adapter.list({search: '/floci'})
expect(calls[0].input).toMatchObject({logGroupNamePrefix: '/floci'})
})
test('get returns null for a group the runtime does not have', async () => {
const {client} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.get('/nope')).resolves.toBeNull()
})
// DescribeLogGroups is a prefix match, so a longer name starting with the
// requested one would otherwise be returned as if it were an exact hit.
test('get matches the name exactly, not by prefix', async () => {
const {client} = stubClient({
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/probe-two', creationTime: 1}]}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.get('/floci/probe')).resolves.toBeNull()
})
test('create and delete issue the right commands', async () => {
const {client, calls} = stubClient({
CreateLogGroupCommand: () => ({}),
DeleteLogGroupCommand: () => ({}),
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/new', creationTime: 1}]}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.create({values: {name: '/floci/new'}})).resolves.toMatchObject({id: '/floci/new'})
await adapter.delete('/floci/new')
expect(calls.map((call) => call.command)).toEqual([
'CreateLogGroupCommand',
'DescribeLogGroupsCommand',
'DeleteLogGroupCommand',
])
})
/**
* `creationTime` is the field the SDK models and real AWS returns. The
* local runtime sends `createdTime`, which the SDK deserializer discards —
* so createdAt is legitimately null against the emulator, and a stub that
* feeds `createdTime` is testing a shape the SDK can never produce. That
* mistake shipped here once already; this pins the real contract.
*/
test('reads the creation timestamp the SDK actually models', async () => {
const {client} = stubClient({
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a', creationTime: 1785260691820}]}),
})
await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([
{createdAt: new Date(1785260691820).toISOString()},
])
})
test('a group with no modelled timestamp reports null rather than a bogus date', async () => {
const {client} = stubClient({
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a'}]}),
})
await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([{createdAt: null}])
})
test('create rejects a blank name before calling the runtime', async () => {
const {client, calls} = stubClient({})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.create({values: {name: ' '}})).rejects.toThrow()
expect(calls).toEqual([])
})
})
describe('AwsLogsAdapter queryLogs', () => {
test('starts a query and maps result fields into row objects', async () => {
const {client, calls} = stubClient({
StartQueryCommand: () => ({queryId: 'q-1'}),
GetQueryResultsCommand: () => ({
status: 'Complete',
results: [
[{field: '@timestamp', value: '2026-01-01 00:00:00.000'}, {field: '@message', value: 'hello'}],
],
}),
})
const adapter = new AwsLogsAdapter(client)
const result = await adapter.queryLogs('/floci/probe', {
queryString: 'fields @timestamp, @message',
startTime: 1000,
endTime: 2000,
limit: 20,
})
expect(result).toEqual({
queryId: 'q-1',
status: 'Complete',
rows: [{'@timestamp': '2026-01-01 00:00:00.000', '@message': 'hello'}],
})
expect(calls[0]).toMatchObject({
command: 'StartQueryCommand',
input: {logGroupName: '/floci/probe', startTime: 1000, endTime: 2000, queryString: 'fields @timestamp, @message', limit: 20},
})
expect(calls[1].command).toBe('GetQueryResultsCommand')
})
// FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS defaults to 0, so a
// real completion normally takes one GetQueryResults call — but a positive
// delay (or real AWS) makes the first call(s) come back Running, so the
// adapter must keep polling rather than returning a stale in-progress result.
test('polls until the query leaves Running/Scheduled', async () => {
let getCalls = 0
const {client, calls} = stubClient({
StartQueryCommand: () => ({queryId: 'q-2'}),
GetQueryResultsCommand: () => {
getCalls += 1
if (getCalls < 3) return {status: getCalls === 1 ? 'Scheduled' : 'Running', results: []}
return {status: 'Complete', results: [[{field: '@message', value: 'done'}]]}
},
})
const adapter = new AwsLogsAdapter(client)
const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1})
expect(result).toEqual({queryId: 'q-2', status: 'Complete', rows: [{'@message': 'done'}]})
expect(calls.filter((call) => call.command === 'GetQueryResultsCommand')).toHaveLength(3)
})
// The P1 this test pins: if the poll deadline is hit while the query is
// still Running/Scheduled, the caller must still get the queryId back —
// dropping it here would make that still-running query unrecoverable, even
// though Floci (or real AWS) keeps executing it server-side.
test('still returns the query id when the poll deadline is hit before completion', async () => {
const {client} = stubClient({
StartQueryCommand: () => ({queryId: 'q-3'}),
GetQueryResultsCommand: () => ({status: 'Running', results: []}),
})
const adapter = new AwsLogsAdapter(client, {timeoutMs: 30, intervalMs: 10})
const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1})
expect(result.queryId).toBe('q-3')
expect(result.status).toBe('Running')
})
test('rejects a blank query string before calling the runtime', async () => {
const {client, calls} = stubClient({})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.queryLogs('/floci/probe', {queryString: ' ', startTime: 0, endTime: 1}))
.rejects.toThrow()
expect(calls).toEqual([])
})
test('raises when the runtime does not hand back a query id', async () => {
const {client} = stubClient({StartQueryCommand: () => ({})})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1}))
.rejects.toThrow()
})
})
describe('AwsLogsAdapter filterLogEvents', () => {
test('filters across streams and preserves event identity and pagination', async () => {
const {client, calls} = stubClient({
FilterLogEventsCommand: () => ({
events: [{eventId: 'event-1', logStreamName: 'stream-a', timestamp: 1785260000000, message: 'ERROR failed', ingestionTime: 1785260001000}],
nextToken: 'page-2',
}),
})
const page = await new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {
filterPattern: 'ERROR',
logStreamNames: ['stream-a'],
startTime: 1000,
endTime: 2000,
}, {cursor: 'page-1', limit: 25})
expect(calls[0]).toMatchObject({command: 'FilterLogEventsCommand', input: {
logGroupName: '/floci/probe', filterPattern: 'ERROR', logStreamNames: ['stream-a'],
startTime: 1000, endTime: 2000, nextToken: 'page-1', limit: 25,
}})
expect(page).toEqual({
items: [{
id: 'event-1', collectionId: 'stream-a', timestamp: new Date(1785260000000).toISOString(),
body: {message: 'ERROR failed'}, metadata: {ingestionTime: new Date(1785260001000).toISOString()},
}],
nextCursor: 'page-2',
})
})
test('retains a continuation token on an empty page', async () => {
const {client} = stubClient({FilterLogEventsCommand: () => ({events: [], nextToken: 'keep-going'})})
await expect(new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {}))
.resolves.toEqual({items: [], nextCursor: 'keep-going'})
})
})
describe('AwsLogsAdapter documents', () => {
test('lists log streams as collections, newest activity first', async () => {
const {client, calls} = stubClient({
DescribeLogStreamsCommand: () => ({
logStreams: [{
logStreamName: 'stream-a',
arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe:log-stream:stream-a',
creationTime: 1785260702176,
storedBytes: 70,
firstEventTimestamp: 1785260000000,
lastEventTimestamp: 1785260001000,
}],
nextToken: 'page-2',
}),
})
const adapter = new AwsLogsAdapter(client)
const page = await adapter.documents.listCollections('/floci/probe')
expect(page).toMatchObject({
items: [{id: 'stream-a', name: 'stream-a', parentId: '/floci/probe'}],
nextCursor: 'page-2',
})
expect(calls[0].input).toMatchObject({
logGroupName: '/floci/probe',
orderBy: 'LastEventTime',
descending: true,
})
})
/**
* Real AWS caps DescribeLogStreams Limit at 50 and rejects anything higher
* with a ValidationException. The SPI defaults to 100 and permits 1000, and
* the local runtime does not enforce the cap — so an unclamped value passes
* every local test and 400s in production. Encode the provider's rule.
*/
test('clamps the stream page size to the AWS maximum of 50', async () => {
const {client, calls} = stubClient({DescribeLogStreamsCommand: () => ({logStreams: []})})
const adapter = new AwsLogsAdapter(client)
await adapter.documents.listCollections('/floci/probe')
await adapter.documents.listCollections('/floci/probe', {limit: 1000})
await adapter.documents.listCollections('/floci/probe', {limit: 25})
expect(calls.map((call) => call.input.limit)).toEqual([50, 50, 25])
})
test('reads log events as items, oldest first', async () => {
const {client, calls} = stubClient({
GetLogEventsCommand: () => ({
events: [
{timestamp: 1785260000000, message: 'hello one', ingestionTime: 1785260702192},
{timestamp: 1785260001000, message: 'hello two', ingestionTime: 1785260702192},
],
nextForwardToken: 'f/2',
nextBackwardToken: 'b/0',
}),
})
const adapter = new AwsLogsAdapter(client)
const page = await adapter.documents.listItems('/floci/probe', 'stream-a')
// Real AWS gives events no id, so it is derived from timestamp and
// position. Two events sharing a millisecond still get distinct ids.
expect(page.items).toMatchObject([
{
id: '1785260000000-0',
collectionId: 'stream-a',
timestamp: new Date(1785260000000).toISOString(),
body: {message: 'hello one'},
},
{id: '1785260001000-1', collectionId: 'stream-a'},
])
expect(page.nextCursor).toBe('f/2')
// Without startFromHead the runtime returns the newest page, so paging
// forward from a fresh cursor would skip the history.
expect(calls[0].input).toMatchObject({startFromHead: true})
})
/**
* The termination rule. GetLogEvents echoes the supplied token back when
* there is nothing further, so a cursor loop that trusts a non-null token
* never ends. Verified against the runtime on 2026-07-28.
*/
test('stops paging when the runtime echoes the cursor back', async () => {
const {client} = stubClient({
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/2', nextBackwardToken: 'b/2'}),
})
const adapter = new AwsLogsAdapter(client)
const page = await adapter.documents.listItems('/floci/probe', 'stream-a', {cursor: 'f/2'})
expect(page.items).toEqual([])
expect(page.nextCursor).toBeNull()
})
test('an empty first page terminates too', async () => {
const {client} = stubClient({
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.documents.listItems('/floci/probe', 'stream-a')).resolves.toMatchObject({nextCursor: null})
})
test('creates and deletes a log stream', async () => {
const {client, calls} = stubClient({
CreateLogStreamCommand: () => ({}),
DeleteLogStreamCommand: () => ({}),
DescribeLogStreamsCommand: () => ({
logStreams: [{logStreamName: 'stream-b', creationTime: 1785260702176}],
}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.documents.createCollection!('/floci/probe', {values: {name: 'stream-b'}}))
.resolves.toMatchObject({id: 'stream-b', parentId: '/floci/probe'})
await adapter.documents.deleteCollection!('/floci/probe', 'stream-b')
expect(calls.map((call) => call.command)).toEqual([
'CreateLogStreamCommand',
'DescribeLogStreamsCommand',
'DeleteLogStreamCommand',
])
})
// Provider behaviour, verified: a missing group raises, a missing stream
// returns an empty page. The contract mirrors that rather than smoothing it.
test('a missing stream yields an empty page rather than an error', async () => {
const {client} = stubClient({
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.documents.listItems('/floci/probe', 'nope')).resolves.toEqual({items: [], nextCursor: null})
})
test('the store advertises no item writes', () => {
const {client} = stubClient({})
const adapter = new AwsLogsAdapter(client)
expect(adapter.documents.putItem).toBeUndefined()
expect(adapter.documents.deleteItem).toBeUndefined()
expect(adapter.documents.queryItems).toBeUndefined()
})
})