Files
joshuaandClaude Opus 5.5 a3e9c5b4f6 Change an open update request to a newer version (1.5.0)
When a site asks again after a newer version came out, the open
update_requested alert moves on to the latest version ("changed from
...") and the alert address is emailed once; the response says
changed: true with previousVersion (protocol §3a). Needs Cross Events
1.47.4 for the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:16:59 +02:00

154 lines
7.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);
});
});
describe('update requests', () => {
const { requestMessage, requestedVersionOf, isOutdated } = require('../src/utils/updateRequests');
test('the version asked for is read back from the alert message', () => {
expect(requestedVersionOf(requestMessage('1.46.2', '1.47.0'))).toBe('1.47.0');
expect(requestedVersionOf(requestMessage('1.46.2', '1.47.3', '1.47.0'))).toBe('1.47.3');
expect(requestMessage('1.46.2', '1.47.3', '1.47.0')).toBe('Update requested: runs 1.46.2, latest is 1.47.3 (changed from 1.47.0)');
});
test('a request is outdated once a newer version is out', () => {
expect(isOutdated(requestMessage('1.46.2', '1.47.0'), '1.47.3')).toBe(true);
expect(isOutdated(requestMessage('1.46.2', '1.47.3'), '1.47.3')).toBe(false);
expect(isOutdated(requestMessage('1.46.2', '1.47.10'), '1.47.9')).toBe(false);
// A message we can't read is treated as outdated, so asking again fixes it.
expect(isOutdated('Update requested', '1.47.3')).toBe(true);
});
});