Files
alist-serve-resource/n8n/test_w3_refactored.js
T
hoelee 3ecee3099e feat(W1): status lifecycle (suspend/delete/resume) + shared effective-grants lib
- 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.
2026-08-23 04:59:43 +08:00

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); });