Files
harbor-net/test/server/activity-journal-service.test.js
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

197 lines
8.7 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 { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
import { DatabaseSync } from 'node:sqlite';
import { assertActivityJournalPage } from '../../dist/shared/activityJournal.js';
function serviceFor(t, filePath, now) {
const storage = openHarborStorage(path.dirname(filePath));
t.after(() => storage.close());
return createActivityJournalService({ db: storage.db, now });
}
function persisted(filePath) {
const db = new DatabaseSync(path.join(path.dirname(filePath), 'harbor.sqlite'), { readOnly: true });
try { return { schemaVersion: 1, events: db.prepare('SELECT value FROM journal ORDER BY sequence').all().map((row) => JSON.parse(row.value)) }; }
finally { db.close(); }
}
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-journal-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return path.join(directory, 'activity-journal.json');
}
const event = (dedupeKey, profileLabel = 'Home') => ({
type: 'subscription.refreshed',
severity: 'info',
source: 'subscription',
dedupeKey: `subscription.refreshed:${dedupeKey}`,
data: { profileId: 'profile-1', profileLabel, host: 'provider.example', serverCount: 12, added: 2, removed: 1 },
});
test('journal appends typed events, deduplicates and keeps stable newest-first cursors', (t) => {
let clock = new Date('2026-08-19T10:00:00.000Z');
const filePath = fixture(t);
const service = serviceFor(t, filePath, () => clock);
service.append(event('refresh:1', 'One'));
clock = new Date('2026-08-19T10:01:00.000Z');
service.append(event('refresh:2', 'Two'));
service.append(event('refresh:2', 'Duplicate'));
const first = service.page(1);
assert.equal(first.events[0].data.profileLabel, 'Two');
assert.ok(first.nextCursor);
clock = new Date('2026-08-19T10:02:00.000Z');
service.append(event('refresh:3', 'Three'));
const older = service.page(10, first.nextCursor);
assert.deepEqual(older.events.map(({ data }) => data.profileLabel), ['One']);
assert.equal(first.events[0].dedupeKey, null);
const inode = fs.statSync(path.join(path.dirname(filePath), 'harbor.sqlite')).ino;
assert.equal(service.page(10).events.length, 3);
assert.equal(fs.statSync(path.join(path.dirname(filePath), 'harbor.sqlite')).ino, inode);
assert.deepEqual(service.page(10, 'expired-cursor').events, []);
});
test('journal prunes events older than 30 days and rejects unsafe payloads', (t) => {
let clock = new Date('2026-07-01T00:00:00.000Z');
const filePath = fixture(t);
const service = serviceFor(t, filePath, () => clock);
service.append(event('old'));
clock = new Date('2026-08-19T00:00:00.000Z');
assert.deepEqual(service.page().events, []);
assert.deepEqual(persisted(filePath).events, []);
service.append(event('new'));
assert.throws(() => service.append({ ...event('unsafe'), data: { rawUrl: 'https://secret' } }), /Unsafe/);
service.append({ ...event('ip'), data: { ...event('ip').data, host: '192.168.1.1' } });
service.append(event('credential-label', 'https://user:pass@example.test/private?token=secret'));
service.append(event('path-label', '192.168.1.7/private'));
service.append(event('ipv6-label', '2001:db8::1'));
const page = service.page();
const labels = page.events.slice(0, 3).map(({ data }) => data.profileLabel);
assert.deepEqual(labels, ['Подписка', 'Подписка', 'Подписка']);
assert.equal(page.events.find(({ data }) => data.host === 'Провайдер')?.data.host, 'Провайдер');
for (const dedupeKey of [
'subscription.refreshed:192.168.1.1',
'subscription.refreshed:2001:db8::1',
'subscription.refreshed:user:pass',
]) service.append({ ...event('safe'), dedupeKey });
service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:user:pass' });
const persistedText = JSON.stringify(persisted(filePath));
assert.doesNotMatch(persistedText, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
assert.match(persistedText, /subscription\.refreshed:sha256:[a-f0-9]{64}/);
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'https://user:pass@example.test/private?token=x' }), /dedupe/i);
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:private/path' }), /dedupe/i);
});
test('journal persists the 10,000 event cap when opening an oversized store', (t) => {
const filePath = fixture(t);
const occurredAt = '2026-08-19T00:00:00.000Z';
const events = Array.from({ length: 10_001 }, (_, index) => ({
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
occurredAt,
...event(`event:${index}`),
}));
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, events }));
const service = serviceFor(t, filePath, () => new Date('2026-08-19T01:00:00.000Z'));
assert.equal(service.page(1).events.length, 1);
assert.equal(persisted(filePath).events.length, 10_000);
});
test('corrupt journal blocks import and leaves the original intact', (t) => {
const filePath = fixture(t);
fs.writeFileSync(filePath, '{broken');
assert.throws(() => serviceFor(t, filePath), /Cannot migrate activity-journal.json/);
assert.equal(fs.readFileSync(filePath, 'utf8'), '{broken');
});
test('journal exposes a latched write failure until a later append succeeds', (t) => {
const filePath = fixture(t);
const service = serviceFor(t, filePath);
service.append(event('before-error'));
const db = new DatabaseSync(path.join(path.dirname(filePath), 'harbor.sqlite'));
db.exec("CREATE TRIGGER reject_journal BEFORE INSERT ON journal BEGIN SELECT RAISE(ABORT, 'simulated journal failure'); END");
try {
assert.throws(() => service.append(event('lost')));
const failed = service.page();
assert.equal(failed.storage.status, 'error');
assert.equal(failed.storage.errorCode, 'JOURNAL_UNAVAILABLE');
assert.equal(failed.events.length, 1);
assert.equal(failed.events[0].dedupeKey, null);
} finally {
db.exec('DROP TRIGGER reject_journal');
db.close();
}
service.append(event('recovered'));
assert.equal(service.page().storage.status, 'ready');
});
test('schema version 1 keeps legacy recovery and accepts per-channel health events', (t) => {
const filePath = fixture(t);
const occurredAt = '2026-08-19T09:00:00.000Z';
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 1,
events: [{
id: '00000000-0000-4000-8000-000000000001',
occurredAt,
type: 'failover.recovered',
severity: 'info',
source: 'failover',
dedupeKey: null,
data: { role: 'primary', reason: 'primary-recovered' },
}],
}));
let clock = new Date('2026-08-19T10:00:00.000Z');
const service = serviceFor(t, filePath, () => clock);
const inputs = [
['failover.primary_unavailable', 'primary', 'warning', 'probe-failed'],
['failover.primary_recovered', 'primary', 'info', 'probe-recovered'],
['failover.reserve_unavailable', 'reserve', 'warning', 'probe-failed'],
['failover.reserve_recovered', 'reserve', 'info', 'probe-recovered'],
];
for (const [type, role, severity, reason] of inputs) {
service.append({
type,
severity,
source: 'failover',
dedupeKey: null,
data: { role, reason },
});
clock = new Date(clock.getTime() + 1_000);
}
const page = service.page();
assert.deepEqual(page.events.map(({ type }) => type), [
'failover.reserve_recovered',
'failover.reserve_unavailable',
'failover.primary_recovered',
'failover.primary_unavailable',
'failover.recovered',
]);
assert.deepEqual(page.events.at(-1).data, { role: 'primary', reason: 'primary-recovered' });
assert.equal(page.retentionDays, 30);
assert.equal(persisted(filePath).schemaVersion, 1);
});
test('journal page parser rejects malformed wire data and strips unknown event fields', (t) => {
const service = serviceFor(t, fixture(t));
service.append(event('wire'));
const page = service.page();
const parsed = assertActivityJournalPage({
...page,
events: page.events.map((item) => ({ ...item, ignored: 'value' })),
});
assert.equal(Object.hasOwn(parsed.events[0], 'ignored'), false);
assert.throws(() => assertActivityJournalPage({ ...page, retentionDays: 31 }), TypeError);
assert.throws(() => assertActivityJournalPage({ ...page, events: [{ broken: true }] }), TypeError);
const future = assertActivityJournalPage({
...page,
events: [{ ...page.events[0], type: 'future.safe_event', data: { raw: 'not exposed' } }],
});
assert.equal(future.events[0].type, 'unknown');
assert.deepEqual(future.events[0].data, {});
});