mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
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:
parent
3dd9c9d9c4
commit
d28e122fe7
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user