98 lines
5.5 KiB
JavaScript
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/);
|
|
});
|