test: Strengthen augment-object mutation coverage (#31854)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Declan Carroll <declan@n8n.io>
This commit is contained in:
n8n-cat-bot[bot] 2026-06-08 10:17:34 +01:00 committed by GitHub
parent 3dd9c9d9c4
commit d28e122fe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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<unknown>([...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<number>([...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<Record<string, unknown>>({ [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<Record<string, null>>({ [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<string, unknown> }>({
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<Record<string, unknown>>({});
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<string, number>);
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<string, number | undefined>);
const undef = augmentObject({ ...obj } as Record<string, number | undefined>);
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<Record<string, unknown>>({});
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<Record<string, unknown>>({});
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<Record<string, unknown>>({ [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<Record<string, unknown>>({ [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<string, number>);
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<string, number>;
const wrapped = augmentObject(original as Record<string, number | undefined>);
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<string>([
...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();
}
},
),
);
});
});
});
});