Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
130 lines
5.7 KiB
JavaScript
130 lines
5.7 KiB
JavaScript
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);
|
||
});
|