Update Harbor client and gateway integration workflows
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createActivityJournalRoute } from '../../dist/server/http/routes/activityJournalRoute.js';
|
||||
|
||||
test('journal route is read-only and forwards bounded cursor pagination', async () => {
|
||||
const calls = [];
|
||||
const route = createActivityJournalRoute({
|
||||
journal: { page: (...args) => {
|
||||
calls.push(args);
|
||||
return { events: [], nextCursor: null, retentionDays: 30, generatedAt: 'now', storage: { status: 'ready', errorCode: null } };
|
||||
} },
|
||||
});
|
||||
let status;
|
||||
let payload;
|
||||
const response = {
|
||||
writeHead: (value) => { status = value; },
|
||||
end: (value) => { payload = JSON.parse(value); },
|
||||
};
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/activity-journal?limit=25&cursor=evt' }, response), true);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(payload.retentionDays, 30);
|
||||
assert.deepEqual(calls, [[25, 'evt']]);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/activity-journal' }, response), false);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
|
||||
import { assertActivityJournalPage } from '../../dist/shared/activityJournal.js';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-journal-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'activity-journal.json');
|
||||
}
|
||||
|
||||
const event = (dedupeKey, profileLabel = 'Home') => ({
|
||||
type: 'subscription.refreshed',
|
||||
severity: 'info',
|
||||
source: 'subscription',
|
||||
dedupeKey: `subscription.refreshed:${dedupeKey}`,
|
||||
data: { profileId: 'profile-1', profileLabel, host: 'provider.example', serverCount: 12, added: 2, removed: 1 },
|
||||
});
|
||||
|
||||
test('journal appends typed events, deduplicates and keeps stable newest-first cursors', (t) => {
|
||||
let clock = new Date('2026-08-19T10:00:00.000Z');
|
||||
const filePath = fixture(t);
|
||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
||||
service.append(event('refresh:1', 'One'));
|
||||
clock = new Date('2026-08-19T10:01:00.000Z');
|
||||
service.append(event('refresh:2', 'Two'));
|
||||
service.append(event('refresh:2', 'Duplicate'));
|
||||
const first = service.page(1);
|
||||
assert.equal(first.events[0].data.profileLabel, 'Two');
|
||||
assert.ok(first.nextCursor);
|
||||
|
||||
clock = new Date('2026-08-19T10:02:00.000Z');
|
||||
service.append(event('refresh:3', 'Three'));
|
||||
const older = service.page(10, first.nextCursor);
|
||||
assert.deepEqual(older.events.map(({ data }) => data.profileLabel), ['One']);
|
||||
assert.equal(first.events[0].dedupeKey, null);
|
||||
const inode = fs.statSync(filePath).ino;
|
||||
assert.equal(service.page(10).events.length, 3);
|
||||
assert.equal(fs.statSync(filePath).ino, inode);
|
||||
assert.deepEqual(service.page(10, 'expired-cursor').events, []);
|
||||
});
|
||||
|
||||
test('journal prunes events older than 30 days and rejects unsafe payloads', (t) => {
|
||||
let clock = new Date('2026-07-01T00:00:00.000Z');
|
||||
const filePath = fixture(t);
|
||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
||||
service.append(event('old'));
|
||||
clock = new Date('2026-08-19T00:00:00.000Z');
|
||||
assert.deepEqual(service.page().events, []);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')).events, []);
|
||||
service.append(event('new'));
|
||||
assert.throws(() => service.append({ ...event('unsafe'), data: { rawUrl: 'https://secret' } }), /Unsafe/);
|
||||
service.append({ ...event('ip'), data: { ...event('ip').data, host: '192.168.1.1' } });
|
||||
service.append(event('credential-label', 'https://user:pass@example.test/private?token=secret'));
|
||||
service.append(event('path-label', '192.168.1.7/private'));
|
||||
service.append(event('ipv6-label', '2001:db8::1'));
|
||||
const page = service.page();
|
||||
const labels = page.events.slice(0, 3).map(({ data }) => data.profileLabel);
|
||||
assert.deepEqual(labels, ['Подписка', 'Подписка', 'Подписка']);
|
||||
assert.equal(page.events.find(({ data }) => data.host === 'Провайдер')?.data.host, 'Провайдер');
|
||||
for (const dedupeKey of [
|
||||
'subscription.refreshed:192.168.1.1',
|
||||
'subscription.refreshed:2001:db8::1',
|
||||
'subscription.refreshed:user:pass',
|
||||
]) service.append({ ...event('safe'), dedupeKey });
|
||||
service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:user:pass' });
|
||||
const persisted = fs.readFileSync(filePath, 'utf8');
|
||||
assert.doesNotMatch(persisted, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
|
||||
assert.match(persisted, /subscription\.refreshed:sha256:[a-f0-9]{64}/);
|
||||
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'https://user:pass@example.test/private?token=x' }), /dedupe/i);
|
||||
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:private/path' }), /dedupe/i);
|
||||
});
|
||||
|
||||
test('journal persists the 10,000 event cap when opening an oversized store', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const occurredAt = '2026-08-19T00:00:00.000Z';
|
||||
const events = Array.from({ length: 10_001 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
occurredAt,
|
||||
...event(`event:${index}`),
|
||||
}));
|
||||
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, events }));
|
||||
const service = createActivityJournalService({
|
||||
filePath,
|
||||
now: () => new Date('2026-08-19T01:00:00.000Z'),
|
||||
});
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).events.length, 10_000);
|
||||
assert.equal(service.page(1).events.length, 1);
|
||||
});
|
||||
|
||||
test('corrupt journal is isolated and recovery becomes a safe event', (t) => {
|
||||
const filePath = fixture(t);
|
||||
fs.writeFileSync(filePath, '{broken');
|
||||
const service = createActivityJournalService({
|
||||
filePath,
|
||||
now: () => new Date('2026-08-19T12:00:00.000Z'),
|
||||
});
|
||||
const page = service.page();
|
||||
assert.equal(page.storage.status, 'ready');
|
||||
assert.equal(page.events[0].type, 'journal.recovered');
|
||||
assert.ok(fs.readdirSync(path.dirname(filePath)).some((name) => name.includes('.corrupt-')));
|
||||
});
|
||||
|
||||
test('journal exposes a latched write failure until a later append succeeds', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const directory = path.dirname(filePath);
|
||||
const service = createActivityJournalService({ filePath });
|
||||
service.append(event('before-error'));
|
||||
fs.chmodSync(directory, 0o555);
|
||||
try {
|
||||
assert.throws(() => service.append(event('lost')));
|
||||
const failed = service.page();
|
||||
assert.equal(failed.storage.status, 'error');
|
||||
assert.equal(failed.storage.errorCode, 'JOURNAL_UNAVAILABLE');
|
||||
assert.equal(failed.events.length, 1);
|
||||
assert.equal(failed.events[0].dedupeKey, null);
|
||||
} finally {
|
||||
fs.chmodSync(directory, 0o755);
|
||||
}
|
||||
service.append(event('recovered'));
|
||||
assert.equal(service.page().storage.status, 'ready');
|
||||
});
|
||||
|
||||
test('journal page parser rejects malformed wire data and strips unknown event fields', (t) => {
|
||||
const service = createActivityJournalService({ filePath: fixture(t) });
|
||||
service.append(event('wire'));
|
||||
const page = service.page();
|
||||
const parsed = assertActivityJournalPage({
|
||||
...page,
|
||||
events: page.events.map((item) => ({ ...item, ignored: 'value' })),
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed.events[0], 'ignored'), false);
|
||||
assert.throws(() => assertActivityJournalPage({ ...page, retentionDays: 31 }), TypeError);
|
||||
assert.throws(() => assertActivityJournalPage({ ...page, events: [{ broken: true }] }), TypeError);
|
||||
const future = assertActivityJournalPage({
|
||||
...page,
|
||||
events: [{ ...page.events[0], type: 'future.safe_event', data: { raw: 'not exposed' } }],
|
||||
});
|
||||
assert.equal(future.events[0].type, 'unknown');
|
||||
assert.deepEqual(future.events[0].data, {});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ function createHarness(overrides = {}) {
|
||||
let stateUpdates = 0;
|
||||
let startFailure = overrides.failStart || null;
|
||||
const events = [];
|
||||
const journalEvents = [];
|
||||
|
||||
const serialize = (operation) => {
|
||||
const run = async () => {
|
||||
@@ -119,13 +120,16 @@ function createHarness(overrides = {}) {
|
||||
restartCommand: () => captureRuntimeCommand(restart, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
|
||||
},
|
||||
route: { isGatewayDirect: () => overrides.gatewayDirect === true },
|
||||
failover: overrides.failover,
|
||||
serialize,
|
||||
now: () => new Date('2026-08-08T12:00:00.000Z'),
|
||||
onEvent: (event) => journalEvents.push(event),
|
||||
});
|
||||
|
||||
return {
|
||||
service,
|
||||
events,
|
||||
journalEvents,
|
||||
serialize,
|
||||
snapshot: () => structuredClone({ state, config, running, peak }),
|
||||
};
|
||||
@@ -167,6 +171,8 @@ test('failed candidate config or runtime restores the old profile, config and ru
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.apply('work', 'b'));
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
assert.equal(harness.journalEvents.at(-1).type, 'connection.failed');
|
||||
assert.match(harness.journalEvents.at(-1).data.errorCode, /^[A-Z0-9_]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -256,6 +262,193 @@ test('running restart preserves a pending desired profile while restoring the ap
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
||||
});
|
||||
|
||||
test('running dual restart rebuilds only the applied failover pair', async () => {
|
||||
const state = initialState();
|
||||
state.failoverPolicy = { enabled: true };
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: { profileId: 'personal', serverId: 'a' },
|
||||
reserve: { profileId: 'work', serverId: 'b' },
|
||||
};
|
||||
const sources = [];
|
||||
const prepared = [];
|
||||
const harness = createHarness({
|
||||
state,
|
||||
failover: {
|
||||
build: (_state, source) => {
|
||||
sources.push(source);
|
||||
return {
|
||||
config: { source },
|
||||
applied: state.appliedFailoverPolicy,
|
||||
primaryProfile: state.profiles[0],
|
||||
primaryServer: serverA,
|
||||
};
|
||||
},
|
||||
prepareActivation: async (role) => { prepared.push(role); harness.events.push(`selector.${role}`); },
|
||||
restoreAppliedActivation: async () => {},
|
||||
reconcile: async () => {},
|
||||
},
|
||||
});
|
||||
await harness.service.restart();
|
||||
assert.deepEqual(sources, ['applied']);
|
||||
assert.deepEqual(prepared, ['primary']);
|
||||
assert.deepEqual(harness.events.slice(0, 4), ['config.write', 'runtime.restart', 'selector.primary', 'state.update']);
|
||||
assert.match(harness.snapshot().config, /"source":"applied"/);
|
||||
});
|
||||
|
||||
test('running single-channel keeps server edits pending after failover is enabled', async () => {
|
||||
const state = initialState();
|
||||
state.failoverPolicy = { enabled: true };
|
||||
state.appliedFailoverPolicy = null;
|
||||
const harness = createHarness({ state });
|
||||
await harness.service.apply('work', 'b');
|
||||
assert.equal(harness.snapshot().state.desiredProfileId, 'work');
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
||||
assert.equal(harness.events.includes('config.write'), false);
|
||||
assert.equal(harness.events.includes('runtime.start'), false);
|
||||
});
|
||||
|
||||
test('running dual restart preserves reserve and commits only after selector read-back', async () => {
|
||||
const state = initialState();
|
||||
state.failoverPolicy = { enabled: true };
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: { profileId: 'personal', serverId: 'a' },
|
||||
reserve: { profileId: 'work', serverId: 'b' },
|
||||
};
|
||||
state.appliedProfileId = 'work';
|
||||
state.appliedServerId = 'b';
|
||||
state.appliedServerSnapshot = serverB;
|
||||
const harness = createHarness({
|
||||
state,
|
||||
failover: {
|
||||
build: () => ({
|
||||
config: { dual: true },
|
||||
applied: state.appliedFailoverPolicy,
|
||||
primaryProfile: state.profiles[0],
|
||||
primaryServer: serverA,
|
||||
}),
|
||||
prepareActivation: async (role) => { harness.events.push(`selector.${role}`); },
|
||||
restoreAppliedActivation: async () => { harness.events.push('selector.primary'); },
|
||||
reconcile: async () => {},
|
||||
},
|
||||
});
|
||||
await harness.service.restart();
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'work');
|
||||
assert.equal(harness.snapshot().state.appliedServerId, 'b');
|
||||
assert.deepEqual(harness.events.slice(0, 4), ['config.write', 'runtime.restart', 'selector.reserve', 'state.update']);
|
||||
});
|
||||
|
||||
test('selector activation failure rolls runtime and config back before applied truth changes', async () => {
|
||||
const state = initialState();
|
||||
state.failoverPolicy = { enabled: true };
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: { profileId: 'personal', serverId: 'a' },
|
||||
reserve: { profileId: 'work', serverId: 'b' },
|
||||
};
|
||||
const harness = createHarness({
|
||||
state,
|
||||
failover: {
|
||||
build: () => ({
|
||||
config: { dual: true },
|
||||
applied: state.appliedFailoverPolicy,
|
||||
primaryProfile: state.profiles[0],
|
||||
primaryServer: serverA,
|
||||
}),
|
||||
prepareActivation: async (role) => {
|
||||
harness.events.push(`selector.${role}`);
|
||||
if (harness.events.filter((event) => event.startsWith('selector.')).length === 1) {
|
||||
throw new Error('selector failed');
|
||||
}
|
||||
},
|
||||
restoreAppliedActivation: async () => { harness.events.push('selector.primary'); },
|
||||
reconcile: async () => {},
|
||||
},
|
||||
});
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.restart(), /selector failed/);
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
assert.equal(harness.events.includes('state.update'), false);
|
||||
assert.deepEqual(harness.events.filter((event) => event.startsWith('selector.')), ['selector.primary', 'selector.primary']);
|
||||
});
|
||||
|
||||
test('state commit rollback restores the previously selected reserve role', async () => {
|
||||
const state = initialState();
|
||||
state.failoverPolicy = { enabled: true };
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: { profileId: 'personal', serverId: 'a' },
|
||||
reserve: { profileId: 'work', serverId: 'b' },
|
||||
};
|
||||
state.appliedProfileId = 'work';
|
||||
state.appliedServerId = 'b';
|
||||
state.appliedServerSnapshot = serverB;
|
||||
const harness = createHarness({
|
||||
state,
|
||||
failStateAt: 1,
|
||||
failover: {
|
||||
build: () => ({
|
||||
config: { dual: true },
|
||||
applied: state.appliedFailoverPolicy,
|
||||
primaryProfile: state.profiles[0],
|
||||
primaryServer: serverA,
|
||||
}),
|
||||
prepareActivation: async (role) => { harness.events.push(`selector.${role}`); },
|
||||
restoreAppliedActivation: async () => { harness.events.push('selector.reserve'); },
|
||||
reconcile: async () => {},
|
||||
},
|
||||
});
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.restart(), /state failed/);
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
assert.deepEqual(harness.events.filter((event) => event.startsWith('selector.')), ['selector.reserve', 'selector.reserve']);
|
||||
});
|
||||
|
||||
test('failed stop restores the selected reserve role before restoring state', async () => {
|
||||
const state = initialState();
|
||||
state.failoverPolicy = { enabled: true };
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: { profileId: 'personal', serverId: 'a' },
|
||||
reserve: { profileId: 'work', serverId: 'b' },
|
||||
};
|
||||
state.appliedProfileId = 'work';
|
||||
state.appliedServerId = 'b';
|
||||
state.appliedServerSnapshot = serverB;
|
||||
const harness = createHarness({
|
||||
state,
|
||||
failStateAt: 1,
|
||||
failover: {
|
||||
restoreAppliedActivation: async () => { harness.events.push('selector.reserve'); },
|
||||
reconcile: async () => {},
|
||||
},
|
||||
});
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.stop(), /state failed/);
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
assert.deepEqual(harness.events.slice(0, 4), ['runtime.stop', 'state.update', 'runtime.start', 'selector.reserve']);
|
||||
});
|
||||
|
||||
test('disabled passive dual rollback restores the selected reserve role', async () => {
|
||||
const state = initialState();
|
||||
state.failoverPolicy = { enabled: false };
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: { profileId: 'personal', serverId: 'a' },
|
||||
reserve: { profileId: 'work', serverId: 'b' },
|
||||
};
|
||||
state.appliedProfileId = 'work';
|
||||
state.appliedServerId = 'b';
|
||||
state.appliedServerSnapshot = serverB;
|
||||
const harness = createHarness({
|
||||
state,
|
||||
failStateAt: 1,
|
||||
failover: {
|
||||
restoreAppliedActivation: async () => { harness.events.push('selector.reserve'); },
|
||||
reconcile: async () => {},
|
||||
},
|
||||
});
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.restart(), /state failed/);
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
assert.equal(harness.events.includes('selector.reserve'), true);
|
||||
});
|
||||
|
||||
test('restart fails closed when runtime status is unknown', async () => {
|
||||
const state = initialState();
|
||||
state.desiredProfileId = 'work';
|
||||
|
||||
@@ -506,3 +506,18 @@ test('a targeted custom row validates and samples only that service', async () =
|
||||
assert.deepEqual(calls, ['https://second.example/', 'https://second.example/', 'https://second.example/']);
|
||||
assert.equal(result.direct.sites[0].latencyMs, 120);
|
||||
});
|
||||
|
||||
test('failover VPN checks apply their per-service timeout to curl', async () => {
|
||||
const calls = [];
|
||||
await createConnectivityDiagnosticsService({
|
||||
proxyPort: 18080,
|
||||
execute: async (args) => { calls.push(args); return response(); },
|
||||
}).runVpn({ target: 'site:youtube', timeoutMs: 9_000 });
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
for (const args of calls) {
|
||||
assert.equal(args[args.indexOf('--max-time') + 1], '9');
|
||||
assert.equal(args[args.indexOf('--connect-timeout') + 1], '3');
|
||||
assert.ok(args.includes('http://127.0.0.1:18080'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,6 +32,12 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
[{ id: 'custom-test', url: 'https://example.com' }],
|
||||
'site:custom-test',
|
||||
);
|
||||
await client.checkConfig({ outbounds: [] });
|
||||
await client.runFailoverProbe('primary', [], 'site:youtube', 9_000);
|
||||
await client.readFailoverSelector();
|
||||
await client.selectFailoverRole('reserve');
|
||||
await client.setFailoverActivityEnabled(true);
|
||||
await client.readFailoverActivity(1024);
|
||||
assert.equal(client.running, true);
|
||||
await client.restart();
|
||||
assert.equal((await client.stop()).running, false);
|
||||
@@ -44,6 +50,12 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
'GET /device-policy /run/dataplane.sock',
|
||||
'PUT /device-policy /run/dataplane.sock',
|
||||
'POST /diagnostics/connectivity /run/dataplane.sock',
|
||||
'POST /config/check /run/dataplane.sock',
|
||||
'POST /failover/probe /run/dataplane.sock',
|
||||
'GET /failover/selector /run/dataplane.sock',
|
||||
'PUT /failover/selector /run/dataplane.sock',
|
||||
'PUT /failover/activity /run/dataplane.sock',
|
||||
'POST /failover/activity/read /run/dataplane.sock',
|
||||
'POST /restart /run/dataplane.sock',
|
||||
'POST /stop /run/dataplane.sock',
|
||||
]);
|
||||
@@ -53,6 +65,10 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
target: 'site:custom-test',
|
||||
});
|
||||
assert.equal(requests[7].timeoutMs, 25_000);
|
||||
assert.deepEqual(requests[8].body, { config: { outbounds: [] } });
|
||||
assert.deepEqual(requests[9].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
||||
assert.equal(requests[9].timeoutMs, 19_000);
|
||||
assert.deepEqual(requests[11].body, { role: 'reserve' });
|
||||
});
|
||||
|
||||
test('connectivity diagnostics expose a retryable domain error', async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { deviceId } from '../../dist/server/services/deviceInventoryService.js';
|
||||
|
||||
const mac = '00:11:22:33:44:55';
|
||||
const id = deviceId(mac);
|
||||
const device = { ip: '192.168.50.7', mac };
|
||||
const device = { ip: '192.168.50.7', mac, alias: 'MacBook' };
|
||||
const connection = (connectionId, type, host, upload, download, sourceIP = device.ip, chains = ['vpn-out']) => ({
|
||||
id: connectionId,
|
||||
metadata: { type, host, sourceIP },
|
||||
@@ -205,3 +205,36 @@ test('domain classification normalizes known services and rejects IP or malforme
|
||||
assert.equal(classifyDomain('192.0.2.1'), null);
|
||||
assert.equal(classifyDomain('broken_label.example'), null);
|
||||
});
|
||||
|
||||
test('failover activity is zero-work while disabled and uses the existing connection poll', async () => {
|
||||
let observedAt = new Date('2026-08-19T10:00:00.000Z');
|
||||
let response = { connections: [
|
||||
connection('work', 'tproxy/tproxy-in', 'r1.googlevideo.com', 0, 0, device.ip, ['channel-primary', 'channel-selector']),
|
||||
connection('probe', 'mixed/diagnostics-primary-in', 'youtube.com', 0, 10_000, device.ip, ['channel-primary']),
|
||||
] };
|
||||
const service = createDomainTrafficService({
|
||||
observe: async () => response,
|
||||
devices: () => [device],
|
||||
now: () => observedAt,
|
||||
});
|
||||
|
||||
await service.refresh();
|
||||
assert.equal(service.activitySnapshot(100), null);
|
||||
service.enableActivity();
|
||||
observedAt = new Date('2026-08-19T10:00:02.000Z');
|
||||
response.connections[0].download = 2_000;
|
||||
response.connections[1].download = 99_000;
|
||||
await service.refresh();
|
||||
const active = service.activitySnapshot(500);
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.totalBytesPerSecond, 1_000);
|
||||
assert.equal(active.transmittingConnections, 1);
|
||||
assert.equal(active.blockers[0].device, 'MacBook');
|
||||
assert.equal(active.blockers[0].service, 'YouTube');
|
||||
|
||||
observedAt = new Date('2026-08-19T10:00:14.000Z');
|
||||
await service.refresh();
|
||||
assert.equal(service.activitySnapshot(500).state, 'quiet');
|
||||
service.disableActivity();
|
||||
assert.equal(service.activitySnapshot(500), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createFailoverRoute } from '../../dist/server/http/routes/failoverRoute.js';
|
||||
|
||||
function request(method, url = '/api/failover') {
|
||||
return { method, url };
|
||||
}
|
||||
|
||||
test('Gateway failover route serializes save, pause, checks and manual switch mutations', async () => {
|
||||
const calls = [];
|
||||
let body = { policy: { enabled: true }, expectedRevision: 7 };
|
||||
const route = createFailoverRoute({
|
||||
appMode: 'gateway',
|
||||
failover: {
|
||||
save: async (value) => calls.push(['save', value]),
|
||||
pause: async (value) => calls.push(['pause', value]),
|
||||
manualSwitch: async (value) => calls.push(['switch', value]),
|
||||
checkNow: async () => calls.push(['check']),
|
||||
},
|
||||
readBody: async () => body,
|
||||
withOperation: async (kind, operation, options) => {
|
||||
calls.push(['operation', kind, options]);
|
||||
return operation();
|
||||
},
|
||||
sendState: async () => calls.push(['state']),
|
||||
});
|
||||
|
||||
assert.equal(await route.handle(request('PUT'), {}), true);
|
||||
body = { paused: true, expectedRevision: 8 };
|
||||
assert.equal(await route.handle(request('POST', '/api/failover/pause'), {}), true);
|
||||
body = { role: 'reserve', expectedRevision: 9 };
|
||||
assert.equal(await route.handle(request('POST', '/api/failover/switch'), {}), true);
|
||||
body = { expectedRevision: 10 };
|
||||
assert.equal(await route.handle(request('POST', '/api/failover/check'), {}), true);
|
||||
assert.deepEqual(calls, [
|
||||
['operation', 'failover-save', { expectedRevision: 7 }], ['save', { enabled: true }], ['state'],
|
||||
['operation', 'failover-pause', { expectedRevision: 8 }], ['pause', true], ['state'],
|
||||
['operation', 'failover-switch', { expectedRevision: 9 }], ['switch', 'reserve'], ['state'],
|
||||
['check'], ['state'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('failover mutations stay Gateway-only and reject unknown roles', async () => {
|
||||
const connectRoute = createFailoverRoute({
|
||||
appMode: 'client', failover: {}, readBody: async () => ({}), withOperation: async () => {}, sendState: async () => {},
|
||||
});
|
||||
await assert.rejects(connectRoute.handle(request('PUT'), {}), { code: 'ENDPOINT_NOT_FOUND' });
|
||||
|
||||
const gatewayRoute = createFailoverRoute({
|
||||
appMode: 'gateway', failover: {}, readBody: async () => ({ role: 'other' }), withOperation: async () => {}, sendState: async () => {},
|
||||
});
|
||||
await assert.rejects(gatewayRoute.handle(request('POST', '/api/failover/switch'), {}), { code: 'REQUEST_INVALID' });
|
||||
});
|
||||
@@ -0,0 +1,635 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_FAILOVER_POLICY,
|
||||
isFailoverConfigured,
|
||||
nextFailoverDecision,
|
||||
normalizeFailoverPolicy,
|
||||
} from '../../dist/shared/failover.js';
|
||||
import { createFailoverService } from '../../dist/server/features/failover/failoverService.js';
|
||||
|
||||
const enabled = normalizeFailoverPolicy({
|
||||
...DEFAULT_FAILOVER_POLICY,
|
||||
enabled: true,
|
||||
primary: { profileId: 'profile-1', serverId: 'server-1' },
|
||||
reserve: { profileId: 'profile-2', serverId: 'server-2' },
|
||||
intervalMs: 15_000,
|
||||
failureWindowMs: 30_000,
|
||||
recoveryWindowMs: 60_000,
|
||||
trafficGuard: { enabled: true, thresholdBytesPerSecond: 1024, quietWindowMs: 5_000 },
|
||||
minimumReserveMs: 60_000,
|
||||
});
|
||||
|
||||
test('failover policy defaults to strict disabled zero-work state', () => {
|
||||
const policy = normalizeFailoverPolicy(null);
|
||||
const decision = nextFailoverDecision({
|
||||
now: 0,
|
||||
policy,
|
||||
currentRole: 'primary',
|
||||
primaryHealth: 'unhealthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'quiet',
|
||||
});
|
||||
assert.equal(policy.enabled, false);
|
||||
assert.equal(decision.status, 'idle');
|
||||
assert.equal(decision.switchTo, null);
|
||||
});
|
||||
|
||||
test('failover save rejects policy values that would otherwise be silently clamped', async () => {
|
||||
const testHarness = harness(serviceState());
|
||||
await assert.rejects(testHarness.service.save({ ...enabled, intervalMs: -1 }), { code: 'REQUEST_INVALID' });
|
||||
});
|
||||
|
||||
test('strict failover validation accepts semantically identical reordered JSON keys', () => {
|
||||
const reordered = {
|
||||
flapProtection: enabled.flapProtection,
|
||||
minimumReserveMs: enabled.minimumReserveMs,
|
||||
trafficGuard: enabled.trafficGuard,
|
||||
recoveryWindowMs: enabled.recoveryWindowMs,
|
||||
failureWindowMs: enabled.failureWindowMs,
|
||||
intervalMs: enabled.intervalMs,
|
||||
checks: enabled.checks,
|
||||
reserve: enabled.reserve,
|
||||
primary: enabled.primary,
|
||||
paused: enabled.paused,
|
||||
enabled: enabled.enabled,
|
||||
version: enabled.version,
|
||||
};
|
||||
assert.deepEqual(normalizeFailoverPolicy(reordered, { strict: true }), enabled);
|
||||
});
|
||||
|
||||
test('enabled failover requires two distinct existing targets', async () => {
|
||||
const sameTarget = normalizeFailoverPolicy({ ...enabled, reserve: enabled.primary });
|
||||
assert.equal(isFailoverConfigured(sameTarget), false);
|
||||
await assert.rejects(harness(serviceState()).service.save(sameTarget), { code: 'REQUEST_INVALID' });
|
||||
|
||||
const state = serviceState({ ...enabled, paused: true });
|
||||
state.profiles[1].servers = [];
|
||||
const testHarness = harness(state);
|
||||
await assert.rejects(testHarness.service.pause(false), { code: 'REQUEST_INVALID' });
|
||||
assert.equal(testHarness.read().failoverPolicy.paused, true);
|
||||
});
|
||||
|
||||
test('local Gateway activity uses the existing domain traffic collector only while enabled', () => {
|
||||
const source = fs.readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /const localFailoverTraffic = !remoteDataplane && settings\.appMode === 'gateway'[\s\S]*createDomainTrafficService/);
|
||||
assert.match(source, /setLocalFailoverActivityEnabled[\s\S]*enableActivity\(\)[\s\S]*setInterval\(refresh, 2_000\)/);
|
||||
assert.match(source, /if \(!enabled\)[\s\S]*clearInterval\(localFailoverTrafficTimer\)[\s\S]*disableActivity\(\)/);
|
||||
assert.doesNotMatch(source, /setFailoverActivityEnabled: async \(\) => \(\{\}\)/);
|
||||
});
|
||||
|
||||
test('failure window and traffic guard delay reserve switch until continuous quiet', () => {
|
||||
let decision = nextFailoverDecision({
|
||||
now: 0,
|
||||
policy: enabled,
|
||||
currentRole: 'primary',
|
||||
primaryHealth: 'unhealthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'active',
|
||||
});
|
||||
decision = nextFailoverDecision({
|
||||
now: 30_000,
|
||||
policy: enabled,
|
||||
currentRole: 'primary',
|
||||
primaryHealth: 'unhealthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'active',
|
||||
memory: decision.memory,
|
||||
});
|
||||
assert.equal(decision.status, 'waiting-for-idle');
|
||||
decision = nextFailoverDecision({
|
||||
now: 31_000,
|
||||
policy: enabled,
|
||||
currentRole: 'primary',
|
||||
primaryHealth: 'unhealthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'quiet',
|
||||
memory: decision.memory,
|
||||
});
|
||||
assert.equal(decision.switchTo, null);
|
||||
decision = nextFailoverDecision({
|
||||
now: 36_000,
|
||||
policy: enabled,
|
||||
currentRole: 'primary',
|
||||
primaryHealth: 'unhealthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'quiet',
|
||||
memory: decision.memory,
|
||||
});
|
||||
assert.equal(decision.switchTo, 'reserve');
|
||||
});
|
||||
|
||||
test('both unhealthy and unknown activity never cause a switch', () => {
|
||||
const both = nextFailoverDecision({
|
||||
now: 30_000,
|
||||
policy: enabled,
|
||||
currentRole: 'primary',
|
||||
primaryHealth: 'unhealthy',
|
||||
reserveHealth: 'unhealthy',
|
||||
activity: 'quiet',
|
||||
});
|
||||
assert.equal(both.reason, 'both-unhealthy');
|
||||
assert.equal(both.switchTo, null);
|
||||
|
||||
const unknown = nextFailoverDecision({
|
||||
now: 30_000,
|
||||
policy: enabled,
|
||||
currentRole: 'primary',
|
||||
primaryHealth: 'unhealthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'unknown',
|
||||
memory: { primaryFailedSince: 0, primaryRecoveredSince: null, quietSince: null },
|
||||
});
|
||||
assert.equal(unknown.reason, 'activity-unknown');
|
||||
assert.equal(unknown.switchTo, null);
|
||||
});
|
||||
|
||||
test('failback waits for recovery, hold and quarantine deadlines', () => {
|
||||
let decision = nextFailoverDecision({
|
||||
now: 10_000,
|
||||
policy: enabled,
|
||||
currentRole: 'reserve',
|
||||
primaryHealth: 'healthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'quiet',
|
||||
holdUntil: 50_000,
|
||||
primaryQuarantineUntil: 70_000,
|
||||
});
|
||||
assert.equal(decision.nextDecisionAt, 70_000);
|
||||
decision = nextFailoverDecision({
|
||||
now: 70_000,
|
||||
policy: enabled,
|
||||
currentRole: 'reserve',
|
||||
primaryHealth: 'healthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'quiet',
|
||||
holdUntil: 50_000,
|
||||
primaryQuarantineUntil: 70_000,
|
||||
memory: decision.memory,
|
||||
});
|
||||
assert.equal(decision.switchTo, null);
|
||||
assert.equal(decision.nextDecisionAt, 130_000);
|
||||
decision = nextFailoverDecision({
|
||||
now: 130_000,
|
||||
policy: enabled,
|
||||
currentRole: 'reserve',
|
||||
primaryHealth: 'healthy',
|
||||
reserveHealth: 'healthy',
|
||||
activity: 'quiet',
|
||||
holdUntil: 50_000,
|
||||
primaryQuarantineUntil: 70_000,
|
||||
memory: { ...decision.memory, quietSince: 125_000 },
|
||||
});
|
||||
assert.equal(decision.switchTo, 'primary');
|
||||
|
||||
const deadReserve = nextFailoverDecision({
|
||||
now: 70_000,
|
||||
policy: enabled,
|
||||
currentRole: 'reserve',
|
||||
primaryHealth: 'healthy',
|
||||
reserveHealth: 'unhealthy',
|
||||
activity: 'quiet',
|
||||
holdUntil: 500_000,
|
||||
primaryQuarantineUntil: 700_000,
|
||||
memory: { primaryFailedSince: null, primaryRecoveredSince: 10_000, quietSince: 65_000 },
|
||||
});
|
||||
assert.equal(deadReserve.switchTo, null);
|
||||
assert.equal(deadReserve.nextDecisionAt, 700_000);
|
||||
});
|
||||
|
||||
function serviceState(policy = enabled) {
|
||||
return {
|
||||
revision: 1,
|
||||
failoverPolicy: policy,
|
||||
failoverRuntimeState: { lastSwitchAt: null, holdUntil: null, primaryQuarantineUntil: null, failoverHistory: [], reasonCode: null },
|
||||
appliedFailoverPolicy: {
|
||||
primary: { profileId: 'profile-1', serverId: 'server-1' },
|
||||
reserve: { profileId: 'profile-2', serverId: 'server-2' },
|
||||
primaryConfigFingerprint: 'a'.repeat(64),
|
||||
reserveConfigFingerprint: 'b'.repeat(64),
|
||||
},
|
||||
appliedProfileId: 'profile-1',
|
||||
appliedServerId: 'server-1',
|
||||
profiles: [
|
||||
{ id: 'profile-1', servers: [{ id: 'server-1', label: 'Primary' }] },
|
||||
{ id: 'profile-2', servers: [{ id: 'server-2', label: 'Reserve' }] },
|
||||
],
|
||||
diagnostics: { customServices: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function harness(initial = serviceState(), overrides = {}) {
|
||||
let state = structuredClone(initial);
|
||||
const calls = [];
|
||||
const dependencies = {
|
||||
state: {
|
||||
read: () => state,
|
||||
update: (mutator) => {
|
||||
state = { ...mutator(state), revision: state.revision + 1 };
|
||||
calls.push('state:update');
|
||||
return state;
|
||||
},
|
||||
},
|
||||
runtime: { isRunning: async () => true },
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async () => ({ vpn: { sites: [{ status: 'available' }] } }),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => { calls.push(`select:${role}`); return { role }; },
|
||||
setFailoverActivityEnabled: async (value) => { calls.push(`activity:${value}`); },
|
||||
readFailoverActivity: async () => ({ activity: { state: 'quiet', observedAt: new Date().toISOString() } }),
|
||||
},
|
||||
buildCandidate: () => ({ config: {}, applied: initial.appliedFailoverPolicy }),
|
||||
serialize: (operation) => operation(),
|
||||
scheduler: {
|
||||
setTimeout: () => ({ unref() {} }),
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { service: createFailoverService(dependencies), calls, read: () => state };
|
||||
}
|
||||
|
||||
test('disabled failover performs one restart-safe collector cleanup and no steady-state work', async () => {
|
||||
const state = serviceState(normalizeFailoverPolicy(null));
|
||||
state.appliedFailoverPolicy = null;
|
||||
const testHarness = harness(state);
|
||||
await testHarness.service.reconcile();
|
||||
assert.deepEqual(testHarness.calls, ['activity:false']);
|
||||
await testHarness.service.reconcile();
|
||||
assert.deepEqual(testHarness.calls, ['activity:false']);
|
||||
assert.equal(testHarness.service.snapshot().activation, 'inactive');
|
||||
});
|
||||
|
||||
test('disabled failover reports an already loaded dual config without managing it', async () => {
|
||||
const testHarness = harness(serviceState(normalizeFailoverPolicy(null)));
|
||||
await testHarness.service.reconcile();
|
||||
assert.deepEqual(testHarness.calls, ['activity:false']);
|
||||
assert.equal(testHarness.service.snapshot().activation, 'passive-loaded');
|
||||
assert.equal(testHarness.service.snapshot().currentRole, 'primary');
|
||||
});
|
||||
|
||||
test('runtime rollback restores the role from applied truth even while disabled', async () => {
|
||||
const state = serviceState(normalizeFailoverPolicy(null));
|
||||
state.appliedProfileId = state.appliedFailoverPolicy.reserve.profileId;
|
||||
state.appliedServerId = state.appliedFailoverPolicy.reserve.serverId;
|
||||
const testHarness = harness(state);
|
||||
await testHarness.service.restoreAppliedActivation(state);
|
||||
assert.deepEqual(testHarness.calls, ['select:reserve']);
|
||||
});
|
||||
|
||||
test('an in-flight observation is discarded after failover is disabled', async () => {
|
||||
const pending = [];
|
||||
const testHarness = harness(serviceState(), {
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: (role) => new Promise((resolve) => pending.push({ role, resolve })),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => { testHarness.calls.push(`select:${role}`); return { role }; },
|
||||
setFailoverActivityEnabled: async (value) => { testHarness.calls.push(`activity:${value}`); },
|
||||
readFailoverActivity: async () => ({ activity: { state: 'quiet', observedAt: new Date().toISOString() } }),
|
||||
},
|
||||
});
|
||||
await testHarness.service.reconcile();
|
||||
const round = testHarness.service.runRound();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await testHarness.service.save({ ...enabled, enabled: false });
|
||||
for (const probe of pending) probe.resolve({ vpn: { sites: [{ status: probe.role === 'reserve' ? 'available' : 'unavailable' }] } });
|
||||
await round;
|
||||
assert.equal(testHarness.read().failoverPolicy.enabled, false);
|
||||
assert.equal(testHarness.calls.some((call) => call.startsWith('select:reserve')), false);
|
||||
assert.deepEqual(testHarness.calls.filter((call) => call.startsWith('activity:')), ['activity:true', 'activity:true', 'activity:false']);
|
||||
});
|
||||
|
||||
test('one channel probe failure does not erase the other channel health', async () => {
|
||||
const testHarness = harness(serviceState(), {
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async (role) => {
|
||||
if (role === 'primary') throw new Error('primary probe transport failed');
|
||||
return { vpn: { sites: [{ status: 'available' }] } };
|
||||
},
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => ({ role }),
|
||||
setFailoverActivityEnabled: async () => {},
|
||||
readFailoverActivity: async () => ({ activity: { state: 'active', observedAt: new Date().toISOString() } }),
|
||||
},
|
||||
});
|
||||
await testHarness.service.checkNow();
|
||||
assert.equal(testHarness.service.snapshot().primary.health, 'unknown');
|
||||
assert.equal(testHarness.service.snapshot().reserve.health, 'healthy');
|
||||
});
|
||||
|
||||
test('monitoring wakes at an earlier decision deadline instead of waiting a full interval', async () => {
|
||||
let clock = 0;
|
||||
const delays = [];
|
||||
const testHarness = harness(serviceState(), {
|
||||
now: () => new Date(clock),
|
||||
scheduler: {
|
||||
setTimeout: (_callback, delay) => { delays.push(delay); return { unref() {} }; },
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async (role) => ({ vpn: { sites: [{ status: role === 'primary' ? 'unavailable' : 'available' }] } }),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => ({ role }),
|
||||
setFailoverActivityEnabled: async () => {},
|
||||
readFailoverActivity: async () => ({ activity: { state: 'quiet', observedAt: new Date(clock).toISOString() } }),
|
||||
},
|
||||
});
|
||||
await testHarness.service.reconcile();
|
||||
await testHarness.service.runRound();
|
||||
clock = 30_000;
|
||||
await testHarness.service.runRound();
|
||||
assert.equal(delays.at(-1), enabled.trafficGuard.quietWindowMs);
|
||||
});
|
||||
|
||||
test('a late activity response cannot switch after failover is disabled', async () => {
|
||||
let resolveActivity;
|
||||
const activity = new Promise((resolve) => { resolveActivity = resolve; });
|
||||
const testHarness = harness(serviceState(), {
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async (role) => ({ vpn: { sites: [{ status: role === 'primary' ? 'unavailable' : 'available' }] } }),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => { testHarness.calls.push(`select:${role}`); return { role }; },
|
||||
setFailoverActivityEnabled: async (value) => { testHarness.calls.push(`activity:${value}`); },
|
||||
readFailoverActivity: async () => activity,
|
||||
},
|
||||
});
|
||||
await testHarness.service.reconcile();
|
||||
const round = testHarness.service.runRound();
|
||||
await new Promise(setImmediate);
|
||||
await testHarness.service.save({ ...enabled, enabled: false });
|
||||
resolveActivity({ activity: { state: 'quiet', observedAt: new Date().toISOString() } });
|
||||
await round;
|
||||
assert.equal(testHarness.calls.some((value) => value.startsWith('select:')), false);
|
||||
});
|
||||
|
||||
test('automatic switch revalidates activity immediately before selector mutation', async () => {
|
||||
let clock = 0;
|
||||
let activityReads = 0;
|
||||
const testHarness = harness(serviceState(), {
|
||||
now: () => new Date(clock),
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async (role) => ({ vpn: { sites: [{ status: role === 'primary' ? 'unavailable' : 'available' }] } }),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => { testHarness.calls.push(`select:${role}`); return { role }; },
|
||||
setFailoverActivityEnabled: async () => {},
|
||||
readFailoverActivity: async () => ({
|
||||
activity: {
|
||||
state: ++activityReads >= 4 ? 'active' : 'quiet',
|
||||
observedAt: new Date(clock).toISOString(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await testHarness.service.reconcile();
|
||||
await testHarness.service.runRound();
|
||||
clock = 30_000;
|
||||
await testHarness.service.runRound();
|
||||
clock = 35_000;
|
||||
await testHarness.service.runRound();
|
||||
assert.equal(testHarness.calls.some((value) => value.startsWith('select:')), false);
|
||||
assert.equal(testHarness.service.snapshot().reason, 'revalidation-required');
|
||||
});
|
||||
|
||||
test('selector read-back failure rolls an uncertain switch back to the canonical role', async () => {
|
||||
const testHarness = harness(serviceState(), {
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async () => ({}),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => {
|
||||
testHarness.calls.push(`select:${role}`);
|
||||
if (role === 'reserve') throw new Error('read-back failed');
|
||||
return { role };
|
||||
},
|
||||
setFailoverActivityEnabled: async () => {},
|
||||
readFailoverActivity: async () => ({ activity: null }),
|
||||
},
|
||||
});
|
||||
await assert.rejects(testHarness.service.manualSwitch('reserve'), /read-back failed/);
|
||||
assert.deepEqual(testHarness.calls, ['select:reserve', 'select:primary']);
|
||||
assert.equal(testHarness.read().appliedServerId, 'server-1');
|
||||
});
|
||||
|
||||
test('failed collector disable is retried because local state remains unknown', async () => {
|
||||
let attempts = 0;
|
||||
const testHarness = harness(serviceState(normalizeFailoverPolicy(null)), {
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async () => ({}),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async () => ({}),
|
||||
setFailoverActivityEnabled: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('dataplane unavailable');
|
||||
},
|
||||
readFailoverActivity: async () => ({ activity: null }),
|
||||
},
|
||||
});
|
||||
await assert.rejects(testHarness.service.reconcile(), /dataplane unavailable/);
|
||||
await testHarness.service.reconcile();
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('each monitoring round repairs selector and collector state after a dataplane restart', async () => {
|
||||
const state = serviceState();
|
||||
state.appliedProfileId = 'profile-2';
|
||||
state.appliedServerId = 'server-2';
|
||||
let selected = 'reserve';
|
||||
let enables = 0;
|
||||
const testHarness = harness(state, {
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async () => ({ vpn: { sites: [{ status: 'available' }] } }),
|
||||
readFailoverSelector: async () => ({ role: selected }),
|
||||
selectFailoverRole: async (role) => { selected = role; return { role }; },
|
||||
setFailoverActivityEnabled: async (value) => { if (value) enables += 1; },
|
||||
readFailoverActivity: async () => ({ activity: { state: 'quiet', observedAt: new Date().toISOString() } }),
|
||||
},
|
||||
});
|
||||
await testHarness.service.reconcile();
|
||||
selected = 'primary';
|
||||
await testHarness.service.runRound();
|
||||
assert.equal(selected, 'reserve');
|
||||
assert.equal(enables, 2);
|
||||
});
|
||||
|
||||
test('selector acknowledgement precedes state commit and a failed commit rolls selector back', async () => {
|
||||
const state = serviceState();
|
||||
const calls = [];
|
||||
const testHarness = harness(state, {
|
||||
state: {
|
||||
read: () => state,
|
||||
update: (mutator) => {
|
||||
const candidate = mutator(state);
|
||||
calls.push('state:update');
|
||||
if (candidate.appliedServerId === 'server-2') throw new Error('write failed');
|
||||
Object.assign(state, candidate);
|
||||
return state;
|
||||
},
|
||||
},
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async () => ({}),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => { calls.push(`select:${role}`); return { role }; },
|
||||
setFailoverActivityEnabled: async () => {},
|
||||
readFailoverActivity: async () => ({ activity: null }),
|
||||
},
|
||||
});
|
||||
await assert.rejects(testHarness.service.manualSwitch('reserve'), /write failed/);
|
||||
assert.deepEqual(calls, ['select:reserve', 'state:update', 'select:primary']);
|
||||
assert.equal(state.appliedServerId, 'server-1');
|
||||
});
|
||||
|
||||
test('manual selector and pause publish in one canonical commit', async () => {
|
||||
const testHarness = harness(serviceState());
|
||||
await testHarness.service.manualSwitch('reserve');
|
||||
assert.equal(testHarness.read().appliedServerId, 'server-2');
|
||||
assert.equal(testHarness.read().failoverPolicy.paused, true);
|
||||
assert.equal(testHarness.calls.filter((call) => call === 'state:update').length, 1);
|
||||
});
|
||||
|
||||
test('post-commit monitoring failure does not report a committed pause as failed', async () => {
|
||||
const warnings = [];
|
||||
const testHarness = harness(serviceState(), {
|
||||
runtime: { isRunning: async () => { throw new Error('runtime unavailable'); } },
|
||||
onWarning: (error) => warnings.push(error.message),
|
||||
});
|
||||
await testHarness.service.pause(true);
|
||||
assert.equal(testHarness.read().failoverPolicy.paused, true);
|
||||
assert.equal(testHarness.service.snapshot().status, 'error');
|
||||
assert.deepEqual(warnings, ['runtime unavailable']);
|
||||
});
|
||||
|
||||
test('pause and resume discard stale failure-window evidence', async () => {
|
||||
let clock = 1_000;
|
||||
const testHarness = harness(serviceState(), {
|
||||
now: () => new Date(clock),
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async (role) => ({
|
||||
vpn: { sites: [{ status: role === 'primary' ? 'unavailable' : 'available' }] },
|
||||
}),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => { testHarness.calls.push(`select:${role}`); return { role }; },
|
||||
setFailoverActivityEnabled: async (value) => { testHarness.calls.push(`activity:${value}`); },
|
||||
readFailoverActivity: async () => ({
|
||||
activity: { state: 'quiet', observedAt: new Date(clock).toISOString(), quietSince: new Date(clock - 10_000).toISOString() },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await testHarness.service.runRound();
|
||||
await testHarness.service.pause(true);
|
||||
clock = 3_600_000;
|
||||
await testHarness.service.pause(false);
|
||||
await testHarness.service.runRound();
|
||||
assert.equal(testHarness.calls.includes('select:reserve'), false);
|
||||
assert.equal(testHarness.service.snapshot().reason, 'failure-window');
|
||||
});
|
||||
|
||||
test('an unconfirmed selector rollback durably pauses automation', async () => {
|
||||
const testHarness = harness(serviceState(), {
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async () => ({}),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async () => { throw new Error('selector unavailable'); },
|
||||
setFailoverActivityEnabled: async () => {},
|
||||
readFailoverActivity: async () => ({ activity: null }),
|
||||
},
|
||||
});
|
||||
await assert.rejects(testHarness.service.manualSwitch('reserve'), AggregateError);
|
||||
assert.equal(testHarness.read().failoverPolicy.paused, true);
|
||||
assert.equal(testHarness.read().failoverRuntimeState.reasonCode, 'selector-unknown');
|
||||
assert.equal(testHarness.service.snapshot().reason, 'selector-unknown');
|
||||
assert.equal(testHarness.service.snapshot().currentRole, 'other');
|
||||
});
|
||||
|
||||
test('successful selector reconciliation clears an unknown-role latch', async () => {
|
||||
const state = serviceState();
|
||||
state.failoverRuntimeState.reasonCode = 'selector-unknown';
|
||||
const testHarness = harness(state);
|
||||
await testHarness.service.reconcile();
|
||||
assert.equal(testHarness.read().failoverRuntimeState.reasonCode, null);
|
||||
assert.equal(testHarness.service.snapshot().currentRole, 'primary');
|
||||
});
|
||||
|
||||
test('disabling on reserve preserves it as the next ordinary single-channel target', async () => {
|
||||
const state = serviceState();
|
||||
state.appliedProfileId = 'profile-2';
|
||||
state.appliedServerId = 'server-2';
|
||||
const testHarness = harness(state);
|
||||
await testHarness.service.save({ ...enabled, enabled: false });
|
||||
assert.equal(testHarness.read().desiredProfileId, 'profile-2');
|
||||
assert.equal(testHarness.read().profiles[1].desiredServerId, 'server-2');
|
||||
assert.equal(testHarness.service.snapshot().activation, 'passive-loaded');
|
||||
});
|
||||
|
||||
test('significant health states append once without logging every observation', async () => {
|
||||
let clock = 0;
|
||||
const health = { primary: 'unavailable', reserve: 'available' };
|
||||
const events = [];
|
||||
const testHarness = harness(serviceState(), {
|
||||
now: () => new Date(clock),
|
||||
onEvent: (event) => events.push(event),
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async (role) => ({ vpn: { sites: [{ status: health[role] }] } }),
|
||||
readFailoverSelector: async () => ({ role: 'primary' }),
|
||||
selectFailoverRole: async (role) => ({ role }),
|
||||
setFailoverActivityEnabled: async () => {},
|
||||
readFailoverActivity: async () => ({ activity: { state: 'quiet', observedAt: new Date(clock).toISOString() } }),
|
||||
},
|
||||
});
|
||||
await testHarness.service.reconcile();
|
||||
await testHarness.service.runRound();
|
||||
clock = 30_000;
|
||||
await testHarness.service.runRound();
|
||||
health.reserve = 'unavailable';
|
||||
clock = 31_000;
|
||||
await testHarness.service.runRound();
|
||||
clock = 32_000;
|
||||
await testHarness.service.runRound();
|
||||
health.primary = 'available';
|
||||
clock = 33_000;
|
||||
await testHarness.service.runRound();
|
||||
|
||||
assert.deepEqual(events.map(({ type }) => type), [
|
||||
'failover.waiting_for_idle',
|
||||
'failover.both_unhealthy',
|
||||
'failover.recovered',
|
||||
]);
|
||||
});
|
||||
|
||||
test('pending target edits keep selector commits bound to the loaded channel pair', async () => {
|
||||
const state = serviceState();
|
||||
state.failoverPolicy = normalizeFailoverPolicy({
|
||||
...state.failoverPolicy,
|
||||
reserve: { profileId: 'profile-3', serverId: 'server-3' },
|
||||
});
|
||||
state.profiles.push({ id: 'profile-3', servers: [{ id: 'server-3', label: 'Desired later' }] });
|
||||
const testHarness = harness(state, {
|
||||
buildCandidate: () => ({
|
||||
config: {},
|
||||
applied: {
|
||||
...state.appliedFailoverPolicy,
|
||||
reserve: { profileId: 'profile-3', serverId: 'server-3' },
|
||||
reserveConfigFingerprint: 'c'.repeat(64),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await testHarness.service.manualSwitch('reserve');
|
||||
assert.equal(testHarness.read().appliedProfileId, 'profile-2');
|
||||
assert.equal(testHarness.read().appliedServerId, 'server-2');
|
||||
assert.equal(testHarness.service.snapshot().activation, 'pending');
|
||||
assert.deepEqual(testHarness.service.snapshot().reserve.target, { profileId: 'profile-2', serverId: 'server-2' });
|
||||
});
|
||||
@@ -109,6 +109,9 @@ function createHarness(overrides = {}) {
|
||||
events.push('operation');
|
||||
return operation();
|
||||
},
|
||||
restoreAppliedActivation: overrides.restoreAppliedActivation
|
||||
? async (previousState) => overrides.restoreAppliedActivation(previousState, events)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -246,6 +249,24 @@ test('route rules rollback continues and classifies runtime restore failure', as
|
||||
assert.ok(broken.events.filter((event) => event === 'state.update').length >= 1);
|
||||
});
|
||||
|
||||
test('route rules rollback restores the previous reserve selector', async () => {
|
||||
const state = canonicalState();
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: { profileId: 'primary', serverId: 'other' },
|
||||
reserve: { profileId: 'primary', serverId: 'server' },
|
||||
};
|
||||
const harness = createHarness({
|
||||
state,
|
||||
failures: { stateUpdates: [{ after: new Error('state') }] },
|
||||
restoreAppliedActivation: async (previousState, events) => {
|
||||
assert.equal(previousState.appliedServerId, 'server');
|
||||
events.push('selector.reserve');
|
||||
},
|
||||
});
|
||||
await assert.rejects(harness.service.update(newRules, 2, 2), /state/);
|
||||
assert.ok(harness.events.indexOf('selector.reserve') > harness.events.indexOf('runtime.restore'));
|
||||
});
|
||||
|
||||
test('route rules route preserves one adapter and state-only response', async () => {
|
||||
const calls = [];
|
||||
const route = createRouteRulesRoute({
|
||||
@@ -260,5 +281,6 @@ test('route rules route preserves one adapter and state-only response', async ()
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createRouteRulesRoute\(\{/);
|
||||
assert.match(source, /state\.appliedFailoverPolicy[\s\S]*buildFailoverCandidate\(\{ \.\.\.state, routeRules \}, 'applied'\)\.config/);
|
||||
assert.doesNotMatch(source, /function applyRouteRules|req\.url === ['"]\/api\/route-rules['"]/);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,13 @@ process.env.APP_MODE = 'gateway';
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
|
||||
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
||||
|
||||
const { buildGatewayConfig } = await import(`../../dist/server/singbox.js?gateway=${Date.now()}`);
|
||||
const {
|
||||
buildDualChannelGatewayConfig,
|
||||
buildGatewayConfig,
|
||||
dualChannelConfigMatchesApplied,
|
||||
fingerprintConfiguredOutbound,
|
||||
fingerprintSelectedOutbound,
|
||||
} = await import(`../../dist/server/singbox.js?gateway=${Date.now()}`);
|
||||
|
||||
const subscriptionConfig = {
|
||||
outbounds: [{
|
||||
@@ -59,3 +65,62 @@ test('gateway preserves mixed user-rule order and dynamic VPN target', () => {
|
||||
assert.deepEqual(config.experimental.clash_api, { external_controller: '127.0.0.1:19090' });
|
||||
assert.equal(config.route.final, 'test-vpn');
|
||||
});
|
||||
|
||||
test('gateway dual-channel config fixes probes to role tags and keeps inbound connections', () => {
|
||||
const reserveConfig = structuredClone(subscriptionConfig);
|
||||
reserveConfig.outbounds[0].tag = 'same-provider-tag';
|
||||
const primaryConfig = structuredClone(subscriptionConfig);
|
||||
primaryConfig.outbounds[0].tag = 'same-provider-tag';
|
||||
const config = buildDualChannelGatewayConfig({
|
||||
primary: { subscriptionConfig: primaryConfig, selectedServerId: 'same-provider-tag' },
|
||||
reserve: { subscriptionConfig: reserveConfig, selectedServerId: 'same-provider-tag' },
|
||||
}, {
|
||||
routeRules: [{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(config.outbounds.map(({ tag }) => tag), [
|
||||
'channel-primary', 'channel-reserve', 'channel-selector', 'direct',
|
||||
]);
|
||||
assert.deepEqual(config.outbounds[2], {
|
||||
type: 'selector',
|
||||
tag: 'channel-selector',
|
||||
outbounds: ['channel-primary', 'channel-reserve'],
|
||||
default: 'channel-primary',
|
||||
interrupt_exist_connections: false,
|
||||
});
|
||||
assert.deepEqual(config.route.rules.slice(1, 5), [
|
||||
{ inbound: ['diagnostics-primary-in'], outbound: 'channel-primary' },
|
||||
{ inbound: ['diagnostics-reserve-in'], outbound: 'channel-reserve' },
|
||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'channel-selector' },
|
||||
{ domain: ['api.example.com'], outbound: 'channel-selector' },
|
||||
]);
|
||||
assert.equal(config.route.final, 'channel-selector');
|
||||
const restoredReserve = buildDualChannelGatewayConfig({
|
||||
primary: { subscriptionConfig: primaryConfig, selectedServerId: 'same-provider-tag' },
|
||||
reserve: { subscriptionConfig: reserveConfig, selectedServerId: 'same-provider-tag' },
|
||||
}, { defaultRole: 'reserve' });
|
||||
assert.equal(restoredReserve.outbounds[2].default, 'channel-reserve');
|
||||
});
|
||||
|
||||
test('cached dual-channel outbounds must match the applied provider fingerprints', () => {
|
||||
const config = buildDualChannelGatewayConfig({
|
||||
primary: { subscriptionConfig, selectedServerId: 'test-vpn' },
|
||||
reserve: { subscriptionConfig, selectedServerId: 'test-vpn' },
|
||||
});
|
||||
const expected = fingerprintSelectedOutbound(subscriptionConfig, 'test-vpn');
|
||||
const applied = {
|
||||
primary: { profileId: 'primary', serverId: 'test-vpn' },
|
||||
reserve: { profileId: 'reserve', serverId: 'test-vpn' },
|
||||
primaryConfigFingerprint: expected,
|
||||
reserveConfigFingerprint: expected,
|
||||
};
|
||||
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), true);
|
||||
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'reserve'), false);
|
||||
assert.equal(fingerprintConfiguredOutbound(config.outbounds[0], 'test-vpn'), expected);
|
||||
config.outbounds[0].uuid = '11111111-1111-4111-8111-111111111111';
|
||||
assert.notEqual(fingerprintConfiguredOutbound(config.outbounds[0], 'test-vpn'), expected);
|
||||
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false);
|
||||
config.outbounds[0].uuid = subscriptionConfig.outbounds[0].uuid;
|
||||
config.outbounds[2].outbounds = ['channel-primary'];
|
||||
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import dgram from 'node:dgram';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createFailoverService } from '../../dist/server/features/failover/failoverService.js';
|
||||
import { createDomainTrafficService } from '../../dist/server/services/domainTrafficService.js';
|
||||
import { DEFAULT_FAILOVER_POLICY, normalizeFailoverPolicy } from '../../dist/shared/failover.js';
|
||||
|
||||
const image = process.env.HARBOR_SINGBOX_IMAGE;
|
||||
|
||||
function listen(server, host = '127.0.0.1') {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, host, () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function listenUdp(socket, host = '127.0.0.1') {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('error', reject);
|
||||
socket.bind(0, host, () => resolve(socket.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function readExactly(socket, size) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let value = Buffer.alloc(0);
|
||||
const onData = (chunk) => {
|
||||
value = Buffer.concat([value, chunk]);
|
||||
if (value.length < size) return;
|
||||
cleanup();
|
||||
if (value.length > size) {
|
||||
socket.pause();
|
||||
socket.unshift(value.subarray(size));
|
||||
}
|
||||
resolve(value.subarray(0, size));
|
||||
};
|
||||
const cleanup = () => {
|
||||
socket.off('data', onData);
|
||||
socket.off('error', reject);
|
||||
socket.off('end', onEnd);
|
||||
};
|
||||
const onEnd = () => {
|
||||
cleanup();
|
||||
reject(new Error('Socket ended early'));
|
||||
};
|
||||
socket.on('data', onData);
|
||||
socket.once('error', reject);
|
||||
socket.once('end', onEnd);
|
||||
socket.resume();
|
||||
});
|
||||
}
|
||||
|
||||
async function openSocksConnection(proxyPort, targetPort, diagnostic = () => {}) {
|
||||
const socket = net.connect(proxyPort, '127.0.0.1');
|
||||
socket.setTimeout(3_000, () => socket.destroy(new Error('SOCKS fixture timed out')));
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
diagnostic('tcp connected');
|
||||
socket.write(Buffer.from([5, 1, 0]));
|
||||
assert.deepEqual(await readExactly(socket, 2), Buffer.from([5, 0]));
|
||||
diagnostic('socks greeting accepted');
|
||||
const host = Buffer.from('host.docker.internal');
|
||||
socket.write(Buffer.from([5, 1, 0, 3, host.length, ...host, targetPort >> 8, targetPort & 255]));
|
||||
const response = await readExactly(socket, 4);
|
||||
diagnostic(`socks connect response ${response.toString('hex')}`);
|
||||
assert.equal(response[1], 0);
|
||||
const addressLength = response[3] === 0
|
||||
? 0
|
||||
: response[3] === 1
|
||||
? 4
|
||||
: response[3] === 4
|
||||
? 16
|
||||
: (await readExactly(socket, 1))[0];
|
||||
await readExactly(socket, addressLength + 2);
|
||||
return socket;
|
||||
}
|
||||
|
||||
async function echo(socket, value) {
|
||||
socket.write(value);
|
||||
assert.equal((await readExactly(socket, Buffer.byteLength(value))).toString(), value);
|
||||
}
|
||||
|
||||
async function openSocksUdpAssociation(proxyPort, targetPort, resolveRelayPort) {
|
||||
const control = net.connect(proxyPort, '127.0.0.1');
|
||||
control.setTimeout(3_000, () => control.destroy(new Error('SOCKS UDP fixture timed out')));
|
||||
await new Promise((resolve, reject) => {
|
||||
control.once('connect', resolve);
|
||||
control.once('error', reject);
|
||||
});
|
||||
control.write(Buffer.from([5, 1, 0]));
|
||||
assert.deepEqual(await readExactly(control, 2), Buffer.from([5, 0]));
|
||||
control.write(Buffer.from([5, 3, 0, 1, 0, 0, 0, 0, 0, 0]));
|
||||
const response = await readExactly(control, 4);
|
||||
assert.equal(response[1], 0);
|
||||
const addressLength = response[3] === 1 ? 4 : response[3] === 4 ? 16 : (await readExactly(control, 1))[0];
|
||||
const addressAndPort = await readExactly(control, addressLength + 2);
|
||||
const boundPort = addressAndPort.readUInt16BE(addressAndPort.length - 2);
|
||||
const relayPort = resolveRelayPort(boundPort);
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
await listenUdp(socket);
|
||||
return {
|
||||
close() { socket.close(); control.destroy(); },
|
||||
async echo(value) {
|
||||
const host = Buffer.from('host.docker.internal');
|
||||
const payload = Buffer.from(value);
|
||||
const packet = Buffer.from([0, 0, 0, 3, host.length, ...host, targetPort >> 8, targetPort & 255, ...payload]);
|
||||
const reply = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(`SOCKS UDP echo timed out; relay ${boundPort}`)), 3_000);
|
||||
socket.once('message', (message) => {
|
||||
clearTimeout(timeout);
|
||||
const headerLength = message[3] === 1 ? 10 : message[3] === 4 ? 22 : 7 + message[4];
|
||||
resolve(message.subarray(headerLength).toString());
|
||||
});
|
||||
});
|
||||
socket.send(packet, relayPort, '127.0.0.1');
|
||||
assert.equal(await reply, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForApi(port) {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/proxies/channel-selector`);
|
||||
if (response.ok) return;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error('Clash API did not start');
|
||||
}
|
||||
|
||||
test('sing-box 1.13 selector preserves inbound TCP and UDP connections and routes new connections', {
|
||||
skip: image ? false : 'Set HARBOR_SINGBOX_IMAGE to run the local Docker capability proof',
|
||||
timeout: 30_000,
|
||||
}, async (t) => {
|
||||
const echoServer = net.createServer((socket) => socket.pipe(socket));
|
||||
const echoPort = await listen(echoServer, '0.0.0.0');
|
||||
const udpEchoServer = dgram.createSocket('udp4');
|
||||
udpEchoServer.on('message', (message, remote) => udpEchoServer.send(message, remote.port, remote.address));
|
||||
const udpEchoPort = await listenUdp(udpEchoServer, '0.0.0.0');
|
||||
const proxyReservation = net.createServer();
|
||||
const proxyPort = await listen(proxyReservation);
|
||||
await new Promise((resolve) => proxyReservation.close(resolve));
|
||||
const apiReservation = net.createServer();
|
||||
const apiPort = await listen(apiReservation);
|
||||
await new Promise((resolve) => apiReservation.close(resolve));
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-selector-'));
|
||||
const configPath = path.join(fixtureDir, 'config.json');
|
||||
const config = {
|
||||
log: { level: 'error' },
|
||||
experimental: {
|
||||
cache_file: { enabled: true, path: '/config/cache.db' },
|
||||
clash_api: { external_controller: '0.0.0.0:19091' },
|
||||
},
|
||||
inbounds: [
|
||||
{ type: 'mixed', tag: 'mixed-in', listen: '0.0.0.0', listen_port: 18081 },
|
||||
{ type: 'mixed', tag: 'diagnostics-primary-in', listen: '0.0.0.0', listen_port: 18082 },
|
||||
{ type: 'mixed', tag: 'diagnostics-reserve-in', listen: '0.0.0.0', listen_port: 18083 },
|
||||
],
|
||||
outbounds: [
|
||||
{ type: 'direct', tag: 'channel-primary' },
|
||||
{ type: 'direct', tag: 'channel-reserve' },
|
||||
{
|
||||
type: 'selector',
|
||||
tag: 'channel-selector',
|
||||
outbounds: ['channel-primary', 'channel-reserve'],
|
||||
default: 'channel-primary',
|
||||
interrupt_exist_connections: false,
|
||||
},
|
||||
],
|
||||
route: {
|
||||
rules: [
|
||||
{ inbound: ['diagnostics-primary-in'], outbound: 'channel-primary' },
|
||||
{ inbound: ['diagnostics-reserve-in'], outbound: 'channel-reserve' },
|
||||
{ inbound: ['mixed-in'], outbound: 'channel-selector' },
|
||||
],
|
||||
final: 'channel-selector',
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const mount = `${fixtureDir}:/config`;
|
||||
const check = spawnSync('docker', [
|
||||
'run', '--rm', '-v', mount, '--entrypoint', 'sing-box', image,
|
||||
'check', '-c', '/config/config.json',
|
||||
], { encoding: 'utf8' });
|
||||
assert.equal(check.status, 0, check.stderr || check.stdout);
|
||||
t.diagnostic('config accepted');
|
||||
|
||||
const containerName = `harbor-selector-${process.pid}-${Date.now()}`;
|
||||
const udpRelayContainerPorts = Array.from({ length: 32 }, (_, index) => 18084 + index);
|
||||
const runtime = spawn('docker', [
|
||||
'run', '--rm', '--name', containerName,
|
||||
'--sysctl', `net.ipv4.ip_local_port_range=${udpRelayContainerPorts[0]} ${udpRelayContainerPorts.at(-1)}`,
|
||||
'-p', `${proxyPort}:18081/tcp`,
|
||||
...udpRelayContainerPorts.flatMap((port) => ['-p', `127.0.0.1::${port}/udp`]),
|
||||
'-p', `${apiPort}:19091`,
|
||||
'-v', mount, '--entrypoint', 'sing-box', image,
|
||||
'run', '-c', '/config/config.json',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let runtimeOutput = '';
|
||||
runtime.stdout.on('data', (chunk) => { runtimeOutput += chunk; });
|
||||
runtime.stderr.on('data', (chunk) => { runtimeOutput += chunk; });
|
||||
t.after(async () => {
|
||||
if (runtime.exitCode == null) {
|
||||
runtime.kill('SIGTERM');
|
||||
await new Promise((resolve) => runtime.once('close', resolve));
|
||||
}
|
||||
echoServer.close();
|
||||
udpEchoServer.close();
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForApi(apiPort);
|
||||
const runtimePid = runtime.pid;
|
||||
t.diagnostic('api ready');
|
||||
const resolveUdpRelayPort = (containerPort) => {
|
||||
const mapping = spawnSync('docker', ['port', containerName, `${containerPort}/udp`], { encoding: 'utf8' });
|
||||
assert.equal(mapping.status, 0, mapping.stderr || mapping.stdout);
|
||||
const port = Number(mapping.stdout.trim().split(':').at(-1));
|
||||
assert.ok(Number.isSafeInteger(port) && port > 0, mapping.stdout);
|
||||
return port;
|
||||
};
|
||||
const before = await (await fetch(`http://127.0.0.1:${apiPort}/proxies/channel-selector`)).json();
|
||||
assert.equal(before.now, 'channel-primary');
|
||||
|
||||
const existing = await openSocksConnection(proxyPort, echoPort, (message) => t.diagnostic(message));
|
||||
t.diagnostic('primary connection open');
|
||||
t.after(() => existing.destroy());
|
||||
await echo(existing, 'before-switch');
|
||||
const existingUdp = await openSocksUdpAssociation(proxyPort, udpEchoPort, resolveUdpRelayPort);
|
||||
t.after(() => existingUdp.close());
|
||||
await existingUdp.echo('before-switch-udp');
|
||||
|
||||
const policy = normalizeFailoverPolicy({
|
||||
...DEFAULT_FAILOVER_POLICY,
|
||||
enabled: true,
|
||||
primary: { profileId: 'primary-profile', serverId: 'primary-server' },
|
||||
reserve: { profileId: 'reserve-profile', serverId: 'reserve-server' },
|
||||
intervalMs: 15_000,
|
||||
failureWindowMs: 30_000,
|
||||
recoveryWindowMs: 60_000,
|
||||
trafficGuard: { enabled: true, thresholdBytesPerSecond: 1024, quietWindowMs: 5_000 },
|
||||
minimumReserveMs: 60_000,
|
||||
});
|
||||
const applied = {
|
||||
primary: policy.primary,
|
||||
reserve: policy.reserve,
|
||||
primaryConfigFingerprint: 'a'.repeat(64),
|
||||
reserveConfigFingerprint: 'b'.repeat(64),
|
||||
};
|
||||
let clock = 0;
|
||||
let state = {
|
||||
revision: 1,
|
||||
failoverPolicy: policy,
|
||||
failoverRuntimeState: { lastSwitchAt: null, holdUntil: null, primaryQuarantineUntil: null, failoverHistory: [], reasonCode: null },
|
||||
appliedFailoverPolicy: applied,
|
||||
appliedProfileId: policy.primary.profileId,
|
||||
appliedServerId: policy.primary.serverId,
|
||||
appliedServerSnapshot: { id: policy.primary.serverId, label: 'Primary' },
|
||||
profiles: [
|
||||
{ id: policy.primary.profileId, servers: [{ id: policy.primary.serverId, label: 'Primary' }] },
|
||||
{ id: policy.reserve.profileId, servers: [{ id: policy.reserve.serverId, label: 'Reserve' }] },
|
||||
],
|
||||
diagnostics: { customServices: [] },
|
||||
};
|
||||
const readSelector = async () => {
|
||||
const value = await (await fetch(`http://127.0.0.1:${apiPort}/proxies/channel-selector`)).json();
|
||||
return { role: value.now === 'channel-primary' ? 'primary' : value.now === 'channel-reserve' ? 'reserve' : 'other' };
|
||||
};
|
||||
const selectRole = async (role) => {
|
||||
const response = await fetch(`http://127.0.0.1:${apiPort}/proxies/channel-selector`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: role === 'primary' ? 'channel-primary' : 'channel-reserve' }),
|
||||
});
|
||||
assert.equal(response.status, 204);
|
||||
const selected = await readSelector();
|
||||
assert.equal(selected.role, role);
|
||||
return selected;
|
||||
};
|
||||
const traffic = createDomainTrafficService({
|
||||
observe: async () => (await fetch(`http://127.0.0.1:${apiPort}/connections`)).json(),
|
||||
devices: () => [],
|
||||
now: () => new Date(clock),
|
||||
});
|
||||
await traffic.refresh();
|
||||
const failover = createFailoverService({
|
||||
state: {
|
||||
read: () => state,
|
||||
update: (mutator) => {
|
||||
state = { ...mutator(state), revision: state.revision + 1 };
|
||||
return state;
|
||||
},
|
||||
},
|
||||
runtime: { isRunning: async () => true },
|
||||
dataplane: {
|
||||
checkConfig: async () => ({}),
|
||||
runFailoverProbe: async (role) => ({ vpn: { sites: [{ status: role === 'primary' ? 'unavailable' : 'available' }] } }),
|
||||
readFailoverSelector: readSelector,
|
||||
selectFailoverRole: selectRole,
|
||||
setFailoverActivityEnabled: async (value) => value ? traffic.enableActivity() : traffic.disableActivity(),
|
||||
readFailoverActivity: async (threshold) => {
|
||||
await traffic.refresh();
|
||||
return { activity: traffic.activitySnapshot(threshold) };
|
||||
},
|
||||
},
|
||||
buildCandidate: () => ({ config: {}, applied }),
|
||||
serialize: (operation) => operation(),
|
||||
scheduler: { setTimeout: () => ({ unref() {} }), clearTimeout: () => {} },
|
||||
now: () => new Date(clock),
|
||||
});
|
||||
t.after(() => failover.shutdown());
|
||||
await failover.reconcile();
|
||||
for (clock of [0, 30_000, 35_000]) {
|
||||
await echo(existing, `active-${clock}-${'x'.repeat(64 * 1024)}`);
|
||||
await failover.runRound();
|
||||
}
|
||||
assert.equal((await readSelector()).role, 'primary');
|
||||
assert.equal(failover.snapshot().status, 'waiting-for-idle');
|
||||
t.diagnostic('active traffic delayed selector switch');
|
||||
|
||||
clock = 46_000;
|
||||
await failover.runRound();
|
||||
assert.equal((await readSelector()).role, 'primary');
|
||||
clock = 51_000;
|
||||
await failover.runRound();
|
||||
const after = await (await fetch(`http://127.0.0.1:${apiPort}/proxies/channel-selector`)).json();
|
||||
assert.equal(after.now, 'channel-reserve');
|
||||
t.diagnostic('quiet window elapsed; selector switched and read back');
|
||||
|
||||
await echo(existing, 'after-switch-existing');
|
||||
await existingUdp.echo('after-switch-existing-udp');
|
||||
const createdAfterSwitch = await openSocksConnection(proxyPort, echoPort, (message) => t.diagnostic(message));
|
||||
t.diagnostic('reserve connection open');
|
||||
t.after(() => createdAfterSwitch.destroy());
|
||||
await echo(createdAfterSwitch, 'after-switch-new');
|
||||
const udpCreatedAfterSwitch = await openSocksUdpAssociation(proxyPort, udpEchoPort, resolveUdpRelayPort);
|
||||
t.after(() => udpCreatedAfterSwitch.close());
|
||||
await udpCreatedAfterSwitch.echo('after-switch-new-udp');
|
||||
|
||||
const connections = await (await fetch(`http://127.0.0.1:${apiPort}/connections`)).json();
|
||||
const chains = connections.connections.map((connection) => connection.chains);
|
||||
assert.ok(chains.some((chain) => chain.includes('channel-primary')), JSON.stringify(chains));
|
||||
assert.ok(chains.some((chain) => chain.includes('channel-reserve')), JSON.stringify(chains));
|
||||
assert.equal(runtime.pid, runtimePid);
|
||||
assert.equal(runtime.exitCode, null);
|
||||
} catch (cause) {
|
||||
assert.fail(`${cause.stack || cause}\n${runtimeOutput}`);
|
||||
}
|
||||
});
|
||||
@@ -320,6 +320,7 @@ setInterval(() => {}, 60_000);
|
||||
'configExists',
|
||||
'connection',
|
||||
'diagnostics',
|
||||
'failover',
|
||||
'fetchedAt',
|
||||
'gatewayAuto',
|
||||
'generatedAt',
|
||||
|
||||
@@ -115,6 +115,39 @@ test('schema v5 migrates rules and diagnostics settings with an exact backup', (
|
||||
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
||||
});
|
||||
|
||||
test('schema v7 migrates failover disabled without losing canonical state', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const legacy = {
|
||||
schemaVersion: 7,
|
||||
revision: 19,
|
||||
profiles: [],
|
||||
desiredProfileId: '',
|
||||
appliedProfileId: '',
|
||||
appliedServerId: '',
|
||||
appliedServerSnapshot: null,
|
||||
routeRules: [],
|
||||
appliedRouteRules: [],
|
||||
routeRulesRevision: 4,
|
||||
diagnostics: { configured: true, customServices: [], hiddenServiceIds: ['google'] },
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(legacy));
|
||||
|
||||
const store = createStateStore(filePath, {
|
||||
now: () => new Date('2026-08-19T12:00:00.000Z'),
|
||||
});
|
||||
const migrated = store.read();
|
||||
|
||||
assert.equal(migrated.schemaVersion, 8);
|
||||
assert.equal(migrated.revision, 19);
|
||||
assert.equal(migrated.routeRulesRevision, 4);
|
||||
assert.equal(migrated.failoverPolicy.enabled, false);
|
||||
assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null);
|
||||
assert.equal(migrated.appliedFailoverPolicy, null);
|
||||
assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']);
|
||||
assert.equal(store.migration.fromVersion, 7);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||
});
|
||||
|
||||
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
||||
for (const [name, rule] of [
|
||||
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
||||
|
||||
@@ -124,6 +124,8 @@ function createHarness(overrides = {}) {
|
||||
clearInterval: (timer) => { timer.clearCalls += 1; },
|
||||
},
|
||||
onRefreshError: overrides.onRefreshError || (() => {}),
|
||||
onEvent: overrides.onEvent,
|
||||
failover: overrides.failover,
|
||||
now: () => new Date('2026-08-08T12:30:00.000Z'),
|
||||
});
|
||||
|
||||
@@ -305,6 +307,36 @@ test('inactive delete is state-only; applied delete requires one stop-and-delete
|
||||
assert.equal(after.config, null);
|
||||
});
|
||||
|
||||
test('failed active delete restores the previous reserve selector', async () => {
|
||||
const state = defaultState();
|
||||
state.failoverPolicy = {
|
||||
enabled: true,
|
||||
paused: false,
|
||||
primary: { profileId: 'work', serverId: 'shared' },
|
||||
reserve: { profileId: 'personal', serverId: 'shared' },
|
||||
};
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: state.failoverPolicy.primary,
|
||||
reserve: state.failoverPolicy.reserve,
|
||||
};
|
||||
const harness = createHarness({
|
||||
state,
|
||||
failStateAt: 1,
|
||||
failover: {
|
||||
reconcile: async () => {},
|
||||
restoreAppliedActivation: async (previousState) => {
|
||||
assert.equal(previousState.appliedProfileId, 'personal');
|
||||
harness.calls.push('selector.reserve');
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(harness.service.deleteProfile('personal', 'stop-and-delete'), /state failed/);
|
||||
assert.equal(harness.snapshot().running, true);
|
||||
assert.equal(harness.snapshot().config, 'old-config');
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
||||
assert.ok(harness.calls.indexOf('selector.reserve') > harness.calls.indexOf('runtime.start'));
|
||||
});
|
||||
|
||||
test('deleting a pending desired profile leaves the running applied route untouched', async () => {
|
||||
const state = defaultState();
|
||||
state.desiredProfileId = 'work';
|
||||
@@ -317,6 +349,90 @@ test('deleting a pending desired profile leaves the running applied route untouc
|
||||
assert.equal(harness.calls.includes('gateway.set'), false);
|
||||
});
|
||||
|
||||
test('deleting a desired failover target disables only the pending policy', async () => {
|
||||
const state = defaultState();
|
||||
state.failoverPolicy = {
|
||||
enabled: true,
|
||||
paused: false,
|
||||
primary: { profileId: 'work', serverId: 'shared' },
|
||||
reserve: { profileId: 'personal', serverId: 'shared' },
|
||||
};
|
||||
const harness = createHarness({ state });
|
||||
await harness.service.deleteProfile('work', 'delete', 3);
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.running, true);
|
||||
assert.equal(after.config, 'old-config');
|
||||
assert.equal(after.state.failoverPolicy.enabled, false);
|
||||
assert.deepEqual(after.state.failoverPolicy.primary, { profileId: '', serverId: '' });
|
||||
assert.deepEqual(after.state.failoverPolicy.reserve, { profileId: 'personal', serverId: 'shared' });
|
||||
});
|
||||
|
||||
test('refresh pauses failover when a loaded channel target disappears', async () => {
|
||||
const state = defaultState();
|
||||
state.failoverPolicy = {
|
||||
enabled: true,
|
||||
paused: false,
|
||||
primary: { profileId: 'personal', serverId: 'shared' },
|
||||
reserve: { profileId: 'work', serverId: 'shared' },
|
||||
};
|
||||
state.appliedFailoverPolicy = {
|
||||
primary: state.failoverPolicy.primary,
|
||||
reserve: state.failoverPolicy.reserve,
|
||||
primaryConfigFingerprint: 'a'.repeat(64),
|
||||
reserveConfigFingerprint: 'b'.repeat(64),
|
||||
};
|
||||
const events = [];
|
||||
const harness = createHarness({ state, onEvent: (event) => events.push(event) });
|
||||
await harness.service.refreshProfile('personal');
|
||||
assert.equal(harness.snapshot().state.failoverPolicy.paused, true);
|
||||
assert.equal(harness.snapshot().running, true);
|
||||
assert.equal(harness.snapshot().config, 'old-config');
|
||||
assert.ok(events.some(({ type }) => type === 'failover.paused'));
|
||||
});
|
||||
|
||||
test('scheduled refresh failures use one stable key for the same failure streak', async () => {
|
||||
const failure = Object.assign(new Error('down'), { code: 'PROVIDER_UNAVAILABLE' });
|
||||
const events = [];
|
||||
const harness = createHarness({
|
||||
fetchSubscription: async () => { throw failure; },
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
await assert.rejects(harness.service.refreshProfile('personal', undefined, 'scheduled'));
|
||||
await assert.rejects(harness.service.refreshProfile('personal', undefined, 'scheduled'));
|
||||
const keys = events.filter(({ type }) => type === 'subscription.refresh_failed').map(({ dedupeKey }) => dedupeKey);
|
||||
assert.equal(keys.length, 2);
|
||||
assert.equal(keys[0], keys[1]);
|
||||
});
|
||||
|
||||
test('scheduled success logs only content changes or recovery while manual refresh always logs', async () => {
|
||||
const unchanged = (id) => ({
|
||||
config: { profile: id },
|
||||
servers: [oldServer],
|
||||
userInfo: { total: 100 },
|
||||
fetchedAt: '2026-08-08T12:00:00.000Z',
|
||||
});
|
||||
const events = [];
|
||||
const harness = createHarness({
|
||||
fetchSubscription: async () => unchanged('work'),
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
await harness.service.refreshProfile('work', undefined, 'scheduled');
|
||||
assert.equal(events.length, 0);
|
||||
await harness.service.refreshProfile('work');
|
||||
assert.deepEqual(events.map(({ type }) => type), ['subscription.refreshed']);
|
||||
|
||||
const recoveryState = defaultState();
|
||||
recoveryState.profiles[1].lastRefreshErrorCode = 'PROVIDER_UNAVAILABLE';
|
||||
const recoveryEvents = [];
|
||||
const recovery = createHarness({
|
||||
state: recoveryState,
|
||||
fetchSubscription: async () => unchanged('work'),
|
||||
onEvent: (event) => recoveryEvents.push(event),
|
||||
});
|
||||
await recovery.service.refreshProfile('work', undefined, 'scheduled');
|
||||
assert.deepEqual(recoveryEvents.map(({ type }) => type), ['subscription.refreshed']);
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/activity-journal/ActivityJournalFeature.tsx'), 'utf8');
|
||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles/features/activity-journal.css'), 'utf8');
|
||||
|
||||
test('journal is the last Gateway-only drawer and refreshes whenever it opens', () => {
|
||||
assert.match(page, /<RoutingToggle[\s\S]*\{isGateway && <ActivityJournalToggle/);
|
||||
assert.match(page, /DRAWER_ORDER = \[[^\]]*'journal'\]/);
|
||||
assert.match(feature, /if \(feature\.isOpen\) void load\(null, status !== 'idle'\)/);
|
||||
assert.match(feature, /assertActivityJournalPage\(await loadPage\(cursor\)\)/);
|
||||
});
|
||||
|
||||
test('journal presents 30-day grouped history with refresh, pagination and stable states', () => {
|
||||
assert.match(feature, /Важные события хранятся 30 дней/);
|
||||
assert.match(feature, /Сегодня[\s\S]*Вчера/);
|
||||
assert.match(feature, /Обновить журнал/);
|
||||
assert.match(feature, /M5 8V4m0 4h4/);
|
||||
assert.match(feature, /Показать ещё/);
|
||||
assert.match(feature, /Журнал временно недоступен[\s\S]*Повторить/);
|
||||
assert.match(styles, /\.client-journal-skeleton/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/);
|
||||
});
|
||||
@@ -34,7 +34,7 @@ test('connection feature preserves actions, local preference and opaque neighbor
|
||||
assert.match(panel, /client-state-detail[\s\S]*\{routingSlot\}[\s\S]*client-state-copy[\s\S]*\{serverSlot\}[\s\S]*client-proxies[\s\S]*\{statusSlot\}/);
|
||||
assert.match(page, /routingSlot=\{<RoutingPendingStatus[\s\S]*blocked=\{connectionBlocked\}[\s\S]*onRestart=\{onRestart\}/);
|
||||
assert.match(routing, /client-route-rules-pending[\s\S]*Перезапустить VPN/);
|
||||
assert.match(page, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
|
||||
assert.match(page, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity \|\| failoverIdentity\} \/>\}/);
|
||||
assert.match(page, /mainIdentity[\s\S]*appliedProfile[\s\S]*appliedServer/);
|
||||
assert.match(page, /statusSlot=\{<>[\s\S]*InlineError[\s\S]*InlineProgress/);
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
|
||||
assert.match(overview, /const appliedServer = appliedProfile\?\.servers\.find[\s\S]*state\.selection\.appliedServerSnapshot/);
|
||||
assert.match(overview, /const mainIdentity = gatewayDirect[\s\S]*: connected[\s\S]*appliedProfile && appliedServer[\s\S]*`\$\{subscriptionDomain\(appliedProfile\.subscription\.host\)\} · \$\{appliedServer\.label\}`/);
|
||||
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{subscriptionDomain\(operationProfile\.subscription\.host\)\} · \$\{operationServer\.label\}`/);
|
||||
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
|
||||
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity \|\| failoverIdentity\} \/>\}/);
|
||||
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
|
||||
assert.match(feature, /const history = globalTraffic\?\.history \|\| \[\][\s\S]*samples=\{history\}[\s\S]*routeLabel="Gateway"[\s\S]*series="speed"/);
|
||||
assert.match(connection, /<section className=\{`client-power-section[\s\S]*client-state-detail[\s\S]*client-connection-title[\s\S]*\{serverSlot\}[\s\S]*client-proxies/);
|
||||
|
||||
@@ -10,6 +10,7 @@ const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewP
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
|
||||
const panel = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
|
||||
const model = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/connectivityResult.ts'), 'utf8');
|
||||
const customServiceAction = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/customServiceAction.ts'), 'utf8');
|
||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/index.ts'), 'utf8');
|
||||
|
||||
const ip = { source: 'cloudflare', address: '198.51.100.10', extra: true };
|
||||
@@ -82,7 +83,7 @@ test('unknown target and legacy-full results pass one identity-preserving parser
|
||||
assert.doesNotMatch(model, /\sas\s(?:ConnectivityResult|Record<string, unknown>)/);
|
||||
});
|
||||
|
||||
test('serial probes and editor stay panel-owned while backend state owns the service set', () => {
|
||||
test('serial probes stay panel-owned while custom service validation is shared by both editors', () => {
|
||||
assert.match(panel, /const targets = onlyTarget \? \[onlyTarget\] : \[[\s\S]*CONNECTIVITY_NETWORK_SOURCE\.id[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/);
|
||||
assert.match(panel, /for \(const target of targets\) \{[\s\S]*setActiveTarget\(target\)[\s\S]*await runConnectivityDiagnostics\(target\)[\s\S]*if \(legacyFullResult\) break/);
|
||||
assert.match(panel, /CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'/);
|
||||
@@ -92,7 +93,8 @@ test('serial probes and editor stay panel-owned while backend state owns the ser
|
||||
assert.doesNotMatch(panel, /localStorage\.setItem/);
|
||||
assert.doesNotMatch(panel, /useState\(read(?:Custom|Hidden)Services\)/);
|
||||
assert.match(panel, /slice\(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES\)/);
|
||||
assert.match(panel, /parsed\.protocol !== 'https:'/);
|
||||
assert.match(panel, /saveCustomDiagnosticService/);
|
||||
assert.match(customServiceAction, /parsed\.protocol !== 'https:'/);
|
||||
assert.match(panel, /document\.startViewTransition\(update\)/);
|
||||
assert.match(panel, /retryable: Boolean\(Reflect\.get\(value, 'retryable'\)\)/);
|
||||
assert.match(panel, /requestDetails\(error\)[\s\S]*requestError\.retryable/);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/failover/FailoverFeature.tsx'), 'utf8');
|
||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles/features/failover.css'), 'utf8');
|
||||
|
||||
test('reserve is a Gateway-only drawer immediately after subscriptions', () => {
|
||||
assert.match(page, /<SubscriptionToggle[\s\S]*\{isGateway && <FailoverToggle[\s\S]*<InstructionsToggle/);
|
||||
assert.match(page, /\{isGateway && hasSubscription && <FailoverPanel/);
|
||||
assert.match(page, /DRAWER_ORDER = \['subscription', 'failover'/);
|
||||
assert.match(feature, /label="Резерв"/);
|
||||
assert.match(feature, /Уже открытые соединения Harbor не закрывает/);
|
||||
});
|
||||
|
||||
test('failover settings expose an off switch, channel targets, checks and per-service timeouts', () => {
|
||||
assert.match(feature, /Использовать резерв[\s\S]*Полностью пассивен/);
|
||||
assert.match(feature, /\['primary', 'reserve'\][\s\S]*Подписка[\s\S]*Сервер/);
|
||||
assert.match(feature, /Что проверять/);
|
||||
assert.match(feature, /min="2"[\s\S]*max="30"[\s\S]*Таймаут проверки:/);
|
||||
assert.match(feature, /Проверять каждые, сек[\s\S]*Сбой должен длиться, сек[\s\S]*Восстановление, сек/);
|
||||
assert.match(feature, /Не переключать во время работы[\s\S]*Тишина перед переключением[\s\S]*Активный трафик, КБ\/с/);
|
||||
assert.match(feature, /Защита от повторных сбоев[\s\S]*Окно повторных сбоев[\s\S]*Карантин основного/);
|
||||
assert.match(feature, /Добавить HTTPS-сервис/);
|
||||
assert.match(feature, /Проверить оба канала/);
|
||||
assert.match(feature, /beforeunload/);
|
||||
});
|
||||
|
||||
test('runtime status keeps fixed geometry and explains active traffic without motion dependence', () => {
|
||||
assert.match(feature, /Ждём завершения активной работы/);
|
||||
assert.match(feature, /Активный трафик ·[\s\S]*transmittingConnections/);
|
||||
assert.match(feature, /activity\?\.blockers\.slice\(0, 2\)\.map/);
|
||||
assert.match(feature, /Ещё \{activity\.blockers\.length - 2\}/);
|
||||
assert.match(feature, /Нестабилен ·[\s\S]*Следующее решение не раньше чем через/);
|
||||
assert.match(feature, /switchRole && snapshot\.enabled && snapshot\.activation === 'active'/);
|
||||
assert.match(styles, /\.client-failover-runtime \{[^}]*min-height:\s*132px/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*transition:\s*none/);
|
||||
});
|
||||
@@ -56,6 +56,10 @@ test('typed Harbor client validates unknown state and isolates wire compatibilit
|
||||
gatewayAvailable: true,
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed, 'proxyPort'), false);
|
||||
assert.throws(() => parseHarborState({
|
||||
...snapshot,
|
||||
failover: { ...snapshot.failover, policy: { ...snapshot.failover.policy, intervalMs: 'fast' } },
|
||||
}), { code: 'INCOMPATIBLE_API' });
|
||||
let incompatible;
|
||||
try {
|
||||
parseHarborState({ ...snapshot, revision: -1 });
|
||||
@@ -116,6 +120,21 @@ test('an equal revision keeps the current snapshot identity', () => {
|
||||
assert.equal(next.snapshot.selection.desiredServerId, 'one');
|
||||
});
|
||||
|
||||
test('equal revision accepts only newer failover observations and retires old epochs', () => {
|
||||
const value = (epoch, sequence, status) => ({
|
||||
...snapshot(4, 'one'),
|
||||
failover: { observationEpoch: epoch, observationSequence: sequence, status },
|
||||
});
|
||||
let state = receive(initialHarborState, value('epoch-a', 1, 'primary'));
|
||||
state = receive(state, value('epoch-a', 2, 'waiting-for-idle'));
|
||||
assert.equal(state.snapshot.failover.status, 'waiting-for-idle');
|
||||
state = receive(state, value('epoch-b', 1, 'reserve'));
|
||||
assert.equal(state.snapshot.failover.status, 'reserve');
|
||||
state = receive(state, value('epoch-a', 99, 'primary'));
|
||||
assert.equal(state.snapshot.failover.status, 'reserve');
|
||||
assert.deepEqual(state.failoverTransport.retiredEpochs, ['epoch-a']);
|
||||
});
|
||||
|
||||
test('selection has no client-side shadow and follows canonical profile snapshots', () => {
|
||||
const state = receive(initialHarborState, snapshot(2, 'two'));
|
||||
assert.equal(Object.hasOwn(state, 'pendingServerId'), false);
|
||||
|
||||
@@ -166,13 +166,13 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
|
||||
assert.match(instructions, /<Drawer[\s\S]*className="client-instructions"/);
|
||||
assert.match(routing, /<Drawer[\s\S]*className="client-local-rules"/);
|
||||
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/);
|
||||
assert.match(component, /const DRAWER_ORDER = \['subscription', 'instructions', 'devices', 'diagnostics', 'routing'\]/);
|
||||
assert.match(component, /const DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'\]/);
|
||||
assert.match(component, /function switchDrawer\(target: DrawerKey\)[\s\S]*from\.inert = true[\s\S]*translateY\(\$\{direction \* 100\}%\)[\s\S]*translateY\(\$\{-direction \* 100\}%\)/);
|
||||
assert.match(component, /const \[drawerSwitchTarget, setDrawerSwitchTarget\] = useState<DrawerKey \| null>\(null\)/);
|
||||
assert.match(component, /const activeRailDrawer = drawerSwitchTarget && drawerControls\[drawerSwitchTarget\]\.isOpen[\s\S]*drawerControls\[drawer\]\.isOpen/);
|
||||
assert.match(component, /setDrawerSwitchTarget\(target\);[\s\S]*flushSync\(\(\) => toControl\.show\(\)\)/);
|
||||
assert.match(component, /fromControl\.close\(\);[\s\S]*setDrawerSwitchTarget\(null\)/);
|
||||
assert.match(component, /<SubscriptionToggle[\s\S]*open=\{activeRailDrawer === 'subscription'\}[\s\S]*<RoutingToggle[\s\S]*open=\{activeRailDrawer === 'routing'\}/);
|
||||
assert.match(component, /<SubscriptionToggle[\s\S]*open=\{activeRailDrawer === 'subscription'\}[\s\S]*<FailoverToggle[\s\S]*<RoutingToggle[\s\S]*open=\{activeRailDrawer === 'routing'\}[\s\S]*<ActivityJournalToggle/);
|
||||
assert.match(component, /const cancel = \(\) => \{[\s\S]*from\.inert = false;[\s\S]*from\.removeAttribute\('aria-hidden'\)/);
|
||||
assert.match(component, /DRAWER_SWITCH_MS = 620[\s\S]*prefers-reduced-motion: reduce[\s\S]*flushSync/);
|
||||
assert.match(component, /current === 'routing' && routingFeature\.dirty[\s\S]*routingFeature\.requestClose\(\)/);
|
||||
|
||||
@@ -31,36 +31,38 @@ const expectedImports = [
|
||||
'./features/servers.css',
|
||||
'./primitives.css',
|
||||
'./features/diagnostics.css',
|
||||
'./features/failover.css',
|
||||
'./features/activity-journal.css',
|
||||
'./layout.css',
|
||||
'./themes.css',
|
||||
];
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 954,
|
||||
cascadeEdges: 984,
|
||||
customProperties: 106,
|
||||
declarations: 3487,
|
||||
declarations: 3805,
|
||||
important: 0,
|
||||
keyframes: 48,
|
||||
media: 13,
|
||||
rules: 971,
|
||||
variableReferences: 837,
|
||||
media: 17,
|
||||
rules: 1045,
|
||||
variableReferences: 977,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'd53f6a2236717d6bc27d486fc19f0f80a33169fa7f68a6df2a19536fd30be3bb',
|
||||
cascadeEdges: '423d29f846b45c3522fdc101b5aa1399264f2abe5953b9687b380d5fb865bd07',
|
||||
customProperties: 'fd6f16069f9fe3526f09d4d574431ddae67150d8e7a8a40e04c9fd2d179ec7ac',
|
||||
declarations: 'e1401e80ed2ebc5d3601d4d61a3b44c2adbf91db451907ef95a0998170ac5de5',
|
||||
declarations: '52b5bcd795dbea19950afee0259c62cb0fb1aaca094d553cfaaae046a5f747f9',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||
keyframes: 'a0d69c5b3e5f235d3a76ed04bdd64f67a73fafc9c59fe2635ffda603709c0ad9',
|
||||
ruleDeclarationSequences: '89f5f3b302f3a10df0ba90dfbbc63a7ccbc1d8a0c784be26a475321f44dfa8e8',
|
||||
selectors: 'dc1a2687ec872d1922a39e8258977949a9b775168764a59119ae6ea93a9f88a1',
|
||||
variableReferences: '843a5f71b0fb74691ed623dbc7ebb7ddddc34e5736e1f419f17cf54e22d43073',
|
||||
witnesses: '81676a3fdb6f3bbd63e86b275b6cb6d40e930651b9d5cbde208408f3163ab2b4',
|
||||
ruleDeclarationSequences: 'bd8d10966c15df58eb76d67fc5a21acaeec524a64805797d5fb33af3b942292e',
|
||||
selectors: '76d2d373aa9ecfd5cac3f95a47ed0fa2c44a659ac98f02f6bfd2f4505de23226',
|
||||
variableReferences: 'bea2b082cf2366041936c2da22ac3e0ab83b3e5cead1326a0f65a015daedbb3b',
|
||||
witnesses: '605555d8bae9b8d64b1c960c398b14cf910e624d547b72e4fcfced85eec30991',
|
||||
},
|
||||
};
|
||||
|
||||
test('public stylesheet exposes exactly twelve flat semantic owners', () => {
|
||||
test('public stylesheet exposes exactly fourteen flat semantic owners', () => {
|
||||
const imports = Array.from(index.matchAll(/^@import ['"](\.\/[^'"]+\.css)['"];$/gm), ([, file]) => file);
|
||||
assert.deepEqual(imports, expectedImports);
|
||||
assert.equal(index, `${expectedImports.map((file) => `@import '${file}';`).join('\n')}\n`);
|
||||
@@ -209,7 +211,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 884);
|
||||
assert.equal(witnesses.length, 1021);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -405,8 +407,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||
|
||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||
assert.deepEqual(assets, ['index-CdbGat4L.css']);
|
||||
assert.deepEqual(assets, ['index-DaioW5qf.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 133670);
|
||||
assert.equal(sha256(built), '1aaa222692046eb166e5bfb8a195e53cf0ef61692a82ae53ca0119b4c8f878a2');
|
||||
assert.equal(built.byteLength, 144728);
|
||||
assert.equal(sha256(built), '7d175321821b38be6a947a76457e310cf519e2af4b9ee95fbb0097933ca70a28');
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
|
||||
|
||||
assert.equal((production.match(/<Tooltip\b/g) || []).length, 14);
|
||||
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
|
||||
assert.equal((production.match(/<RailAction\b/g) || []).length, 5);
|
||||
assert.equal((production.match(/<Drawer\b/g) || []).length, 5);
|
||||
assert.equal((production.match(/<RailAction\b/g) || []).length, 7);
|
||||
assert.equal((production.match(/<Drawer\b/g) || []).length, 7);
|
||||
assert.doesNotMatch(production, /className="client-tooltip"|className="client-copy-label"|className="client-drawer-close"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user