Files
simstudioai--sim/apps/sim/lib/webhooks/providers/linear.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

177 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { linearHandler } from '@/lib/webhooks/providers/linear'
function signLinearBody(secret: string, rawBody: string): string {
return crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
}
function requestWithLinearSignature(secret: string, rawBody: string): NextRequest {
const signature = signLinearBody(secret, rawBody)
return new NextRequest('http://localhost/test', {
headers: {
'Linear-Signature': signature,
},
})
}
describe('Linear webhook provider', () => {
it('rejects signed requests when webhookTimestamp is missing', async () => {
const secret = 'linear-secret'
const rawBody = JSON.stringify({
action: 'create',
type: 'Issue',
})
const res = await linearHandler.verifyAuth!({
request: requestWithLinearSignature(secret, rawBody),
rawBody,
requestId: 'linear-t1',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects signed requests when webhookTimestamp skew is too large', async () => {
const secret = 'linear-secret'
const rawBody = JSON.stringify({
action: 'update',
type: 'Issue',
webhookTimestamp: Date.now() - 600_000,
})
const res = await linearHandler.verifyAuth!({
request: requestWithLinearSignature(secret, rawBody),
rawBody,
requestId: 'linear-t2',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('accepts signed requests within the allowed timestamp window', async () => {
const secret = 'linear-secret'
const rawBody = JSON.stringify({
action: 'update',
type: 'Issue',
webhookTimestamp: Date.now(),
})
const res = await linearHandler.verifyAuth!({
request: requestWithLinearSignature(secret, rawBody),
rawBody,
requestId: 'linear-t3',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('accepts signed requests within the 5-minute skew window (wider than Linears literal 60s suggestion, to tolerate retries)', async () => {
const secret = 'linear-secret'
const rawBody = JSON.stringify({
action: 'update',
type: 'Issue',
webhookTimestamp: Date.now() - 61_000,
})
const res = await linearHandler.verifyAuth!({
request: requestWithLinearSignature(secret, rawBody),
rawBody,
requestId: 'linear-t4',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('skips verification entirely when no webhookSecret is configured', async () => {
const rawBody = JSON.stringify({ action: 'create', type: 'Issue' })
const res = await linearHandler.verifyAuth!({
request: new NextRequest('http://localhost/test'),
rawBody,
requestId: 'linear-t5',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
describe('matchEvent', () => {
it('returns null-body-safe result instead of throwing when body is null', async () => {
const result = await linearHandler.matchEvent!({
body: null,
requestId: 'linear-t6',
providerConfig: { triggerId: 'linear_issue_created' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(result).toBe(false)
})
})
describe('formatInput', () => {
it('does not throw when body is null', async () => {
const { input } = await linearHandler.formatInput!({ body: null } as any)
expect(input).toMatchObject({ action: '', type: '', actor: null })
})
})
describe('extractIdempotencyId', () => {
it('builds a stable key from type, action, entity id, and updatedAt', () => {
const key = linearHandler.extractIdempotencyId!({
type: 'Issue',
action: 'update',
data: { id: 'issue-1', updatedAt: '2026-07-08T00:00:00.000Z' },
})
expect(key).toBe('linear:Issue:update:issue-1:2026-07-08T00:00:00.000Z')
})
it('falls back to createdAt when updatedAt is absent (create events)', () => {
const key = linearHandler.extractIdempotencyId!({
type: 'Comment',
action: 'create',
data: { id: 'comment-1', createdAt: '2026-07-08T00:00:00.000Z' },
})
expect(key).toBe('linear:Comment:create:comment-1:2026-07-08T00:00:00.000Z')
})
it('returns null when the entity id is missing', () => {
const key = linearHandler.extractIdempotencyId!({ type: 'Issue', action: 'create' })
expect(key).toBeNull()
})
it('returns null when type is missing', () => {
const key = linearHandler.extractIdempotencyId!({ action: 'create', data: { id: 'x' } })
expect(key).toBeNull()
})
it('returns null instead of throwing when the body is null', () => {
expect(linearHandler.extractIdempotencyId!(null)).toBeNull()
})
it('returns null instead of throwing when the body is a non-object', () => {
expect(linearHandler.extractIdempotencyId!('not an object')).toBeNull()
expect(linearHandler.extractIdempotencyId!(42)).toBeNull()
expect(linearHandler.extractIdempotencyId!(['array'])).toBeNull()
})
})
})