391 lines
17 KiB
JavaScript
391 lines
17 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
|
|
import { createSubscriptionService } from '../../dist/server/features/subscription/index.js';
|
|
import { createSubscriptionMutationRoute } from '../../dist/server/http/routes/subscriptionMutationRoute.js';
|
|
|
|
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, outbound: 'direct' }];
|
|
|
|
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 {
|
|
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 ?? defaultState());
|
|
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
|
let gatewayAuto = structuredClone(overrides.gatewayAuto ?? { mode: 'local-vpn', gatewayId: '' });
|
|
let running = overrides.running ?? true;
|
|
let tail = Promise.resolve();
|
|
let updateCount = 0;
|
|
const calls = [];
|
|
const timers = [];
|
|
const providerCalls = [];
|
|
|
|
const serialize = (operation) => {
|
|
const result = tail.then(operation, operation);
|
|
tail = result.then(() => undefined, () => undefined);
|
|
return result;
|
|
};
|
|
|
|
const service = createSubscriptionService({
|
|
provider: {
|
|
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) => {
|
|
updateCount += 1;
|
|
const next = structuredClone(mutator(structuredClone(state)));
|
|
state = { ...next, revision: state.revision + 1 };
|
|
calls.push('state.update');
|
|
if (overrides.failStateAt === updateCount) throw new Error('state failed');
|
|
return structuredClone(state);
|
|
},
|
|
},
|
|
config: {
|
|
build: (_value, selectedServerId, rules) => ({ selectedServerId, rules }),
|
|
read: () => config,
|
|
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');
|
|
if (overrides.failStop) throw overrides.failStop;
|
|
running = false;
|
|
},
|
|
start: async () => {
|
|
calls.push('runtime.start');
|
|
if (overrides.failStart) throw overrides.failStart;
|
|
running = true;
|
|
},
|
|
},
|
|
gatewayAuto: {
|
|
read: () => structuredClone(gatewayAuto),
|
|
set: (value) => { gatewayAuto = structuredClone(value); calls.push('gateway.set'); },
|
|
createInitial: () => ({ mode: 'local-vpn' }),
|
|
},
|
|
serialize,
|
|
scheduler: {
|
|
setInterval: (callback, intervalMs) => {
|
|
const timer = {
|
|
callback,
|
|
intervalMs,
|
|
unrefCalls: 0,
|
|
clearCalls: 0,
|
|
unref() { this.unrefCalls += 1; },
|
|
};
|
|
timers.push(timer);
|
|
return timer;
|
|
},
|
|
clearInterval: (timer) => { timer.clearCalls += 1; },
|
|
},
|
|
onRefreshError: overrides.onRefreshError || (() => {}),
|
|
now: () => new Date('2026-08-08T12:30:00.000Z'),
|
|
});
|
|
|
|
return {
|
|
service,
|
|
calls,
|
|
timers,
|
|
providerCalls,
|
|
advanceBackgroundRevision: (patch = {}) => {
|
|
state = { ...state, ...structuredClone(patch), revision: state.revision + 1 };
|
|
},
|
|
snapshot: () => structuredClone({ state, config, gatewayAuto, running }),
|
|
};
|
|
}
|
|
|
|
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 });
|
|
|
|
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 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');
|
|
assert.equal(scoped.snapshot().state.desiredProfileId, 'work');
|
|
|
|
const sameServer = scoped.snapshot().state;
|
|
sameServer.desiredProfileId = 'personal';
|
|
const reactivated = createHarness({ state: sameServer });
|
|
await reactivated.service.selectProfileServer('work', 'next', sameServer.revision);
|
|
assert.equal(reactivated.snapshot().state.desiredProfileId, 'work');
|
|
});
|
|
|
|
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('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('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 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');
|
|
|
|
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 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',
|
|
);
|
|
});
|
|
|
|
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('active refresh publishes the route rules used by the rebuilt config', async () => {
|
|
const nextRules = [{ type: 'domain', value: 'vpn.example', enabled: true, outbound: 'vpn' }];
|
|
const state = defaultState();
|
|
state.routeRules = nextRules;
|
|
state.appliedRouteRules = routeRules;
|
|
const local = createHarness({ state, fetchSubscription: async () => parsed('personal', [oldServer]) });
|
|
await local.service.refreshProfile('personal', state.revision);
|
|
assert.deepEqual(local.snapshot().state.appliedRouteRules, nextRules);
|
|
assert.deepEqual(JSON.parse(local.snapshot().config).rules, nextRules);
|
|
|
|
const bypassed = createHarness({
|
|
state,
|
|
gatewayAuto: { mode: 'gateway-direct', gatewayId: 'gateway' },
|
|
fetchSubscription: async () => parsed('personal', [oldServer]),
|
|
});
|
|
await bypassed.service.refreshProfile('personal', state.revision);
|
|
assert.deepEqual(bypassed.snapshot().state.appliedRouteRules, []);
|
|
});
|
|
|
|
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: 'local-vpn', gatewayId: '' });
|
|
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) => {
|
|
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].unrefCalls, 1);
|
|
harness.timers[0].callback();
|
|
await new Promise(setImmediate);
|
|
await new Promise(setImmediate);
|
|
assert.equal(harness.providerCalls.length, 2);
|
|
assert.deepEqual(errors, ['PROVIDER_UNAVAILABLE']);
|
|
harness.service.stopAutoRefresh();
|
|
harness.service.stopAutoRefresh();
|
|
assert.equal(harness.timers[0].clearCalls, 1);
|
|
});
|
|
|
|
test('profile mutation route scopes every target and keeps bounded legacy shims', async () => {
|
|
const calls = [];
|
|
const sent = [];
|
|
const service = {
|
|
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,
|
|
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.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' });
|
|
|
|
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
|
assert.match(source, /createSubscriptionMutationRoute\(\{/);
|
|
assert.doesNotMatch(source, /req\.url === ['"]\/api\/subscription\/\(?:fetch\|refresh\)['"]/);
|
|
});
|