Files
harbor-net/test/server/state-store.test.js
T
dokril 76a99f098a
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 13s
Add DNS diagnostics across dataplane and client
2026-09-01 04:16:15 +03:00

292 lines
10 KiB
JavaScript

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 {
atomicWriteJson,
createStateStore,
STATE_SCHEMA_VERSION,
} from '../../dist/server/services/stateStore.js';
import { createServerId } from '../../dist/shared/serverIdentity.js';
const fixture = (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return path.join(directory, 'state.json');
};
test('raw JSON stores stay typed unknown until a migrator validates them', () => {
const source = fs.readFileSync(
new URL('../../src/server/services/stateStore.ts', import.meta.url),
'utf8',
);
assert.match(source, /createJsonStore\(options: RawJsonStoreOptions\): JsonStore<unknown>/);
assert.match(source, /const migrate = options\.migrate \|\| \(\(value: unknown\) => value\)/);
assert.doesNotMatch(source, /value as T|migrate = \(value\) => value as/);
});
test('data invariant: failure before rename preserves the last successful file', (t) => {
const filePath = fixture(t);
atomicWriteJson(filePath, { revision: 1 });
assert.throws(
() => atomicWriteJson(filePath, { revision: 2 }, {
beforeRename: () => { throw new Error('injected failure'); },
}),
/injected failure/,
);
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')), { revision: 1 });
assert.equal(
fs.readdirSync(path.dirname(filePath)).some((name) => name.endsWith('.tmp')),
false,
);
});
test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) => {
const filePath = fixture(t);
const legacy = {
schemaVersion: 2,
revision: 7,
selectedTag: 'nl',
servers: [{ tag: 'nl', type: 'vless', server: 'nl.example', server_port: 443 }],
};
fs.writeFileSync(filePath, JSON.stringify(legacy));
const store = createStateStore(filePath, {
now: () => new Date('2026-07-11T12:00:00.000Z'),
});
const migrated = store.read();
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
assert.deepEqual(migrated.routeRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' },
]);
const primary = migrated.profiles[0];
assert.equal(primary.label, 'Основной');
assert.equal(primary.desiredServerId, createServerId(legacy.servers[0]));
assert.equal(migrated.appliedProfileId, primary.id);
assert.equal(migrated.appliedServerId, primary.desiredServerId);
assert.equal(Object.hasOwn(migrated, 'selectedServerId'), false);
assert.equal(store.migration.fromVersion, 2);
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
});
test('schema v5 migrates rules and diagnostics settings with an exact backup', (t) => {
const filePath = fixture(t);
const legacy = {
schemaVersion: 5,
revision: 11,
routeRulesRevision: 7,
routeRules: [
{ type: 'domain', value: 'api.example.com', enabled: true },
{ type: 'domain_suffix', value: 'example.com', enabled: false },
],
appliedRouteRules: [
{ type: 'domain_suffix', value: 'example.com', enabled: false },
{ type: 'domain', value: 'api.example.com', enabled: true },
],
};
const bytes = JSON.stringify(legacy);
fs.writeFileSync(filePath, bytes);
const store = createStateStore(filePath, {
now: () => new Date('2026-08-17T12:00:00.000Z'),
});
const migrated = store.read();
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
assert.deepEqual(migrated.diagnostics, {
configured: false,
customServices: [],
hiddenServiceIds: [],
customDnsResolvers: [],
customDnsDomains: [],
});
assert.equal(migrated.routeRulesRevision, 7);
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
assert.deepEqual(migrated.appliedRouteRules.map(({ type, outbound }) => [type, outbound]), [
['domain_suffix', 'direct'],
['domain', 'direct'],
]);
assert.equal(store.migration.fromVersion, 5);
assert.equal(store.migration.toVersion, STATE_SCHEMA_VERSION);
assert.match(store.migration.backupPath, /\.backup-v5-/);
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, STATE_SCHEMA_VERSION);
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.deepEqual(migrated.traffic, { grouping: 'site', sort: 'popular', retentionSeconds: 10 });
assert.equal(store.migration.fromVersion, 7);
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
});
test('schema v9 adds empty DNS settings atomically and preserves the exact backup', (t) => {
const filePath = fixture(t);
const legacy = {
schemaVersion: 9,
revision: 23,
routeRules: [],
appliedRouteRules: [],
diagnostics: {
configured: true,
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/' }],
hiddenServiceIds: ['youtube'],
},
};
const bytes = JSON.stringify(legacy);
fs.writeFileSync(filePath, bytes);
const store = createStateStore(filePath, {
now: () => new Date('2026-09-01T12:00:00.000Z'),
});
const migrated = store.read();
assert.equal(migrated.schemaVersion, 10);
assert.equal(migrated.revision, 23);
assert.deepEqual(migrated.diagnostics.customDnsResolvers, []);
assert.deepEqual(migrated.diagnostics.customDnsDomains, []);
assert.equal(store.migration.fromVersion, 9);
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
});
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 }],
['unknown', { type: 'domain', value: 'example.com', enabled: true, outbound: 'block' }],
]) {
const filePath = `${fixture(t)}-${name}`;
const bytes = JSON.stringify({
schemaVersion: 6,
routeRules: [rule],
appliedRouteRules: [],
});
fs.writeFileSync(filePath, bytes);
const store = createStateStore(filePath);
assert.throws(() => store.read(), /outbound/);
assert.equal(fs.readFileSync(filePath, 'utf8'), bytes);
assert.equal(store.migration, null);
}
});
test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
const filePath = fixture(t);
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 3,
selectedTag: 'Amsterdam',
servers: [
{ tag: 'Amsterdam', type: 'vless', server: 'nl-1.example', server_port: 443 },
{ tag: 'Amsterdam', type: 'vless', server: 'nl-2.example', server_port: 443 },
],
}));
const migrated = createStateStore(filePath).read();
assert.equal(migrated.profiles[0].desiredServerId, '');
assert.equal(migrated.appliedServerId, '');
assert.equal(migrated.profiles[0].servers.length, 2);
});
test('schema v4 migration never combines canonical state with a different cache owner', (t) => {
const filePath = fixture(t);
const stateServer = {
tag: 'State server',
type: 'vless',
server: 'state.example',
server_port: 443,
};
const cacheServer = {
tag: 'Cache server',
type: 'vless',
server: 'cache.example',
server_port: 8443,
};
const stateServerId = createServerId(stateServer);
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 4,
revision: 9,
subscriptionUrl: 'https://state.example/subscription-a',
servers: [stateServer],
selectedServerId: stateServerId,
appliedServerId: stateServerId,
connectionDesired: 'running',
}));
const migrated = createStateStore(filePath, {
legacySubscriptionCache: {
url: 'https://cache.example/subscription-b',
config: { outbounds: [cacheServer] },
servers: [cacheServer],
userInfo: { total: 123 },
fetchedAt: '2026-08-11T12:00:00.000Z',
},
}).read();
assert.equal(migrated.profiles.length, 1);
assert.equal(migrated.profiles[0].subscriptionUrl, 'https://state.example/subscription-a');
assert.equal(migrated.profiles[0].subscriptionConfig, null);
assert.deepEqual(migrated.profiles[0].servers.map(({ id }) => id), [stateServerId]);
assert.equal(migrated.profiles[0].desiredServerId, stateServerId);
assert.equal(migrated.appliedServerId, stateServerId);
assert.equal(migrated.appliedServerSnapshot.id, stateServerId);
assert.equal(JSON.stringify(migrated).includes('cache.example'), false);
});
test('data invariant: corrupt JSON preserves original bytes and returns explicit recovery state', (t) => {
const filePath = fixture(t);
fs.writeFileSync(filePath, '{broken');
const store = createStateStore(filePath, {
now: () => new Date('2026-07-11T12:00:00.000Z'),
});
const recovered = store.read();
assert.equal(recovered.schemaVersion, STATE_SCHEMA_VERSION);
assert.equal(recovered.revision, 0);
assert.equal(store.recovery.kind, 'corrupt-json');
assert.equal(fs.readFileSync(store.recovery.backupPath, 'utf8'), '{broken');
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
});
test('concurrent updates are serialized without lost values', async (t) => {
const store = createStateStore(fixture(t));
store.read();
await Promise.all(Array.from({ length: 50 }, () => Promise.resolve().then(() => (
store.update((state) => ({ ...state, counter: (state.counter || 0) + 1 }))
))));
assert.equal(store.read().counter, 50);
assert.throws(() => store.update(async (state) => state), /must be synchronous/);
});