diff --git a/packages/core/src/lib/utils/merge.ts b/packages/core/src/lib/utils/merge.ts index 83356cd5d3..1d07c5d296 100644 --- a/packages/core/src/lib/utils/merge.ts +++ b/packages/core/src/lib/utils/merge.ts @@ -1,5 +1,13 @@ -function isObject(item: unknown): item is object { - return item !== null && typeof item === "object" +function isPlainObject(item: unknown): item is Record { + return ( + item !== null && + typeof item === "object" && + !Array.isArray(item) && + !(item instanceof Date) && + !(item instanceof RegExp) && + !(item instanceof Error) && + Object.prototype.toString.call(item) === "[object Object]" + ) } /** Deep merge two or more objects */ @@ -8,23 +16,34 @@ export function merge>( ...sources: Array | undefined> ): T & Record { if (!sources.length) return target - const source = sources.shift() - if (isObject(target) && isObject(source)) { - for (const key in source) { - if (isObject(source[key])) { - if (!isObject(target[key])) - (target as Record)[key] = Array.isArray(source[key]) - ? [] - : {} + for (const source of sources) { + if (!source) { + continue + } + + // Use Object.keys to avoid prototype pollution + for (const key of Object.keys(source)) { + const sourceValue = source[key] + const targetValue = (target as Record)[key] + + // Skip undefined values from source + if (sourceValue === undefined) { + continue + } + + // If both values are plain objects, merge them recursively + if (isPlainObject(targetValue) && isPlainObject(sourceValue)) { merge( - (target as Record)[key] as T, - source[key] as Record + targetValue as Record, + sourceValue as Record ) - } else if (source[key] !== undefined) - (target as Record)[key] = source[key] + } else { + // Otherwise, override the target value with the source value + ;(target as Record)[key] = sourceValue + } } } - return merge(target, ...sources) + return target as T & Record } diff --git a/packages/core/test/merge.test.ts b/packages/core/test/merge.test.ts index f067b953de..df79b9558a 100644 --- a/packages/core/test/merge.test.ts +++ b/packages/core/test/merge.test.ts @@ -108,4 +108,240 @@ describe("merge function", () => { authorization: "https://example.com/user-config", }) }) + + it("should handle merging Date object", () => { + const target = { + sessionToken: { + name: "authjs.session-token", + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: false, + }, + }, + } + const source = { + sessionToken: { + options: { + expires: new Date("2024-01-01T00:00:00Z"), + }, + }, + } + + const expected = { + sessionToken: { + name: "authjs.session-token", + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: false, + expires: source.sessionToken.options.expires, + }, + }, + } + + expect(merge(target, source)).toEqual(expected) + }) + + it("should handle RegExp objects", () => { + const target = { pattern: /old/g } + const source = { pattern: /new/i } + + const result = merge(target, source) + expect(result.pattern).toEqual(/new/i) + expect(result.pattern.flags).toBe("i") + }) + + it("should handle Error objects", () => { + const target = { error: new Error("old error") } + const source = { error: new TypeError("new error") } + + const result = merge(target, source) + expect(result.error).toBeInstanceOf(TypeError) + expect(result.error.message).toBe("new error") + }) + + it("should handle mixed types correctly", () => { + const target = { + number: 1, + string: "hello", + boolean: true, + array: [1, 2, 3], + object: { nested: "value" }, + nullValue: null, + undefinedValue: undefined, + } + + const source = { + number: 2, + string: "world", + boolean: false, + array: [4, 5], + object: { nested: "new value", added: "property" }, + nullValue: "not null anymore", + undefinedValue: "defined now", + newProperty: "brand new", + } + + const result = merge(target, source) + + expect(result).toEqual({ + number: 2, + string: "world", + boolean: false, + array: [4, 5], + object: { nested: "new value", added: "property" }, + nullValue: "not null anymore", + undefinedValue: "defined now", + newProperty: "brand new", + }) + }) + + it("should mutate the target object", () => { + const target = { a: 1, b: { c: 2 } } + const source = { b: { d: 3 } } + const originalTarget = JSON.parse(JSON.stringify(target)) + const originalSource = JSON.parse(JSON.stringify(source)) + + const result = merge(target, source) + + // Target should be mutated + expect(target).not.toEqual(originalTarget) + expect(target).toBe(result) // Should return the same reference + expect(target.b.c).toBe(2) // Original nested properties preserved + expect((target.b as any).d).toBe(3) // New properties added + + // Source should not be mutated + expect(source).toEqual(originalSource) + }) + + it("should handle deeply nested objects", () => { + const target = { + level1: { + level2: { + level3: { + level4: { + value: "original", + }, + }, + }, + }, + } + + const source = { + level1: { + level2: { + level3: { + level4: { + value: "updated", + newValue: "added", + }, + newLevel4: "added", + }, + newLevel3: "added", + }, + newLevel2: "added", + }, + newLevel1: "added", + } + + const result = merge(target, source) + + expect(result).toEqual({ + level1: { + level2: { + level3: { + level4: { + value: "updated", + newValue: "added", + }, + newLevel4: "added", + }, + newLevel3: "added", + }, + newLevel2: "added", + }, + newLevel1: "added", + }) + }) + + it("should handle class instances correctly", () => { + class TestClass { + constructor(public value: string) {} + } + + const target = { instance: new TestClass("original") } + const source = { instance: new TestClass("updated") } + + const result = merge(target, source) + expect(result.instance).toBeInstanceOf(TestClass) + expect(result.instance.value).toBe("updated") + }) + + it("should handle symbols as values", () => { + const sym1 = Symbol("test1") + const sym2 = Symbol("test2") + + const target = { symbol: sym1 } + const source = { symbol: sym2 } + + const result = merge(target, source) + expect(result.symbol).toBe(sym2) + }) + + it("should handle arrays with objects", () => { + const target = { items: [{ id: 1, name: "item1" }] } + const source = { + items: [ + { id: 2, name: "item2" }, + { id: 3, name: "item3" }, + ], + } + + const result = merge(target, source) + expect(result.items).toEqual([ + { id: 2, name: "item2" }, + { id: 3, name: "item3" }, + ]) + }) + + it("should handle empty arrays", () => { + const target = { items: [1, 2, 3] } + const source = { items: [] } + + const result = merge(target, source) + expect(result.items).toEqual([]) + }) + + it("should handle constructor property", () => { + const target = { constructor: Object } + const source = { constructor: Array } + + const result = merge(target, source) + expect(result.constructor).toBe(Array) + }) + + it("should handle Map and Set objects", () => { + const map1 = new Map([["key1", "value1"]]) + const map2 = new Map([["key2", "value2"]]) + const set1 = new Set([1, 2]) + const set2 = new Set([3, 4]) + + const target = { map: map1, set: set1 } + const source = { map: map2, set: set2 } + + const result = merge(target, source) + expect(result.map).toBe(map2) + expect(result.set).toBe(set2) + }) + + it("should handle objects with numeric keys", () => { + const target = { 0: "zero", 1: "one" } + const source = { 1: "updated one", 2: "two" } + + const result = merge(target, source) + expect(result).toEqual({ 0: "zero", 1: "updated one", 2: "two" }) + }) })