@andypai/agent-kanban 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-jql.test.ts +51 -0
  13. package/src/__tests__/jira-provider-read.test.ts +50 -0
  14. package/src/__tests__/mcp-server.test.ts +2 -2
  15. package/src/__tests__/metrics.test.ts +37 -1
  16. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  17. package/src/__tests__/server.test.ts +126 -0
  18. package/src/__tests__/use-cases.test.ts +77 -0
  19. package/src/__tests__/webhooks.test.ts +75 -22
  20. package/src/api.ts +58 -36
  21. package/src/column-roles.ts +52 -0
  22. package/src/commands/board.ts +4 -4
  23. package/src/db.ts +145 -114
  24. package/src/errors.ts +1 -0
  25. package/src/index.ts +84 -33
  26. package/src/mcp/core.ts +8 -7
  27. package/src/metrics.ts +48 -23
  28. package/src/provider-runtime.ts +9 -2
  29. package/src/providers/index.ts +4 -1
  30. package/src/providers/jira-cache.ts +23 -6
  31. package/src/providers/jira-core.ts +921 -0
  32. package/src/providers/jira-jql.ts +48 -0
  33. package/src/providers/jira.ts +111 -759
  34. package/src/providers/linear-cache.ts +5 -3
  35. package/src/providers/linear-core.ts +688 -0
  36. package/src/providers/linear.ts +70 -554
  37. package/src/providers/postgres-jira-cache.ts +597 -0
  38. package/src/providers/postgres-jira.ts +15 -1312
  39. package/src/providers/postgres-linear-cache.ts +500 -0
  40. package/src/providers/postgres-linear.ts +22 -1088
  41. package/src/providers/postgres-local.ts +181 -74
  42. package/src/providers/types.ts +71 -2
  43. package/src/server.ts +118 -32
  44. package/src/use-cases.ts +139 -0
  45. package/src/webhooks.ts +26 -0
  46. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  47. package/ui/dist/index.html +1 -1
  48. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test, beforeEach } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns, addTask } from '../db'
3
+ import { initSchema, seedDefaultColumns, addTask, addColumn, moveTask } from '../db'
4
4
  import { getBoardMetrics } from '../metrics'
5
5
 
6
6
  let db: Database
@@ -62,3 +62,39 @@ describe('getBoardMetrics', () => {
62
62
  expect(metrics.tasksByPriority).toHaveLength(0)
63
63
  })
64
64
  })
65
+
66
+ describe('getBoardMetrics with custom column names', () => {
67
+ function customBoard(columnNames: string[]): Database {
68
+ const db = new Database(':memory:')
69
+ db.run('PRAGMA foreign_keys = ON')
70
+ initSchema(db)
71
+ for (const name of columnNames) addColumn(db, name)
72
+ return db
73
+ }
74
+
75
+ test('classifies done/in-progress by role despite custom case and spacing', () => {
76
+ const db = customBoard(['Todo', 'In Progress', 'Human Review', 'Merging', 'Done'])
77
+ addTask(db, 'a', { column: 'Todo' })
78
+ const b = addTask(db, 'b', { column: 'Todo' })
79
+ const c = addTask(db, 'c', { column: 'Todo' })
80
+ moveTask(db, b.id, 'In Progress')
81
+ moveTask(db, c.id, 'Done')
82
+
83
+ const metrics = getBoardMetrics(db)
84
+ // 'In Progress' (space + caps) used to never match the literal 'in-progress'.
85
+ expect(metrics.inProgressCount).toBe(1)
86
+ expect(metrics.completedTasks).toBe(1)
87
+ expect(metrics.completionPercent).toBe(33)
88
+ })
89
+
90
+ test('falls back to the terminal column for completed when no done-named column', () => {
91
+ const db = customBoard(['Todo', 'Doing', 'Shipping'])
92
+ const t = addTask(db, 'a', { column: 'Todo' })
93
+ moveTask(db, t.id, 'Shipping')
94
+
95
+ const metrics = getBoardMetrics(db)
96
+ expect(metrics.completedTasks).toBe(1)
97
+ // 'Doing' is a recognized in-progress synonym.
98
+ expect(metrics.inProgressCount).toBe(0)
99
+ })
100
+ })
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import postgres from 'postgres'
3
3
 
4
4
  import { run } from '../index'
5
- import type { Task, TaskComment, TaskWithColumn } from '../types'
5
+ import { openKanbanRuntime } from '../provider-runtime'
6
+ import type { BoardMetrics, Task, TaskComment, TaskWithColumn } from '../types'
6
7
 
7
8
  const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
8
9
  const pgTest = databaseUrl ? test : test.skip
@@ -131,4 +132,92 @@ describe('postgres local provider', () => {
131
132
 
132
133
  expect(created.column_name).toBe('Todo')
133
134
  })
135
+
136
+ async function positionsInColumn(
137
+ columnName: string,
138
+ ): Promise<Array<{ id: string; position: number }>> {
139
+ if (!sql) throw new Error('sql not initialized')
140
+ return sql<Array<{ id: string; position: number }>>`
141
+ SELECT t.id, t.position FROM tasks t
142
+ JOIN columns c ON t.column_id = c.id
143
+ WHERE c.name = ${columnName}
144
+ ORDER BY t.position
145
+ `
146
+ }
147
+
148
+ pgTest('move renumbers the source column and records column-time tracking', async () => {
149
+ const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
150
+ const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
151
+ const c = expectOk<TaskWithColumn>(await run(['task', 'add', 'C', '-c', 'backlog']))
152
+ expect((await positionsInColumn('backlog')).map((r) => r.id)).toEqual([a.id, b.id, c.id])
153
+
154
+ expectOk<Task>(await run(['task', 'move', b.id, 'in-progress']))
155
+
156
+ // Source column compacted to 0..n-1 with no gap left by B.
157
+ expect(await positionsInColumn('backlog')).toEqual([
158
+ { id: a.id, position: 0 },
159
+ { id: c.id, position: 1 },
160
+ ])
161
+
162
+ // B has a closed backlog interval and an open in-progress interval.
163
+ const tracking = await sql!<Array<{ column_name: string; exited_at: string | null }>>`
164
+ SELECT c.name AS column_name, ct.exited_at
165
+ FROM column_time_tracking ct JOIN columns c ON ct.column_id = c.id
166
+ WHERE ct.task_id = ${b.id}
167
+ ORDER BY ct.entered_at
168
+ `
169
+ expect(tracking.map((r) => r.column_name)).toEqual(['backlog', 'in-progress'])
170
+ expect(tracking[0]!.exited_at).not.toBeNull()
171
+ expect(tracking[1]!.exited_at).toBeNull()
172
+ })
173
+
174
+ pgTest('move to the same column is a no-op (no extra tracking, no reorder)', async () => {
175
+ const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
176
+ const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
177
+
178
+ expectOk<Task>(await run(['task', 'move', a.id, 'backlog']))
179
+
180
+ // Order preserved (no re-append of A to the end).
181
+ expect((await positionsInColumn('backlog')).map((r) => r.id)).toEqual([a.id, b.id])
182
+ // Only the single creation interval exists for A.
183
+ const trackingRows = await sql!<Array<{ count: string | number }>>`
184
+ SELECT COUNT(*) AS count FROM column_time_tracking WHERE task_id = ${a.id}
185
+ `
186
+ expect(Number(trackingRows[0]?.count ?? 0)).toBe(1)
187
+ })
188
+
189
+ pgTest('delete renumbers the remaining tasks in the column', async () => {
190
+ const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
191
+ const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
192
+ const c = expectOk<TaskWithColumn>(await run(['task', 'add', 'C', '-c', 'backlog']))
193
+
194
+ expectOk<Task>(await run(['task', 'delete', b.id]))
195
+
196
+ expect(await positionsInColumn('backlog')).toEqual([
197
+ { id: a.id, position: 0 },
198
+ { id: c.id, position: 1 },
199
+ ])
200
+ })
201
+
202
+ pgTest('getMetrics reports completion time for tasks that reached Done', async () => {
203
+ const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
204
+ expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
205
+ expectOk<Task>(await run(['task', 'move', a.id, 'done']))
206
+
207
+ const runtime = await openKanbanRuntime()
208
+ let metrics: BoardMetrics
209
+ try {
210
+ metrics = await runtime.provider.getMetrics()
211
+ } finally {
212
+ await runtime.close()
213
+ }
214
+
215
+ expect(metrics.completedTasks).toBe(1)
216
+ // Bug fix: a task resting in Done now contributes to the average (no longer
217
+ // requires exited_at), and the value is a real number, not a string.
218
+ expect(typeof metrics.avgCompletionHours).toBe('number')
219
+ expect(metrics.avgCompletionHours!).toBeGreaterThanOrEqual(0)
220
+ expect(metrics.tasksCreatedThisWeek).toBe(2)
221
+ expect(metrics.inProgressCount).toBe(0)
222
+ })
134
223
  })
@@ -289,3 +289,129 @@ describe('startServer', () => {
289
289
  expect(syncCalls).toBeGreaterThanOrEqual(2)
290
290
  })
291
291
  })
292
+
293
+ describe('startServer auth + CORS', () => {
294
+ const TOKEN = 'secret-token'
295
+
296
+ test('without a token, the API stays open (localhost default)', async () => {
297
+ const runtime = startServer(makeProvider(), 0)
298
+ runtimes.push(runtime)
299
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`)
300
+ expect(res.status).toBe(200)
301
+ })
302
+
303
+ test('with a token, protected routes require Bearer auth', async () => {
304
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
305
+ runtimes.push(runtime)
306
+
307
+ const noAuth = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`)
308
+ expect(noAuth.status).toBe(401)
309
+ const noAuthBody = (await noAuth.json()) as { ok: boolean; error: { code: string } }
310
+ expect(noAuthBody.ok).toBe(false)
311
+ expect(noAuthBody.error.code).toBe('UNAUTHORIZED')
312
+
313
+ const wrong = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`, {
314
+ headers: { Authorization: 'Bearer nope' },
315
+ })
316
+ expect(wrong.status).toBe(401)
317
+
318
+ const ok = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`, {
319
+ headers: { Authorization: `Bearer ${TOKEN}` },
320
+ })
321
+ expect(ok.status).toBe(200)
322
+ })
323
+
324
+ test('?token= does NOT authorize HTTP API routes (header only)', async () => {
325
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
326
+ runtimes.push(runtime)
327
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap?token=${TOKEN}`)
328
+ expect(res.status).toBe(401)
329
+ })
330
+
331
+ test('/ws requires the token and accepts it as a query param', async () => {
332
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
333
+ runtimes.push(runtime)
334
+
335
+ // Plain GET (no upgrade) exercises the auth gate before the upgrade attempt.
336
+ const noToken = await fetch(`http://127.0.0.1:${runtime.port}/ws`)
337
+ expect(noToken.status).toBe(401)
338
+
339
+ // Correct query token passes the gate; the non-WebSocket request then fails
340
+ // the upgrade (400) rather than auth (401).
341
+ const withToken = await fetch(`http://127.0.0.1:${runtime.port}/ws?token=${TOKEN}`)
342
+ expect(withToken.status).not.toBe(401)
343
+ })
344
+
345
+ test('with a token, /api/health stays public', async () => {
346
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
347
+ runtimes.push(runtime)
348
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/health`)
349
+ expect(res.status).toBe(200)
350
+ })
351
+
352
+ test('with a token, webhook routes are exempt (provider secret guards them)', async () => {
353
+ const runtime = startServer(
354
+ makeProvider({
355
+ type: 'local',
356
+ async handleWebhook() {
357
+ return { handled: true }
358
+ },
359
+ }),
360
+ 0,
361
+ { authToken: TOKEN },
362
+ )
363
+ runtimes.push(runtime)
364
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/webhooks/local`, {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: '{}',
368
+ })
369
+ // Reaches the provider handler instead of being rejected by bearer auth.
370
+ expect(res.status).not.toBe(401)
371
+ })
372
+
373
+ test('wsClients are tracked per server instance, not shared globally', async () => {
374
+ const a = startServer(makeProvider(), 0)
375
+ runtimes.push(a)
376
+ const b = startServer(makeProvider(), 0)
377
+ runtimes.push(b)
378
+
379
+ const ws = new WebSocket(`ws://127.0.0.1:${a.port}/ws`)
380
+ await new Promise<void>((resolve, reject) => {
381
+ ws.onopen = () => resolve()
382
+ ws.onerror = () => reject(new Error('ws connection failed'))
383
+ })
384
+ // Let server A's open handler register the socket.
385
+ await sleep(25)
386
+
387
+ const healthA = (await (await fetch(`http://127.0.0.1:${a.port}/api/health`)).json()) as {
388
+ data: { wsClients: number }
389
+ }
390
+ const healthB = (await (await fetch(`http://127.0.0.1:${b.port}/api/health`)).json()) as {
391
+ data: { wsClients: number }
392
+ }
393
+ expect(healthA.data.wsClients).toBe(1)
394
+ // Before per-instance scoping, B shared A's global set and also reported 1.
395
+ expect(healthB.data.wsClients).toBe(0)
396
+ ws.close()
397
+ })
398
+
399
+ test('CORS headers are emitted only when an allowed origin is configured', async () => {
400
+ const withOrigin = startServer(makeProvider(), 0, {
401
+ allowedOrigin: 'https://kanban.example',
402
+ })
403
+ runtimes.push(withOrigin)
404
+ const res = await fetch(`http://127.0.0.1:${withOrigin.port}/api/health`)
405
+ expect(res.headers.get('access-control-allow-origin')).toBe('https://kanban.example')
406
+
407
+ const preflight = await fetch(`http://127.0.0.1:${withOrigin.port}/api/bootstrap`, {
408
+ method: 'OPTIONS',
409
+ })
410
+ expect(preflight.headers.get('access-control-allow-origin')).toBe('https://kanban.example')
411
+
412
+ const noOrigin = startServer(makeProvider(), 0)
413
+ runtimes.push(noOrigin)
414
+ const bare = await fetch(`http://127.0.0.1:${noOrigin.port}/api/health`)
415
+ expect(bare.headers.get('access-control-allow-origin')).toBeNull()
416
+ })
417
+ })
@@ -0,0 +1,77 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { initSchema, seedDefaultColumns } from '../db'
4
+ import { createProvider } from '../providers/index'
5
+ import type { KanbanProvider } from '../providers/types'
6
+ import * as useCases from '../use-cases'
7
+
8
+ let db: Database
9
+ let provider: KanbanProvider
10
+
11
+ beforeEach(() => {
12
+ db = new Database(':memory:')
13
+ db.run('PRAGMA foreign_keys = ON')
14
+ initSchema(db)
15
+ seedDefaultColumns(db)
16
+ provider = createProvider(db, { provider: 'local' }, ':memory:')
17
+ })
18
+
19
+ describe('use-cases label normalization', () => {
20
+ // The use-case layer owns label normalization so every transport feeds raw
21
+ // labels in its own shape and gets the same result.
22
+ test('normalizes CLI-style nested flag arrays', async () => {
23
+ const task = await useCases.createTask(provider, {
24
+ title: 'cli',
25
+ labels: [['bug', 'ui'], undefined],
26
+ })
27
+ expect(task.labels).toEqual(['bug', 'ui'])
28
+ })
29
+
30
+ test('normalizes HTTP-style string arrays', async () => {
31
+ const task = await useCases.createTask(provider, {
32
+ title: 'http',
33
+ labels: ['bug', 'ui'],
34
+ })
35
+ expect(task.labels).toEqual(['bug', 'ui'])
36
+ })
37
+
38
+ test('normalizes a comma-separated string and de-dupes', async () => {
39
+ const task = await useCases.createTask(provider, {
40
+ title: 'csv',
41
+ labels: 'bug, ui, bug',
42
+ })
43
+ expect(task.labels).toEqual(['bug', 'ui'])
44
+ })
45
+
46
+ test('treats omitted labels as none', async () => {
47
+ const task = await useCases.createTask(provider, { title: 'none' })
48
+ expect(task.labels).toEqual([])
49
+ })
50
+ })
51
+
52
+ describe('use-cases provider forwarding', () => {
53
+ test('createTask then getTask/listTasks round-trip', async () => {
54
+ const created = await useCases.createTask(provider, { title: 'roundtrip', column: 'backlog' })
55
+ const fetched = await useCases.getTask(provider, created.id)
56
+ expect(fetched.id).toBe(created.id)
57
+ const all = await useCases.listTasks(provider)
58
+ expect(all.map((t) => t.id)).toContain(created.id)
59
+ })
60
+
61
+ test('moveTask forwards to the target column and returns the task', async () => {
62
+ const created = await useCases.createTask(provider, { title: 'mover', column: 'backlog' })
63
+ const columns = await useCases.listColumns(provider)
64
+ const target = columns.find((c) => c.name.toLowerCase() === 'in-progress')!
65
+ const moved = await useCases.moveTask(provider, created.id, target.name)
66
+ expect(moved.column_id).toBe(target.id)
67
+ })
68
+
69
+ test('addComment / listComments / updateComment forward correctly', async () => {
70
+ const task = await useCases.createTask(provider, { title: 'commented' })
71
+ const comment = await useCases.addComment(provider, task.id, 'first')
72
+ const listed = await useCases.listComments(provider, task.id)
73
+ expect(listed.map((c) => c.body)).toContain('first')
74
+ const updated = await useCases.updateComment(provider, task.id, comment.id, 'edited')
75
+ expect(updated.body).toBe('edited')
76
+ })
77
+ })
@@ -27,6 +27,18 @@ function hmac(secret: string, body: string): string {
27
27
  return createHmac('sha256', secret).update(body).digest('hex')
28
28
  }
29
29
 
30
+ const JIRA_TEST_SECRET = 'topsecret'
31
+ const LINEAR_TEST_SECRET = 'hushhush'
32
+
33
+ // Sign for both providers; each handler only reads its own header, so this works
34
+ // regardless of which provider the test exercises.
35
+ function signedHeaders(body: string): Record<string, string> {
36
+ return {
37
+ 'x-hub-signature': `sha256=${hmac(JIRA_TEST_SECRET, body)}`,
38
+ 'linear-signature': hmac(LINEAR_TEST_SECRET, body),
39
+ }
40
+ }
41
+
30
42
  function jsonResponse(body: unknown, status = 200): Response {
31
43
  return new Response(JSON.stringify(body), {
32
44
  status,
@@ -108,7 +120,7 @@ describe('Jira webhook', () => {
108
120
  test('issue_created upserts the task', async () => {
109
121
  const db = new Database(':memory:')
110
122
  seedJira(db)
111
- delete process.env['JIRA_WEBHOOK_SECRET']
123
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
112
124
  const client = new JiraClient({
113
125
  baseUrl: jiraConfig.baseUrl,
114
126
  email: jiraConfig.email,
@@ -133,7 +145,7 @@ describe('Jira webhook', () => {
133
145
  },
134
146
  },
135
147
  })
136
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
148
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
137
149
  expect(result.handled).toBe(true)
138
150
  const tasks = getCachedJiraTasks(db)
139
151
  expect(tasks.find((t) => t.externalRef === 'ENG-100')?.title).toBe('New issue')
@@ -142,7 +154,7 @@ describe('Jira webhook', () => {
142
154
  test('issue_created accepts Jira webhook string descriptions', async () => {
143
155
  const db = new Database(':memory:')
144
156
  seedJira(db)
145
- delete process.env['JIRA_WEBHOOK_SECRET']
157
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
146
158
  const client = new JiraClient({
147
159
  baseUrl: jiraConfig.baseUrl,
148
160
  email: jiraConfig.email,
@@ -170,7 +182,7 @@ describe('Jira webhook', () => {
170
182
  },
171
183
  })
172
184
 
173
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
185
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
174
186
 
175
187
  expect(result.handled).toBe(true)
176
188
  expect(getCachedJiraTasks(db).find((t) => t.externalRef === 'ENG-101')?.description).toBe(
@@ -193,7 +205,7 @@ describe('Jira webhook', () => {
193
205
  updatedAt: '2025-02-01',
194
206
  },
195
207
  ])
196
- delete process.env['JIRA_WEBHOOK_SECRET']
208
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
197
209
  const client = new JiraClient({
198
210
  baseUrl: jiraConfig.baseUrl,
199
211
  email: jiraConfig.email,
@@ -214,7 +226,7 @@ describe('Jira webhook', () => {
214
226
  },
215
227
  },
216
228
  })
217
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
229
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
218
230
  expect(result.handled).toBe(true)
219
231
  expect(getCachedJiraTasks(db).find((t) => t.externalRef === 'ENG-200')).toBeUndefined()
220
232
  })
@@ -298,7 +310,7 @@ describe('Jira webhook', () => {
298
310
  test('ignores issue updates from other projects', async () => {
299
311
  const db = new Database(':memory:')
300
312
  seedJira(db)
301
- delete process.env['JIRA_WEBHOOK_SECRET']
313
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
302
314
  const client = new JiraClient({
303
315
  baseUrl: jiraConfig.baseUrl,
304
316
  email: jiraConfig.email,
@@ -323,7 +335,7 @@ describe('Jira webhook', () => {
323
335
  },
324
336
  })
325
337
 
326
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
338
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
327
339
 
328
340
  expect(result.handled).toBe(false)
329
341
  expect(result.message).toContain('Ignoring issue from project')
@@ -333,7 +345,7 @@ describe('Jira webhook', () => {
333
345
  test('issue_updated backfills activity immediately', async () => {
334
346
  const db = new Database(':memory:')
335
347
  seedJira(db)
336
- delete process.env['JIRA_WEBHOOK_SECRET']
348
+ process.env['JIRA_WEBHOOK_SECRET'] = JIRA_TEST_SECRET
337
349
  const originalFetch = globalThis.fetch
338
350
  globalThis.fetch = (async (input) => {
339
351
  const url =
@@ -381,7 +393,7 @@ describe('Jira webhook', () => {
381
393
  },
382
394
  })
383
395
 
384
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
396
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
385
397
 
386
398
  expect(result.handled).toBe(true)
387
399
  expect(getCachedJiraTasks(db).find((task) => task.externalRef === 'ENG-600')?.column_id).toBe(
@@ -434,7 +446,7 @@ describe('Linear webhook', () => {
434
446
  test('Issue.create upserts the task', async () => {
435
447
  const db = new Database(':memory:')
436
448
  seedLinear(db)
437
- delete process.env['LINEAR_WEBHOOK_SECRET']
449
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
438
450
  const provider = new LinearProvider(db, 'tid', 'key')
439
451
  const body = JSON.stringify({
440
452
  action: 'create',
@@ -454,7 +466,7 @@ describe('Linear webhook', () => {
454
466
  commentCount: 1,
455
467
  },
456
468
  })
457
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
469
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
458
470
  expect(result.handled).toBe(true)
459
471
  const tasks = getCachedLinearTasks(db)
460
472
  const issue = tasks.find((t) => t.externalRef === 'DX-1')
@@ -479,14 +491,14 @@ describe('Linear webhook', () => {
479
491
  updatedAt: '2025-01',
480
492
  },
481
493
  ])
482
- delete process.env['LINEAR_WEBHOOK_SECRET']
494
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
483
495
  const provider = new LinearProvider(db, 'tid', 'key')
484
496
  const body = JSON.stringify({
485
497
  action: 'remove',
486
498
  type: 'Issue',
487
499
  data: { id: 'ix', identifier: 'DX-9' },
488
500
  })
489
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
501
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
490
502
  expect(result.handled).toBe(true)
491
503
  expect(getCachedLinearTasks(db).find((t) => t.externalRef === 'DX-9')).toBeUndefined()
492
504
  })
@@ -494,7 +506,7 @@ describe('Linear webhook', () => {
494
506
  test('ignores create/update events from another team', async () => {
495
507
  const db = new Database(':memory:')
496
508
  seedLinear(db)
497
- delete process.env['LINEAR_WEBHOOK_SECRET']
509
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
498
510
  const provider = new LinearProvider(db, 'tid', 'key')
499
511
  const body = JSON.stringify({
500
512
  action: 'update',
@@ -510,7 +522,7 @@ describe('Linear webhook', () => {
510
522
  },
511
523
  })
512
524
 
513
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
525
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
514
526
 
515
527
  expect(result.handled).toBe(false)
516
528
  expect(result.message).toContain("Ignoring issue from team 'other-team'")
@@ -520,7 +532,7 @@ describe('Linear webhook', () => {
520
532
  test('falls back to issue-team lookup when the webhook payload omits team info', async () => {
521
533
  const db = new Database(':memory:')
522
534
  seedLinear(db)
523
- delete process.env['LINEAR_WEBHOOK_SECRET']
535
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
524
536
  globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
525
537
  const body = JSON.parse(String(init?.body)) as {
526
538
  query: string
@@ -559,7 +571,7 @@ describe('Linear webhook', () => {
559
571
  },
560
572
  })
561
573
 
562
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
574
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
563
575
 
564
576
  expect(result.handled).toBe(false)
565
577
  expect(result.message).toContain("Ignoring issue from team 'OPS'")
@@ -583,7 +595,7 @@ describe('Linear webhook', () => {
583
595
  updatedAt: '2025-02-01T00:00:00.000Z',
584
596
  },
585
597
  ])
586
- delete process.env['LINEAR_WEBHOOK_SECRET']
598
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
587
599
  const provider = new LinearProvider(db, 'tid', 'key')
588
600
  const body = JSON.stringify({
589
601
  action: 'update',
@@ -599,7 +611,7 @@ describe('Linear webhook', () => {
599
611
  },
600
612
  })
601
613
 
602
- const result = await provider.handleWebhook({ headers: {}, rawBody: body })
614
+ const result = await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
603
615
 
604
616
  expect(result.handled).toBe(true)
605
617
  expect(
@@ -610,7 +622,7 @@ describe('Linear webhook', () => {
610
622
  test('create event stamps lastWebhookAt without clobbering team/lastSyncAt', async () => {
611
623
  const db = new Database(':memory:')
612
624
  seedLinear(db)
613
- delete process.env['LINEAR_WEBHOOK_SECRET']
625
+ process.env['LINEAR_WEBHOOK_SECRET'] = LINEAR_TEST_SECRET
614
626
  const provider = new LinearProvider(db, 'tid', 'key')
615
627
  const body = JSON.stringify({
616
628
  action: 'create',
@@ -626,7 +638,7 @@ describe('Linear webhook', () => {
626
638
  },
627
639
  })
628
640
  const before = Date.now()
629
- await provider.handleWebhook({ headers: {}, rawBody: body })
641
+ await provider.handleWebhook({ headers: signedHeaders(body), rawBody: body })
630
642
  const meta = loadSyncMeta(db)
631
643
  expect(meta.lastWebhookAt).not.toBeNull()
632
644
  expect(new Date(meta.lastWebhookAt!).getTime()).toBeGreaterThanOrEqual(before)
@@ -677,3 +689,44 @@ describe('Linear webhook', () => {
677
689
  expect(result.unauthorized).toBe(true)
678
690
  })
679
691
  })
692
+
693
+ describe('webhook fail-closed when secret is unset', () => {
694
+ test('Jira rejects unauthenticated webhooks when JIRA_WEBHOOK_SECRET is unset', async () => {
695
+ const prev = process.env['JIRA_WEBHOOK_SECRET']
696
+ delete process.env['JIRA_WEBHOOK_SECRET']
697
+ try {
698
+ const db = new Database(':memory:')
699
+ seedJira(db)
700
+ const client = new JiraClient({
701
+ baseUrl: jiraConfig.baseUrl,
702
+ email: jiraConfig.email,
703
+ apiToken: jiraConfig.apiToken,
704
+ })
705
+ const provider = new JiraProvider(db, jiraConfig, client)
706
+ const body = JSON.stringify({ webhookEvent: 'jira:issue_created', issue: { id: '1' } })
707
+ const result = await provider.handleWebhook({ headers: {}, rawBody: body })
708
+ expect(result.handled).toBe(false)
709
+ expect(result.unauthorized).toBe(true)
710
+ } finally {
711
+ if (prev === undefined) delete process.env['JIRA_WEBHOOK_SECRET']
712
+ else process.env['JIRA_WEBHOOK_SECRET'] = prev
713
+ }
714
+ })
715
+
716
+ test('Linear rejects unauthenticated webhooks when LINEAR_WEBHOOK_SECRET is unset', async () => {
717
+ const prev = process.env['LINEAR_WEBHOOK_SECRET']
718
+ delete process.env['LINEAR_WEBHOOK_SECRET']
719
+ try {
720
+ const db = new Database(':memory:')
721
+ seedLinear(db)
722
+ const provider = new LinearProvider(db, 'tid', 'key')
723
+ const body = JSON.stringify({ action: 'create', type: 'Issue', data: { id: 'i1' } })
724
+ const result = await provider.handleWebhook({ headers: {}, rawBody: body })
725
+ expect(result.handled).toBe(false)
726
+ expect(result.unauthorized).toBe(true)
727
+ } finally {
728
+ if (prev === undefined) delete process.env['LINEAR_WEBHOOK_SECRET']
729
+ else process.env['LINEAR_WEBHOOK_SECRET'] = prev
730
+ }
731
+ })
732
+ })