Files
harbor-net/test/server/connection-service.test.js
T
dokril 7c255192b0
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 14s
Update Harbor client and gateway functionality
2026-08-17 15:23:16 +03:00

334 lines
13 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, outbound: 'direct' }];
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.appliedRouteRules, rules);
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 state = initialState();
state.appliedRouteRules = [];
const harness = createHarness({ gatewayDirect: true, state });
await harness.service.apply('work', 'b');
const after = harness.snapshot();
assert.equal(after.state.desiredProfileId, 'work');
assert.equal(after.state.appliedProfileId, 'personal');
assert.deepEqual(after.state.appliedRouteRules, []);
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"/);
assert.deepEqual(stopped.snapshot().state.appliedRouteRules, rules);
});
test('restart in gateway-direct keeps user rules omitted from applied truth', async () => {
const state = initialState();
state.appliedRouteRules = [];
const harness = createHarness({ gatewayDirect: true, state });
await harness.service.restart();
assert.deepEqual(harness.snapshot().state.appliedRouteRules, []);
});
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 },
);
});