node-half.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. /** Node half: registers the /api prefix route bridging to the api gateway. */
  2. import { EventEmitter } from 'node:events'
  3. import { Readable } from 'node:stream'
  4. import { Context } from 'cordis'
  5. import { describe, expect, it } from 'vitest'
  6. import type { IncomingMessage, ServerResponse } from 'node:http'
  7. import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
  8. import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
  9. import { API_PATH, apply, inject } from '../src/index.ts'
  10. /** Structural httpServer fake: the plugin only touches register(). */
  11. function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
  12. return {
  13. register(route) {
  14. routes.push(route)
  15. return () => { routes.splice(routes.indexOf(route), 1) }
  16. },
  17. tapIndex: () => () => {},
  18. port: 0,
  19. }
  20. }
  21. /** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
  22. function fakeRequest(headers: Record<string, string>): IncomingMessage {
  23. const request = Readable.from([]) as unknown as IncomingMessage
  24. Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
  25. return request
  26. }
  27. /** Response recorder compatible with both the fence's short-circuit and the bridge. */
  28. function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
  29. const state: { status?: number; body?: unknown } = {}
  30. const response = Object.assign(new EventEmitter(), {
  31. writableEnded: false,
  32. writeHead(value: number) { state.status = value; return this },
  33. write() { return true },
  34. end(this: { writableEnded: boolean }, value?: unknown) {
  35. if (value !== undefined) state.body = value
  36. this.writableEnded = true
  37. return this
  38. },
  39. }) as unknown as ServerResponse
  40. return { response, state }
  41. }
  42. async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
  43. const ctx = new Context()
  44. const routes: WebRoute[] = []
  45. ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
  46. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  47. const fiber = ctx.plugin({ inject: [...inject], apply }, config)
  48. await fiber.await()
  49. return { routes, dispose: () => fiber.dispose() }
  50. }
  51. describe('connection node half', () => {
  52. it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
  53. const routes: WebRoute[] = []
  54. const ctx = new Context()
  55. ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
  56. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  57. // The apply throw also escapes cordis as a late rejection — the shape the
  58. // boot's installFailLoud is contracted to catch. Capture it so the run
  59. // stays clean, same pattern as the webserver bind-failure test.
  60. const rejections: unknown[] = []
  61. const onUnhandled = (err: unknown): void => { rejections.push(err) }
  62. process.on('unhandledRejection', onUnhandled)
  63. try {
  64. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
  65. await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
  66. expect(routes).toHaveLength(0)
  67. for (let i = 0; i < 100 && rejections.length === 0; i++) {
  68. await new Promise(resolve => setTimeout(resolve, 10))
  69. }
  70. expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
  71. } finally {
  72. process.off('unhandledRejection', onUnhandled)
  73. }
  74. })
  75. it('registers the /api prefix route and removes it with the fiber', async () => {
  76. const { routes, dispose } = await mounted()
  77. expect(routes).toHaveLength(1)
  78. expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
  79. await dispose()
  80. expect(routes).toHaveLength(0)
  81. })
  82. it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
  83. const { routes, dispose } = await mounted()
  84. const { response, state } = fakeResponse()
  85. await routes[0]!.handler(fakeRequest({
  86. host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
  87. }), response)
  88. expect(state.status).toBe(403)
  89. expect(state.body).toBe('forbidden')
  90. await dispose()
  91. })
  92. it('passes loopback and declared-authority requests through to the bridge', async () => {
  93. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
  94. // Loopback, no browser markers (curl shape): the fence passes; the carrier
  95. // answers 404 for a GET unary path — proof the bridge ran.
  96. const loopback = fakeResponse()
  97. await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
  98. expect(loopback.state.status).toBe(404)
  99. // LAN authority declared as a port-less IP literal — the shape the CLI
  100. // derives for `--host 0.0.0.0` — passes markerless curl on any port.
  101. const lan = fakeResponse()
  102. await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
  103. expect(lan.state.status).toBe(404)
  104. // Declared public authority, same-origin browser shape.
  105. const declared = fakeResponse()
  106. await routes[0]!.handler(fakeRequest({
  107. host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
  108. }), declared.response)
  109. expect(declared.state.status).toBe(404)
  110. await dispose()
  111. })
  112. })