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, outbound: 'direct' }]; const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'vpn' }]; 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, null, 2); 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; }, }, route: { isGatewayDirect: () => overrides.gatewayDirect === true }, 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(newRules, 2, undefined), (error) => error.code === 'REQUEST_INVALID'); assert.throws(() => harness.service.update('bad', 2, 2), (error) => error.code === 'REQUEST_INVALID'); assert.throws(() => harness.service.update(newRules, -1, 2), (error) => error.code === 'REQUEST_INVALID'); assert.throws( () => harness.service.update([{ type: 'domain', value: 'example.com', enabled: true }], 2, 2), (error) => error.code === 'REQUEST_INVALID', ); await assert.rejects(harness.service.update(oldRules, 1, 2), (error) => error.code === 'STATE_CONFLICT'); const before = harness.snapshot(); await harness.service.update(oldRules, 2, 2); assert.deepEqual(harness.snapshot(), before); assert.deepEqual(harness.events, []); }); test('route rules use the explicit domain revision and normalize without losing target', async () => { const explicit = createHarness(); await explicit.service.update([ { type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true, outbound: 'vpn' }, { type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'direct' }, ], 2, 2); assert.deepEqual(explicit.snapshot().state.routeRules, newRules); assert.equal(explicit.snapshot().state.routeRulesRevision, 3); }); 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, 2); 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, 2); 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, 2); assert.deepEqual(stopped.snapshot().state.appliedRouteRules, oldRules); assert.equal(stopped.events.includes('runtime.apply'), false); }); test('route rules in gateway-direct update desired order without touching config or runtime', async () => { const state = { ...canonicalState(), appliedRouteRules: [] }; const harness = createHarness({ state, gatewayDirect: true, running: true }); await harness.service.update(newRules, 2, 2); assert.deepEqual(harness.snapshot().state.routeRules, newRules); assert.deepEqual(harness.snapshot().state.appliedRouteRules, []); assert.equal(harness.snapshot().state.routeRulesRevision, 3); assert.equal(harness.snapshot().config, 'old-config'); assert.deepEqual(harness.events, ['operation', 'state.update']); }); test('route rules skip runtime apply when the generated config bytes are unchanged', async () => { const config = JSON.stringify({ selectedServerId: 'server', routeRules: newRules }, null, 2); const harness = createHarness({ running: true, config }); await harness.service.update(newRules, 2, 2); assert.deepEqual(harness.snapshot().state.appliedRouteRules, newRules); assert.deepEqual(harness.events, ['operation', 'state.update']); }); 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, 2)); 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, 2), (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, 2), (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, 2), (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, 2), (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, rulesContractVersion: 2 }), 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/v2' }, response), true); assert.deepEqual(calls, [[newRules, 3, 2], '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['"]/); });