// Verify the REFACTORED W3 body produces correct reconciliation after the // calculateEffectiveGrants extraction into shared/. Runs the ACTUAL inlined // code from W3-expiry-sync.json against a mock ctx. // Run: node n8n/test_w3_refactored.js const fs = require('fs'); const path = require('path'); const vm = require('vm'); const workflow = JSON.parse(fs.readFileSync(path.join(__dirname, 'W3-expiry-sync.json'), 'utf8')); const code = workflow.nodes.find(n => n.name === 'Reconcile & Apply (Expiry Sweep)').parameters.jsCode; const TODAY = new Date().toISOString().slice(0, 10); const RESOURCES = [ { Id: 1, alist_path: '/Class/Python/01' }, { Id: 2, alist_path: '/Class/Health' }, { Id: 3, alist_path: '/Template/Music' }, ]; const PRODUCT_RES = { 2: [{ Id: 2 }], 3: [{ Id: 3 }], 4: [{ Id: 1 }] }; const CUSTOMERS = [ { Id: 1, username: 'cust1', alist_role_id: 10, alist_user_id: 100, status: 'active' }, { Id: 2, username: 'cust2', alist_role_id: 11, alist_user_id: 101, status: 'active' }, { Id: 3, username: 'cust3', alist_role_id: null, alist_user_id: null, status: 'active' }, ]; const CPS = { 1: [{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' }], 2: [{ Id: 2, customer: 2, product: 3, status: 'active', expires_at: TODAY }], }; const GRANTS = { 1: [{ Id: 1, customer: 1, resource: 3, status: 'active', expires_at: '2099-01-01', permission: 'read' }], 2: [], }; const ROLE_SCOPES = { 10: [{ path: '/Class/Python/01', permission: 0 }], 11: [{ path: '/Template/Music', permission: 0 }], }; const wroteRole = [], disabledUsers = []; const sandbox = { $env: { NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', ALIST_URL: 'https://al', 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', ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p', W3_DRY_RUN: 'false', ALIST_AUTO_DISABLE_ON_EXPIRE: 'true', wroteRole, disabledUsers, }, console, }; sandbox.helpers = { httpRequest: async (opts) => { const u = opts.url; if (u.endsWith('/api/auth/login')) return { data: { token: 'ADMIN_TOKEN' } }; if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES }; if (u.includes('/tc/records?limit=10000')) return { list: CUSTOMERS }; const cpm = u.match(/tcpc\/records\?where=\(customer,eq,(\d+)\)/); if (cpm) return { list: CPS[Number(cpm[1])] || [] }; const gm = u.match(/tag\/records\?where=\(customer,eq,(\d+)\)/); if (gm) return { list: GRANTS[Number(gm[1])] || [] }; const pm = u.match(/tp\/links\/lnk\/records\/(\d+)/); if (pm) return { list: PRODUCT_RES[Number(pm[1])] || [] }; const rg = u.match(/role\/get\?id=(\d+)/); if (rg) return { data: { permission_scopes: ROLE_SCOPES[Number(rg[1])] || [] } }; if (u.endsWith('/api/admin/role/update')) { wroteRole.push(opts.body); return { code: 200 }; } if (u.endsWith('/api/admin/user/update')) { disabledUsers.push(opts.body); return { code: 200 }; } throw new Error('unexpected URL: ' + u); }, }; async function main() { // Wrap exactly like n8n: top-level await with `this` bound to a node context // that exposes `.helpers` and `$env`. const wrapped = `(async function(){\n${code}\n})`; const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w3-body.js' }); const result = await fn(); const changed = result.json.changed; const errors = result.json.errors; let fail = 0; const A = (n, c, d) => { if (c) console.log('PASS ' + n); else { fail++; console.log('FAIL ' + n + ' -> ' + JSON.stringify(d)); } }; A('scanned 3', result.json.customersScanned === 3, result.json.customersScanned); A('cust3 skipped', !changed.find(c => c.customerId === 3), changed); A('no errors', errors.length === 0, errors); const c1 = changed.find(c => c.customerId === 1); A('cust1 changed', !!c1, changed); if (c1) { A('cust1 add Health', c1.toAdd.includes('/Class/Health'), c1.toAdd); A('cust1 add Music (grant)', c1.toAdd.includes('/Template/Music'), c1.toAdd); A('cust1 remove stale Python', c1.toRemove.includes('/Class/Python/01'), c1.toRemove); } const c2 = changed.find(c => c.customerId === 2); A('cust2 changed', !!c2, changed); if (c2) { A('cust2 remove Music', c2.toRemove.includes('/Template/Music'), c2.toRemove); A('cust2 add empty', c2.toAdd.length === 0, c2.toAdd); A('cust2 disabled', c2.applied.includes('disable'), c2.applied); } A('2 role updates', wroteRole.length === 2, wroteRole.length); A('1 disable', disabledUsers.length === 1, disabledUsers.length); console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`); process.exit(fail === 0 ? 0 : 1); } main().then(() => {}).catch(e => { console.error(e); process.exit(1); });