- Extract calculateEffectiveGrants into n8n/shared/effective-grants.js (single source of truth), inlined at build time into W1/W2/W3 via gen_w*.js + inline_shared.js — removes the duplicated algorithm. - W1 now provisions with a status branch: suspended/deleted -> disable AList user + clear role scopes (soft delete) active -> create/update user+role, RECOMPUTE desired scopes (never restore stale), re-enable - Loop guard corrected: check AList user LIVE disabled state via GET /api/admin/user/get (sync_status alone breaks suspend->resume). - W2/W3 rebuilt on shared lib (identical behaviour). - Tests: test_effective_grants.js (15), test_w3_refactored.js (13), test_w1_lifecycle.js (23) — all green. - Drop superseded w2-compute.js / w3-compute.js / test_w3.js.
107 lines
4.8 KiB
JavaScript
107 lines
4.8 KiB
JavaScript
// 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); });
|