rufloui 0.3.2 → 0.3.35

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.
@@ -0,0 +1,605 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+ import {
4
+ gitlabWebhookRoutes,
5
+ getGitLabWebhookEvents,
6
+ updateGitLabEventByTaskId,
7
+ GitLabWebhookConfig,
8
+ GitLabWebhookStores,
9
+ } from '../webhook-gitlab'
10
+
11
+ // ── Shared test fixtures ──────────────────────────────────────────────────
12
+
13
+ const testIssuePayload = {
14
+ object_attributes: {
15
+ action: 'open',
16
+ title: 'Test GitLab issue',
17
+ description: 'Test body content',
18
+ url: 'https://gitlab.com/group/project/-/issues/42',
19
+ iid: 42,
20
+ },
21
+ project: { path_with_namespace: 'group/project' },
22
+ user: { username: 'gitlab-dev' },
23
+ labels: [{ title: 'bug' }, { title: 'urgent' }],
24
+ }
25
+
26
+ // ── Router helper ─────────────────────────────────────────────────────────
27
+
28
+ function createTestRouter(overrides?: Partial<GitLabWebhookConfig>) {
29
+ const config: GitLabWebhookConfig = {
30
+ enabled: true,
31
+ gitlabToken: 'glpat-test',
32
+ webhookSecret: '',
33
+ repos: [],
34
+ autoAssign: false,
35
+ taskTemplate: '',
36
+ ...overrides,
37
+ }
38
+
39
+ const stores: GitLabWebhookStores = {
40
+ createAndAssignTask: vi.fn().mockResolvedValue({ taskId: 't1', assigned: true }),
41
+ broadcast: vi.fn(),
42
+ }
43
+
44
+ const router = gitlabWebhookRoutes(
45
+ () => config,
46
+ (c) => { Object.assign(config, c) },
47
+ stores,
48
+ )
49
+
50
+ return { config, stores, router }
51
+ }
52
+
53
+ /** Find and call a route handler on the Express router */
54
+ async function callRoute(
55
+ router: ReturnType<typeof gitlabWebhookRoutes>,
56
+ method: 'post' | 'get' | 'put',
57
+ path: string,
58
+ opts?: {
59
+ body?: unknown
60
+ headers?: Record<string, string>
61
+ },
62
+ ): Promise<{ status: number; json: unknown }> {
63
+ const layer = (router as any).stack.find(
64
+ (l: any) => l.route?.path === path && l.route?.methods?.[method],
65
+ )
66
+ if (!layer) throw new Error(`No ${method.toUpperCase()} ${path} route found`)
67
+ const handler = layer.route.stack[0].handle
68
+
69
+ let statusCode = 200
70
+ let jsonBody: unknown = null
71
+
72
+ const req = {
73
+ body: opts?.body ?? {},
74
+ headers: {
75
+ 'content-type': 'application/json',
76
+ ...(opts?.headers || {}),
77
+ },
78
+ }
79
+
80
+ const res = {
81
+ status(code: number) { statusCode = code; return this },
82
+ json(data: unknown) { jsonBody = data; return this },
83
+ }
84
+
85
+ await handler(req, res, () => {})
86
+ return { status: statusCode, json: jsonBody }
87
+ }
88
+
89
+ // ── POST /gitlab — valid issue event ──────────────────────────────────────
90
+
91
+ describe('POST /gitlab — valid issue event', () => {
92
+ it('returns 200 and ok:true for a valid opened issue', async () => {
93
+ const { router } = createTestRouter()
94
+
95
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
96
+ body: testIssuePayload,
97
+ headers: { 'x-gitlab-event': 'Issue Hook' },
98
+ })
99
+
100
+ expect(status).toBe(200)
101
+ expect((json as any).ok).toBe(true)
102
+ expect((json as any).action).toBe('task_created')
103
+ expect((json as any).eventId).toMatch(/^gl-/)
104
+ })
105
+
106
+ it('stores the event with correct fields', async () => {
107
+ const { router } = createTestRouter()
108
+ const eventsBefore = getGitLabWebhookEvents().length
109
+
110
+ await callRoute(router, 'post', '/gitlab', {
111
+ body: testIssuePayload,
112
+ headers: { 'x-gitlab-event': 'Issue Hook' },
113
+ })
114
+
115
+ const events = getGitLabWebhookEvents()
116
+ expect(events.length).toBeGreaterThan(eventsBefore)
117
+
118
+ const latest = events[0]
119
+ expect(latest.provider).toBe('gitlab')
120
+ expect(latest.event).toBe('issue.open')
121
+ expect(latest.title).toBe('Test GitLab issue')
122
+ expect(latest.author).toBe('gitlab-dev')
123
+ expect(latest.labels).toEqual(['bug', 'urgent'])
124
+ expect(latest.number).toBe(42)
125
+ expect(latest.repo).toBe('group/project')
126
+ expect(latest.url).toBe('https://gitlab.com/group/project/-/issues/42')
127
+ expect(latest.receivedAt).toBeTruthy()
128
+ expect(latest.status).toBe('received')
129
+ })
130
+
131
+ it('broadcasts webhook:received event', async () => {
132
+ const { router, stores } = createTestRouter()
133
+
134
+ await callRoute(router, 'post', '/gitlab', {
135
+ body: testIssuePayload,
136
+ headers: { 'x-gitlab-event': 'Issue Hook' },
137
+ })
138
+
139
+ expect(stores.broadcast).toHaveBeenCalledWith('webhook:received', expect.objectContaining({
140
+ provider: 'gitlab',
141
+ event: 'issue.open',
142
+ }))
143
+ })
144
+
145
+ it('handles reopen action', async () => {
146
+ const { router } = createTestRouter()
147
+ const payload = {
148
+ ...testIssuePayload,
149
+ object_attributes: { ...testIssuePayload.object_attributes, action: 'reopen' },
150
+ }
151
+
152
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
153
+ body: payload,
154
+ headers: { 'x-gitlab-event': 'Issue Hook' },
155
+ })
156
+
157
+ expect(status).toBe(200)
158
+ expect((json as any).ok).toBe(true)
159
+ })
160
+ })
161
+
162
+ // ── POST /gitlab — disabled ───────────────────────────────────────────────
163
+
164
+ describe('POST /gitlab — disabled', () => {
165
+ it('returns 503 when webhooks are disabled', async () => {
166
+ const { router } = createTestRouter({ enabled: false })
167
+
168
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
169
+ body: testIssuePayload,
170
+ headers: { 'x-gitlab-event': 'Issue Hook' },
171
+ })
172
+
173
+ expect(status).toBe(503)
174
+ expect((json as any).error).toMatch(/disabled/i)
175
+ })
176
+ })
177
+
178
+ // ── POST /gitlab — token validation ──────────────────────────────────────
179
+
180
+ describe('POST /gitlab — token validation', () => {
181
+ it('returns 401 when X-Gitlab-Token header is missing', async () => {
182
+ const { router } = createTestRouter({ webhookSecret: 'my-secret' })
183
+
184
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
185
+ body: testIssuePayload,
186
+ headers: { 'x-gitlab-event': 'Issue Hook' },
187
+ })
188
+
189
+ expect(status).toBe(401)
190
+ expect((json as any).error).toMatch(/missing/i)
191
+ })
192
+
193
+ it('returns 401 when X-Gitlab-Token does not match', async () => {
194
+ const { router } = createTestRouter({ webhookSecret: 'my-secret' })
195
+
196
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
197
+ body: testIssuePayload,
198
+ headers: { 'x-gitlab-event': 'Issue Hook', 'x-gitlab-token': 'wrong-secret' },
199
+ })
200
+
201
+ expect(status).toBe(401)
202
+ expect((json as any).error).toMatch(/invalid/i)
203
+ })
204
+
205
+ it('accepts correct X-Gitlab-Token', async () => {
206
+ const { router } = createTestRouter({ webhookSecret: 'my-secret' })
207
+
208
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
209
+ body: testIssuePayload,
210
+ headers: { 'x-gitlab-event': 'Issue Hook', 'x-gitlab-token': 'my-secret' },
211
+ })
212
+
213
+ expect(status).toBe(200)
214
+ expect((json as any).ok).toBe(true)
215
+ })
216
+
217
+ it('skips token validation when no secret configured', async () => {
218
+ const { router } = createTestRouter({ webhookSecret: '' })
219
+
220
+ const { status } = await callRoute(router, 'post', '/gitlab', {
221
+ body: testIssuePayload,
222
+ headers: { 'x-gitlab-event': 'Issue Hook' },
223
+ })
224
+
225
+ expect(status).toBe(200)
226
+ })
227
+ })
228
+
229
+ // ── POST /gitlab — event filtering ───────────────────────────────────────
230
+
231
+ describe('POST /gitlab — event filtering', () => {
232
+ it('ignores non-Issue Hook events', async () => {
233
+ const { router } = createTestRouter()
234
+
235
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
236
+ body: {},
237
+ headers: { 'x-gitlab-event': 'Push Hook' },
238
+ })
239
+
240
+ expect(status).toBe(200)
241
+ expect((json as any).action).toBe('ignored')
242
+ })
243
+
244
+ it('ignores close action on issues', async () => {
245
+ const { router } = createTestRouter()
246
+ const payload = {
247
+ ...testIssuePayload,
248
+ object_attributes: { ...testIssuePayload.object_attributes, action: 'close' },
249
+ }
250
+
251
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
252
+ body: payload,
253
+ headers: { 'x-gitlab-event': 'Issue Hook' },
254
+ })
255
+
256
+ expect(status).toBe(200)
257
+ expect((json as any).action).toBe('ignored')
258
+ })
259
+
260
+ it('ignores update action on issues', async () => {
261
+ const { router } = createTestRouter()
262
+ const payload = {
263
+ ...testIssuePayload,
264
+ object_attributes: { ...testIssuePayload.object_attributes, action: 'update' },
265
+ }
266
+
267
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
268
+ body: payload,
269
+ headers: { 'x-gitlab-event': 'Issue Hook' },
270
+ })
271
+
272
+ expect(status).toBe(200)
273
+ expect((json as any).action).toBe('ignored')
274
+ })
275
+ })
276
+
277
+ // ── POST /gitlab — repo filtering ────────────────────────────────────────
278
+
279
+ describe('POST /gitlab — repo filtering', () => {
280
+ it('ignores events from non-monitored repos', async () => {
281
+ const { router } = createTestRouter({ repos: ['other/project'] })
282
+
283
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
284
+ body: testIssuePayload,
285
+ headers: { 'x-gitlab-event': 'Issue Hook' },
286
+ })
287
+
288
+ expect(status).toBe(200)
289
+ expect((json as any).action).toBe('ignored')
290
+ expect((json as any).reason).toMatch(/not monitored/)
291
+ })
292
+
293
+ it('accepts events when repo is in monitored list', async () => {
294
+ const { router } = createTestRouter({ repos: ['group/project'] })
295
+
296
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
297
+ body: testIssuePayload,
298
+ headers: { 'x-gitlab-event': 'Issue Hook' },
299
+ })
300
+
301
+ expect(status).toBe(200)
302
+ expect((json as any).ok).toBe(true)
303
+ expect((json as any).action).toBe('task_created')
304
+ })
305
+
306
+ it('accepts events when repo list is empty (all repos)', async () => {
307
+ const { router } = createTestRouter({ repos: [] })
308
+
309
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
310
+ body: testIssuePayload,
311
+ headers: { 'x-gitlab-event': 'Issue Hook' },
312
+ })
313
+
314
+ expect(status).toBe(200)
315
+ expect((json as any).ok).toBe(true)
316
+ })
317
+ })
318
+
319
+ // ── POST /gitlab — autoAssign ─────────────────────────────────────────────
320
+
321
+ describe('POST /gitlab — autoAssign', () => {
322
+ it('creates and assigns task when autoAssign is enabled', async () => {
323
+ const { router, stores } = createTestRouter({ autoAssign: true })
324
+
325
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
326
+ body: testIssuePayload,
327
+ headers: { 'x-gitlab-event': 'Issue Hook' },
328
+ })
329
+
330
+ expect(status).toBe(200)
331
+ expect((json as any).taskId).toBe('t1')
332
+ expect(stores.createAndAssignTask).toHaveBeenCalledWith(
333
+ '[group/project#42] Test GitLab issue',
334
+ expect.stringContaining('GitLab Issue:'),
335
+ )
336
+ })
337
+
338
+ it('does not create task when autoAssign is disabled', async () => {
339
+ const { router, stores } = createTestRouter({ autoAssign: false })
340
+
341
+ await callRoute(router, 'post', '/gitlab', {
342
+ body: testIssuePayload,
343
+ headers: { 'x-gitlab-event': 'Issue Hook' },
344
+ })
345
+
346
+ expect(stores.createAndAssignTask).not.toHaveBeenCalled()
347
+ })
348
+
349
+ it('sets status to failed when task creation throws', async () => {
350
+ const { router, stores } = createTestRouter({ autoAssign: true })
351
+ ;(stores.createAndAssignTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'))
352
+
353
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
354
+ body: testIssuePayload,
355
+ headers: { 'x-gitlab-event': 'Issue Hook' },
356
+ })
357
+
358
+ expect(status).toBe(200)
359
+ const events = getGitLabWebhookEvents()
360
+ const latest = events[0]
361
+ expect(latest.status).toBe('failed')
362
+ })
363
+ })
364
+
365
+ // ── GET /gitlab/config ────────────────────────────────────────────────────
366
+
367
+ describe('GET /gitlab/config', () => {
368
+ it('returns config with masked token', async () => {
369
+ const { router } = createTestRouter({ gitlabToken: 'glpat-abcdefghij' })
370
+
371
+ const { status, json } = await callRoute(router, 'get', '/gitlab/config')
372
+
373
+ expect(status).toBe(200)
374
+ const c = json as any
375
+ expect(c.enabled).toBe(true)
376
+ expect(c.hasToken).toBe(true)
377
+ expect(c.tokenPreview).toBe('...ghij')
378
+ expect(c.repos).toEqual([])
379
+ expect(c.autoAssign).toBe(false)
380
+ })
381
+
382
+ it('returns empty preview when no token set', async () => {
383
+ const { router } = createTestRouter({ gitlabToken: '' })
384
+
385
+ const { json } = await callRoute(router, 'get', '/gitlab/config')
386
+
387
+ expect((json as any).hasToken).toBe(false)
388
+ expect((json as any).tokenPreview).toBe('')
389
+ })
390
+ })
391
+
392
+ // ── PUT /gitlab/config ────────────────────────────────────────────────────
393
+
394
+ describe('PUT /gitlab/config', () => {
395
+ it('updates config fields', async () => {
396
+ const { router, config } = createTestRouter()
397
+
398
+ const { status, json } = await callRoute(router, 'put', '/gitlab/config', {
399
+ body: {
400
+ enabled: false,
401
+ gitlabToken: 'new-token',
402
+ repos: ['a/b', 'c/d'],
403
+ autoAssign: true,
404
+ taskTemplate: 'custom template',
405
+ },
406
+ })
407
+
408
+ expect(status).toBe(200)
409
+ expect((json as any).ok).toBe(true)
410
+ expect(config.enabled).toBe(false)
411
+ expect(config.gitlabToken).toBe('new-token')
412
+ expect(config.repos).toEqual(['a/b', 'c/d'])
413
+ expect(config.autoAssign).toBe(true)
414
+ expect(config.taskTemplate).toBe('custom template')
415
+ })
416
+
417
+ it('filters invalid repos (must contain /)', async () => {
418
+ const { router, config } = createTestRouter()
419
+
420
+ await callRoute(router, 'put', '/gitlab/config', {
421
+ body: { repos: ['valid/repo', 'noslash', 'also/valid'] },
422
+ })
423
+
424
+ expect(config.repos).toEqual(['valid/repo', 'also/valid'])
425
+ })
426
+ })
427
+
428
+ // ── POST /gitlab/test ─────────────────────────────────────────────────────
429
+
430
+ describe('POST /gitlab/test', () => {
431
+ it('returns 503 when disabled', async () => {
432
+ const { router } = createTestRouter({ enabled: false })
433
+
434
+ const { status } = await callRoute(router, 'post', '/gitlab/test')
435
+
436
+ expect(status).toBe(503)
437
+ })
438
+
439
+ it('creates a test event', async () => {
440
+ const { router, stores } = createTestRouter({ enabled: true })
441
+
442
+ const { status, json } = await callRoute(router, 'post', '/gitlab/test')
443
+
444
+ expect(status).toBe(200)
445
+ expect((json as any).ok).toBe(true)
446
+ expect((json as any).eventId).toMatch(/^gl-test-/)
447
+ expect(stores.broadcast).toHaveBeenCalledWith('webhook:received', expect.objectContaining({
448
+ provider: 'gitlab',
449
+ repo: 'test/webhook-test',
450
+ }))
451
+ })
452
+
453
+ it('auto-assigns test event when autoAssign enabled', async () => {
454
+ const { router, stores } = createTestRouter({ enabled: true, autoAssign: true })
455
+
456
+ const { json } = await callRoute(router, 'post', '/gitlab/test')
457
+
458
+ expect((json as any).taskId).toBe('t1')
459
+ expect((json as any).assigned).toBe(true)
460
+ expect(stores.createAndAssignTask).toHaveBeenCalled()
461
+ })
462
+
463
+ it('does not auto-assign when disabled', async () => {
464
+ const { router, stores } = createTestRouter({ enabled: true, autoAssign: false })
465
+
466
+ const { json } = await callRoute(router, 'post', '/gitlab/test')
467
+
468
+ expect((json as any).message).toMatch(/auto-assign disabled/)
469
+ expect(stores.createAndAssignTask).not.toHaveBeenCalled()
470
+ })
471
+
472
+ it('handles task creation failure on test', async () => {
473
+ const { router, stores } = createTestRouter({ enabled: true, autoAssign: true })
474
+ ;(stores.createAndAssignTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'))
475
+
476
+ const { json } = await callRoute(router, 'post', '/gitlab/test')
477
+
478
+ expect((json as any).ok).toBe(false)
479
+ expect((json as any).error).toMatch(/failed/i)
480
+ })
481
+ })
482
+
483
+ // ── GET /gitlab/events ────────────────────────────────────────────────────
484
+
485
+ describe('GET /gitlab/events', () => {
486
+ it('returns the events array', async () => {
487
+ const { router } = createTestRouter()
488
+
489
+ // Add an event first
490
+ await callRoute(router, 'post', '/gitlab', {
491
+ body: testIssuePayload,
492
+ headers: { 'x-gitlab-event': 'Issue Hook' },
493
+ })
494
+
495
+ const { status, json } = await callRoute(router, 'get', '/gitlab/events')
496
+
497
+ expect(status).toBe(200)
498
+ expect(Array.isArray(json)).toBe(true)
499
+ expect((json as any[]).length).toBeGreaterThan(0)
500
+ })
501
+ })
502
+
503
+ // ── updateGitLabEventByTaskId ─────────────────────────────────────────────
504
+
505
+ describe('updateGitLabEventByTaskId', () => {
506
+ it('updates status of event matching taskId', async () => {
507
+ const { router, stores } = createTestRouter({ autoAssign: true })
508
+
509
+ await callRoute(router, 'post', '/gitlab', {
510
+ body: testIssuePayload,
511
+ headers: { 'x-gitlab-event': 'Issue Hook' },
512
+ })
513
+
514
+ const events = getGitLabWebhookEvents()
515
+ const latest = events[0]
516
+ expect(latest.taskId).toBe('t1')
517
+
518
+ updateGitLabEventByTaskId('t1', 'completed')
519
+
520
+ expect(latest.status).toBe('completed')
521
+ })
522
+
523
+ it('does nothing for unknown taskId', () => {
524
+ updateGitLabEventByTaskId('nonexistent', 'failed')
525
+ // No error thrown
526
+ })
527
+ })
528
+
529
+ // ── Edge cases ────────────────────────────────────────────────────────────
530
+
531
+ describe('Edge cases', () => {
532
+ it('handles missing project in payload', async () => {
533
+ const { router } = createTestRouter()
534
+ const payload = {
535
+ object_attributes: { action: 'open', title: 'No project', iid: 1 },
536
+ user: { username: 'dev' },
537
+ labels: [],
538
+ }
539
+
540
+ const { status, json } = await callRoute(router, 'post', '/gitlab', {
541
+ body: payload,
542
+ headers: { 'x-gitlab-event': 'Issue Hook' },
543
+ })
544
+
545
+ expect(status).toBe(200)
546
+ const events = getGitLabWebhookEvents()
547
+ expect(events[0].repo).toBe('unknown/unknown')
548
+ })
549
+
550
+ it('handles missing labels in payload', async () => {
551
+ const { router } = createTestRouter()
552
+ const payload = {
553
+ object_attributes: { action: 'open', title: 'No labels', iid: 5 },
554
+ project: { path_with_namespace: 'a/b' },
555
+ user: { username: 'dev' },
556
+ }
557
+
558
+ const { status } = await callRoute(router, 'post', '/gitlab', {
559
+ body: payload,
560
+ headers: { 'x-gitlab-event': 'Issue Hook' },
561
+ })
562
+
563
+ expect(status).toBe(200)
564
+ const events = getGitLabWebhookEvents()
565
+ expect(events[0].labels).toEqual([])
566
+ })
567
+
568
+ it('handles missing user in payload', async () => {
569
+ const { router } = createTestRouter()
570
+ const payload = {
571
+ object_attributes: { action: 'open', title: 'No user', iid: 6 },
572
+ project: { path_with_namespace: 'a/b' },
573
+ labels: [],
574
+ }
575
+
576
+ const { status } = await callRoute(router, 'post', '/gitlab', {
577
+ body: payload,
578
+ headers: { 'x-gitlab-event': 'Issue Hook' },
579
+ })
580
+
581
+ expect(status).toBe(200)
582
+ const events = getGitLabWebhookEvents()
583
+ expect(events[0].author).toBe('unknown')
584
+ })
585
+
586
+ it('truncates long description in task body', async () => {
587
+ const { router, stores } = createTestRouter({ autoAssign: true })
588
+ const payload = {
589
+ ...testIssuePayload,
590
+ object_attributes: {
591
+ ...testIssuePayload.object_attributes,
592
+ description: 'Z'.repeat(3000),
593
+ },
594
+ }
595
+
596
+ await callRoute(router, 'post', '/gitlab', {
597
+ body: payload,
598
+ headers: { 'x-gitlab-event': 'Issue Hook' },
599
+ })
600
+
601
+ const taskDesc = (stores.createAndAssignTask as ReturnType<typeof vi.fn>).mock.calls[0][1] as string
602
+ // Body should be truncated to 2000 chars
603
+ expect(taskDesc.length).toBeLessThan(3000)
604
+ })
605
+ })