#!/usr/bin/env node import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const port = 56000 + Math.floor(Math.random() * 3000); const origin = `http://127.0.0.1:${port}`; const pathBase = '/xiaoshu-sync'; const base = `${origin}${pathBase}`; const keyId = 'local-smoke'; const secret = randomBytes(32).toString('hex'); const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); let output = ''; const dotnet = process.env.DOTNET_PATH || '/usr/local/bin/dotnet'; const child = spawn(dotnet, [ 'run', '--project', 'legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj', '-c', 'Release', '--no-build', '--no-restore', '--urls', origin, ], { cwd: repoRoot, env: { ...process.env, ASPNETCORE_ENVIRONMENT: 'Production', DOTNET_ROLL_FORWARD: 'Major', ConnectionStrings__LegacySqlServer: 'Server=127.0.0.1,1;Database=not-used;User Id=none;Password=none;TrustServerCertificate=True;Connect Timeout=1', XiaoshuSync__KeyId: keyId, XiaoshuSync__Secret: secret, XiaoshuSync__PathBase: pathBase, }, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout.on('data', (chunk) => { output = `${output}${chunk}`.slice(-12000); }); child.stderr.on('data', (chunk) => { output = `${output}${chunk}`.slice(-12000); }); child.on('error', (error) => { output = `${output}\n${error.message}`.slice(-12000); }); function signatureHeaders(method, pathAndQuery, body = '', timestamp = Math.floor(Date.now() / 1000), nonce = randomUUID().replaceAll('-', '')) { const bodyHash = createHash('sha256').update(body).digest('hex'); const canonical = `${method.toUpperCase()}\n${pathAndQuery}\n${timestamp}\n${nonce}\n${bodyHash}`; return { 'X-Xiaoshu-Key-Id': keyId, 'X-Xiaoshu-Timestamp': String(timestamp), 'X-Xiaoshu-Nonce': nonce, 'X-Xiaoshu-Signature': createHmac('sha256', secret).update(canonical).digest('hex'), }; } async function waitForHealth() { const deadline = Date.now() + 30000; while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`同步桥提前退出:${output}`); try { const response = await fetch(`${base}/v1/health`); if (response.ok && (await response.json()).status === 'ok') return; } catch {} await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(`同步桥健康检查超时:${output}`); } async function expectStatus(url, expected, init = {}) { const response = await fetch(url, init); if (response.status !== expected) { const body = await response.text(); throw new Error(`${url} 期望 HTTP ${expected},实际 ${response.status}: ${body.slice(0, 500)}`); } return response; } try { await waitForHealth(); await expectStatus(`${base}/v1/manifest`, 401); const invalidPath = `${pathBase}/v1/changes?dataset=unsupported`; const nonce = randomUUID().replaceAll('-', ''); const headers = signatureHeaders('GET', invalidPath, '', Math.floor(Date.now() / 1000), nonce); await expectStatus(`${origin}${invalidPath}`, 400, { headers }); await expectStatus(`${origin}${invalidPath}`, 409, { headers }); const expiredHeaders = signatureHeaders('GET', invalidPath, '', Math.floor(Date.now() / 1000) - 1000); await expectStatus(`${origin}${invalidPath}`, 401, { headers: expiredHeaders }); const oversized = JSON.stringify({ actor: 'smoke', reason: 'request-size-guard', idempotencyKey: randomUUID(), payload: { value: 'x'.repeat(1_048_576) } }); await expectStatus(`${base}/v1/commands/member.update-profile`, 413, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: oversized, }); console.log(JSON.stringify({ passed: true, publicHealth: true, manifestRequiresSignature: true, pathBaseSignature: true, replayRejected: true, expiredSignatureRejected: true, requestSizeGuard: true }, null, 2)); } finally { if (child.exitCode === null) child.kill('SIGTERM'); await new Promise((resolve) => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); resolve(); }, 3000).unref(); }); }