callback-runtime-smoke-test.mjs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  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 { PersonalPollingRuntime } from '../runtime/callback-service/src/personal-polling.mjs';
  9. import { loadRuntimeConfig, resolveRuntimeMode } from '../runtime/callback-service/src/config-loader.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. const statePath = path.join(tempRoot, 'runtime-state.json');
  90. writeRuntimeState({ pid: 123, status: 'running', apiSecret: 'hidden', components: { relay: { token: 'hidden' } } }, statePath);
  91. const state = readRuntimeState(statePath);
  92. assert.equal(state.status, 'running');
  93. assert.equal('apiSecret' in state, false);
  94. assert.equal('token' in state.components.relay, false);
  95. requestRuntimeStop(statePath);
  96. assert.equal(readRuntimeStopRequest(statePath).targetPid, 123);
  97. clearRuntimeStopRequest(statePath);
  98. assert.deepEqual(readRuntimeStopRequest(statePath), {});
  99. let listener = { running: true, syncKey: 7, lastError: 'offline', startedAt: 1 };
  100. let stopOptions = null;
  101. let recoveryCount = 0;
  102. const personalStates = [];
  103. const personal = new PersonalPollingRuntime({
  104. config: { retryMs: 3000, friendPollingEnabled: false },
  105. workspaceRoot: tempRoot,
  106. onState: statePatch => personalStates.push(statePatch),
  107. listenerApi: {
  108. getStatus: async () => ({
  109. data: {
  110. account: { online: recoveryCount > 0 },
  111. listener,
  112. },
  113. }),
  114. recoverLogin: async () => {
  115. recoveryCount += 1;
  116. return { summary: { loggedIn: true, statusCode: 2 } };
  117. },
  118. stop: async options => { stopOptions = options; listener = { ...listener, running: false }; },
  119. start: async () => {
  120. listener = { running: true, syncKey: 7, lastError: '', startedAt: 2 };
  121. return { data: listener };
  122. },
  123. },
  124. });
  125. const recoveredListener = await personal.pollOnce();
  126. assert.equal(recoveryCount, 1);
  127. assert.deepEqual(stopOptions, { preserveAgentState: true });
  128. assert.equal(recoveredListener.running, true);
  129. assert.equal(recoveredListener.lastError, '');
  130. assert.equal(personalStates.at(-1).personalPolling.status, 'running');
  131. assert.equal(personalStates.at(-1).personalPolling.lastError, '');
  132. process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 25 }, null, 2)}\n`);
  133. } finally {
  134. fs.rmSync(tempRoot, { recursive: true, force: true });
  135. }