- Single Code-node sweep: desired = CP-active paths UNION active AccessGrants; expiry excluded -> role/update rewrite; optional auto-disable (env-gated, default off) - disable guard: only when had scopes before (never fresh empty-role customer) - loop guard: no NocoDB Customers.status flip (avoids W1 re-provision loop) - test_w3.js mock-verified both AUTO_DISABLE modes; gen_w3.js builds JSON from w3-compute.js
138 lines
7.1 KiB
JavaScript
138 lines
7.1 KiB
JavaScript
// 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-<TEST>' } };
|
|
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');
|
|
})();
|