geocrop-platform./apps/nextgen/server/tests/supabase-license-fetch.test.js

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