Files
harbor-net/test/server/subscription-mutation.test.js
T
dokril aa9c959368
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s
Refactor VPN proxy components and update related behavior
2026-08-11 01:27:46 +03:00

365 lines
15 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 }];
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 = { mode: 'gateway-direct', gatewayId: 'gateway' };
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');
});
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('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) => {
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\)['"]/);
});