Skip to content

Commit 3533353

Browse files
authored
test(forks): assert the sync-default audit fan-out against real audit_log rows (#8418)
* test(forks): assert the sync-default audit fan-out against real audit_log rows * test(forks): match sync-default audit rows by origin and assert the exact member set * test(forks): derive the reverse fan-out expectation independently of its result
1 parent 5a1a4f3 commit 3533353

2 files changed

Lines changed: 91 additions & 52 deletions

File tree

Lines changed: 13 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { workspace } from '@sim/db/schema'
54
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
6-
import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock'
7-
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock'
5+
import { auditMock } from '@sim/testing/mocks/audit.mock'
6+
import { resetDbChainMock } from '@sim/testing/mocks/database.mock'
87
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
98
import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
109
import {
@@ -33,7 +32,6 @@ vi.mock('@/ee/workspace-forking/lib/lineage/lineage-root', () => workspaceForkin
3332
import { setForkSyncDefault } from '@/ee/workspace-forking/application/sync-default'
3433

3534
const principal = createSessionPrincipal({ userId: 'actor-1' })
36-
const LINEAGE = ['root-ws', 'fork-a', 'fork-b', 'grandchild']
3735

3836
beforeEach(() => {
3937
resetDbChainMock()
@@ -45,50 +43,21 @@ beforeEach(() => {
4543
})
4644
workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined)
4745
mockResolveForkLineageRootId.mockResolvedValue('root-ws')
48-
mockResolveForkLineageWorkspaceIds.mockResolvedValue(LINEAGE)
49-
// The live-member row lock, then the `UPDATE ... RETURNING` of the members that changed.
50-
queueTableRows(
51-
workspace,
52-
LINEAGE.map((id) => ({ id }))
53-
)
54-
dbChainMockFns.returning.mockResolvedValue(LINEAGE.map((id) => ({ id, name: `Name of ${id}` })))
5546
})
5647

57-
const run = (excludeNewWorkflows: boolean) =>
58-
setForkSyncDefault.execute({
59-
principal,
60-
input: { workspaceId: 'fork-a', excludeNewWorkflows },
61-
})
62-
48+
/**
49+
* The lineage-wide write and its audit fan-out are proven against real Postgres in
50+
* `fork-sync.integration.ts`. This covers the one branch that suite cannot stage: an unlink
51+
* committing between the root walk and the lineage lock.
52+
*/
6353
describe('setForkSyncDefault', () => {
64-
/**
65-
* One entry per member, because the default genuinely changed for all of them. A single
66-
* entry on the calling workspace would leave the other members' admins with no record.
67-
*/
68-
it("files one audit entry per updated member, in that member's own workspace and name", async () => {
69-
await run(true)
70-
const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry)
71-
expect(audited).toHaveLength(LINEAGE.length)
72-
expect(audited.map((entry) => entry.resourceId).sort()).toEqual([...LINEAGE].sort())
73-
for (const entry of audited) {
74-
expect(entry.action).toBe('workspace.fork_sync_default_changed')
75-
// Filed in the workspace it describes. Without an explicit workspaceId the wrapper
76-
// defaults it to the caller's workspace, so every entry would pile into one log and
77-
// the other members' admins would see nothing.
78-
expect(entry.workspaceId).toBe(entry.resourceId)
79-
// The member's OWN name, not a raw id and not the caller's name.
80-
expect(entry.resourceName).toBe(`Name of ${entry.resourceId}`)
81-
expect(entry.metadata).toMatchObject({
82-
forkSyncNewWorkflowsExcluded: true,
83-
originWorkspaceId: 'fork-a',
84-
originWorkspaceName: 'Fork A',
85-
})
86-
}
87-
})
88-
89-
/** An unlink that moved the caller out of the locked root's lineage must not be written. */
9054
it('refuses when the locked root no longer reaches the calling workspace', async () => {
9155
mockResolveForkLineageWorkspaceIds.mockResolvedValue(['root-ws', 'fork-b'])
92-
await expect(run(true)).rejects.toMatchObject({ statusCode: 409 })
56+
await expect(
57+
setForkSyncDefault.execute({
58+
principal,
59+
input: { workspaceId: 'fork-a', excludeNewWorkflows: true },
60+
})
61+
).rejects.toMatchObject({ statusCode: 409 })
9362
})
9463
})

‎apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts‎

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { AuditAction } from '@sim/audit'
12
import { db } from '@sim/db'
23
import {
4+
auditLog,
35
folder,
46
outboxEvent,
57
permissions,
@@ -15,8 +17,8 @@ import {
1517
workspaceSandbox,
1618
} from '@sim/db/schema'
1719
import { generateId } from '@sim/utils/id'
18-
import { and, eq } from 'drizzle-orm'
19-
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
20+
import { and, eq, inArray, sql } from 'drizzle-orm'
21+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
2022
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
2123
import { processOutboxEventById } from '@/lib/core/outbox/service'
2224
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
@@ -765,9 +767,10 @@ describe('authorized fork and sync against PostgreSQL', () => {
765767

766768
/**
767769
* The opt-in policy end to end: set from a fork, it reaches the parent and changes only
768-
* what differs; a genuinely new workflow (created, duplicated, or a fork's starter) and a
769-
* new fork take it; a forked copy stays synced; no existing workflow moves; and an archived
770-
* member is walked through for the lineage root but never written.
770+
* what differs, filing one audit entry in each changed workspace's own log; a genuinely
771+
* new workflow (created, duplicated, or a fork's starter) and a new fork take it; a forked
772+
* copy stays synced; no existing workflow moves; and an archived member is walked through
773+
* for the lineage root but never written.
771774
*/
772775
it('gives new workflows the lineage fork-sync default while copies stay synced', async () => {
773776
const childId = await createChild()
@@ -787,13 +790,64 @@ describe('authorized fork and sync against PostgreSQL', () => {
787790
)[0]?.excluded
788791
const setDefault = (workspaceId: string, excludeNewWorkflows: boolean) =>
789792
setForkSyncDefault.execute({ principal, input: { workspaceId, excludeNewWorkflows } })
793+
/** Every audit entry a change issued from `originId` filed, whichever workspace it named. */
794+
const auditedFrom = (originId: string) =>
795+
db
796+
.select({
797+
workspaceId: auditLog.workspaceId,
798+
resourceId: auditLog.resourceId,
799+
resourceName: auditLog.resourceName,
800+
metadata: auditLog.metadata,
801+
})
802+
.from(auditLog)
803+
.where(
804+
and(
805+
eq(auditLog.action, AuditAction.WORKSPACE_FORK_SYNC_DEFAULT_CHANGED),
806+
sql`${auditLog.metadata} ->> 'originWorkspaceId' = ${originId}`
807+
)
808+
)
790809
try {
791810
const first = await setDefault(childId, true)
792811
expect(first.changedWorkspaces.map((member) => member.id)).toEqual(
793812
expect.arrayContaining([sourceWorkspaceId, childId])
794813
)
795814
expect((await setDefault(childId, true)).changedWorkspaces).toEqual([])
796815
expect(await policyOf(sourceWorkspaceId)).toBe(true)
816+
817+
// Each changed member's admins see the change in their own log, under that workspace's name.
818+
const changed = new Map(
819+
(
820+
await db
821+
.select({ id: workspace.id, name: workspace.name })
822+
.from(workspace)
823+
.where(
824+
inArray(
825+
workspace.id,
826+
first.changedWorkspaces.map((member) => member.id)
827+
)
828+
)
829+
).map((member) => [member.id, member.name])
830+
)
831+
await vi.waitFor(
832+
async () => {
833+
const entries = await auditedFrom(childId)
834+
// Exactly the changed members, once each: no missing, duplicate, or extra entry,
835+
// including from the no-op repeat issued from the same workspace.
836+
expect(entries.map((entry) => entry.resourceId).sort()).toEqual(
837+
[...changed.keys()].sort()
838+
)
839+
for (const entry of entries) {
840+
expect(entry.workspaceId).toBe(entry.resourceId)
841+
expect(entry.resourceName).toBe(changed.get(entry.resourceId!))
842+
expect(entry.metadata).toMatchObject({
843+
forkSyncNewWorkflowsExcluded: true,
844+
originWorkspaceId: childId,
845+
originWorkspaceName: changed.get(childId),
846+
})
847+
}
848+
},
849+
{ timeout: 5000 }
850+
)
797851
expect(await excludedFor(sourceWorkflowId)).toBe(false)
798852

799853
const [copy] = await db
@@ -838,10 +892,26 @@ describe('authorized fork and sync against PostgreSQL', () => {
838892
).toEqual([{ excluded: true }])
839893

840894
await db.update(workspace).set({ archivedAt: new Date() }).where(eq(workspace.id, childId))
841-
await setDefault(grandchildId, false)
842-
expect(await policyOf(sourceWorkspaceId)).toBe(false)
843-
expect(await policyOf(grandchildId)).toBe(false)
895+
const fromGrandchild = await setDefault(grandchildId, false)
896+
// Every live member flips back - the ones the first change covered and the two forks
897+
// created since - while the archived child is neither written nor audited.
898+
const expectedFromGrandchild = [
899+
...[...changed.keys()].filter((id) => id !== childId),
900+
newForkId,
901+
grandchildId,
902+
].sort()
903+
expect(fromGrandchild.changedWorkspaces.map((member) => member.id).sort()).toEqual(
904+
expectedFromGrandchild
905+
)
906+
for (const id of expectedFromGrandchild) expect(await policyOf(id)).toBe(false)
844907
expect(await policyOf(childId)).toBe(true)
908+
await vi.waitFor(
909+
async () => {
910+
const entries = await auditedFrom(grandchildId)
911+
expect(entries.map((entry) => entry.resourceId).sort()).toEqual(expectedFromGrandchild)
912+
},
913+
{ timeout: 5000 }
914+
)
845915
} finally {
846916
await db.update(workspace).set({ archivedAt: null }).where(eq(workspace.id, childId))
847917
await setDefault(sourceWorkspaceId, false)

0 commit comments

Comments
 (0)