From d28e122fe7bdd704535ef524d27acbf930447cb4 Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" <283985454+n8n-cat-bot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:17:34 +0100 Subject: [PATCH] test: Strengthen augment-object mutation coverage (#31854) Co-authored-by: n8n-cat-bot[bot] Co-authored-by: Claude Opus 4.7 Co-authored-by: Declan Carroll --- packages/workflow/test/augment-object.test.ts | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) diff --git a/packages/workflow/test/augment-object.test.ts b/packages/workflow/test/augment-object.test.ts index 0435066eada..88df32e1fa5 100644 --- a/packages/workflow/test/augment-object.test.ts +++ b/packages/workflow/test/augment-object.test.ts @@ -1,3 +1,5 @@ +import fc from 'fast-check'; + import { augmentArray, augmentObject } from '../src/augment-object'; import type { IDataObject } from '../src/interfaces'; import { deepCopy } from '../src/utils'; @@ -578,4 +580,516 @@ describe('AugmentObject', () => { expect(augmentedObject.constructor.name).toEqual('Object'); }); }); + + // Property-based invariants targeting the behavioural seams of the proxy + // traps. fast-check generates the random inputs; the assertions pin down + // the invariant each branch is supposed to preserve. + describe('invariants (fast-check)', () => { + // Primitive value generator — exercises the `typeof !== 'object' || null` + // fast-paths in both `augment` and the object `get` trap. NaN excluded + // because `Object.is` checks below would fail spuriously. + const arbPrimitive = fc.oneof( + fc.integer(), + fc.double({ noNaN: true }), + fc.string(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + ); + + // Object/array keys. Excludes the proxy-special keys that would short- + // circuit the trap and reserved property names that would clobber + // inherited slots in unrelated ways. + const arbKey = fc + .string({ minLength: 1, maxLength: 6 }) + .filter((k) => k !== 'constructor' && k !== '__proto__' && k !== 'toJSON'); + + // Plain (toJSON-free, RegExp-free, Date-free) JSON-ish values, suitable + // for use as set-trap inputs where we want identity to hold or where we + // want to assert non-mutation of the original. + const arbJsonScalar = fc.oneof(fc.integer(), fc.string(), fc.boolean(), fc.constant(null)); + + describe('augment dispatch — value-type fast paths', () => { + it('returns primitives untouched through the array proxy', () => { + fc.assert( + fc.property(arbPrimitive, (value) => { + const wrapped = augmentArray([value]); + expect(Object.is(wrapped[0], value)).toBe(true); + }), + ); + }); + + it('returns RegExp instances by reference when nested in arrays', () => { + fc.assert( + fc.property( + fc.constantFrom('abc', '[a-z]+', '\\d+', 'x.y'), + fc.constantFrom('', 'g', 'i', 'gi', 'gim'), + (pattern, flags) => { + const regex = new RegExp(pattern, flags); + const wrapped = augmentArray([regex]); + expect(wrapped[0]).toBe(regex); + }, + ), + ); + }); + + it('clones Date values inside arrays so mutations cannot bleed into the original', () => { + fc.assert( + fc.property( + // Avoid 1990 — we mutate to that year and need a strict inequality. + fc.integer({ min: 0, max: 2_000_000_000_000 }), + (epoch) => { + const original = new Date(epoch); + const originalYear = original.getFullYear(); + fc.pre(originalYear !== 1990); + + const wrapped = augmentArray([original]); + const fetched = wrapped[0] as Date; + + expect(fetched).not.toBe(original); + expect(fetched).toBeInstanceOf(Date); + expect(fetched.valueOf()).toBe(original.valueOf()); + + fetched.setFullYear(1990); + expect(original.getFullYear()).toBe(originalYear); + }, + ), + ); + }); + + it('clones Uint8Array values inside arrays', () => { + fc.assert( + fc.property(fc.uint8Array({ minLength: 1, maxLength: 16 }), (bytes) => { + const snapshot = Array.from(bytes); + const wrapped = augmentArray([bytes]); + const fetched = wrapped[0] as Uint8Array; + + expect(fetched).not.toBe(bytes); + expect(fetched).toBeInstanceOf(Uint8Array); + expect(Array.from(fetched)).toEqual(snapshot); + + fetched[0] = (fetched[0] + 1) & 0xff; + expect(Array.from(bytes)).toEqual(snapshot); + }), + ); + }); + }); + + describe('augmentArray', () => { + it('re-entry guard — augmenting an already-augmented array returns the same proxy', () => { + fc.assert( + fc.property(fc.array(fc.integer(), { maxLength: 10 }), (xs) => { + const first = augmentArray([...xs]); + expect(augmentArray(first)).toBe(first); + }), + ); + }); + + it('re-entry guard holds via a nested object proxy', () => { + fc.assert( + fc.property(fc.array(fc.integer(), { maxLength: 5 }), (xs) => { + const wrapped = augmentObject({ list: [...xs] }); + const list = wrapped.list; + expect(augmentArray(list)).toBe(list); + }), + ); + }); + + it('constructor short-circuit — always returns Array even when newData has a poisoned constructor', () => { + fc.assert( + fc.property(fc.array(fc.integer(), { minLength: 1, maxLength: 5 }), (xs) => { + const wrapped = augmentArray([...xs]); + (wrapped as unknown as { constructor: unknown }).constructor = function Fake() {}; + expect(wrapped.constructor).toBe(Array); + expect(wrapped.constructor.name).toBe('Array'); + }), + ); + }); + + it('getOwnPropertyDescriptor pre-mutation delegates to the original target', () => { + fc.assert( + fc.property( + fc.array(fc.integer(), { minLength: 1, maxLength: 8 }), + fc.nat({ max: 7 }), + (xs, rawIdx) => { + fc.pre(xs.length > 0); + const idx = rawIdx % xs.length; + const wrapped = augmentArray([...xs]); + expect(Object.getOwnPropertyDescriptor(wrapped, idx)).toEqual({ + value: xs[idx], + writable: true, + enumerable: true, + configurable: true, + }); + expect(Object.getOwnPropertyDescriptor(wrapped, 'length')?.value).toBe(xs.length); + }, + ), + ); + }); + + it('getOwnPropertyDescriptor post-push reports new length, leaves original intact', () => { + fc.assert( + fc.property(fc.array(fc.integer(), { maxLength: 8 }), fc.integer(), (xs, extra) => { + const original = [...xs]; + const wrapped = augmentArray(original); + wrapped.push(extra); + expect(Object.getOwnPropertyDescriptor(wrapped, 'length')?.value).toBe(xs.length + 1); + expect(Object.getOwnPropertyDescriptor(original, 'length')?.value).toBe(xs.length); + }), + ); + }); + + it('getOwnPropertyDescriptor for pushed-only indices returns the default-branch shape', () => { + fc.assert( + fc.property(fc.array(fc.integer(), { maxLength: 5 }), fc.integer(), (xs, extra) => { + const wrapped = augmentArray([...xs]); + wrapped.push(extra); + const descriptor = Object.getOwnPropertyDescriptor(wrapped, xs.length); + // Pushed-only index → default frozen descriptor (no `value`, + // `writable: false`). Proxy machinery normalises the missing + // value to `undefined`. This branch is observably distinct from + // the original-index branch (writable: true) above. + expect(descriptor?.enumerable).toBe(true); + expect(descriptor?.configurable).toBe(true); + expect(descriptor?.writable).toBe(false); + expect(descriptor?.value).toBeUndefined(); + }), + ); + }); + + it('mutation traps — delete, has and ownKeys reflect the augmented state', () => { + fc.assert( + fc.property( + fc.array(fc.integer(), { minLength: 1, maxLength: 6 }), + fc.integer(), + fc.nat({ max: 5 }), + (xs, extra, rawIdx) => { + const original = [...xs]; + const wrapped = augmentArray(original); + wrapped.push(extra); + + const pushedIdx = xs.length; + expect(pushedIdx in wrapped).toBe(true); + expect(Object.keys(wrapped)).toContain(String(pushedIdx)); + + const idx = rawIdx % xs.length; + delete wrapped[idx]; + expect(idx in wrapped).toBe(false); + expect(wrapped[idx]).toBeUndefined(); + + // Original is left untouched by every mutation above. + expect(original).toEqual(xs); + }, + ), + ); + }); + + it('set on nested objects augments them so writes do not leak to the original element', () => { + fc.assert( + fc.property(fc.integer(), fc.integer(), (initial, overwrite) => { + const inner = { a: initial }; + const wrapped = augmentArray<{ a: number }>([]); + wrapped.push(inner); + const fetched = wrapped[0]; + fetched.a = overwrite; + expect(inner.a).toBe(initial); + expect(wrapped[0].a).toBe(overwrite); + }), + ); + }); + }); + + describe('augmentObject', () => { + it('re-entry guard — augmenting an already-augmented object returns the same proxy', () => { + fc.assert( + fc.property(fc.dictionary(arbKey, fc.integer(), { maxKeys: 5 }), (obj) => { + const first = augmentObject({ ...obj }); + expect(augmentObject(first)).toBe(first); + }), + ); + }); + + it('re-entry guard holds via a nested child proxy', () => { + fc.assert( + fc.property(fc.dictionary(arbKey, fc.integer(), { maxKeys: 5 }), (obj) => { + const wrapped = augmentObject({ inner: { ...obj } }); + const inner = wrapped.inner; + expect(augmentObject(inner)).toBe(inner); + }), + ); + }); + + it('constructor short-circuit — always returns Object', () => { + fc.assert( + fc.property(fc.dictionary(arbKey, fc.integer(), { maxKeys: 4 }), (obj) => { + const wrapped = augmentObject({ ...obj }); + // Poison the constructor slot via a typed cast so the next read + // MUST hit the `key === 'constructor'` short-circuit branch. + (wrapped as unknown as { constructor: unknown }).constructor = function Fake() {}; + expect(wrapped.constructor).toBe(Object); + expect(wrapped.constructor.name).toBe('Object'); + }), + ); + }); + + it('returns top-level primitives untouched', () => { + fc.assert( + fc.property(arbKey, arbPrimitive, (key, value) => { + const wrapped = augmentObject>({ [key]: value }); + expect(Object.is(wrapped[key], value)).toBe(true); + }), + ); + }); + + it('returns null directly without falling into the RegExp / toJSON branches', () => { + fc.assert( + fc.property(arbKey, (key) => { + const wrapped = augmentObject>({ [key]: null }); + expect(wrapped[key]).toBeNull(); + }), + ); + }); + + it('serializes nested RegExp values via toString()', () => { + fc.assert( + fc.property( + fc.constantFrom('abc', '[a-z]+', '\\d+', 'x.y'), + fc.constantFrom('', 'g', 'i', 'gi', 'gim'), + (pattern, flags) => { + const regex = new RegExp(pattern, flags); + const wrapped = augmentObject({ nested: { pattern: regex } }); + expect(wrapped.nested.pattern).toBe(regex.toString()); + }, + ), + ); + }); + + it('invokes toJSON when it is a function, returning its result', () => { + fc.assert( + fc.property(arbJsonScalar, (sentinel) => { + const wrapped = augmentObject({ + nested: { + value: 42, + toJSON: () => sentinel, + }, + }); + expect(wrapped.nested).toBe(sentinel); + }), + ); + }); + + it('does not call toJSON when the property is not a function', () => { + fc.assert( + fc.property( + arbJsonScalar.filter((v) => typeof v !== 'function'), + (notFunc) => { + const wrapped = augmentObject<{ nested: Record }>({ + nested: { toJSON: notFunc }, + }); + const fetched = wrapped.nested; + expect(fetched.toJSON).toBe(notFunc); + }, + ), + ); + }); + + it('caches the augmented child so repeated reads return the same proxy', () => { + fc.assert( + fc.property(fc.dictionary(arbKey, fc.integer(), { maxKeys: 4 }), (obj) => { + const wrapped = augmentObject({ nested: { ...obj } }); + const first = wrapped.nested; + const second = wrapped.nested; + expect(first).toBe(second); + }), + ); + }); + + it('round-trips a fresh primitive set: after wrapped[k] = v, get/has/ownKeys/descriptor all agree', () => { + fc.assert( + fc.property( + arbKey, + arbJsonScalar.filter((v) => v !== null), + (key, value) => { + const wrapped = augmentObject>({}); + wrapped[key] = value; + expect(Object.is(wrapped[key], value)).toBe(true); + expect(key in wrapped).toBe(true); + expect(Object.keys(wrapped)).toContain(key); + expect(Object.getOwnPropertyDescriptor(wrapped, key)?.value).toBe(value); + }, + ), + ); + }); + + it('delete makes the key invisible through every read trap', () => { + fc.assert( + fc.property(fc.dictionary(arbKey, fc.integer(), { minKeys: 1, maxKeys: 5 }), (obj) => { + const keys = Object.keys(obj); + const target = keys[0]; + const wrapped = augmentObject({ ...obj } as Record); + delete wrapped[target]; + expect(target in wrapped).toBe(false); + expect(wrapped[target]).toBeUndefined(); + expect(Object.keys(wrapped)).not.toContain(target); + expect(Reflect.ownKeys(wrapped)).not.toContain(target); + expect(Object.getOwnPropertyDescriptor(wrapped, target)).toBeUndefined(); + }), + ); + }); + + it('set-to-undefined is observationally equivalent to delete (visibility-wise)', () => { + fc.assert( + fc.property(fc.dictionary(arbKey, fc.integer(), { minKeys: 1, maxKeys: 5 }), (obj) => { + const keys = Object.keys(obj); + const target = keys[0]; + const deleted = augmentObject({ ...obj } as Record); + const undef = augmentObject({ ...obj } as Record); + delete deleted[target]; + undef[target] = undefined; + expect(target in deleted).toBe(target in undef); + expect(Object.keys(deleted).sort()).toEqual(Object.keys(undef).sort()); + expect(Reflect.ownKeys(deleted).sort()).toEqual(Reflect.ownKeys(undef).sort()); + }), + ); + }); + + // The delete trap purges newData[key] when the key was previously set + // (so the next get does not fall through to the cached value). Pin + // that explicitly — the deletedProperties check alone would not catch + // it, because a brand-new key never gets tracked as deleted. + it('delete after set on a brand-new key purges the cached newData value', () => { + fc.assert( + fc.property(arbKey, fc.integer(), (key, value) => { + const wrapped = augmentObject>({}); + wrapped[key] = value; + delete wrapped[key]; + expect(wrapped[key]).toBeUndefined(); + expect(key in wrapped).toBe(false); + expect(Object.keys(wrapped)).not.toContain(key); + expect(Object.getOwnPropertyDescriptor(wrapped, key)).toBeUndefined(); + }), + ); + }); + + // Symmetric to the above for the set-to-undefined branch. + it('set-to-undefined after set on a brand-new key purges the cached newData value', () => { + fc.assert( + fc.property(arbKey, fc.integer(), (key, value) => { + const wrapped = augmentObject>({}); + wrapped[key] = value; + wrapped[key] = undefined; + expect(wrapped[key]).toBeUndefined(); + expect(key in wrapped).toBe(false); + expect(Object.keys(wrapped)).not.toContain(key); + expect(Object.getOwnPropertyDescriptor(wrapped, key)).toBeUndefined(); + }), + ); + }); + + it('re-setting a deleted key restores visibility through every trap', () => { + fc.assert( + fc.property( + arbKey, + fc.integer(), + arbJsonScalar.filter((v) => v !== null && v !== undefined), + (key, original, replacement) => { + const wrapped = augmentObject>({ [key]: original }); + delete wrapped[key]; + wrapped[key] = replacement; + expect(key in wrapped).toBe(true); + expect(Object.is(wrapped[key], replacement)).toBe(true); + expect(Reflect.ownKeys(wrapped)).toContain(key); + }, + ), + ); + }); + + it('re-setting a key cleared via set-to-undefined restores visibility', () => { + fc.assert( + fc.property( + arbKey, + fc.integer(), + arbJsonScalar.filter((v) => v !== null && v !== undefined), + (key, original, replacement) => { + const wrapped = augmentObject>({ [key]: original }); + wrapped[key] = undefined; + expect(key in wrapped).toBe(false); + wrapped[key] = replacement; + expect(key in wrapped).toBe(true); + expect(Object.is(wrapped[key], replacement)).toBe(true); + }, + ), + ); + }); + + it('ownKeys never duplicates entries across original and new keys', () => { + fc.assert( + fc.property( + fc.dictionary(arbKey, fc.integer(), { maxKeys: 5 }), + fc.dictionary(arbKey, fc.integer(), { maxKeys: 5 }), + (original, additions) => { + const wrapped = augmentObject({ ...original } as Record); + for (const [k, v] of Object.entries(additions)) wrapped[k] = v; + const keys = Reflect.ownKeys(wrapped); + expect(new Set(keys).size).toBe(keys.length); + }, + ), + ); + }); + }); + + // Model-based stateful test: drives a random sequence of set / delete / + // set-undefined operations and asserts the invariants every operation + // MUST preserve, including (a) original immutability and (b) read-trap + // consistency (has ⇔ ownKeys ⇔ getOwnPropertyDescriptor ⇔ get). + describe('stateful invariants', () => { + const arbOp = fc.oneof( + fc.record({ kind: fc.constant('set' as const), key: arbKey, value: fc.integer() }), + fc.record({ kind: fc.constant('delete' as const), key: arbKey }), + fc.record({ kind: fc.constant('setUndefined' as const), key: arbKey }), + ); + + it('arbitrary op sequences never mutate the original and keep read traps consistent', () => { + fc.assert( + fc.property( + fc.dictionary(arbKey, fc.integer(), { maxKeys: 4 }), + fc.array(arbOp, { maxLength: 20 }), + (initial, ops) => { + const original = { ...initial }; + const snapshot = JSON.parse(JSON.stringify(original)) as Record; + const wrapped = augmentObject(original as Record); + + for (const op of ops) { + if (op.kind === 'set') wrapped[op.key] = op.value; + else if (op.kind === 'delete') delete wrapped[op.key]; + else wrapped[op.key] = undefined; + } + + // Invariant 1: the original is structurally untouched. + expect(original).toEqual(snapshot); + + // Invariant 2: read traps agree pairwise on every key the + // universe might mention. + const reflectKeys = Reflect.ownKeys(wrapped); + const objectKeys = Object.keys(wrapped); + expect(new Set(objectKeys)).toEqual(new Set(reflectKeys.map(String))); + + const universe = new Set([ + ...Object.keys(snapshot), + ...ops.map((o) => o.key), + ]); + for (const key of universe) { + const present = key in wrapped; + const inOwnKeys = reflectKeys.includes(key); + const descriptor = Object.getOwnPropertyDescriptor(wrapped, key); + expect(present).toBe(inOwnKeys); + expect(present).toBe(descriptor !== undefined); + if (!present) expect(wrapped[key]).toBeUndefined(); + } + }, + ), + ); + }); + }); + }); });