91f2f08995
## Why The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`, `usageEvent`, `applicationLog`) each wrote to ClickHouse through their own fire-and-forget writer (`AuditService`, `UsageEventWriterService`, and the `application-logs` driver), with the per-type knowledge (table names, normalization, access rules) spread across several modules. Three of them reimplemented the same ClickHouse insert, and the read side, the live stream, and the producers lived in different modules under two different names. This consolidates them into one `core-modules/event-logs/` subsystem (emit, write, live, read), with the per-type config in a single registry so adding an event type is roughly one file. The base Logs settings tab and free application logs shipped separately in #21180 (merged). This PR adds the unified backend, the registry, and the viewer's live mode and entitlement gating. ## Pipeline ```mermaid flowchart TB subgraph PROD["Producers"] A["auth, billing, impersonation,<br/>webhook, custom-domain"] U["usage listener"] F["logic-function executor (app logs)"] R["record CRUD (entity events)"] end EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"] EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"]) CIE["CreateEventLogFromInternalEvent"] SINK["WorkspaceEventSinkService.ingest()"] C1["ClickHouseEventSink"] C2["ConsoleEventSink"] LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"] CH[("ClickHouse, 5 tables, async_insert")] CHAN(["WORKSPACE_EVENTS_CHANNEL"]) RS["EventLogsService (registry-driven read)"] LR["EventLogsLiveResolver"] UI["Settings > Logs"] A --> EM U --> EM F --> EM EM -->|direct| SINK R --> EQ --> CIE -->|ingest| SINK SINK --> C1 --> CH SINK --> C2 SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI CH --> RS --> UI ``` ## What it does - Producers call `EventLogEmitterService.createContext().insert*()`, which builds a typed `WorkspaceEventEnvelope` and writes it through `WorkspaceEventSinkService` to the configured sinks (ClickHouse, Console) plus a presence-gated live fan-out. Record/CRUD events reach the same sink through the existing `entityEventsToDbQueue`. There is no dedicated queue; ClickHouse `async_insert` batches server-side. Writes are best-effort, as on main today. - `EVENT_LOG_TYPES[table]` is the per-type source of truth: the ClickHouse table, the required entitlement, the free-text filter column, and the row-to-GraphQL mapping. Read row shapes derive from the write rows. - Four modules along their dependency boundaries: `EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the entitlement-gated GraphQL read, which is where billing/enterprise/permissions stay so producers stay light). - Logs viewer: per-table columns, filters (text, date, record), live mode, and an upgrade card that points to Billing on Cloud or the Admin Panel on self-hosted. Application logs are free on every plan; the other four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT` fallback to the upgrade card). - Renames `AuditService` to `EventLogEmitterService`, and the generic `Monitoring` event to a typed `Impersonation` event (`level` + `action`). - Removes `UsageEventWriterService`, the `application-logs` driver/module, and `AuditService`'s direct inserts. ## Durability Writes are best-effort, the same as main today (the old writers were fire-and-forget). A dedicated queue was tried mid-PR and removed: `async_insert` already batches server-side, so the queue only added durability, which isn't a requirement right now. The `EventSink` seam keeps a durable transport (e.g. a Redis-Streams buffer) easy to add later without touching producers. ## Out of scope S3 peer sink (seam only), Postgres or any second read path, `ReplicatedMergeTree`, ClickHouse table-schema changes, and the record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern). ## Testing Unit tests cover the registry definitions and row normalization, the entitlement gating, the envelope builders, and the producers. Integration tests cover the write paths (record create produces an `objectEvent`; the track mutation produces a `workspaceEvent`) and the read/query path across all five tables. Verified with typecheck, lint, a server boot, and GraphQL/SDK codegen.
649 lines
20 KiB
TypeScript
649 lines
20 KiB
TypeScript
import process from 'process';
|
|
|
|
import {
|
|
type ClickHouseClient,
|
|
ClickHouseLogLevel,
|
|
createClient,
|
|
} from '@clickhouse/client';
|
|
import request from 'supertest';
|
|
|
|
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
|
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
|
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
|
|
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
|
|
|
|
const client = request(`http://localhost:${APP_PORT}`);
|
|
|
|
describe('Event Logs (integration)', () => {
|
|
let clickHouseClient: ClickHouseClient;
|
|
const testWorkspaceId = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
|
const testUserWorkspaceId = '20202020-3957-45c9-be39-337dc4d9100a';
|
|
const otherUserWorkspaceId = '20202020-0000-4000-8000-0000000000ff';
|
|
|
|
beforeAll(async () => {
|
|
jest.useRealTimers();
|
|
|
|
clickHouseClient = createClient({
|
|
url: process.env.CLICKHOUSE_URL,
|
|
clickhouse_settings: {
|
|
allow_experimental_json_type: 1,
|
|
},
|
|
log: { level: ClickHouseLogLevel.OFF },
|
|
});
|
|
|
|
await seedTestData();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanupTestData();
|
|
|
|
if (clickHouseClient) {
|
|
await clickHouseClient.close();
|
|
}
|
|
});
|
|
|
|
const seedTestData = async () => {
|
|
const now = new Date();
|
|
|
|
const pageviewRecords = Array.from({ length: 25 }, (_, i) => ({
|
|
workspaceId: testWorkspaceId,
|
|
userId: testUserWorkspaceId,
|
|
name: i % 2 === 0 ? 'settings/profile' : 'objects/companies',
|
|
timestamp: formatDateTimeForClickHouse(
|
|
new Date(now.getTime() - i * 60000),
|
|
),
|
|
properties: { path: `/settings/${i}` },
|
|
}));
|
|
|
|
const workspaceEventRecords = Array.from({ length: 15 }, (_, i) => ({
|
|
workspaceId: testWorkspaceId,
|
|
userId: testUserWorkspaceId,
|
|
event:
|
|
i % 3 === 0
|
|
? 'user.login'
|
|
: i % 3 === 1
|
|
? 'user.logout'
|
|
: 'settings.updated',
|
|
timestamp: formatDateTimeForClickHouse(
|
|
new Date(now.getTime() - i * 120000),
|
|
),
|
|
properties: { action: `action_${i}` },
|
|
}));
|
|
|
|
const objectEventRecords = Array.from({ length: 20 }, (_, i) => ({
|
|
workspaceId: testWorkspaceId,
|
|
userId: testUserWorkspaceId,
|
|
event: i % 2 === 0 ? 'company.created' : 'company.updated',
|
|
timestamp: formatDateTimeForClickHouse(
|
|
new Date(now.getTime() - i * 90000),
|
|
),
|
|
properties: { field: `field_${i}` },
|
|
recordId: `record-${i}`,
|
|
objectMetadataId: i % 2 === 0 ? 'object-meta-1' : 'object-meta-2',
|
|
isCustom: i % 4 === 0,
|
|
}));
|
|
|
|
const usageEventRecords = Array.from({ length: 8 }, (_, i) => ({
|
|
workspaceId: testWorkspaceId,
|
|
userWorkspaceId: i < 5 ? testUserWorkspaceId : otherUserWorkspaceId,
|
|
resourceType: UsageResourceType.AI,
|
|
operationType: UsageOperationType.AI_CHAT_TOKEN,
|
|
quantity: (i + 1) * 100,
|
|
unit: UsageUnit.TOKEN,
|
|
creditsUsedMicro: (i + 1) * 500,
|
|
resourceId: `agent-${i}`,
|
|
resourceContext: 'gpt-4o',
|
|
metadata: {},
|
|
timestamp: formatDateTimeForClickHouse(
|
|
new Date(now.getTime() - i * 60000),
|
|
),
|
|
}));
|
|
|
|
const applicationLogRecords = Array.from({ length: 8 }, (_, i) => ({
|
|
workspaceId: testWorkspaceId,
|
|
applicationId: 'app-1',
|
|
logicFunctionId: 'fn-1',
|
|
logicFunctionName: i % 2 === 0 ? 'sendEmail' : 'syncContacts',
|
|
executionId: `exec-${i}`,
|
|
level: i % 2 === 0 ? 'INFO' : 'ERROR',
|
|
message: `log line ${i}`,
|
|
timestamp: formatDateTimeForClickHouse(
|
|
new Date(now.getTime() - i * 60000),
|
|
),
|
|
}));
|
|
|
|
await clickHouseClient.insert({
|
|
table: 'pageview',
|
|
values: pageviewRecords,
|
|
format: 'JSONEachRow',
|
|
});
|
|
|
|
await clickHouseClient.insert({
|
|
table: 'workspaceEvent',
|
|
values: workspaceEventRecords,
|
|
format: 'JSONEachRow',
|
|
});
|
|
|
|
await clickHouseClient.insert({
|
|
table: 'objectEvent',
|
|
values: objectEventRecords,
|
|
format: 'JSONEachRow',
|
|
});
|
|
|
|
await clickHouseClient.insert({
|
|
table: 'usageEvent',
|
|
values: usageEventRecords,
|
|
format: 'JSONEachRow',
|
|
});
|
|
|
|
await clickHouseClient.insert({
|
|
table: 'applicationLog',
|
|
values: applicationLogRecords,
|
|
format: 'JSONEachRow',
|
|
});
|
|
|
|
// Wait for ClickHouse async inserts to complete
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
};
|
|
|
|
const cleanupTestData = async () => {
|
|
try {
|
|
await clickHouseClient.command({
|
|
query: `ALTER TABLE pageview DELETE WHERE workspaceId = '${testWorkspaceId}'`,
|
|
});
|
|
await clickHouseClient.command({
|
|
query: `ALTER TABLE workspaceEvent DELETE WHERE workspaceId = '${testWorkspaceId}'`,
|
|
});
|
|
await clickHouseClient.command({
|
|
query: `ALTER TABLE objectEvent DELETE WHERE workspaceId = '${testWorkspaceId}'`,
|
|
});
|
|
await clickHouseClient.command({
|
|
query: `ALTER TABLE usageEvent DELETE WHERE workspaceId = '${testWorkspaceId}'`,
|
|
});
|
|
await clickHouseClient.command({
|
|
query: `ALTER TABLE applicationLog DELETE WHERE workspaceId = '${testWorkspaceId}'`,
|
|
});
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
};
|
|
|
|
const makeEventLogsQuery = (
|
|
input: Record<string, unknown>,
|
|
token = APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
|
) => {
|
|
return client
|
|
.post('/metadata')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({
|
|
query: `
|
|
query EventLogs($input: EventLogQueryInput!) {
|
|
eventLogs(input: $input) {
|
|
records {
|
|
event
|
|
timestamp
|
|
userId
|
|
properties
|
|
recordId
|
|
objectMetadataId
|
|
isCustom
|
|
}
|
|
totalCount
|
|
pageInfo {
|
|
endCursor
|
|
hasNextPage
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: { input },
|
|
});
|
|
};
|
|
|
|
describe('querying different tables', () => {
|
|
it('should query PAGEVIEW table', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 10,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.errors).toBeUndefined();
|
|
expect(response.body.data.eventLogs).toBeDefined();
|
|
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
|
|
expect(response.body.data.eventLogs.totalCount).toBeGreaterThanOrEqual(
|
|
response.body.data.eventLogs.records.length,
|
|
);
|
|
});
|
|
|
|
it('should query WORKSPACE_EVENT table', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'WORKSPACE_EVENT',
|
|
first: 10,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.errors).toBeUndefined();
|
|
expect(response.body.data.eventLogs).toBeDefined();
|
|
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should query OBJECT_EVENT table', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'OBJECT_EVENT',
|
|
first: 10,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.errors).toBeUndefined();
|
|
expect(response.body.data.eventLogs).toBeDefined();
|
|
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
|
|
|
|
const record = response.body.data.eventLogs.records[0];
|
|
|
|
expect(record.recordId).toBeDefined();
|
|
expect(record.objectMetadataId).toBeDefined();
|
|
});
|
|
|
|
it('should query USAGE_EVENT table and fold columns into properties', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'USAGE_EVENT',
|
|
first: 10,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.errors).toBeUndefined();
|
|
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
|
|
|
|
const record = response.body.data.eventLogs.records[0];
|
|
|
|
expect(typeof record.event).toBe('string');
|
|
expect(record.event.length).toBeGreaterThan(0);
|
|
expect(record.userId).toBeDefined();
|
|
expect(record.properties.operationType).toBeDefined();
|
|
expect(record.properties.quantity).toBeDefined();
|
|
expect(record.properties.unit).toBeDefined();
|
|
expect(record.properties.creditsUsedMicro).toBeDefined();
|
|
});
|
|
|
|
it('should query APPLICATION_LOG table and fold level/message into properties', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'APPLICATION_LOG',
|
|
first: 10,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.errors).toBeUndefined();
|
|
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
|
|
|
|
const record = response.body.data.eventLogs.records[0];
|
|
|
|
expect(typeof record.event).toBe('string');
|
|
expect(record.event.length).toBeGreaterThan(0);
|
|
expect(record.properties.level).toBeDefined();
|
|
expect(record.properties.message).toBeDefined();
|
|
expect(record.properties.executionId).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('pagination', () => {
|
|
it('should return hasNextPage=true when more records exist', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 5,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.eventLogs.records.length).toBe(5);
|
|
expect(response.body.data.eventLogs.pageInfo.hasNextPage).toBe(true);
|
|
expect(response.body.data.eventLogs.pageInfo.endCursor).toBeDefined();
|
|
});
|
|
|
|
it('should fetch next page using cursor', async () => {
|
|
const firstPage = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 5,
|
|
});
|
|
|
|
expect(firstPage.body.data.eventLogs.pageInfo.hasNextPage).toBe(true);
|
|
const cursor = firstPage.body.data.eventLogs.pageInfo.endCursor;
|
|
|
|
const secondPage = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 5,
|
|
after: cursor,
|
|
});
|
|
|
|
expect(secondPage.status).toBe(200);
|
|
expect(secondPage.body.data.eventLogs.records.length).toBeGreaterThan(0);
|
|
|
|
const firstPageTimestamps = firstPage.body.data.eventLogs.records.map(
|
|
(r: { timestamp: string }) => r.timestamp,
|
|
);
|
|
const secondPageTimestamps = secondPage.body.data.eventLogs.records.map(
|
|
(r: { timestamp: string }) => r.timestamp,
|
|
);
|
|
|
|
const lastFirstPage = new Date(
|
|
firstPageTimestamps[firstPageTimestamps.length - 1],
|
|
);
|
|
const firstSecondPage = new Date(secondPageTimestamps[0]);
|
|
|
|
expect(lastFirstPage.getTime()).toBeGreaterThanOrEqual(
|
|
firstSecondPage.getTime(),
|
|
);
|
|
});
|
|
|
|
it('should return correct totalCount regardless of page size', async () => {
|
|
const smallPage = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 3,
|
|
});
|
|
|
|
const largePage = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 100,
|
|
});
|
|
|
|
expect(smallPage.body.data.eventLogs.totalCount).toBe(
|
|
largePage.body.data.eventLogs.totalCount,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('filters', () => {
|
|
describe('eventType filter', () => {
|
|
it('should filter by event type (partial match)', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 50,
|
|
filters: {
|
|
eventType: 'settings',
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
|
|
|
|
response.body.data.eventLogs.records.forEach(
|
|
(record: { event: string }) => {
|
|
expect(record.event.toLowerCase()).toContain('settings');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should filter by event type case-insensitively', async () => {
|
|
const lowerCase = await makeEventLogsQuery({
|
|
table: 'WORKSPACE_EVENT',
|
|
first: 50,
|
|
filters: { eventType: 'login' },
|
|
});
|
|
|
|
const upperCase = await makeEventLogsQuery({
|
|
table: 'WORKSPACE_EVENT',
|
|
first: 50,
|
|
filters: { eventType: 'LOGIN' },
|
|
});
|
|
|
|
expect(lowerCase.body.data.eventLogs.totalCount).toBe(
|
|
upperCase.body.data.eventLogs.totalCount,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('dateRange filter', () => {
|
|
it('should filter by start date', async () => {
|
|
const now = new Date();
|
|
const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000);
|
|
|
|
const response = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 50,
|
|
filters: {
|
|
dateRange: {
|
|
start: tenMinutesAgo.toISOString(),
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
response.body.data.eventLogs.records.forEach(
|
|
(record: { timestamp: string }) => {
|
|
expect(new Date(record.timestamp).getTime()).toBeGreaterThanOrEqual(
|
|
tenMinutesAgo.getTime(),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should filter by end date', async () => {
|
|
const now = new Date();
|
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
|
|
|
const response = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 50,
|
|
filters: {
|
|
dateRange: {
|
|
end: fiveMinutesAgo.toISOString(),
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
response.body.data.eventLogs.records.forEach(
|
|
(record: { timestamp: string }) => {
|
|
expect(new Date(record.timestamp).getTime()).toBeLessThanOrEqual(
|
|
fiveMinutesAgo.getTime(),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should filter by date range (start and end)', async () => {
|
|
const now = new Date();
|
|
const twentyMinutesAgo = new Date(now.getTime() - 20 * 60 * 1000);
|
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
|
|
|
const response = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 50,
|
|
filters: {
|
|
dateRange: {
|
|
start: twentyMinutesAgo.toISOString(),
|
|
end: fiveMinutesAgo.toISOString(),
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
response.body.data.eventLogs.records.forEach(
|
|
(record: { timestamp: string }) => {
|
|
const timestamp = new Date(record.timestamp).getTime();
|
|
|
|
expect(timestamp).toBeGreaterThanOrEqual(
|
|
twentyMinutesAgo.getTime(),
|
|
);
|
|
expect(timestamp).toBeLessThanOrEqual(fiveMinutesAgo.getTime());
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('object event specific filters', () => {
|
|
it('should filter by recordId', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'OBJECT_EVENT',
|
|
first: 50,
|
|
filters: {
|
|
recordId: 'record-0',
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
if (response.body.data.eventLogs.records.length > 0) {
|
|
response.body.data.eventLogs.records.forEach(
|
|
(record: { recordId: string }) => {
|
|
expect(record.recordId).toBe('record-0');
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
it('should filter by objectMetadataId', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'OBJECT_EVENT',
|
|
first: 50,
|
|
filters: {
|
|
objectMetadataId: 'object-meta-1',
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
if (response.body.data.eventLogs.records.length > 0) {
|
|
response.body.data.eventLogs.records.forEach(
|
|
(record: { objectMetadataId: string }) => {
|
|
expect(record.objectMetadataId).toBe('object-meta-1');
|
|
},
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('usage event specific filters', () => {
|
|
it('should filter usage events by userWorkspaceId', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'USAGE_EVENT',
|
|
first: 50,
|
|
filters: {
|
|
userWorkspaceId: testUserWorkspaceId,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
|
|
|
|
response.body.data.eventLogs.records.forEach(
|
|
(record: { userId: string }) => {
|
|
expect(record.userId).toBe(testUserWorkspaceId);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('combined filters', () => {
|
|
it('should apply multiple filters together', async () => {
|
|
const now = new Date();
|
|
const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1000);
|
|
|
|
const response = await makeEventLogsQuery({
|
|
table: 'WORKSPACE_EVENT',
|
|
first: 50,
|
|
filters: {
|
|
eventType: 'login',
|
|
dateRange: {
|
|
start: thirtyMinutesAgo.toISOString(),
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
response.body.data.eventLogs.records.forEach(
|
|
(record: { event: string; timestamp: string }) => {
|
|
expect(record.event.toLowerCase()).toContain('login');
|
|
expect(new Date(record.timestamp).getTime()).toBeGreaterThanOrEqual(
|
|
thirtyMinutesAgo.getTime(),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('record structure', () => {
|
|
it('should return properly structured pageview records', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'PAGEVIEW',
|
|
first: 1,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const record = response.body.data.eventLogs.records[0];
|
|
|
|
expect(record).toHaveProperty('event');
|
|
expect(record).toHaveProperty('timestamp');
|
|
expect(record).toHaveProperty('userId');
|
|
expect(record).toHaveProperty('properties');
|
|
expect(typeof record.event).toBe('string');
|
|
expect(typeof record.timestamp).toBe('string');
|
|
});
|
|
|
|
it('should return properly structured object event records', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'OBJECT_EVENT',
|
|
first: 1,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const record = response.body.data.eventLogs.records[0];
|
|
|
|
expect(record).toHaveProperty('event');
|
|
expect(record).toHaveProperty('timestamp');
|
|
expect(record).toHaveProperty('recordId');
|
|
expect(record).toHaveProperty('objectMetadataId');
|
|
expect(record).toHaveProperty('isCustom');
|
|
});
|
|
});
|
|
|
|
describe('permissions', () => {
|
|
it('should deny access to member role', async () => {
|
|
const response = await makeEventLogsQuery(
|
|
{ table: 'PAGEVIEW', first: 10 },
|
|
APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.errors).toBeDefined();
|
|
expect(response.body.errors[0].extensions.code).toBe('FORBIDDEN');
|
|
});
|
|
|
|
it('should deny access without authentication', async () => {
|
|
const response = await client.post('/metadata').send({
|
|
query: `
|
|
query EventLogs($input: EventLogQueryInput!) {
|
|
eventLogs(input: $input) {
|
|
records { event }
|
|
totalCount
|
|
pageInfo { hasNextPage }
|
|
}
|
|
}
|
|
`,
|
|
variables: { input: { table: 'PAGEVIEW', first: 10 } },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.errors).toBeDefined();
|
|
expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should reject invalid table name', async () => {
|
|
const response = await makeEventLogsQuery({
|
|
table: 'INVALID_TABLE' as 'PAGEVIEW',
|
|
first: 10,
|
|
});
|
|
|
|
// Invalid enum values are rejected by GraphQL validation before reaching the resolver
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.errors).toBeDefined();
|
|
});
|
|
});
|
|
});
|