Clarify and strengthen state invariant tests
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-07-12 12:22:59 +03:00
parent 5e33360c92
commit 2a214fc28b
5 changed files with 76 additions and 5 deletions

View File

@@ -0,0 +1,17 @@
# Data consistency regression suite
`npm test` is the required fast regression gate. It uses generated fixtures, temporary directories, loopback HTTP servers and a fake sing-box executable; it does not require internet, root or an installed sing-box.
The protected invariants are:
| Invariant | Regression coverage |
|---|---|
| One backend canonical snapshot owns servers and desired/applied selection | `test/data-consistency-regression.test.js`, `test/server/state-contract.test.js` |
| Revisions increase and an older response cannot replace newer state | `test/server/state-contract.test.js`, `test/web/harbor-state.test.js` |
| Provider failure, parser failure and runtime failure do not partially commit subscription state | `test/server/state-contract.test.js` |
| Atomic write failure preserves the last file and corrupt JSON preserves its original bytes | `test/server/state-store.test.js` |
| Legacy state migrates with an explicit result for ambiguous selection | `test/server/state-store.test.js` |
| Stable IDs survive reorder and duplicate labels for 1, 30 and 300 servers | `test/data-consistency-regression.test.js`, `test/server/subscription.test.js` |
| Initial control outage does not invent domain state; repeated failures retain and mark the last snapshot stale | `test/web/harbor-state.test.js` |
Fixtures are generated in test code to keep the suite small and deterministic. Packet-level networking, browser screenshots and accessibility automation are intentionally deferred to their dedicated roadmap tasks.

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubscriptionBody } from '../src/server/subscription.js';
import { createStateSnapshot, normalizeStoredState } from '../src/shared/contracts/state.js';
const outbound = (index) => ({
type: 'vless',
tag: index % 2 ? 'Amsterdam' : 'Frankfurt',
server: `vpn-${index}.example.test`,
server_port: 443,
});
const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds }));
test('data invariant: 1, 30 and 300 servers keep unique IDs across reorder and duplicate labels', () => {
for (const size of [1, 30, 300]) {
const source = Array.from({ length: size }, (_, index) => outbound(index));
const before = parse(source).servers;
const after = parse([...source].reverse()).servers;
assert.equal(before.length, size);
assert.equal(new Set(before.map((server) => server.id)).size, size);
assert.deepEqual(
after.map((server) => server.id).sort(),
before.map((server) => server.id).sort(),
);
}
});
test('data invariant: one canonical snapshot owns server selection and never exposes the subscription URL', () => {
const servers = parse(Array.from({ length: 30 }, (_, index) => outbound(index))).servers;
const selectedServerId = servers[17].id;
const stored = normalizeStoredState({
revision: 9,
subscriptionUrl: 'https://provider.example/private-token',
servers,
selectedServerId,
appliedServerId: selectedServerId,
});
const snapshot = createStateSnapshot({
storedState: stored,
runtime: { running: false },
appMode: 'client',
configExists: true,
subscriptionHost: 'provider.example/…',
now: new Date('2026-07-12T12:00:00.000Z'),
});
assert.equal(snapshot.revision, 9);
assert.deepEqual(snapshot.selection, { desiredServerId: selectedServerId, appliedServerId: selectedServerId });
assert.equal(snapshot.servers.find((server) => server.id === selectedServerId)?.host, 'vpn-17.example.test');
assert.equal(JSON.stringify(snapshot).includes('private-token'), false);
});

View File

@@ -90,7 +90,7 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
); );
}); });
test('GET and domain mutations return one state shape with monotonic revisions', async (t) => { test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
const binDir = path.join(dir, 'bin'); const binDir = path.join(dir, 'bin');
const config = { const config = {

View File

@@ -17,7 +17,7 @@ const fixture = (t) => {
return path.join(directory, 'state.json'); return path.join(directory, 'state.json');
}; };
test('a failure before rename preserves the last successful file', (t) => { test('data invariant: failure before rename preserves the last successful file', (t) => {
const filePath = fixture(t); const filePath = fixture(t);
atomicWriteJson(filePath, { revision: 1 }); atomicWriteJson(filePath, { revision: 1 });
@@ -79,7 +79,7 @@ test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
assert.equal(migrated.servers.length, 2); assert.equal(migrated.servers.length, 2);
}); });
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => { test('data invariant: corrupt JSON preserves original bytes and returns explicit recovery state', (t) => {
const filePath = fixture(t); const filePath = fixture(t);
fs.writeFileSync(filePath, '{broken'); fs.writeFileSync(filePath, '{broken');

View File

@@ -26,7 +26,7 @@ function deferred() {
return { promise, resolve }; return { promise, resolve };
} }
test('an older polling promise cannot replace a newer mutation snapshot', async () => { test('data invariant: an older polling promise cannot replace a newer mutation snapshot', async () => {
let state = receive(initialHarborState, snapshot(1, 'one')); let state = receive(initialHarborState, snapshot(1, 'one'));
const poll = deferred(); const poll = deferred();
const mutation = deferred(); const mutation = deferred();
@@ -68,7 +68,7 @@ test('pending selection is cleared when its server disappears', () => {
assert.equal(receive(state, snapshot(2, 'one', ['one'])).pendingServerId, ''); assert.equal(receive(state, snapshot(2, 'one', ['one'])).pendingServerId, '');
}); });
test('initial 500 and connection refusal become retryable boot failures', () => { test('data invariant: initial control outage is retryable without a fabricated snapshot', () => {
const serverError = Object.assign(new Error('Internal Server Error'), { status: 500 }); const serverError = Object.assign(new Error('Internal Server Error'), { status: 500 });
const refused = new TypeError('fetch failed'); const refused = new TypeError('fetch failed');