geocrop-platform./apps/nextgen/server/test/licensing.integration.test.js

243 lines
11 KiB
JavaScript

const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const SUPERADMIN_DIR = path.join(__dirname, '..', '..', 'superadmin', 'server');
const SERVER_DIR = path.join(__dirname, '..');
const LicenseTokenGenerator = require(path.join(SUPERADMIN_DIR, 'src', 'services', 'LicenseTokenGenerator'));
const LicenseCache = require(path.join(SERVER_DIR, 'src', 'services', 'LicenseCacheService'));
const Database = require('better-sqlite3');
const TenantId = 'integration-test-tenant-' + Date.now();
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? 'PASS' : 'FAIL';
console.log(`${tag} ${name}${detail ? ' — ' + detail : ''}`);
}
function buildValidPayload(overrides) {
const start = new Date('2026-01-01T00:00:00Z');
const end = overrides && overrides.expired
? new Date('2020-01-01T00:00:00Z')
: new Date('2027-01-01T00:00:00Z');
return Object.assign({
tenant_id: TenantId,
tenant_code: 'TST',
term_name: 'Term 1',
academic_year: '2026',
start_date: start.toISOString(),
end_date: end.toISOString(),
max_students: 100,
max_staff: 20
}, overrides || {});
}
function makeReq(pathname, method, headers) {
return {
method,
path: pathname,
headers: headers || {},
tenantId: undefined
};
}
function makeRes() {
const headers = {};
return {
statusCode: 200,
body: undefined,
headers,
setHeader(k, v) { headers[k] = v; },
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; }
};
}
async function main() {
const generated = LicenseTokenGenerator.generateToken(buildValidPayload({}));
record('T15 sign EdDSA', generated && generated.token && generated.token.split('.').length === 3, '3-segment token');
const headerB64 = generated.token.split('.')[0];
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'));
record('T15 header alg=EdDSA', header.alg === 'EdDSA' && header.typ === 'JWT', JSON.stringify(header));
const verifyOk = LicenseTokenGenerator.verifyToken(generated.token);
record('T15 verifyToken accepts valid token', verifyOk && verifyOk.isValid === true);
const tampered = generated.token.slice(0, -2) + (generated.token.endsWith('A') ? 'B' : 'A');
const verifyBad = LicenseTokenGenerator.verifyToken(tampered);
record('T15 verifyToken rejects tampered signature', verifyBad && verifyBad.isValid === false);
const hsHeader = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const hsPayload = Buffer.from(JSON.stringify({ sub: 'x' })).toString('base64url');
const hsSig = Buffer.from('not-a-real-signature').toString('base64url');
const hsToken = `${hsHeader}.${hsPayload}.${hsSig}`;
const verifyHs = LicenseTokenGenerator.verifyToken(hsToken);
record('T15 verifyToken rejects HS256', verifyHs && verifyHs.isValid === false, verifyHs && verifyHs.error);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-int-'));
const dbPath = path.join(tmpDir, 'tenant.db');
const db = new Database(dbPath);
db.exec(`CREATE TABLE IF NOT EXISTS tenant_licenses (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
term_name TEXT NOT NULL,
academic_year TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
grace_period_days INTEGER DEFAULT 7,
max_students INTEGER DEFAULT 1000,
max_staff INTEGER DEFAULT 200,
license_key TEXT NOT NULL,
signed_license_token TEXT NOT NULL,
algorithm TEXT DEFAULT 'EdDSA',
status TEXT DEFAULT 'ACTIVE',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.prepare(`INSERT INTO tenant_licenses
(id, tenant_id, term_name, academic_year, start_date, end_date, license_key, signed_license_token, algorithm, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
'row-1', TenantId, 'Term 1', '2026', '2026-01-01', '2027-01-01',
generated.license_key, generated.token, 'EdDSA', 'ACTIVE'
);
const expiredGenerated = LicenseTokenGenerator.generateToken(buildValidPayload({ expired: true }));
const expiredTenant = 'expired-tenant-' + Date.now();
db.prepare(`INSERT INTO tenant_licenses
(id, tenant_id, term_name, academic_year, start_date, end_date, license_key, signed_license_token, algorithm, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
'row-2', expiredTenant, 'Term 0', '2025', '2019-01-01', '2020-01-01',
expiredGenerated.license_key, expiredGenerated.token, 'EdDSA', 'ACTIVE'
);
db.close();
process.env.DB_PATH = dbPath;
process.env.SUPERADMIN_PUBLIC_KEY = LicenseTokenGenerator.getKeyPair().publicKey;
const fetched = await LicenseCache.get(TenantId);
record('T16 cache miss returns null on fresh tenant', fetched === null);
await LicenseCache.set(TenantId, JSON.parse(Buffer.from(generated.token.split('.')[1], 'base64url').toString('utf8')), 120);
const cached = await LicenseCache.get(TenantId);
record('T16 cache set/get returns payload', cached && cached.iss === 'super_admin_licensing_engine');
await LicenseCache.del(TenantId);
const cleared = await LicenseCache.get(TenantId);
record('T16 cache del clears entry', cleared === null);
const LicenseTokenMiddleware = require(path.join(SERVER_DIR, 'src', 'middleware', 'licensingMiddleware'));
const verifyMiddleware = LicenseTokenMiddleware.verifyLicenseMiddleware || LicenseTokenMiddleware;
const updateSync = LicenseTokenMiddleware.updateSystemClockSync;
let nextCalls = 0;
const next = () => { nextCalls += 1; };
const validGet = await runMiddleware(verifyMiddleware, makeReq('/api/students', 'GET', { 'x-tenant-id': TenantId }), makeRes(), next);
record('T16 GET valid token allows next() with ACTIVE header',
validGet.nextCalled && validGet.res.statusCode !== 402 && validGet.res.headers['X-License-Status'] === 'ACTIVE',
`nextCalled=${validGet.nextCalled} status=${validGet.res.statusCode} header=${validGet.res.headers['X-License-Status']}`);
nextCalls = 0;
const validPost = await runMiddleware(verifyMiddleware, makeReq('/api/students', 'POST', { 'x-tenant-id': TenantId }), makeRes(), next);
record('T16 POST valid token allows next()',
validPost.nextCalled && validPost.res.statusCode !== 402);
nextCalls = 0;
await LicenseCache.del(expiredTenant);
const expiredGet = await runMiddleware(verifyMiddleware, makeReq('/api/legacy', 'GET', { 'x-tenant-id': expiredTenant }), makeRes(), next);
record('T16 GET expired token allows read-only next() with restricted header',
expiredGet.nextCalled && expiredGet.res.statusCode !== 402 && expiredGet.res.headers['X-License-Status'] && /Restricted|Expired/i.test(expiredGet.res.headers['X-License-Status']),
`header=${expiredGet.res.headers['X-License-Status']}`);
nextCalls = 0;
await LicenseCache.del(expiredTenant);
const expiredPost = await runMiddleware(verifyMiddleware, makeReq('/api/legacy', 'POST', { 'x-tenant-id': expiredTenant }), makeRes(), next);
record('T16 POST expired token returns 402 LICENSE_EXPIRED',
!expiredPost.nextCalled && expiredPost.res.statusCode === 402 && expiredPost.res.body && expiredPost.res.body.code === 'LICENSE_EXPIRED',
`code=${expiredPost.res.body && expiredPost.res.body.code}`);
nextCalls = 0;
const bypassLogin = await runMiddleware(verifyMiddleware, makeReq('/api/auth/login', 'POST'), makeRes(), next);
record('T16 bypass path /api/auth/login allows next()', bypassLogin.nextCalled);
nextCalls = 0;
const bypassLicense = await runMiddleware(verifyMiddleware, makeReq('/api/license/current', 'GET'), makeRes(), next);
record('T16 bypass path /api/license/current allows next()', bypassLicense.nextCalled);
nextCalls = 0;
const optionsCall = await runMiddleware(verifyMiddleware, makeReq('/api/students', 'OPTIONS'), makeRes(), next);
record('T16 OPTIONS request bypassed', optionsCall.nextCalled);
nextCalls = 0;
const missingTenant = 'no-license-' + Date.now();
const missingPost = await runMiddleware(verifyMiddleware, makeReq('/api/anything', 'POST', { 'x-tenant-id': missingTenant }), makeRes(), next);
record('T16 POST missing license returns 402 NO_LICENSE',
!missingPost.nextCalled && missingPost.res.statusCode === 402 && missingPost.res.body && missingPost.res.body.code === 'NO_LICENSE',
`code=${missingPost.res.body && missingPost.res.body.code}`);
if (typeof updateSync === 'function') {
const remoteTs = new Date(Date.now() - 600000).toISOString();
updateSync(remoteTs);
nextCalls = 0;
const driftPost = await runMiddleware(verifyMiddleware, makeReq('/api/anything', 'POST', { 'x-tenant-id': TenantId }), makeRes(), next);
record('T16 clock drift > 300s blocks POST with 402 CLOCK_DRIFT_DETECTED',
!driftPost.nextCalled && driftPost.res.statusCode === 402 && driftPost.res.body && driftPost.res.body.code === 'CLOCK_DRIFT_DETECTED',
`code=${driftPost.res.body && driftPost.res.body.code}`);
nextCalls = 0;
const driftGet = await runMiddleware(verifyMiddleware, makeReq('/api/anything', 'GET', { 'x-tenant-id': TenantId }), makeRes(), next);
record('T16 clock drift still allows GET with restricted header',
driftGet.nextCalled && driftGet.res.headers['X-License-Status'] && driftGet.res.headers['X-License-Status'].includes('Drift'),
`header=${driftGet.res.headers['X-License-Status']}`);
} else {
record('T16 updateSystemClockSync export', false, 'missing named export');
}
try { await LicenseCache._close(); } catch (_) {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
const failed = results.filter(r => !r.ok);
console.log(`\nFINAL: ${results.length - failed.length}/${results.length} passed`);
process.exit(failed.length === 0 ? 0 : 1);
}
function runMiddleware(mw, req, res, next) {
return new Promise((resolve) => {
let nextCalled = false;
let finished = false;
const finish = (extra) => {
if (finished) return;
finished = true;
resolve(Object.assign({ nextCalled, res }, extra || {}));
};
const captureNext = (...args) => {
nextCalled = true;
if (typeof next === 'function') next(...args);
finish();
};
const captureRes = {
setHeader: (k, v) => { res.setHeader(k, v); return captureRes; },
status: (code) => { res.statusCode = code; return captureRes; },
json: (payload) => { res.body = payload; finish(); return captureRes; },
headers: res.headers
};
try {
const maybe = mw(req, captureRes, captureNext);
Promise.resolve(maybe).catch((err) => {
console.error('middleware threw:', err.message);
finish();
});
} catch (err) {
console.error('middleware sync threw:', err.message);
finish();
}
setTimeout(() => finish({ timeout: true }), 3000);
});
}
main().catch((err) => {
console.error('Integration test crashed:', err);
process.exit(1);
});