Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 270 additions & 19 deletions apps/sim/lib/workspaces/admin-move.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const {
sendInvitationEmail,
countPendingSeatInvitations,
resolveSeatCapacity,
collectWorkspaceCredentialSummary,
getSourceOrganization,
} = vi.hoisted(() => ({
resolveMoveEntitlements: vi.fn(() =>
Promise.resolve({
Expand Down Expand Up @@ -73,8 +75,49 @@ const {
sendInvitationEmail: vi.fn(),
countPendingSeatInvitations: vi.fn(() => Promise.resolve(0)),
resolveSeatCapacity: vi.fn(() => Promise.resolve(10)),
collectWorkspaceCredentialSummary: vi.fn(),
getSourceOrganization: vi.fn(),
}))

const SOURCE_ORGANIZATION = {
id: 'org-source',
name: 'Source',
ownerId: 'source-owner',
ownerName: 'Source Owner',
ownerEmail: 'source-owner@example.com',
}

const EMPTY_CREDENTIALS = {
items: [] as Array<{
id: string
displayName: string
type: string
backedBySourceOrgMember: boolean
}>,
credentialGroupCount: 0,
environmentVariableKeys: [] as string[],
byokKeyCount: 0,
truncatedCredentials: 0,
truncatedEnvironmentVariableKeys: 0,
}

const POPULATED_CREDENTIALS = {
...EMPTY_CREDENTIALS,
items: [
{ id: 'credential-1', displayName: 'Slack', type: 'oauth', backedBySourceOrgMember: true },
],
credentialGroupCount: 1,
environmentVariableKeys: ['OPENAI_API_KEY'],
byokKeyCount: 2,
}

/** A workspace whose secrets exceed the response bounds, so rows were dropped. */
const TRUNCATED_CREDENTIALS = {
...POPULATED_CREDENTIALS,
truncatedCredentials: 3,
truncatedEnvironmentVariableKeys: 7,
}

vi.mock('@sim/audit', () => ({
AuditAction: {
WORKSPACE_UPDATED: 'workspace.updated',
Expand Down Expand Up @@ -121,16 +164,7 @@ vi.mock('@/lib/table/billing', () => ({ invalidateWorkspaceTableLimitsCache }))
vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ deleteCustomBlock }))
vi.mock('@/lib/workspaces/admin-move-source-impact', () => ({
cleanupSourceOrganizationArtifactsTx,
collectWorkspaceCredentialSummary: vi.fn(() =>
Promise.resolve({
items: [],
credentialGroupCount: 0,
environmentVariableKeys: [],
byokKeyCount: 0,
truncatedCredentials: 0,
truncatedEnvironmentVariableKeys: 0,
})
),
collectWorkspaceCredentialSummary,
countRetentionRulesForWorkspace: vi.fn(() => ({
piiRedactionRules: 0,
retentionOverrides: 0,
Expand All @@ -140,15 +174,7 @@ vi.mock('@/lib/workspaces/admin-move-source-impact', () => ({
findRetainedCollaboratorCaps: vi.fn(() => Promise.resolve([])),
findUnpublishableCustomBlocks,
findSourceOrgCustomBlocksForWorkspace,
getSourceOrganization: vi.fn(() =>
Promise.resolve({
id: 'org-source',
name: 'Source',
ownerId: 'source-owner',
ownerName: 'Source Owner',
ownerEmail: 'source-owner@example.com',
})
),
getSourceOrganization,
resolveMoveEntitlements,
willBrandingChange: vi.fn(() => Promise.resolve(false)),
}))
Expand Down Expand Up @@ -210,6 +236,30 @@ function queueMoveSelects(workspaceRow: Record<string, unknown>) {
queueTableRows(organization, [destination])
}

/**
* The reload path reads the completed operation, then the workspace twice — the
* applied-state check and the summary reload — and the destination once.
*/
function queueMoveOperationSelects(audit: Record<string, unknown>) {
queueTableRows(outboxEvent, [
{
eventType: 'admin.workspace-move-operation',
status: 'completed',
payload: {
request: {
workspaceId: movedWorkspace.id,
destinationOrganizationId: destination.id,
expectedOwnerId: movedWorkspace.ownerId,
},
audit,
},
},
])
queueTableRows(workspace, [movedWorkspace])
queueTableRows(workspace, [movedWorkspace])
queueTableRows(organization, [destination])
}

afterAll(resetDbChainMock)

beforeEach(() => {
Expand All @@ -227,6 +277,8 @@ beforeEach(() => {
destinationIsEnterprise: false,
capabilitiesLost: [],
})
collectWorkspaceCredentialSummary.mockResolvedValue(EMPTY_CREDENTIALS)
getSourceOrganization.mockResolvedValue(SOURCE_ORGANIZATION)
changeWorkspaceStoragePayerInTx.mockResolvedValue({
billableBytes: 128,
newPayer: { type: 'organization', id: destination.id },
Expand Down Expand Up @@ -788,6 +840,205 @@ describe('moveWorkspaceToOrganization retries', () => {
)
})

/**
* A completed move records `sourceOrganizationId` even when it is `null`, so
* a reload can tell "this workspace came from a personal source" apart from
* "this operation predates the field". Collapsing the two made every reload
* of a personal-source move claim its origin had failed to persist.
*/
it('does not warn about an unpersisted source for a move recorded as personal', async () => {
queueMoveOperationSelects({
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: '2026-08-20T00:00:00.000Z',
sourceOrganizationId: null,
})

const view = await getWorkspaceMoveOperation(
movedWorkspace.id,
destination.id,
movedWorkspace.ownerId,
'operation-1'
)

expect(view.notices).toEqual([])
expect(view.sourceOrganization).toBeNull()
})

it('still warns when the payload never recorded a source organization', async () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
queueMoveOperationSelects({
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: '2026-08-20T00:00:00.000Z',
})

const view = await getWorkspaceMoveOperation(
movedWorkspace.id,
destination.id,
movedWorkspace.ownerId,
'operation-1'
)

expect(view.notices).toEqual([
'This move was recorded before the source organization was persisted, so it cannot be reported.',
])
})

it('reports the workspace credentials when a completed operation is reloaded', async () => {
collectWorkspaceCredentialSummary.mockResolvedValueOnce(POPULATED_CREDENTIALS)
queueMoveOperationSelects({
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: '2026-08-20T00:00:00.000Z',
sourceOrganizationId: 'org-source',
})

const view = await getWorkspaceMoveOperation(
movedWorkspace.id,
destination.id,
movedWorkspace.ownerId,
'operation-1'
)

/** Resolved against the recorded source, so `backedBySourceOrgMember` means something. */
expect(collectWorkspaceCredentialSummary).toHaveBeenCalledWith(movedWorkspace.id, 'org-source')
expect(view.credentials).toEqual(POPULATED_CREDENTIALS)
})

/**
* A recorded id whose organization has since been deleted is the third state:
* the payload answered, but the answer can no longer be resolved to a name.
*/
it('distinguishes a deleted source organization from an unrecorded one', async () => {
getSourceOrganization.mockResolvedValueOnce(null)
queueMoveOperationSelects({
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: '2026-08-20T00:00:00.000Z',
sourceOrganizationId: 'org-source',
})

const view = await getWorkspaceMoveOperation(
movedWorkspace.id,
destination.id,
movedWorkspace.ownerId,
'operation-1'
)

expect(view.sourceOrganization).toBeNull()
expect(view.notices).toEqual([
'The organization this workspace came from has since been deleted, so it can no longer be named.',
])
})

it('reports the workspace credentials in the applied summary', async () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
queueMoveSelects(organizationWorkspace)
collectWorkspaceCredentialSummary.mockResolvedValueOnce(POPULATED_CREDENTIALS)

const summary = await moveWorkspaceToOrganization({
workspaceId: organizationWorkspace.id,
destinationOrganizationId: destination.id,
adminEmail: 'admin@sim.ai',
durableOperationId: 'operation-1',
})

/** The PRE-move organization: that is what `backedBySourceOrgMember` compares against. */
expect(collectWorkspaceCredentialSummary).toHaveBeenCalledWith(
organizationWorkspace.id,
'org-source',
expect.anything()
)
expect(summary.credentials).toEqual(POPULATED_CREDENTIALS)
/** Nothing was dropped, so the review is complete and says nothing about truncation. */
expect(summary.sourceOrganizationImpact.truncated).toBeNull()
})

/**
* The applied path used to hardcode these two counters to zero, which would
* present a truncated credential list as a complete one.
*/
it('carries dropped credential counts into the applied truncation record', async () => {
queueMoveSelects(organizationWorkspace)
collectWorkspaceCredentialSummary.mockResolvedValueOnce(TRUNCATED_CREDENTIALS)

const summary = await moveWorkspaceToOrganization({
workspaceId: organizationWorkspace.id,
destinationOrganizationId: destination.id,
adminEmail: 'admin@sim.ai',
durableOperationId: 'operation-1',
})

expect(summary.sourceOrganizationImpact.truncated).toMatchObject({
credentials: 3,
environmentVariableKeys: 7,
})
})

it('carries dropped credential counts into a reloaded truncation record', async () => {
collectWorkspaceCredentialSummary.mockResolvedValueOnce(TRUNCATED_CREDENTIALS)
queueMoveOperationSelects({
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: '2026-08-20T00:00:00.000Z',
sourceOrganizationId: 'org-source',
})

const view = await getWorkspaceMoveOperation(
movedWorkspace.id,
destination.id,
movedWorkspace.ownerId,
'operation-1'
)

expect(view.sourceOrganizationImpact.truncated).toMatchObject({
credentials: 3,
environmentVariableKeys: 7,
})
})

it('reports the workspace credentials on a retry of a completed move', async () => {
queueMoveSelects(movedWorkspace)
queueTableRows(outboxEvent, [
{
eventType: 'admin.workspace-move-operation',
status: 'completed',
payload: {
request: {
workspaceId: movedWorkspace.id,
destinationOrganizationId: destination.id,
expectedOwnerId: movedWorkspace.ownerId,
},
audit: {
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: '2026-08-20T00:00:00.000Z',
sourceOrganizationId: null,
},
},
},
])
collectWorkspaceCredentialSummary.mockResolvedValueOnce(POPULATED_CREDENTIALS)

const summary = await moveWorkspaceToOrganization({
workspaceId: movedWorkspace.id,
destinationOrganizationId: destination.id,
adminEmail: 'admin@sim.ai',
expectedOwnerId: movedWorkspace.ownerId,
auditOperationId: 'operation-1',
operationCorrelationId: 'operation-1',
durableOperationId: 'operation-1',
})

expect(summary.credentials).toEqual(POPULATED_CREDENTIALS)
expect(summary.notices).toEqual([])
})

it('takes shared advisory locks before the workspace row lock and payer mutation', async () => {
queueMoveSelects(personalWorkspace)

Expand Down
Loading
Loading