cos-loopback.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. /** Loopback HTTP capture for tests that exercise real COS SDK request serialization. */
  2. import { createServer, type ServerResponse } from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import type COS from 'cos-nodejs-sdk-v5'
  5. /** One request the SDK actually sent, with bytes read from the wire. */
  6. export interface CosLoopbackRequest {
  7. readonly method: string
  8. /** Request path including the query string, as sent to the real endpoint. */
  9. readonly path: string
  10. /** Lowercased request headers exactly as the SDK wrote them. */
  11. readonly headers: Record<string, string | string[] | undefined>
  12. /** Exact body bytes received. */
  13. readonly body: Buffer
  14. /** Signed URL the SDK built before the test redirected it to this server. */
  15. readonly signedUrl: string | undefined
  16. }
  17. /** Owns one request's reply; use {@link answer} for a complete response. */
  18. export type CosLoopbackResponder = (request: CosLoopbackRequest, response: ServerResponse) => void | Promise<void>
  19. /** One isolated HTTP origin that captures SDK requests instead of reaching COS. */
  20. export interface CosLoopback {
  21. readonly requests: CosLoopbackRequest[]
  22. /** Redirect every SDK request to this server while keeping the signed headers intact. */
  23. redirect(cos: COS): void
  24. /** Close the server; rejects with the first responder failure so no reply error stays silent. */
  25. close(): Promise<void>
  26. }
  27. /**
  28. * Write a complete response and close the connection so the client cannot reuse its socket.
  29. * @param socket Response object owned by the responder.
  30. * @param status HTTP status code.
  31. * @param body Complete response body.
  32. * @param headers Additional response headers.
  33. */
  34. export function answer(socket: ServerResponse, status = 200, body: string | Buffer = '',
  35. headers: Record<string, string> = {}): void {
  36. socket.writeHead(status, { connection: 'close', ...headers })
  37. socket.end(body)
  38. }
  39. /**
  40. * Build a COS error body the SDK maps to its error code.
  41. * @param code COS error code, such as `NoSuchKey` or `InternalError`.
  42. * @param message Server message; tests use it to prove diagnostics stay out of retained records.
  43. * @returns XML error document.
  44. */
  45. export function cosError(code: string, message = 'fixture'): string {
  46. return `<Error><Code>${code}</Code><Message>${message}</Message></Error>`
  47. }
  48. /**
  49. * Start a loopback server that records each request before the responder answers it.
  50. * @param responder Reply for each captured request; the default is an empty 200.
  51. * @returns Recorded requests, the SDK redirect, and an idempotent close.
  52. */
  53. export async function startCosLoopback(
  54. responder: CosLoopbackResponder = (_request, response) => { answer(response) },
  55. ): Promise<CosLoopback> {
  56. const requests: CosLoopbackRequest[] = []
  57. const signedUrls: string[] = []
  58. const failures: unknown[] = []
  59. const server = createServer((incoming, response) => {
  60. const chunks: Buffer[] = []
  61. incoming.on('data', (chunk: Buffer) => chunks.push(chunk))
  62. incoming.on('end', () => {
  63. const request: CosLoopbackRequest = {
  64. method: incoming.method ?? '',
  65. path: incoming.url ?? '',
  66. headers: incoming.headers,
  67. body: Buffer.concat(chunks),
  68. signedUrl: signedUrls[requests.length],
  69. }
  70. requests.push(request)
  71. void Promise.resolve(responder(request, response)).catch((error: unknown) => {
  72. failures.push(error)
  73. response.destroy()
  74. })
  75. })
  76. })
  77. await new Promise<void>((resolve, reject) => {
  78. server.once('error', reject)
  79. server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() })
  80. })
  81. const { port } = server.address() as AddressInfo
  82. let closed = false
  83. return {
  84. requests,
  85. redirect(cos) {
  86. cos.on('before-send', (options: { url: string }) => {
  87. signedUrls.push(options.url)
  88. const parsed = new URL(options.url)
  89. options.url = `http://127.0.0.1:${port}${parsed.pathname}${parsed.search}`
  90. })
  91. },
  92. async close() {
  93. if (closed) return
  94. closed = true
  95. server.closeAllConnections()
  96. await new Promise<void>((resolve, reject) => server.close((error) => {
  97. if (error === undefined) resolve()
  98. else reject(error)
  99. }))
  100. if (failures.length > 0) throw failures[0]
  101. },
  102. }
  103. }