Files
simstudioai--sim/apps/sim/lib/webhooks/processor.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

333 lines
9.7 KiB
TypeScript

/**
* @vitest-environment node
*/
import type { webhook, workflow } from '@sim/db/schema'
import {
createMockRequest,
envFlagsMock,
executionPreprocessingMock,
executionPreprocessingMockFns,
} from '@sim/testing'
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGenerateId,
mockEnqueue,
mockGetJobQueue,
mockProviderHandler,
mockShouldExecuteInline,
mockWebhookLookupResult,
} = vi.hoisted(() => ({
mockGenerateId: vi.fn(),
mockEnqueue: vi.fn(),
mockGetJobQueue: vi.fn(),
mockProviderHandler: { current: {} as Record<string, unknown> },
mockShouldExecuteInline: vi.fn(),
mockWebhookLookupResult: { rows: [] as Array<{ webhook: any; workflow: any }> },
}))
const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
vi.mock('@sim/db', () => {
const selectChain = {
from: () => selectChain,
innerJoin: () => selectChain,
leftJoin: () => selectChain,
where: () => Promise.resolve(mockWebhookLookupResult.rows),
}
return {
db: { select: () => selectChain },
webhook: {},
workflow: {},
workflowDeploymentVersion: {},
}
})
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
eq: vi.fn(),
isNull: vi.fn(),
or: vi.fn(),
}))
vi.mock('@sim/utils/id', () => ({
generateId: mockGenerateId,
generateShortId: vi.fn(() => 'mock-short-id'),
isValidUuid: vi.fn((v: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
),
}))
vi.mock('@/lib/billing/subscriptions/utils', () => ({
checkEnterprisePlan: vi.fn().mockReturnValue(true),
checkTeamPlan: vi.fn().mockReturnValue(true),
}))
vi.mock('@/lib/core/async-jobs', () => ({
getInlineJobQueue: vi.fn(),
getJobQueue: mockGetJobQueue,
shouldExecuteInline: mockShouldExecuteInline,
}))
vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
vi.mock('@sim/security/compare', () => ({
safeCompare: vi.fn().mockReturnValue(true),
}))
vi.mock('@/lib/environment/utils', () => ({
getEffectiveDecryptedEnv: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
vi.mock('@/lib/webhooks/pending-verification', () => ({
getPendingWebhookVerification: vi.fn(),
matchesPendingWebhookVerificationProbe: vi.fn().mockReturnValue(false),
requiresPendingWebhookVerification: vi.fn().mockReturnValue(false),
}))
vi.mock('@/lib/webhooks/utils', () => ({
convertSquareBracketsToTwiML: vi.fn((value: string) => value),
}))
vi.mock('@/lib/webhooks/utils.server', () => ({
handleSlackChallenge: vi.fn().mockReturnValue(null),
handleWhatsAppVerification: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/webhooks/providers', () => ({
getProviderHandler: vi.fn(() => mockProviderHandler.current),
}))
vi.mock('@/background/webhook-execution', () => ({
executeWebhookJob: vi.fn().mockResolvedValue({ success: true }),
}))
vi.mock('@/executor/utils/reference-validation', () => ({
resolveEnvVarReferences: vi.fn((value: string) => value),
}))
vi.mock('@/triggers/confluence/utils', () => ({
isConfluencePayloadMatch: vi.fn().mockReturnValue(true),
}))
vi.mock('@/triggers/constants', () => ({
isPollingWebhookProvider: vi.fn((provider: string) => provider === 'gmail'),
}))
vi.mock('@/triggers/github/utils', () => ({
isGitHubEventMatch: vi.fn().mockReturnValue(true),
}))
vi.mock('@/triggers/jira/utils', () => ({
isJiraEventMatch: vi.fn().mockReturnValue(true),
}))
import { dispatchResolvedWebhookTarget, findAllWebhooksForPath } from '@/lib/webhooks/processor'
type WebhookRecord = typeof webhook.$inferSelect
type WorkflowRecord = typeof workflow.$inferSelect
function makeWebhookRecord(overrides: Partial<WebhookRecord>): WebhookRecord {
const now = new Date('2026-01-01T00:00:00.000Z')
return {
id: 'webhook-1',
workflowId: 'workflow-1',
deploymentVersionId: null,
blockId: null,
path: 'incoming/test',
provider: 'generic',
providerConfig: {},
isActive: true,
failedCount: 0,
lastFailedAt: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
}
}
function makeWorkflowRecord(overrides: Partial<WorkflowRecord>): WorkflowRecord {
const now = new Date('2026-01-01T00:00:00.000Z')
return {
id: 'workflow-1',
userId: 'owner-1',
workspaceId: 'workspace-1',
folderId: null,
sortOrder: 0,
name: 'Webhook workflow',
description: null,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: true,
deployedAt: now,
isPublicApi: false,
locked: false,
runCount: 0,
lastRunAt: null,
variables: {},
archivedAt: null,
...overrides,
}
}
describe('findAllWebhooksForPath cross-tenant collision', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWebhookLookupResult.rows = []
})
const makeRow = (workflowId: string, webhookId: string, createdAt: Date) => ({
webhook: { id: webhookId, workflowId, path: 'shared-path', createdAt },
workflow: { id: workflowId },
})
it('returns all rows when they belong to a single workflow', async () => {
mockWebhookLookupResult.rows = [
makeRow('workflow-1', 'wh-a', new Date('2026-01-01')),
makeRow('workflow-1', 'wh-b', new Date('2026-01-02')),
]
const results = await findAllWebhooksForPath({ requestId: 'req-1', path: 'shared-path' })
expect(results).toHaveLength(2)
expect(results.map((r) => r.webhook.id)).toEqual(['wh-a', 'wh-b'])
})
it('drops foreign rows when a path collides across workflows, keeping the earliest owner', async () => {
const victim = makeRow('victim-workflow', 'victim-wh', new Date('2026-01-01'))
const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01'))
mockWebhookLookupResult.rows = [attacker, victim]
const results = await findAllWebhooksForPath({ requestId: 'req-2', path: 'shared-path' })
expect(results).toHaveLength(1)
expect(results[0].webhook.id).toBe('victim-wh')
expect(results[0].webhook.workflowId).toBe('victim-workflow')
})
it("preserves the owner's full multi-webhook match while dropping a foreign row", async () => {
const victimA = makeRow('victim-workflow', 'victim-wh-a', new Date('2026-01-01'))
const victimB = makeRow('victim-workflow', 'victim-wh-b', new Date('2026-01-03'))
const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01'))
mockWebhookLookupResult.rows = [victimB, attacker, victimA]
const results = await findAllWebhooksForPath({ requestId: 'req-5', path: 'shared-path' })
expect(results).toHaveLength(2)
expect(results.every((r) => r.webhook.workflowId === 'victim-workflow')).toBe(true)
expect(results.map((r) => r.webhook.id).sort()).toEqual(['victim-wh-a', 'victim-wh-b'])
})
it('returns an empty array when no webhooks match', async () => {
mockWebhookLookupResult.rows = []
const results = await findAllWebhooksForPath({ requestId: 'req-3', path: 'missing' })
expect(results).toEqual([])
})
it('returns an empty array when path is not provided', async () => {
const results = await findAllWebhooksForPath({ requestId: 'req-4' })
expect(results).toEqual([])
})
})
describe('webhook processor execution identity', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPreprocessExecution.mockResolvedValue({
success: true,
actorUserId: 'actor-user-1',
})
mockEnqueue.mockResolvedValue('job-1')
mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue })
mockProviderHandler.current = {}
mockShouldExecuteInline.mockReturnValue(false)
mockGenerateId.mockReturnValue('generated-execution-id')
})
it('reuses preprocessing execution identity when queueing a polling webhook', async () => {
const expectedCorrelation = {
executionId: 'generated-execution-id',
requestId: 'request-1',
source: 'webhook',
workflowId: 'workflow-1',
webhookId: 'webhook-1',
path: 'incoming/gmail',
provider: 'gmail',
triggerType: 'webhook',
}
const result = await dispatchResolvedWebhookTarget(
makeWebhookRecord({
path: 'incoming/gmail',
provider: 'gmail',
}),
makeWorkflowRecord({}),
{ event: 'message.received' },
createMockRequest('POST', { event: 'message.received' }) as NextRequest,
{
requestId: 'request-1',
path: 'incoming/gmail',
}
)
expect(result.outcome).toBe('queued')
expect(mockGenerateId).toHaveBeenCalledTimes(1)
expect(mockEnqueue).toHaveBeenCalledWith(
'webhook-execution',
expect.objectContaining({
workflowId: 'workflow-1',
provider: 'gmail',
}),
expect.objectContaining({
metadata: expect.objectContaining({
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'actor-user-1',
correlation: expectedCorrelation,
}),
})
)
})
it('routes queue-mode providers through the durable job backend', async () => {
mockProviderHandler.current = { executionMode: 'queue' }
const result = await dispatchResolvedWebhookTarget(
makeWebhookRecord({
id: 'webhook-2',
path: 'tiktok',
provider: 'tiktok',
}),
makeWorkflowRecord({
id: 'workflow-2',
workspaceId: 'workspace-2',
}),
{ event: 'post.publish.complete' },
createMockRequest('POST', { event: 'post.publish.complete' }) as NextRequest,
{
requestId: 'request-2',
}
)
expect(result.outcome).toBe('queued')
expect(result.response.status).toBe(200)
expect(mockEnqueue).toHaveBeenCalledWith(
'webhook-execution',
expect.objectContaining({
provider: 'tiktok',
workflowId: 'workflow-2',
}),
expect.any(Object)
)
})
})