// Unit test for shared/effective-grants.js — validate calculateEffectiveGrants // against mocked NocoDB/AList, exercising the tricky cases: // * two products share a resource -> MAX expiry wins // * expired CP (expires_at <= today) excluded // * non-active CP excluded // * manual AccessGrant unioned in, and MAX expiry across sources // * permission readwrite override // Run: node n8n/test_effective_grants.js const path = require('path'); const { calculateEffectiveGrants, linkId } = require(path.join(__dirname, 'shared', 'effective-grants.js')); const TODAY = new Date().toISOString().slice(0, 10); // real today for expiry edge // ---------------- mock data ---------------- const RESOURCES = [ { Id: 1, alist_path: '/Class/Python/01' }, { Id: 2, alist_path: '/Class/Python/02' }, { Id: 3, alist_path: '/Class/Health' }, { Id: 4, alist_path: '/Template/Music' }, ]; // product -> resources (M2M link) const PRODUCT_RES = { 1: [{ Id: 1 }, { Id: 2 }], // Product A: Python 01+02 2: [{ Id: 3 }], // Product B: Health 3: [{ Id: 4 }], // Product C: Music 4: [{ Id: 1 }], // Product D: shares Python 01 with A }; const CUSTOMERPRODUCTS = [ { Id: 1, customer: 1, product: 1, status: 'active', expires_at: '2099-01-01' }, { Id: 2, customer: 1, product: 2, status: 'active', expires_at: '2099-06-01' }, { Id: 3, customer: 1, product: 4, status: 'active', expires_at: '2098-12-01' }, // shares path /Class/Python/01 { Id: 4, customer: 1, product: 3, status: 'active', expires_at: TODAY }, // EXPIRING TODAY -> excluded { Id: 5, customer: 1, product: 3, status: 'revoked', expires_at: '2099-01-01' }, // revoked -> excluded ]; const ACCESSGRANTS = [ { Id: 1, customer: 1, resource: 4, status: 'active', expires_at: '2099-03-01', permission: 'readwrite' }, { Id: 2, customer: 1, resource: 3, status: 'active', expires_at: '2098-01-01', permission: 'read' }, { Id: 3, customer: 1, resource: 2, status: 'expired', expires_at: '2020-01-01' }, // expired grant excluded ]; // mock ctx implementing ctx.$env + ctx.helpers.httpRequest function makeCtx() { const calls = []; const ctx = { $env: { NOCODB_URL: 'https://nocodb.test', NOCODB_TOKEN: 'test-token', ALIST_URL: 'https://alist.test', NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcpc', NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr', NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_LINK_PROD_RESOURCES: 'lnk', NOCODB_TABLE_PURCHASES: 'tpur', }, helpers: { httpRequest: async (opts) => { calls.push(opts.url); const u = opts.url; // Resources list if (u.includes('/tc/records') && u.includes('limit=10000')) { if (u.includes('/tr/records')) return { list: RESOURCES }; } if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES }; // CustomerProducts where customer eq if (u.includes('/tcpc/records?where=(customer.Id,eq,')) { return { list: CUSTOMERPRODUCTS }; } // AccessGrants where customer eq if (u.includes('/tag/records?where=(customer.Id,eq,')) { return { list: ACCESSGRANTS }; } // Product M2M link const m = u.match(/\/tp\/links\/lnk\/records\/(\d+)/); if (m) { return { list: PRODUCT_RES[Number(m[1])] || [] }; } throw new Error('unexpected mock URL: ' + u); }, }, }; return { ctx, calls }; } let failures = 0; function assert(name, cond, detail) { if (cond) { console.log('PASS ' + name); } else { failures++; console.log('FAIL ' + name + (detail ? ' -> ' + JSON.stringify(detail) : '')); } } async function main() { const { ctx } = makeCtx(); const r = await calculateEffectiveGrants(ctx, 1); // desiredPaths expected: // Product A (2099-01-01): /Class/Python/01, /Class/Python/02 // Product B (2099-06-01): /Class/Health // Product D (2098-12-01): /Class/Python/01 (max(2099-01-01, 2098-12-01) = 2099-01-01) // Product C expiring TODAY -> excluded -> /Template/Music NOT from CP // grant 1 (readwrite, 2099-03-01): /Template/Music // grant 2 (read, 2098-01-01): /Class/Health (already present, max(2099-06-01) wins) // grant 3 expired -> excluded // => final paths: Python/01, Python/02, Health, Music (4 paths) const paths = r.desiredPaths; assert('4 desired paths', paths.length === 4, paths); assert('has Python/01', paths.includes('/Class/Python/01'), paths); assert('has Python/02', paths.includes('/Class/Python/02'), paths); assert('has Health', paths.includes('/Class/Health'), paths); assert('has Music (via manual grant)', paths.includes('/Template/Music'), paths); // expiry MAX across shared resource assert('Python/01 expiry = 2099-01-01 (MAX of A 2099-01-01 and D 2098-12-01)', r.desired['/Class/Python/01'].expires === '2099-01-01', r.desired['/Class/Python/01']); // Health expiry = MAX(CP B 2099-06-01, grant 2098-01-01) = 2099-06-01 assert('Health expiry = 2099-06-01 (MAX of product vs grant)', r.desired['/Class/Health'].expires === '2099-06-01', r.desired['/Class/Health']); // Music expiry = 2099-03-01 from manual grant, permission readwrite -> 1 assert('Music permission = readwrite (1)', r.desired['/Template/Music'].permission === 1, r.desired['/Template/Music']); // Python/01 permission default 0 assert('Python/01 permission = 0 (default)', r.desired['/Class/Python/01'].permission === 0, r.desired['/Class/Python/01']); // newScopes shape assert('newScopes length 4', r.newScopes.length === 4, r.newScopes); assert('newScopes are {path,permission}', r.newScopes.every(s => typeof s.path === 'string' && typeof s.permission === 'number'), r.newScopes); // linkId normalisation assert('linkId object', linkId({ Id: 7 }) === 7); assert('linkId array', linkId([{ Id: 7 }]) === 7); assert('linkId null', linkId(null) === null); assert('linkId number', linkId(7) === 7); console.log(failures === 0 ? '\nALL TESTS PASSED' : `\n${failures} TEST(S) FAILED`); process.exit(failures === 0 ? 0 : 1); } main();