Skip to content

Commit 068422b

Browse files
authored
refactor(audit): derive updatedFields through one shared helper (#6604)
* refactor(audit): derive updatedFields through one shared helper Six copies of Object.keys(updateData).filter(k => k !== 'updatedAt') across four files decided, independently, which columns an audit row reports. The exclusion set is an audit convention, not a local detail, so it moves to @sim/audit as auditUpdatedFields and the exclusion becomes a single edit. The admin organizations route evaluated the expression twice in one handler and filed it under the metadata key `fields` while every other site uses `updatedFields`, so any consumer filtering on updatedFields silently missed org updates. It now computes once and uses the shared key; nothing reads metadata.fields. auditMock carries the real implementation rather than a stub, since callers under test derive their audit metadata through it. The two suites that hand-roll an @sim/audit factory source it from there. * test(audit): pin the testing mock's copy of auditUpdatedFields @sim/audit devDepends on @sim/testing, so the mock cannot import the real helper without closing a package cycle. Assert parity from the audit side instead, where the dependency already runs the safe direction, so a change to the exclusion convention cannot leave mocked callers validating behavior the deployed helper no longer has.
1 parent a40e379 commit 068422b

10 files changed

Lines changed: 95 additions & 14 deletions

File tree

apps/sim/app/api/v1/admin/organizations/[id]/route.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@
3030
* Response: AdminSingleResponse<{ success, organizationId, slug, membersRemoved, workspacesDetached }>
3131
*/
3232

33-
import { AuditAction, AuditResourceType, recordAudit, recordAuditBatch } from '@sim/audit'
33+
import {
34+
AuditAction,
35+
AuditResourceType,
36+
auditUpdatedFields,
37+
recordAudit,
38+
recordAuditBatch,
39+
} from '@sim/audit'
3440
import { db } from '@sim/db'
3541
import { member, organization, subscription } from '@sim/db/schema'
3642
import { createLogger } from '@sim/logger'
@@ -178,9 +184,8 @@ export const PATCH = withRouteHandler(
178184
.where(eq(organization.id, organizationId))
179185
.returning()
180186

181-
logger.info(`Admin API: Updated organization ${organizationId}`, {
182-
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
183-
})
187+
const updatedFields = auditUpdatedFields(updateData)
188+
logger.info(`Admin API: Updated organization ${organizationId}`, { updatedFields })
184189

185190
recordAudit({
186191
workspaceId: null,
@@ -190,7 +195,7 @@ export const PATCH = withRouteHandler(
190195
resourceId: organizationId,
191196
resourceName: updated.name,
192197
description: `Admin API updated organization "${updated.name}"`,
193-
metadata: { fields: Object.keys(updateData).filter((k) => k !== 'updatedAt') },
198+
metadata: { updatedFields },
194199
request,
195200
})
196201

apps/sim/lib/credentials/orchestration/index.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
4+
import {
5+
auditMock,
6+
dbChainMockFns,
7+
queueTableRows,
8+
resetDbChainMock,
9+
schemaMock,
10+
} from '@sim/testing'
511
import { beforeEach, describe, expect, it, vi } from 'vitest'
612

713
const {
@@ -26,6 +32,7 @@ vi.mock('@sim/audit', () => ({
2632
AuditAction: { CREDENTIAL_UPDATED: 'credential.updated' },
2733
AuditResourceType: { CREDENTIAL: 'credential' },
2834
recordAudit: mockRecordAudit,
35+
auditUpdatedFields: auditMock.auditUpdatedFields,
2936
}))
3037
vi.mock('@/lib/credentials/access', () => ({
3138
getCredentialActorContext: mockGetCredentialActorContext,

apps/sim/lib/credentials/orchestration/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
1+
import { AuditAction, AuditResourceType, auditUpdatedFields, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { credential, environment, webhook, workspaceEnvironment } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
@@ -336,7 +336,7 @@ export async function performUpdateCredential(
336336
.where(and(eq(webhook.provider, 'slack'), eq(webhook.routingKey, params.credentialId)))
337337
}
338338

339-
const updatedFields = Object.keys(updates).filter((key) => key !== 'updatedAt')
339+
const updatedFields = auditUpdatedFields(updates)
340340
recordAudit({
341341
workspaceId: access.credential.workspaceId,
342342
actorId: params.userId,

apps/sim/lib/mcp/orchestration/server-lifecycle.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
1+
import { AuditAction, AuditResourceType, auditUpdatedFields, recordAudit } from '@sim/audit'
22
import { db, mcpServers } from '@sim/db'
33
import { mcpServerOauth } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
@@ -400,7 +400,7 @@ export async function updateMcpServer(
400400
success: true,
401401
server,
402402
configurationChanged: shouldClearCache,
403-
updatedFields: Object.keys(updateData).filter((key) => key !== 'updatedAt'),
403+
updatedFields: auditUpdatedFields(updateData),
404404
}
405405
} catch (error) {
406406
logger.error('Failed to update MCP server', { error })

apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
4+
import { auditMock, dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
vi.mock('@sim/audit', () => ({
@@ -14,6 +14,7 @@ vi.mock('@sim/audit', () => ({
1414
MCP_TOOL: 'mcp_tool',
1515
},
1616
recordAudit: vi.fn(),
17+
auditUpdatedFields: auditMock.auditUpdatedFields,
1718
}))
1819
vi.mock('@sim/db', () => ({
1920
...dbChainMock,

apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
1+
import { AuditAction, AuditResourceType, auditUpdatedFields, recordAudit } from '@sim/audit'
22
import { db, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db'
33
import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
@@ -549,7 +549,7 @@ export async function performUpdateWorkflowMcpServer(
549549
if (params.description !== undefined) updateData.description = params.description?.trim() || null
550550
if (params.isPublic !== undefined) updateData.isPublic = params.isPublic
551551

552-
const updatedFields = Object.keys(updateData).filter((key) => key !== 'updatedAt')
552+
const updatedFields = auditUpdatedFields(updateData)
553553

554554
try {
555555
const [server] = await db
@@ -936,7 +936,7 @@ export async function performUpdateWorkflowMcpTool(
936936
updateData.parameterSchema = applyDescriptionOverrides(baseSchema, overrides)
937937
}
938938

939-
const updatedFields = Object.keys(updateData).filter((key) => key !== 'updatedAt')
939+
const updatedFields = auditUpdatedFields(updateData)
940940

941941
const tool = await db.transaction(async (tx) => {
942942
await acquireWorkflowMcpServerLock(tx, params.serverId)

packages/audit/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { recordAudit, recordAuditBatch } from './log'
22
export type { AuditActionType, AuditResourceTypeValue } from './types'
33
export { AuditAction, AuditResourceType } from './types'
4+
export { auditUpdatedFields } from './updated-fields'
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { auditMock } from '@sim/testing'
2+
import { describe, expect, it } from 'vitest'
3+
import { auditUpdatedFields } from './updated-fields'
4+
5+
describe('auditUpdatedFields', () => {
6+
it('returns the written columns', () => {
7+
expect(auditUpdatedFields({ name: 'Renamed', url: 'https://example.com' })).toEqual([
8+
'name',
9+
'url',
10+
])
11+
})
12+
13+
it('drops updatedAt, which every write moves', () => {
14+
expect(auditUpdatedFields({ name: 'Renamed', updatedAt: new Date() })).toEqual(['name'])
15+
})
16+
17+
it('keeps columns explicitly written as null — clearing a value is a change', () => {
18+
expect(auditUpdatedFields({ lastConnected: null, lastError: null })).toEqual([
19+
'lastConnected',
20+
'lastError',
21+
])
22+
})
23+
24+
it('returns an empty list when only updatedAt was written', () => {
25+
expect(auditUpdatedFields({ updatedAt: new Date() })).toEqual([])
26+
})
27+
28+
/**
29+
* `auditMock` carries its own copy because `@sim/audit` devDepends on
30+
* `@sim/testing` — importing the real helper there would close a package
31+
* cycle. The copy is pinned from this side instead, where the dependency
32+
* already runs the safe direction.
33+
*/
34+
it('stays in step with the copy @sim/testing hands to mocked callers', () => {
35+
const cases: object[] = [
36+
{ name: 'Renamed', url: 'https://example.com' },
37+
{ name: 'Renamed', updatedAt: new Date() },
38+
{ lastConnected: null, lastError: null },
39+
{ updatedAt: new Date() },
40+
{},
41+
]
42+
for (const updateValues of cases) {
43+
expect(auditMock.auditUpdatedFields(updateValues)).toEqual(auditUpdatedFields(updateValues))
44+
}
45+
})
46+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Columns a write touched, ready for an audit row's `updatedFields` metadata.
3+
*
4+
* Pass the update object the write actually applied — never the caller's
5+
* params. A param is not a write: an unchanged value still arrives, and a
6+
* writer often sets columns nobody asked for (a status reset forced by some
7+
* other change). Deriving names from input is what makes audit rows name fields
8+
* that were never written and omit the ones that were.
9+
*
10+
* `updatedAt` is excluded because every write moves it, so it is noise in every
11+
* row. Keeping that rule here means it is one edit if the set ever grows.
12+
*/
13+
export function auditUpdatedFields(updateValues: object): string[] {
14+
return Object.keys(updateValues).filter((key) => key !== 'updatedAt')
15+
}

packages/testing/src/mocks/audit.mock.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export const auditMockFns = {
2828
export const auditMock = {
2929
recordAudit: auditMockFns.mockRecordAudit,
3030
recordAuditBatch: auditMockFns.mockRecordAuditBatch,
31+
/**
32+
* Real implementation, not a stub: callers under test derive their audit
33+
* metadata through it, so stubbing it would erase what the test asserts.
34+
*/
35+
auditUpdatedFields: (updateValues: object): string[] =>
36+
Object.keys(updateValues).filter((key) => key !== 'updatedAt'),
3137
AuditAction: {
3238
API_KEY_CREATED: 'api_key.created',
3339
API_KEY_UPDATED: 'api_key.updated',

0 commit comments

Comments
 (0)