Files
hope-events/backend/tests/hub.test.js
T

130 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
jest.mock('../src/config/db', () => ({}));
const crypto = require('crypto');
const { verifyToken, stateFor } = require('../src/hub/licence');
const { incomeBand } = require('../src/hub/report');
const { isBlocked } = require('../src/hub/readOnly');
const { compareVersions } = require('../src/controllers/hubController');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
function sign(payload, key = privateKey) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `hel1.${body}.${crypto.sign(null, Buffer.from(`hel1.${body}`), key).toString('base64url')}`;
}
const NOW = new Date('2026-10-26T10:00:00Z');
const base = {
v: 1, installId: 'inst_1', status: 'active', graceDays: 14,
licenceExpiresAt: '2027-10-31T21:59:59.000Z',
issuedAt: '2026-10-26T09:45:00.000Z', validUntil: '2026-11-25T09:45:00.000Z',
};
describe('licence tokens', () => {
test('a token the Hub signed verifies; edited or foreign ones do not', () => {
expect(verifyToken(sign(base), publicKey)).toMatchObject({ installId: 'inst_1', status: 'active' });
const [p, , s] = sign(base).split('.');
const edited = `${p}.${Buffer.from(JSON.stringify({ ...base, status: 'active', licenceExpiresAt: '2099-01-01' })).toString('base64url')}.${s}`;
expect(verifyToken(edited, publicKey)).toBeNull();
const other = crypto.generateKeyPairSync('ed25519');
expect(verifyToken(sign(base, other.privateKey), publicKey)).toBeNull();
expect(verifyToken('', publicKey)).toBeNull();
expect(verifyToken(sign(base), null)).toBeNull();
});
});
describe('licence states', () => {
test('never connected → unlicensed, not read-only', () => {
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: false });
});
test('a token for another install counts as none', () => {
expect(stateFor(base, 'inst_other', NOW).state).toBe('unlicensed');
});
test('active until the earlier of the licence end and validUntil', () => {
expect(stateFor(base, 'inst_1', NOW)).toMatchObject({ state: 'active', readOnly: false });
expect(stateFor(base, 'inst_1', NOW).endsAt.toISOString()).toBe('2026-11-25T09:45:00.000Z');
});
test('grace after the end date for graceDays, with days left — then read-only', () => {
const expired = { ...base, licenceExpiresAt: '2026-10-20T00:00:00.000Z' };
expect(stateFor(expired, 'inst_1', NOW)).toMatchObject({ state: 'grace', readOnly: false, daysLeft: 8 });
expect(stateFor(expired, 'inst_1', new Date('2026-11-05T00:00:00Z'))).toMatchObject({ state: 'suspended', readOnly: true, expired: true });
});
test('Hub unreachable: still active for 30 days, then grace, then read-only', () => {
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-11-20T00:00:00Z')).state).toBe('active');
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-12-01T00:00:00Z')).state).toBe('grace');
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-12-15T00:00:00Z')).state).toBe('suspended');
});
test('suspended and revoked are read-only straight away', () => {
expect(stateFor({ ...base, status: 'suspended' }, 'inst_1', NOW)).toMatchObject({ state: 'suspended', readOnly: true });
expect(stateFor({ ...base, status: 'revoked' }, 'inst_1', NOW)).toMatchObject({ state: 'revoked', readOnly: true });
});
});
describe('heartbeat report', () => {
test('income is sent as a band, never an amount', () => {
expect(incomeBand(0)).toBe('R0–R1 000');
expect(incomeBand(1000)).toBe('R0–R1 000');
expect(incomeBand(1000.4)).toBe('R0–R1 000');
expect(incomeBand(1001)).toBe('R1 001–R5 000');
expect(incomeBand(19999)).toBe('R5 001–R20 000');
expect(incomeBand(100000)).toBe('R50 001–R100 000');
expect(incomeBand(250000)).toBe('over R100 000');
expect(incomeBand(null)).toBe('R0–R1 000');
});
});
describe('read-only mode', () => {
test.each([
['POST', '/api/events'],
['POST', '/api/registrations'],
['POST', '/api/registrations/manual'],
['PUT', '/api/registrations/abc/options'],
['POST', '/api/payments'],
['POST', '/api/payments/checkout'],
['POST', '/api/payments/pay/chk_1'],
['POST', '/api/waitlist'],
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
test.each([
['POST', '/api/tickets/scan/QR123'],
['GET', '/api/events'],
['PUT', '/api/registrations/abc'],
['POST', '/api/payments/refund'],
['POST', '/api/privacy/me/requests'],
['POST', '/api/hub/connect'],
['POST', '/api/users/login'],
['POST', '/api/reports/pdf'],
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
});
describe('pasting the Hub public key', () => {
const { normalisePem } = require('../src/hub/publicKey');
const pem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const base64 = pem.replace(/-----(BEGIN|END) PUBLIC KEY-----|\s/g, '');
test.each([
['proper PEM', pem],
['with \\n', pem.replace(/\n/g, '\\n')],
['all on one line', `-----BEGIN PUBLIC KEY-----${base64}-----END PUBLIC KEY-----`],
['just the base64', base64],
])('%s works', (label, input) => {
expect(() => crypto.createPublicKey(normalisePem(input))).not.toThrow();
expect(verifyToken(sign(base), crypto.createPublicKey(normalisePem(input)))).not.toBeNull();
});
test('empty means none', () => {
expect(normalisePem('')).toBeNull();
expect(normalisePem(undefined)).toBeNull();
});
});
test('version comparison for "update available"', () => {
expect(compareVersions('1.41.0', '1.41.1')).toBeLessThan(0);
expect(compareVersions('1.41.0', '1.41.0')).toBe(0);
expect(compareVersions('2.0.0', '1.50.3')).toBeGreaterThan(0);
});