244 lines
9.8 KiB
JavaScript
244 lines
9.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
|
|
import { createRouteRulesService } from '../../dist/server/features/routing/index.js';
|
|
import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js';
|
|
|
|
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 ?? canonicalState());
|
|
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
|
const failures = {
|
|
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
|
|
...overrides.failures,
|
|
};
|
|
let stateUpdateIndex = 0;
|
|
let tail = Promise.resolve();
|
|
let active = 0;
|
|
let peak = 0;
|
|
const events = [];
|
|
|
|
const serialize = (operation) => {
|
|
const run = async () => {
|
|
active += 1;
|
|
peak = Math.max(peak, active);
|
|
try { return await operation(); } finally { active -= 1; }
|
|
};
|
|
const result = tail.then(run, run);
|
|
tail = result.then(() => undefined, () => undefined);
|
|
return result;
|
|
};
|
|
|
|
const service = createRouteRulesService({
|
|
state: {
|
|
read: () => structuredClone(state),
|
|
update: (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('state.update');
|
|
if (failure?.after) throw failure.after;
|
|
return structuredClone(state);
|
|
},
|
|
},
|
|
subscription: {
|
|
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
|
|
},
|
|
config: {
|
|
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;
|
|
},
|
|
},
|
|
runtime: {
|
|
isRunning: async () => overrides.running ?? true,
|
|
applyCommand: async () => {
|
|
events.push('runtime.apply');
|
|
return overrides.commandResult || { ok: true, mutationStarted: true };
|
|
},
|
|
restoreRunning: async () => {
|
|
events.push('runtime.restore');
|
|
if (failures.runtimeRestore) throw failures.runtimeRestore;
|
|
},
|
|
},
|
|
serialize,
|
|
runOperation: async (operation) => {
|
|
events.push('operation');
|
|
return operation();
|
|
},
|
|
});
|
|
|
|
return {
|
|
service,
|
|
events,
|
|
snapshot: () => structuredClone({ state, config, 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);
|
|
}
|
|
|
|
test('route rules validate strictly, conflict before no-op, and preserve no-op revisions', async () => {
|
|
const harness = createHarness();
|
|
assert.throws(() => harness.service.update('bad', 2, undefined), (error) => error.code === 'REQUEST_INVALID');
|
|
assert.throws(() => harness.service.update(newRules, -1, undefined), (error) => error.code === 'REQUEST_INVALID');
|
|
await assert.rejects(harness.service.update(oldRules, 1, undefined), (error) => error.code === 'STATE_CONFLICT');
|
|
const before = harness.snapshot();
|
|
await harness.service.update(oldRules, 2, undefined);
|
|
assert.deepEqual(harness.snapshot(), before);
|
|
assert.deepEqual(harness.events, []);
|
|
});
|
|
|
|
test('route rules support explicit domain revision and legacy global revision with normalization', async () => {
|
|
const explicit = createHarness();
|
|
await explicit.service.update([
|
|
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true },
|
|
{ type: 'domain_suffix', value: 'new.example', enabled: true },
|
|
], 2, undefined);
|
|
assert.deepEqual(explicit.snapshot().state.routeRules, newRules);
|
|
assert.equal(explicit.snapshot().state.routeRulesRevision, 3);
|
|
|
|
const legacy = createHarness();
|
|
await legacy.service.update(newRules, undefined, 10);
|
|
assert.deepEqual(legacy.snapshot().state.routeRules, newRules);
|
|
});
|
|
|
|
test('route rules state-only path leaves config and applied rules unchanged', async () => {
|
|
for (const state of [
|
|
{ ...canonicalState([]), revision: 1, routeRulesRevision: 0 },
|
|
{ ...canonicalState(), revision: 1, routeRulesRevision: 0 },
|
|
]) {
|
|
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);
|
|
assert.equal(harness.snapshot().config, 'old-config');
|
|
assert.deepEqual(harness.events, ['operation', 'state.update']);
|
|
}
|
|
});
|
|
|
|
test('route rules running apply updates active rules while stopped leaves them pending', async () => {
|
|
const running = createHarness({ running: true });
|
|
await running.service.update(newRules, 2, undefined);
|
|
assert.deepEqual(running.snapshot().state.appliedRouteRules, newRules);
|
|
assert.deepEqual(running.events, ['operation', 'config.write', 'runtime.apply', 'state.update']);
|
|
|
|
const stopped = createHarness({ running: false });
|
|
await stopped.service.update(newRules, 2, undefined);
|
|
assert.deepEqual(stopped.snapshot().state.appliedRouteRules, oldRules);
|
|
assert.equal(stopped.events.includes('runtime.apply'), false);
|
|
});
|
|
|
|
test('route rules rollback restores config/domain and honors runtime mutation phase', async () => {
|
|
for (const failures of [
|
|
{ configWriteAfter: new Error('config') },
|
|
{ stateUpdates: [{ after: new Error('state') }] },
|
|
]) {
|
|
const harness = createHarness({ failures });
|
|
const before = harness.snapshot();
|
|
await assert.rejects(harness.service.update(newRules, 2, undefined));
|
|
assertDomainRestored(harness.snapshot(), before);
|
|
}
|
|
|
|
const preMutation = new Error('invalid config');
|
|
const local = createHarness({ commandResult: { ok: false, mutationStarted: false, error: preMutation } });
|
|
await assert.rejects(local.service.update(newRules, 2, undefined), (error) => error === preMutation);
|
|
assert.equal(local.events.includes('runtime.restore'), false);
|
|
|
|
const postMutation = new Error('remote failed');
|
|
const remote = createHarness({ commandResult: { ok: false, mutationStarted: true, error: postMutation } });
|
|
await assert.rejects(remote.service.update(newRules, 2, undefined), (error) => error === postMutation);
|
|
assert.equal(remote.events.includes('runtime.restore'), true);
|
|
});
|
|
|
|
test('route rules rollback continues and classifies runtime restore failure', async () => {
|
|
const original = new Error('state failed');
|
|
const configRestore = new Error('config restore failed');
|
|
const aggregate = createHarness({
|
|
failures: { stateUpdates: [original], configRestore },
|
|
});
|
|
await assert.rejects(aggregate.service.update(newRules, 2, undefined), (error) => {
|
|
assert.ok(error instanceof AggregateError);
|
|
assert.deepEqual(error.errors, [original, configRestore]);
|
|
return true;
|
|
});
|
|
|
|
const runtimeRestore = new Error('runtime restore failed');
|
|
const broken = createHarness({
|
|
failures: { stateUpdates: [original], runtimeRestore },
|
|
});
|
|
await assert.rejects(broken.service.update(newRules, 2, undefined), (error) => {
|
|
assert.equal(error.code, 'PROCESS_START_FAILED');
|
|
assert.deepEqual(error.cause.errors, [original, runtimeRestore]);
|
|
return true;
|
|
});
|
|
assert.ok(broken.events.filter((event) => event === 'state.update').length >= 1);
|
|
});
|
|
|
|
test('route rules route preserves one adapter and state-only response', async () => {
|
|
const calls = [];
|
|
const route = createRouteRulesRoute({
|
|
routeRules: { update: async (...args) => { calls.push(args); } },
|
|
readBody: async () => ({ rules: newRules, expectedRulesRevision: 3, expectedRevision: 99 }),
|
|
sendState: async () => { calls.push('sent'); },
|
|
});
|
|
const response = {};
|
|
assert.equal(await route.handle({ method: 'POST', url: '/api/route-rules' }, response), false);
|
|
assert.equal(await route.handle({ method: 'PUT', url: '/api/route-rules' }, response), true);
|
|
assert.deepEqual(calls, [[newRules, 3, 99], 'sent']);
|
|
|
|
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
|
assert.match(source, /createRouteRulesRoute\(\{/);
|
|
assert.doesNotMatch(source, /function applyRouteRules|req\.url === ['"]\/api\/route-rules['"]/);
|
|
});
|