smoke-legacy-sync-bridge.mjs 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. #!/usr/bin/env node
  2. import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
  3. import { spawn } from 'node:child_process';
  4. import { dirname, resolve } from 'node:path';
  5. import { fileURLToPath } from 'node:url';
  6. const port = 56000 + Math.floor(Math.random() * 3000);
  7. const origin = `http://127.0.0.1:${port}`;
  8. const pathBase = '/xiaoshu-sync';
  9. const base = `${origin}${pathBase}`;
  10. const keyId = 'local-smoke';
  11. const secret = randomBytes(32).toString('hex');
  12. const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
  13. let output = '';
  14. const dotnet = process.env.DOTNET_PATH || '/usr/local/bin/dotnet';
  15. const child = spawn(dotnet, [
  16. 'run', '--project', 'legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj',
  17. '-c', 'Release', '--no-build', '--no-restore', '--urls', origin,
  18. ], {
  19. cwd: repoRoot,
  20. env: {
  21. ...process.env,
  22. ASPNETCORE_ENVIRONMENT: 'Production',
  23. DOTNET_ROLL_FORWARD: 'Major',
  24. ConnectionStrings__LegacySqlServer: 'Server=127.0.0.1,1;Database=not-used;User Id=none;Password=none;TrustServerCertificate=True;Connect Timeout=1',
  25. XiaoshuSync__KeyId: keyId,
  26. XiaoshuSync__Secret: secret,
  27. XiaoshuSync__PathBase: pathBase,
  28. },
  29. stdio: ['ignore', 'pipe', 'pipe'],
  30. });
  31. child.stdout.on('data', (chunk) => { output = `${output}${chunk}`.slice(-12000); });
  32. child.stderr.on('data', (chunk) => { output = `${output}${chunk}`.slice(-12000); });
  33. child.on('error', (error) => { output = `${output}\n${error.message}`.slice(-12000); });
  34. function signatureHeaders(method, pathAndQuery, body = '', timestamp = Math.floor(Date.now() / 1000), nonce = randomUUID().replaceAll('-', '')) {
  35. const bodyHash = createHash('sha256').update(body).digest('hex');
  36. const canonical = `${method.toUpperCase()}\n${pathAndQuery}\n${timestamp}\n${nonce}\n${bodyHash}`;
  37. return {
  38. 'X-Xiaoshu-Key-Id': keyId,
  39. 'X-Xiaoshu-Timestamp': String(timestamp),
  40. 'X-Xiaoshu-Nonce': nonce,
  41. 'X-Xiaoshu-Signature': createHmac('sha256', secret).update(canonical).digest('hex'),
  42. };
  43. }
  44. async function waitForHealth() {
  45. const deadline = Date.now() + 30000;
  46. while (Date.now() < deadline) {
  47. if (child.exitCode !== null) throw new Error(`同步桥提前退出:${output}`);
  48. try {
  49. const response = await fetch(`${base}/v1/health`);
  50. if (response.ok && (await response.json()).status === 'ok') return;
  51. } catch {}
  52. await new Promise((resolve) => setTimeout(resolve, 250));
  53. }
  54. throw new Error(`同步桥健康检查超时:${output}`);
  55. }
  56. async function expectStatus(url, expected, init = {}) {
  57. const response = await fetch(url, init);
  58. if (response.status !== expected) {
  59. const body = await response.text();
  60. throw new Error(`${url} 期望 HTTP ${expected},实际 ${response.status}: ${body.slice(0, 500)}`);
  61. }
  62. return response;
  63. }
  64. try {
  65. await waitForHealth();
  66. await expectStatus(`${base}/v1/manifest`, 401);
  67. const invalidPath = `${pathBase}/v1/changes?dataset=unsupported`;
  68. const nonce = randomUUID().replaceAll('-', '');
  69. const headers = signatureHeaders('GET', invalidPath, '', Math.floor(Date.now() / 1000), nonce);
  70. await expectStatus(`${origin}${invalidPath}`, 400, { headers });
  71. await expectStatus(`${origin}${invalidPath}`, 409, { headers });
  72. const expiredHeaders = signatureHeaders('GET', invalidPath, '', Math.floor(Date.now() / 1000) - 1000);
  73. await expectStatus(`${origin}${invalidPath}`, 401, { headers: expiredHeaders });
  74. const oversized = JSON.stringify({ actor: 'smoke', reason: 'request-size-guard', idempotencyKey: randomUUID(), payload: { value: 'x'.repeat(1_048_576) } });
  75. await expectStatus(`${base}/v1/commands/member.update-profile`, 413, {
  76. method: 'POST',
  77. headers: { 'Content-Type': 'application/json' },
  78. body: oversized,
  79. });
  80. console.log(JSON.stringify({ passed: true, publicHealth: true, manifestRequiresSignature: true, pathBaseSignature: true, replayRejected: true, expiredSignatureRejected: true, requestSizeGuard: true }, null, 2));
  81. } finally {
  82. if (child.exitCode === null) child.kill('SIGTERM');
  83. await new Promise((resolve) => {
  84. if (child.exitCode !== null) return resolve();
  85. child.once('exit', resolve);
  86. setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); resolve(); }, 3000).unref();
  87. });
  88. }