feat(W3): expiry sync / full reconciliation sweep (schedule */5) + design + tested compute

- 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
This commit is contained in:
2026-08-23 01:58:58 +08:00
parent 543f4a7885
commit a907a85a57
6 changed files with 435 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
// 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');
})();