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
177 lines
5.4 KiB
TypeScript
177 lines
5.4 KiB
TypeScript
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 Linear’s 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()
|
||
})
|
||
})
|
||
})
|