From 54467130b5b98de23a9a015afc365d6494f0b987 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 23:33:58 -0700 Subject: [PATCH 1/3] feat(mothership): erase private memory when a member leaves or an organization is deleted Account deletion already queues the worker's memory cleanup. Removing a member, removing an external collaborator from an organization's workspaces, moving a member to another organization and deleting an organization now queue it too, in the same transaction, with the worker's new organization_member and organization targets. --- .../membership-external-removal.test.ts | 56 ++++++++++++++++++- .../lib/billing/organizations/membership.ts | 10 ++++ .../generated/memory-notes-access.ts | 8 +++ .../organizations/resource-cleanup.test.ts | 16 +++++- .../sim/lib/organizations/resource-cleanup.ts | 6 +- 5 files changed, 93 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/billing/organizations/membership-external-removal.test.ts b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts index 2634d05a351..f4d4e433680 100644 --- a/apps/sim/lib/billing/organizations/membership-external-removal.test.ts +++ b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts @@ -1,23 +1,77 @@ import { member } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' import { organizationMemberLimitsMock, organizationMemberLimitsMockFns, } from '@sim/testing/mocks/organization-member-limits.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/billing/organizations/member-limits', () => organizationMemberLimitsMock) +vi.mock('@/lib/core/outbox/service', () => outboxServiceMock) import { removeExternalUserFromOrganizationWorkspaces, removeUserFromOrganization, } from '@/lib/billing/organizations/membership' +import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event' const mockSetOrgMemberUsageLimit = organizationMemberLimitsMockFns.mockSetOrgMemberUsageLimit +const { mockEnqueueOutboxEvents } = outboxServiceMockFns + +/** The private-memory erasure a removal queues for the departing user (D106 in Mothership). */ +function memberErasure(userId: string) { + return [ + expect.anything(), + MEMORY_CLEANUP_EVENT, + [ + expect.objectContaining({ + request: { + version: 1, + target: { kind: 'organization_member', organizationId: 'org-1', userId }, + }, + }), + ], + ] +} describe('external organization access removal', () => { beforeEach(() => { resetDbChainMock() + setEnv({ COPILOT_API_KEY: 'test-key' }) + }) + + afterAll(() => { + resetEnvMock() + }) + + it("erases the departing user's private Mothership memory in the organization", async () => { + await removeExternalUserFromOrganizationWorkspaces({ + organizationId: 'org-1', + userId: 'external-user', + }) + expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(...memberErasure('external-user')) + + queueTableRows(member, [{ id: 'membership', userId: 'target', role: 'member' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'membership' }]) + await removeUserFromOrganization({ + organizationId: 'org-1', + userId: 'target', + memberId: 'membership', + skipBillingLogic: true, + onError: 'throw', + }) + expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(...memberErasure('target')) + }) + + it('erases nothing when a removal does not commit', async () => { + queueTableRows(member, []) + queueTableRows(member, [{ id: 'new-membership' }]) + await expect( + removeExternalUserFromOrganizationWorkspaces({ organizationId: 'org-1', userId: 'external' }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockEnqueueOutboxEvents).not.toHaveBeenCalled() }) it('clears a stale per-user organization usage cap in the removal transaction', async () => { diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 127a9b26e0c..a76ec3243e2 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -61,6 +61,7 @@ import { isRetryableTransactionError } from '@/lib/db/transaction' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { type StoppedRun, stopLiveRunsTx } from '@/lib/mothership/async-runs/repository' +import { enqueueMemoryCleanup } from '@/lib/mothership/memories/cleanup-outbox' import { requireMemberManagementAuthority } from '@/lib/organizations/members/authority' import { revokePersonalApiKeysTx, @@ -1192,6 +1193,13 @@ export async function transferUserBetweenOrganizations( if (deleted.length === 0) { throw new Error('Member could not be transferred because their role changed') } + await enqueueMemoryCleanup(tx, [ + { + kind: 'organization_member', + organizationId: params.sourceOrganizationId, + userId: params.userId, + }, + ]) const cancelledInvitations = invitationIds.length ? await tx @@ -1407,6 +1415,7 @@ export async function removeUserFromOrganization( 'The membership changed before removal. Refresh and try again.' ) } + await enqueueMemoryCleanup(tx, [{ kind: 'organization_member', organizationId, userId }]) if (!skipBillingLogic) { await enqueueOutboxEvent(tx, MEMBER_BILLING_RECONCILIATION_EVENT_TYPE, { @@ -1679,6 +1688,7 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: { ) await setOrgMemberUsageLimit(organizationId, userId, null, undefined, tx) + await enqueueMemoryCleanup(tx, [{ kind: 'organization_member', organizationId, userId }]) const cancelledInvitations = invitationIds.length ? await tx diff --git a/apps/sim/lib/mothership/generated/memory-notes-access.ts b/apps/sim/lib/mothership/generated/memory-notes-access.ts index b1c414e4986..94361b84b4f 100644 --- a/apps/sim/lib/mothership/generated/memory-notes-access.ts +++ b/apps/sim/lib/mothership/generated/memory-notes-access.ts @@ -53,6 +53,14 @@ export const MemoryCleanupInput = z.strictObject({ z.strictObject({ kind: z.literal("private_owner"), userId: z.string().min(1).max(200) }), z.strictObject({ kind: z.literal("workspace"), workspaceId: z.uuid() }), z.strictObject({ kind: z.literal("project"), projectId: z.uuid() }), + // Erase private memory held in an organization: all of it when the organization is + // deleted, one person's when they leave it (D106). + z.strictObject({ kind: z.literal("organization"), organizationId: z.string().min(1).max(200) }), + z.strictObject({ + kind: z.literal("organization_member"), + organizationId: z.string().min(1).max(200), + userId: z.string().min(1).max(200), + }), ]), }); export type MemoryCleanupInput = z.infer; diff --git a/apps/sim/lib/organizations/resource-cleanup.test.ts b/apps/sim/lib/organizations/resource-cleanup.test.ts index 5002b217e0c..e6700ef7751 100644 --- a/apps/sim/lib/organizations/resource-cleanup.test.ts +++ b/apps/sim/lib/organizations/resource-cleanup.test.ts @@ -12,6 +12,7 @@ vi.mock('@/lib/cleanup/chat-cleanup', () => chatCleanupMock) vi.mock('@/lib/core/outbox/service', () => outboxServiceMock) import type { OutboxEventContext } from '@/lib/core/outbox/service' +import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event' import { enqueueOrganizationResourceCleanup, organizationResourceCleanupOutboxHandlers, @@ -20,7 +21,7 @@ import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resourc const { mockDeleteFile } = storageServiceMockFns const { mockCleanupCopilotBackend } = chatCleanupMockFns -const { mockEnqueueOutboxEvent } = outboxServiceMockFns +const { mockEnqueueOutboxEvent, mockEnqueueOutboxEvents } = outboxServiceMockFns const ORG_ID = 'org-1' const CHAT_ID = '10000000-0000-4000-8000-000000000001' @@ -60,6 +61,19 @@ afterAll(() => { }) describe('enqueueOrganizationResourceCleanup', () => { + it("erases the organization's private Mothership memory in the delete transaction", async () => { + queueTableRows(organization, [{ id: ORG_ID }]) + + await db.transaction(async (tx) => { + await enqueueOrganizationResourceCleanup(tx, ORG_ID) + expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(tx, MEMORY_CLEANUP_EVENT, [ + expect.objectContaining({ + request: { version: 1, target: { kind: 'organization', organizationId: ORG_ID } }, + }), + ]) + }) + }) + it('locks the organization and snapshots only canonical direct org resources inside the caller transaction', async () => { queueTableRows(organization, [{ id: ORG_ID }]) queueTableRows(workspaceFiles, [ diff --git a/apps/sim/lib/organizations/resource-cleanup.ts b/apps/sim/lib/organizations/resource-cleanup.ts index d6eb271cc10..0ff0ee39d4a 100644 --- a/apps/sim/lib/organizations/resource-cleanup.ts +++ b/apps/sim/lib/organizations/resource-cleanup.ts @@ -8,6 +8,7 @@ import { env } from '@/lib/core/config/env' import { enqueueOutboxEvent, type OutboxHandlerRegistry } from '@/lib/core/outbox/service' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import type { DbTransaction } from '@/lib/db/types' +import { enqueueMemoryCleanup } from '@/lib/mothership/memories/cleanup-outbox' import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resource-cleanup-event' import { deleteFile } from '@/lib/uploads/core/storage-service' @@ -35,7 +36,8 @@ const organizationResourceCleanupSchema = z.discriminatedUnion('kind', [ * Captures canonical bindings before the org FK cascade removes them. The org * row lock excludes new direct resources; a rebound resource is checked again * by the worker. Each keyset page becomes its own bounded outbox event in the - * caller's delete transaction, so rollback cannot leave cleanup work behind. + * caller's delete transaction, so rollback cannot leave cleanup work behind. The + * members' private Mothership memory in the organization is erased the same way. */ export async function enqueueOrganizationResourceCleanup( tx: DbTransaction, @@ -105,6 +107,8 @@ export async function enqueueOrganizationResourceCleanup( chatCursor = chats[chats.length - 1].id if (chats.length < CLEANUP_BATCH_SIZE) break } + + await enqueueMemoryCleanup(tx, [{ kind: 'organization', organizationId }]) } export const organizationResourceCleanupOutboxHandlers = { From 7416a7fbafd79cdec90f5f030471c1691c2c7bf0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:04:21 -0700 Subject: [PATCH 2/3] test(mothership): prove offboarding queues memory erasure with a real database The unit tests only checked mocked outbox calls. The integration test runs member removal, external removal and organization cleanup against Postgres: each committed change queues its erasure, a rolled-back one queues nothing. --- .../membership-external-removal.test.ts | 56 +------- .../memories/offboarding.integration.ts | 124 ++++++++++++++++++ .../organizations/resource-cleanup.test.ts | 16 +-- 3 files changed, 126 insertions(+), 70 deletions(-) create mode 100644 apps/sim/lib/mothership/memories/offboarding.integration.ts diff --git a/apps/sim/lib/billing/organizations/membership-external-removal.test.ts b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts index f4d4e433680..2634d05a351 100644 --- a/apps/sim/lib/billing/organizations/membership-external-removal.test.ts +++ b/apps/sim/lib/billing/organizations/membership-external-removal.test.ts @@ -1,77 +1,23 @@ import { member } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' -import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' import { organizationMemberLimitsMock, organizationMemberLimitsMockFns, } from '@sim/testing/mocks/organization-member-limits.mock' -import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/billing/organizations/member-limits', () => organizationMemberLimitsMock) -vi.mock('@/lib/core/outbox/service', () => outboxServiceMock) import { removeExternalUserFromOrganizationWorkspaces, removeUserFromOrganization, } from '@/lib/billing/organizations/membership' -import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event' const mockSetOrgMemberUsageLimit = organizationMemberLimitsMockFns.mockSetOrgMemberUsageLimit -const { mockEnqueueOutboxEvents } = outboxServiceMockFns - -/** The private-memory erasure a removal queues for the departing user (D106 in Mothership). */ -function memberErasure(userId: string) { - return [ - expect.anything(), - MEMORY_CLEANUP_EVENT, - [ - expect.objectContaining({ - request: { - version: 1, - target: { kind: 'organization_member', organizationId: 'org-1', userId }, - }, - }), - ], - ] -} describe('external organization access removal', () => { beforeEach(() => { resetDbChainMock() - setEnv({ COPILOT_API_KEY: 'test-key' }) - }) - - afterAll(() => { - resetEnvMock() - }) - - it("erases the departing user's private Mothership memory in the organization", async () => { - await removeExternalUserFromOrganizationWorkspaces({ - organizationId: 'org-1', - userId: 'external-user', - }) - expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(...memberErasure('external-user')) - - queueTableRows(member, [{ id: 'membership', userId: 'target', role: 'member' }]) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'membership' }]) - await removeUserFromOrganization({ - organizationId: 'org-1', - userId: 'target', - memberId: 'membership', - skipBillingLogic: true, - onError: 'throw', - }) - expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(...memberErasure('target')) - }) - - it('erases nothing when a removal does not commit', async () => { - queueTableRows(member, []) - queueTableRows(member, [{ id: 'new-membership' }]) - await expect( - removeExternalUserFromOrganizationWorkspaces({ organizationId: 'org-1', userId: 'external' }) - ).rejects.toMatchObject({ code: 'conflict' }) - expect(mockEnqueueOutboxEvents).not.toHaveBeenCalled() }) it('clears a stale per-user organization usage cap in the removal transaction', async () => { diff --git a/apps/sim/lib/mothership/memories/offboarding.integration.ts b/apps/sim/lib/mothership/memories/offboarding.integration.ts new file mode 100644 index 00000000000..b7e408dae33 --- /dev/null +++ b/apps/sim/lib/mothership/memories/offboarding.integration.ts @@ -0,0 +1,124 @@ +import { db } from '@sim/db' +import { member, organization, outboxEvent, user } from '@sim/db/schema' +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeAll, expect, it } from 'vitest' + +readTestDatabaseUrl() + +const ownerId = generateId() +const memberId = generateId() +const externalId = generateId() +const organizationId = generateId() +const membershipId = generateId() +let runtime: { + membership: typeof import('@/lib/billing/organizations/membership') + resources: typeof import('@/lib/organizations/resource-cleanup') + event: typeof import('@/lib/mothership/memories/cleanup-event') +} + +beforeAll(async () => { + // The cleanup outbox reads its worker credential and destination when it loads. + process.env.SIM_AGENT_API_URL = 'http://127.0.0.1:9' + process.env.COPILOT_API_KEY = 'memory-offboarding-fixture-key' + const [membership, resources, event] = await Promise.all([ + import('@/lib/billing/organizations/membership'), + import('@/lib/organizations/resource-cleanup'), + import('@/lib/mothership/memories/cleanup-event'), + ]) + runtime = { membership, resources, event } + await db.insert(user).values( + [ownerId, memberId, externalId].map((id) => ({ + id, + name: 'Offboarding fixture', + email: `${id}@memory.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Offboarding', slug: organizationId }) + await db.insert(member).values([ + { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() }, + { id: membershipId, organizationId, userId: memberId, role: 'member', createdAt: new Date() }, + ]) +}, 60_000) + +afterAll(async () => { + await db + .delete(outboxEvent) + .where( + and( + eq(outboxEvent.eventType, runtime.event.MEMORY_CLEANUP_EVENT), + sql`${outboxEvent.payload}->'request'->'target'->>'organizationId' = ${organizationId}` + ) + ) + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [ownerId, memberId, externalId])) +}) + +/** The memory erasures queued for this organization, as `kind:userId` (or `kind`). */ +async function queued(): Promise { + const rows = await db + .select({ payload: outboxEvent.payload }) + .from(outboxEvent) + .where( + and( + eq(outboxEvent.eventType, runtime.event.MEMORY_CLEANUP_EVENT), + sql`${outboxEvent.payload}->'request'->'target'->>'organizationId' = ${organizationId}` + ) + ) + return rows + .map(({ payload }) => { + const target = (payload as { request: { target: { kind: string; userId?: string } } }).request + .target + return target.userId ? `${target.kind}:${target.userId}` : target.kind + }) + .sort() +} + +it('queues private memory erasure with each committed departure and organization deletion, never on rollback', async () => { + // A removal whose actor lacks authority rolls back with nothing queued. + await expect( + runtime.membership.removeUserFromOrganization({ + organizationId, + userId: memberId, + memberId: membershipId, + actorUserId: externalId, + skipBillingLogic: true, + onError: 'throw', + }) + ).rejects.toThrow() + expect(await queued()).toEqual([]) + + await runtime.membership.removeUserFromOrganization({ + organizationId, + userId: memberId, + memberId: membershipId, + skipBillingLogic: true, + onError: 'throw', + }) + await runtime.membership.removeExternalUserFromOrganizationWorkspaces({ + organizationId, + userId: externalId, + }) + expect(await queued()).toEqual( + [`organization_member:${externalId}`, `organization_member:${memberId}`].sort() + ) + + // Organization deletion queues the erasure in the delete transaction: a rollback drops it. + await expect( + db.transaction(async (tx) => { + await runtime.resources.enqueueOrganizationResourceCleanup(tx, organizationId) + throw new Error('rollback fixture') + }) + ).rejects.toThrow('rollback fixture') + expect(await queued()).not.toContain('organization') + await db.transaction(async (tx) => { + await runtime.resources.enqueueOrganizationResourceCleanup(tx, organizationId) + }) + expect(await queued()).toContain('organization') +}, 30_000) diff --git a/apps/sim/lib/organizations/resource-cleanup.test.ts b/apps/sim/lib/organizations/resource-cleanup.test.ts index e6700ef7751..5002b217e0c 100644 --- a/apps/sim/lib/organizations/resource-cleanup.test.ts +++ b/apps/sim/lib/organizations/resource-cleanup.test.ts @@ -12,7 +12,6 @@ vi.mock('@/lib/cleanup/chat-cleanup', () => chatCleanupMock) vi.mock('@/lib/core/outbox/service', () => outboxServiceMock) import type { OutboxEventContext } from '@/lib/core/outbox/service' -import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event' import { enqueueOrganizationResourceCleanup, organizationResourceCleanupOutboxHandlers, @@ -21,7 +20,7 @@ import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resourc const { mockDeleteFile } = storageServiceMockFns const { mockCleanupCopilotBackend } = chatCleanupMockFns -const { mockEnqueueOutboxEvent, mockEnqueueOutboxEvents } = outboxServiceMockFns +const { mockEnqueueOutboxEvent } = outboxServiceMockFns const ORG_ID = 'org-1' const CHAT_ID = '10000000-0000-4000-8000-000000000001' @@ -61,19 +60,6 @@ afterAll(() => { }) describe('enqueueOrganizationResourceCleanup', () => { - it("erases the organization's private Mothership memory in the delete transaction", async () => { - queueTableRows(organization, [{ id: ORG_ID }]) - - await db.transaction(async (tx) => { - await enqueueOrganizationResourceCleanup(tx, ORG_ID) - expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(tx, MEMORY_CLEANUP_EVENT, [ - expect.objectContaining({ - request: { version: 1, target: { kind: 'organization', organizationId: ORG_ID } }, - }), - ]) - }) - }) - it('locks the organization and snapshots only canonical direct org resources inside the caller transaction', async () => { queueTableRows(organization, [{ id: ORG_ID }]) queueTableRows(workspaceFiles, [ From 0f6cd9711469679d3fd3bc9cc2ca08c93d4db924 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 00:11:12 -0700 Subject: [PATCH 3/3] test(mothership): cover a successful external collaborator removal --- .../memories/offboarding.integration.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/mothership/memories/offboarding.integration.ts b/apps/sim/lib/mothership/memories/offboarding.integration.ts index b7e408dae33..8b5bf8a8f5e 100644 --- a/apps/sim/lib/mothership/memories/offboarding.integration.ts +++ b/apps/sim/lib/mothership/memories/offboarding.integration.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' -import { member, organization, outboxEvent, user } from '@sim/db/schema' +import { member, organization, outboxEvent, permissions, user, workspace } from '@sim/db/schema' import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { generateId } from '@sim/utils/id' import { and, eq, inArray, sql } from 'drizzle-orm' import { afterAll, beforeAll, expect, it } from 'vitest' @@ -12,6 +13,7 @@ const memberId = generateId() const externalId = generateId() const organizationId = generateId() const membershipId = generateId() +const workspaceId = generateId() let runtime: { membership: typeof import('@/lib/billing/organizations/membership') resources: typeof import('@/lib/organizations/resource-cleanup') @@ -45,6 +47,22 @@ beforeAll(async () => { { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() }, { id: membershipId, organizationId, userId: memberId, role: 'member', createdAt: new Date() }, ]) + // The external collaborator works in one of the organization's workspaces without membership. + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Offboarding', + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + }) + await db.insert(permissions).values({ + id: generateId(), + userId: externalId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'write', + }) }, 60_000) afterAll(async () => { @@ -56,6 +74,7 @@ afterAll(async () => { sql`${outboxEvent.payload}->'request'->'target'->>'organizationId' = ${organizationId}` ) ) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) await db.delete(organization).where(eq(organization.id, organizationId)) await db.delete(user).where(inArray(user.id, [ownerId, memberId, externalId])) }) @@ -101,10 +120,12 @@ it('queues private memory erasure with each committed departure and organization skipBillingLogic: true, onError: 'throw', }) - await runtime.membership.removeExternalUserFromOrganizationWorkspaces({ - organizationId, - userId: externalId, - }) + expect( + await runtime.membership.removeExternalUserFromOrganizationWorkspaces({ + organizationId, + userId: externalId, + }) + ).toMatchObject({ success: true, workspaceAccessRevoked: 1 }) expect(await queued()).toEqual( [`organization_member:${externalId}`, `organization_member:${memberId}`].sort() )