callback-runtime-smoke-test.mjs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import assert from 'node:assert/strict';
  2. import crypto from 'node:crypto';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import { pathToFileURL } from 'node:url';
  7. import { decryptPayload, EnterpriseRelayRuntime } from '../runtime/callback-service/src/enterprise-relay-client.mjs';
  8. import { loadRuntimeConfig, resolveRuntimeMode } from '../runtime/callback-service/src/config-loader.mjs';
  9. import { PortraitQueueWorker } from '../runtime/callback-service/src/portrait-queue-worker.mjs';
  10. import {
  11. clearRuntimeStopRequest,
  12. readRuntimeState,
  13. readRuntimeStopRequest,
  14. requestRuntimeStop,
  15. writeRuntimeState,
  16. } from '../runtime/callback-service/src/runtime-state.mjs';
  17. function encryptV2(payload, publicKey) {
  18. const aesKey = crypto.randomBytes(32);
  19. const iv = crypto.randomBytes(12);
  20. const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
  21. const ciphertext = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]);
  22. const envelope = {
  23. key: crypto.publicEncrypt({ key: publicKey, oaepHash: 'sha256' }, aesKey).toString('base64'),
  24. iv: iv.toString('base64'),
  25. tag: cipher.getAuthTag().toString('base64'),
  26. ciphertext: ciphertext.toString('base64'),
  27. };
  28. return `v2:${Buffer.from(JSON.stringify(envelope)).toString('base64')}`;
  29. }
  30. const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-runtime-smoke-'));
  31. try {
  32. const configPath = path.join(tempRoot, 'runtime-config.mjs');
  33. fs.writeFileSync(configPath, "export default { edition: 'personal', dashboard: { port: 4399 } };\n", 'utf8');
  34. const loaded = await loadRuntimeConfig({ workspaceRoot: tempRoot, configPath });
  35. assert.equal(loaded.mode, 'personal');
  36. assert.equal(loaded.config.dashboard.port, 4399);
  37. assert.equal(loaded.config.enterprise.relay.batchSize, 100);
  38. assert.equal(resolveRuntimeMode({ edition: 'enterprise' }), 'enterprise');
  39. const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
  40. const payload = JSON.stringify({ cmd: 15000, content: 'runtime-smoke' });
  41. const encrypted = encryptV2(payload, publicKey);
  42. assert.equal(decryptPayload(encrypted, privateKey), payload);
  43. const relayConfig = { batchSize: 10, waitMs: 1000, retryMinMs: 250, retryMaxMs: 1000 };
  44. const relayContext = {
  45. baseUrl: 'http://relay.test',
  46. apiSecret: 'test-secret',
  47. privateKey,
  48. tenantId: 'tenant-test',
  49. guid: 'guid-test',
  50. };
  51. let ackBody = null;
  52. let processedEnvelope = null;
  53. const successFetch = async (url, options) => {
  54. if (url.endsWith('/api/relay/poll')) {
  55. return new Response(JSON.stringify({ events: [{ eventId: 'event-1', encryptedPayload: encrypted }] }), {
  56. status: 200,
  57. headers: { 'Content-Type': 'application/json' },
  58. });
  59. }
  60. ackBody = JSON.parse(options.body);
  61. return new Response(JSON.stringify({ ackedCount: 1 }), {
  62. status: 200,
  63. headers: { 'Content-Type': 'application/json' },
  64. });
  65. };
  66. const relay = new EnterpriseRelayRuntime({
  67. config: relayConfig,
  68. fetchImpl: successFetch,
  69. processor: async envelope => { processedEnvelope = envelope; },
  70. });
  71. const relayResult = await relay.pollOnce(relayContext);
  72. assert.equal(relayResult.acked, 1);
  73. assert.deepEqual(ackBody.eventIds, ['event-1']);
  74. assert.equal(processedEnvelope.data[0].content, 'runtime-smoke');
  75. let failureAckCalled = false;
  76. const failureRelay = new EnterpriseRelayRuntime({
  77. config: relayConfig,
  78. fetchImpl: async url => {
  79. if (url.endsWith('/api/relay/ack')) failureAckCalled = true;
  80. return new Response(JSON.stringify({ events: [{ eventId: 'event-2', encryptedPayload: encrypted }] }), {
  81. status: 200,
  82. headers: { 'Content-Type': 'application/json' },
  83. });
  84. },
  85. processor: async () => { throw new Error('processor-test-failure'); },
  86. });
  87. await assert.rejects(() => failureRelay.pollOnce(relayContext), /retained 1 failed event/);
  88. assert.equal(failureAckCalled, false);
  89. let releasePortraitRun;
  90. let portraitRuns = 0;
  91. const portraitStates = [];
  92. const portraitWorker = new PortraitQueueWorker({
  93. intervalMs: 60_000,
  94. processor: async limit => {
  95. portraitRuns += 1;
  96. assert.equal(limit, 5);
  97. await new Promise(resolve => { releasePortraitRun = resolve; });
  98. return { processed: 2, remaining: 3 };
  99. },
  100. onState: patch => portraitStates.push(patch.portraitQueue),
  101. });
  102. portraitWorker.start();
  103. assert.equal(portraitRuns, 1);
  104. assert.deepEqual(await portraitWorker.runOnce(), { skipped: true, reason: 'in-flight' });
  105. releasePortraitRun();
  106. await portraitWorker.stop();
  107. assert.equal(portraitStates.some(item => item.processed === 2 && item.remaining === 3), true);
  108. assert.equal(portraitStates.at(-1).status, 'stopped');
  109. const statePath = path.join(tempRoot, 'runtime-state.json');
  110. writeRuntimeState({ pid: 123, status: 'running', apiSecret: 'hidden', components: { relay: { token: 'hidden' } } }, statePath);
  111. const state = readRuntimeState(statePath);
  112. assert.equal(state.status, 'running');
  113. assert.equal('apiSecret' in state, false);
  114. assert.equal('token' in state.components.relay, false);
  115. requestRuntimeStop(statePath);
  116. assert.equal(readRuntimeStopRequest(statePath).targetPid, 123);
  117. clearRuntimeStopRequest(statePath);
  118. assert.deepEqual(readRuntimeStopRequest(statePath), {});
  119. process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 24 }, null, 2)}\n`);
  120. } finally {
  121. fs.rmSync(tempRoot, { recursive: true, force: true });
  122. }