// Idempotency + lifecycle + SELF-WRITE-BACK SUPPRESSION tests for W1 (Step 3). // 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: // - a business change (admin flips status) processes exactly once // - our own write-back echo (bookkeeping-only delta) is SKIPPED and does NOT // PATCH again (this is the loop fix). // 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', sync_required: false, 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); } // Simulate one W1 run. `event` mirrors the NocoDB v2 webhook old/new rows: // - business change: previousRow has a DIFFERENT status than newRow (admin edit) // - echo: previousRow == newRow on business fields, only bookkeeping moved. async function runW1(customerId, { status, oldRow, newRow, syncRequired } = {}) { // apply the post-update state to the customer record (what W1 re-reads) if (status !== undefined) state.customer.status = status; if (syncRequired !== undefined) state.customer.sync_required = syncRequired; state.customer.alist_user_id = state.customer.alist_user_id ?? null; state.customer.alist_role_id = state.customer.alist_role_id ?? null; const prevRow = oldRow || { ...state.customer }; const nRow = newRow || { ...state.customer }; 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}, previousRow: ${JSON.stringify(prevRow)}, newRow: ${JSON.stringify(nRow)} };\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, business change: pending->active) --- console.log('\n[1] active fresh customer (business change)'); let r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'pending', sync_status: 'pending' }, newRow: { Id: 1, username: 'alice', status: 'active', sync_status: 'pending' } }); 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; A('scopes = Health + Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1)); A('wrote alist_user_id + role_id back', state.customer.alist_user_id === uid1 && state.customer.alist_role_id === rid1, state.customer); const patchesAfter1 = state.patched.length; // --- 2. write-back echo (only bookkeeping moved) -> skip, NO new PATCH --- console.log('\n[2] write-back echo (bookkeeping-only)'); r = await runW1(1, { oldRow: { Id: 1, username: 'alice', status: 'active', alist_user_id: uid1, alist_role_id: rid1 }, newRow: { Id: 1, username: 'alice', status: 'active', alist_user_id: uid1, alist_role_id: rid1, sync_status: 'synced', sync_required: false } }); A('echo -> skip_writeback_echo', r.json.lifecycle === 'skip_writeback_echo', r.json); A('NO new PATCH (loop broken)', state.patched.length === patchesAfter1, { before: patchesAfter1, after: state.patched.length }); A('no new user/role', state.users.length === 1 && state.roles.length === 1, { u: state.users.length, r: state.roles.length }); // --- 3. suspended (business change: active->suspended) --- console.log('\n[3] suspended (business change)'); r = await runW1(1, { status: 'suspended', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: '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); const patchesAfter3 = state.patched.length; // --- 4. suspend echo (bookkeeping-only) -> skip, no re-PATCH --- console.log('\n[4] suspend echo (bookkeeping-only)'); r = await runW1(1, { oldRow: { Id: 1, username: 'alice', status: 'suspended' }, newRow: { Id: 1, username: 'alice', status: 'suspended', sync_status: 'synced', sync_required: false } }); A('echo -> skip', r.json.lifecycle === 'skip_writeback_echo', r.json); A('still disabled (unchanged)', userById(uid1).disabled === true, userById(uid1)); A('NO new PATCH', state.patched.length === patchesAfter3, { before: patchesAfter3, after: state.patched.length }); // --- 5. active resume (business change) -> re-enable + RECOMPUTE (not stale) --- console.log('\n[5] active resume (recompute, not restore stale)'); state.cps = []; // remove all CPs -> only manual grant remains r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'suspended' }, newRow: { Id: 1, username: 'alice', status: '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 (business change) -> soft delete --- console.log('\n[6] deleted (business change)'); r = await runW1(1, { status: 'deleted', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: '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); // --- 7. sync_required=true forces reprocess even without business diff --- console.log('\n[7] sync_required flag forces reprocess'); state.cps = [{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' }]; r = await runW1(1, { status: 'active', syncRequired: true, oldRow: { Id: 1, username: 'alice', status: 'deleted' }, newRow: { Id: 1, username: 'alice', status: 'active', sync_required: true } }); A('lifecycle active_provision (forced)', r.json.lifecycle === 'active_provision', r.json); A('user re-enabled', userById(uid1).disabled === false, userById(uid1)); 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); });