Files
alist-serve-resource/n8n/test_effective_grants.js
T
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

139 lines
6.1 KiB
JavaScript

// 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.Id,eq,')) {
return { list: CUSTOMERPRODUCTS };
}
// AccessGrants where customer eq
if (u.includes('/tag/records?where=(customer.Id,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();