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
4 changes: 3 additions & 1 deletion apps/sim/app/api/table/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,7 +320,9 @@ export const CreateColumnSchema = createTableColumnBodySchema
export const UpdateColumnSchema = updateTableColumnBodySchema
export const DeleteColumnSchema = deleteTableColumnBodySchema

export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
export function normalizeColumn(
col: ColumnDefinition
): ColumnDefinition & { required: boolean; unique: boolean } {
return {
// Preserve the stable column id — it's the row-data storage key, so dropping
// it makes clients fall back to `name` and miss id-keyed cell values.
Expand Down
259 changes: 108 additions & 151 deletions apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,192 +1,149 @@
/**
* @vitest-environment node
*
* Public v2 cancel-runs — stops workflow/enrichment cell runs, as opposed to
* `job/cancel`, which stops an import or delete. The predicate translates to
* storage keys before the cancel so an unknown field 400s rather than becoming
* a cancel that silently matches nothing.
*/

import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockCheckRateLimit,
mockResolveWorkspaceScope,
mockCheckAccess,
mockCancelRuns,
mockPredicateToFilter,
mockSignalRowsChanged,
mockGateError,
TableQueryValidationError,
} = vi.hoisted(() => ({
mockCheckRateLimit: vi.fn(),
mockResolveWorkspaceScope: vi.fn(),
mockCheckAccess: vi.fn(),
mockCancelRuns: vi.fn(),
mockPredicateToFilter: vi.fn(),
mockSignalRowsChanged: vi.fn(),
mockGateError: vi.fn(),
TableQueryValidationError: class TableQueryValidationError extends Error {},
}))
const { mocks, MockTableRowsValidationError } = vi.hoisted(() => {
class MockTableRowsValidationError extends Error {}
return {
mocks: {
authenticate: vi.fn(),
preauthRate: vi.fn(),
operationRate: vi.fn(),
gate: vi.fn(),
cancelRuns: vi.fn(),
},
MockTableRowsValidationError,
}
})

vi.mock('@/app/api/v1/middleware', () => ({
checkRateLimit: mockCheckRateLimit,
resolveWorkspaceScope: mockResolveWorkspaceScope,
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({
authenticateV2ApiKey: mocks.authenticate,
V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {},
}))

vi.mock('@/app/api/table/utils', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
checkAccess: mockCheckAccess,
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
checkRateLimitDirect = mocks.preauthRate
checkRateLimitDirectOrThrow = mocks.operationRate
},
getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }),
}))

vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
v2BulkPredicateToFilter: mockPredicateToFilter,
vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate }))
vi.mock('@/lib/table/application/rows', () => ({
TableRowsValidationError: MockTableRowsValidationError,
}))
vi.mock('@/lib/table/application/runs', () => ({
cancelTableRuns: { operation: { id: 'tables.runs.cancel' }, execute: mocks.cancelRuns },
}))

vi.mock('@/lib/table/workflow-columns', () => ({ cancelWorkflowGroupRuns: mockCancelRuns }))
vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged }))
vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError }))
vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))

import { POST } from '@/app/api/v2/tables/[tableId]/cancel-runs/route'

const TABLE = {
id: 'table-1',
workspaceId: 'ws-1',
schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] },
const WORKSPACE_ID = 'workspace-1'
const PRINCIPAL = {
kind: 'workspace_api_key' as const,
workspaceId: WORKSPACE_ID,
keyId: 'key-1',
}

const RATE_LIMIT_OK = {
const AUTH = {
principal: PRINCIPAL,
rolloutUserId: 'owner-1',
rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`],
rateLimitSubscription: null,
keyType: 'workspace' as const,
}
const RATE = {
allowed: true,
userId: 'user-1',
keyType: 'workspace',
workspaceId: 'ws-1',
limit: 100,
remaining: 99,
resetAt: new Date('2026-01-01T01:00:00Z'),
retryAfterMs: 0,
}

function callPost(body: unknown) {
const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/cancel-runs', {
function call(body: unknown) {
const request = new NextRequest('http://localhost/api/v2/tables/table-1/cancel-runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'content-type': 'application/json', 'x-api-key': 'secret' },
body: JSON.stringify(body),
})
return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) })
return {
request,
response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }),
}
}

beforeEach(() => {
vi.clearAllMocks()
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
mockResolveWorkspaceScope.mockResolvedValue(null)
mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
mockCancelRuns.mockResolvedValue(4)
mockGateError.mockResolvedValue(null)
})

describe('POST /api/v2/tables/[tableId]/cancel-runs', () => {
it('cancels every run under scope "all" and reports the count', async () => {
const res = await callPost({ workspaceId: 'ws-1', scope: 'all' })

expect(res.status).toBe(200)
expect((await res.json()).data).toEqual({ cancelled: 4 })
expect(mockCancelRuns).toHaveBeenCalledWith('table-1', undefined, {
filter: undefined,
excludeRowIds: undefined,
})
// Cancelling clears the affected cells, so open readers must refetch.
expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1')
})

it('scopes to a single row when asked', async () => {
const res = await callPost({ workspaceId: 'ws-1', scope: 'row', rowId: 'row-1' })

expect(res.status).toBe(200)
expect(mockCancelRuns).toHaveBeenCalledWith('table-1', 'row-1', expect.anything())
})

it('translates a name-keyed predicate to the storage-keyed filter', async () => {
mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } })
const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] }

await callPost({ workspaceId: 'ws-1', scope: 'all', filter: predicate })

expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema)
expect(mockCancelRuns).toHaveBeenCalledWith(
'table-1',
undefined,
expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } })
)
beforeEach(() => {
vi.clearAllMocks()
mocks.authenticate.mockResolvedValue(AUTH)
mocks.preauthRate.mockResolvedValue(RATE)
mocks.operationRate.mockResolvedValue(RATE)
mocks.gate.mockResolvedValue(null)
mocks.cancelRuns.mockResolvedValue({ table: { id: 'table-1' }, cancelled: 4 })
})

it('400s an unresolvable predicate field instead of cancelling nothing', async () => {
mockPredicateToFilter.mockImplementation(() => {
throw new TableQueryValidationError('Unknown column "nope"')
})

const res = await callPost({
workspaceId: 'ws-1',
it('delegates a filtered all-scope cancellation and reports the authoritative count', async () => {
const predicate = { all: [{ field: 'status', op: 'eq', value: 'ready' }] }
const invocation = call({
workspaceId: WORKSPACE_ID,
scope: 'all',
filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] },
filter: predicate,
excludeRowIds: ['row-2'],
})

expect(res.status).toBe(400)
expect(mockCancelRuns).not.toHaveBeenCalled()
})

it('400s scope "row" with no rowId', async () => {
const res = await callPost({ workspaceId: 'ws-1', scope: 'row' })

expect(res.status).toBe(400)
expect(mockCancelRuns).not.toHaveBeenCalled()
})

it('400s scope "row" combined with a filter', async () => {
const res = await callPost({
workspaceId: 'ws-1',
scope: 'row',
rowId: 'row-1',
filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] },
const response = await invocation.response

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: { cancelled: 4 } })
expect(mocks.cancelRuns).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
scope: 'all',
tableId: 'table-1',
assertedWorkspaceId: WORKSPACE_ID,
predicate,
excludeRowIds: ['row-2'],
},
request: invocation.request,
})

expect(res.status).toBe(400)
expect(mockCancelRuns).not.toHaveBeenCalled()
})

it('403s a read-only member', async () => {
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })

const res = await callPost({ workspaceId: 'ws-1', scope: 'all' })

expect(res.status).toBe(403)
expect(mockCancelRuns).not.toHaveBeenCalled()
it('delegates one canonical row scope without select-all fields', async () => {
const invocation = call({ workspaceId: WORKSPACE_ID, scope: 'row', rowId: 'row-1' })
const response = await invocation.response

expect(response.status).toBe(200)
expect(mocks.cancelRuns).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
scope: 'row',
tableId: 'table-1',
assertedWorkspaceId: WORKSPACE_ID,
rowId: 'row-1',
},
request: invocation.request,
})
})

it('404s with the gate off, before any work', async () => {
mockGateError.mockResolvedValue(
new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), {
status: 404,
})
)
it('preserves an authoritative zero-cancellation result', async () => {
mocks.cancelRuns.mockResolvedValue({ table: { id: 'table-1' }, cancelled: 0 })

const res = await callPost({ workspaceId: 'ws-1', scope: 'all' })
const response = await call({ workspaceId: WORKSPACE_ID, scope: 'all' }).response

expect(res.status).toBe(404)
expect(mockCheckAccess).not.toHaveBeenCalled()
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: { cancelled: 0 } })
})

it('429s a throttled caller', async () => {
mockCheckRateLimit.mockResolvedValue({
...RATE_LIMIT_OK,
allowed: false,
remaining: 0,
retryAfterMs: 1000,
})

const res = await callPost({ workspaceId: 'ws-1', scope: 'all' })
it('rejects an incomplete or contradictory row scope before delegation', async () => {
const missing = await call({ workspaceId: WORKSPACE_ID, scope: 'row' }).response
const contradictory = await call({
workspaceId: WORKSPACE_ID,
scope: 'row',
rowId: 'row-1',
filter: { all: [{ field: 'status', op: 'eq', value: 'ready' }] },
}).response

expect(res.status).toBe(429)
expect(mockCancelRuns).not.toHaveBeenCalled()
expect(missing.status).toBe(400)
expect(contradictory.status).toBe(400)
expect(mocks.cancelRuns).not.toHaveBeenCalled()
})
})
Loading
Loading