aps-agent/apps/desktop/test/sidecar.test.cjs

270 lines
9.0 KiB
JavaScript
Raw Normal View History

const assert = require('node:assert/strict');
const { EventEmitter, once } = require('node:events');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { PassThrough } = require('node:stream');
const test = require('node:test');
const {
SidecarManager,
buildChildEnv,
bundledSidecarPath,
probeHealth,
reserveLoopbackPort,
waitForHealth,
} = require('../sidecar.cjs');
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-sidecar-'));
const resourcesPath = path.join(root, 'resources');
const apsHome = path.join(root, '.aps');
fs.mkdirSync(path.join(resourcesPath, 'sidecar'), { recursive: true });
fs.mkdirSync(path.join(resourcesPath, 'web', 'dist'), { recursive: true });
fs.writeFileSync(path.join(resourcesPath, 'sidecar', 'aps-sidecar.exe'), 'fixture');
fs.writeFileSync(path.join(resourcesPath, 'web', 'dist', 'index.html'), '<!doctype html>');
return { root, resourcesPath, apsHome };
}
function fakeChild(pid) {
const child = new EventEmitter();
child.pid = pid;
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killed = false;
child.kill = () => {
child.killed = true;
child.emit('exit', 0, null);
return true;
};
return child;
}
test('reserves a loopback port', async () => {
const port = await reserveLoopbackPort();
assert.ok(Number.isInteger(port));
assert.ok(port > 0 && port < 65_536);
});
test('requires the packaged sidecar executable', () => {
const { root, resourcesPath } = fixture();
try {
assert.equal(
bundledSidecarPath(resourcesPath, 'win32'),
path.join(resourcesPath, 'sidecar', 'aps-sidecar.exe'),
);
assert.throws(() => bundledSidecarPath(path.join(root, 'missing'), 'win32'), /missing/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('health wait retries transient failures', async () => {
let attempts = 0;
await waitForHealth('http://127.0.0.1:1/api/health', {
timeoutMs: 500,
intervalMs: 1,
probe: async () => {
attempts += 1;
if (attempts < 3) throw new Error('not ready');
},
});
assert.equal(attempts, 3);
});
test('health probe requires the expected sidecar nonce', async () => {
const nonce = 'c'.repeat(64);
const received = [];
const server = http.createServer((request, response) => {
received.push(request.headers['x-aps-sidecar-nonce']);
response.writeHead(200, { 'X-APS-Sidecar-Nonce': nonce });
response.end('{"ok":true}');
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const url = `http://127.0.0.1:${address.port}/api/health`;
try {
await probeHealth(url, { expectedNonce: nonce });
await assert.rejects(
probeHealth(url, { expectedNonce: 'd'.repeat(64) }),
/unexpected sidecar identity/,
);
assert.deepEqual(received, [nonce, 'd'.repeat(64)]);
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
test('cleans Conda and Python environment state before launching a frozen sidecar', () => {
const condaRoot = 'D:\\AppData\\anaconda3';
const env = buildChildEnv(
{
PATH: [
condaRoot,
`${condaRoot}\\Library\\bin`,
`${condaRoot}\\Library\\mingw-w64\\bin`,
`${condaRoot}\\Scripts`,
`${condaRoot}\\condabin`,
'C:\\Windows\\System32',
'C:\\Program Files\\Git\\cmd',
'C:\\APS\\Scripts',
].join(';'),
CONDA_PREFIX: condaRoot,
CONDA_DEFAULT_ENV: 'base',
CONDA_EXE: `${condaRoot}\\Scripts\\conda.exe`,
PYTHONHOME: 'bad-home',
PythonPath: 'bad-path',
PYTHONUSERBASE: 'bad-user-site',
WEB_CONCURRENCY: '2',
UVICORN_WORKERS: '3',
GUNICORN_CMD_ARGS: '--workers 4',
APS_AUTH_PROVIDER: 'untrusted-provider',
HTTP_PROXY: 'http://untrusted.invalid',
},
{
APS_MODE: 'desktop',
CONDA_PREFIX: 'C:\\override-conda',
PYTHONPATH: 'override-user-site',
PYTHONNOUSERSITE: '0',
},
'win32',
);
assert.equal(
env.PATH,
['C:\\Windows\\System32', 'C:\\Program Files\\Git\\cmd', 'C:\\APS\\Scripts'].join(';'),
);
assert.equal(env.CONDA_PREFIX, undefined);
assert.equal(env.CONDA_DEFAULT_ENV, undefined);
assert.equal(env.CONDA_EXE, undefined);
assert.equal(env.PYTHONHOME, undefined);
assert.equal(env.PythonPath, undefined);
assert.equal(env.PYTHONPATH, undefined);
assert.equal(env.PYTHONUSERBASE, undefined);
assert.equal(env.WEB_CONCURRENCY, undefined);
assert.equal(env.UVICORN_WORKERS, undefined);
assert.equal(env.GUNICORN_CMD_ARGS, undefined);
assert.equal(env.APS_AUTH_PROVIDER, undefined);
assert.equal(env.HTTP_PROXY, undefined);
assert.equal(env.PYTHONNOUSERSITE, '1');
assert.equal(env.PYTHONUTF8, '1');
assert.equal(env.APS_MODE, 'desktop');
});
test('starts the bundled executable with a same-origin UI and stops it', async () => {
const { root, resourcesPath, apsHome } = fixture();
const calls = [];
const child = fakeChild(42);
const manager = new SidecarManager({
resourcesPath,
apsHome,
platform: 'win32',
getPort: async () => 43123,
waitForHealthy: async () => {},
createNonce: () => 'a'.repeat(64),
terminateChild: (target) => target.kill(),
spawnProcess: (...args) => {
calls.push(args);
return child;
},
});
try {
assert.equal(await manager.start(), 'http://127.0.0.1:43123');
assert.equal(calls.length, 1);
assert.equal(path.basename(calls[0][0]), 'aps-sidecar.exe');
assert.deepEqual(calls[0][1], []);
assert.equal(calls[0][2].env.APS_HOME, apsHome);
assert.equal(calls[0][2].env.APS_MODE, 'desktop');
assert.equal(calls[0][2].env.APS_API_PORT, '43123');
assert.equal(calls[0][2].env.APS_UI_DIR, path.join(resourcesPath, 'web', 'dist'));
assert.equal(calls[0][2].env.APS_SIDECAR_NONCE, 'a'.repeat(64));
assert.equal(calls[0][2].env.APS_PARENT_PID, String(process.pid));
manager.stop();
assert.equal(child.killed, true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('restarts an unexpectedly exited sidecar with a fresh port and nonce', async () => {
const { root, resourcesPath, apsHome } = fixture();
const children = [fakeChild(1), fakeChild(2)];
const ports = [43124, 43125];
const nonces = ['b'.repeat(64), 'c'.repeat(64)];
const calls = [];
let launches = 0;
const manager = new SidecarManager({
resourcesPath,
apsHome,
platform: 'win32',
getPort: async () => ports.shift(),
waitForHealthy: async () => {},
restartDelayMs: 1,
stabilityResetMs: 10_000,
createNonce: () => nonces.shift(),
terminateChild: (target) => target.kill(),
spawnProcess: (...args) => {
calls.push(args);
return children[launches++];
},
});
try {
await manager.start();
const restarted = once(manager, 'restarted');
children[0].emit('exit', 7, null);
const [event] = await restarted;
assert.equal(event.attempt, 1);
assert.equal(launches, 2);
assert.equal(manager.origin, 'http://127.0.0.1:43125');
assert.equal(calls[0][2].env.APS_API_PORT, '43124');
assert.equal(calls[0][2].env.APS_SIDECAR_NONCE, 'b'.repeat(64));
assert.equal(calls[1][2].env.APS_API_PORT, '43125');
assert.equal(calls[1][2].env.APS_SIDECAR_NONCE, 'c'.repeat(64));
manager.stop();
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('generates a one-time audit token per launch and never leaks it to the child env', async () => {
const { root, resourcesPath, apsHome } = fixture();
const children = [fakeChild(1), fakeChild(2)];
const tokens = ['audit-token-1', 'audit-token-2'];
const calls = [];
let launches = 0;
const manager = new SidecarManager({
resourcesPath,
apsHome,
platform: 'win32',
appVersion: '0.2.0',
getPort: async () => 43127,
waitForHealthy: async () => {},
restartDelayMs: 1,
stabilityResetMs: 10_000,
createNonce: () => 'a'.repeat(64),
createAuditToken: () => tokens.shift(),
terminateChild: (target) => target.kill(),
spawnProcess: (...args) => {
calls.push(args);
return children[launches++];
},
});
try {
await manager.start();
assert.equal(manager.auditToken, 'audit-token-1');
// 应用版本注入子进程 env;审计 token 仅限 Electron main 进程内,不进 env/不写盘
assert.equal(calls[0][2].env.APS_APP_VERSION, '0.2.0');
assert.equal(calls[0][2].env.APS_AUDIT_TOKEN, undefined);
assert.equal(calls[0][2].env.APS_SIDECAR_NONCE, 'a'.repeat(64));
const restarted = once(manager, 'restarted');
children[0].emit('exit', 7, null);
await restarted;
assert.equal(manager.auditToken, 'audit-token-2'); // 重启后一次性 token 重新生成
assert.equal(calls[1][2].env.APS_AUDIT_TOKEN, undefined);
manager.stop();
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});