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); }); });