Introduce stable server IDs for subscription state
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-12 11:59:22 +03:00
parent 005c7a101b
commit 267afc5c7e
16 changed files with 323 additions and 145 deletions

View File

@@ -11,6 +11,7 @@ import {
createStateSnapshot,
normalizeStoredState,
} from '../../src/shared/contracts/state.js';
import { createServerId } from '../../src/shared/serverIdentity.js';
import { HARBOR_VERSIONS } from '../../src/shared/versions.js';
const root = path.resolve(import.meta.dirname, '../..');
@@ -59,10 +60,12 @@ async function waitForState(port, child, stderr) {
}
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
const legacyServerId = createServerId(legacyServer);
const stored = normalizeStoredState({
subscriptionUrl: 'https://provider.example/subscription/test',
selectedTag: ' legacy ',
servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }],
servers: [legacyServer],
});
const snapshot = createStateSnapshot({
storedState: stored,
@@ -76,8 +79,8 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
assert.equal(snapshot.apiVersion, 1);
assert.deepEqual(snapshot.selection, {
desiredServerId: 'legacy',
appliedServerId: 'legacy',
desiredServerId: legacyServerId,
appliedServerId: legacyServerId,
});
assert.equal(snapshot.connection.process, 'running');
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
@@ -100,6 +103,7 @@ test('GET and domain mutations return one state shape with monotonic revisions',
tls: { enabled: true },
}],
};
const testServerId = createServerId(config.outbounds[0]);
fs.mkdirSync(binDir);
const singboxPath = path.join(binDir, 'sing-box');
const workingSingbox = `#!/usr/bin/env node
@@ -190,7 +194,7 @@ setInterval(() => {}, 60_000);
runtime: { singBox: '1.12.13' },
});
assertStateSnapshot(initial);
assert.equal(initial.selection.appliedServerId, 'test-vpn');
assert.equal(initial.selection.appliedServerId, testServerId);
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
@@ -266,7 +270,7 @@ setInterval(() => {}, 60_000);
);
assert.equal(missingServer.response.status, 404);
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, 'test-vpn');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
async function stateResponse(pathname, method = 'POST', body) {
const result = await request(port, pathname, method, body);
@@ -285,10 +289,14 @@ setInterval(() => {}, 60_000);
const fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' });
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
'test-vpn',
);
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
assert.deepEqual(applied.state.selection, {
desiredServerId: 'test-vpn',
appliedServerId: 'test-vpn',
desiredServerId: testServerId,
appliedServerId: testServerId,
});
assert.equal(applied.state.connection.process, 'running');
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
@@ -323,7 +331,7 @@ setInterval(() => {}, 60_000);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
{ domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
{ inbound: ['mixed-in'], outbound: testServerId },
]);
await mutation('/api/singbox/stop');

View File

@@ -9,6 +9,7 @@ import {
createStateStore,
STATE_SCHEMA_VERSION,
} from '../../src/server/services/stateStore.js';
import { createServerId } from '../../src/shared/serverIdentity.js';
const fixture = (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-'));
@@ -39,7 +40,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
schemaVersion: 2,
revision: 7,
selectedTag: 'nl',
servers: [{ tag: 'nl' }],
servers: [{ tag: 'nl', type: 'vless', server: 'nl.example', server_port: 443 }],
};
fs.writeFileSync(filePath, JSON.stringify(legacy));
@@ -53,11 +54,31 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(migrated.appliedTag, 'nl');
assert.equal(migrated.selectedServerId, createServerId(legacy.servers[0]));
assert.equal(migrated.appliedServerId, migrated.selectedServerId);
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('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.selectedServerId, '');
assert.equal(migrated.appliedServerId, '');
assert.equal(migrated.servers.length, 2);
});
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => {
const filePath = fixture(t);
fs.writeFileSync(filePath, '{broken');

View File

@@ -6,26 +6,51 @@ import {
selectRefreshedServer,
} from '../../src/server/subscription.js';
test('subscription server tags are trimmed for selection', () => {
const { servers } = parseSubscriptionBody(JSON.stringify({
outbounds: [{ type: 'vless', tag: 'de-frankfurt ', server: 'de.example', server_port: 443 }],
}));
assert.equal(servers[0].tag, 'de-frankfurt');
const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds }));
const outbound = (tag, server, server_port = 443) => ({
type: 'vless',
tag,
server,
server_port,
});
test('refreshed subscription keeps the selected server when its tag still exists', () => {
const servers = [{ tag: 'de' }, { tag: 'nl' }];
test('duplicate labels remain independently addressable by stable ID', () => {
const { config, servers } = parse([
outbound('Amsterdam', 'nl-1.example'),
outbound('Amsterdam', 'nl-2.example'),
]);
assert.equal(selectRefreshedServer('nl', servers), 'nl');
assert.equal(servers.length, 2);
assert.equal(servers[0].label, 'Amsterdam');
assert.equal(servers[1].label, 'Amsterdam');
assert.notEqual(servers[0].id, servers[1].id);
assert.deepEqual(config.outbounds.map((item) => item.tag), servers.map((server) => server.id));
});
test('refreshed subscription selects the first server when the old tag disappeared', () => {
const servers = [{ tag: 'de' }, { tag: 'nl' }];
test('provider reorder does not change server IDs or selection', () => {
const before = parse([outbound('DE', 'de.example'), outbound('NL', 'nl.example')]).servers;
const after = parse([outbound('NL', 'nl.example'), outbound('DE', 'de.example')]).servers;
const selectedId = before[1].id;
assert.equal(selectRefreshedServer('old-name', servers), 'de');
assert.deepEqual(
new Map(after.map((server) => [server.host, server.id])),
new Map(before.map((server) => [server.host, server.id])),
);
assert.equal(selectRefreshedServer(selectedId, before, after), selectedId);
});
test('refreshed subscription does not select a server before the first user choice', () => {
assert.equal(selectRefreshedServer('', [{ tag: 'de' }]), '');
test('cosmetic rename keeps selection for the same endpoint', () => {
const before = parse([outbound('Old name', 'nl.example')]).servers;
const after = parse([outbound('New name', 'nl.example')]).servers;
assert.equal(after[0].id, before[0].id);
assert.equal(selectRefreshedServer(before[0].id, before, after), before[0].id);
});
test('removed selected server requires an explicit new choice', () => {
const before = parse([outbound('DE', 'de.example'), outbound('NL', 'nl.example')]).servers;
const after = parse([outbound('DE', 'de.example')]).servers;
assert.equal(selectRefreshedServer(before[1].id, before, after), '');
assert.equal(selectRefreshedServer('', before, after), '');
});