243 lines
11 KiB
JavaScript
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);
|
|
}); |