handler.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { createHmac } from 'node:crypto'
  2. import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import type { Context } from '@deepseek-ai/cordis'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  7. import { createGitHubWebhookHandler } from '../src/handler.ts'
  8. const servers: Server[] = []
  9. afterEach(async () => {
  10. await Promise.all(servers.splice(0).map(server => new Promise<void>(resolve => server.close(() => { resolve() }))))
  11. })
  12. /** One mutable fake for credential rotation and dispatch observation. */
  13. function fakeContext(secret = 'fixture-secret'): {
  14. ctx: Context
  15. dispatch: ReturnType<typeof vi.fn>
  16. setSecret(value: string | undefined): void
  17. warnings: ReturnType<typeof vi.fn>
  18. } {
  19. let current = secret as string | undefined
  20. const dispatch = vi.fn()
  21. const warnings = vi.fn()
  22. return {
  23. ctx: {
  24. credentials: {
  25. resolve: async () => current === undefined ? undefined : { value: current, source: 'environment' },
  26. },
  27. webhookRuntime: { dispatch },
  28. logger: { warn: warnings },
  29. } as unknown as Context,
  30. dispatch,
  31. setSecret(value) { current = value },
  32. warnings,
  33. }
  34. }
  35. /** Start a real Node server around the package-owned route handler. */
  36. async function serve(ctx: Context, maxBodyBytes = 1024): Promise<string> {
  37. const handler = createGitHubWebhookHandler(ctx, {
  38. source: 'primary',
  39. secretEnv: credentialRef('DSH_GITHUB_WEBHOOK_SECRET'),
  40. maxBodyBytes,
  41. })
  42. const server = createServer((request, response) => { void handler(request, response) })
  43. servers.push(server)
  44. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  45. const port = (server.address() as AddressInfo).port
  46. return `http://127.0.0.1:${String(port)}`
  47. }
  48. /** HMAC header for one exact UTF-8 body. */
  49. function signature(secret: string, body: string): string {
  50. return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
  51. }
  52. /** Send one GitHub-shaped request. */
  53. async function post(
  54. base: string,
  55. body: string,
  56. options: {
  57. secret?: string
  58. signature?: string
  59. event?: string
  60. delivery?: string
  61. contentType?: string
  62. method?: string
  63. } = {},
  64. ): Promise<Response> {
  65. const secret = options.secret ?? 'fixture-secret'
  66. return await fetch(base, {
  67. method: options.method ?? 'POST',
  68. headers: {
  69. 'content-type': options.contentType ?? 'application/json',
  70. 'x-hub-signature-256': options.signature ?? signature(secret, body),
  71. 'x-github-event': options.event ?? 'pull_request',
  72. 'x-github-delivery': options.delivery ?? 'delivery-1',
  73. },
  74. ...(options.method === 'GET' ? {} : { body }),
  75. })
  76. }
  77. describe('GitHub webhook HTTP handler', () => {
  78. it('verifies, projects, dispatches, and answers 202', async () => {
  79. const fake = fakeContext()
  80. const base = await serve(fake.ctx)
  81. const body = JSON.stringify({ action: 'ready_for_review', number: 1 })
  82. const response = await post(base, body, { contentType: 'application/json; charset=utf-8' })
  83. expect(response.status).toBe(202)
  84. expect(await response.text()).toBe('')
  85. expect(fake.dispatch).toHaveBeenCalledOnce()
  86. const dispatched: unknown = fake.dispatch.mock.calls[0]?.[0]
  87. expect(dispatched).toMatchObject({
  88. kind: 'github',
  89. source: 'primary',
  90. deliveryId: 'delivery-1',
  91. event: { name: 'pull_request', payload: { action: 'ready_for_review', number: 1 } },
  92. })
  93. expect(typeof (dispatched as { receivedAt?: unknown }).receivedAt).toBe('number')
  94. })
  95. it('resolves the secret for each request so rotation takes effect immediately', async () => {
  96. const fake = fakeContext('first')
  97. const base = await serve(fake.ctx)
  98. const body = JSON.stringify({ ping: true })
  99. expect((await post(base, body, { secret: 'first', delivery: 'first' })).status).toBe(202)
  100. fake.setSecret('second')
  101. expect((await post(base, body, { secret: 'first', delivery: 'stale' })).status).toBe(401)
  102. expect((await post(base, body, { secret: 'second', delivery: 'second' })).status).toBe(202)
  103. expect(fake.dispatch).toHaveBeenCalledTimes(2)
  104. })
  105. it.each([
  106. ['method', { method: 'GET' }, 405],
  107. ['content type', { contentType: 'text/plain' }, 415],
  108. ['content type parameter', { contentType: 'application/json; boundary=x' }, 415],
  109. ['content type parameters', { contentType: 'application/json; charset=utf-8; boundary=x' }, 415],
  110. ['signature', { signature: 'sha256=bad' }, 401],
  111. ['event header', { event: '' }, 400],
  112. ['delivery header', { delivery: '' }, 400],
  113. ] as const)('rejects an invalid %s before dispatch', async (_label, options, status) => {
  114. const fake = fakeContext()
  115. const base = await serve(fake.ctx)
  116. const response = await post(base, '{}', options)
  117. expect(response.status).toBe(status)
  118. if (status === 405) expect(response.headers.get('allow')).toBe('POST')
  119. expect(fake.dispatch).not.toHaveBeenCalled()
  120. })
  121. it('rejects a missing Content-Type before body processing', async () => {
  122. const fake = fakeContext()
  123. const handler = createGitHubWebhookHandler(fake.ctx, {
  124. source: 'primary',
  125. secretEnv: credentialRef('DSH_GITHUB_WEBHOOK_SECRET'),
  126. maxBodyBytes: 1024,
  127. })
  128. const request = { method: 'POST', headers: {}, headersDistinct: {} } as unknown as IncomingMessage
  129. const writeHead = vi.fn()
  130. const response = { setHeader: vi.fn(), writeHead, end: vi.fn() } as unknown as ServerResponse
  131. await handler(request, response)
  132. expect(writeHead).toHaveBeenCalledWith(415, expect.any(Object))
  133. expect(fake.dispatch).not.toHaveBeenCalled()
  134. })
  135. it('rejects duplicate required headers', async () => {
  136. const fake = fakeContext()
  137. const handler = createGitHubWebhookHandler(fake.ctx, {
  138. source: 'primary',
  139. secretEnv: credentialRef('DSH_GITHUB_WEBHOOK_SECRET'),
  140. maxBodyBytes: 1024,
  141. })
  142. const request = {
  143. method: 'POST',
  144. headers: { 'content-type': 'application/json' },
  145. headersDistinct: {
  146. 'x-hub-signature-256': ['sha256=unused'],
  147. 'x-github-delivery': ['delivery-1'],
  148. 'x-github-event': ['pull_request', 'ping'],
  149. },
  150. complete: true,
  151. async * [Symbol.asyncIterator]() { yield Buffer.from('{}') },
  152. } as unknown as IncomingMessage
  153. const writeHead = vi.fn()
  154. const response = { setHeader: vi.fn(), writeHead, end: vi.fn() } as unknown as ServerResponse
  155. await handler(request, response)
  156. expect(writeHead).toHaveBeenCalledWith(400, expect.any(Object))
  157. expect(fake.dispatch).not.toHaveBeenCalled()
  158. })
  159. it.each([
  160. ['not JSON', '{', 400],
  161. ['array', '[]', 400],
  162. ['non-lossless number', '{"value":1e400}', 400],
  163. ] as const)('rejects a signed %s body', async (_label, body, status) => {
  164. const fake = fakeContext()
  165. const base = await serve(fake.ctx)
  166. const response = await post(base, body)
  167. expect(response.status).toBe(status)
  168. expect(fake.dispatch).not.toHaveBeenCalled()
  169. })
  170. it('rejects declared and streamed bodies over the configured cap', async () => {
  171. const fake = fakeContext()
  172. const base = await serve(fake.ctx, 2)
  173. const response = await post(base, '{} ')
  174. expect(response.status).toBe(413)
  175. expect(fake.dispatch).not.toHaveBeenCalled()
  176. })
  177. it('answers 503 when the credential or runtime is unavailable', async () => {
  178. const missing = fakeContext()
  179. missing.setSecret(undefined)
  180. const missingBase = await serve(missing.ctx)
  181. expect((await post(missingBase, '{}')).status).toBe(503)
  182. const closing = fakeContext()
  183. closing.dispatch.mockImplementation(() => { throw new Error('closing') })
  184. const closingBase = await serve(closing.ctx)
  185. expect((await post(closingBase, '{}')).status).toBe(503)
  186. expect(closing.warnings).toHaveBeenCalledTimes(1)
  187. })
  188. it('does not leak the signed payload or secret in an infrastructure diagnostic', async () => {
  189. const fake = fakeContext('super-secret')
  190. ;(fake.ctx.credentials.resolve as ReturnType<typeof vi.fn> | undefined) = vi.fn(async () => {
  191. throw new Error('credential store unavailable')
  192. }) as never
  193. const base = await serve(fake.ctx)
  194. const body = JSON.stringify({ private: 'payload-secret' })
  195. expect((await post(base, body, { secret: 'super-secret' })).status).toBe(503)
  196. const diagnostics = JSON.stringify(fake.warnings.mock.calls)
  197. expect(diagnostics).not.toContain('super-secret')
  198. expect(diagnostics).not.toContain('payload-secret')
  199. })
  200. })