374 lines
13 KiB
JavaScript
374 lines
13 KiB
JavaScript
// 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');
|
|
});
|
|
});
|
|
|