Skip to content
Open
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
134 changes: 134 additions & 0 deletions apps/sim/app/api/workspaces/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @vitest-environment node
*/
import {
auditMock,
auditMockFns,
authMockFns,
createMockRequest,
permissionsMock,
permissionsMockFns,
posthogServerMock,
posthogServerMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockArchiveWorkspace } = vi.hoisted(() => ({
mockArchiveWorkspace: vi.fn(),
}))

vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)

vi.mock('@/lib/workspaces/lifecycle', () => ({
archiveWorkspace: mockArchiveWorkspace,
}))

vi.mock('@sim/audit', () => auditMock)

vi.mock('@/lib/posthog/server', () => posthogServerMock)

import { DELETE } from '@/app/api/workspaces/[id]/route'

function callDelete(workspaceId = 'workspace-1') {
const request = createMockRequest('DELETE', {})
return DELETE(request, { params: Promise.resolve({ id: workspaceId }) })
}

describe('DELETE /api/workspaces/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-admin', name: 'Admin', email: 'admin@example.com' },
})
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
})

it('returns 401 when the caller is unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)

const response = await callDelete()

expect(response.status).toBe(401)
expect(mockArchiveWorkspace).not.toHaveBeenCalled()
})

it('returns 403 when the caller lacks admin permission', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')

const response = await callDelete()

expect(response.status).toBe(403)
expect(mockArchiveWorkspace).not.toHaveBeenCalled()
})

it('returns 404 when the workspace does not exist', async () => {
mockArchiveWorkspace.mockResolvedValue({ archived: false })

const response = await callDelete()

expect(response.status).toBe(404)
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
})

it('archives the workspace, records an audit entry, and captures the event on success', async () => {
mockArchiveWorkspace.mockResolvedValue({
archived: true,
workspaceName: 'Test Workspace',
})

const response = await callDelete('workspace-1')
const body = await response.json()

expect(response.status).toBe(200)
expect(body).toEqual({ success: true })
expect(mockArchiveWorkspace).toHaveBeenCalledWith('workspace-1', {
requestId: 'workspace-workspace-1',
provisionFallbackForStrandedMembers: true,
actorId: 'user-admin',
actorName: 'Admin',
actorEmail: 'admin@example.com',
})
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'workspace-1',
actorId: 'user-admin',
resourceName: 'Test Workspace',
})
)
expect(posthogServerMockFns.mockCaptureServerEvent).toHaveBeenCalledWith(
'user-admin',
'workspace_deleted',
expect.objectContaining({ workspace_id: 'workspace-1' }),
expect.objectContaining({ groups: { workspace: 'workspace-1' } })
)
})

it('succeeds and records which members were auto-provisioned a replacement workspace', async () => {
mockArchiveWorkspace.mockResolvedValue({
archived: true,
workspaceName: 'Test Workspace',
provisionedWorkspaceUserIds: ['user-victim'],
})

const response = await callDelete('workspace-1')
const body = await response.json()

expect(response.status).toBe(200)
expect(body).toEqual({ success: true })
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
provisionedWorkspaceUserIds: ['user-victim'],
}),
})
)
})

it('returns 500 when archival throws unexpectedly', async () => {
mockArchiveWorkspace.mockRejectedValue(new Error('db exploded'))

const response = await callDelete()

expect(response.status).toBe(500)
})
})
39 changes: 11 additions & 28 deletions apps/sim/app/api/workspaces/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
const logger = createLogger('WorkspaceByIdAPI')

import { db } from '@sim/db'
import { permissions, workspace } from '@sim/db/schema'
import { workspace } from '@sim/db/schema'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
getEffectiveWorkspacePermission,
Expand Down Expand Up @@ -239,30 +239,6 @@ export const DELETE = withRouteHandler(
}

try {
const [[workspaceRecord], totalWorkspaces] = await Promise.all([
db
.select({ name: workspace.name })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1),
db
.select({ id: permissions.entityId })
.from(permissions)
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
.where(
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
isNull(workspace.archivedAt)
)
),
])

/** Counts all workspace memberships (any role), not just admin — prevents the user from reaching a zero-workspace state. */
if (totalWorkspaces.length <= 1) {
return NextResponse.json({ error: 'Cannot delete the only workspace' }, { status: 400 })
}

logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`)

const workspaceWorkflows = await db
Expand All @@ -274,9 +250,13 @@ export const DELETE = withRouteHandler(

const archiveResult = await archiveWorkspace(workspaceId, {
requestId: `workspace-${workspaceId}`,
provisionFallbackForStrandedMembers: true,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
})

if (!archiveResult.archived && !workspaceRecord) {
if (!archiveResult.archived) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

Expand All @@ -288,13 +268,16 @@ export const DELETE = withRouteHandler(
action: AuditAction.WORKSPACE_DELETED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
resourceName: workspaceRecord?.name,
description: `Archived workspace "${workspaceRecord?.name || workspaceId}"`,
resourceName: archiveResult.workspaceName,
description: `Archived workspace "${archiveResult.workspaceName || workspaceId}"`,
metadata: {
affected: {
workflows: workflowIds.length,
},
archived: archiveResult.archived,
...(archiveResult.provisionedWorkspaceUserIds?.length && {
provisionedWorkspaceUserIds: archiveResult.provisionedWorkspaceUserIds,
}),
},
request,
})
Expand Down
128 changes: 13 additions & 115 deletions apps/sim/app/api/workspaces/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
import { settings, type WorkspaceMode, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import type { PlanCategory } from '@/lib/billing/plan-helpers'
import { PlatformEvents } from '@/lib/core/telemetry'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
import { type CreateWorkspaceRecordParams, createWorkspaceRecord } from '@/lib/workspaces/create'
import {
CONTACT_OWNER_TO_UPGRADE_REASON,
evaluateWorkspaceInvitePolicy,
Expand Down Expand Up @@ -264,15 +260,7 @@ async function createDefaultWorkspace(
})
}

interface CreateWorkspaceParams {
userId: string
name: string
skipDefaultWorkflow?: boolean
explicitColor?: string
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
}
type CreateWorkspaceParams = Omit<CreateWorkspaceRecordParams, 'executor'>

async function createWorkspace({
userId,
Expand All @@ -283,96 +271,15 @@ async function createWorkspace({
workspaceMode,
billedAccountUserId,
}: CreateWorkspaceParams) {
const workspaceId = generateId()
const workflowId = generateId()
const now = new Date()
const color = explicitColor || getRandomWorkspaceColor()

try {
await db.transaction(async (tx) => {
await tx.insert(workspace).values({
id: workspaceId,
name,
color,
ownerId: userId,
organizationId,
workspaceMode,
billedAccountUserId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
})

const permissionRows = [
{
id: generateId(),
entityType: 'workspace' as const,
entityId: workspaceId,
userId,
permissionType: 'admin' as const,
createdAt: now,
updatedAt: now,
},
]

if (
workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
billedAccountUserId &&
billedAccountUserId !== userId
) {
permissionRows.push({
id: generateId(),
entityType: 'workspace' as const,
entityId: workspaceId,
userId: billedAccountUserId,
permissionType: 'admin' as const,
createdAt: now,
updatedAt: now,
})
}

await tx.insert(permissions).values(permissionRows)

if (!skipDefaultWorkflow) {
await tx.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: null,
name: 'default-agent',
description: 'Your first workflow - start building here!',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})

const { workflowState } = buildDefaultWorkflowArtifacts()
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
}

logger.info(
skipDefaultWorkflow
? `Created ${workspaceMode} workspace ${workspaceId} for user ${userId}`
: `Created ${workspaceMode} workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
)
})
} catch (error) {
logger.error(`Failed to create workspace ${workspaceId}:`, error)
throw error
}

try {
PlatformEvents.workspaceCreated({
workspaceId,
userId,
name,
})
} catch {
// Telemetry should not fail the operation
}
const record = await createWorkspaceRecord({
userId,
name,
skipDefaultWorkflow,
explicitColor,
organizationId,
workspaceMode,
billedAccountUserId,
})

const invitePolicy = await getWorkspaceInvitePolicy({
organizationId,
Expand All @@ -389,16 +296,7 @@ async function createWorkspace({
: CONTACT_OWNER_TO_UPGRADE_REASON

return {
id: workspaceId,
name,
color,
ownerId: userId,
organizationId,
workspaceMode,
billedAccountUserId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
...record,
role: 'owner',
permissions: 'admin',
inviteMembersEnabled: invitePolicy.allowed,
Expand Down
Loading
Loading