@andypai/agent-kanban 0.7.0 → 0.8.0
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.
- package/README.md +13 -8
- package/package.json +1 -1
- package/src/__tests__/api.test.ts +220 -0
- package/src/__tests__/cli-task-lifecycle-matrix.test.ts +1262 -0
- package/src/__tests__/index.test.ts +107 -3
- package/src/__tests__/jira-wiring.test.ts +25 -2
- package/src/__tests__/server.test.ts +78 -0
- package/src/__tests__/sync-config.test.ts +14 -1
- package/src/__tests__/tracker-config-webhook-secret.test.ts +89 -0
- package/src/__tests__/transport-input.test.ts +67 -1
- package/src/__tests__/tunnel.test.ts +116 -0
- package/src/__tests__/webhook-events-receipts.test.ts +155 -0
- package/src/__tests__/webhooks.test.ts +47 -1
- package/src/api.ts +53 -15
- package/src/db.ts +18 -1
- package/src/index.ts +76 -17
- package/src/providers/factory.ts +1 -1
- package/src/providers/jira-core.ts +9 -3
- package/src/providers/linear-core.ts +9 -3
- package/src/providers/local.ts +2 -2
- package/src/providers/sqlite-local-store.ts +5 -1
- package/src/sync-config.ts +7 -2
- package/src/tracker-config.ts +72 -4
- package/src/transport-input.ts +46 -14
- package/src/tunnel.ts +22 -5
|
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors'
|
|
7
7
|
import type { Task, TaskComment } from '../types'
|
|
8
|
-
import { parseMcpArgs, parseServeArgs, run } from '../index'
|
|
8
|
+
import { assertTunnelSecurity, parseMcpArgs, parseServeArgs, run } from '../index'
|
|
9
9
|
|
|
10
10
|
async function withTempDb(runTest: (dbPath: string) => Promise<void>): Promise<void> {
|
|
11
11
|
const dir = mkdtempSync(join(tmpdir(), 'kanban-run-'))
|
|
@@ -85,12 +85,38 @@ describe('parseServeArgs', () => {
|
|
|
85
85
|
})
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
-
test('--sync-interval-ms rejects
|
|
89
|
-
|
|
88
|
+
test('--sync-interval-ms rejects below-minimum and overflow values as INVALID_ARGUMENT', () => {
|
|
89
|
+
// The whole flag family reports rejections with one code: a digit-but-too-small
|
|
90
|
+
// value, a value past MAX_SAFE_INTEGER (silent precision loss), OR a digit
|
|
91
|
+
// string so long that Number() overflows to Infinity — all INVALID_ARGUMENT.
|
|
92
|
+
for (const raw of ['999', '0', '9007199254740993', '9'.repeat(309)]) {
|
|
90
93
|
expect(() => parseServeArgs(['serve', '--sync-interval-ms', raw])).toThrow(KanbanError)
|
|
94
|
+
try {
|
|
95
|
+
parseServeArgs(['serve', '--sync-interval-ms', raw])
|
|
96
|
+
} catch (err) {
|
|
97
|
+
expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
|
|
98
|
+
}
|
|
91
99
|
}
|
|
92
100
|
})
|
|
93
101
|
|
|
102
|
+
test('D7: --sync-interval-ms rejects non-digit notation (hex/scientific/garbage/empty)', () => {
|
|
103
|
+
for (const bad of ['0x1000', '1e3', '1_000', '3.5', 'abc', '-1000']) {
|
|
104
|
+
expect(() => parseServeArgs(['serve', `--sync-interval-ms=${bad}`])).toThrow(KanbanError)
|
|
105
|
+
try {
|
|
106
|
+
parseServeArgs(['serve', `--sync-interval-ms=${bad}`])
|
|
107
|
+
} catch (err) {
|
|
108
|
+
expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// explicit empty is rejected rather than silently falling back to the default
|
|
112
|
+
expect(() => parseServeArgs(['serve', '--sync-interval-ms='])).toThrow(KanbanError)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('D7: --sync-interval-ms accepts a plain integer >= 1000', () => {
|
|
116
|
+
expect(parseServeArgs(['serve', '--sync-interval-ms=1000']).syncIntervalMs).toBe(1000)
|
|
117
|
+
expect(parseServeArgs(['serve', '--sync-interval-ms=600000']).syncIntervalMs).toBe(600000)
|
|
118
|
+
})
|
|
119
|
+
|
|
94
120
|
test('--token / --allowed-origin flags and env are parsed; flags win over env', () => {
|
|
95
121
|
const prevToken = process.env['KANBAN_API_TOKEN']
|
|
96
122
|
const prevOrigin = process.env['KANBAN_ALLOWED_ORIGIN']
|
|
@@ -129,6 +155,84 @@ describe('parseServeArgs', () => {
|
|
|
129
155
|
expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
|
|
130
156
|
}
|
|
131
157
|
})
|
|
158
|
+
|
|
159
|
+
test('D6: rejects non-integer / out-of-range / trailing-garbage ports', () => {
|
|
160
|
+
for (const bad of ['abc', '123abc', '-1', '70000', '3.5', '0x10', '']) {
|
|
161
|
+
expect(() => parseServeArgs(['serve', `--port=${bad}`])).toThrow(KanbanError)
|
|
162
|
+
try {
|
|
163
|
+
parseServeArgs(['serve', `--port=${bad}`])
|
|
164
|
+
} catch (err) {
|
|
165
|
+
expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('D6: accepts valid ports including 0 (OS-assigned ephemeral)', () => {
|
|
171
|
+
expect(parseServeArgs(['serve', '--port=0']).port).toBe(0)
|
|
172
|
+
expect(parseServeArgs(['serve', '--port=8080']).port).toBe(8080)
|
|
173
|
+
expect(parseServeArgs(['serve', '--port=65535']).port).toBe(65535)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('D6: an invalid PORT env value is rejected too', () => {
|
|
177
|
+
const prev = process.env['PORT']
|
|
178
|
+
process.env['PORT'] = 'not-a-port'
|
|
179
|
+
try {
|
|
180
|
+
expect(() => parseServeArgs(['serve'])).toThrow(KanbanError)
|
|
181
|
+
} finally {
|
|
182
|
+
if (prev === undefined) delete process.env['PORT']
|
|
183
|
+
else process.env['PORT'] = prev
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('assertTunnelSecurity (F44 + D5)', () => {
|
|
189
|
+
test('no tunnel: always allowed regardless of token/secret', () => {
|
|
190
|
+
expect(() => assertTunnelSecurity({ tunnel: false }, {})).not.toThrow()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('F44: tunnel without an API token is refused', () => {
|
|
194
|
+
expect(() => assertTunnelSecurity({ tunnel: true }, {})).toThrow(KanbanError)
|
|
195
|
+
try {
|
|
196
|
+
assertTunnelSecurity({ tunnel: true }, {})
|
|
197
|
+
} catch (err) {
|
|
198
|
+
expect((err as KanbanError).message).toContain('without an API token')
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('tunnel + token on the local provider is allowed (local has no webhooks)', () => {
|
|
203
|
+
expect(() =>
|
|
204
|
+
assertTunnelSecurity({ tunnel: true, authToken: 't' }, { KANBAN_PROVIDER: 'local' }),
|
|
205
|
+
).not.toThrow()
|
|
206
|
+
// default provider is local when KANBAN_PROVIDER is unset
|
|
207
|
+
expect(() => assertTunnelSecurity({ tunnel: true, authToken: 't' }, {})).not.toThrow()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('D5: tunnel + token on jira without JIRA_WEBHOOK_SECRET is refused', () => {
|
|
211
|
+
try {
|
|
212
|
+
assertTunnelSecurity({ tunnel: true, authToken: 't' }, { KANBAN_PROVIDER: 'jira' })
|
|
213
|
+
throw new Error('expected refusal')
|
|
214
|
+
} catch (err) {
|
|
215
|
+
expect((err as KanbanError).message).toContain('JIRA_WEBHOOK_SECRET')
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('D5: tunnel + token on jira WITH the secret is allowed', () => {
|
|
220
|
+
expect(() =>
|
|
221
|
+
assertTunnelSecurity(
|
|
222
|
+
{ tunnel: true, authToken: 't' },
|
|
223
|
+
{ KANBAN_PROVIDER: 'jira', JIRA_WEBHOOK_SECRET: 's' },
|
|
224
|
+
),
|
|
225
|
+
).not.toThrow()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('D5: tunnel + token on linear without LINEAR_WEBHOOK_SECRET is refused', () => {
|
|
229
|
+
try {
|
|
230
|
+
assertTunnelSecurity({ tunnel: true, authToken: 't' }, { KANBAN_PROVIDER: 'Linear' })
|
|
231
|
+
throw new Error('expected refusal')
|
|
232
|
+
} catch (err) {
|
|
233
|
+
expect((err as KanbanError).message).toContain('LINEAR_WEBHOOK_SECRET')
|
|
234
|
+
}
|
|
235
|
+
})
|
|
132
236
|
})
|
|
133
237
|
|
|
134
238
|
describe('parseMcpArgs', () => {
|
|
@@ -132,14 +132,37 @@ describe('jira-wiring', () => {
|
|
|
132
132
|
expect(provider.type).toBe('jira')
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
-
test('JIRA_BOARD_ID
|
|
135
|
+
test('JIRA_BOARD_ID unset omits boardId (no board pinned)', () => {
|
|
136
136
|
setJiraRequiredEnv()
|
|
137
|
-
process.env['JIRA_BOARD_ID'] = 'notanumber'
|
|
138
137
|
const config = trackerConfigFromEnv()
|
|
139
138
|
expect(config.provider).toBe('jira')
|
|
140
139
|
expect(config).not.toHaveProperty('boardId')
|
|
141
140
|
})
|
|
142
141
|
|
|
142
|
+
test('JIRA_BOARD_ID accepts a plain positive integer', () => {
|
|
143
|
+
setJiraRequiredEnv()
|
|
144
|
+
process.env['JIRA_BOARD_ID'] = ' 42 '
|
|
145
|
+
const config = trackerConfigFromEnv()
|
|
146
|
+
expect(config.provider).toBe('jira')
|
|
147
|
+
expect((config as { boardId?: number }).boardId).toBe(42)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('JIRA_BOARD_ID rejects malformed values as INVALID_CONFIG (no silent wrong board)', () => {
|
|
151
|
+
setJiraRequiredEnv()
|
|
152
|
+
// Number.parseInt would have quietly coerced these to 12 / 1 / -5 / 0 and
|
|
153
|
+
// pinned a wrong board; they must now be rejected.
|
|
154
|
+
for (const raw of ['notanumber', '12abc', '1e3', '0x10', '3.5', '-5', '0']) {
|
|
155
|
+
process.env['JIRA_BOARD_ID'] = raw
|
|
156
|
+
let code: string | undefined
|
|
157
|
+
try {
|
|
158
|
+
trackerConfigFromEnv()
|
|
159
|
+
} catch (err) {
|
|
160
|
+
code = (err as KanbanError).code
|
|
161
|
+
}
|
|
162
|
+
expect(code).toBe(ErrorCode.INVALID_CONFIG)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
143
166
|
test('trackerConfigFromEnv carries remote polling sync interval', () => {
|
|
144
167
|
setJiraRequiredEnv()
|
|
145
168
|
process.env['KANBAN_SYNC_INTERVAL_MS'] = '60000'
|
|
@@ -370,6 +370,84 @@ describe('startServer auth + CORS', () => {
|
|
|
370
370
|
expect(res.status).not.toBe(401)
|
|
371
371
|
})
|
|
372
372
|
|
|
373
|
+
test('a throwing provider webhook handler returns an enveloped 500, not a dropped connection', async () => {
|
|
374
|
+
const runtime = startServer(
|
|
375
|
+
makeProvider({
|
|
376
|
+
type: 'local',
|
|
377
|
+
async handleWebhook() {
|
|
378
|
+
throw new Error('boom during webhook apply')
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
0,
|
|
382
|
+
{ authToken: TOKEN },
|
|
383
|
+
)
|
|
384
|
+
runtimes.push(runtime)
|
|
385
|
+
const res = await fetch(`http://127.0.0.1:${runtime.port}/api/webhooks/local`, {
|
|
386
|
+
method: 'POST',
|
|
387
|
+
headers: { 'Content-Type': 'application/json' },
|
|
388
|
+
body: '{}',
|
|
389
|
+
})
|
|
390
|
+
const body = (await res.json()) as { ok: boolean; error: { code: string } }
|
|
391
|
+
expect(res.status).toBe(500)
|
|
392
|
+
expect(body.ok).toBe(false)
|
|
393
|
+
expect(body.error.code).toBe('INTERNAL_ERROR')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('F06: a `/kanban`-prefixed API path still enforces the auth gate (no prefix bypass)', async () => {
|
|
397
|
+
const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
|
|
398
|
+
runtimes.push(runtime)
|
|
399
|
+
|
|
400
|
+
const noAuth = await fetch(`http://127.0.0.1:${runtime.port}/kanban/api/bootstrap`)
|
|
401
|
+
expect(noAuth.status).toBe(401)
|
|
402
|
+
|
|
403
|
+
const withAuth = await fetch(`http://127.0.0.1:${runtime.port}/kanban/api/bootstrap`, {
|
|
404
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
405
|
+
})
|
|
406
|
+
expect(withAuth.status).toBe(200)
|
|
407
|
+
|
|
408
|
+
// Health stays public under the prefix too.
|
|
409
|
+
const health = await fetch(`http://127.0.0.1:${runtime.port}/kanban/api/health`)
|
|
410
|
+
expect(health.status).toBe(200)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('F13: a connected WS client receives task:upsert when a task is created via the API', async () => {
|
|
414
|
+
const runtime = startServer(makeProvider(), 0)
|
|
415
|
+
runtimes.push(runtime)
|
|
416
|
+
|
|
417
|
+
const ws = new WebSocket(`ws://127.0.0.1:${runtime.port}/ws`)
|
|
418
|
+
await new Promise<void>((resolve, reject) => {
|
|
419
|
+
ws.onopen = () => resolve()
|
|
420
|
+
ws.onerror = () => reject(new Error('ws connection failed'))
|
|
421
|
+
})
|
|
422
|
+
const received = new Promise<{
|
|
423
|
+
type: string
|
|
424
|
+
task?: { column_id?: string }
|
|
425
|
+
columnId?: string
|
|
426
|
+
}>((resolve, reject) => {
|
|
427
|
+
ws.onmessage = (e) => resolve(JSON.parse(String(e.data)))
|
|
428
|
+
// Fast-fail with a clear message instead of hanging until the global test
|
|
429
|
+
// timeout if the broadcast never arrives (e.g. the socket was not yet
|
|
430
|
+
// registered when the mutation fired).
|
|
431
|
+
setTimeout(() => reject(new Error('timed out waiting for ws broadcast')), 4000)
|
|
432
|
+
})
|
|
433
|
+
try {
|
|
434
|
+
// Let the open handler register the socket before the mutation broadcasts.
|
|
435
|
+
await sleep(25)
|
|
436
|
+
|
|
437
|
+
await fetch(`http://127.0.0.1:${runtime.port}/api/tasks`, {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
headers: { 'Content-Type': 'application/json' },
|
|
440
|
+
body: JSON.stringify({ title: 'Broadcast me' }),
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const msg = await received
|
|
444
|
+
expect(msg.type).toBe('task:upsert')
|
|
445
|
+
expect(msg.columnId).toBe('backlog')
|
|
446
|
+
} finally {
|
|
447
|
+
ws.close()
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
|
|
373
451
|
test('wsClients are tracked per server instance, not shared globally', async () => {
|
|
374
452
|
const a = startServer(makeProvider(), 0)
|
|
375
453
|
runtimes.push(a)
|
|
@@ -17,7 +17,20 @@ describe('sync config', () => {
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
test('rejects invalid or too-aggressive intervals', () => {
|
|
20
|
-
|
|
20
|
+
// OBS-1: digits-only — hex/scientific notation (which Number() would accept)
|
|
21
|
+
// and over-long digit strings (precision loss / Infinity) are now rejected
|
|
22
|
+
// for the env var too, matching the strict --sync-interval-ms CLI flag.
|
|
23
|
+
for (const raw of [
|
|
24
|
+
'999',
|
|
25
|
+
'5s',
|
|
26
|
+
'1000.5',
|
|
27
|
+
'0',
|
|
28
|
+
'0x3e8',
|
|
29
|
+
'1e3',
|
|
30
|
+
'1_000',
|
|
31
|
+
'9007199254740993',
|
|
32
|
+
'9'.repeat(309),
|
|
33
|
+
]) {
|
|
21
34
|
let err: unknown
|
|
22
35
|
try {
|
|
23
36
|
resolvePollingSyncIntervalMs(raw)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
WEBHOOK_SECRET_ENV,
|
|
4
|
+
trackerProviderFromEnv,
|
|
5
|
+
webhookSecretFromEnv,
|
|
6
|
+
type TrackerProvider,
|
|
7
|
+
} from '../tracker-config'
|
|
8
|
+
|
|
9
|
+
// OBS-2: the provider -> webhook-secret-env mapping is a single source of truth.
|
|
10
|
+
// WEBHOOK_SECRET_ENV is typed Record<TrackerProvider, …>, so adding a new
|
|
11
|
+
// provider to the union is a COMPILE error until its secret env is declared here
|
|
12
|
+
// — that compile-time exhaustiveness is the real guard against assertTunnelSecurity
|
|
13
|
+
// silently failing open for a new webhook-capable provider. These runtime tests
|
|
14
|
+
// pin the map contents and the env normalization the guard relies on.
|
|
15
|
+
|
|
16
|
+
describe('WEBHOOK_SECRET_ENV (single source of truth)', () => {
|
|
17
|
+
test('maps each known provider to its secret env (local has none)', () => {
|
|
18
|
+
expect(WEBHOOK_SECRET_ENV).toEqual({
|
|
19
|
+
local: null,
|
|
20
|
+
linear: 'LINEAR_WEBHOOK_SECRET',
|
|
21
|
+
jira: 'JIRA_WEBHOOK_SECRET',
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('every non-local provider points at a *_WEBHOOK_SECRET env name', () => {
|
|
26
|
+
for (const [provider, envName] of Object.entries(WEBHOOK_SECRET_ENV)) {
|
|
27
|
+
if (provider === 'local') {
|
|
28
|
+
expect(envName).toBeNull()
|
|
29
|
+
} else {
|
|
30
|
+
expect(envName).toMatch(/_WEBHOOK_SECRET$/)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('trackerProviderFromEnv', () => {
|
|
37
|
+
test('normalizes known providers (case/whitespace-insensitive)', () => {
|
|
38
|
+
expect(trackerProviderFromEnv({ KANBAN_PROVIDER: 'jira' })).toBe('jira')
|
|
39
|
+
expect(trackerProviderFromEnv({ KANBAN_PROVIDER: 'Linear' })).toBe('linear')
|
|
40
|
+
expect(trackerProviderFromEnv({ KANBAN_PROVIDER: ' JIRA ' })).toBe('jira')
|
|
41
|
+
expect(trackerProviderFromEnv({ KANBAN_PROVIDER: 'local' })).toBe('local')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('falls back to local for unset / blank / unrecognized values', () => {
|
|
45
|
+
expect(trackerProviderFromEnv({})).toBe('local')
|
|
46
|
+
expect(trackerProviderFromEnv({ KANBAN_PROVIDER: '' })).toBe('local')
|
|
47
|
+
// A future provider not yet wired in resolves to local (no webhooks) rather
|
|
48
|
+
// than an unmapped value; once added to TrackerProvider it must gain a
|
|
49
|
+
// WEBHOOK_SECRET_ENV entry (compile-enforced).
|
|
50
|
+
expect(trackerProviderFromEnv({ KANBAN_PROVIDER: 'github' })).toBe('local')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('inherited Object property names do not leak through the map lookup', () => {
|
|
54
|
+
// The normalizer derives from WEBHOOK_SECRET_ENV's OWN keys, so prototype
|
|
55
|
+
// names must not be treated as providers.
|
|
56
|
+
for (const name of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
|
|
57
|
+
expect(trackerProviderFromEnv({ KANBAN_PROVIDER: name })).toBe('local')
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('webhookSecretFromEnv', () => {
|
|
63
|
+
// The runtime signature enforcement in jira-core/linear-core resolves its secret
|
|
64
|
+
// through this helper, and the assertTunnelSecurity tunnel gate resolves the same
|
|
65
|
+
// env name through WEBHOOK_SECRET_ENV. These tests pin that they read the SAME env
|
|
66
|
+
// name, so the gate and enforcement can't drift into a fail-open.
|
|
67
|
+
test('reads exactly the env name WEBHOOK_SECRET_ENV declares for each provider', () => {
|
|
68
|
+
for (const [provider, envName] of Object.entries(WEBHOOK_SECRET_ENV)) {
|
|
69
|
+
if (envName === null) continue
|
|
70
|
+
const secret = `secret-for-${envName}`
|
|
71
|
+
expect(webhookSecretFromEnv(provider as TrackerProvider, { [envName]: secret })).toBe(secret)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('returns undefined when the declared secret env is unset (open dev mode)', () => {
|
|
76
|
+
expect(webhookSecretFromEnv('jira', {})).toBeUndefined()
|
|
77
|
+
expect(webhookSecretFromEnv('linear', {})).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('local has no webhook secret env, so it never resolves a secret', () => {
|
|
81
|
+
expect(webhookSecretFromEnv('local', { JIRA_WEBHOOK_SECRET: 'x' })).toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('does not read a different env than the one the provider declares', () => {
|
|
85
|
+
// Setting Linear's secret must not satisfy Jira's lookup, and vice versa.
|
|
86
|
+
expect(webhookSecretFromEnv('jira', { LINEAR_WEBHOOK_SECRET: 'l' })).toBeUndefined()
|
|
87
|
+
expect(webhookSecretFromEnv('linear', { JIRA_WEBHOOK_SECRET: 'j' })).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
3
|
+
import { parseBoundedInt, parseDecimalDigits, parsePositiveInt } from '../transport-input'
|
|
4
|
+
|
|
5
|
+
describe('parseDecimalDigits', () => {
|
|
6
|
+
test('parses plain decimal digit strings (whitespace-trimmed)', () => {
|
|
7
|
+
expect(parseDecimalDigits('0')).toBe(0)
|
|
8
|
+
expect(parseDecimalDigits('42')).toBe(42)
|
|
9
|
+
expect(parseDecimalDigits(' 1000 ')).toBe(1000)
|
|
10
|
+
expect(parseDecimalDigits(String(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('returns null for anything that is not exact non-negative decimal digits', () => {
|
|
14
|
+
for (const raw of [
|
|
15
|
+
'',
|
|
16
|
+
' ',
|
|
17
|
+
'notanumber',
|
|
18
|
+
'12abc',
|
|
19
|
+
'-1',
|
|
20
|
+
'3.5',
|
|
21
|
+
'0x10',
|
|
22
|
+
'1e3',
|
|
23
|
+
'1_000',
|
|
24
|
+
'+5',
|
|
25
|
+
'9007199254740993', // past MAX_SAFE_INTEGER → precision loss
|
|
26
|
+
'9'.repeat(309), // Number() → Infinity
|
|
27
|
+
]) {
|
|
28
|
+
expect(parseDecimalDigits(raw)).toBeNull()
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('parseBoundedInt', () => {
|
|
34
|
+
test('accepts in-range digit strings (inclusive bounds)', () => {
|
|
35
|
+
expect(parseBoundedInt('0', { min: 0, max: 65535, field: 'port' })).toBe(0)
|
|
36
|
+
expect(parseBoundedInt('65535', { min: 0, max: 65535, field: 'port' })).toBe(65535)
|
|
37
|
+
expect(parseBoundedInt('1000', { min: 1000, field: 'iv' })).toBe(1000)
|
|
38
|
+
expect(parseBoundedInt(' 42 ', { min: 1, max: 100, field: 'n' })).toBe(42)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test.each([
|
|
42
|
+
['65536', { min: 0, max: 65535, field: 'port' }],
|
|
43
|
+
['999', { min: 1000, field: 'iv' }],
|
|
44
|
+
['-1', { min: 0, max: 65535, field: 'port' }],
|
|
45
|
+
['3.5', { min: 0, max: 65535, field: 'port' }],
|
|
46
|
+
['0x10', { min: 0, max: 65535, field: 'port' }],
|
|
47
|
+
['1e3', { min: 1000, field: 'iv' }],
|
|
48
|
+
['', { min: 0, max: 65535, field: 'port' }],
|
|
49
|
+
['9007199254740993', { min: 1000, field: 'iv' }], // past MAX_SAFE_INTEGER → precision loss
|
|
50
|
+
['9'.repeat(309), { min: 1000, field: 'iv' }], // Number() → Infinity
|
|
51
|
+
] as const)('rejects %p as INVALID_ARGUMENT', (value, opts) => {
|
|
52
|
+
expect(() => parseBoundedInt(value, opts)).toThrow(KanbanError)
|
|
53
|
+
try {
|
|
54
|
+
parseBoundedInt(value, opts)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('message form depends on whether max is bounded', () => {
|
|
61
|
+
expect(() => parseBoundedInt('x', { min: 0, max: 65535, field: 'port' })).toThrow(
|
|
62
|
+
/port must be an integer between 0 and 65535/,
|
|
63
|
+
)
|
|
64
|
+
expect(() => parseBoundedInt('x', { min: 1000, field: '--sync-interval-ms' })).toThrow(
|
|
65
|
+
/--sync-interval-ms must be an integer >= 1000/,
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
3
69
|
|
|
4
70
|
describe('parsePositiveInt', () => {
|
|
5
71
|
test('returns undefined for absent values', () => {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { startCloudflareTunnel } from '../tunnel'
|
|
3
|
+
|
|
4
|
+
// The tunnel is exercised with an injected `command` so no real `cloudflared`
|
|
5
|
+
// binary is needed; stdout/stderr from a tiny shell script drive URL detection,
|
|
6
|
+
// and the injected log/warn/onUrl sinks let us assert behaviour deterministically.
|
|
7
|
+
|
|
8
|
+
function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
|
|
9
|
+
let resolve!: (v: T) => void
|
|
10
|
+
const promise = new Promise<T>((r) => {
|
|
11
|
+
resolve = r
|
|
12
|
+
})
|
|
13
|
+
return { promise, resolve }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('startCloudflareTunnel (F42/F51/F52/F53)', () => {
|
|
17
|
+
test('F51: detects the trycloudflare URL from stdout and announces once', async () => {
|
|
18
|
+
const url = 'https://random-words-1234.trycloudflare.com'
|
|
19
|
+
const got = deferred<string>()
|
|
20
|
+
const logs: string[] = []
|
|
21
|
+
const handle = startCloudflareTunnel(3000, {
|
|
22
|
+
command: ['bash', '-c', `echo "INF | ${url} |"; sleep 1`],
|
|
23
|
+
onUrl: (u) => got.resolve(u),
|
|
24
|
+
log: (m) => logs.push(m),
|
|
25
|
+
warn: () => {},
|
|
26
|
+
})
|
|
27
|
+
expect(await got.promise).toBe(url)
|
|
28
|
+
expect(logs.some((l) => l.includes(url))).toBe(true)
|
|
29
|
+
handle.stop()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('F51: detects the URL from stderr too', async () => {
|
|
33
|
+
const url = 'https://stderr-side-9999.trycloudflare.com'
|
|
34
|
+
const got = deferred<string>()
|
|
35
|
+
const handle = startCloudflareTunnel(3000, {
|
|
36
|
+
command: ['bash', '-c', `echo "${url}" 1>&2; sleep 1`],
|
|
37
|
+
onUrl: (u) => got.resolve(u),
|
|
38
|
+
log: () => {},
|
|
39
|
+
warn: () => {},
|
|
40
|
+
})
|
|
41
|
+
expect(await got.promise).toBe(url)
|
|
42
|
+
handle.stop()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('F51: announces exactly once even when the URL is printed repeatedly', async () => {
|
|
46
|
+
const url = 'https://dup-5678.trycloudflare.com'
|
|
47
|
+
const urls: string[] = []
|
|
48
|
+
const handle = startCloudflareTunnel(3000, {
|
|
49
|
+
command: ['bash', '-c', `for i in 1 2 3 4; do echo ${url}; done; sleep 1`],
|
|
50
|
+
onUrl: (u) => urls.push(u),
|
|
51
|
+
log: () => {},
|
|
52
|
+
warn: () => {},
|
|
53
|
+
})
|
|
54
|
+
// Give the scanner time to read all four lines before asserting.
|
|
55
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
56
|
+
handle.stop()
|
|
57
|
+
expect(urls).toEqual([url])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('D4: detects a URL split across separate stream chunks', async () => {
|
|
61
|
+
// The URL is printed in two writes separated by a sleep so they land in
|
|
62
|
+
// distinct read chunks. Per-chunk matching (pre-fix) misses it; the buffered
|
|
63
|
+
// scanner reassembles it. parts: "https://abc" + "def-1234.trycloudflare.com"
|
|
64
|
+
const url = 'https://abcdef-1234.trycloudflare.com'
|
|
65
|
+
const got = deferred<string>()
|
|
66
|
+
const handle = startCloudflareTunnel(3000, {
|
|
67
|
+
command: [
|
|
68
|
+
'bash',
|
|
69
|
+
'-c',
|
|
70
|
+
`printf 'INF | https://abc'; sleep 0.15; printf 'def-1234.trycloudflare.com |\n'; sleep 1`,
|
|
71
|
+
],
|
|
72
|
+
onUrl: (u) => got.resolve(u),
|
|
73
|
+
log: () => {},
|
|
74
|
+
warn: () => {},
|
|
75
|
+
})
|
|
76
|
+
expect(await got.promise).toBe(url)
|
|
77
|
+
handle.stop()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('F52: warns with an actionable message and rethrows when the binary is missing', () => {
|
|
81
|
+
let warned = ''
|
|
82
|
+
expect(() =>
|
|
83
|
+
startCloudflareTunnel(3000, {
|
|
84
|
+
command: ['cloudflared-does-not-exist-xyz-123'],
|
|
85
|
+
warn: (m) => {
|
|
86
|
+
warned = m
|
|
87
|
+
},
|
|
88
|
+
log: () => {},
|
|
89
|
+
}),
|
|
90
|
+
).toThrow()
|
|
91
|
+
expect(warned).toContain('Failed to start cloudflared')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('F53: warns when the process exits before a URL is established', async () => {
|
|
95
|
+
const warned = deferred<string>()
|
|
96
|
+
const handle = startCloudflareTunnel(3000, {
|
|
97
|
+
command: ['bash', '-c', 'exit 1'],
|
|
98
|
+
warn: (m) => warned.resolve(m),
|
|
99
|
+
log: () => {},
|
|
100
|
+
})
|
|
101
|
+
expect(await warned.promise).toContain('before a public URL was established')
|
|
102
|
+
handle.stop()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('F53: stop() is best-effort and does not throw even if called twice', async () => {
|
|
106
|
+
const handle = startCloudflareTunnel(3000, {
|
|
107
|
+
command: ['bash', '-c', 'sleep 2'],
|
|
108
|
+
log: () => {},
|
|
109
|
+
warn: () => {},
|
|
110
|
+
})
|
|
111
|
+
expect(() => {
|
|
112
|
+
handle.stop()
|
|
113
|
+
handle.stop()
|
|
114
|
+
}).not.toThrow()
|
|
115
|
+
})
|
|
116
|
+
})
|