149 lines
6.8 KiB
JavaScript
149 lines
6.8 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 { assertActivityJournalPage } from '../../dist/shared/activityJournal.js';
|
|
|
|
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 = createActivityJournalService({ filePath, now: () => 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(filePath).ino;
|
|
assert.equal(service.page(10).events.length, 3);
|
|
assert.equal(fs.statSync(filePath).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 = createActivityJournalService({ filePath, now: () => clock });
|
|
service.append(event('old'));
|
|
clock = new Date('2026-08-19T00:00:00.000Z');
|
|
assert.deepEqual(service.page().events, []);
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')).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 persisted = fs.readFileSync(filePath, 'utf8');
|
|
assert.doesNotMatch(persisted, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
|
|
assert.match(persisted, /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 = createActivityJournalService({
|
|
filePath,
|
|
now: () => new Date('2026-08-19T01:00:00.000Z'),
|
|
});
|
|
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).events.length, 10_000);
|
|
assert.equal(service.page(1).events.length, 1);
|
|
});
|
|
|
|
test('corrupt journal is isolated and recovery becomes a safe event', (t) => {
|
|
const filePath = fixture(t);
|
|
fs.writeFileSync(filePath, '{broken');
|
|
const service = createActivityJournalService({
|
|
filePath,
|
|
now: () => new Date('2026-08-19T12:00:00.000Z'),
|
|
});
|
|
const page = service.page();
|
|
assert.equal(page.storage.status, 'ready');
|
|
assert.equal(page.events[0].type, 'journal.recovered');
|
|
assert.ok(fs.readdirSync(path.dirname(filePath)).some((name) => name.includes('.corrupt-')));
|
|
});
|
|
|
|
test('journal exposes a latched write failure until a later append succeeds', (t) => {
|
|
const filePath = fixture(t);
|
|
const service = createActivityJournalService({ filePath });
|
|
service.append(event('before-error'));
|
|
const renameSync = fs.renameSync;
|
|
fs.renameSync = (source, target) => {
|
|
if (target === filePath) throw new Error('simulated journal write failure');
|
|
return renameSync(source, target);
|
|
};
|
|
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 {
|
|
fs.renameSync = renameSync;
|
|
}
|
|
service.append(event('recovered'));
|
|
assert.equal(service.page().storage.status, 'ready');
|
|
});
|
|
|
|
test('journal page parser rejects malformed wire data and strips unknown event fields', (t) => {
|
|
const service = createActivityJournalService({ filePath: 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, {});
|
|
});
|