admin_realtime_probe.mjs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import assert from 'node:assert/strict';
  2. import { readFileSync } from 'node:fs';
  3. import { test } from 'node:test';
  4. const source = readFileSync(new URL('../../public/static/js/realtime-probe.js', import.meta.url));
  5. const { RealtimeProbe, ProbeError, apiAddress, apiEndpoint, redact } = await import(`data:text/javascript;base64,${source.toString('base64')}`);
  6. const A = 'admin.orders.withdraw';
  7. const B = 'admin.users.online';
  8. const flush = () => new Promise((resolve) => setImmediate(resolve));
  9. const event = (seq, channel = A) => ({ seq, id: seq, channel, event: channel === A ? 'order.updated' : 'user.online',
  10. action: channel === A ? 'updated' : 'online', key: { id: 88 }, data: { status: 1 } });
  11. const config = { code: 0, data: { key: 'fixture-key', ws_host: 'localhost', ws_port: 8080, force_tls: false,
  12. channels: [A, B, 'admin.stats.platform_count', 'admin.stats.unprocessed'], last_seq: 0, session_expires_at: 1900000000, auth_endpoint: '/admin/broadcasting/auth' } };
  13. class Subscription {
  14. constructor() { this.listeners = new Map(); }
  15. listen(name, callback) { this.listeners.set(name, callback); return this; }
  16. subscribed(callback) { this.ready = callback; return this; }
  17. error(callback) { this.failed = callback; return this; }
  18. emit(message) { this.listeners.get(`.${message.event}`)?.(message); }
  19. }
  20. class Echo {
  21. constructor(options) {
  22. this.options = options; this.channels = new Map(); this.id = '1.1';
  23. this.connector = { pusher: { connection: { bind: (name, callback) => { this[name] = callback; } } } };
  24. }
  25. private(name) { if (!this.channels.has(name)) this.channels.set(name, new Subscription()); return this.channels.get(name); }
  26. leave(name) { this.channels.delete(name); }
  27. socketId() { return this.id; }
  28. disconnect() { this.state_change?.({ current: 'disconnected' }); }
  29. }
  30. const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done; }); return { resolve, promise }; };
  31. async function setup(request) {
  32. const logs = [], events = [], requests = [];
  33. const probe = new RealtimeProbe({ Echo, Pusher: function Pusher() {}, onLog: (entry) => logs.push(entry), onEvent: (entry) => events.push(entry),
  34. request: async (url, options) => {
  35. requests.push({ url, options });
  36. if (request) return request(url, options);
  37. if (url.includes('/config')) return structuredClone(config);
  38. if (url.includes('/auth')) return { auth: 'fixture-signature' };
  39. return { code: 0, data: { last_seq: 0, resync: false, events: [] } };
  40. } });
  41. await probe.configure('https://api.example.test/', 'Bearer fixture-token');
  42. return { probe, logs, events, requests };
  43. }
  44. test('configuration and historical replay never count as real-time delivery', async () => {
  45. const { probe } = await setup();
  46. probe.connect([A]);
  47. probe.echo.state_change({ current: 'connected' });
  48. probe.echo.private(A).ready(); await flush();
  49. probe.receive(event(1), 'replay');
  50. assert.equal(probe.state.liveCount, 0);
  51. assert.equal(probe.state.replayCount, 1);
  52. probe.echo.private(A).emit(event(2));
  53. assert.equal(probe.state.liveCount, 1);
  54. });
  55. test('manual reconnect retains each channel resume point and ignores a stale HTTP completion', async () => {
  56. const old = deferred(); let replayCalls = 0;
  57. const { probe, events, requests } = await setup((url) => {
  58. if (url.includes('/config')) return structuredClone(config);
  59. if (++replayCalls === 1) return old.promise;
  60. return { code: 0, data: { resync: false, last_seq: 2, events: [event(2)] } };
  61. });
  62. probe.connect([A]); const first = probe.echo;
  63. first.private(A).ready();
  64. first.private(A).emit(event(1));
  65. probe.disconnect();
  66. old.resolve({ code: 0, data: { resync: false, last_seq: 10, events: [event(10)] } });
  67. await flush();
  68. probe.connect([A]); probe.echo.private(A).ready(); await flush();
  69. assert.equal(new URL(requests.at(-1).url).searchParams.get('after_seq'), '0');
  70. assert.deepEqual(events.map((e) => e.event.seq), [1, 2]);
  71. });
  72. test('changing credentials invalidates in-flight responses and clears old session data', async () => {
  73. const response = deferred();
  74. const { probe } = await setup();
  75. probe.request = () => response.promise;
  76. const loading = probe.configure('https://api.example.test', 'new-token');
  77. probe.invalidate();
  78. response.resolve(structuredClone(config));
  79. assert.equal(await loading, null);
  80. assert.equal(probe.state.config, null);
  81. assert.equal(probe.token, '');
  82. assert.equal(probe.channels.size, 0);
  83. });
  84. test('duplicate detection is scoped by seq and channel, not just seq', async () => {
  85. const { probe, events } = await setup();
  86. probe.receive(event(1), 'live'); probe.receive(event(1), 'replay'); probe.receive(event(1, B), 'live');
  87. assert.deepEqual(events.map((e) => e.duplicate), [false, true, false]);
  88. assert.equal(probe.state.duplicateCount, 1);
  89. });
  90. test('rejected authorization stays visible and 401 stops the expired session', async () => {
  91. const { probe, logs } = await setup();
  92. probe.connect([A]);
  93. probe.request = async () => { throw new ProbeError('expired', 401); };
  94. let callbackError;
  95. await probe.authorize(`private-${A}`, '1.1', (error) => { callbackError = error; }, probe.epoch);
  96. assert.equal(callbackError.status, 401);
  97. assert.equal(probe.state.expired, true);
  98. assert.equal(probe.echo, null);
  99. assert.throws(() => probe.connect([A]), /登录已过期/);
  100. assert.ok(logs.some((entry) => entry.message.includes('登录已过期')));
  101. });
  102. test('applying subscriptions twice does not add duplicate handlers; leaving stops that channel', async () => {
  103. const { probe, events } = await setup();
  104. probe.connect([A]); const sub = probe.echo.private(A);
  105. probe.subscribe([A]);
  106. sub.emit(event(1)); assert.equal(events.length, 1);
  107. probe.subscribe([]); sub.emit(event(2)); assert.equal(events.length, 1);
  108. });
  109. test('redaction removes credentials from log messages and nested event payloads', () => {
  110. const token = 'fixture-token';
  111. const safe = redact({ message: `Echo failed for Bearer ${token}`, data: { token, password: 'hidden', text: token } }, token);
  112. assert.equal(JSON.stringify(safe).includes(token), false);
  113. assert.equal(safe.data.password, '[已隐藏]');
  114. });
  115. test('base and auth URLs cannot carry credentials or change the request origin', () => {
  116. assert.throws(() => apiAddress('https://name:password@example.test'), /账号/);
  117. assert.throws(() => apiAddress('javascript:alert(1)'), /http/);
  118. assert.throws(() => apiEndpoint('https://api.example.test', '//other.example.test/auth'), /路径/);
  119. assert.equal(apiEndpoint('https://api.example.test/base', '/admin/broadcasting/auth'), 'https://api.example.test/base/admin/broadcasting/auth');
  120. });
  121. test('invalid reconfiguration does not leave a previous usable configuration', async () => {
  122. const { probe } = await setup();
  123. await assert.rejects(probe.configure('not-an-address', 'new-token'), /完整 API 地址/);
  124. assert.equal(probe.state.config, null);
  125. assert.throws(() => probe.connect([A]), /先获取/);
  126. });
  127. test('statistics channels receive stats.updated snapshots without treating them as list rows', async () => {
  128. const { probe, events } = await setup();
  129. const name = 'admin.stats.platform_count';
  130. probe.connect([name]);
  131. probe.echo.private(name).emit({ seq: 1, channel: name, event: 'stats.updated', action: 'updated', key: { scope: 'all' },
  132. data: { total: 2, apple: 1, android: 0, pc: 1, mobile: 0, wechat: 0, unknown: 0 } });
  133. assert.equal(probe.state.liveCount, 1);
  134. assert.equal(events[0].event.data.total, 2);
  135. assert.deepEqual(events[0].event.key, { scope: 'all' });
  136. });