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); });