import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs'; import { buildSshArgs, computeTunnelRetryDelay } from '../scripts/start-remote.mjs'; const enrollment = { installation_id: 'dev-123', ssh: { host: '203.0.113.10', port: 22, user: 'tunnel', certificate: 'ssh-ed25519-cert-v01@openssh.com AAAATEST cert', host_key: 'ssh-ed25519 AAAATEST host', }, database: { name: 'system_db', username: 'client_123', password: 'a password with # symbols', }, redis: { host: 'redis.example.test', port: 6380, username: 'cangku_1234567890abcdef', password: 'redis secret', db: 0, tls: true, tls_server_name: 'redis.example.test', }, application_admin: { username: 'dev_123', password: 'one-time-password', }, }; test('validates complete enrollment responses', () => { assert.equal(validateEnrollmentResponse(enrollment), enrollment); assert.throws( () => validateEnrollmentResponse({ ...enrollment, database: { ...enrollment.database, password: '' } }), /database\.password/u ); }); test('builds an SSH tunnel only for the system database', () => { const args = buildSshArgs({ localDatabasePort: 13307, ssh: { host: 'example.test', port: 50232, user: 'cangku-tunnel', remoteDatabaseHost: '127.0.0.1', remoteDatabasePort: 3307, }, }); assert.ok(args.includes('127.0.0.1:13307:127.0.0.1:3307')); assert.equal(args.some((value) => String(value).includes(':6380')), false); }); test('merges environment values without losing unrelated settings', () => { const result = mergeEnv('KEEP=yes\nLOCAL_DB_HOST=old\n', { LOCAL_DB_HOST: '127.0.0.1', LOCAL_DB_PASSWORD: 'secret # value', }); assert.match(result, /^KEEP=yes$/mu); assert.match(result, /^LOCAL_DB_HOST=127\.0\.0\.1$/mu); assert.match(result, /^LOCAL_DB_PASSWORD="secret # value"$/mu); }); test('pins standard and non-standard SSH host targets', () => { assert.equal(getKnownHostsLine('example.test', 22, 'ssh-ed25519 KEY'), 'example.test ssh-ed25519 KEY\n'); assert.equal(getKnownHostsLine('example.test', 2222, 'ssh-ed25519 KEY'), '[example.test]:2222 ssh-ed25519 KEY\n'); }); // ───────────────────────────────────────────────────────────────────────────── // 隧道自愈(根治远端 RST 导致「网站打不开」) // // 旧行为:隧道进程 exit → stopChildren(),把后端 + 前端一起杀掉,于是每一次远端 // RST 都升级成一次整栈不可用,还得靠外部守护清残留、整树重启。 // 新行为:隧道 exit → 只重连隧道本身,应用进程不受影响。 // ───────────────────────────────────────────────────────────────────────────── const tunnelConfig = { localDatabasePort: 13307, ssh: { host: 'example.test', port: 50232, user: 'cangku-tunnel', remoteDatabaseHost: '127.0.0.1', remoteDatabasePort: 3307, }, }; test('tunnel keepalive: 更快发现死连接,且不会在 connect 阶段无限等待', () => { const flags = new Set(buildSshArgs(tunnelConfig)); // ExitOnForwardFailure 缺失会出现最阴险的故障:进程活着、端口却没绑上(「静默成功」) assert.ok(flags.has('ExitOnForwardFailure=yes'), '必须保留 ExitOnForwardFailure=yes'); assert.ok(flags.has('ServerAliveInterval=15'), '探活间隔应为 15s,以便更快发现被 RST 的连接'); assert.ok(flags.has('ServerAliveCountMax=3')); assert.ok(flags.has('TCPKeepAlive=yes'), '需要内核层保活兜底'); assert.ok(flags.has('ConnectTimeout=10'), '连不上时应快速失败,交给退避重连而不是干等'); }); test('tunnel retry backoff: 1s 起指数增长,封顶 30s 且不会溢出', () => { assert.equal(computeTunnelRetryDelay(0), 1000); assert.equal(computeTunnelRetryDelay(1), 2000); assert.equal(computeTunnelRetryDelay(2), 4000); assert.equal(computeTunnelRetryDelay(4), 16000); assert.equal(computeTunnelRetryDelay(5), 30000); // 长期不可用时 attempt 会一路增长,必须封顶而不是算成 Infinity / 0 assert.equal(computeTunnelRetryDelay(200), 30000); assert.ok(Number.isFinite(computeTunnelRetryDelay(2000))); }); test('防腐化:隧道自愈循环不得停止后端/前端进程', async () => { const source = await readFile(new URL('../scripts/start-remote.mjs', import.meta.url), 'utf8'); const start = source.indexOf('const superviseTunnel = async () => {'); const end = source.indexOf('const startTunnelSupervisor = () => {'); assert.ok(start > 0 && end > start, '未定位到 superviseTunnel 实现'); const body = source.slice(start, end); assert.equal( body.includes('stopChildren()'), false, '隧道重连不得牵连后端/前端 —— 否则又回到「隧道一断,整个开发栈陪葬」' ); assert.ok( body.includes('await sleep('), '隧道退出后必须先等待再重连,避免 RST 时疯狂重连打爆对端' ); });