handler.spec.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import { createHmac } from 'node:crypto'
  2. import { createServer, request as httpRequest, 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. /** Send body chunks without Content-Length through a real Node client socket. */
  78. async function postChunked(
  79. base: string,
  80. chunks: readonly string[],
  81. endDelayMs = 0,
  82. ): Promise<{ body: string; status: number }> {
  83. return await new Promise((resolve, reject) => {
  84. const request = httpRequest(base, {
  85. method: 'POST',
  86. headers: {
  87. connection: 'close',
  88. 'content-type': 'application/json',
  89. 'transfer-encoding': 'chunked',
  90. 'x-hub-signature-256': 'sha256=unused',
  91. 'x-github-event': 'pull_request',
  92. 'x-github-delivery': 'chunked-delivery',
  93. },
  94. }, (response) => {
  95. let body = ''
  96. response.setEncoding('utf8')
  97. response.on('data', (chunk: string) => { body += chunk })
  98. response.on('end', () => { resolve({ body, status: response.statusCode ?? 0 }) })
  99. })
  100. request.once('error', reject)
  101. request.once('socket', (socket) => { socket.setNoDelay(true) })
  102. for (const chunk of chunks) request.write(chunk)
  103. if (endDelayMs === 0) request.end()
  104. else setTimeout(() => { request.end() }, endDelayMs)
  105. })
  106. }
  107. describe('GitHub webhook HTTP handler', () => {
  108. it('verifies, projects, dispatches, and answers 202', async () => {
  109. const fake = fakeContext()
  110. const base = await serve(fake.ctx)
  111. const body = JSON.stringify({ action: 'ready_for_review', number: 1 })
  112. const response = await post(base, body, { contentType: 'application/json; charset=utf-8' })
  113. expect(response.status).toBe(202)
  114. expect(await response.text()).toBe('')
  115. expect(fake.dispatch).toHaveBeenCalledOnce()
  116. const dispatched: unknown = fake.dispatch.mock.calls[0]?.[0]
  117. expect(dispatched).toMatchObject({
  118. kind: 'github',
  119. source: 'primary',
  120. deliveryId: 'delivery-1',
  121. event: { name: 'pull_request', payload: { action: 'ready_for_review', number: 1 } },
  122. })
  123. expect(typeof (dispatched as { receivedAt?: unknown }).receivedAt).toBe('number')
  124. })
  125. it('resolves the secret for each request so rotation takes effect immediately', async () => {
  126. const fake = fakeContext('first')
  127. const base = await serve(fake.ctx)
  128. const body = JSON.stringify({ ping: true })
  129. expect((await post(base, body, { secret: 'first', delivery: 'first' })).status).toBe(202)
  130. fake.setSecret('second')
  131. expect((await post(base, body, { secret: 'first', delivery: 'stale' })).status).toBe(401)
  132. expect((await post(base, body, { secret: 'second', delivery: 'second' })).status).toBe(202)
  133. expect(fake.dispatch).toHaveBeenCalledTimes(2)
  134. })
  135. it.each([
  136. ['method', { method: 'GET' }, 405],
  137. ['content type', { contentType: 'text/plain' }, 415],
  138. ['content type parameter', { contentType: 'application/json; boundary=x' }, 415],
  139. ['content type parameters', { contentType: 'application/json; charset=utf-8; boundary=x' }, 415],
  140. ['signature', { signature: 'sha256=bad' }, 401],
  141. ['event header', { event: '' }, 400],
  142. ['delivery header', { delivery: '' }, 400],
  143. ] as const)('rejects an invalid %s before dispatch', async (_label, options, status) => {
  144. const fake = fakeContext()
  145. const base = await serve(fake.ctx)
  146. const response = await post(base, '{}', options)
  147. expect(response.status).toBe(status)
  148. if (status === 405) expect(response.headers.get('allow')).toBe('POST')
  149. expect(fake.dispatch).not.toHaveBeenCalled()
  150. })
  151. it('rejects a missing Content-Type before body processing', async () => {
  152. const fake = fakeContext()
  153. const handler = createGitHubWebhookHandler(fake.ctx, {
  154. source: 'primary',
  155. secretEnv: credentialRef('DSH_GITHUB_WEBHOOK_SECRET'),
  156. maxBodyBytes: 1024,
  157. })
  158. const request = { method: 'POST', headers: {}, headersDistinct: {} } as unknown as IncomingMessage
  159. const writeHead = vi.fn()
  160. const response = { setHeader: vi.fn(), writeHead, end: vi.fn() } as unknown as ServerResponse
  161. await handler(request, response)
  162. expect(writeHead).toHaveBeenCalledWith(415, expect.any(Object))
  163. expect(fake.dispatch).not.toHaveBeenCalled()
  164. })
  165. it('rejects duplicate required headers', async () => {
  166. const fake = fakeContext()
  167. const handler = createGitHubWebhookHandler(fake.ctx, {
  168. source: 'primary',
  169. secretEnv: credentialRef('DSH_GITHUB_WEBHOOK_SECRET'),
  170. maxBodyBytes: 1024,
  171. })
  172. const request = {
  173. method: 'POST',
  174. headers: { 'content-type': 'application/json' },
  175. headersDistinct: {
  176. 'x-hub-signature-256': ['sha256=unused'],
  177. 'x-github-delivery': ['delivery-1'],
  178. 'x-github-event': ['pull_request', 'ping'],
  179. },
  180. complete: true,
  181. async * [Symbol.asyncIterator]() { yield Buffer.from('{}') },
  182. } as unknown as IncomingMessage
  183. const writeHead = vi.fn()
  184. const response = { setHeader: vi.fn(), writeHead, end: vi.fn() } as unknown as ServerResponse
  185. await handler(request, response)
  186. expect(writeHead).toHaveBeenCalledWith(400, expect.any(Object))
  187. expect(fake.dispatch).not.toHaveBeenCalled()
  188. })
  189. it.each([
  190. ['not JSON', '{', 400],
  191. ['array', '[]', 400],
  192. ['non-lossless number', '{"value":1e400}', 400],
  193. ] as const)('rejects a signed %s body', async (_label, body, status) => {
  194. const fake = fakeContext()
  195. const base = await serve(fake.ctx)
  196. const response = await post(base, body)
  197. expect(response.status).toBe(status)
  198. expect(fake.dispatch).not.toHaveBeenCalled()
  199. })
  200. it('rejects a declared body over the configured cap', async () => {
  201. const fake = fakeContext()
  202. const base = await serve(fake.ctx, 2)
  203. const response = await post(base, '{} ')
  204. expect(response.status).toBe(413)
  205. expect(fake.dispatch).not.toHaveBeenCalled()
  206. })
  207. it('answers 413 for a chunked body over the cap without resetting the connection', async () => {
  208. const fake = fakeContext()
  209. const base = await serve(fake.ctx, 2)
  210. await expect(postChunked(base, ['abc'], 50)).resolves.toEqual({
  211. body: 'request body is too large',
  212. status: 413,
  213. })
  214. expect(fake.dispatch).not.toHaveBeenCalled()
  215. })
  216. it('answers 503 when the credential or runtime is unavailable', async () => {
  217. const missing = fakeContext()
  218. missing.setSecret(undefined)
  219. const missingBase = await serve(missing.ctx)
  220. expect((await post(missingBase, '{}')).status).toBe(503)
  221. const closing = fakeContext()
  222. closing.dispatch.mockImplementation(() => { throw new Error('closing') })
  223. const closingBase = await serve(closing.ctx)
  224. expect((await post(closingBase, '{}')).status).toBe(503)
  225. expect(closing.warnings).toHaveBeenCalledTimes(1)
  226. })
  227. it('does not leak the signed payload or secret in an infrastructure diagnostic', async () => {
  228. const fake = fakeContext('super-secret')
  229. ;(fake.ctx.credentials.resolve as ReturnType<typeof vi.fn> | undefined) = vi.fn(async () => {
  230. throw new Error('credential store unavailable')
  231. }) as never
  232. const base = await serve(fake.ctx)
  233. const body = JSON.stringify({ private: 'payload-secret' })
  234. expect((await post(base, body, { secret: 'super-secret' })).status).toBe(503)
  235. const diagnostics = JSON.stringify(fake.warnings.mock.calls)
  236. expect(diagnostics).not.toContain('super-secret')
  237. expect(diagnostics).not.toContain('payload-secret')
  238. })
  239. })