Refactor VPN proxy components and update related behavior
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 01:27:46 +03:00
parent c89e56942a
commit aa9c959368
58 changed files with 4234 additions and 2755 deletions
+214 -380
View File
@@ -12,130 +12,113 @@ 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 duplicateA = { id: 'a2', label: 'Alpha', host: 'a2.example', port: 3, protocol: 'vless' };
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? {
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,
servers: [serverA, serverB],
selectedServerId: serverA.id,
appliedServerId: serverA.id,
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 stateUpdateIndex = 0;
let stateUpdates = 0;
let startFailure = overrides.failStart || null;
const events = [];
const failures = {
...overrides.failures,
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
runtimeStarts: [...(overrides.failures?.runtimeStarts || [])],
runtimeStops: [...(overrides.failures?.runtimeStops || [])],
runtimeRestarts: [...(overrides.failures?.runtimeRestarts || [])],
};
function applyState(mutator) {
const failure = failures.stateUpdates[stateUpdateIndex++];
if (failure instanceof Error) throw failure;
const revision = state.revision + 1;
state = { ...structuredClone(mutator(structuredClone(state))), revision };
events.push(stateUpdateIndex === 1 ? 'state.desired' : stateUpdateIndex === 2 ? 'state.applied' : 'state.restore');
if (failure?.after) throw failure.after;
return structuredClone(state);
}
const serialize = (operation) => {
const run = async () => {
active += 1;
peak = Math.max(peak, active);
try {
return await operation();
} finally {
active -= 1;
}
try { return await operation(); } finally { active -= 1; }
};
const result = tail.then(run, run);
tail = result.then(() => undefined, () => undefined);
return result;
};
const stopRuntime = async () => {
events.push('runtime.stop');
const failure = failures.runtimeStops.shift();
if (failure) {
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 restartRuntime = async () => {
const restart = async () => {
events.push('runtime.restart');
const failure = failures.runtimeRestarts.shift();
if (failure) {
if (failure.code !== 'CONFIG_INVALID') running = false;
throw failure;
}
if (overrides.failRestart) throw overrides.failRestart;
running = true;
};
const service = createConnectionService({
state: {
read: () => structuredClone(state),
update: applyState,
},
subscription: {
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
},
state: { read: () => structuredClone(state), update: updateState },
config: {
exists: () => config !== null,
build: (_subscription, selectedServerId, routeRules) => ({ selectedServerId, routeRules }),
read: () => config,
write: (value) => {
events.push('config.write');
if (failures.configWrite) throw failures.configWrite;
config = JSON.stringify(value);
if (failures.configWriteAfter) throw failures.configWriteAfter;
},
restore: (value) => {
events.push('config.restore');
if (failures.configRestore) throw failures.configRestore;
config = value;
},
remove: () => {
events.push('config.remove');
if (failures.configRemove) throw failures.configRemove;
config = null;
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 (failures.runtimeStatus) throw failures.runtimeStatus;
if (overrides.failStatus) throw overrides.failStatus;
return running;
},
start: async () => {
events.push('runtime.start');
const failure = failures.runtimeStarts.shift();
if (failure) {
running = false;
throw failure;
}
if (overrides.startGate) await overrides.startGate();
running = true;
},
stop: stopRuntime,
stopCommand: () => captureRuntimeCommand(stopRuntime),
restartCommand: () => captureRuntimeCommand(
restartRuntime,
{ preMutationErrorCodes: ['CONFIG_INVALID'] },
),
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'),
});
@@ -143,344 +126,195 @@ function createHarness(overrides = {}) {
return {
service,
events,
enqueue: serialize,
setState: (value) => { state = structuredClone(value); },
serialize,
snapshot: () => structuredClone({ state, config, running, peak }),
};
}
function assertDomainRestored(actual, expected) {
const actualDomain = structuredClone(actual);
const expectedDomain = structuredClone(expected);
delete actualDomain.state.revision;
delete expectedDomain.state.revision;
delete actualDomain.peak;
delete expectedDomain.peak;
assert.deepEqual(actualDomain, expectedDomain);
assert.ok(actual.state.revision >= expected.state.revision);
function domain(value) {
const copy = structuredClone(value);
delete copy.state.revision;
delete copy.peak;
return copy;
}
test('apply resolves ID inside the queue and commits desired, config, runtime, then applied state', async () => {
const harness = createHarness({ state: {
revision: 5,
servers: [serverA, serverB, duplicateA],
selectedServerId: serverA.id,
appliedServerId: serverA.id,
routeRules: rules,
appliedRouteRules: rules,
} });
assert.deepEqual(await harness.service.apply(' b ', 'Alpha'), { serverId: 'b', selectedTag: 'Beta' });
const snapshot = harness.snapshot();
assert.equal(snapshot.state.selectedServerId, 'b');
assert.equal(snapshot.state.appliedServerId, 'b');
assert.equal(snapshot.state.connectionDesired, 'running');
assert.equal(snapshot.state.appliedAt, '2026-08-08T12:00:00.000Z');
assert.deepEqual(snapshot.state.appliedRouteRules, rules);
assert.deepEqual(harness.events, ['state.desired', 'config.write', 'runtime.start', 'state.applied']);
await assert.rejects(harness.service.apply('', 'Alpha'), (error) => error.code === 'SERVER_NOT_FOUND');
await assert.rejects(harness.service.apply('missing', 'Beta'), (error) => error.code === 'SERVER_NOT_FOUND');
});
test('apply accepts one legacy label and rejects missing config before mutation', async () => {
const unique = createHarness();
assert.deepEqual(await unique.service.apply('', ' Beta '), { serverId: 'b', selectedTag: 'Beta' });
const missing = createHarness({ missingSubscription: true });
const before = missing.snapshot();
await assert.rejects(missing.service.apply('b', ''), (error) => error.code === 'CONFIG_INVALID');
assertDomainRestored(missing.snapshot(), before);
assert.deepEqual(missing.events, []);
});
test('apply restores previous running domain/config/runtime after mutation failures', async () => {
const failureCases = [
{ failures: { stateUpdates: [new Error('desired')] }, expected: 'desired' },
{ failures: { configWriteAfter: new Error('config') }, expected: 'config' },
{ failures: { runtimeStarts: [new Error('start')] }, expected: 'start' },
{ failures: { stateUpdates: [null, { after: new Error('applied') }] }, expected: 'applied' },
];
for (const { failures, expected } of failureCases) {
const harness = createHarness({ failures });
const before = harness.snapshot();
await assert.rejects(harness.service.apply('b', ''), new RegExp(expected));
assertDomainRestored(harness.snapshot(), before);
}
});
test('apply compensates a desired state update that persists and then throws', async () => {
const failure = new Error('desired persisted then failed');
const harness = createHarness({ failures: { stateUpdates: [{ after: failure }] } });
const before = harness.snapshot();
await assert.rejects(harness.service.apply('b', ''), (error) => error === failure);
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();
assertDomainRestored(after, before);
assert.ok(after.state.revision > before.state.revision);
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('apply restores previous stopped state and prior config absence', async () => {
const failure = new Error('start');
const harness = createHarness({ config: null, running: false, failures: { runtimeStarts: [failure] } });
const before = harness.snapshot();
await assert.rejects(harness.service.apply('b', ''), (error) => error === failure);
assertDomainRestored(harness.snapshot(), before);
assert.ok(harness.events.includes('config.remove'));
assert.ok(harness.events.includes('runtime.stop'));
});
test('apply rollback continues after restore failures and marks runtime rollback failure', async () => {
const original = new Error('apply failed');
const configFailure = new Error('config restore failed');
const nonRuntime = createHarness({
failures: { runtimeStarts: [original], configRestore: configFailure },
});
await assert.rejects(nonRuntime.service.apply('b', ''), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [original, configFailure]);
return true;
});
assert.ok(nonRuntime.events.filter((event) => event === 'runtime.start').length >= 2);
assert.ok(nonRuntime.events.filter((event) => event.startsWith('state.')).length >= 2);
const rollbackFailure = new Error('runtime rollback failed');
const runtimeBroken = createHarness({ failures: { runtimeStarts: [original, rollbackFailure] } });
await assert.rejects(runtimeBroken.service.apply('b', ''), (error) => {
assert.equal(error.code, 'PROCESS_START_FAILED');
assert.deepEqual(error.cause.errors, [original, rollbackFailure]);
return true;
});
assert.ok(runtimeBroken.events.filter((event) => event.startsWith('state.')).length >= 2);
});
test('queued apply resolves latest state and concurrent valid applies remain serial', async () => {
let release;
const blocker = new Promise((resolve) => { release = resolve; });
const stale = createHarness();
const blocked = stale.enqueue(() => blocker);
const apply = stale.service.apply('b', '');
stale.setState({ ...stale.snapshot().state, servers: [serverA] });
release();
await blocked;
await assert.rejects(apply, (error) => error.code === 'SERVER_NOT_FOUND');
const serial = createHarness();
const [first, second] = await Promise.all([
serial.service.apply('a', ''),
serial.service.apply('b', ''),
]);
assert.equal(first.serverId, 'a');
assert.equal(second.serverId, 'b');
assert.equal(serial.snapshot().state.appliedServerId, 'b');
assert.equal(serial.snapshot().peak, 1);
});
test('server apply route preserves defaults, operation kind, response, and single ownership', async () => {
const calls = [];
const sent = [];
const route = createServerApplyRoute({
connection: {
apply: async (serverId, selectedTag) => {
calls.push([serverId, selectedTag]);
return { serverId: 'resolved', selectedTag: 'Resolved' };
},
},
readBody: async () => ({}),
withOperation: async (kind, operation) => {
calls.push(kind);
return operation();
},
sendState: async (_res, extra) => { sent.push(extra); },
});
const response = {};
assert.equal(await route.handle({ method: 'GET', url: '/api/apply' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/apply' }, response), true);
assert.deepEqual(calls, ['apply-server', ['', '']]);
assert.deepEqual(sent, [{ serverId: 'resolved', selectedTag: 'Resolved' }]);
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createServerApplyRoute\(\{/);
assert.doesNotMatch(source, /function applySelectedServer|req\.url === ['"]\/api\/apply['"]/);
});
test('stop always cleans runtime and commits only desired stopped state', async () => {
for (const running of [true, false]) {
const harness = createHarness({ running });
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 harness.service.stop();
const after = harness.snapshot();
assert.equal(after.running, false);
assert.equal(after.state.connectionDesired, 'stopped');
assert.equal(after.state.selectedServerId, before.state.selectedServerId);
assert.equal(after.state.appliedServerId, before.state.appliedServerId);
assert.deepEqual(after.state.appliedRouteRules, before.state.appliedRouteRules);
assert.equal(harness.events.filter((event) => event === 'runtime.stop').length, 1);
await assert.rejects(harness.service.apply('work', 'b'));
assert.deepEqual(domain(harness.snapshot()), domain(before));
}
});
test('restart validates config inside the queue and commits applied runtime fields', async () => {
const missing = createHarness({ config: null });
const beforeMissing = missing.snapshot();
await assert.rejects(missing.service.restart(), (error) => error.code === 'CONFIG_INVALID');
assertDomainRestored(missing.snapshot(), beforeMissing);
assert.deepEqual(missing.events, []);
for (const running of [true, false]) {
const harness = createHarness({ running, state: {
revision: 1,
servers: [serverA, serverB],
selectedServerId: serverB.id,
appliedServerId: serverA.id,
routeRules: rules,
appliedRouteRules: [],
appliedAt: 'preserved',
connectionDesired: 'stopped',
} });
await harness.service.restart();
const after = harness.snapshot();
assert.equal(after.running, true);
assert.equal(after.state.appliedServerId, serverB.id);
assert.equal(after.state.connectionDesired, 'running');
assert.deepEqual(after.state.appliedRouteRules, rules);
assert.equal(after.state.appliedAt, 'preserved');
}
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('stop and restart compensate runtime and pre/post-write state failures', async () => {
const statusFailure = new Error('status failed');
for (const command of ['stop', 'restart']) {
const status = createHarness({ failures: { runtimeStatus: statusFailure } });
await status.service[command]();
assert.ok(status.events.includes(`runtime.${command}`));
}
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);
for (const stateFailure of [new Error('state before'), { after: new Error('state after') }]) {
const stopHarness = createHarness({ failures: { stateUpdates: [stateFailure] } });
const stopBefore = stopHarness.snapshot();
await assert.rejects(stopHarness.service.stop(), /state/);
assertDomainRestored(stopHarness.snapshot(), stopBefore);
const restartHarness = createHarness({
running: false,
failures: { stateUpdates: [stateFailure] },
});
const restartBefore = restartHarness.snapshot();
await assert.rejects(restartHarness.service.restart(), /state/);
assertDomainRestored(restartHarness.snapshot(), restartBefore);
}
const stopFailure = new Error('stop failed');
const stopped = createHarness({ failures: { runtimeStops: [stopFailure] } });
const stoppedBefore = stopped.snapshot();
await assert.rejects(stopped.service.stop(), (error) => error === stopFailure);
assertDomainRestored(stopped.snapshot(), stoppedBefore);
const restartFailure = new Error('restart failed');
const restarted = createHarness({ running: false, failures: { runtimeRestarts: [restartFailure] } });
const restartedBefore = restarted.snapshot();
await assert.rejects(restarted.service.restart(), (error) => error === restartFailure);
assertDomainRestored(restarted.snapshot(), restartedBefore);
const running = createHarness();
await running.service.activate('work');
assert.equal(running.snapshot().state.appliedProfileId, 'work');
assert.equal(running.snapshot().state.appliedServerId, 'b');
});
test('restart preserves CONFIG_INVALID when validation fails before runtime mutation', async () => {
const invalid = new HarborError('CONFIG_INVALID');
const harness = createHarness({ failures: { runtimeRestarts: [invalid] } });
const before = harness.snapshot();
await assert.rejects(harness.service.restart(), (error) => error === invalid);
assertDomainRestored(harness.snapshot(), before);
assert.equal(harness.events.filter((event) => event === 'runtime.restart').length, 1);
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('runtime command adapter makes local and remote mutation phase explicit', async () => {
const localFailure = new HarborError('CONFIG_INVALID');
assert.deepEqual(
await captureRuntimeCommand(
async () => { throw localFailure; },
{ preMutationErrorCodes: ['CONFIG_INVALID'] },
),
{ ok: false, mutationStarted: false, error: localFailure },
);
const remoteFailure = new HarborError('PROCESS_START_FAILED');
assert.deepEqual(
await captureRuntimeCommand(async () => { throw remoteFailure; }),
{ ok: false, mutationStarted: true, error: remoteFailure },
);
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('runtime command rollback continues through state errors and maps runtime restore failure', async () => {
const original = new Error('stop state failed');
const stateRestore = new Error('state restore failed');
const aggregate = createHarness({
failures: { stateUpdates: [{ after: original }, stateRestore] },
});
await assert.rejects(aggregate.service.stop(), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [original, stateRestore]);
return true;
});
assert.equal(aggregate.snapshot().running, true);
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 stopFailure = new Error('stop failed');
const runtimeRestore = new Error('start rollback failed');
const broken = createHarness({
failures: { runtimeStops: [stopFailure], runtimeStarts: [runtimeRestore] },
});
await assert.rejects(broken.service.stop(), (error) => {
assert.equal(error.code, 'PROCESS_START_FAILED');
assert.deepEqual(error.cause.errors, [stopFailure, runtimeRestore]);
return true;
});
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('apply, stop, and restart share one deterministic connection queue', async () => {
const applyThenStop = createHarness();
await Promise.all([
applyThenStop.service.apply('b', ''),
applyThenStop.service.stop(),
]);
assert.equal(applyThenStop.snapshot().state.connectionDesired, 'stopped');
assert.equal(applyThenStop.snapshot().peak, 1);
const stopThenRestart = createHarness();
await Promise.all([
stopThenRestart.service.stop(),
stopThenRestart.service.restart(),
]);
assert.equal(stopThenRestart.snapshot().state.connectionDesired, 'running');
assert.equal(stopThenRestart.snapshot().running, true);
assert.equal(stopThenRestart.snapshot().peak, 1);
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('connection runtime route preserves operation kinds, response extras, and single ownership', async () => {
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 route = createConnectionRuntimeRoute({
const applyRoute = createServerApplyRoute({
connection: {
stop: async () => { calls.push('service.stop'); },
restart: async () => { calls.push('service.restart'); },
apply: async (...args) => {
calls.push(args);
return { profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' };
},
},
withOperation: async (kind, operation) => {
calls.push(kind);
return operation();
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); },
});
const response = {};
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' }]);
assert.equal(await route.handle({ method: 'GET', url: '/api/singbox/stop' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/singbox/stop' }, response), true);
assert.equal(await route.handle({ method: 'POST', url: '/api/singbox/restart' }, response), true);
assert.deepEqual(calls, ['stop', 'service.stop', 'start', 'service.restart']);
assert.deepEqual(sent, [{ singboxRunning: false }, { singboxRunning: true }]);
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.match(source, /createConnectionRuntimeRoute\(\{/);
assert.doesNotMatch(source, /req\.url === ['"]\/api\/singbox\/(?:stop|restart)['"]/);
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 },
);
});
+24 -10
View File
@@ -68,12 +68,17 @@ test('connectivity diagnostics endpoint is available in Connect and Gateway thro
test('connectivity use case captures applied server before probes and preserves result fields', async () => {
const events = [];
let state = {
desiredProfileId: 'primary',
appliedProfileId: 'primary',
appliedServerId: 'applied',
selectedServerId: 'selected',
servers: [
{ id: 'applied', label: 'Applied before probe' },
{ id: 'selected', label: 'Selected' },
],
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [
{ id: 'applied', label: 'Applied before probe' },
{ id: 'selected', label: 'Selected' },
],
}],
};
let releaseProbe;
const probe = new Promise((resolve) => { releaseProbe = resolve; });
@@ -95,7 +100,7 @@ test('connectivity use case captures applied server before probes and preserves
},
});
const resultPromise = useCase.run({ raw: true }, 42);
state.servers[0].label = 'Changed during probe';
state.profiles[0].servers[0].label = 'Changed during probe';
releaseProbe();
const result = await resultPromise;
@@ -114,9 +119,13 @@ test('connectivity use case keeps applied priority, selected fallback and error
const result = { vpn: { available: false }, marker: true };
const selected = createConnectivityDiagnosticsUseCase({
readState: () => ({
desiredProfileId: 'primary',
appliedServerId: '',
selectedServerId: 'selected',
servers: [{ id: 'selected', label: 'Selected' }],
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [{ id: 'selected', label: 'Selected' }],
}],
}),
runDiagnostics: async () => result,
});
@@ -127,9 +136,14 @@ test('connectivity use case keeps applied priority, selected fallback and error
const missingApplied = createConnectivityDiagnosticsUseCase({
readState: () => ({
desiredProfileId: 'primary',
appliedProfileId: 'primary',
appliedServerId: 'missing',
selectedServerId: 'selected',
servers: [{ id: 'selected', label: 'Selected' }],
profiles: [{
id: 'primary',
desiredServerId: 'selected',
servers: [{ id: 'selected', label: 'Selected' }],
}],
}),
runDiagnostics: async () => result,
});
+74 -27
View File
@@ -15,6 +15,30 @@ import { createGatewayAutoRoute } from '../../dist/server/http/routes/gatewayAut
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
const storedProfile = (servers = [server]) => ({
id: 'primary',
label: 'Основной',
subscriptionUrl,
subscriptionConfig: { outbounds: [] },
servers,
userInfo: {},
fetchedAt: null,
desiredServerId: servers[0]?.id || '',
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
});
const canonicalState = (profiles = [storedProfile()]) => ({
revision: 10,
profiles,
desiredProfileId: profiles[0]?.id || '',
appliedProfileId: profiles[0]?.id || '',
appliedServerId: profiles[0]?.desiredServerId || '',
appliedServerSnapshot: profiles[0]?.servers[0] || null,
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
gatewayAutoEnabled: true,
});
const networkA = {
gateway: '192.168.50.111',
interface: 'en0',
@@ -51,17 +75,7 @@ function deferred() {
}
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? {
revision: 10,
servers: [server],
selectedServerId: server.id,
appliedServerId: server.id,
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
subscriptionUrl,
gatewayAutoEnabled: true,
});
let state = structuredClone(overrides.state ?? canonicalState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
let network = Object.hasOwn(overrides, 'network') ? overrides.network : networkA;
let tail = Promise.resolve();
@@ -109,11 +123,14 @@ function createHarness(overrides = {}) {
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
},
config: {
build: (_subscription, selectedServerId, routeRules, gatewayAuto) => ({
selectedServerId,
routeRules,
clientDirect: gatewayAuto.mode === 'gateway-direct',
}),
build: (_subscription, selectedServerId, routeRules, gatewayAuto) => {
if (failures.configBuild) throw failures.configBuild;
return {
selectedServerId,
routeRules,
clientDirect: gatewayAuto.mode === 'gateway-direct',
};
},
read: () => config,
write: (value) => {
events.push('config.write');
@@ -144,6 +161,10 @@ function createHarness(overrides = {}) {
events.push('runtime.restore');
if (failures.runtimeRestore) throw failures.runtimeRestore;
},
stopCommand: async () => {
events.push('runtime.stop');
return overrides.stopCommandResult || { ok: true, mutationStarted: true };
},
},
discovery: {
readHostNetwork: () => structuredClone(network),
@@ -224,16 +245,8 @@ test('gateway-auto handles no-op and metadata-only discovery without config/runt
test('gateway-auto mode changes separate state-only, stopped and running paths', async () => {
const stateOnly = createHarness({
gatewayAuto: directState(),
state: {
revision: 1,
servers: [],
selectedServerId: '',
appliedServerId: '',
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
gatewayAutoEnabled: true,
},
state: { ...canonicalState([]), revision: 1 },
running: false,
});
await stateOnly.service.setEnabled(false);
assert.equal(stateOnly.snapshot().gatewayAuto.mode, 'local-vpn');
@@ -266,6 +279,38 @@ test('gateway-auto startup writes candidate config before publication without ap
]);
});
test('gateway loss safely stops when the applied server disappeared from refreshed config', async () => {
const state = canonicalState();
state.profiles[0].servers = [];
state.profiles[0].desiredServerId = '';
const buildError = Object.assign(new Error('server removed'), { code: 'SERVER_NOT_FOUND' });
const harness = createHarness({
state,
gatewayAuto: directState(),
network: networkB,
failures: { configBuild: buildError },
});
await assert.rejects(harness.service.refresh(), (error) => error === buildError);
const after = harness.snapshot();
assert.equal(after.gatewayAuto.mode, 'local-vpn');
assert.equal(after.state.connectionDesired, 'stopped');
assert.equal(after.state.appliedProfileId, '');
assert.equal(after.state.appliedServerId, '');
assert.equal(harness.events.includes('runtime.stop'), true);
});
test('Gateway discovery preserves a stale applied local runtime when direct config cannot build', async () => {
const state = canonicalState();
state.profiles[0].servers = [];
state.profiles[0].desiredServerId = '';
const buildError = Object.assign(new Error('server removed'), { code: 'SERVER_NOT_FOUND' });
const harness = createHarness({ state, failures: { configBuild: buildError } });
const before = harness.snapshot();
await assert.rejects(harness.service.refresh(), (error) => error === buildError);
assert.deepEqual(withoutRevision(harness.snapshot()), withoutRevision(before));
assert.equal(harness.events.includes('runtime.stop'), false);
});
test('disable and re-enable preserve verified Gateway identity and persisted preference commits', async () => {
const harness = createHarness({ gatewayAuto: directState() });
await harness.service.setEnabled(false);
@@ -382,7 +427,9 @@ test('gateway-auto discards stale subscription and route probe results', async (
staleSubscription.service.set(directState());
const refresh = staleSubscription.service.refresh();
await new Promise((resolve) => setImmediate(resolve));
staleSubscription.patchState({ subscriptionUrl: `${subscriptionUrl}-new` });
const changedState = staleSubscription.snapshot().state;
changedState.profiles[0].subscriptionUrl = `${subscriptionUrl}-new`;
staleSubscription.setState(changedState);
gate.resolve(verifiedA);
await refresh;
assert.deepEqual(staleSubscription.snapshot().gatewayAuto, createGatewayAutoState());
+27 -12
View File
@@ -8,17 +8,32 @@ import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesR
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true }];
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true }];
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
const storedProfile = (servers = [server]) => ({
id: 'primary',
label: 'Основной',
subscriptionUrl: 'https://provider.example/sub',
subscriptionConfig: { outbounds: [] },
servers,
userInfo: {},
fetchedAt: null,
desiredServerId: servers[0]?.id || '',
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
});
const canonicalState = (profiles = [storedProfile()]) => ({
revision: 10,
routeRulesRevision: 2,
routeRules: oldRules,
appliedRouteRules: oldRules,
profiles,
desiredProfileId: profiles[0]?.id || '',
appliedProfileId: profiles[0]?.id || '',
appliedServerId: profiles[0]?.desiredServerId || '',
appliedServerSnapshot: profiles[0]?.servers[0] || null,
});
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? {
revision: 10,
routeRulesRevision: 2,
routeRules: oldRules,
appliedRouteRules: oldRules,
servers: [server],
selectedServerId: server.id,
appliedServerId: server.id,
});
let state = structuredClone(overrides.state ?? canonicalState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
const failures = {
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
@@ -140,10 +155,10 @@ test('route rules support explicit domain revision and legacy global revision wi
test('route rules state-only path leaves config and applied rules unchanged', async () => {
for (const state of [
{ revision: 1, routeRulesRevision: 0, routeRules: oldRules, appliedRouteRules: oldRules, servers: [], selectedServerId: '', appliedServerId: '' },
{ revision: 1, routeRulesRevision: 0, routeRules: oldRules, appliedRouteRules: oldRules, servers: [server], selectedServerId: server.id, appliedServerId: server.id },
{ ...canonicalState([]), revision: 1, routeRulesRevision: 0 },
{ ...canonicalState(), revision: 1, routeRulesRevision: 0 },
]) {
const harness = createHarness({ state, missingSubscription: Boolean(state.selectedServerId) });
const harness = createHarness({ state, missingSubscription: Boolean(state.profiles.length) });
await harness.service.update(newRules, 0, undefined);
assert.deepEqual(harness.snapshot().state.routeRules, newRules);
assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules);
+20 -11
View File
@@ -39,23 +39,29 @@ test('server health selection coerces IDs, deduplicates, ignores unknowns, and k
];
const checked = [];
const service = createServerHealthService({
readServers: () => servers,
readProfiles: () => [{ id: 'personal', servers }],
readDesiredProfileId: () => 'personal',
ping: async (host, port) => {
checked.push([host, port]);
return { ok: true };
},
});
assert.deepEqual((await service.check(['3', 1, '3', 'missing'])).map(({ id }) => id), ['1', '3']);
assert.deepEqual((await service.check('personal', ['3', 1, '3', 'missing'])).map(({ id }) => id), ['1', '3']);
assert.deepEqual(checked, [['one.example', 1], ['three.example', 3]]);
assert.deepEqual((await service.check(undefined)).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual((await service.check('2')).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual((await service.check([])).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual(await service.check(['missing']), []);
assert.deepEqual((await service.check('', undefined)).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual((await service.check('personal', '2')).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual((await service.check('personal', [])).map(({ id }) => id), ['1', '2', '3']);
assert.deepEqual(await service.check('personal', ['missing']), []);
assert.throws(() => service.check('missing', []), (error) => error.code === 'PROFILE_NOT_FOUND');
const failure = new Error('ping failed');
await assert.rejects(
createServerHealthService({ readServers: () => servers, ping: async () => { throw failure; } }).check([]),
createServerHealthService({
readProfiles: () => [{ id: 'personal', servers }],
readDesiredProfileId: () => 'personal',
ping: async () => { throw failure; },
}).check('personal', []),
(error) => error === failure,
);
});
@@ -63,15 +69,18 @@ test('server health selection coerces IDs, deduplicates, ignores unknowns, and k
test('server health route is the only endpoint adapter', async () => {
const sent = [];
const route = createServerHealthRoute({
serverHealth: { check: async (ids) => [{ ids }] },
serverHealth: { check: async (profileId, ids) => [{ profileId, ids }] },
readBody: async () => ({ serverIds: ['chosen'] }),
sendState: async (_res, extra) => { sent.push(extra); },
});
const response = {};
assert.equal(await route.handle({ method: 'GET', url: '/api/servers/ping-all' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/servers/ping-all' }, response), true);
assert.deepEqual(sent, [{ results: [{ ids: ['chosen'] }] }]);
assert.equal(await route.handle({ method: 'GET', url: '/api/profiles/personal/servers/ping' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/profiles/personal/servers/ping' }, response), true);
assert.deepEqual(sent, [{
profileId: 'personal',
results: [{ profileId: 'personal', ids: ['chosen'] }],
}]);
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createServerHealthRoute\(\{/);
+340
View File
@@ -0,0 +1,340 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '../..');
function listen(server, ...args) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(...args, () => {
server.off('error', reject);
resolve(server.address());
});
});
}
function close(server) {
return new Promise((resolve) => server.close(resolve));
}
async function freePort() {
const server = http.createServer();
const address = await listen(server, 0, '127.0.0.1');
await close(server);
return address.port;
}
async function waitForState(port, child, stderr) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`Harbor exited early: ${stderr()}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/api/state`);
if (response.ok) return response.json();
} catch {
// The disposable listener is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`Harbor did not start: ${stderr()}`);
}
async function stopChild(child) {
if (child.exitCode !== null) return;
child.kill('SIGTERM');
await new Promise((resolve) => child.once('exit', resolve));
}
function fakeSingbox(directory) {
const binDirectory = path.join(directory, 'bin');
const markerPath = path.join(directory, 'sing-box-runs.log');
fs.mkdirSync(binDirectory);
const executable = path.join(binDirectory, 'sing-box');
fs.writeFileSync(executable, `#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === 'check') process.exit(0);
if (process.argv[2] === 'version') {
console.log('sing-box version 1.13.18');
process.exit(0);
}
if (process.argv[2] === 'run') {
fs.appendFileSync(process.env.HARBOR_TEST_RUN_MARKER, 'run\\n');
setInterval(() => {}, 60_000);
}
`);
fs.chmodSync(executable, 0o755);
return { binDirectory, markerPath };
}
function profileState(server) {
return {
schemaVersion: 5,
revision: 4,
profiles: [{
id: 'profile-a',
label: 'Основной',
subscriptionUrl: 'https://provider.example/subscription-a',
subscriptionConfig: null,
servers: [server],
userInfo: {},
fetchedAt: null,
desiredServerId: server.id,
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
}],
desiredProfileId: 'profile-a',
appliedProfileId: 'profile-a',
appliedServerId: server.id,
appliedServerSnapshot: server,
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
connectionDesired: 'running',
};
}
function generatedConfig(server, final = server.id) {
return {
outbounds: [
{
type: server.protocol,
tag: server.id,
server: server.host,
server_port: server.port,
},
{ type: 'direct', tag: 'direct' },
],
route: { final },
};
}
async function startClientFixture(t, {
state,
cacheContents,
config,
}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
const { binDirectory, markerPath } = fakeSingbox(directory);
if (state !== undefined) fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify(state));
if (cacheContents !== undefined) {
fs.writeFileSync(path.join(directory, 'subscription-cache.json'), cacheContents);
}
if (config !== undefined) {
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), JSON.stringify(config));
}
const port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], {
cwd: root,
env: {
...process.env,
APP_COMPONENT: 'control',
APP_MODE: 'client',
DATA_DIR: directory,
PORT: String(port),
PATH: `${binDirectory}:${process.env.PATH || ''}`,
HARBOR_HOST_NETWORK_STATE: path.join(directory, 'missing-network.json'),
HARBOR_TEST_RUN_MARKER: markerPath,
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
t.after(async () => {
await stopChild(child);
fs.rmSync(directory, { recursive: true, force: true });
});
return {
directory,
markerPath,
state: await waitForState(port, child, () => stderr),
};
}
test('corrupt legacy cache with no canonical subscription fails closed instead of starting stale config', async (t) => {
const staleServer = {
id: 'stale-server',
label: 'Stale server',
host: 'stale.example',
port: 443,
protocol: 'vless',
};
const fixture = await startClientFixture(t, {
state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' },
cacheContents: '{broken',
config: generatedConfig(staleServer),
});
assert.equal(fixture.state.subscription.status, 'missing');
assert.deepEqual(fixture.state.profiles, []);
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fs.existsSync(fixture.markerPath), false);
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
name.startsWith('subscription-cache.json.corrupt-')
)));
});
test('legacy cache owned by another URL is backed up without mixing providers and boots stopped', async (t) => {
const stateServer = {
id: 'server-a',
label: 'State server',
host: 'state.example',
port: 443,
protocol: 'vless',
};
const cacheServer = {
id: 'server-b',
label: 'Cache server',
host: 'cache.example',
port: 8443,
protocol: 'vless',
type: 'vless',
tag: 'Cache server',
server: 'cache.example',
server_port: 8443,
};
const fixture = await startClientFixture(t, {
state: {
schemaVersion: 4,
revision: 2,
subscriptionUrl: 'https://state.example/subscription-a',
servers: [stateServer],
selectedServerId: stateServer.id,
appliedServerId: stateServer.id,
connectionDesired: 'running',
},
cacheContents: JSON.stringify({
url: 'https://cache.example/subscription-b',
config: { outbounds: [cacheServer] },
servers: [cacheServer],
}),
config: generatedConfig(stateServer),
});
assert.equal(fixture.state.profiles.length, 1);
assert.equal(fixture.state.profiles[0].subscription.host, 'state.example/…');
assert.deepEqual(fixture.state.profiles[0].servers.map(({ id }) => id), [stateServer.id]);
assert.equal(JSON.stringify(fixture.state).includes('cache.example'), false);
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fixture.state.selection.appliedServerId, '');
assert.equal(fs.existsSync(path.join(fixture.directory, 'subscription-cache.json')), false);
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
name.startsWith('subscription-cache.json.backup-v1-')
)));
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
assert.equal(fs.existsSync(fixture.markerPath), false);
});
test('boot rejects an existing config whose route mode disagrees with the current route', async (t) => {
const server = {
id: 'server-a',
label: 'Server A',
host: 'a.example',
port: 443,
protocol: 'vless',
};
const fixture = await startClientFixture(t, {
state: profileState(server),
config: generatedConfig(server, 'direct'),
});
assert.equal(fixture.state.route.mode, 'local-vpn');
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fs.existsSync(fixture.markerPath), false);
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
});
test('boot rejects an existing config owned by a different applied target', async (t) => {
const appliedServer = {
id: 'server-a',
label: 'Server A',
host: 'a.example',
port: 443,
protocol: 'vless',
};
const otherServer = {
id: 'server-b',
label: 'Server B',
host: 'b.example',
port: 8443,
protocol: 'vless',
};
const fixture = await startClientFixture(t, {
state: profileState(appliedServer),
config: generatedConfig(otherServer),
});
assert.equal(fixture.state.selection.appliedServerId, '');
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fs.existsSync(fixture.markerPath), false);
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
});
test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-'));
const socketPath = path.join(directory, 'dataplane.sock');
const requests = [];
let running = true;
const dataplane = http.createServer((req, res) => {
requests.push(`${req.method} ${req.url}`);
if (req.method === 'GET' && req.url === '/status') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ running, startedAt: running ? '2026-08-11T12:00:00.000Z' : null }));
}
if (req.method === 'POST' && req.url === '/stop') {
running = false;
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ running: false, startedAt: null }));
}
res.writeHead(503, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: 'not used by this fixture' }));
});
await listen(dataplane, socketPath);
fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({
schemaVersion: 5,
revision: 3,
profiles: [],
desiredProfileId: '',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
routeRules: [],
appliedRouteRules: [],
routeRulesRevision: 0,
connectionDesired: 'stopped',
}));
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), '{}');
const port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], {
cwd: root,
env: {
...process.env,
APP_COMPONENT: 'control',
APP_MODE: 'gateway',
DATA_DIR: directory,
DATAPLANE_SOCKET: socketPath,
PORT: String(port),
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
t.after(async () => {
await stopChild(child);
await close(dataplane);
fs.rmSync(directory, { recursive: true, force: true });
});
const state = await waitForState(port, child, () => stderr);
assert.equal(requests.includes('POST /stop'), true);
assert.equal(running, false);
assert.equal(state.connection.desired, 'stopped');
assert.equal(state.connection.process, 'stopped');
});
+145 -68
View File
@@ -60,7 +60,7 @@ async function waitForState(port, child, stderr) {
throw new Error(`Harbor did not start: ${stderr()}`);
}
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
test('state v1 projects legacy storage through the canonical profile snapshot', () => {
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
const legacyServerId = createServerId(legacyServer);
const stored = normalizeStoredState({
@@ -79,9 +79,15 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
});
assert.equal(snapshot.apiVersion, 1);
assert.equal(snapshot.profiles.length, 1);
assert.equal(snapshot.profiles[0].id, 'profile_primary');
assert.equal(snapshot.profiles[0].desiredServerId, legacyServerId);
assert.deepEqual(snapshot.selection, {
desiredProfileId: 'profile_primary',
desiredServerId: legacyServerId,
appliedProfileId: 'profile_primary',
appliedServerId: legacyServerId,
appliedServerSnapshot: snapshot.profiles[0].servers[0],
});
assert.equal(snapshot.connection.process, 'running');
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
@@ -145,7 +151,7 @@ test('startup discards a rejected cached subscription and returns to first-run',
assert.equal(child.exitCode, null);
});
test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
const binDir = path.join(dir, 'bin');
const config = {
@@ -177,12 +183,9 @@ setInterval(() => {}, 60_000);
fs.chmodSync(singboxPath, 0o755);
let providerFetchCount = 0;
let delayedPath = '';
let invalidNextPath = '';
let trafficExhaustedNextPath = '';
let delayedRequestStarted = null;
let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (req, res) => {
const subscriptionServer = http.createServer((req, res) => {
providerFetchCount += 1;
if (req.url === '/timeout') return;
if (req.url === '/unavailable') {
@@ -234,11 +237,6 @@ setInterval(() => {}, 60_000);
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === delayedPath) {
delayedRequestStarted?.();
await new Promise((resolve) => { releaseDelayedRequest = resolve; });
delayedPath = '';
}
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100',
@@ -256,6 +254,7 @@ setInterval(() => {}, 60_000);
url: subscriptionUrl,
config,
}));
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
const port = await freePort();
const child = spawn(process.execPath, ['dist/server/main.js'], {
@@ -289,7 +288,13 @@ setInterval(() => {}, 60_000);
runtime: { singBox: '1.12.13' },
});
assertStateSnapshot(initial);
assert.equal(initial.profiles.length, 1);
assert.equal(initial.profiles[0].id, 'profile_primary');
assert.equal(initial.profiles[0].label, 'Основной');
assert.equal(initial.selection.desiredProfileId, 'profile_primary');
assert.equal(initial.selection.appliedServerId, testServerId);
assert.equal(initial.selection.appliedProfileId, 'profile_primary');
assert.equal(initial.selection.appliedServerSnapshot.id, testServerId);
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
@@ -297,6 +302,15 @@ setInterval(() => {}, 60_000);
assert.equal(initial.route.localRulesRevision, 0);
assert.equal(initial.route.localRulesPendingRestart, false);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(migratedState.schemaVersion, 5);
assert.equal(migratedState.profiles.length, 1);
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
assert.ok(fs.readdirSync(dir).some((name) => name.startsWith('subscription-cache.json.backup-v1-')));
const stateKeys = Object.keys(initial).sort();
assert.deepEqual(stateKeys, [
'apiVersion',
@@ -309,6 +323,7 @@ setInterval(() => {}, 60_000);
'mode',
'operation',
'port',
'profiles',
'proxyPort',
'revision',
'route',
@@ -350,11 +365,11 @@ setInterval(() => {}, 60_000);
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
assert.equal(providerUnavailable.payload.error.retryable, true);
const preservedSubscription = {
state: JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')),
cache: fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
config: fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
};
const primaryProfileId = initial.profiles[0].id;
const preservedPrimary = structuredClone(
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).profiles[0],
);
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'],
@@ -362,46 +377,59 @@ setInterval(() => {}, 60_000);
['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
['/disabled', 'SUBSCRIPTION_DISABLED'],
]) {
const failedImport = await rawRequest(
const failedAdd = await rawRequest(
port,
'/api/subscription/fetch',
'/api/profiles',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}${pathname}` },
{
label: `Новая ${pathname}`,
url: `http://127.0.0.1:${subscriptionPort}${pathname}`,
expectedRevision: revision,
},
);
assert.equal(failedImport.payload.error.code, expectedCode);
assert.equal(failedAdd.payload.error.code, expectedCode);
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(storedAfterFailure.subscriptionUrl, preservedSubscription.state.subscriptionUrl);
assert.equal(storedAfterFailure.selectedTag, preservedSubscription.state.selectedTag);
assert.deepEqual(storedAfterFailure.servers, preservedSubscription.state.servers);
assert.equal(
fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
preservedSubscription.cache,
);
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
assert.equal(
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
preservedSubscription.config,
preservedConfig,
);
revision = (await request(port, '/api/state')).revision;
}
trafficExhaustedNextPath = '/subscription/test';
const exhaustedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
const exhaustedRefresh = await rawRequest(
port,
`/api/profiles/${primaryProfileId}/refresh`,
'POST',
{ expectedRevision: revision },
);
assert.equal(exhaustedRefresh.response.status, 400);
assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
const stateAfterExhaustedRefresh = await request(port, '/api/state');
assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), preservedSubscription.cache);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedSubscription.config);
assert.equal(stateAfterExhaustedRefresh.profiles[0].subscription.status, 'stale');
assert.equal(
stateAfterExhaustedRefresh.profiles[0].subscription.errorCode,
'SUBSCRIPTION_TRAFFIC_EXHAUSTED',
);
assert.equal(stateAfterExhaustedRefresh.profiles[0].servers[0].id, testServerId);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedConfig);
revision = stateAfterExhaustedRefresh.revision;
const missingServer = await rawRequest(
port,
'/api/apply',
'POST',
{ selectedTag: 'missing-server' },
{ profileId: primaryProfileId, serverId: 'missing-server', expectedRevision: revision },
);
assert.equal(missingServer.response.status, 404);
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
const stateAfterMissingServer = await request(port, '/api/state');
assert.equal(stateAfterMissingServer.selection.desiredProfileId, primaryProfileId);
assert.equal(stateAfterMissingServer.selection.desiredServerId, testServerId);
revision = stateAfterMissingServer.revision;
async function stateResponse(pathname, method = 'POST', body) {
const result = await request(port, pathname, method, body);
@@ -417,27 +445,76 @@ setInterval(() => {}, 60_000);
return result;
}
const fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
'test-vpn',
);
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
assert.deepEqual(applied.state.selection, {
desiredServerId: testServerId,
appliedServerId: testServerId,
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
const fetchesBeforeDuplicate = providerFetchCount;
const duplicateProfile = await rawRequest(port, '/api/profiles', 'POST', {
label: 'основной',
url: replacementUrl,
expectedRevision: revision,
});
assert.equal(duplicateProfile.response.status, 409);
assert.equal(duplicateProfile.payload.error.code, 'PROFILE_NAME_CONFLICT');
assert.equal(providerFetchCount, fetchesBeforeDuplicate);
assert.equal((await request(port, '/api/state')).revision, revision);
const fetchesBeforeAdd = providerFetchCount;
const added = await mutation('/api/profiles', 'POST', {
label: 'Работа',
url: replacementUrl,
expectedRevision: revision,
});
const workProfileId = added.profileId;
assert.equal(providerFetchCount, fetchesBeforeAdd + 1);
assert.equal(added.state.profiles.length, 2);
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'))
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
testServerId,
);
const renamed = await mutation(`/api/profiles/${workProfileId}`, 'PATCH', {
label: 'Офис',
expectedRevision: revision,
});
assert.equal(renamed.state.profiles.find(({ id }) => id === workProfileId).label, 'Офис');
const selected = await mutation(`/api/profiles/${workProfileId}/server`, 'PUT', {
serverId: testServerId,
expectedRevision: revision,
});
assert.equal(
selected.state.profiles.find(({ id }) => id === workProfileId).desiredServerId,
testServerId,
);
assert.equal(selected.state.profiles.find(({ id }) => id === primaryProfileId).desiredServerId, testServerId);
const applied = await mutation('/api/apply', 'POST', {
profileId: workProfileId,
serverId: testServerId,
expectedRevision: revision,
});
assert.equal(applied.state.selection.desiredProfileId, workProfileId);
assert.equal(applied.state.selection.desiredServerId, testServerId);
assert.equal(applied.state.selection.appliedProfileId, workProfileId);
assert.equal(applied.state.selection.appliedServerId, testServerId);
assert.equal(applied.state.selection.appliedServerSnapshot.id, testServerId);
assert.equal(applied.state.connection.process, 'running');
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
const configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
invalidNextPath = '/subscription/test';
const failedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
invalidNextPath = '/subscription/replacement';
const failedRefresh = await rawRequest(
port,
`/api/profiles/${workProfileId}/refresh`,
'POST',
{ expectedRevision: revision },
);
assert.equal(failedRefresh.response.status, 400);
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).selectedTag, 'test-vpn');
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), cacheBeforeFailedRefresh);
const storedAfterFailedRefresh = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
assert.equal(staleWorkProfile.desiredServerId, testServerId);
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
assert.equal(staleWorkProfile.servers[0].id, testServerId);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), configBeforeFailedRefresh);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
@@ -549,28 +626,28 @@ if (process.argv[2] === 'check') {
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
const delayedRequest = new Promise((resolve) => { delayedRequestStarted = resolve; });
delayedPath = '/subscription/test';
const staleRefresh = rawRequest(port, '/api/subscription/refresh', 'POST');
await delayedRequest;
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
await mutation('/api/subscription/fetch', 'POST', { url: replacementUrl });
releaseDelayedRequest();
const staleRefreshResult = await staleRefresh;
assert.equal(staleRefreshResult.response.status, 409);
assert.equal(staleRefreshResult.payload.error.code, 'STATE_CONFLICT');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, replacementUrl);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')).url, replacementUrl);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual(forgotten.state.route.localRules, routed.state.route.localRules);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
const inactiveDeleted = await mutation(`/api/profiles/${primaryProfileId}`, 'DELETE', {
mode: 'delete',
expectedRevision: revision,
});
assert.deepEqual(inactiveDeleted.state.profiles.map(({ id }) => id), [workProfileId]);
assert.equal(inactiveDeleted.state.selection.appliedProfileId, workProfileId);
const activeDeleted = await mutation(`/api/profiles/${workProfileId}`, 'DELETE', {
mode: 'stop-and-delete',
expectedRevision: revision,
});
assert.equal(activeDeleted.state.subscription.status, 'missing');
assert.deepEqual(activeDeleted.state.profiles, []);
assert.equal(activeDeleted.state.selection.desiredProfileId, '');
assert.equal(activeDeleted.state.selection.appliedProfileId, '');
assert.equal(activeDeleted.state.servers.length, 0);
assert.deepEqual(activeDeleted.state.route.localRules, routed.state.route.localRules);
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
assert.equal(missingConfig.response.status, 422);
assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID');
assert.equal(missingConfig.response.status, 404);
assert.equal(missingConfig.payload.error.code, 'PROFILE_NOT_FOUND');
assert.equal(missingConfig.payload.error.retryable, false);
});
+53 -5
View File
@@ -63,9 +63,12 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
assert.deepEqual(migrated.routeRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(migrated.appliedTag, 'nl');
assert.equal(migrated.selectedServerId, createServerId(legacy.servers[0]));
assert.equal(migrated.appliedServerId, migrated.selectedServerId);
const primary = migrated.profiles[0];
assert.equal(primary.label, 'Основной');
assert.equal(primary.desiredServerId, createServerId(legacy.servers[0]));
assert.equal(migrated.appliedProfileId, primary.id);
assert.equal(migrated.appliedServerId, primary.desiredServerId);
assert.equal(Object.hasOwn(migrated, 'selectedServerId'), false);
assert.equal(store.migration.fromVersion, 2);
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
@@ -84,9 +87,54 @@ test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
const migrated = createStateStore(filePath).read();
assert.equal(migrated.selectedServerId, '');
assert.equal(migrated.profiles[0].desiredServerId, '');
assert.equal(migrated.appliedServerId, '');
assert.equal(migrated.servers.length, 2);
assert.equal(migrated.profiles[0].servers.length, 2);
});
test('schema v4 migration never combines canonical state with a different cache owner', (t) => {
const filePath = fixture(t);
const stateServer = {
tag: 'State server',
type: 'vless',
server: 'state.example',
server_port: 443,
};
const cacheServer = {
tag: 'Cache server',
type: 'vless',
server: 'cache.example',
server_port: 8443,
};
const stateServerId = createServerId(stateServer);
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 4,
revision: 9,
subscriptionUrl: 'https://state.example/subscription-a',
servers: [stateServer],
selectedServerId: stateServerId,
appliedServerId: stateServerId,
connectionDesired: 'running',
}));
const migrated = createStateStore(filePath, {
legacySubscriptionCache: {
url: 'https://cache.example/subscription-b',
config: { outbounds: [cacheServer] },
servers: [cacheServer],
userInfo: { total: 123 },
fetchedAt: '2026-08-11T12:00:00.000Z',
},
}).read();
assert.equal(migrated.profiles.length, 1);
assert.equal(migrated.profiles[0].subscriptionUrl, 'https://state.example/subscription-a');
assert.equal(migrated.profiles[0].subscriptionConfig, null);
assert.deepEqual(migrated.profiles[0].servers.map(({ id }) => id), [stateServerId]);
assert.equal(migrated.profiles[0].desiredServerId, stateServerId);
assert.equal(migrated.appliedServerId, stateServerId);
assert.equal(migrated.appliedServerSnapshot.id, stateServerId);
assert.equal(JSON.stringify(migrated).includes('cache.example'), false);
});
test('data invariant: corrupt JSON preserves original bytes and returns explicit recovery state', (t) => {
+248 -423
View File
@@ -4,144 +4,111 @@ import test from 'node:test';
import { createSubscriptionService } from '../../dist/server/features/subscription/index.js';
import { createSubscriptionMutationRoute } from '../../dist/server/http/routes/subscriptionMutationRoute.js';
import { HarborError } from '../../dist/shared/errors.js';
const oldServer = {
id: 'srv_old',
label: 'Old',
host: 'old.example',
port: 443,
protocol: 'vless',
};
const nextServer = {
id: 'srv_next',
label: 'Next',
host: 'next.example',
port: 443,
protocol: 'vless',
};
const oldServer = { id: 'shared', label: 'Old', host: 'old.example', port: 443, protocol: 'vless' };
const nextServer = { id: 'next', label: 'Next', host: 'next.example', port: 443, protocol: 'vless' };
const routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
function parsed(servers = [nextServer]) {
const profile = (id, label, server = oldServer) => ({
id,
label,
subscriptionUrl: `https://${id}.example/sub`,
subscriptionConfig: { profile: id },
servers: [server],
userInfo: { total: 100 },
fetchedAt: '2026-08-08T10:00:00.000Z',
desiredServerId: server.id,
lastRefreshAttemptAt: null,
lastRefreshErrorCode: null,
});
const parsed = (id, servers = [nextServer]) => ({
config: { profile: id, refreshed: true },
servers,
userInfo: { total: 200 },
fetchedAt: '2026-08-08T12:00:00.000Z',
});
function defaultState() {
return {
config: { normalized: true },
sourceConfig: { source: true },
servers,
userInfo: { total: 100 },
fetchedAt: '2026-08-08T10:00:00.000Z',
revision: 3,
profiles: [profile('personal', 'Личный'), profile('work', 'Работа')],
desiredProfileId: 'personal',
appliedProfileId: 'personal',
appliedServerId: 'shared',
appliedServerSnapshot: oldServer,
routeRules,
appliedRouteRules: routeRules,
routeRulesRevision: 1,
connectionDesired: 'running',
};
}
function createHarness(overrides = {}) {
let state = structuredClone(overrides.state ?? {
revision: 3,
subscriptionUrl: 'https://old.example/sub',
servers: [oldServer],
selectedServerId: oldServer.id,
appliedServerId: oldServer.id,
routeRules,
gatewayAutoEnabled: false,
connectionDesired: 'running',
});
let cache = structuredClone(overrides.cache ?? { url: 'old', config: { old: true } });
let config = overrides.config ?? 'old-config-bytes';
let gatewayAuto = structuredClone(overrides.gatewayAuto ?? { mode: 'gateway-direct', gatewayId: 'old' });
let state = structuredClone(overrides.state ?? defaultState());
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
let gatewayAuto = { mode: 'gateway-direct', gatewayId: 'gateway' };
let running = overrides.running ?? true;
let serialized = Promise.resolve();
let tail = Promise.resolve();
let updateCount = 0;
const calls = [];
const failures = { ...(overrides.failures || {}) };
const timers = [];
const providerCalls = [];
const failOnce = (name) => {
const failure = failures[name];
if (!failure) return;
delete failures[name];
throw failure;
const serialize = (operation) => {
const result = tail.then(operation, operation);
tail = result.then(() => undefined, () => undefined);
return result;
};
const dependencies = {
const service = createSubscriptionService({
provider: {
fetchSubscription: overrides.fetchSubscription || (async () => parsed()),
selectRefreshedServer: overrides.selectRefreshedServer || ((current, _before, after) => (
fetchSubscription: async (url) => {
providerCalls.push(url);
if (overrides.fetchSubscription) return overrides.fetchSubscription(url);
return parsed(url.includes('work') ? 'work' : 'new');
},
selectRefreshedServer: (current, _before, after) => (
after.some((server) => server.id === current) ? current : ''
)),
),
},
state: {
read: () => structuredClone(state),
update: (mutator) => {
failOnce('stateUpdate');
const revision = state.revision + 1;
state = { ...structuredClone(mutator(structuredClone(state))), revision };
updateCount += 1;
const next = structuredClone(mutator(structuredClone(state)));
state = { ...next, revision: state.revision + 1 };
calls.push('state.update');
failOnce('stateUpdateAfter');
if (overrides.failStateAt === updateCount) throw new Error('state failed');
return structuredClone(state);
},
},
cache: {
read: () => structuredClone(cache),
write: (value) => {
failOnce('cacheWrite');
cache = structuredClone(value);
calls.push('cache.write');
},
remove: () => {
failOnce('cacheRemove');
cache = null;
calls.push('cache.remove');
},
},
config: {
build: (_value, selectedServerId, rules) => ({ selectedServerId, rules }),
read: () => config,
write: (value) => {
failOnce('configWrite');
config = JSON.stringify(value);
calls.push('config.write');
},
restore: (value) => {
failOnce('configRestore');
config = value;
calls.push('config.restore');
},
remove: () => {
failOnce('configRemove');
config = null;
calls.push('config.remove');
},
write: (value) => { config = JSON.stringify(value); calls.push('config.write'); },
restore: (value) => { config = value; calls.push('config.restore'); },
remove: () => { config = null; calls.push('config.remove'); },
},
runtime: {
isRunning: async () => running,
stop: async () => {
calls.push('runtime.stop');
failOnce('runtimeStop');
if (overrides.failStop) throw overrides.failStop;
running = false;
},
start: async () => {
calls.push('runtime.start');
const failure = Array.isArray(failures.runtimeStart)
? failures.runtimeStart.shift()
: failures.runtimeStart;
if (failure) {
if (!Array.isArray(failures.runtimeStart)) delete failures.runtimeStart;
running = false;
throw failure;
}
if (overrides.failStart) throw overrides.failStart;
running = true;
},
},
gatewayAuto: {
read: () => structuredClone(gatewayAuto),
set: (value) => {
gatewayAuto = structuredClone(value);
calls.push('gateway.set');
},
set: (value) => { gatewayAuto = structuredClone(value); calls.push('gateway.set'); },
createInitial: () => ({ mode: 'local-vpn' }),
},
serialize: (operation) => {
const result = serialized.then(operation, operation);
serialized = result.then(() => undefined, () => undefined);
return result;
},
serialize,
scheduler: {
setInterval: (callback, intervalMs) => {
const timer = {
@@ -157,383 +124,241 @@ function createHarness(overrides = {}) {
clearInterval: (timer) => { timer.clearCalls += 1; },
},
onRefreshError: overrides.onRefreshError || (() => {}),
};
now: () => new Date('2026-08-08T12:30:00.000Z'),
});
return {
service: createSubscriptionService(dependencies),
service,
calls,
timers,
snapshot: () => structuredClone({ state, cache, config, gatewayAuto, running }),
providerCalls,
advanceBackgroundRevision: (patch = {}) => {
state = { ...state, ...structuredClone(patch), revision: state.revision + 1 };
},
snapshot: () => structuredClone({ state, config, gatewayAuto, running }),
};
}
function assertRestoredSnapshot(actual, expected, label) {
const actualRevision = actual.state.revision;
const expectedRevision = expected.state.revision;
const actualDomain = structuredClone(actual);
const expectedDomain = structuredClone(expected);
delete actualDomain.state.revision;
delete expectedDomain.state.revision;
assert.deepEqual(actualDomain, expectedDomain, label);
assert.ok(actualRevision >= expectedRevision, `${label}: revision moved backwards`);
}
test('add fetches before commit, keeps the runtime, and only the first profile becomes desired', async () => {
const empty = defaultState();
empty.profiles = [];
empty.desiredProfileId = '';
empty.appliedProfileId = '';
empty.appliedServerId = '';
empty.appliedServerSnapshot = null;
empty.connectionDesired = 'stopped';
const harness = createHarness({ state: empty, running: false });
test('subscription import commits source config, clears selection, and leaves runtime stopped', async () => {
const result = await harness.service.addProfile(' Личный ', 'https://new.example/sub', 3);
const after = harness.snapshot();
assert.equal(result.label, 'Личный');
assert.equal(after.state.profiles.length, 1);
assert.equal(after.state.desiredProfileId, result.profileId);
assert.equal(after.state.profiles[0].subscriptionUrl, 'https://new.example/sub');
assert.equal(after.state.profiles[0].desiredServerId, '');
assert.equal(after.running, false);
assert.equal(after.config, 'old-config');
await harness.service.addProfile('Работа', 'https://work.example/sub');
assert.equal(harness.snapshot().state.desiredProfileId, result.profileId);
});
test('rename and server selection are profile-scoped and reject stale or duplicate writes', async () => {
const harness = createHarness();
await harness.service.renameProfile('work', 'Офис', 3);
assert.equal(harness.snapshot().state.profiles.find(({ id }) => id === 'work').label, 'Офис');
const duplicateRevision = harness.snapshot().state.revision;
await assert.rejects(harness.service.renameProfile('work', 'личный'), (error) => error.code === 'PROFILE_NAME_CONFLICT');
assert.equal(harness.snapshot().state.revision, duplicateRevision);
await assert.rejects(harness.service.selectProfileServer('work', 'missing'), (error) => error.code === 'SERVER_NOT_FOUND');
const result = await harness.service.importSubscription('https://new.example/sub');
const snapshot = harness.snapshot();
assert.equal(result.success, true);
assert.equal(result.selectedServerId, '');
assert.equal(snapshot.state.subscriptionUrl, 'https://new.example/sub');
assert.deepEqual(snapshot.state.routeRules, routeRules);
assert.equal(snapshot.state.gatewayAutoEnabled, false);
assert.equal(snapshot.state.connectionDesired, 'stopped');
assert.deepEqual(snapshot.cache.config, { source: true });
assert.equal(snapshot.config, null);
assert.deepEqual(snapshot.gatewayAuto, { mode: 'local-vpn' });
assert.equal(snapshot.running, false);
assert.equal(harness.calls.filter((call) => call === 'runtime.stop').length, 1);
assert.equal(harness.calls.filter((call) => call === 'runtime.start').length, 0);
const state = harness.snapshot().state;
state.profiles.find(({ id }) => id === 'work').servers = [oldServer, nextServer];
state.profiles.find(({ id }) => id === 'work').desiredServerId = 'shared';
const scoped = createHarness({ state });
await scoped.service.selectProfileServer('work', 'next', state.revision);
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'work').desiredServerId, 'next');
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'personal').desiredServerId, 'shared');
});
test('subscription refresh retains selection and restarts only a previously running runtime', async () => {
const retained = { ...oldServer, label: 'Renamed' };
const harness = createHarness({ fetchSubscription: async () => parsed([retained, nextServer]) });
const result = await harness.service.refreshSavedSubscription();
const snapshot = harness.snapshot();
assert.equal(result.selectedServerId, oldServer.id);
assert.equal(snapshot.state.selectedServerId, oldServer.id);
assert.match(snapshot.config, /srv_old/);
assert.equal(snapshot.running, true);
assert.equal(harness.calls.filter((call) => call === 'runtime.stop').length, 0);
assert.equal(harness.calls.filter((call) => call === 'runtime.start').length, 1);
test('refreshing an inactive profile does not mutate config, runtime, or the applied pair', async () => {
const harness = createHarness({ fetchSubscription: async () => parsed('work', [oldServer, nextServer]) });
await harness.service.refreshProfile('work', 3);
const after = harness.snapshot();
assert.equal(after.state.profiles.find(({ id }) => id === 'work').servers.length, 2);
assert.equal(after.state.appliedProfileId, 'personal');
assert.equal(after.state.appliedServerId, 'shared');
assert.equal(after.config, 'old-config');
assert.equal(harness.calls.includes('runtime.start'), false);
});
test('subscription import restores every snapshot on pre-commit failures', async () => {
for (const failurePoint of ['runtimeStop', 'configRemove', 'cacheWrite', 'stateUpdate', 'stateUpdateAfter']) {
const failure = new Error(failurePoint);
const harness = createHarness({ failures: { [failurePoint]: failure } });
const before = harness.snapshot();
await assert.rejects(
harness.service.importSubscription('https://new.example/sub'),
(error) => error === failure,
failurePoint,
);
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
}
test('provider failure retains the last successful list and records a scoped stale marker', async () => {
const failure = Object.assign(new Error('provider down'), { code: 'PROVIDER_UNAVAILABLE' });
const harness = createHarness({ fetchSubscription: async () => { throw failure; } });
await assert.rejects(harness.service.refreshProfile('work', 3), (error) => error === failure);
const work = harness.snapshot().state.profiles.find(({ id }) => id === 'work');
assert.deepEqual(work.servers, [oldServer]);
assert.equal(work.lastRefreshErrorCode, 'PROVIDER_UNAVAILABLE');
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
});
test('subscription refresh restores snapshots and reapplies the old config on pre-commit failures', async () => {
for (const failurePoint of ['configWrite', 'cacheWrite', 'stateUpdate', 'stateUpdateAfter']) {
const failure = new Error(failurePoint);
const harness = createHarness({
fetchSubscription: async () => parsed([oldServer]),
failures: { [failurePoint]: failure },
});
const before = harness.snapshot();
await assert.rejects(
harness.service.refreshSavedSubscription(),
(error) => error === failure,
failurePoint,
);
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
assert.equal(
harness.calls.filter((call) => call === 'runtime.start').length,
failurePoint === 'stateUpdate' || failurePoint === 'stateUpdateAfter' ? 2 : 0,
failurePoint,
);
}
});
test('subscription reset restores cache, config, gateway, state, and runtime on removal or commit failure', async () => {
for (const failurePoint of ['configRemove', 'cacheRemove', 'stateUpdate', 'stateUpdateAfter']) {
const failure = new Error(failurePoint);
const harness = createHarness({ failures: { [failurePoint]: failure } });
const before = harness.snapshot();
await assert.rejects(
harness.service.resetSavedSubscription(),
(error) => error === failure,
failurePoint,
);
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
}
const noRuntime = createHarness({ failures: { stateUpdate: new Error('commit') } });
await assert.rejects(noRuntime.service.resetSavedSubscription({ stopRuntime: false }));
assert.equal(noRuntime.calls.includes('runtime.stop'), false);
assert.equal(noRuntime.calls.includes('runtime.start'), false);
const stoppedCleanup = createHarness({ running: false });
await stoppedCleanup.service.resetSavedSubscription();
assert.equal(stoppedCleanup.calls.filter((call) => call === 'runtime.stop').length, 1);
assert.equal(stoppedCleanup.calls.includes('runtime.start'), false);
});
test('subscription rollback attempts remaining restores and aggregates restoration failures', async () => {
const commitFailure = new Error('commit failed');
const configRestoreFailure = new Error('config restore failed');
const configBroken = createHarness({
fetchSubscription: async () => parsed([oldServer]),
failures: { stateUpdate: commitFailure, configRestore: configRestoreFailure },
test('provider mutations keep admission CAS while tolerating background-only revision bumps', async () => {
let releaseAdd;
let addStarted;
const addFetchStarted = new Promise((resolve) => { addStarted = resolve; });
const adding = createHarness({
fetchSubscription: async () => {
addStarted();
return new Promise((resolve) => { releaseAdd = resolve; });
},
});
const beforeConfigBroken = configBroken.snapshot();
await assert.rejects(configBroken.service.refreshSavedSubscription(), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [commitFailure, configRestoreFailure]);
return true;
});
assert.equal(configBroken.snapshot().cache.url, beforeConfigBroken.cache.url);
assert.deepEqual(configBroken.snapshot().gatewayAuto, beforeConfigBroken.gatewayAuto);
assert.equal(configBroken.snapshot().running, true);
assert.equal(configBroken.calls.filter((call) => call === 'runtime.start').length, 2);
const pendingAdd = adding.service.addProfile('Резерв', 'https://reserve.example/sub', 3);
await addFetchStarted;
adding.advanceBackgroundRevision({ gatewayFreshness: 'verified' });
releaseAdd(parsed('reserve'));
const added = await pendingAdd;
assert.equal(adding.snapshot().state.profiles.some(({ id }) => id === added.profileId), true);
assert.equal(adding.snapshot().state.gatewayFreshness, 'verified');
const removeFailure = new Error('remove failed');
const cacheRestoreFailure = new Error('cache restore failed');
const cacheBroken = createHarness({
failures: { configRemove: removeFailure, cacheWrite: cacheRestoreFailure },
let rejectRefresh;
let refreshStarted;
const refreshFetchStarted = new Promise((resolve) => { refreshStarted = resolve; });
const refreshing = createHarness({
fetchSubscription: async () => {
refreshStarted();
return new Promise((_resolve, reject) => { rejectRefresh = reject; });
},
});
const beforeCacheBroken = cacheBroken.snapshot();
await assert.rejects(cacheBroken.service.importSubscription('https://new.example/sub'), (error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [removeFailure, cacheRestoreFailure]);
return true;
});
assert.equal(cacheBroken.snapshot().config, beforeCacheBroken.config);
assert.deepEqual(cacheBroken.snapshot().gatewayAuto, beforeCacheBroken.gatewayAuto);
assert.equal(cacheBroken.snapshot().running, true);
assert.equal(cacheBroken.calls.filter((call) => call === 'runtime.start').length, 1);
});
test('failed refresh start rolls back, and failed rollback start reports both causes', async () => {
const applyFailure = new Error('apply failed');
const recovered = createHarness({
fetchSubscription: async () => parsed([oldServer]),
failures: { runtimeStart: [applyFailure] },
});
const before = recovered.snapshot();
await assert.rejects(
recovered.service.refreshSavedSubscription(),
(error) => error === applyFailure,
const providerFailure = Object.assign(new Error('provider down'), { code: 'PROVIDER_UNAVAILABLE' });
const pendingRefresh = refreshing.service.refreshProfile('work', 3);
await refreshFetchStarted;
refreshing.advanceBackgroundRevision({ gatewayFreshness: 'verified' });
rejectRefresh(providerFailure);
await assert.rejects(pendingRefresh, (error) => error === providerFailure);
const refreshedState = refreshing.snapshot().state;
assert.equal(refreshedState.gatewayFreshness, 'verified');
assert.equal(
refreshedState.profiles.find(({ id }) => id === 'work').lastRefreshErrorCode,
'PROVIDER_UNAVAILABLE',
);
assert.deepEqual(recovered.snapshot(), before);
assert.equal(recovered.calls.filter((call) => call === 'runtime.start').length, 2);
const rollbackFailure = new Error('rollback failed');
const broken = createHarness({
fetchSubscription: async () => parsed([oldServer]),
failures: { runtimeStart: [applyFailure, rollbackFailure] },
});
await assert.rejects(broken.service.refreshSavedSubscription(), (error) => {
assert.equal(error.code, 'PROCESS_START_FAILED');
assert.ok(error.cause instanceof AggregateError);
assert.deepEqual(error.cause.errors, [applyFailure, rollbackFailure]);
return true;
});
});
test('subscription refresh coalesces provider work and rejects a stale late result', async () => {
let releaseOld;
const oldResult = new Promise((resolve) => { releaseOld = resolve; });
let providerCalls = 0;
test('active refresh applies a retained server and keeps last-applied snapshot if it disappears', async () => {
const retained = createHarness({ fetchSubscription: async () => parsed('personal', [oldServer, nextServer]) });
await retained.service.refreshProfile('personal', 3);
assert.ok(retained.calls.includes('config.write'));
assert.ok(retained.calls.includes('runtime.start'));
assert.equal(retained.snapshot().state.appliedServerId, 'shared');
const removed = createHarness({ fetchSubscription: async () => parsed('personal', [nextServer]) });
await removed.service.refreshProfile('personal', 3);
const after = removed.snapshot();
assert.equal(after.state.profiles[0].desiredServerId, '');
assert.equal(after.state.appliedServerId, 'shared');
assert.deepEqual(after.state.appliedServerSnapshot, oldServer);
assert.equal(removed.calls.includes('config.write'), false);
assert.equal(after.running, true);
});
test('inactive delete is state-only; applied delete requires one stop-and-delete transaction', async () => {
const inactive = createHarness();
await inactive.service.deleteProfile('work', 'delete', 3);
assert.deepEqual(inactive.snapshot().state.profiles.map(({ id }) => id), ['personal']);
assert.equal(inactive.snapshot().config, 'old-config');
assert.equal(inactive.snapshot().running, true);
const active = createHarness();
await assert.rejects(active.service.deleteProfile('personal', 'delete', 3), (error) => error.code === 'PROFILE_IN_USE');
await active.service.deleteProfile('personal', 'stop-and-delete', 3);
const after = active.snapshot();
assert.deepEqual(after.state.profiles.map(({ id }) => id), ['work']);
assert.equal(after.state.appliedProfileId, '');
assert.equal(after.state.appliedServerSnapshot, null);
assert.equal(after.state.connectionDesired, 'stopped');
assert.equal(after.running, false);
assert.equal(after.config, null);
});
test('deleting a pending desired profile leaves the running applied route untouched', async () => {
const state = defaultState();
state.desiredProfileId = 'work';
const harness = createHarness({ state });
await harness.service.deleteProfile('work', 'delete', 3);
const after = harness.snapshot();
assert.equal(after.config, 'old-config');
assert.equal(after.running, true);
assert.deepEqual(after.gatewayAuto, { mode: 'gateway-direct', gatewayId: 'gateway' });
assert.equal(harness.calls.includes('gateway.set'), false);
});
test('auto refresh iterates profiles once, reports scoped failures, and stops idempotently', async () => {
const errors = [];
const failure = Object.assign(new Error('down'), { code: 'PROVIDER_UNAVAILABLE' });
const harness = createHarness({
fetchSubscription: async (url) => {
providerCalls += 1;
return url.includes('old') ? oldResult : parsed();
},
});
const first = harness.service.refreshSavedSubscription();
const second = harness.service.refreshSavedSubscription();
assert.equal(first, second);
assert.equal(providerCalls, 1);
await harness.service.importSubscription('https://new.example/sub');
releaseOld(parsed([oldServer]));
await assert.rejects(first, (error) => error.code === 'STATE_CONFLICT');
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://new.example/sub');
assert.equal(providerCalls, 2);
});
test('subscription generation rejects late success after a newer import of the same URL', async () => {
let releaseRefresh;
const delayed = new Promise((resolve) => { releaseRefresh = resolve; });
let providerCalls = 0;
const harness = createHarness({
fetchSubscription: async () => {
providerCalls += 1;
return providerCalls === 1 ? delayed : parsed([nextServer]);
},
});
const stale = harness.service.refreshSavedSubscription();
await harness.service.importSubscription('https://old.example/sub');
releaseRefresh(parsed([oldServer]));
await assert.rejects(stale, (error) => error.code === 'STATE_CONFLICT');
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://old.example/sub');
});
test('subscription generation prevents a late terminal refresh from forgetting a newer import', async () => {
let rejectRefresh;
const delayed = new Promise((_resolve, reject) => { rejectRefresh = reject; });
let providerCalls = 0;
const harness = createHarness({
fetchSubscription: async () => {
providerCalls += 1;
return providerCalls === 1 ? delayed : parsed([nextServer]);
},
});
const stale = harness.service.refreshSavedSubscription();
await harness.service.importSubscription('https://old.example/sub');
rejectRefresh(new HarborError('SUBSCRIPTION_EXPIRED'));
await assert.rejects(stale, (error) => error.code === 'STATE_CONFLICT');
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://old.example/sub');
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
assert.ok(harness.snapshot().cache);
});
test('subscription generation prevents a delayed import from resurrecting a forgotten subscription', async () => {
let releaseImport;
const delayed = new Promise((resolve) => { releaseImport = resolve; });
const harness = createHarness({ fetchSubscription: async () => delayed });
const staleImport = harness.service.importSubscription('https://new.example/sub');
await harness.service.resetSavedSubscription();
releaseImport(parsed());
await assert.rejects(staleImport, (error) => error.code === 'STATE_CONFLICT');
assert.equal(Boolean(harness.snapshot().state.subscriptionUrl), false);
assert.equal(harness.snapshot().cache, null);
assert.equal(harness.snapshot().config, null);
});
test('subscription generation lets only the first completed concurrent import commit', async () => {
let releaseFirst;
let releaseSecond;
const firstResult = new Promise((resolve) => { releaseFirst = resolve; });
const secondResult = new Promise((resolve) => { releaseSecond = resolve; });
const harness = createHarness({
fetchSubscription: async (url) => url.includes('first') ? firstResult : secondResult,
});
const first = harness.service.importSubscription('https://first.example/sub');
const second = harness.service.importSubscription('https://second.example/sub');
releaseSecond(parsed([nextServer]));
await second;
releaseFirst(parsed([oldServer]));
await assert.rejects(first, (error) => error.code === 'STATE_CONFLICT');
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://second.example/sub');
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
});
test('only terminal subscription refresh failures forget saved data', async () => {
const cases = [
['SUBSCRIPTION_EXPIRED', true],
['SUBSCRIPTION_DISABLED', true],
['SUBSCRIPTION_REJECTED', true],
['SUBSCRIPTION_TRAFFIC_EXHAUSTED', false],
['SUBSCRIPTION_INVALID', false],
['PROVIDER_UNAVAILABLE', false],
];
for (const [code, resets] of cases) {
const harness = createHarness({
running: false,
fetchSubscription: async () => { throw new HarborError(code); },
});
await assert.rejects(harness.service.refreshSavedSubscription(), (error) => error.code === code);
assert.equal(Boolean(harness.snapshot().state.subscriptionUrl), !resets, code);
assert.equal(Boolean(harness.snapshot().cache), !resets, code);
}
});
test('subscription scheduler is unrefed, skips empty state, reports errors, and stops idempotently', async () => {
const errors = [];
let providerCalls = 0;
const harness = createHarness({
state: { revision: 0, servers: [], selectedServerId: '', appliedServerId: '', routeRules: [] },
fetchSubscription: async () => {
providerCalls += 1;
throw new HarborError('PROVIDER_UNAVAILABLE');
if (url.includes('work')) throw failure;
return parsed('personal', [oldServer]);
},
onRefreshError: (error) => errors.push(error.code),
});
harness.service.startAutoRefresh(1234);
harness.service.startAutoRefresh(1234);
assert.equal(harness.timers.length, 1);
assert.equal(harness.timers[0].intervalMs, 1234);
assert.equal(harness.timers[0].unrefCalls, 1);
harness.timers[0].callback();
await new Promise(setImmediate);
assert.equal(providerCalls, 0);
const stateful = createHarness({
fetchSubscription: async () => {
providerCalls += 1;
throw new HarborError('PROVIDER_UNAVAILABLE');
},
onRefreshError: (error) => errors.push(error.code),
});
stateful.service.startAutoRefresh(10);
stateful.timers[0].callback();
await new Promise(setImmediate);
await new Promise(setImmediate);
assert.equal(providerCalls, 1);
assert.equal(harness.providerCalls.length, 2);
assert.deepEqual(errors, ['PROVIDER_UNAVAILABLE']);
stateful.service.stopAutoRefresh();
stateful.service.stopAutoRefresh();
assert.equal(stateful.timers[0].clearCalls, 1);
harness.service.stopAutoRefresh();
harness.service.stopAutoRefresh();
assert.equal(harness.timers[0].clearCalls, 1);
});
test('subscription mutation route preserves methods, operation kinds, and response fields', async () => {
const operations = [];
test('profile mutation route scopes every target and keeps bounded legacy shims', async () => {
const calls = [];
const sent = [];
const service = {
importSubscription: async (url) => ({ success: true, imported: url }),
refreshSavedSubscription: async () => ({ success: true, refreshed: true }),
resetSavedSubscription: async () => { operations.push('reset'); },
preflightAddProfile: () => {},
preflightRenameProfile: () => {},
addProfile: async (...args) => ({ success: true, args }),
renameProfile: async (...args) => ({ success: true, args }),
selectProfileServer: async (...args) => ({ success: true, args }),
refreshProfile: async (...args) => ({ success: true, args }),
deleteProfile: async (...args) => ({ success: true, args }),
importSubscription: async (...args) => ({ success: true, args }),
refreshSavedSubscription: async (...args) => ({ success: true, args }),
resetSavedSubscription: async (...args) => ({ success: true, args }),
};
const route = createSubscriptionMutationRoute({
subscriptionService: service,
readBody: async () => ({ url: ' https://new.example/sub ' }),
withOperation: async (kind, operation) => {
operations.push(kind);
return operation();
connection: { activate: async (...args) => ({ success: true, args }) },
readBody: async () => ({
label: 'Работа', url: 'https://work.example/sub', serverId: 'next', mode: 'delete', expectedRevision: 7,
}),
withOperation: async (kind, operation, options = {}) => {
calls.push([kind, options]);
return operation(8);
},
sendState: async (_res, extra = {}) => { sent.push(extra); },
});
const response = {};
for (const [method, url] of [
['POST', '/api/profiles'],
['PATCH', '/api/profiles/work'],
['PUT', '/api/profiles/work/server'],
['POST', '/api/profiles/work/activate'],
['POST', '/api/profiles/work/refresh'],
['DELETE', '/api/profiles/work'],
]) assert.equal(await route.handle({ method, url }, response), true);
assert.equal(await route.handle({ method: 'GET', url: '/api/subscription/fetch' }, response), false);
assert.equal(await route.handle({ method: 'POST', url: '/api/subscription/fetch' }, response), true);
assert.equal(await route.handle({ method: 'POST', url: '/api/subscription/refresh' }, response), true);
assert.equal(await route.handle({ method: 'DELETE', url: '/api/subscription' }, response), true);
assert.deepEqual(operations, ['subscription-import', 'subscription-refresh', 'subscription-forget', 'reset']);
assert.deepEqual(sent, [
{ success: true, imported: 'https://new.example/sub' },
{ refreshed: true },
{},
assert.deepEqual(calls.map(([kind]) => kind), [
'profile-add', 'profile-rename', 'profile-select-server', 'profile-activate', 'profile-refresh', 'profile-delete',
]);
});
assert.equal(sent.length, 6);
assert.deepEqual(calls[2][1], { expectedRevision: 7, profileId: 'work', serverId: 'next' });
test('composition root contains no displaced subscription mutation owner', () => {
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(source, /createSubscriptionMutationRoute\(\{/);
assert.match(source, /subscriptionService\.startAutoRefresh\(SUBSCRIPTION_REFRESH_INTERVAL_MS\)/);
assert.doesNotMatch(source, /subscriptionRefreshPromise|subscriptionRefreshTimer|TERMINAL_SUBSCRIPTION_CODES/);
assert.doesNotMatch(source, /req\.url === ['"]\/api\/subscription\/(?:fetch|refresh)['"]/);
assert.doesNotMatch(source, /req\.method === ['"]DELETE['"] && req\.url === ['"]\/api\/subscription['"]/);
assert.doesNotMatch(source, /req\.url === ['"]\/api\/subscription\/\(?:fetch\|refresh\)['"]/);
});