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.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Unit test for shared/effective-grants.js — validate calculateEffectiveGrants
|
||||
// against mocked NocoDB/AList, exercising the tricky cases:
|
||||
// * two products share a resource -> MAX expiry wins
|
||||
// * expired CP (expires_at <= today) excluded
|
||||
// * non-active CP excluded
|
||||
// * manual AccessGrant unioned in, and MAX expiry across sources
|
||||
// * permission readwrite override
|
||||
// Run: node n8n/test_effective_grants.js
|
||||
const path = require('path');
|
||||
const { calculateEffectiveGrants, linkId } = require(path.join(__dirname, 'shared', 'effective-grants.js'));
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10); // real today for expiry edge
|
||||
|
||||
// ---------------- mock data ----------------
|
||||
const RESOURCES = [
|
||||
{ Id: 1, alist_path: '/Class/Python/01' },
|
||||
{ Id: 2, alist_path: '/Class/Python/02' },
|
||||
{ Id: 3, alist_path: '/Class/Health' },
|
||||
{ Id: 4, alist_path: '/Template/Music' },
|
||||
];
|
||||
// product -> resources (M2M link)
|
||||
const PRODUCT_RES = {
|
||||
1: [{ Id: 1 }, { Id: 2 }], // Product A: Python 01+02
|
||||
2: [{ Id: 3 }], // Product B: Health
|
||||
3: [{ Id: 4 }], // Product C: Music
|
||||
4: [{ Id: 1 }], // Product D: shares Python 01 with A
|
||||
};
|
||||
const CUSTOMERPRODUCTS = [
|
||||
{ Id: 1, customer: 1, product: 1, status: 'active', expires_at: '2099-01-01' },
|
||||
{ Id: 2, customer: 1, product: 2, status: 'active', expires_at: '2099-06-01' },
|
||||
{ Id: 3, customer: 1, product: 4, status: 'active', expires_at: '2098-12-01' }, // shares path /Class/Python/01
|
||||
{ Id: 4, customer: 1, product: 3, status: 'active', expires_at: TODAY }, // EXPIRING TODAY -> excluded
|
||||
{ Id: 5, customer: 1, product: 3, status: 'revoked', expires_at: '2099-01-01' }, // revoked -> excluded
|
||||
];
|
||||
const ACCESSGRANTS = [
|
||||
{ Id: 1, customer: 1, resource: 4, status: 'active', expires_at: '2099-03-01', permission: 'readwrite' },
|
||||
{ Id: 2, customer: 1, resource: 3, status: 'active', expires_at: '2098-01-01', permission: 'read' },
|
||||
{ Id: 3, customer: 1, resource: 2, status: 'expired', expires_at: '2020-01-01' }, // expired grant excluded
|
||||
];
|
||||
|
||||
// mock ctx implementing ctx.$env + ctx.helpers.httpRequest
|
||||
function makeCtx() {
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
$env: {
|
||||
NOCODB_URL: 'https://nocodb.test',
|
||||
NOCODB_TOKEN: 'test-token',
|
||||
ALIST_URL: 'https://alist.test',
|
||||
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',
|
||||
NOCODB_TABLE_PURCHASES: 'tpur',
|
||||
},
|
||||
helpers: {
|
||||
httpRequest: async (opts) => {
|
||||
calls.push(opts.url);
|
||||
const u = opts.url;
|
||||
// Resources list
|
||||
if (u.includes('/tc/records') && u.includes('limit=10000')) {
|
||||
if (u.includes('/tr/records')) return { list: RESOURCES };
|
||||
}
|
||||
if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES };
|
||||
// CustomerProducts where customer eq
|
||||
if (u.includes('/tcpc/records?where=(customer,eq,')) {
|
||||
return { list: CUSTOMERPRODUCTS };
|
||||
}
|
||||
// AccessGrants where customer eq
|
||||
if (u.includes('/tag/records?where=(customer,eq,')) {
|
||||
return { list: ACCESSGRANTS };
|
||||
}
|
||||
// Product M2M link
|
||||
const m = u.match(/\/tp\/links\/lnk\/records\/(\d+)/);
|
||||
if (m) {
|
||||
return { list: PRODUCT_RES[Number(m[1])] || [] };
|
||||
}
|
||||
throw new Error('unexpected mock URL: ' + u);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { ctx, calls };
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function assert(name, cond, detail) {
|
||||
if (cond) { console.log('PASS ' + name); }
|
||||
else { failures++; console.log('FAIL ' + name + (detail ? ' -> ' + JSON.stringify(detail) : '')); }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { ctx } = makeCtx();
|
||||
const r = await calculateEffectiveGrants(ctx, 1);
|
||||
|
||||
// desiredPaths expected:
|
||||
// Product A (2099-01-01): /Class/Python/01, /Class/Python/02
|
||||
// Product B (2099-06-01): /Class/Health
|
||||
// Product D (2098-12-01): /Class/Python/01 (max(2099-01-01, 2098-12-01) = 2099-01-01)
|
||||
// Product C expiring TODAY -> excluded -> /Template/Music NOT from CP
|
||||
// grant 1 (readwrite, 2099-03-01): /Template/Music
|
||||
// grant 2 (read, 2098-01-01): /Class/Health (already present, max(2099-06-01) wins)
|
||||
// grant 3 expired -> excluded
|
||||
// => final paths: Python/01, Python/02, Health, Music (4 paths)
|
||||
const paths = r.desiredPaths;
|
||||
assert('4 desired paths', paths.length === 4, paths);
|
||||
assert('has Python/01', paths.includes('/Class/Python/01'), paths);
|
||||
assert('has Python/02', paths.includes('/Class/Python/02'), paths);
|
||||
assert('has Health', paths.includes('/Class/Health'), paths);
|
||||
assert('has Music (via manual grant)', paths.includes('/Template/Music'), paths);
|
||||
|
||||
// expiry MAX across shared resource
|
||||
assert('Python/01 expiry = 2099-01-01 (MAX of A 2099-01-01 and D 2098-12-01)',
|
||||
r.desired['/Class/Python/01'].expires === '2099-01-01', r.desired['/Class/Python/01']);
|
||||
// Health expiry = MAX(CP B 2099-06-01, grant 2098-01-01) = 2099-06-01
|
||||
assert('Health expiry = 2099-06-01 (MAX of product vs grant)',
|
||||
r.desired['/Class/Health'].expires === '2099-06-01', r.desired['/Class/Health']);
|
||||
// Music expiry = 2099-03-01 from manual grant, permission readwrite -> 1
|
||||
assert('Music permission = readwrite (1)',
|
||||
r.desired['/Template/Music'].permission === 1, r.desired['/Template/Music']);
|
||||
// Python/01 permission default 0
|
||||
assert('Python/01 permission = 0 (default)',
|
||||
r.desired['/Class/Python/01'].permission === 0, r.desired['/Class/Python/01']);
|
||||
|
||||
// newScopes shape
|
||||
assert('newScopes length 4', r.newScopes.length === 4, r.newScopes);
|
||||
assert('newScopes are {path,permission}', r.newScopes.every(s => typeof s.path === 'string' && typeof s.permission === 'number'), r.newScopes);
|
||||
|
||||
// linkId normalisation
|
||||
assert('linkId object', linkId({ Id: 7 }) === 7);
|
||||
assert('linkId array', linkId([{ Id: 7 }]) === 7);
|
||||
assert('linkId null', linkId(null) === null);
|
||||
assert('linkId number', linkId(7) === 7);
|
||||
|
||||
console.log(failures === 0 ? '\nALL TESTS PASSED' : `\n${failures} TEST(S) FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
main();
|
||||
Reference in New Issue
Block a user