// Tests for the Supabase-first license fetch in licensingMiddleware.js. // // Architecture: SuperAdmin publishes the signed license to Supabase // cloud `tenant_licenses`. The tenant server fetches it from there // with the anon key, falls back to its local SQLite only on Supabase // failure. These tests stub the SupabaseLicenseService methods on the // real singleton (which the middleware imports once) and then restore // the originals in afterEach. We avoid `require.cache` patching because // Windows path-casing makes the cache key brittle; mutating the exported // singleton's methods is the same approach vitest userland code uses. const { pointAtDevDb } = require('./setup'); pointAtDevDb(); process.env.ENABLE_LICENSING_IN_TESTS = 'true'; const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const LicenseCache = require('../src/services/LicenseCacheService'); const SupabaseLicenseService = require('../src/services/SupabaseLicenseService'); const ED25519_PUB = `-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAWg05BQdh7zUjCHpvS7w7RKv7lSgLT1ZtULbwJGYq0vw= -----END PUBLIC KEY-----`; const ED25519_PRIV = `-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VwBCIEIHU4ibnw5vxTf4FTstDk++KyLY4hDehbYuKxkVYpP3um -----END PRIVATE KEY-----`; const TENANT_ID = '5b39bcb5-506a-456d-b7cb-91730b397cc1'; function signLicense(claims) { const header = { alg: 'EdDSA', typ: 'JWT' }; const enc = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); const data = `${enc(header)}.${enc(claims)}`; const sig = crypto.sign(null, Buffer.from(data), ED25519_PRIV); return `${data}.${sig.toString('base64url')}`; } function futureExp() { return Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30; // +30d } function buildSupabaseRow(overrides = {}) { const exp = futureExp(); const token = signLicense({ tenant_id: TENANT_ID, nbf: Math.floor(Date.now() / 1000) - 60, exp, ...overrides.claims, }); return { id: overrides.id || 'lic-test-001', tenant_id: TENANT_ID, license_key: 'LIC-TEST-001', signed_license_token: token, algorithm: 'EdDSA', term_name: 'Term 1', academic_year: '2026', start_date: new Date().toISOString().slice(0, 10), end_date: new Date(exp * 1000).toISOString().slice(0, 10), grace_period_days: 7, max_students: 1000, max_staff: 200, status: 'active', override_reason: null, issued_by: 'SuperAdmin Cloud', ...overrides.row, }; } function makeSaStub(overrides = {}) { const calls = []; const stub = { supabaseUrl: 'https://example.supabase.co', supabaseKey: overrides.key || 'anon-key', _configured: true, isConfigured: () => (overrides.isConfigured === false ? false : true), fetchActiveLicense: async (tenantId) => { calls.push(tenantId); if (overrides.fetchError) throw overrides.fetchError; if (overrides.fetchResult === null) return null; return overrides.fetchResult || buildSupabaseRow(); }, fetchActiveLicenseCalls: calls, upsertLicenseCache: () => (overrides.upsertResult !== false), }; return stub; } function installSaStub(overrides = {}) { // Replace methods on the live singleton so the middleware (which has // already captured the same reference at require time) sees the // override. Save the originals so afterEach can restore them. const stub = makeSaStub(overrides); return { stub, restore: () => { SupabaseLicenseService.isConfigured = originalIsConfigured; SupabaseLicenseService.fetchActiveLicense = originalFetch; SupabaseLicenseService.upsertLicenseCache = originalUpsert; }, }; } const originalIsConfigured = SupabaseLicenseService.isConfigured.bind(SupabaseLicenseService); const originalFetch = SupabaseLicenseService.fetchActiveLicense.bind(SupabaseLicenseService); const originalUpsert = SupabaseLicenseService.upsertLicenseCache.bind(SupabaseLicenseService); let activeRestore = null; function installSaStubWrap(overrides) { if (activeRestore) activeRestore(); const { stub, restore } = installSaStub(overrides); SupabaseLicenseService.isConfigured = stub.isConfigured; SupabaseLicenseService.fetchActiveLicense = stub.fetchActiveLicense; SupabaseLicenseService.upsertLicenseCache = stub.upsertLicenseCache; SupabaseLicenseService.__stub = stub; activeRestore = restore; } function makeReq(overrides = {}) { return { method: overrides.method || 'POST', path: overrides.path || '/api/grades', headers: { 'x-tenant-id': TENANT_ID, ...(overrides.headers || {}), }, tenantId: TENANT_ID, user: undefined, ...overrides, }; } function makeRes() { const r = { statusCode: 200, body: null, headers: {}, }; r.status = (code) => { r.statusCode = code; return r; }; r.json = (body) => { r.body = body; return r; }; r.setHeader = (k, v) => { r.headers[k] = v; }; return r; } let savedEnv; beforeAll(() => { savedEnv = { SUPERADMIN_PUBLIC_KEY: process.env.SUPERADMIN_PUBLIC_KEY, NODE_ENV: process.env.NODE_ENV, }; process.env.SUPERADMIN_PUBLIC_KEY = ED25519_PUB; process.env.NODE_ENV = 'development'; }); afterAll(() => { if (savedEnv.SUPERADMIN_PUBLIC_KEY !== undefined) { process.env.SUPERADMIN_PUBLIC_KEY = savedEnv.SUPERADMIN_PUBLIC_KEY; } else { delete process.env.SUPERADMIN_PUBLIC_KEY; } if (savedEnv.NODE_ENV !== undefined) { process.env.NODE_ENV = savedEnv.NODE_ENV; } }); // Skip the entire suite if the dev DB doesn't have tenant_licenses or // the public key. The middleware falls back to "Dev-Bypass" when // SUPERADMIN_PUBLIC_KEY is empty, but we override it above so the // verification path runs. const DB_PATH = process.env.DB_PATH; const hasLocalLicenseTable = (() => { try { const Database = require('better-sqlite3'); const db = new Database(DB_PATH); const row = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tenant_licenses'") .get(); db.close(); return !!row; } catch (_) { return false; } })(); const itDb = hasLocalLicenseTable ? it : it.skip; describe('Tenant licensing middleware — Supabase-first fetch', () => { let middleware; beforeEach(async () => { // Clear the in-process license cache so each test starts from a // cold Supabase-first path. Without this, the second test would // hit the LRU entry from the first test and skip the Supabase fetch. try { await LicenseCache.del(TENANT_ID); } catch (_) {} // Wipe the bundled default local-row so tests that expect // "no license" hit a real cold path. Real dev DBs in this repo // ship with one default row, which is great for manual dev but // would make these tests no-ops. try { const Database = require('better-sqlite3'); const db = new Database(DB_PATH); db.prepare('DELETE FROM tenant_licenses WHERE tenant_id = ?').run(TENANT_ID); db.close(); } catch (_) {} // Always start from a fresh require of the middleware so the test // can rebuild the cache/Supabase contract deterministically. delete require.cache[path.resolve(__dirname, '..', 'src', 'middleware', 'licensingMiddleware.js')]; middleware = require('../src/middleware/licensingMiddleware'); }); afterEach(() => { if (activeRestore) { activeRestore(); activeRestore = null; } }); it('blocks mutations when Supabase has no license and local DB has no row', async () => { installSaStubWrap({ fetchResult: null }); const req = makeReq({ method: 'POST', path: '/api/grades' }); const res = makeRes(); let nextCalled = false; await middleware(req, res, () => { nextCalled = true; }); expect(nextCalled).toBe(false); expect(res.statusCode).toBe(402); expect(res.body.code).toBe('NO_LICENSE'); }); it('allows mutations when Supabase returns a valid signed license', async () => { installSaStubWrap(); const req = makeReq({ method: 'POST', path: '/api/grades' }); const res = makeRes(); let nextCalled = false; await middleware(req, res, () => { nextCalled = true; }); expect(nextCalled).toBe(true); expect(res.headers['X-License-Status']).toBe('ACTIVE'); expect(res.headers['X-License-Source']).toBe('supabase'); expect(req.license).toBeTruthy(); expect(req.license.source).toBe('supabase'); }); it('allows read-only requests when Supabase has no license (graceful read)', async () => { installSaStubWrap({ fetchResult: null }); const req = makeReq({ method: 'GET', path: '/api/grades' }); const res = makeRes(); let nextCalled = false; await middleware(req, res, () => { nextCalled = true; }); expect(nextCalled).toBe(true); expect(res.headers['X-License-Status']).toBe('No-License'); expect(res.headers['X-License-Source']).toBe('none'); }); it('falls back to local SQLite when Supabase fetch throws', async () => { installSaStubWrap({ fetchError: new Error('Supabase 503') }); const req = makeReq({ method: 'POST', path: '/api/grades' }); const res = makeRes(); let nextCalled = false; await middleware(req, res, () => { nextCalled = true; }); // If the local DB has the bundled default license, we expect ACTIVE // from local. If not, we expect 402 NO_LICENSE. Either is acceptable // here — the contract is "fall back, don't crash". if (res.statusCode === 402) { expect(res.body.code).toBe('NO_LICENSE'); } else { expect(nextCalled).toBe(true); expect(['local', 'supabase']).toContain(res.headers['X-License-Source']); } }); it('rejects with 402 INVALID_SIGNATURE when Supabase returns a token signed by an unknown key', async () => { // Sign a token with an unrelated key, so verification fails. const badKey = crypto.generateKeyPairSync('ed25519'); const header = { alg: 'EdDSA', typ: 'JWT' }; const enc = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); const claims = { tenant_id: TENANT_ID, nbf: Math.floor(Date.now() / 1000) - 60, exp: futureExp(), }; const data = `${enc(header)}.${enc(claims)}`; const sig = crypto.sign(null, Buffer.from(data), badKey.privateKey); const badToken = `${data}.${sig.toString('base64url')}`; installSaStubWrap({ fetchResult: buildSupabaseRow({ row: { signed_license_token: badToken } }), }); const req = makeReq({ method: 'POST', path: '/api/grades' }); const res = makeRes(); let nextCalled = false; await middleware(req, res, () => { nextCalled = true; }); expect(nextCalled).toBe(false); expect(res.statusCode).toBe(402); expect(res.body.code).toBe('INVALID_SIGNATURE'); }); it('bypasses licensing for systems_admin role', async () => { installSaStubWrap({ fetchResult: null }); const req = makeReq({ method: 'POST', path: '/api/users', user: { role: 'systems_admin' } }); const res = makeRes(); let nextCalled = false; await middleware(req, res, () => { nextCalled = true; }); expect(nextCalled).toBe(true); }); it('treats tenant_id mismatch (debug supabase row with different tenant_id) as a SIG-only failure', async () => { // We cannot easily inject a two-tenant race here without refactoring // the middleware to accept a row provider. The middleware filters by // tenant_id at the Supabase query level, so a forged row with a // different tenant_id never reaches the middleware. This test asserts // that contract indirectly: the SA stub's fetchActiveLicense is // called with the tenant_id from the request. installSaStubWrap({ fetchResult: null }); const req = makeReq({ method: 'POST', path: '/api/grades' }); const res = makeRes(); await middleware(req, res, () => {}); expect(SupabaseLicenseService.__stub.fetchActiveLicenseCalls).toContain(TENANT_ID); }); }); describe('SupabaseLicenseService — unit-level', () => { // We can't exercise the real client without a network round-trip. // Instead we test the upsert path against the local SQLite. This is // the resilience write-back the user asked for. beforeEach(() => { // Restore the real methods so the unit test exercises the real // service, not the stub from the previous describe block. if (activeRestore) { activeRestore(); activeRestore = null; } }); it('upsertLicenseCache writes a row that the middleware can read back', () => { const row = buildSupabaseRow(); const ok = SupabaseLicenseService.upsertLicenseCache(row); expect(ok).toBe(true); const Database = require('better-sqlite3'); const db = new Database(DB_PATH); const got = db .prepare('SELECT id, license_key, signed_license_token, algorithm FROM tenant_licenses WHERE id = ?') .get(row.id); db.close(); expect(got).toBeTruthy(); expect(got.id).toBe(row.id); expect(got.license_key).toBe(row.license_key); expect(got.signed_license_token).toBe(row.signed_license_token); expect(got.algorithm).toBe('EdDSA'); }); });