|
| 1 | +/** |
| 2 | + * The plans an organization's audit-log listing must get, proven on the exact SQL `queryAuditLogs` |
| 3 | + * sends. Narrowed by nothing but a time range, each workspace is read by a keyset scan of |
| 4 | + * `audit_log_workspace_created_at_id_idx` that a page limit stops, never by collecting and sorting |
| 5 | + * the whole scope. Any other filter keeps the single scope predicate, so its plan is the one the |
| 6 | + * filter's own index drives. |
| 7 | + */ |
| 8 | +import { db } from '@sim/db' |
| 9 | +import { auditLog, user, workspace } from '@sim/db/schema' |
| 10 | +import { explainCaptured, type PlanNode } from '@sim/db/testing/explain' |
| 11 | +import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' |
| 12 | +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' |
| 13 | +import { generateId } from '@sim/utils/id' |
| 14 | +import { eq, inArray, like, sql } from 'drizzle-orm' |
| 15 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' |
| 16 | +import { |
| 17 | + type AuditLogFilterParams, |
| 18 | + type AuditLogListScope, |
| 19 | + queryAuditLogs, |
| 20 | +} from '@/lib/audit-logs/query' |
| 21 | + |
| 22 | +const organizationId = generateId() |
| 23 | +const memberId = generateId() |
| 24 | +const departedId = generateId() |
| 25 | +const workspaceId = generateId() |
| 26 | +const idPrefix = `audit_plan_${generateId()}_` |
| 27 | +const workspaceIndex = 'audit_log_workspace_created_at_id_idx' |
| 28 | +const orgLevelIndex = 'audit_log_org_level_created_at_id_idx' |
| 29 | + |
| 30 | +const cursor = Buffer.from( |
| 31 | + JSON.stringify({ createdAt: '2026-01-01T03:00:00.000Z', id: `${idPrefix}~` }) |
| 32 | +).toString('base64') |
| 33 | + |
| 34 | +const listingPlan = ( |
| 35 | + filters: AuditLogFilterParams, |
| 36 | + options: { includeDeparted?: boolean; cursor?: string } = {} |
| 37 | +) => { |
| 38 | + const scope: AuditLogListScope = { |
| 39 | + organizationId, |
| 40 | + orgWorkspaceIds: [workspaceId], |
| 41 | + orgMemberIds: [memberId], |
| 42 | + includeDeparted: options.includeDeparted ?? false, |
| 43 | + } |
| 44 | + return explainCaptured(db, () => queryAuditLogs(scope, filters, 50, options.cursor)) |
| 45 | +} |
| 46 | + |
| 47 | +/** The workspace part's keyset scans, each directly under the limit that stops it after a page. */ |
| 48 | +const limitedWorkspaceScans = (nodes: PlanNode[]) => |
| 49 | + nodes |
| 50 | + .filter((node) => node['Node Type'] === 'Limit') |
| 51 | + .map((node) => node.Plans?.[0]) |
| 52 | + .filter((child) => child?.['Index Name'] === workspaceIndex) |
| 53 | + |
| 54 | +const KEYSET_CASES: [ |
| 55 | + string, |
| 56 | + AuditLogFilterParams, |
| 57 | + { includeDeparted?: boolean; cursor?: string }, |
| 58 | +][] = [ |
| 59 | + ['no filter, current members', {}, {}], |
| 60 | + ['no filter, departed members included', {}, { includeDeparted: true }], |
| 61 | + ['no filter, after a cursor', {}, { cursor }], |
| 62 | + ['a start date', { startDate: '2026-01-01T01:00:00.000Z' }, {}], |
| 63 | + [ |
| 64 | + 'a date range, after a cursor', |
| 65 | + { startDate: '2026-01-01T01:00:00.000Z', endDate: '2026-01-01T04:00:00.000Z' }, |
| 66 | + { cursor }, |
| 67 | + ], |
| 68 | +] |
| 69 | + |
| 70 | +const NARROWED_CASES: [string, AuditLogFilterParams][] = [ |
| 71 | + ['an action', { action: 'organization.updated' }], |
| 72 | + [ |
| 73 | + 'an action and a start date', |
| 74 | + { action: 'organization.updated', startDate: '2026-01-01T01:00:00.000Z' }, |
| 75 | + ], |
| 76 | + ['a resource type', { resourceType: 'workflow' }], |
| 77 | + ['a resource', { resourceType: 'workflow', resourceId: 'resource-7' }], |
| 78 | + ['an actor', { actorId: memberId }], |
| 79 | + ['a workspace', { workspaceId }], |
| 80 | + ['a search', { search: 'fixture' }], |
| 81 | +] |
| 82 | + |
| 83 | +describe('organization audit-log listing plans in PostgreSQL', () => { |
| 84 | + beforeAll(async () => { |
| 85 | + assertDisposableTestDatabaseUrl(process.env.DATABASE_URL ?? '') |
| 86 | + await db.insert(user).values( |
| 87 | + [memberId, departedId].map((id) => ({ |
| 88 | + id, |
| 89 | + name: 'Audit plan fixture', |
| 90 | + email: `${id}@audit-plan.test`, |
| 91 | + emailVerified: true, |
| 92 | + createdAt: new Date(), |
| 93 | + updatedAt: new Date(), |
| 94 | + })) |
| 95 | + ) |
| 96 | + await insertWorkspaceFixture(db, { |
| 97 | + id: workspaceId, |
| 98 | + name: 'Audit plan fixture', |
| 99 | + ownerId: memberId, |
| 100 | + billedAccountUserId: memberId, |
| 101 | + }) |
| 102 | + // A workspace with a long history written mostly by a departed member, and workspace-less |
| 103 | + // rows of many other organizations, so collecting the scope and sorting it costs far more than |
| 104 | + // a keyset scan that stops at a page, even one that passes over departed rows to fill it. |
| 105 | + await db.execute(sql` |
| 106 | + INSERT INTO ${auditLog} (id, workspace_id, actor_id, action, resource_type, resource_id, |
| 107 | + metadata, created_at) |
| 108 | + SELECT ${idPrefix} || n, CASE WHEN n % 4 = 1 THEN ${workspaceId} END, |
| 109 | + CASE WHEN n % 20 = 1 OR n % 2 = 0 THEN ${memberId} WHEN n % 4 = 1 THEN ${departedId} END, |
| 110 | + 'organization.updated', |
| 111 | + CASE WHEN n % 997 = 0 THEN 'organization' ELSE 'workflow' END, |
| 112 | + CASE WHEN n % 997 = 0 THEN ${organizationId} ELSE 'resource-' || n END, |
| 113 | + jsonb_build_object('organizationId', |
| 114 | + CASE WHEN n % 50 = 0 THEN ${organizationId} ELSE 'other-organization-' || (n % 400) END), |
| 115 | + timestamp '2026-01-01' + n * interval '1 second' |
| 116 | + FROM generate_series(1, 20000) AS n`) |
| 117 | + await db.execute(sql`VACUUM ANALYZE ${auditLog}`) |
| 118 | + }) |
| 119 | + |
| 120 | + afterAll(async () => { |
| 121 | + await db.delete(auditLog).where(like(auditLog.id, `${idPrefix}%`)) |
| 122 | + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) |
| 123 | + await db.delete(user).where(inArray(user.id, [memberId, departedId])) |
| 124 | + }) |
| 125 | + |
| 126 | + it.each(KEYSET_CASES)( |
| 127 | + 'reads each workspace newest first through its keyset index and stops after a page (%s)', |
| 128 | + async (_label, filters, options) => { |
| 129 | + const nodes = await listingPlan(filters, options) |
| 130 | + const [scan] = limitedWorkspaceScans(nodes) |
| 131 | + expect(scan).toMatchObject({ 'Node Type': 'Index Scan', 'Scan Direction': 'Backward' }) |
| 132 | + expect(scan?.['Index Cond']).toMatch(/workspace_id = org_workspace\.workspace_id/) |
| 133 | + if (filters.startDate) expect(scan?.['Index Cond']).toMatch(/date_trunc\(.*\) >= /) |
| 134 | + if (filters.endDate) expect(scan?.['Index Cond']).toMatch(/date_trunc\(.*\) <= /) |
| 135 | + if (options.cursor) expect(scan?.['Index Cond']).toMatch(/ROW\(date_trunc/) |
| 136 | + expect(nodes.filter((node) => node['Index Name'] === orgLevelIndex)).toHaveLength(1) |
| 137 | + } |
| 138 | + ) |
| 139 | + |
| 140 | + it.each(NARROWED_CASES)( |
| 141 | + 'keeps the single scope predicate when the listing is narrowed by %s', |
| 142 | + async (_label, filters) => { |
| 143 | + const nodes = await listingPlan(filters) |
| 144 | + expect(nodes.filter((node) => node['Node Type'] === 'Function Scan')).toEqual([]) |
| 145 | + expect(nodes.filter((node) => /org_workspace/.test(node['Index Cond'] ?? ''))).toEqual([]) |
| 146 | + } |
| 147 | + ) |
| 148 | +}) |
0 commit comments