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:
2026-08-23 04:59:43 +08:00
parent 870a702f7b
commit 3ecee3099e
18 changed files with 1216 additions and 1109 deletions
+213
View File
@@ -0,0 +1,213 @@
// Idempotency + lifecycle tests for the W1 body (status lifecycle).
// Loads the ACTUAL inlined code from W1-customer-provision.json and runs it
// against a mock ctx, driving a customer through:
// active -> suspended -> active (resume) -> deleted
// and asserting converged AList end-state each time, plus duplicate-webhook
// idempotency.
// Run: node n8n/test_w1_lifecycle.js
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const workflow = JSON.parse(fs.readFileSync(path.join(__dirname, 'W1-customer-provision.json'), 'utf8'));
const code = workflow.nodes.find(n => n.name === 'Provision & Apply Lifecycle').parameters.jsCode;
const TODAY = new Date().toISOString().slice(0, 10);
// ---- mutable mock state (represents AList + NocoDB) ----
const state = {
// NocoDB customer record (the fields W1 reads)
customer: { Id: 1, username: 'alice', password: 'pw', status: 'active', alist_user_id: null, alist_role_id: null, sync_status: 'pending', last_sync_error: null },
// AList
users: [], // {id, username, disabled, role}
roles: [], // {id, name, permission_scopes}
nextUserId: 1000,
nextRoleId: 2000,
// NocoDB CPs (for calculateEffectiveGrants)
cps: [
{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' },
],
grants: [{ Id: 1, customer: 1, resource: 3, status: 'active', expires_at: '2099-01-01', permission: 'read' }],
resources: [
{ Id: 1, alist_path: '/Class/Python/01' },
{ Id: 2, alist_path: '/Class/Health' },
{ Id: 3, alist_path: '/Template/Music' },
],
productRes: { 2: [{ Id: 2 }] },
// record write-back PATCHes
patched: [],
};
function mockHttp(opts) {
const u = opts.url;
const m = opts.method || 'GET';
const body = opts.body || {};
if (u.endsWith('/api/auth/login')) return { data: { token: 'TOKEN' } };
// NocoDB customer GET
const cget = u.match(/\/tc\/records\/(\d+)$/);
if (cget && m === 'GET') return state.customer;
// NocoDB customer PATCH (write-back)
if (u.includes('/tc/records') && m === 'PATCH') {
const fields = body[0] || body;
state.patched.push(Object.assign({}, fields));
// apply to customer state
Object.assign(state.customer, fields);
return { code: 200 };
}
// resources list
if (u.includes('/tr/records?limit=10000')) return { list: state.resources };
// CP where customer eq
const cpm = u.match(/tcpc\/records\?where=\(customer,eq,(\d+)\)/);
if (cpm) return { list: state.cps.filter(c => c.customer === Number(cpm[1])) };
// grants where customer eq
const gm = u.match(/tag\/records\?where=\(customer,eq,(\d+)\)/);
if (gm) return { list: state.grants.filter(g => g.customer === Number(gm[1])) };
// product M2M
const pm = u.match(/tp\/links\/lnk\/records\/(\d+)/);
if (pm) return { list: state.productRes[Number(pm[1])] || [] };
// role create
if (u.endsWith('/api/admin/role/create')) {
const r = { id: state.nextRoleId++, name: body.name, description: body.description, permission_scopes: body.permission_scopes || [] };
state.roles.push(r);
return { data: r };
}
// role list
if (u.endsWith('/api/admin/role/list?page=1&per_page=100')) return { data: { content: state.roles } };
// role get
const rg = u.match(/role\/get\?id=(\d+)/);
if (rg) {
const r = state.roles.find(x => x.id === Number(rg[1]));
if (!r) return { data: {} };
return { data: { name: r.name, description: r.description, permission_scopes: r.permission_scopes } };
}
// role update
if (u.endsWith('/api/admin/role/update')) {
const r = state.roles.find(x => x.id === body.id);
if (r) r.permission_scopes = body.permission_scopes;
return { code: 200 };
}
// user create
if (u.endsWith('/api/admin/user/create')) {
const us = { id: state.nextUserId++, username: body.username, disabled: body.disabled ?? false, role: body.role };
state.users.push(us);
return { data: us };
}
// user get (loop guard checks actual disabled state)
const ug = u.match(/user\/get\?id=(\d+)/);
if (ug) {
const us = state.users.find(x => x.id === Number(ug[1]));
return { data: us || {} };
}
// user list
if (u.endsWith('/api/admin/user/list?page=1&per_page=100')) return { data: { content: state.users } };
// user update
if (u.endsWith('/api/admin/user/update')) {
const us = state.users.find(x => x.id === body.id);
if (us) {
if (body.disabled !== undefined) us.disabled = body.disabled;
if (body.role !== undefined) us.role = body.role;
}
return { code: 200 };
}
throw new Error('unexpected: ' + m + ' ' + u);
}
async function runW1(customerId, customerStatusOverride) {
// build ctx reflecting CURRENT state.customer
if (customerStatusOverride !== undefined) state.customer.status = customerStatusOverride;
// ensure alist ids are on the customer record (W1 reads them from NocoDB)
state.customer.alist_user_id = state.customer.alist_user_id ?? null;
state.customer.alist_role_id = state.customer.alist_role_id ?? null;
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',
},
console,
};
sandbox.helpers = { httpRequest: mockHttp };
const wrapped = `(async function(){\nconst $json = { customerId: ${customerId} };\n${code}\n})`;
const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w1-body.js' });
return await fn();
}
let fail = 0;
const A = (n, c, d) => { if (c) console.log('PASS ' + n); else { fail++; console.log('FAIL ' + n + ' -> ' + JSON.stringify(d)); } };
function roleScopes(roleId) {
const r = state.roles.find(x => x.id === roleId);
return (r?.permission_scopes || []).map(s => s.path).sort();
}
function userById(id) { return state.users.find(x => x.id === id); }
async function main() {
// --- 1. active (fresh customer) -> provision + grants ---
console.log('\n[1] active fresh customer');
let r = await runW1(1, 'active');
A('lifecycle active_provision', r.json.lifecycle === 'active_provision', r.json);
A('user created', state.users.length === 1, state.users);
A('role created', state.roles.length === 1, state.roles);
A('user enabled', userById(state.users[0].id).disabled === false, state.users[0]);
const uid1 = state.users[0].id;
const rid1 = state.roles[0].id;
// desired = Health (product 2) + Music (manual grant) = 2 paths
A('scopes = Health + Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1));
// write-back happened
A('wrote alist_user_id + role_id back', state.customer.alist_user_id === uid1 && state.customer.alist_role_id === rid1, state.customer);
// --- 2. duplicate webhook (echo) -> idempotent no-op ---
console.log('\n[2] duplicate webhook (echo, already synced)');
r = await runW1(1, 'active');
A('echo -> skip', r.json.lifecycle === 'skip_echo', r.json);
A('no new user/role', state.users.length === 1 && state.roles.length === 1, { u: state.users.length, r: state.roles.length });
// --- 3. suspended -> disable + clear scopes ---
console.log('\n[3] suspended');
r = await runW1(1, 'suspended');
A('lifecycle suspend_or_delete', r.json.lifecycle === 'suspend_or_delete', r.json);
A('user disabled', userById(uid1).disabled === true, userById(uid1));
A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1));
A('user NOT deleted (soft)', state.users.length === 1, state.users);
// --- 4. duplicate suspend webhook -> idempotent ---
console.log('\n[4] duplicate suspend');
const usersBefore = state.users.length;
r = await runW1(1, 'suspended');
A('still disabled', userById(uid1).disabled === true, userById(uid1));
A('no new users', state.users.length === usersBefore, state.users.length);
// --- 5. active resume -> re-enable + RECOMPUTE scopes (not stale) ---
// To prove recompute-not-restore, change the CP set first: remove Health product,
// the desired should now be only Music (NOT the old Health+Music restored).
console.log('\n[5] active resume (recompute, not restore stale)');
state.cps = []; // remove all CPs -> only manual grant remains
r = await runW1(1, 'active');
A('lifecycle active_provision (resume)', r.json.lifecycle === 'active_provision', r.json);
A('user re-enabled', userById(uid1).disabled === false, userById(uid1));
A('scopes recomputed = only Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Template/Music']), roleScopes(rid1));
// --- 6. deleted -> soft delete (disable + clear, keep user) ---
console.log('\n[6] deleted');
r = await runW1(1, 'deleted');
A('lifecycle suspend_or_delete', r.json.lifecycle === 'suspend_or_delete', r.json);
A('user disabled', userById(uid1).disabled === true, userById(uid1));
A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1));
A('user retained (soft delete)', state.users.length === 1, state.users);
console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`);
process.exit(fail === 0 ? 0 : 1);
}
main().then(() => {}).catch(e => { console.error('RUN ERROR:', e); process.exit(1); });