Files
harbor-net/test/server/harbor-storage.test.js
T
dokril 1ae23d848b
Build and Deploy Gateway / build-and-push (push) Successful in 1m22s
Build and Deploy Gateway / deploy (push) Successful in 16s
Migrate Harbor state and traffic history to SQLite
2026-09-10 19:21:21 +03:00

98 lines
5.5 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import test from 'node:test';
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
import { migrateStoredState } from '../../dist/server/services/stateStore.js';
import { migrateDeviceInventoryState } from '../../dist/server/services/deviceInventoryService.js';
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-sqlite-'));
const stores = [];
t.after(() => {
for (const store of stores) if (store.db.isOpen) store.close();
fs.rmSync(directory, { recursive: true, force: true });
});
return { directory, open: () => { const store = openHarborStorage(directory); stores.push(store); return store; } };
}
test('atomic import preserves revisions, rules, device checkpoints and journal; JSON becomes backup only', (t) => {
const f = fixture(t);
const state = migrateStoredState({ revision: 41, routeRules: [{ type: 'domain_suffix', value: 'example.org', enabled: true }] });
const mac = 'aa:bb:cc:dd:ee:ff';
const devices = migrateDeviceInventoryState({ revision: 17, traffic: {
baselinesByMac: { [mac]: { epoch: 'kernel-1', uploadBytes: '9007199254740993', downloadBytes: '123' } },
totalsByMac: { [mac]: { uploadBytes: '9007199254740993', downloadBytes: '987', observedAt: '2026-09-10T10:00:00.000Z' } },
} });
const event = { id: '00000000-0000-4000-8000-000000000001', occurredAt: new Date().toISOString(),
type: 'connection.stopped', severity: 'info', source: 'connection', dedupeKey: null, data: {} };
for (const [name, value] of [['state.json', state], ['devices.json', devices], ['activity-journal.json', { schemaVersion: 1, events: [event] }]]) {
fs.writeFileSync(path.join(f.directory, name), JSON.stringify(value));
}
const originals = ['state.json', 'devices.json', 'activity-journal.json'].map((name) => fs.readFileSync(path.join(f.directory, name), 'utf8'));
let store = f.open();
assert.equal(store.imported, true);
assert.deepEqual(store.state.read(), state);
assert.deepEqual(store.devices.read(), devices);
assert.deepEqual(createActivityJournalService({ db: store.db }).page().events, [event]);
store.state.update((value) => ({ ...value, revision: 42 }));
store.devices.update((value) => ({ ...value, revision: 18 }));
store.close();
// Even broken obsolete files cannot override or break the canonical SQL state.
fs.writeFileSync(path.join(f.directory, 'state.json'), '{obsolete');
store = f.open();
assert.equal(store.imported, false);
assert.equal(store.state.read().revision, 42);
assert.equal(store.devices.read().revision, 18);
assert.equal(store.devices.read().traffic.baselinesByMac[mac].uploadBytes, '9007199254740993');
assert.equal(fs.readFileSync(path.join(f.directory, 'devices.json'), 'utf8'), originals[1]);
assert.equal(fs.readFileSync(path.join(f.directory, 'activity-journal.json'), 'utf8'), originals[2]);
});
test('failed import leaves no partial documents and can be retried after fixing the original', (t) => {
const f = fixture(t);
fs.writeFileSync(path.join(f.directory, 'state.json'), JSON.stringify({ revision: 9 }));
fs.writeFileSync(path.join(f.directory, 'devices.json'), '{broken');
assert.throws(f.open, /Cannot migrate devices.json/);
assert.equal(fs.readFileSync(path.join(f.directory, 'devices.json'), 'utf8'), '{broken');
const inspect = new DatabaseSync(path.join(f.directory, 'harbor.sqlite'));
assert.equal(inspect.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'documents'").get().n, 0);
inspect.close();
fs.writeFileSync(path.join(f.directory, 'devices.json'), '{}');
assert.equal(f.open().state.read().revision, 9);
});
test('an optional legacy null subscription cache migrates without changing the backup', (t) => {
const f = fixture(t);
fs.writeFileSync(path.join(f.directory, 'state.json'), JSON.stringify({ schemaVersion: 4, revision: 7 }));
fs.writeFileSync(path.join(f.directory, 'subscription-cache.json'), 'null');
assert.equal(f.open().state.read().revision, 7);
assert.equal(fs.readFileSync(path.join(f.directory, 'subscription-cache.json'), 'utf8'), 'null');
});
test('a database constraint failure rolls back the entire import including journal and marker', (t) => {
const f = fixture(t);
const event = { id: '00000000-0000-4000-8000-000000000001', occurredAt: new Date().toISOString(),
type: 'connection.stopped', severity: 'info', source: 'connection', dedupeKey: null, data: {} };
fs.writeFileSync(path.join(f.directory, 'activity-journal.json'), JSON.stringify({ schemaVersion: 1, events: [event, event] }));
assert.throws(f.open, /UNIQUE/);
const db = new DatabaseSync(path.join(f.directory, 'harbor.sqlite'));
assert.equal(db.prepare('PRAGMA user_version').get().user_version, 0);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n, 0);
db.close();
});
test('durable DB corruption, future versions and invalid synchronous mutations never fall back to old JSON', (t) => {
const f = fixture(t);
const store = f.open();
assert.throws(() => store.state.update(async (state) => state), /synchronous/);
assert.equal(store.state.read().revision, 0);
store.db.exec('PRAGMA user_version = 99');
store.close();
fs.writeFileSync(path.join(f.directory, 'state.json'), '{}');
assert.throws(f.open, /Unsupported Harbor database/);
});