// Standalone correctness test for w3-compute.js logic. // Mocks this.helpers.httpRequest + $env, runs the exact n8n Code-node code, // and asserts desired-union / diff / disable decisions. // Run: node n8n/test_w3.js (from Windows bash: node n8n/test_w3.js) const fs = require('fs'); const path = require('path'); const src = fs.readFileSync(path.join(__dirname, 'w3-compute.js'), 'utf8'); // ---- fixtures (mirror NocoDB records / AList API shapes) ---- // Resources: Id -> alist_path const RES = { 11: '/Class/Forex', 12: '/Class/Python', 13: '/Manual/Ebook' }; // Products: Id -> [resourceIds] const PROD_RES = { 101: [11, 12], 102: [13] }; // Customers: Id -> {alist_role_id, alist_user_id, status, disabled} const CUST = { 1: { alist_role_id: 501, alist_user_id: 901, status: 'active', disabled: false }, // qualified; has expired CP 2: { alist_role_id: 502, alist_user_id: 902, status: 'active', disabled: false }, // all expired -> should clear+disable(if on) 3: { alist_role_id: 503, alist_user_id: 903, status: 'active', disabled: false }, // no scopes now 4: { alist_role_id: null, alist_user_id: 904, status: 'active', disabled: false }, // not provisioned -> skip }; // CustomerProducts: {Id, customer:Id, product:Id, expires_at, status} const CPS = { 1: [{ cid: 1, product: 101, expires_at: '2026-12-31', status: 'active' }, // live -> /Class/Forex,/Class/Python { cid: 1, product: 102, expires_at: '2020-01-01', status: 'active' }], // expired -> dropped 2: [{ cid: 2, product: 101, expires_at: '2020-01-01', status: 'active' }], // expired -> empty 3: [], }; // AccessGrants: active manual grant for cust 1 on /Manual/Ebook const GRANTS = { 1: [{ resource: 13, status: 'active', expires_at: null }], }; // Current AList role scopes const ROLE_SCOPES = { 501: ['/Class/Forex', '/Stale/Old'], // has stale -> remove; needs /Class/Python,/Manual/Ebook 502: ['/Class/Forex'], // all expired -> clear 503: [], }; // ---- mock httpRequest keyed by URL ---- const calls = { roleUpdate: [], userUpdate: [] }; function httpRequest({ method, url, headers, body }) { if (url.endsWith('/api/auth/login')) return { data: { token: 'tk-' } }; const g = (m) => new RegExp(m).exec(url); let m; // per-table list endpoints if (url.includes('/tables/RES/records?limit=10000')) { return { list: Object.keys(RES).map((Id) => ({ Id: Number(Id), alist_path: RES[Id] })) }; } if (url.includes('/tables/GRANT/records?limit=10000')) { const list = []; for (const cid of Object.keys(GRANTS)) for (const gg of GRANTS[cid]) list.push({ ...gg, customer: { Id: Number(cid) } }); return { list }; } if (url.includes('/tables/CUST/records?limit=10000')) { return { list: Object.keys(CUST).map((Id) => ({ Id: Number(Id), username: 'u' + Id, ...CUST[Id] })) }; } // customerproducts by customer: /tables/CP/records?where=(customer.Id,eq,N)&limit=1000 if ((m = g('/tables/CP/records\\?where=\\(customer.Id,eq,([0-9]+)\\)&limit=1000'))) { const cid = Number(m[1]); return { list: (CPS[cid] || []).map((c) => ({ ...c, customer: { Id: cid } })) }; } // product links: /tables/PROD/links/LNK/records/{pid} if ((m = g('/tables/PROD/links/LNK/records/([0-9]+)'))) { const pid = Number(m[1]); return { list: (PROD_RES[pid] || []).map((Id) => ({ Id })) }; } // role get if ((m = g('/api/admin/role/get\\?id=([0-9]+)'))) { const rid = Number(m[1]); return { data: { id: rid, name: 'r' + rid, description: '', permission_scopes: (ROLE_SCOPES[rid] || []).map((path) => ({ path, permission: 0 })) } }; } if (url.endsWith('/api/admin/role/update')) { calls.roleUpdate.push(body); return { data: null }; } if (url.endsWith('/api/admin/user/update')) { calls.userUpdate.push(body); return { data: null }; } throw new Error('no mock for ' + method + ' ' + url); } const $env = { NOCODB_URL: 'http://nc', NOCODB_TOKEN: 't', ALIST_URL: 'http://alist', NOCODB_TABLE_CUSTOMERS: 'CUST', NOCODB_TABLE_CUSTOMERPRODUCTS: 'CP', NOCODB_TABLE_PRODUCTS: 'PROD', NOCODB_TABLE_RESOURCES: 'RES', NOCODB_TABLE_ACCESSGRANTS: 'GRANT', NOCODB_LINK_PROD_RESOURCES: 'LNK', ALIST_ADMIN_USERNAME: 'a', ALIST_ADMIN_PASSWORD: 'p', ALIST_AUTO_DISABLE_ON_EXPIRE: process.env.TEST_DISABLE === '1' ? 'true' : 'false', W3_DRY_RUN: 'false', }; // w3-compute uses plain (non-arrow) functions -> `this` resolves to globalThis // when called bare. Wrap in an async IIFE so the file's top-level `await` works // (n8n Code nodes also support top-level await). The file ends with `return {json:{...}}` // which becomes the IIFE's resolve value ( {json: payload} ). globalThis.helpers = { httpRequest }; (async () => { const runner = new Function('$env', '$json', 'return (async () => {\n' + src + '\n})();'); const json = await runner($env, {}); const out = json.json; // unwrap the {json: {...}} wrapper console.log('SUMMARY:', JSON.stringify(out, null, 2)); console.log('SUMMARY:', JSON.stringify(out, null, 2)); // ---- assertions ---- const fail = (msg) => { console.error('FAIL: ' + msg); process.exitCode = 1; }; const ok = () => {}; const changed = out.changed; const c1 = changed.find((c) => c.customerId === 1); if (!c1) fail('cust1 should change'); else { if (!c1.toRemove.includes('/Stale/Old')) fail('cust1: stale /Stale/Old not removed'); if (!c1.toAdd.includes('/Class/Python')) fail('cust1: /Class/Python not added'); if (!c1.toAdd.includes('/Manual/Ebook')) fail('cust1: manual /Manual/Ebook not added'); if (c1.toAdd.includes('/Class/Forex')) fail('cust1: /Class/Forex already present, should not be toAdd'); if (c1.applied.includes('role_scopes') === false) ok(); } const c2 = changed.find((c) => c.customerId === 2); if (!c2) fail('cust2 should change (all expired -> clear)'); else { if (!c2.toRemove.includes('/Class/Forex')) fail('cust2: expired /Class/Forex not cleared'); if (c2.desiredPaths.length !== 0) fail('cust2 desiredPaths should be empty'); if (process.env.TEST_DISABLE === '1' && !c2.applied.includes('disable')) fail('cust2 should disable when auto-disable ON'); if (process.env.TEST_DISABLE !== '1' && c2.applied.includes('disable')) fail('cust2 must NOT disable when auto-disable OFF'); } const c3 = changed.find((c) => c.customerId === 3); if (c3) fail('cust3 (already empty scope) should NOT change'); if (changed.find((c) => c.customerId === 4)) fail('cust4 (no role) must be skipped'); // inspect actual writes const ru = calls.roleUpdate.find((b) => b.id === 501); if (ru) { const paths = (ru.permission_scopes || []).map((s) => s.path); if (!paths.includes('/Class/Forex')) fail('write cust1 lost /Class/Forex'); if (!paths.includes('/Manual/Ebook')) fail('write cust1 missing manual grant'); if (paths.includes('/Stale/Old')) fail('write cust1 kept stale'); } else fail('no role/update call for cust1'); if (process.env.TEST_DISABLE === '1' && !calls.userUpdate.find((b) => b.id === 902)) fail('cust2 user not disabled'); const nFail = Object.keys(calls.roleUpdate).length; // crude; we already assert specifics console.log(process.exitCode ? '\n>>> TEST FAILED' : '\n>>> ALL CHECKS PASSED'); })();