Files
hoelee 21e3a3b45f fix(shared): link-field where query must use (customer.Id,eq,N) not (customer,eq,N)
- calculateEffectiveGrants queried CustomerProducts/AccessGrants with
  (customer,eq,N), which matches the link field's DISPLAY NAME and always
  returns 0 rows -> desiredPaths always [] -> W1/W2/W3 write empty scopes.
- Regression from the shared-lib refactor (3ecee30): W2 had already fixed
  this in 36a6b3b but the shared extract reverted to the old syntax.
- Correct syntax (customer.Id,eq,N) verified against live NocoDB for
  customer 6 -> /Class/Health, /Class/Other, /Template/Music.
- Updated all three test mocks to match the corrected where clause.
2026-08-23 06:55:04 +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\.Id,eq,(\d+)\)/);
if (cpm) return { list: CPS[Number(cpm[1])] || [] };
const gm = u.match(/tag\/records\?where=\(customer\.Id,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); });