// Verify the W4 daily reconciliation body against a mock AList + NocoDB. // Runs the ACTUAL inlined code from W4-daily-reconcile.json in a vm sandbox // with realistic state mutations (create/update actually change the mock so // verify-after-repair reads back real state). // Run: node n8n/test_w4_reconcile.js const fs = require('fs'); const path = require('path'); const vm = require('vm'); const workflow = JSON.parse(fs.readFileSync(path.join(__dirname, 'W4-daily-reconcile.json'), 'utf8')); const code = workflow.nodes.find(n => n.name === 'Reconcile & Repair (Daily Full)').parameters.jsCode; // --------------------------------------------------------------------------- // mock world // --------------------------------------------------------------------------- function freshWorld(w) { return { customers: JSON.parse(JSON.stringify(w.customers || [])), users: JSON.parse(JSON.stringify(w.users || [])), roles: JSON.parse(JSON.stringify(w.roles || [])), cps: JSON.parse(JSON.stringify(w.cps || {})), grants: JSON.parse(JSON.stringify(w.grants || {})), productRes: JSON.parse(JSON.stringify(w.productRes || {})), resources: JSON.parse(JSON.stringify(w.resources || [ { Id: 1, alist_path: '/Class/A' }, { Id: 2, alist_path: '/Class/B' }, { Id: 3, alist_path: '/Class/D' }, { Id: 4, alist_path: '/Template/Music' }, ])), }; } let nextUserId = 1000, nextRoleId = 900; async function runWorld(world, extraEnv = {}) { const S = freshWorld(world); const logRows = []; const userUpdates = [], roleUpdates = [], patches = [], creates = { users: [], roles: [] }; const throwOn = {}; // url-substring -> Error const httpRequest = async (opts) => { const u = opts.url, m = (opts.method || 'GET').toUpperCase(); for (const k of Object.keys(throwOn)) if (u.includes(k)) throw new Error('mock throw: ' + k); const body = opts.body; // ---- NocoDB ---- if (m === 'GET' && u.includes(`/tc/records?limit=10000`)) return { list: S.customers }; if (m === 'GET' && u.includes(`/tr/records?limit=10000`)) return { list: S.resources }; const cm = u.match(/\/tc\/records\/(\d+)$/); if (m === 'GET' && cm) return S.customers.find(c => c.Id === Number(cm[1])) || null; if (m === 'PATCH' && u.includes('/tc/records')) { for (const row of body) { const c = S.customers.find(x => x.Id === row.Id); if (c) Object.assign(c, row); patches.push(row); } return {}; } const cpm = u.match(/\/tcp\/records\?where=\(customer\.Id,eq,(\d+)\)/); if (m === 'GET' && cpm) return { list: S.cps[Number(cpm[1])] || [] }; const gm = u.match(/\/tag\/records\?where=\(customer\.Id,eq,(\d+)\)/); if (m === 'GET' && gm) return { list: S.grants[Number(gm[1])] || [] }; const pm = u.match(/\/tp\/links\/lnk\/records\/(\d+)/); if (m === 'GET' && pm) return { list: S.productRes[Number(pm[1])] || [] }; if (m === 'GET' && u.includes('/tlog/records')) return { list: logRows }; if (m === 'POST' && u.includes('/tlog/records')) { (Array.isArray(body) ? body : [body]).forEach(r => logRows.push(r)); return {}; } // ---- AList auth ---- if (u.endsWith('/api/auth/login')) return { data: { token: 'ADMIN_TOKEN' } }; // ---- AList users ---- if (m === 'GET' && u.includes('/api/admin/user/list')) return { data: { content: S.users } }; const ug = u.match(/\/api\/admin\/user\/get\?id=(\d+)/); if (m === 'GET' && ug) { const x = S.users.find(uu => uu.id === Number(ug[1])); return x ? { data: x } : { code: 404, data: null }; } if (m === 'POST' && u.endsWith('/api/admin/user/create')) { const nu = Object.assign({ id: nextUserId++, disabled: false }, body); // real AList user/get always carries disabled S.users.push(nu); creates.users.push(body); return { code: 200, data: null }; } if (m === 'POST' && u.endsWith('/api/admin/user/update')) { const x = S.users.find(uu => uu.id === body.id); if (x) { userUpdates.push(body); if (!(body.id === 400)) Object.assign(x, body); // 400 simulates AList refusing } return { code: 200, data: null }; } // ---- AList roles ---- if (m === 'GET' && u.includes('/api/admin/role/list')) return { data: { content: S.roles } }; const rg = u.match(/\/api\/admin\/role\/get\?id=(\d+)/); if (m === 'GET' && rg) { const x = S.roles.find(r => r.id === Number(rg[1])); return x ? { data: x } : { code: 404, data: null }; } if (m === 'POST' && u.endsWith('/api/admin/role/create')) { const nr = Object.assign({ id: nextRoleId++, permission_scopes: [] }, body); S.roles.push(nr); creates.roles.push(body); return { code: 200, data: null }; } if (m === 'POST' && u.endsWith('/api/admin/role/update')) { const x = S.roles.find(r => r.id === body.id); roleUpdates.push(body); if (x) Object.assign(x, body); return { code: 200, data: null }; } throw new Error('unexpected request: ' + m + ' ' + u); }; const sandbox = { $env: Object.assign({ NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcp', NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr', NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_TABLE_RECONCILELOGS: 'tlog', NOCODB_LINK_PROD_RESOURCES: 'lnk', NOCODB_FK_RECONCILELOG_CUSTOMER: 'nc_jitu___Customers_id', ALIST_URL: 'https://al', ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p', W4_DRY_RUN: 'false', }, extraEnv), $json: {}, console, }; sandbox.helpers = { httpRequest }; const wrapped = `(async function(){\n${code}\n})`; const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w4-body.js' }); const result = await fn(); return { s: result.json, users: S.users, roles: S.roles, customers: S.customers, logRows, userUpdates, roleUpdates, patches, creates, }; } // --------------------------------------------------------------------------- // worlds // --------------------------------------------------------------------------- const W = { healthy: { customers: [{ Id: 1, username: 'alice', status: 'active', alist_user_id: 100, alist_role_id: 50 }], users: [{ id: 100, username: 'alice', base_path: '/', permission: 16384, disabled: false, role: [50] }], roles: [{ id: 50, name: 'customer-alice', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }], cps: { 1: [{ Id: 11, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' }] }, productRes: { 2: [{ Id: 1 }] }, grants: { 1: [] }, }, adoptUser: { // alist_user_id lost in NocoDB but user exists by username customers: [{ Id: 2, username: 'alice', status: 'active', alist_user_id: null, alist_role_id: 50 }], users: [{ id: 100, username: 'alice', base_path: '/', permission: 16384, disabled: false, role: [50] }], roles: [{ id: 50, name: 'customer-alice', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }], cps: { 2: [{ Id: 12, customer: 2, product: 2, status: 'active', expires_at: '2099-01-01' }] }, productRes: { 2: [{ Id: 1 }] }, grants: { 2: [] }, }, danglingUser: { // alist_user_id points at deleted user customers: [{ Id: 3, username: 'alice', status: 'active', alist_user_id: 999, alist_role_id: 50 }], users: [{ id: 100, username: 'alice', base_path: '/', permission: 16384, disabled: false, role: [50] }], roles: [{ id: 50, name: 'customer-alice', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }], cps: { 3: [{ Id: 13, customer: 3, product: 2, status: 'active', expires_at: '2099-01-01' }] }, productRes: { 2: [{ Id: 1 }] }, grants: { 3: [] }, }, provision: { // nothing in AList, active customer with entitlement customers: [{ Id: 4, username: 'bob', status: 'active', alist_user_id: null, alist_role_id: null, password: 'pwBob1' }], users: [], roles: [], cps: { 4: [{ Id: 14, customer: 4, product: 2, status: 'active', expires_at: '2099-01-01' }] }, productRes: { 2: [{ Id: 2 }] }, grants: { 4: [] }, }, reEnable: { // disabled + wrong base_path/permission customers: [{ Id: 5, username: 'carol', status: 'active', alist_user_id: 101, alist_role_id: 51 }], users: [{ id: 101, username: 'carol', base_path: '/Class', permission: 0, disabled: true, role: [51] }], roles: [{ id: 51, name: 'customer-carol', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }], cps: { 5: [{ Id: 15, customer: 5, product: 2, status: 'active', expires_at: '2099-01-01' }] }, productRes: { 2: [{ Id: 1 }] }, grants: { 5: [] }, }, scopesDrift: { // stale scope in AList customers: [{ Id: 6, username: 'dave', status: 'active', alist_user_id: 102, alist_role_id: 52 }], users: [{ id: 102, username: 'dave', base_path: '/', permission: 16384, disabled: false, role: [52] }], roles: [{ id: 52, name: 'customer-dave', description: 'x', permission_scopes: [{ path: '/Old/Path', permission: 0 }] }], cps: { 6: [{ Id: 16, customer: 6, product: 2, status: 'active', expires_at: '2099-01-01' }] }, productRes: { 2: [{ Id: 3 }] }, grants: { 6: [] }, }, expired: { // non-active customer: disable + clear scopes customers: [{ Id: 7, username: 'eve', status: 'expired', alist_user_id: 103, alist_role_id: 53 }], users: [{ id: 103, username: 'eve', base_path: '/', permission: 16384, disabled: false, role: [53] }], roles: [{ id: 53, name: 'customer-eve', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }], cps: { 7: [] }, grants: { 7: [] }, }, pendingNoUser: { customers: [{ Id: 8, username: 'frank', status: 'pending', alist_user_id: null, alist_role_id: null }], users: [], roles: [], cps: { 8: [] }, grants: { 8: [] }, }, usernameDrift: { customers: [{ Id: 9, username: 'grace', status: 'active', alist_user_id: 104, alist_role_id: 54 }], users: [{ id: 104, username: 'grace_old', base_path: '/', permission: 16384, disabled: false, role: [54] }], roles: [{ id: 54, name: 'customer-grace', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }], cps: { 9: [{ Id: 17, customer: 9, product: 2, status: 'active', expires_at: '2099-01-01' }] }, productRes: { 2: [{ Id: 1 }] }, grants: { 9: [] }, }, verifyFail: { // AList refuses the user update -> verify_failed customers: [{ Id: 10, username: 'ivy', status: 'active', alist_user_id: 400, alist_role_id: 56 }], users: [{ id: 400, username: 'ivy', base_path: '/', permission: 16384, disabled: true, role: [56] }], roles: [{ id: 56, name: 'customer-ivy', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }], cps: { 10: [{ Id: 18, customer: 10, product: 2, status: 'active', expires_at: '2099-01-01' }] }, productRes: { 2: [{ Id: 1 }] }, grants: { 10: [] }, }, error: { // grants fetch explodes for this customer customers: [{ Id: 11, username: 'hank', status: 'active', alist_user_id: 105, alist_role_id: 57 }], users: [{ id: 105, username: 'hank', base_path: '/', permission: 16384, disabled: false, role: [57] }], roles: [{ id: 57, name: 'customer-hank', description: 'x', permission_scopes: [{ path: '/Class/A', permission: 0 }] }], cps: { 11: [] }, grants: { 11: [] }, }, }; async function main() { let fail = 0; const A = (n, c, d) => { if (c) console.log('PASS ' + n); else { fail++; console.log('FAIL ' + n + ' -> ' + JSON.stringify(d)); } }; const E = { W4_DRY_RUN: 'false' }; // -- healthy: nothing to do { const r = await runWorld(W.healthy, E); A('healthy skipped=1', r.s.skipped === 1, r.s); A('healthy no log rows', r.logRows.length === 0, r.logRows); A('healthy no writes', r.userUpdates.length === 0 && r.roleUpdates.length === 0, r); A('healthy 0 drift', r.s.driftsDetected === 0, r.s); } // -- adopt user by username (id lost) { const r = await runWorld(W.adoptUser, E); A('adopt: 1 drift / 1 repaired', r.s.driftsDetected === 1 && r.s.repaired === 1, r.s); A('adopt: dangling_user_ref log', r.logRows[0] && r.logRows[0].drift_type === 'dangling_user_ref' && r.logRows[0].result === 'repaired', r.logRows); A('adopt: customer FK written', r.logRows[0] && r.logRows[0].nc_jitu___Customers_id === 2, r.logRows[0]); A('adopt: no duplicate user', r.users.length === 1, r.users); A('adopt: writeBack alist_user_id', r.patches.some(p => p.alist_user_id === 100), r.patches); A('adopt: next run healthy (id filled)', r.customers[0].alist_user_id === 100, r.customers); } // -- dangling user ref (404 -> lookup by username -> adopt) { const r = await runWorld(W.danglingUser, E); A('dangling: adopted 100', r.s.driftsDetected === 1 && r.s.repaired === 1, r.s); A('dangling: no user created', r.users.length === 1 && r.creates.users.length === 0, r); A('dangling: customer id repaired', r.customers[0].alist_user_id === 100, r.customers); } // -- full provision { const r = await runWorld(W.provision, E); A('provision: role+user created', r.creates.roles.length === 1 && r.creates.users.length === 1, r.creates); A('provision: user role bound', r.users[0].role[0] === r.roles[0].id, r.users); A('provision: canonical fields', r.users[0].base_path === '/' && r.users[0].permission === 16384 && r.users[0].disabled === false, r.users); A('provision: scopes applied', JSON.stringify(r.roles[0].permission_scopes.map(s => s.path)) === JSON.stringify(['/Class/B']), r.roles); A('provision: ids written back', r.patches.some(p => p.alist_user_id) && r.patches.some(p => p.alist_role_id), r.patches); A('provision: user_missing logged', r.logRows.some(l => l.drift_type === 'user_missing'), r.logRows); A('provision: verify passed (no verify_failed)', !r.logRows.some(l => l.drift_type === 'verify_failed'), r.logRows); } // -- re-enable + canonical fix { const r = await runWorld(W.reEnable, E); A('re-enable: user updated', r.userUpdates.some(u => u.disabled === false && u.base_path === '/' && u.permission === 16384), r.userUpdates); A('re-enable: 3 drifts logged', ['user_disabled_drift','base_path_drift','permission_drift'].every(t => r.logRows.some(l => l.drift_type === t)), r.logRows); A('re-enable: repaired=3', r.s.repaired === 3, r.s); } // -- scopes drift { const r = await runWorld(W.scopesDrift, E); A('scopes: role rewritten', r.roleUpdates.length === 1 && r.roles[0].permission_scopes.map(s => s.path).join() === '/Class/D', r.roleUpdates); A('scopes: drift logged', r.logRows.some(l => l.drift_type === 'scopes_drift' && l.result === 'repaired'), r.logRows); } // -- expired cleanup { const r = await runWorld(W.expired, E); A('expired: user disabled', r.userUpdates.some(u => u.disabled === true), r.userUpdates); A('expired: scopes cleared', r.roles[0].permission_scopes.length === 0, r.roles); A('expired: repaired=2', r.s.repaired === 2, r.s); A('expired: verify ok', !r.logRows.some(l => l.drift_type === 'verify_failed'), r.logRows); } // -- pending no user: untouched { const r = await runWorld(W.pendingNoUser, E); A('pending: skipped=1', r.s.skipped === 1, r.s); A('pending: no rows / no writes', r.logRows.length === 0 && r.creates.users.length === 0 && r.creates.roles.length === 0, r); } // -- username drift: detect + log only { const r = await runWorld(W.usernameDrift, E); A('drift: username_drift logged', r.logRows.some(l => l.drift_type === 'username_drift' && l.result === 'log_only' && l.action === 'log_only'), r.logRows); A('drift: no rename (username untouched)', r.users[0].username === 'grace_old', r.users); A('drift: no new user', r.creates.users.length === 0, r.creates); A('drift: no user patch', r.userUpdates.length === 0, r.userUpdates); } // -- verify failed (AList refuses update) { const r = await runWorld(W.verifyFail, E); A('verifyfail: verify_failed logged', r.logRows.some(l => l.drift_type === 'verify_failed' && l.result === 'failed'), r.logRows); A('verifyfail: not counted as repaired', r.logRows.filter(l => l.result === 'repaired').length === 1, r.logRows); } // -- error path (grants fetch explodes) { const r3 = await runWorldWithThrow(W.error, 'grant'); A('error: errors[] populated', r3.s.errors.length === 1 && r3.s.errors[0].customerId === 11, r3.s.errors); A('error: reconcile_error row', r3.logRows.some(l => l.drift_type === 'reconcile_error' && l.result === 'failed'), r3.logRows); } // -- dry run: zero writes { const r = await runWorld(W.provision, { W4_DRY_RUN: 'true' }); A('dryrun: no creates', r.creates.users.length === 0 && r.creates.roles.length === 0, r.creates); A('dryrun: no patches', r.patches.length === 0, r.patches); A('dryrun: no log POST', r.logRows.length === 0, r.logRows); A('dryrun: planned rows reported', r.s.plannedLogRows > 0 && r.s.logRowsWritten === 0, r.s); A('dryrun: dryRun flag', r.s.dryRun === true, r.s); } console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`); process.exit(fail === 0 ? 0 : 1); } // helper: run with grants fetch throwing for customer 11 async function runWorldWithThrow(world, marker) { const S = freshWorld(world); const logRows = []; const httpRequest = async (opts) => { const u = opts.url, m = (opts.method || 'GET').toUpperCase(); if (u.includes('/tag/records')) throw new Error('grants fetch boom'); if (m === 'GET' && u.includes('/tc/records?limit=10000')) return { list: S.customers }; if (m === 'GET' && u.includes(`/tr/records?limit=10000`)) return { list: S.resources }; if (m === 'GET' && u.includes('/api/admin/user/list')) return { data: { content: S.users } }; if (m === 'GET' && u.includes('/api/admin/role/list')) return { data: { content: S.roles } }; const ug = u.match(/\/api\/admin\/user\/get\?id=(\d+)/); if (m === 'GET' && ug) { const x = S.users.find(uu => uu.id === Number(ug[1])); return x ? { data: x } : { code: 404, data: null }; } const rg = u.match(/\/api\/admin\/role\/get\?id=(\d+)/); if (m === 'GET' && rg) { const x = S.roles.find(r => r.id === Number(rg[1])); return x ? { data: x } : { code: 404, data: null }; } if (u.endsWith('/api/auth/login')) return { data: { token: 'ADMIN_TOKEN' } }; if (m === 'POST' && u.includes('/tlog/records')) { (Array.isArray(opts.body) ? opts.body : [opts.body]).forEach(r => logRows.push(r)); return {}; } if (m === 'POST' && u.endsWith('/api/admin/user/update')) return { code: 200 }; if (m === 'POST' && u.endsWith('/api/admin/role/update')) return { code: 200 }; throw new Error('unexpected: ' + m + ' ' + u); }; const sandbox = { $env: { NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', NOCODB_TABLE_CUSTOMERS: 'tc', NOCODB_TABLE_CUSTOMERPRODUCTS: 'tcp', NOCODB_TABLE_PRODUCTS: 'tp', NOCODB_TABLE_RESOURCES: 'tr', NOCODB_TABLE_ACCESSGRANTS: 'tag', NOCODB_TABLE_RECONCILELOGS: 'tlog', NOCODB_LINK_PROD_RESOURCES: 'lnk', ALIST_URL: 'https://al', ALIST_ADMIN_USERNAME: 'admin', ALIST_ADMIN_PASSWORD: 'p', W4_DRY_RUN: 'false' }, $json: {}, console, }; sandbox.helpers = { httpRequest }; const fn = vm.runInNewContext(`(async function(){\n${code}\n})`, sandbox, { filename: 'w4-body.js' }); const result = await fn(); return { s: result.json, logRows }; } main().then(() => {}).catch(e => { console.error(e); process.exit(1); });