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('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 = createActivityJournalService({ filePath, now: () => 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(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1); }); 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, {}); });