Files
events-hub/backend/tests/core.test.js
joshuaandClaude Opus 5.5 e72330893a Hub v1: install API, licences, health dashboard, alerts
- Protocol 1 (docs/hub-protocol.md): public key, register, signed heartbeats
- Ed25519 licence tokens (30-day validity, status, expiry, grace, limits)
- Dashboard: installs, install detail, customers and licences, alerts,
  settings and audit log; two-factor login required
- Alert checks every 5 minutes, emailed once; heartbeat roll-up after 30 days
- Unit tests for encryption, tokens, signatures, TOTP and alert rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-26 12:12:51 +02:00

136 lines
6.4 KiB
JavaScript

jest.mock('../src/config/db', () => ({}));
const cryptoNode = require('crypto');
process.env.HUB_ENCRYPTION_KEY = 'test-encryption-key-that-is-long-enough-123';
const hubCrypto = require('../src/utils/crypto');
const licenceToken = require('../src/utils/licenceToken');
const { signature, checkSignature } = require('../src/utils/signing');
const totp = require('../src/utils/totp');
const { problems, compareVersions } = require('../src/utils/alerts');
describe('secrets at rest', () => {
test('encrypt/decrypt round-trip, different every time', () => {
const a = hubCrypto.encrypt('s3cret');
expect(a).not.toBe(hubCrypto.encrypt('s3cret'));
expect(hubCrypto.decrypt(a)).toBe('s3cret');
});
test('tampering is detected', () => {
const [v, iv, tag, data] = hubCrypto.encrypt('s3cret').split(':');
const flipped = Buffer.from(data, 'base64url');
flipped[0] ^= 1;
expect(() => hubCrypto.decrypt([v, iv, tag, flipped.toString('base64url')].join(':'))).toThrow();
});
test('licence keys look like CEH-XXXX-XXXX-XXXX-XXXX with no look-alike characters', () => {
const key = hubCrypto.licenceKey();
expect(key).toMatch(/^CEH(-[ACDEFGHJKMNPQRTUVWXY234679]{4}){4}$/);
expect(hubCrypto.normaliseLicenceKey(' ceh-7k2m-9qxa-p4rt-wd8c ')).toBe('CEH-7K2M-9QXA-P4RT-WD8C');
});
});
describe('licence tokens', () => {
const { privateKey, publicKey } = cryptoNode.generateKeyPairSync('ed25519');
const licence = { id: 'lic_1', plan: 'standard', status: 'active', expiresAt: new Date('2027-10-31T00:00:00Z'), graceDays: 14, aiMonthlyCapCents: 5000 };
const now = new Date('2026-10-24T08:00:00Z');
test('payload is valid for 30 days and carries the limits', () => {
const p = licenceToken.buildPayload(licence, { id: 'inst_1' }, 'Hope Church', now);
expect(p).toMatchObject({ v: 1, licenceId: 'lic_1', installId: 'inst_1', customer: 'Hope Church', status: 'active', graceDays: 14, limits: { aiMonthlyCapCents: 5000 } });
expect(p.validUntil).toBe('2026-11-23T08:00:00.000Z');
expect(p.licenceExpiresAt).toBe('2027-10-31T00:00:00.000Z');
});
test('signed tokens verify, edited ones do not', () => {
const token = licenceToken.sign({ status: 'active' }, privateKey);
expect(token.startsWith('hel1.')).toBe(true);
expect(licenceToken.verify(token, publicKey)).toEqual({ status: 'active' });
const [prefix, , sig] = token.split('.');
const forged = `${prefix}.${Buffer.from(JSON.stringify({ status: 'active', plan: 'gold' })).toString('base64url')}.${sig}`;
expect(licenceToken.verify(forged, publicKey)).toBeNull();
expect(licenceToken.verify('nonsense', publicKey)).toBeNull();
const other = cryptoNode.generateKeyPairSync('ed25519');
expect(licenceToken.verify(token, other.publicKey)).toBeNull();
});
});
describe('request signatures', () => {
const secret = 'install-secret';
const now = Date.parse('2026-10-24T08:00:00Z');
const ts = '2026-10-24T08:00:30.000Z';
const body = '{"protocolVersion":1}';
test('a correct signature passes', () => {
expect(checkSignature({ secret, timestamp: ts, rawBody: body, given: signature(secret, ts, body), now })).toBeNull();
});
test('a changed body, wrong secret or old timestamp fails', () => {
expect(checkSignature({ secret, timestamp: ts, rawBody: body + ' ', given: signature(secret, ts, body), now })).toBe('BAD_SIGNATURE');
expect(checkSignature({ secret: 'other', timestamp: ts, rawBody: body, given: signature(secret, ts, body), now })).toBe('BAD_SIGNATURE');
const old = '2026-10-24T07:50:00.000Z';
expect(checkSignature({ secret, timestamp: old, rawBody: body, given: signature(secret, old, body), now })).toBe('STALE_REQUEST');
expect(checkSignature({ secret, timestamp: 'nope', rawBody: body, given: 'x', now })).toBe('STALE_REQUEST');
});
});
describe('two-factor codes', () => {
// RFC 6238 test secret "12345678901234567890" (SHA-1): at T=59s the 8-digit code is 94287082.
const secret = totp.base32Encode(Buffer.from('12345678901234567890'));
test('matches the RFC test vector', () => {
expect(totp.codeAt(secret, 1)).toBe('287082');
expect(totp.verifyCode(secret, '287082', 59 * 1000)).toBe(true);
});
test('accepts one step of clock drift, not more', () => {
expect(totp.verifyCode(secret, '287082', 89 * 1000)).toBe(true);
expect(totp.verifyCode(secret, '287082', 150 * 1000)).toBe(false);
expect(totp.verifyCode(secret, '12345', 59 * 1000)).toBe(false);
});
test('base32 round-trips', () => {
const buf = cryptoNode.randomBytes(20);
expect(totp.base32Decode(totp.base32Encode(buf)).equals(buf)).toBe(true);
});
});
describe('alerts', () => {
const settings = { offline_minutes: '60', backup_max_age_hours: '48', disk_min_free_percent: '10', errors_per_day: '50', licence_expiry_warning_days: '14', latest_version: '1.42.0' };
const now = new Date('2026-10-24T12:00:00Z');
const healthy = {
registeredAt: new Date('2026-01-01'),
lastHeartbeatAt: new Date('2026-10-24T11:50:00Z'),
version: '1.42.0',
health: { db: true, lastBackupAt: '2026-10-24T02:00:00Z', disk: { freeBytes: 50, totalBytes: 100 }, errorsLast24h: 0, jobs: { failedLast24h: 0 }, pendingMigrations: 0 },
licence: { status: 'active', expiresAt: new Date('2027-10-31'), graceDays: 14 },
};
test('a healthy install has no problems', () => {
expect(problems(healthy, settings, now)).toEqual([]);
});
test('offline, stale backup, low disk, errors, expiring licence, behind', () => {
const types = problems({
...healthy,
lastHeartbeatAt: new Date('2026-10-24T10:00:00Z'),
version: '1.41.3',
health: { ...healthy.health, lastBackupAt: '2026-10-21T02:00:00Z', disk: { freeBytes: 5, totalBytes: 100 }, errorsLast24h: 80 },
licence: { status: 'active', expiresAt: new Date('2026-11-01'), graceDays: 14 },
}, settings, now).map(p => p.type);
expect(types).toEqual(['offline', 'backup_stale', 'disk_low', 'errors_high', 'licence_expiring', 'behind']);
});
test('a never-seen install is offline an hour after registering, with no health alerts', () => {
const types = problems({ registeredAt: new Date('2026-10-24T10:00:00Z'), lastHeartbeatAt: null, health: null, licence: null }, settings, now).map(p => p.type);
expect(types).toEqual(['offline']);
});
test('version comparison', () => {
expect(compareVersions('1.40.2', '1.41.0')).toBeLessThan(0);
expect(compareVersions('1.41.0', '1.41.0')).toBe(0);
expect(compareVersions('2.0.0', '1.99.9')).toBeGreaterThan(0);
});
});