321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
captureRuntimeCommand,
|
|
createConnectionService,
|
|
} from '../../dist/server/features/connection/index.js';
|
|
import { createConnectionRuntimeRoute } from '../../dist/server/http/routes/connectionRuntimeRoute.js';
|
|
import { createServerApplyRoute } from '../../dist/server/http/routes/serverApplyRoute.js';
|
|
import { HarborError } from '../../dist/shared/errors.js';
|
|
|
|
const serverA = { id: 'a', label: 'Alpha', host: 'a.example', port: 1, protocol: 'vless' };
|
|
const serverB = { id: 'b', label: 'Beta', host: 'b.example', port: 2, protocol: 'vless' };
|
|
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
|
|
|
|
const profile = (id, label, desiredServerId = 'a', servers = [serverA, serverB]) => ({
|
|
id,
|
|
label,
|
|
subscriptionUrl: `https://${id}.example/sub`,
|
|
subscriptionConfig: { outbounds: [] },
|
|
servers,
|
|
userInfo: {},
|
|
fetchedAt: '2026-08-08T10:00:00.000Z',
|
|
desiredServerId,
|
|
lastRefreshAttemptAt: null,
|
|
lastRefreshErrorCode: null,
|
|
});
|
|
|
|
function initialState() {
|
|
return {
|
|
revision: 5,
|
|
profiles: [profile('personal', 'Личный'), profile('work', 'Работа', 'b')],
|
|
desiredProfileId: 'personal',
|
|
appliedProfileId: 'personal',
|
|
appliedServerId: 'a',
|
|
appliedServerSnapshot: serverA,
|
|
routeRules: rules,
|
|
appliedRouteRules: rules,
|
|
routeRulesRevision: 1,
|
|
connectionDesired: 'running',
|
|
};
|
|
}
|
|
|
|
function createHarness(overrides = {}) {
|
|
let state = structuredClone(overrides.state ?? initialState());
|
|
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
|
let running = overrides.running ?? true;
|
|
let tail = Promise.resolve();
|
|
let active = 0;
|
|
let peak = 0;
|
|
let stateUpdates = 0;
|
|
let startFailure = overrides.failStart || null;
|
|
const events = [];
|
|
|
|
const serialize = (operation) => {
|
|
const run = async () => {
|
|
active += 1;
|
|
peak = Math.max(peak, active);
|
|
try { return await operation(); } finally { active -= 1; }
|
|
};
|
|
const result = tail.then(run, run);
|
|
tail = result.then(() => undefined, () => undefined);
|
|
return result;
|
|
};
|
|
|
|
const updateState = (mutator) => {
|
|
stateUpdates += 1;
|
|
const next = structuredClone(mutator(structuredClone(state)));
|
|
state = { ...next, revision: state.revision + 1 };
|
|
events.push('state.update');
|
|
if (overrides.failStateAt === stateUpdates) throw new Error('state failed');
|
|
return structuredClone(state);
|
|
};
|
|
|
|
const start = async () => {
|
|
events.push('runtime.start');
|
|
if (startFailure) {
|
|
const failure = startFailure;
|
|
startFailure = null;
|
|
running = false;
|
|
throw failure;
|
|
}
|
|
if (overrides.startGate) await overrides.startGate();
|
|
running = true;
|
|
};
|
|
const stop = async () => {
|
|
events.push('runtime.stop');
|
|
if (overrides.failStop) throw overrides.failStop;
|
|
running = false;
|
|
};
|
|
const restart = async () => {
|
|
events.push('runtime.restart');
|
|
if (overrides.failRestart) throw overrides.failRestart;
|
|
running = true;
|
|
};
|
|
|
|
const service = createConnectionService({
|
|
state: { read: () => structuredClone(state), update: updateState },
|
|
config: {
|
|
build: (_subscription, selectedServerId, routeRules) => ({ selectedServerId, routeRules }),
|
|
read: () => config,
|
|
write: (value) => {
|
|
events.push('config.write');
|
|
config = JSON.stringify(value);
|
|
if (overrides.failConfigWrite) throw overrides.failConfigWrite;
|
|
},
|
|
restore: (value) => { events.push('config.restore'); config = value; },
|
|
remove: () => { events.push('config.remove'); config = null; },
|
|
},
|
|
runtime: {
|
|
isRunning: async () => {
|
|
if (overrides.failStatus) throw overrides.failStatus;
|
|
return running;
|
|
},
|
|
start,
|
|
stop,
|
|
stopCommand: () => captureRuntimeCommand(stop),
|
|
restartCommand: () => captureRuntimeCommand(restart, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
|
|
},
|
|
route: { isGatewayDirect: () => overrides.gatewayDirect === true },
|
|
serialize,
|
|
now: () => new Date('2026-08-08T12:00:00.000Z'),
|
|
});
|
|
|
|
return {
|
|
service,
|
|
events,
|
|
serialize,
|
|
snapshot: () => structuredClone({ state, config, running, peak }),
|
|
};
|
|
}
|
|
|
|
function domain(value) {
|
|
const copy = structuredClone(value);
|
|
delete copy.state.revision;
|
|
delete copy.peak;
|
|
return copy;
|
|
}
|
|
|
|
test('apply validates the profile pair and publishes applied identity only after runtime succeeds', async () => {
|
|
const harness = createHarness();
|
|
assert.deepEqual(await harness.service.apply('work', 'a', '', 5), {
|
|
profileId: 'work', serverId: 'a', selectedTag: 'Alpha',
|
|
});
|
|
const after = harness.snapshot();
|
|
assert.equal(after.state.desiredProfileId, 'work');
|
|
assert.equal(after.state.profiles.find(({ id }) => id === 'work').desiredServerId, 'a');
|
|
assert.equal(after.state.appliedProfileId, 'work');
|
|
assert.equal(after.state.appliedServerId, 'a');
|
|
assert.deepEqual(after.state.appliedServerSnapshot, serverA);
|
|
assert.deepEqual(harness.events, ['config.write', 'runtime.start', 'state.update']);
|
|
|
|
await assert.rejects(harness.service.apply('personal', 'missing'), (error) => error.code === 'SERVER_NOT_FOUND');
|
|
await assert.rejects(harness.service.apply('missing', 'a'), (error) => error.code === 'PROFILE_NOT_FOUND');
|
|
await assert.rejects(harness.service.apply('personal', 'a', '', 4), (error) => error.code === 'STATE_CONFLICT');
|
|
});
|
|
|
|
test('failed candidate config or runtime restores the old profile, config and running state', async () => {
|
|
for (const failure of [
|
|
{ failConfigWrite: new Error('write failed') },
|
|
{ failStart: new Error('start failed') },
|
|
{ failStateAt: 1 },
|
|
]) {
|
|
const harness = createHarness(failure);
|
|
const before = harness.snapshot();
|
|
await assert.rejects(harness.service.apply('work', 'b'));
|
|
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
|
}
|
|
});
|
|
|
|
test('desired and applied targets stay old while candidate runtime is still starting', async () => {
|
|
let entered;
|
|
let release;
|
|
const started = new Promise((resolve) => { entered = resolve; });
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
const harness = createHarness({ startGate: async () => { entered(); await gate; } });
|
|
const switching = harness.service.apply('work', 'b');
|
|
await started;
|
|
assert.equal(harness.snapshot().state.desiredProfileId, 'personal');
|
|
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
|
release();
|
|
await switching;
|
|
});
|
|
|
|
test('activate only changes desired while stopped and transactionally switches while running', async () => {
|
|
const stopped = createHarness({ running: false });
|
|
await stopped.service.activate('work');
|
|
assert.equal(stopped.snapshot().state.desiredProfileId, 'work');
|
|
assert.equal(stopped.snapshot().state.appliedProfileId, 'personal');
|
|
assert.equal(stopped.events.includes('config.write'), false);
|
|
|
|
const running = createHarness();
|
|
await running.service.activate('work');
|
|
assert.equal(running.snapshot().state.appliedProfileId, 'work');
|
|
assert.equal(running.snapshot().state.appliedServerId, 'b');
|
|
});
|
|
|
|
test('gateway-direct changes only local desired state and never claims a remote applied target', async () => {
|
|
const harness = createHarness({ gatewayDirect: true });
|
|
await harness.service.apply('work', 'b');
|
|
const after = harness.snapshot();
|
|
assert.equal(after.state.desiredProfileId, 'work');
|
|
assert.equal(after.state.appliedProfileId, 'personal');
|
|
assert.equal(harness.events.includes('config.write'), false);
|
|
assert.equal(harness.events.includes('runtime.start'), false);
|
|
});
|
|
|
|
test('stop clears applied identity but preserves the desired pair', async () => {
|
|
const harness = createHarness();
|
|
await harness.service.stop();
|
|
const after = harness.snapshot();
|
|
assert.equal(after.running, false);
|
|
assert.equal(after.state.connectionDesired, 'stopped');
|
|
assert.equal(after.state.desiredProfileId, 'personal');
|
|
assert.equal(after.state.profiles[0].desiredServerId, 'a');
|
|
assert.equal(after.state.appliedProfileId, '');
|
|
assert.equal(after.state.appliedServerId, '');
|
|
assert.equal(after.state.appliedServerSnapshot, null);
|
|
});
|
|
|
|
test('restart uses applied pair while running and desired pair while stopped', async () => {
|
|
const running = createHarness();
|
|
await running.service.restart();
|
|
assert.match(running.snapshot().config, /"selectedServerId":"a"/);
|
|
|
|
const stoppedState = initialState();
|
|
stoppedState.desiredProfileId = 'work';
|
|
stoppedState.connectionDesired = 'stopped';
|
|
const stopped = createHarness({ running: false, state: stoppedState });
|
|
await stopped.service.restart();
|
|
assert.equal(stopped.snapshot().state.appliedProfileId, 'work');
|
|
assert.equal(stopped.snapshot().state.appliedServerId, 'b');
|
|
assert.match(stopped.snapshot().config, /"selectedServerId":"b"/);
|
|
});
|
|
|
|
test('running restart preserves a pending desired profile while restoring the applied pair', async () => {
|
|
const state = initialState();
|
|
state.desiredProfileId = 'work';
|
|
const harness = createHarness({ state });
|
|
await harness.service.restart();
|
|
assert.equal(harness.snapshot().state.desiredProfileId, 'work');
|
|
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
|
});
|
|
|
|
test('restart fails closed when runtime status is unknown', async () => {
|
|
const state = initialState();
|
|
state.desiredProfileId = 'work';
|
|
const failure = new Error('status unavailable');
|
|
const harness = createHarness({ state, failStatus: failure });
|
|
const before = harness.snapshot();
|
|
await assert.rejects(harness.service.restart(), (error) => error === failure);
|
|
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
|
});
|
|
|
|
test('all connection mutations share one deterministic queue', async () => {
|
|
let release;
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
const harness = createHarness({ startGate: () => gate });
|
|
const apply = harness.service.apply('work', 'b');
|
|
const stop = harness.service.stop();
|
|
release();
|
|
await Promise.all([apply, stop]);
|
|
assert.equal(harness.snapshot().peak, 1);
|
|
assert.equal(harness.snapshot().state.connectionDesired, 'stopped');
|
|
});
|
|
|
|
test('pair-aware apply and runtime routes preserve one HTTP owner', async () => {
|
|
const calls = [];
|
|
const sent = [];
|
|
const applyRoute = createServerApplyRoute({
|
|
connection: {
|
|
apply: async (...args) => {
|
|
calls.push(args);
|
|
return { profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' };
|
|
},
|
|
},
|
|
readBody: async () => ({ profileId: 'personal', serverId: 'a', expectedRevision: 7 }),
|
|
withOperation: async (kind, operation, options) => {
|
|
calls.push([kind, options]);
|
|
return operation(8);
|
|
},
|
|
sendState: async (_res, extra) => { sent.push(extra); },
|
|
});
|
|
assert.equal(await applyRoute.handle({ method: 'POST', url: '/api/apply' }, {}), true);
|
|
assert.deepEqual(calls, [
|
|
['apply-server', { expectedRevision: 7, profileId: 'personal', serverId: 'a' }],
|
|
['personal', 'a', '', 8],
|
|
]);
|
|
assert.deepEqual(sent, [{ profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' }]);
|
|
|
|
const runtimeCalls = [];
|
|
const runtimeRoute = createConnectionRuntimeRoute({
|
|
connection: {
|
|
stop: async () => { runtimeCalls.push('service.stop'); },
|
|
restart: async () => { runtimeCalls.push('service.restart'); },
|
|
},
|
|
withOperation: async (kind, operation) => { runtimeCalls.push(kind); return operation(); },
|
|
sendState: async () => {},
|
|
});
|
|
await runtimeRoute.handle({ method: 'POST', url: '/api/singbox/stop' }, {});
|
|
await runtimeRoute.handle({ method: 'POST', url: '/api/singbox/restart' }, {});
|
|
assert.deepEqual(runtimeCalls, ['stop', 'service.stop', 'start', 'service.restart']);
|
|
|
|
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
|
assert.doesNotMatch(source, /function applySelectedServer|req\.url === ['"]\/api\/apply['"]/);
|
|
});
|
|
|
|
test('runtime command adapter distinguishes failures before and after mutation', async () => {
|
|
const invalid = new HarborError('CONFIG_INVALID');
|
|
assert.deepEqual(
|
|
await captureRuntimeCommand(async () => { throw invalid; }, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
|
|
{ ok: false, mutationStarted: false, error: invalid },
|
|
);
|
|
const failed = new Error('remote failed');
|
|
assert.deepEqual(
|
|
await captureRuntimeCommand(async () => { throw failed; }),
|
|
{ ok: false, mutationStarted: true, error: failed },
|
|
);
|
|
});
|