fix(W1): self-write-back suppression via old/new field comparison

- Replace brittle sync_status==='synced' loop guard with NocoDB v2
  old/new row comparison (previous_rows vs rows).
- businessChanged() = business field move OR sync_required set; echo
  (bookkeeping-only delta) -> skip_writeback_echo, zero AList mutation.
- writeBack() value-change guard: GET live record, PATCH only fields
  whose value actually changes (no-change -> no PATCH -> no webhook).
- sync_required Checkbox honored as force-reprocess flag, cleared on write-back.
- Extract-id node passes previousRow/newRow through Load Env to lifecycle.
- W1 lifecycle test now covers echo suppression (26 assertions).
- Customers.status still NOT consumed by permission calc (TBD semantics).
- W2/W3 effective-grants semantics unchanged.
This commit is contained in:
2026-08-23 06:43:01 +08:00
parent 3ecee3099e
commit 9bcdfc2049
5 changed files with 166 additions and 85 deletions
+48 -34
View File
@@ -1,9 +1,11 @@
// Idempotency + lifecycle tests for the W1 body (status lifecycle).
// 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 duplicate-webhook
// idempotency.
// 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');
@@ -17,7 +19,7 @@ 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 },
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}
@@ -121,13 +123,19 @@ function mockHttp(opts) {
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)
// 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',
@@ -139,7 +147,7 @@ async function runW1(customerId, customerStatusOverride) {
console,
};
sandbox.helpers = { httpRequest: mockHttp };
const wrapped = `(async function(){\nconst $json = { customerId: ${customerId} };\n${code}\n})`;
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();
}
@@ -154,59 +162,65 @@ function roleScopes(roleId) {
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');
// --- 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;
// 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);
const patchesAfter1 = state.patched.length;
// --- 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);
// --- 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 -> disable + clear scopes ---
console.log('\n[3] suspended');
r = await runW1(1, 'suspended');
// --- 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. 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);
// --- 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 -> 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).
// --- 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, 'active');
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 -> soft delete (disable + clear, keep user) ---
console.log('\n[6] deleted');
r = await runW1(1, 'deleted');
// --- 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);
}