- Release.product and requires; unique (product, version); latest version per product (latest_version stays Cross Events' latest) - Release sources per product (Releases page): repository, tag prefix, notes file. Defaults: every product from crosscode-platform plus Cross Events' v1.2.3 tags from hope-events. A product's releases are only removed when all its sources were read and found tags - "— requires accounts 1.2.0" in a notes heading: Request update answers 409 REQUIREMENT_NOT_MET until the church's install of that product is new enough; install page and Releases page show it; v2 replies carry requires - @crosscode/contracts 1.1.0 vendored (optional requires on releases) - Alerts, dashboard and install lists compare against each product's latest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
16 KiB
JavaScript
292 lines
16 KiB
JavaScript
// Hub 2.0 step 2: protocol v2 next to v1. The contract samples (@crosscode/contracts/samples) go
|
|
// through the real install API with signed requests, and every v2 reply is checked against the
|
|
// contract's response schema. In-memory database.
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const mockC1 = '0b8f3c2a-5d4e-4f6a-9b7c-1d2e3f4a5b6c';
|
|
const mockC2 = '1c9a4d3b-6e5f-4a7b-8c8d-2e3f4a5b6c7d';
|
|
const mockL_EVENTS = '2d0b5e4c-7f6a-4b8c-9d9e-3f4a5b6c7d8e';
|
|
const mockL_WEBSITE = '3e1c6f5d-8a7b-4c9d-8eaf-4a5b6c7d8e9f';
|
|
const mockL_OTHER = '4f2d7a6e-9b8c-4dae-9fb0-5b6c7d8e9fa0';
|
|
const mockDb = (() => {
|
|
const t = { licences: [], installs: [], events: [], heartbeats: [], releases: [] };
|
|
const id = p => `${p}_${crypto.randomUUID()}`;
|
|
const customers = { [mockC1]: { id: mockC1, name: 'Hope Family Church' }, [mockC2]: { id: mockC2, name: 'Grace Church' } };
|
|
const withLicence = l => l && { ...l, install: t.installs.find(i => i.licenceId === l.id) || null, customer: customers[l.customerId] };
|
|
return {
|
|
t,
|
|
licence: {
|
|
findUnique: jest.fn(async ({ where }) => withLicence(t.licences.find(l => (where.key ? l.key === where.key : l.id === where.id)))),
|
|
},
|
|
install: {
|
|
findUnique: jest.fn(async ({ where }) => t.installs.find(i => i.id === where.id) || null),
|
|
create: jest.fn(async ({ data }) => { const row = { id: crypto.randomUUID(), registeredAt: new Date(Date.now() + t.installs.length), releasedAt: null, ...data }; t.installs.push(row); return row; }),
|
|
update: jest.fn(async ({ where, data }) => Object.assign(t.installs.find(i => i.id === where.id), Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)))),
|
|
findMany: jest.fn(async ({ where, select }) => t.installs
|
|
.filter(i => i.customerId === where.customerId && i.id !== where.id.not && i.releasedAt === null)
|
|
.filter(i => !where.publicKey || i.publicKey != null)
|
|
.filter(i => !where.licence || t.licences.find(l => l.id === i.licenceId)?.status !== 'revoked')
|
|
.sort((a, b) => a.registeredAt - b.registeredAt)
|
|
.map(i => Object.fromEntries(Object.keys(select).map(k => [k, i[k]])))),
|
|
},
|
|
installEvent: {
|
|
create: jest.fn(async ({ data }) => { const row = { id: id('ev'), createdAt: new Date(), ...data }; t.events.push(row); return row; }),
|
|
updateMany: jest.fn(async () => ({ count: 0 })),
|
|
findFirst: jest.fn(async () => null),
|
|
findMany: jest.fn(async () => []),
|
|
},
|
|
heartbeat: { create: jest.fn(async ({ data }) => { t.heartbeats.push(data); return data; }) },
|
|
release: { findMany: jest.fn(async ({ where } = {}) => t.releases.filter(r => !where?.product || r.product === where.product)) },
|
|
customer: { findUnique: jest.fn(async ({ where }) => customers[where.id]) },
|
|
$transaction: jest.fn(async ops => Promise.all(ops)),
|
|
};
|
|
})();
|
|
jest.mock('../src/config/db', () => mockDb);
|
|
// Every test registers: the hourly register limit would cut in part-way through the file.
|
|
jest.mock('express-rate-limit', () => () => (req, res, next) => next());
|
|
jest.mock('../src/utils/settings', () => ({ getSettings: jest.fn(async () => ({})), setSettings: jest.fn(async () => ({})) }));
|
|
jest.mock('../src/utils/notices', () => ({ noticesForInstall: jest.fn(async () => []) }));
|
|
|
|
process.env.HUB_ENCRYPTION_KEY = 'test-encryption-key-that-is-long-enough-123';
|
|
const hubKey = crypto.generateKeyPairSync('ed25519');
|
|
process.env.HUB_SIGNING_KEY = hubKey.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
|
|
const express = require('express');
|
|
const { hub } = require('@crosscode/contracts');
|
|
const licenceToken = require('../src/utils/licenceToken');
|
|
const { signature } = require('../src/utils/signing');
|
|
|
|
const SAMPLES = path.join(path.dirname(require.resolve('@crosscode/contracts/package.json')), 'samples');
|
|
const sample = name => JSON.parse(fs.readFileSync(path.join(SAMPLES, name), 'utf8'));
|
|
const pem = () => crypto.generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
|
|
const KEYS = { events: 'CEH-7K2M-9QXA-P4RT-WD8C', website: 'CCW-AC3D-E4FG-H6JK-M7NP', events2: 'CEH-QRTU-VWXY-2346-79AC' };
|
|
|
|
let server;
|
|
let base;
|
|
beforeAll(async () => {
|
|
const app = express();
|
|
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); } }));
|
|
app.use('/api', require('../src/routes/installApi'));
|
|
await new Promise(resolve => { server = app.listen(0, '127.0.0.1', resolve); });
|
|
base = `http://127.0.0.1:${server.address().port}/api`;
|
|
});
|
|
afterAll(() => new Promise(resolve => server.close(resolve)));
|
|
|
|
beforeEach(() => {
|
|
const { t } = mockDb;
|
|
t.installs.length = 0; t.events.length = 0; t.heartbeats.length = 0;
|
|
t.releases.splice(0, t.releases.length,
|
|
{ product: 'events', version: '2.4.0', releasedAt: new Date('2026-11-20T08:00:00Z'), urgent: false, notes: '- Event feed', requires: [] },
|
|
{ product: 'events', version: '3.0.0', releasedAt: new Date('2026-12-01T08:00:00Z'), urgent: false, notes: '- Sign in with your church account', requires: [{ product: 'accounts', version: '1.2.0' }] },
|
|
{ product: 'website', version: '1.0.0', releasedAt: new Date('2026-11-25T08:00:00Z'), urgent: false, notes: '- First release', requires: [] },
|
|
{ product: 'website', version: '1.1.0', releasedAt: new Date('2026-12-05T08:00:00Z'), urgent: false, notes: '- Sermon series', requires: [] },
|
|
);
|
|
t.licences.splice(0, t.licences.length,
|
|
{ id: mockL_EVENTS, customerId: mockC1, product: 'events', key: KEYS.events, status: 'active', plan: 'Grow', expiresAt: null, graceDays: 14, aiMonthlyCapCents: 0 },
|
|
{ id: mockL_WEBSITE, customerId: mockC1, product: 'website', key: KEYS.website, status: 'active', plan: 'Website', expiresAt: null, graceDays: 14, aiMonthlyCapCents: 0 },
|
|
{ id: mockL_OTHER, customerId: mockC2, product: 'events', key: KEYS.events2, status: 'active', plan: 'Seed', expiresAt: null, graceDays: 14, aiMonthlyCapCents: 0 },
|
|
);
|
|
});
|
|
|
|
const post = (url, body, headers = {}) => fetch(`${base}${url}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
|
|
|
/** A signed request from an install (§3). */
|
|
function signed(url, reg, body) {
|
|
const raw = JSON.stringify(body);
|
|
const timestamp = new Date().toISOString();
|
|
return post(url, raw, { 'X-Hub-Install': reg.installId, 'X-Hub-Timestamp': timestamp, 'X-Hub-Signature': signature(reg.installSecret, timestamp, raw) });
|
|
}
|
|
|
|
const eventsRegistration = () => sample('hub-register-request.json');
|
|
const websiteRegistration = () => {
|
|
const r = sample('hub-register-request.json');
|
|
return { ...r, licenceKey: KEYS.website, product: 'website', install: { ...r.install, url: 'https://www.hopefamily.co.za', version: '1.0.0', features: {}, publicKey: pem() } };
|
|
};
|
|
|
|
async function register(body) {
|
|
const res = await post('/installs/register', body);
|
|
return { status: res.status, json: await res.json() };
|
|
}
|
|
|
|
describe('register', () => {
|
|
test('the v2 sample registers, and the reply fits the contract', async () => {
|
|
const { status, json } = await register(eventsRegistration());
|
|
expect(status).toBe(201);
|
|
expect(hub.registerResponseSchema.safeParse(json).error?.issues ?? []).toEqual([]);
|
|
expect(json.linkedProducts).toEqual([]);
|
|
expect(mockDb.t.installs[0]).toMatchObject({ product: 'events', protocolVersion: 2, publicKey: expect.stringContaining('BEGIN PUBLIC KEY') });
|
|
});
|
|
|
|
test('the licence token carries the customer and product, and fits the contract', async () => {
|
|
const { json } = await register(eventsRegistration());
|
|
const payload = licenceToken.verify(json.licence, hubKey.publicKey);
|
|
expect(hub.licencePayloadSchema.safeParse(payload).error?.issues ?? []).toEqual([]);
|
|
expect(payload).toMatchObject({ customerId: mockC1, product: 'events', installId: json.installId });
|
|
});
|
|
|
|
test('a second product lists the first in linkedProducts (and nothing more)', async () => {
|
|
const events = await register(eventsRegistration());
|
|
const { status, json } = await register(websiteRegistration());
|
|
expect(status).toBe(201);
|
|
expect(json.linkedProducts).toEqual([{ product: 'events', installId: events.json.installId, url: 'https://events.hopefamily.co.za', publicKey: eventsRegistration().install.publicKey }]);
|
|
});
|
|
|
|
test('another church\'s installs are never listed', async () => {
|
|
await register({ ...eventsRegistration(), licenceKey: KEYS.events2, install: { ...eventsRegistration().install, url: 'https://events.grace.test' } });
|
|
const { json } = await register(eventsRegistration());
|
|
expect(json.linkedProducts).toEqual([]);
|
|
});
|
|
|
|
test('a product that doesn\'t match the licence is refused', async () => {
|
|
const { status, json } = await register({ ...websiteRegistration(), product: 'events' });
|
|
expect(status).toBe(409);
|
|
expect(json.code).toBe('WRONG_PRODUCT');
|
|
});
|
|
|
|
test('a key typed in lower case with spaces still registers', async () => {
|
|
const { status } = await register({ ...eventsRegistration(), licenceKey: ' ceh-7k2m-9qxa-p4rt-wd8c ' });
|
|
expect(status).toBe(201);
|
|
});
|
|
|
|
test('a v2 body that breaks the contract is refused with what is wrong', async () => {
|
|
const body = eventsRegistration();
|
|
delete body.install.publicKey;
|
|
const { status, json } = await register(body);
|
|
expect(status).toBe(400);
|
|
expect(json).toMatchObject({ code: 'INVALID_REQUEST', message: expect.stringContaining('install.publicKey') });
|
|
});
|
|
|
|
test('v1 registers as before: Events, no linkedProducts, no public key', async () => {
|
|
await register(websiteRegistration());
|
|
const r = eventsRegistration();
|
|
const { status, json } = await register({ protocolVersion: 1, licenceKey: r.licenceKey, install: { name: r.install.name, url: r.install.url, version: '2.1.1' } });
|
|
expect(status).toBe(201);
|
|
expect(json).not.toHaveProperty('linkedProducts');
|
|
expect(mockDb.t.installs.find(i => i.product === 'events')).toMatchObject({ protocolVersion: 1, publicKey: undefined });
|
|
});
|
|
|
|
test('protocol 3 is refused', async () => {
|
|
const { status, json } = await register({ ...eventsRegistration(), protocolVersion: 3 });
|
|
expect(status).toBe(400);
|
|
expect(json.code).toBe('UNSUPPORTED_PROTOCOL');
|
|
});
|
|
});
|
|
|
|
describe('heartbeat', () => {
|
|
test.each([
|
|
['events', eventsRegistration, 'hub-heartbeat-events.json'],
|
|
['website', websiteRegistration, 'hub-heartbeat-website.json'],
|
|
])('the %s sample is recorded, and the reply fits the contract', async (product, registration, file) => {
|
|
const { json: reg } = await register(registration());
|
|
const res = await signed('/installs/heartbeat', reg, sample(file));
|
|
const json = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(hub.heartbeatResponseSchema.safeParse(json).error?.issues ?? []).toEqual([]);
|
|
expect(mockDb.t.installs.find(i => i.id === reg.installId).stats).toEqual(sample(file).stats);
|
|
});
|
|
|
|
test('linkedProducts in the heartbeat reply', async () => {
|
|
const { json: events } = await register(eventsRegistration());
|
|
const { json: website } = await register(websiteRegistration());
|
|
const json = await (await signed('/installs/heartbeat', events, sample('hub-heartbeat-events.json'))).json();
|
|
expect(json.linkedProducts.map(l => [l.product, l.installId])).toEqual([['website', website.installId]]);
|
|
});
|
|
|
|
test('each product is told its own releases', async () => {
|
|
const { json: website } = await register(websiteRegistration());
|
|
expect(website.latestVersion).toBe('1.1.0');
|
|
expect(website.releases.map(r => r.version)).toEqual(['1.1.0']);
|
|
const { json: events } = await register(eventsRegistration());
|
|
expect(events.latestVersion).toBe('3.0.0');
|
|
expect(events.releases.map(r => r.version)).toEqual(['3.0.0', '2.4.0']);
|
|
});
|
|
|
|
test('v2 replies carry requirements; v1 replies are as before', async () => {
|
|
const { json: v2 } = await register(eventsRegistration());
|
|
expect(v2.releases[0].requires).toEqual([{ product: 'accounts', version: '1.2.0' }]);
|
|
expect(v2.releases[1]).not.toHaveProperty('requires');
|
|
const r = eventsRegistration();
|
|
const { json: v1 } = await register({ protocolVersion: 1, licenceKey: r.licenceKey, install: { name: r.install.name, url: r.install.url, version: '2.1.1' } });
|
|
expect(v1.releases.every(x => !('requires' in x))).toBe(true);
|
|
});
|
|
|
|
test('a heartbeat for another product is refused', async () => {
|
|
const { json: reg } = await register(eventsRegistration());
|
|
const res = await signed('/installs/heartbeat', reg, sample('hub-heartbeat-website.json'));
|
|
expect(res.status).toBe(409);
|
|
expect((await res.json()).code).toBe('WRONG_PRODUCT');
|
|
});
|
|
|
|
test('a v2 heartbeat that breaks the contract is refused', async () => {
|
|
const { json: reg } = await register(eventsRegistration());
|
|
const body = sample('hub-heartbeat-events.json');
|
|
delete body.health;
|
|
const res = await signed('/installs/heartbeat', reg, body);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).code).toBe('INVALID_REQUEST');
|
|
});
|
|
|
|
test('a v1 heartbeat is answered as before (no linkedProducts)', async () => {
|
|
const r = eventsRegistration();
|
|
const { json: reg } = await register({ protocolVersion: 1, licenceKey: r.licenceKey, install: { name: r.install.name, url: r.install.url, version: '2.1.1' } });
|
|
const res = await signed('/installs/heartbeat', reg, { protocolVersion: 1, version: '2.1.1', health: { db: true }, stats: { users: 3 } });
|
|
const json = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(Object.keys(json).sort()).toEqual(['latestVersion', 'licence', 'notices', 'releases', 'serverTime']);
|
|
});
|
|
|
|
test('an unsigned heartbeat is refused', async () => {
|
|
const { json: reg } = await register(eventsRegistration());
|
|
const res = await post('/installs/heartbeat', sample('hub-heartbeat-events.json'), { 'X-Hub-Install': reg.installId, 'X-Hub-Timestamp': new Date().toISOString(), 'X-Hub-Signature': 'nope' });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('update requests (v2)', () => {
|
|
test('a v2 request without its version is refused', async () => {
|
|
const { json: reg } = await register(eventsRegistration());
|
|
const res = await signed('/installs/update-request', reg, { protocolVersion: 2 });
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).code).toBe('INVALID_REQUEST');
|
|
});
|
|
|
|
test('an update that needs Accounts first is refused until Accounts is new enough', async () => {
|
|
const { json: reg } = await register(eventsRegistration());
|
|
let res = await signed('/installs/update-request', reg, { protocolVersion: 2, version: '2.3.0' });
|
|
let json = await res.json();
|
|
expect(res.status).toBe(409);
|
|
expect(json).toMatchObject({ code: 'REQUIREMENT_NOT_MET', requirements: [{ product: 'accounts', version: '1.2.0', current: null }] });
|
|
expect(json.message).toContain('Accounts 1.2.0 or later must be installed first');
|
|
|
|
// The church's Accounts install, on 1.1.0: still refused, and the message says what it runs.
|
|
mockDb.t.installs.push({ id: crypto.randomUUID(), customerId: mockC1, product: 'accounts', version: '1.1.0', releasedAt: null, registeredAt: new Date() });
|
|
json = await (await signed('/installs/update-request', reg, { protocolVersion: 2, version: '2.3.0' })).json();
|
|
expect(json.message).toContain('Accounts must be updated to 1.2.0 first (it runs 1.1.0)');
|
|
|
|
// On 1.2.0: the request goes through, and fits the contract.
|
|
mockDb.t.installs.find(i => i.product === 'accounts').version = '1.2.0';
|
|
res = await signed('/installs/update-request', reg, { protocolVersion: 2, version: '2.3.0' });
|
|
json = await res.json();
|
|
expect(res.status).toBe(201);
|
|
expect(json.version).toBe('3.0.0');
|
|
expect(hub.updateRequestResponseSchema.safeParse(json).error?.issues ?? []).toEqual([]);
|
|
});
|
|
|
|
test('another church\'s Accounts doesn\'t count', async () => {
|
|
mockDb.t.installs.push({ id: crypto.randomUUID(), customerId: mockC2, product: 'accounts', version: '9.0.0', releasedAt: null, registeredAt: new Date() });
|
|
const { json: reg } = await register(eventsRegistration());
|
|
const res = await signed('/installs/update-request', reg, { protocolVersion: 2, version: '2.3.0' });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
test('a product with no requirements updates as before', async () => {
|
|
const { json: reg } = await register(websiteRegistration());
|
|
const res = await signed('/installs/update-request', reg, { protocolVersion: 2, version: '1.0.0' });
|
|
expect(res.status).toBe(201);
|
|
});
|
|
});
|