270 lines
9.0 KiB
JavaScript
270 lines
9.0 KiB
JavaScript
|
|
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 });
|
|||
|
|
}
|
|||
|
|
});
|