Refactor VPN proxy components and update related behavior
This commit is contained in:
@@ -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\)['"]/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user