Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

547 lines
18 KiB
TypeScript

/**
* @vitest-environment node
*/
import {
dbChainMockFns,
environmentUtilsMockFns,
executionPreprocessingMock,
executionPreprocessingMockFns,
LoggingSessionMock,
loggerMock,
loggingSessionMock,
loggingSessionMockFns,
resetEnvironmentUtilsMock,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockResolveWebhookRecordProviderConfig,
mockExecuteWorkflowCore,
mockWasExecutionFinalizedByCore,
mockExecuteWithIdempotency,
mockRefreshExecutionSlotExpiry,
mockReleaseExecutionSlot,
mockLoadDeploymentVersionState,
mockGetProviderHandler,
mockSetResolvedSecretTraceRegistry,
} = vi.hoisted(() => ({
mockResolveWebhookRecordProviderConfig: vi.fn(),
mockExecuteWorkflowCore: vi.fn(),
mockWasExecutionFinalizedByCore: vi.fn(),
mockExecuteWithIdempotency: vi.fn(),
mockRefreshExecutionSlotExpiry: vi.fn().mockResolvedValue(true),
mockReleaseExecutionSlot: vi.fn(),
mockGetProviderHandler: vi.fn(() => ({})),
mockSetResolvedSecretTraceRegistry: vi.fn(),
mockLoadDeploymentVersionState: vi.fn(
async (_workflowId: string, deploymentVersionId: string) => ({
blocks: {},
edges: [],
loops: {},
parallels: {},
deploymentVersionId,
})
),
}))
const mockGetEffectiveEnvironmentSnapshot =
environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot
afterAll(resetEnvironmentUtilsMock)
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
vi.mock('@/lib/webhooks/env-resolver', () => ({
resolveWebhookRecordProviderConfig: mockResolveWebhookRecordProviderConfig,
}))
vi.mock('@/lib/workflows/executor/execution-core', () => ({
executeWorkflowCore: mockExecuteWorkflowCore,
wasExecutionFinalizedByCore: mockWasExecutionFinalizedByCore,
}))
vi.mock('@/lib/billing/calculations/usage-reservation', () => ({
refreshExecutionSlotExpiry: mockRefreshExecutionSlotExpiry,
releaseExecutionSlot: mockReleaseExecutionSlot,
}))
vi.mock('@/lib/core/idempotency', () => ({
IdempotencyService: { createWebhookIdempotencyKey: vi.fn(() => 'idempotency-key') },
webhookIdempotency: {
executeWithIdempotency: mockExecuteWithIdempotency,
},
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
loadDeployedWorkflowState: vi.fn(async () => ({
blocks: {},
edges: [],
loops: {},
parallels: {},
deploymentVersionId: 'deployment-1',
})),
loadWorkflowDeploymentVersionState: mockLoadDeploymentVersionState,
}))
vi.mock('@/lib/webhooks/providers', () => ({ getProviderHandler: mockGetProviderHandler }))
vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
buildTraceSpans: vi.fn(() => ({ traceSpans: [] })),
}))
vi.mock('@/lib/core/execution-limits', () => ({
capExecutionTimeoutMs: vi.fn((policyTimeoutMs, requestedTimeoutMs) =>
requestedTimeoutMs === undefined ? policyTimeoutMs : requestedTimeoutMs
),
createTimeoutAbortController: vi.fn(() => ({
signal: new AbortController().signal,
cleanup: vi.fn(),
isTimedOut: () => false,
timeoutMs: 120_000,
})),
getAsyncExecutionTimeoutForBillingAttribution: vi.fn(() => 120_000),
getExecutionDeadlineAt: vi.fn(() => new Date(Date.now() + 120_000)),
getTimeoutErrorMessage: vi.fn(() => 'timed out'),
RESERVATION_TTL_BUFFER_MS: 300_000,
}))
vi.mock('@/lib/workflows/executor/pause-persistence', () => ({
handlePostExecutionPauseState: vi.fn(),
}))
vi.mock('@/lib/webhooks/attachment-processor', () => ({
WebhookAttachmentProcessor: class {},
}))
vi.mock('@/app/api/auth/oauth/utils', () => ({
resolveOAuthAccountId: vi.fn(),
}))
vi.mock('@/executor/execution/snapshot', () => ({
ExecutionSnapshot: class {},
}))
vi.mock('@/tools/safe-assign', () => ({ safeAssign: vi.fn() }))
vi.mock('@/blocks', () => ({ getBlock: vi.fn(() => null) }))
vi.mock('@/triggers', () => ({
getTrigger: vi.fn(),
isTriggerValid: vi.fn(() => false),
}))
import {
executeWebhookJob,
resolveWebhookExecutionProviderConfig,
type WebhookExecutionPayload,
} from './webhook-execution'
const webhookExecutionLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex(
([name]) => name === 'TriggerWebhookExecution'
)
const webhookExecutionLogger =
loggerMock.createLogger.mock.results[webhookExecutionLoggerCallIndex]?.value
if (!webhookExecutionLogger) {
throw new Error('TriggerWebhookExecution logger mock was not initialized')
}
describe('resolveWebhookExecutionProviderConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the resolved webhook record when provider config resolution succeeds', async () => {
const webhookRecord = {
id: 'webhook-1',
providerConfig: {
botToken: '{{SLACK_BOT_TOKEN}}',
},
}
const resolvedWebhookRecord = {
...webhookRecord,
providerConfig: {
botToken: 'xoxb-resolved',
},
}
mockResolveWebhookRecordProviderConfig.mockResolvedValue(resolvedWebhookRecord)
await expect(
resolveWebhookExecutionProviderConfig(webhookRecord, 'slack', 'user-1', 'workspace-1')
).resolves.toEqual(resolvedWebhookRecord)
expect(mockResolveWebhookRecordProviderConfig).toHaveBeenCalledWith(
webhookRecord,
'user-1',
'workspace-1'
)
})
it('throws a contextual error when provider config resolution fails', async () => {
mockResolveWebhookRecordProviderConfig.mockRejectedValue(new Error('env lookup failed'))
await expect(
resolveWebhookExecutionProviderConfig(
{
id: 'webhook-1',
providerConfig: {
botToken: '{{SLACK_BOT_TOKEN}}',
},
},
'slack',
'user-1',
'workspace-1'
)
).rejects.toThrow(
'Failed to resolve webhook provider config for slack webhook webhook-1: env lookup failed'
)
})
})
describe('executeWebhookJob fault vs error handling', () => {
const billingAttribution = {
actorUserId: 'user-1',
workspaceId: 'workspace-1',
organizationId: null,
billedAccountUserId: 'user-1',
billingEntity: { type: 'user' as const, id: 'user-1' },
billingPeriod: {
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-01T00:00:00.000Z',
},
payerSubscription: null,
}
const payload: WebhookExecutionPayload = {
webhookId: 'webhook-1',
workflowId: 'workflow-1',
userId: 'user-1',
billingAttribution,
executionId: 'execution-1',
requestId: 'request-1',
provider: 'gmail',
body: { message: 'hello' },
headers: {},
path: '/webhook',
workspaceId: 'workspace-1',
}
beforeEach(() => {
vi.clearAllMocks()
LoggingSessionMock.mockImplementation(function LoggingSession() {
return {
safeStart: loggingSessionMockFns.mockSafeStart,
safeComplete: loggingSessionMockFns.mockSafeComplete,
safeCompleteWithError: loggingSessionMockFns.mockSafeCompleteWithError,
waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution,
markAsFailed: loggingSessionMockFns.mockMarkAsFailed,
setExecutionDeadlineAt: loggingSessionMockFns.mockSetExecutionDeadlineAt,
setResolvedSecretTraceRegistry: mockSetResolvedSecretTraceRegistry,
projectDiagnosticError: loggingSessionMockFns.mockProjectDiagnosticError,
}
})
mockGetProviderHandler.mockReturnValue({})
mockExecuteWithIdempotency.mockImplementation(
(_provider: string, _key: string, operation: () => Promise<unknown>) => operation()
)
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValue({
success: true,
actorUserId: 'user-1',
billingAttribution,
workflowRecord: {
workspaceId: 'workspace-1',
userId: 'user-1',
variables: {},
isDeployed: true,
archivedAt: null,
},
executionTimeout: { async: 120_000 },
})
mockResolveWebhookRecordProviderConfig.mockImplementation(async (record) => record)
mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: {},
personalDecrypted: {},
workspaceDecrypted: {},
conflicts: [],
decryptionFailures: [],
})
dbChainMockFns.limit.mockResolvedValue([{ id: 'webhook-1' }])
})
it('completes the run (does not throw) when the failure was finalized by core', async () => {
mockExecuteWorkflowCore.mockRejectedValue(
new Error('Gmail 2 is missing required fields: Label')
)
mockWasExecutionFinalizedByCore.mockReturnValue(true)
const result = await executeWebhookJob(payload)
expect(result).toMatchObject({
success: false,
workflowId: 'workflow-1',
executionId: 'execution-1',
provider: 'gmail',
})
expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalled()
// User/workflow errors are already recorded by core — the catch must not re-log them.
expect(loggingSessionMockFns.mockSafeCompleteWithError).not.toHaveBeenCalled()
})
it('faults the run (re-throws) when the failure was not finalized by core', async () => {
const secret = 'webhook-error-secret-7f3a91'
const rawError = new Error(
`Workflow state not found ${secret} __var_API_KEY __sim_code_1_binding_0`
)
const projectedError = 'Workflow state not found {{API_KEY}} {{API_KEY}} [RUNTIME_BINDING]'
loggingSessionMockFns.mockProjectDiagnosticError.mockReturnValueOnce({
workflowId: 'workflow-1',
provider: 'gmail',
error: projectedError,
})
mockExecuteWorkflowCore.mockRejectedValue(rawError)
mockWasExecutionFinalizedByCore.mockReturnValue(false)
await expect(executeWebhookJob(payload)).rejects.toBe(rawError)
// waitForPostExecution must run on every path so the finalized-by-core signal is always reliable.
expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalled()
// Pipeline/infra errors are recorded here before re-throwing to fault the trigger.dev run.
expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalled()
expect(loggingSessionMockFns.mockProjectDiagnosticError).toHaveBeenCalledWith(rawError, {
workflowId: 'workflow-1',
provider: 'gmail',
})
expect(webhookExecutionLogger.error).toHaveBeenCalledWith(
'[request-1] Webhook execution failed',
{ workflowId: 'workflow-1', provider: 'gmail', error: projectedError }
)
const loggerPayload = JSON.stringify(webhookExecutionLogger.error.mock.calls)
expect(loggerPayload).not.toContain(secret)
expect(loggerPayload).not.toContain('__var_')
expect(loggerPayload).not.toContain('__sim_')
expect(rawError.message).toContain(secret)
})
it('executes against the deployment version admitted by webhook ingress', async () => {
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})
await executeWebhookJob({
...payload,
deploymentVersionId: 'deployment-admitted',
})
expect(mockLoadDeploymentVersionState).toHaveBeenCalledWith(
'workflow-1',
'deployment-admitted',
'workspace-1'
)
})
it('does not pass provider-config provenance absent from the trigger input', async () => {
mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({
personalEncrypted: { WEBHOOK_SECRET: 'personal-ciphertext' },
workspaceEncrypted: { WEBHOOK_SECRET: 'workspace-ciphertext' },
personalDecrypted: { WEBHOOK_SECRET: 'personal-value' },
workspaceDecrypted: { WEBHOOK_SECRET: 'workspace-value' },
conflicts: ['WEBHOOK_SECRET'],
decryptionFailures: [],
})
mockResolveWebhookRecordProviderConfig.mockImplementation(
async (record, _userId, _workspaceId, options) => {
options.onResolved('WEBHOOK_SECRET', options.envVars.WEBHOOK_SECRET)
return record
}
)
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})
await executeWebhookJob(payload)
expect(mockResolveWebhookRecordProviderConfig).toHaveBeenCalledWith(
{ id: 'webhook-1' },
'user-1',
'workspace-1',
expect.objectContaining({
envVars: { WEBHOOK_SECRET: 'workspace-value' },
onResolved: expect.any(Function),
})
)
expect(mockExecuteWorkflowCore).toHaveBeenCalledWith(
expect.objectContaining({
trustedInitialResolvedSecretTraceProvenance: {
version: 1,
complete: true,
entries: [],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
},
})
)
expect(mockSetResolvedSecretTraceRegistry).toHaveBeenCalledOnce()
})
it('passes provider-config provenance when its value crosses in the trigger input', async () => {
mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: { WEBHOOK_SECRET: 'workspace-ciphertext' },
personalDecrypted: {},
workspaceDecrypted: { WEBHOOK_SECRET: 'workspace-value' },
conflicts: [],
decryptionFailures: [],
})
mockResolveWebhookRecordProviderConfig.mockImplementation(
async (record, _userId, _workspaceId, options) => {
options.onResolved('WEBHOOK_SECRET', options.envVars.WEBHOOK_SECRET)
return record
}
)
mockGetProviderHandler.mockReturnValue({
formatInput: vi.fn().mockResolvedValue({
input: { authorization: 'Bearer workspace-value' },
}),
})
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})
await executeWebhookJob(payload)
expect(mockExecuteWorkflowCore).toHaveBeenCalledWith(
expect.objectContaining({
trustedInitialResolvedSecretTraceProvenance: {
version: 1,
complete: true,
entries: [{ name: 'WEBHOOK_SECRET', encryptedValue: 'workspace-ciphertext' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
},
})
)
})
it('installs provenance before a post-resolution webhook setup failure', async () => {
const rawMessage = 'Webhook handler exposed activated-secret-value'
const rawError = new Error(rawMessage)
mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: { WEBHOOK_SECRET: 'workspace-ciphertext' },
personalDecrypted: {},
workspaceDecrypted: { WEBHOOK_SECRET: 'activated-secret-value' },
conflicts: [],
decryptionFailures: [],
})
mockResolveWebhookRecordProviderConfig.mockImplementation(
async (record, _userId, _workspaceId, options) => {
options.onResolved('WEBHOOK_SECRET', options.envVars.WEBHOOK_SECRET)
return record
}
)
mockGetProviderHandler.mockReturnValue({
formatInput: vi.fn().mockRejectedValue(rawError),
})
await expect(executeWebhookJob(payload)).rejects.toBe(rawError)
expect(mockSetResolvedSecretTraceRegistry).toHaveBeenCalledOnce()
expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ message: rawMessage }),
})
)
expect(mockExecuteWorkflowCore).not.toHaveBeenCalled()
})
it('acknowledges and skips queued webhook work after the workflow is undeployed', async () => {
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValueOnce({
success: true,
actorUserId: 'user-1',
billingAttribution,
workflowRecord: {
workspaceId: 'workspace-1',
userId: 'user-1',
variables: {},
isDeployed: false,
archivedAt: null,
},
executionTimeout: { async: 120_000 },
})
const result = await executeWebhookJob(payload)
expect(result).toMatchObject({ skipped: true, success: false, workflowId: 'workflow-1' })
expect(mockExecuteWorkflowCore).not.toHaveBeenCalled()
expect(mockReleaseExecutionSlot).toHaveBeenCalled()
})
it('releases the reservation when idempotency returns a cached result', async () => {
const cachedResult = {
success: true,
workflowId: 'workflow-1',
executionId: 'original-execution',
}
mockExecuteWithIdempotency.mockResolvedValueOnce(cachedResult)
await expect(executeWebhookJob(payload)).resolves.toBe(cachedResult)
expect(executionPreprocessingMockFns.mockPreprocessExecution).not.toHaveBeenCalled()
expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-1')
})
it('releases the reservation when background preprocessing fails', async () => {
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValueOnce({
success: false,
error: { message: 'workflow archived', statusCode: 404 },
})
await expect(executeWebhookJob(payload)).rejects.toThrow('workflow archived')
expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-1')
})
it('rejects queued webhook work without an immutable attribution snapshot', async () => {
await expect(
executeWebhookJob({
...payload,
billingAttribution: undefined,
} as unknown as WebhookExecutionPayload)
).rejects.toThrow('Billing attribution snapshot must be an object')
expect(executionPreprocessingMockFns.mockPreprocessExecution).not.toHaveBeenCalled()
})
})