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

270 lines
9.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 });
}
});