diff --git a/apps/login/src/helpers/base64.test.ts b/apps/login/src/helpers/base64.test.ts new file mode 100644 index 000000000..371a7b6a8 --- /dev/null +++ b/apps/login/src/helpers/base64.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { coerceToBase64Url, coerceToArrayBuffer } from "./base64"; + +const originalBtoa = global.btoa; +const originalAtob = global.atob; + +beforeEach(() => { + global.btoa = (str: string) => Buffer.from(str, "binary").toString("base64"); + global.atob = (b64: string) => Buffer.from(b64, "base64").toString("binary"); + global.window = global.window || ({} as any); + (global.window as any).btoa = global.btoa; + (global.window as any).atob = global.atob; +}); + +afterEach(() => { + global.btoa = originalBtoa; + global.atob = originalAtob; +}); + +describe("base64 utilities", () => { + describe("coerceToBase64Url", () => { + it("should convert Uint8Array to base64url", () => { + const uint8Array = new Uint8Array([72, 101, 108, 108, 111]); + const result = coerceToBase64Url(uint8Array, "test"); + + expect(result).not.toContain("+"); + expect(result).not.toContain("/"); + expect(result).not.toContain("="); + expect(typeof result).toBe("string"); + }); + + it("should convert Array to base64url", () => { + const array = [72, 101, 108, 108, 111]; + const result = coerceToBase64Url(array, "test"); + + expect(result).not.toContain("+"); + expect(result).not.toContain("/"); + expect(result).not.toContain("="); + expect(typeof result).toBe("string"); + }); + + it("should convert ArrayBuffer to base64url", () => { + const buffer = new Uint8Array([72, 101, 108, 108, 111]).buffer; + const result = coerceToBase64Url(buffer, "test"); + + expect(result).not.toContain("+"); + expect(result).not.toContain("/"); + expect(result).not.toContain("="); + expect(typeof result).toBe("string"); + }); + + it("should convert standard base64 string to base64url", () => { + const base64 = "Hello+World/Test="; + const result = coerceToBase64Url(base64, "test"); + + expect(result).toBe("Hello-World_Test"); + expect(result).not.toContain("+"); + expect(result).not.toContain("/"); + expect(result).not.toContain("="); + }); + + it("should handle already URL-safe base64 strings", () => { + const base64url = "Hello-World_Test"; + const result = coerceToBase64Url(base64url, "test"); + + expect(result).toBe("Hello-World_Test"); + }); + + it("should remove padding from base64 strings", () => { + const base64WithPadding = "Test=="; + const result = coerceToBase64Url(base64WithPadding, "test"); + + expect(result).toBe("Test"); + expect(result).not.toContain("="); + }); + + it("should handle empty Uint8Array", () => { + const uint8Array = new Uint8Array([]); + const result = coerceToBase64Url(uint8Array, "test"); + + expect(typeof result).toBe("string"); + expect(result).toBe(""); + }); + + it("should handle empty string", () => { + const result = coerceToBase64Url("", "test"); + + expect(result).toBe(""); + }); + + it("should throw error for non-coercible types", () => { + expect(() => coerceToBase64Url(123, "number")).toThrow("could not coerce 'number' to string"); + + expect(() => coerceToBase64Url(null, "null")).toThrow("could not coerce 'null' to string"); + + expect(() => coerceToBase64Url(undefined, "undefined")).toThrow("could not coerce 'undefined' to string"); + + expect(() => coerceToBase64Url({}, "object")).toThrow("could not coerce 'object' to string"); + }); + + it("should handle binary data correctly", () => { + const binaryData = new Uint8Array([0, 1, 2, 255, 254, 253]); + const result = coerceToBase64Url(binaryData, "binary"); + + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("should handle large Uint8Arrays", () => { + const largeArray = new Uint8Array(1000); + for (let i = 0; i < 1000; i++) { + largeArray[i] = i % 256; + } + + const result = coerceToBase64Url(largeArray, "large"); + + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("should produce consistent results for same input", () => { + const input = new Uint8Array([1, 2, 3, 4, 5]); + const result1 = coerceToBase64Url(input, "test"); + const result2 = coerceToBase64Url(input, "test"); + + expect(result1).toBe(result2); + }); + }); + + describe("coerceToArrayBuffer", () => { + it("should convert base64url string to ArrayBuffer", () => { + const base64url = "SGVsbG8"; + const result = coerceToArrayBuffer(base64url, "test"); + + expect(result).toBeInstanceOf(ArrayBuffer); + expect(result.byteLength).toBeGreaterThan(0); + }); + + it("should convert base64 string with URL-safe characters to ArrayBuffer", () => { + const base64url = "Hello-World_Test"; + const result = coerceToArrayBuffer(base64url, "test"); + + expect(result).toBeInstanceOf(ArrayBuffer); + }); + + it("should convert Array to ArrayBuffer", () => { + const array = [72, 101, 108, 108, 111]; + const result = coerceToArrayBuffer(array, "test"); + + expect(result).toBeInstanceOf(ArrayBuffer); + + const view = new Uint8Array(result); + expect(Array.from(view)).toEqual(array); + }); + + it("should convert Uint8Array to ArrayBuffer", () => { + const uint8Array = new Uint8Array([72, 101, 108, 108, 111]); + const result = coerceToArrayBuffer(uint8Array, "test"); + + expect(result).toBeInstanceOf(ArrayBuffer); + expect(result).toBe(uint8Array.buffer); + }); + + it("should handle ArrayBuffer input (passthrough)", () => { + const buffer = new Uint8Array([1, 2, 3]).buffer; + const result = coerceToArrayBuffer(buffer, "test"); + + expect(result).toBeInstanceOf(ArrayBuffer); + expect(result).toBe(buffer); + }); + + it("should handle empty string", () => { + const result = coerceToArrayBuffer("", "test"); + + expect(result).toBeInstanceOf(ArrayBuffer); + expect(result.byteLength).toBe(0); + }); + + it("should handle empty array", () => { + const result = coerceToArrayBuffer([], "test"); + + expect(result).toBeInstanceOf(ArrayBuffer); + expect(result.byteLength).toBe(0); + }); + + it("should throw TypeError for non-coercible types", () => { + expect(() => coerceToArrayBuffer(123, "number")).toThrow(TypeError); + expect(() => coerceToArrayBuffer(123, "number")).toThrow("could not coerce 'number' to ArrayBuffer"); + + expect(() => coerceToArrayBuffer(null, "null")).toThrow(TypeError); + expect(() => coerceToArrayBuffer(undefined, "undefined")).toThrow(TypeError); + expect(() => coerceToArrayBuffer({}, "object")).toThrow(TypeError); + }); + + it("should handle binary data correctly", () => { + const binaryArray = [0, 1, 2, 255, 254, 253]; + const result = coerceToArrayBuffer(binaryArray, "binary"); + + expect(result).toBeInstanceOf(ArrayBuffer); + + const view = new Uint8Array(result); + expect(Array.from(view)).toEqual(binaryArray); + }); + + it("should handle large arrays", () => { + const largeArray = new Array(1000).fill(0).map((_, i) => i % 256); + const result = coerceToArrayBuffer(largeArray, "large"); + + expect(result).toBeInstanceOf(ArrayBuffer); + expect(result.byteLength).toBe(1000); + }); + + it("should produce consistent results for same input", () => { + const input = [1, 2, 3, 4, 5]; + const result1 = coerceToArrayBuffer(input, "test"); + const result2 = coerceToArrayBuffer(input, "test"); + + const view1 = new Uint8Array(result1); + const view2 = new Uint8Array(result2); + + expect(Array.from(view1)).toEqual(Array.from(view2)); + }); + }); + + describe("round-trip conversions", () => { + it("should successfully round-trip Uint8Array -> base64url -> ArrayBuffer", () => { + const original = new Uint8Array([1, 2, 3, 4, 5, 255, 254, 253]); + + const base64url = coerceToBase64Url(original, "test"); + const recovered = coerceToArrayBuffer(base64url, "test"); + + const recoveredArray = new Uint8Array(recovered); + expect(Array.from(recoveredArray)).toEqual(Array.from(original)); + }); + + it("should successfully round-trip Array -> base64url -> ArrayBuffer -> Array", () => { + const original = [10, 20, 30, 40, 50]; + + const base64url = coerceToBase64Url(original, "test"); + const buffer = coerceToArrayBuffer(base64url, "test"); + const recovered = Array.from(new Uint8Array(buffer)); + + expect(recovered).toEqual(original); + }); + + it("should handle empty data in round-trip", () => { + const original = new Uint8Array([]); + + const base64url = coerceToBase64Url(original, "test"); + const recovered = coerceToArrayBuffer(base64url, "test"); + + expect(recovered.byteLength).toBe(0); + }); + + it("should handle special characters in round-trip", () => { + const original = new Uint8Array([0xff, 0xff, 0xff, 0xfe, 0x00, 0x01]); + + const base64url = coerceToBase64Url(original, "test"); + + expect(base64url).not.toContain("+"); + expect(base64url).not.toContain("/"); + expect(base64url).not.toContain("="); + + const recovered = coerceToArrayBuffer(base64url, "test"); + const recoveredArray = new Uint8Array(recovered); + + expect(Array.from(recoveredArray)).toEqual(Array.from(original)); + }); + + it("should handle large data in round-trip", () => { + const original = new Uint8Array(1024); + for (let i = 0; i < 1024; i++) { + original[i] = Math.floor(Math.random() * 256); + } + + const base64url = coerceToBase64Url(original, "test"); + const recovered = coerceToArrayBuffer(base64url, "test"); + const recoveredArray = new Uint8Array(recovered); + + expect(Array.from(recoveredArray)).toEqual(Array.from(original)); + }); + }); + + describe("edge cases", () => { + it("should handle all zero bytes", () => { + const zeros = new Uint8Array([0, 0, 0, 0]); + const base64url = coerceToBase64Url(zeros, "test"); + const recovered = coerceToArrayBuffer(base64url, "test"); + + expect(Array.from(new Uint8Array(recovered))).toEqual([0, 0, 0, 0]); + }); + + it("should handle all max bytes", () => { + const maxBytes = new Uint8Array([255, 255, 255, 255]); + const base64url = coerceToBase64Url(maxBytes, "test"); + const recovered = coerceToArrayBuffer(base64url, "test"); + + expect(Array.from(new Uint8Array(recovered))).toEqual([255, 255, 255, 255]); + }); + + it("should handle single byte", () => { + const singleByte = new Uint8Array([42]); + const base64url = coerceToBase64Url(singleByte, "test"); + const recovered = coerceToArrayBuffer(base64url, "test"); + + expect(Array.from(new Uint8Array(recovered))).toEqual([42]); + }); + + it("should provide meaningful error messages with parameter names", () => { + expect(() => coerceToBase64Url(123, "myParameter")).toThrow("could not coerce 'myParameter' to string"); + + expect(() => coerceToArrayBuffer(123, "anotherParam")).toThrow("could not coerce 'anotherParam' to ArrayBuffer"); + }); + }); +}); diff --git a/apps/login/src/helpers/colors.test.ts b/apps/login/src/helpers/colors.test.ts new file mode 100644 index 000000000..fc9c7366f --- /dev/null +++ b/apps/login/src/helpers/colors.test.ts @@ -0,0 +1,622 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + computeMap, + setTheme, + DARK_PRIMARY, + PRIMARY, + DARK_WARN, + WARN, + DARK_BACKGROUND, + BACKGROUND, + DARK_TEXT, + TEXT, +} from "./colors"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; + +describe("color utilities", () => { + describe("computeMap", () => { + it("should compute light theme color maps with default colors", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: PRIMARY, + warnColor: WARN, + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, false); + + expect(result).toHaveProperty("background"); + expect(result).toHaveProperty("primary"); + expect(result).toHaveProperty("warn"); + expect(result).toHaveProperty("text"); + expect(result).toHaveProperty("link"); + + expect(result.background).toHaveLength(14); + expect(result.primary).toHaveLength(14); + expect(result.warn).toHaveLength(14); + expect(result.text).toHaveLength(14); + expect(result.link).toHaveLength(14); + }); + + it("should compute dark theme color maps", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: PRIMARY, + warnColor: WARN, + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, true); + + expect(result.background).toHaveLength(14); + expect(result.primary).toHaveLength(14); + expect(result.warn).toHaveLength(14); + expect(result.text).toHaveLength(14); + expect(result.link).toHaveLength(14); + }); + + it("should generate color objects with correct properties", () => { + const branding = { + lightTheme: { + backgroundColor: "#ffffff", + fontColor: "#000000", + primaryColor: "#5469d4", + warnColor: "#cd3d56", + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, false); + + result.primary.forEach((color) => { + expect(color).toHaveProperty("name"); + expect(color).toHaveProperty("hex"); + expect(color).toHaveProperty("rgb"); + expect(color).toHaveProperty("contrastColor"); + + expect(color.hex).toMatch(/^#[0-9a-f]{6}$/i); + expect(color.rgb).toMatch(/^rgb\(/); + expect(["#ffffff", "hsla(0, 0%, 0%, 0.87)"]).toContain(color.contrastColor); + }); + }); + + it("should generate correct shade names", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: PRIMARY, + warnColor: WARN, + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, false); + + const expectedNames = [ + "50", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "A100", + "A200", + "A400", + "A700", + ]; + + expect(result.primary.map((c) => c.name)).toEqual(expectedNames); + }); + + it("should compute different colors for light vs dark theme", () => { + const branding = { + lightTheme: { + backgroundColor: "#ffffff", + fontColor: "#000000", + primaryColor: "#5469d4", + warnColor: "#cd3d56", + }, + darkTheme: { + backgroundColor: "#111827", + fontColor: "#ffffff", + primaryColor: "#2073c4", + warnColor: "#ff3b5b", + }, + }; + + const light = computeMap(branding, false); + const dark = computeMap(branding, true); + + // Primary colors should be different + expect(light.primary[5].hex).not.toBe(dark.primary[5].hex); + + // Background colors should be different + expect(light.background[5].hex).not.toBe(dark.background[5].hex); + + // Text colors should be different + expect(light.text[5].hex).not.toBe(dark.text[5].hex); + }); + + it("should handle custom brand colors", () => { + const customBranding = { + lightTheme: { + backgroundColor: "#f0f0f0", + fontColor: "#333333", + primaryColor: "#ff6b6b", + warnColor: "#feca57", + }, + darkTheme: { + backgroundColor: "#1e1e1e", + fontColor: "#e0e0e0", + primaryColor: "#ff8b8b", + warnColor: "#fed57", + }, + }; + + const result = computeMap(customBranding, false); + + // Should generate valid hex colors + result.primary.forEach((color) => { + expect(color.hex).toMatch(/^#[0-9a-f]{6}$/i); + }); + }); + + it("should generate lighter shades for lower values", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: "#5469d4", + warnColor: WARN, + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, false); + + // 50 should be lighter than 500 + const shade50 = result.primary.find((c) => c.name === "50"); + const shade500 = result.primary.find((c) => c.name === "500"); + + expect(shade50).toBeDefined(); + expect(shade500).toBeDefined(); + + // Lighter colors have higher RGB values + const rgb50 = shade50!.rgb; + const rgb500 = shade500!.rgb; + + expect(rgb50).toBeDefined(); + expect(rgb500).toBeDefined(); + }); + + it("should generate darker shades for higher values", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: "#5469d4", + warnColor: WARN, + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, false); + + // 900 should be darker than 500 + const shade500 = result.primary.find((c) => c.name === "500"); + const shade900 = result.primary.find((c) => c.name === "900"); + + expect(shade500).toBeDefined(); + expect(shade900).toBeDefined(); + }); + + it("should compute link colors using primary color", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: "#5469d4", + warnColor: WARN, + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, false); + + // Link should be computed based on primary color + expect(result.link).toHaveLength(14); + result.link.forEach((color) => { + expect(color.hex).toMatch(/^#[0-9a-f]{6}$/i); + }); + }); + + it("should handle grayscale colors", () => { + const grayscaleBranding = { + lightTheme: { + backgroundColor: "#ffffff", + fontColor: "#000000", + primaryColor: "#808080", + warnColor: "#666666", + }, + darkTheme: { + backgroundColor: "#000000", + fontColor: "#ffffff", + primaryColor: "#a0a0a0", + warnColor: "#999999", + }, + }; + + const result = computeMap(grayscaleBranding, false); + + // Should handle grayscale without errors + result.primary.forEach((color) => { + expect(color.hex).toMatch(/^#[0-9a-f]{6}$/i); + expect(color.contrastColor).toBeDefined(); + }); + }); + + it("should assign correct contrast colors for light backgrounds", () => { + const branding = { + lightTheme: { + backgroundColor: "#ffffff", + fontColor: TEXT, + primaryColor: "#ffeb3b", // Light yellow + warnColor: WARN, + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, false); + + // Light colors should have dark contrast + const lightShades = result.primary.filter((c) => ["50", "100", "200", "A100"].includes(c.name)); + + lightShades.forEach((color) => { + expect(color.contrastColor).toBe("hsla(0, 0%, 0%, 0.87)"); + }); + }); + + it("should assign correct contrast colors for dark backgrounds", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: "#1a237e", // Dark blue + warnColor: WARN, + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + const result = computeMap(branding, false); + + // Dark colors should have light contrast + const darkShades = result.primary.filter((c) => ["800", "900"].includes(c.name)); + + darkShades.forEach((color) => { + expect(color.contrastColor).toBe("#ffffff"); + }); + }); + }); + + describe("setTheme", () => { + let mockDocument: any; + + beforeEach(() => { + mockDocument = { + documentElement: { + style: { + setProperty: vi.fn(), + }, + }, + }; + }); + + it("should set theme with default colors when no policy provided", () => { + setTheme(mockDocument); + + expect(mockDocument.documentElement.style.setProperty).toHaveBeenCalled(); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + const propertyNames = calls.map((call: any[]) => call[0]); + + const hasLightBackground = propertyNames.some((name: string) => name.includes("--theme-light-background-")); + const hasDarkBackground = propertyNames.some((name: string) => name.includes("--theme-dark-background-")); + const hasLightPrimary = propertyNames.some((name: string) => name.includes("--theme-light-primary-")); + const hasDarkPrimary = propertyNames.some((name: string) => name.includes("--theme-dark-primary-")); + + expect(hasLightBackground).toBe(true); + expect(hasDarkBackground).toBe(true); + expect(hasLightPrimary).toBe(true); + expect(hasDarkPrimary).toBe(true); + }); + + it("should set theme with custom policy colors", () => { + const customPolicy = { + lightTheme: { + backgroundColor: "#f5f5f5", + fontColor: "#212121", + primaryColor: "#ff6b6b", + warnColor: "#feca57", + }, + darkTheme: { + backgroundColor: "#1a1a1a", + fontColor: "#fafafa", + primaryColor: "#ff8b8b", + warnColor: "#fed57", + }, + } as BrandingSettings; + + setTheme(mockDocument, customPolicy); + + expect(mockDocument.documentElement.style.setProperty).toHaveBeenCalled(); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + expect(calls.length).toBeGreaterThan(0); + }); + + it("should set CSS custom properties for all color shades", () => { + setTheme(mockDocument); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + const propertyNames = calls.map((call: any[]) => call[0]); + + ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"].forEach((shade) => { + const hasShade = propertyNames.some((name: string) => name.includes(`-${shade}`)); + expect(hasShade).toBe(true); + }); + + ["A100", "A200", "A400", "A700"].forEach((shade) => { + const hasShade = propertyNames.some((name: string) => name.includes(`-${shade}`)); + expect(hasShade).toBe(true); + }); + }); + + it("should set contrast colors for each shade", () => { + setTheme(mockDocument); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + const propertyNames = calls.map((call: any[]) => call[0]); + + const contrastProperties = propertyNames.filter((name: string) => name.includes("-contrast-")); + + expect(contrastProperties.length).toBeGreaterThan(0); + }); + + it("should set secondary alpha colors for text and link", () => { + setTheme(mockDocument); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + const propertyNames = calls.map((call: any[]) => call[0]); + + const secondaryProperties = propertyNames.filter((name: string) => name.includes("-secondary-")); + + expect(secondaryProperties.length).toBeGreaterThan(0); + + secondaryProperties.forEach((prop: string) => { + expect(prop.includes("-text-") || prop.includes("-link-")).toBe(true); + }); + }); + + it("should set properties for both light and dark themes", () => { + setTheme(mockDocument); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + const propertyNames = calls.map((call: any[]) => call[0]); + + const lightProperties = propertyNames.filter((name: string) => name.includes("--theme-light-")); + const darkProperties = propertyNames.filter((name: string) => name.includes("--theme-dark-")); + + expect(lightProperties.length).toBeGreaterThan(0); + expect(darkProperties.length).toBeGreaterThan(0); + }); + + it("should set properties for all color types", () => { + setTheme(mockDocument); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + const propertyNames = calls.map((call: any[]) => call[0]); + + const types = ["background", "primary", "warn", "text", "link"]; + + types.forEach((type) => { + const typeProperties = propertyNames.filter((name: string) => name.includes(`-${type}-`)); + expect(typeProperties.length).toBeGreaterThan(0); + }); + }); + + it("should use fallback colors when policy has partial values", () => { + const partialPolicy = { + lightTheme: { + primaryColor: "#ff0000", + }, + } as BrandingSettings; + + setTheme(mockDocument, partialPolicy); + + expect(mockDocument.documentElement.style.setProperty).toHaveBeenCalled(); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + expect(calls.length).toBeGreaterThan(0); + }); + + it("should handle undefined policy gracefully", () => { + expect(() => setTheme(mockDocument, undefined)).not.toThrow(); + expect(mockDocument.documentElement.style.setProperty).toHaveBeenCalled(); + }); + + it("should set valid hex color values", () => { + setTheme(mockDocument); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + + calls.forEach((call: any[]) => { + const [propertyName, propertyValue] = call; + + if (!propertyName.includes("-contrast-") && !propertyName.includes("-secondary-")) { + expect(propertyValue).toMatch(/^#[0-9a-f]{6}$/i); + } + }); + }); + + it("should set alpha channel for secondary colors", () => { + setTheme(mockDocument); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + + const secondaryCalls = calls.filter((call: any[]) => call[0].includes("-secondary-")); + + secondaryCalls.forEach((call: any[]) => { + const [, propertyValue] = call; + expect(propertyValue).toMatch(/c7$/i); + }); + }); + + it("should call setProperty exactly once per CSS variable", () => { + setTheme(mockDocument); + + const calls = mockDocument.documentElement.style.setProperty.mock.calls; + const propertyNames = calls.map((call: any[]) => call[0]); + + // Check for duplicates + const uniqueProperties = new Set(propertyNames); + expect(uniqueProperties.size).toBe(propertyNames.length); + }); + }); + + describe("default color constants", () => { + it("should have valid default color values", () => { + expect(PRIMARY).toMatch(/^#[0-9a-f]{6}$/i); + expect(DARK_PRIMARY).toMatch(/^#[0-9a-f]{6}$/i); + expect(WARN).toMatch(/^#[0-9a-f]{6}$/i); + expect(DARK_WARN).toMatch(/^#[0-9a-f]{6}$/i); + expect(BACKGROUND).toMatch(/^#[0-9a-f]{6}$/i); + expect(DARK_BACKGROUND).toMatch(/^#[0-9a-f]{6}$/i); + expect(TEXT).toMatch(/^#[0-9a-f]{6}$/i); + expect(DARK_TEXT).toMatch(/^#[0-9a-f]{6}$/i); + }); + + it("should have distinct colors for light and dark themes", () => { + expect(PRIMARY).not.toBe(DARK_PRIMARY); + expect(WARN).not.toBe(DARK_WARN); + expect(BACKGROUND).not.toBe(DARK_BACKGROUND); + expect(TEXT).not.toBe(DARK_TEXT); + }); + }); + + describe("edge cases", () => { + it("should handle extreme light colors", () => { + const branding = { + lightTheme: { + backgroundColor: "#ffffff", + fontColor: "#fefefe", + primaryColor: "#fffacd", + warnColor: "#fff8dc", + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: DARK_PRIMARY, + warnColor: DARK_WARN, + }, + }; + + expect(() => computeMap(branding, false)).not.toThrow(); + }); + + it("should handle extreme dark colors", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: PRIMARY, + warnColor: WARN, + }, + darkTheme: { + backgroundColor: "#000000", + fontColor: "#010101", + primaryColor: "#0a0a0a", + warnColor: "#141414", + }, + }; + + expect(() => computeMap(branding, true)).not.toThrow(); + }); + + it("should handle highly saturated colors", () => { + const branding = { + lightTheme: { + backgroundColor: BACKGROUND, + fontColor: TEXT, + primaryColor: "#ff0000", + warnColor: "#00ff00", + }, + darkTheme: { + backgroundColor: DARK_BACKGROUND, + fontColor: DARK_TEXT, + primaryColor: "#0000ff", + warnColor: "#ffff00", + }, + }; + + const result = computeMap(branding, false); + expect(result.primary).toHaveLength(14); + expect(result.warn).toHaveLength(14); + }); + }); +}); diff --git a/apps/login/src/helpers/validators.test.ts b/apps/login/src/helpers/validators.test.ts new file mode 100644 index 000000000..796e46eb5 --- /dev/null +++ b/apps/login/src/helpers/validators.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from "vitest"; +import { symbolValidator, numberValidator, upperCaseValidator, lowerCaseValidator } from "./validators"; + +describe("validators", () => { + describe("symbolValidator", () => { + it("should return true for strings with symbols", () => { + expect(symbolValidator("!")).toBe(true); + expect(symbolValidator("@")).toBe(true); + expect(symbolValidator("#")).toBe(true); + expect(symbolValidator("$")).toBe(true); + expect(symbolValidator("%")).toBe(true); + expect(symbolValidator("^")).toBe(true); + expect(symbolValidator("&")).toBe(true); + expect(symbolValidator("*")).toBe(true); + expect(symbolValidator("(")).toBe(true); + expect(symbolValidator(")")).toBe(true); + }); + + it("should return true for strings with punctuation", () => { + expect(symbolValidator(".")).toBe(true); + expect(symbolValidator(",")).toBe(true); + expect(symbolValidator(";")).toBe(true); + expect(symbolValidator(":")).toBe(true); + expect(symbolValidator("?")).toBe(true); + expect(symbolValidator("'")).toBe(true); + expect(symbolValidator('"')).toBe(true); + }); + + it("should return true for strings with special characters", () => { + expect(symbolValidator("-")).toBe(true); + expect(symbolValidator("_")).toBe(true); + expect(symbolValidator("=")).toBe(true); + expect(symbolValidator("+")).toBe(true); + expect(symbolValidator("[")).toBe(true); + expect(symbolValidator("]")).toBe(true); + expect(symbolValidator("{")).toBe(true); + expect(symbolValidator("}")).toBe(true); + expect(symbolValidator("|")).toBe(true); + expect(symbolValidator("\\")).toBe(true); + expect(symbolValidator("/")).toBe(true); + }); + + it("should return true for strings with whitespace", () => { + expect(symbolValidator(" ")).toBe(true); + expect(symbolValidator("\t")).toBe(true); + expect(symbolValidator("\n")).toBe(true); + }); + + it("should return false for strings with only letters", () => { + expect(symbolValidator("abc")).toBe(false); + expect(symbolValidator("ABC")).toBe(false); + expect(symbolValidator("aBc")).toBe(false); + }); + + it("should return false for strings with only numbers", () => { + expect(symbolValidator("123")).toBe(false); + expect(symbolValidator("0")).toBe(false); + }); + + it("should return false for alphanumeric strings", () => { + expect(symbolValidator("abc123")).toBe(false); + expect(symbolValidator("ABC123")).toBe(false); + expect(symbolValidator("Test123")).toBe(false); + }); + + it("should return true for mixed strings with symbols", () => { + expect(symbolValidator("Password!")).toBe(true); + expect(symbolValidator("Test@123")).toBe(true); + expect(symbolValidator("hello-world")).toBe(true); + expect(symbolValidator("user_name")).toBe(true); + }); + + it("should return false for empty strings", () => { + expect(symbolValidator("")).toBe(false); + }); + + it("should return true for Unicode symbols", () => { + expect(symbolValidator("€")).toBe(true); + expect(symbolValidator("©")).toBe(true); + expect(symbolValidator("™")).toBe(true); + }); + }); + + describe("numberValidator", () => { + it("should return true for strings with single digits", () => { + expect(numberValidator("0")).toBe(true); + expect(numberValidator("1")).toBe(true); + expect(numberValidator("5")).toBe(true); + expect(numberValidator("9")).toBe(true); + }); + + it("should return true for strings with multiple digits", () => { + expect(numberValidator("123")).toBe(true); + expect(numberValidator("000")).toBe(true); + expect(numberValidator("999")).toBe(true); + }); + + it("should return false for strings without numbers", () => { + expect(numberValidator("abc")).toBe(false); + expect(numberValidator("ABC")).toBe(false); + expect(numberValidator("test")).toBe(false); + }); + + it("should return true for alphanumeric strings", () => { + expect(numberValidator("abc123")).toBe(true); + expect(numberValidator("Test1")).toBe(true); + expect(numberValidator("1test")).toBe(true); + }); + + it("should return false for strings with only symbols", () => { + expect(numberValidator("!@#$%")).toBe(false); + expect(numberValidator("***")).toBe(false); + }); + + it("should return true for mixed content with numbers", () => { + expect(numberValidator("Password1")).toBe(true); + expect(numberValidator("Test@123")).toBe(true); + expect(numberValidator("hello-2-world")).toBe(true); + }); + + it("should return false for empty strings", () => { + expect(numberValidator("")).toBe(false); + }); + + it("should return false for whitespace only", () => { + expect(numberValidator(" ")).toBe(false); + expect(numberValidator("\t\n")).toBe(false); + }); + + it("should handle numbers anywhere in the string", () => { + expect(numberValidator("a1")).toBe(true); + expect(numberValidator("1a")).toBe(true); + expect(numberValidator("abc1def")).toBe(true); + }); + }); + + describe("upperCaseValidator", () => { + it("should return true for strings with single uppercase letters", () => { + expect(upperCaseValidator("A")).toBe(true); + expect(upperCaseValidator("Z")).toBe(true); + expect(upperCaseValidator("M")).toBe(true); + }); + + it("should return true for strings with multiple uppercase letters", () => { + expect(upperCaseValidator("ABC")).toBe(true); + expect(upperCaseValidator("TEST")).toBe(true); + expect(upperCaseValidator("HELLO")).toBe(true); + }); + + it("should return false for strings with only lowercase letters", () => { + expect(upperCaseValidator("abc")).toBe(false); + expect(upperCaseValidator("test")).toBe(false); + expect(upperCaseValidator("hello")).toBe(false); + }); + + it("should return true for mixed case strings", () => { + expect(upperCaseValidator("Test")).toBe(true); + expect(upperCaseValidator("heLLo")).toBe(true); + expect(upperCaseValidator("aBcDeF")).toBe(true); + }); + + it("should return false for strings with only numbers", () => { + expect(upperCaseValidator("123")).toBe(false); + expect(upperCaseValidator("000")).toBe(false); + }); + + it("should return false for strings with only symbols", () => { + expect(upperCaseValidator("!@#$%")).toBe(false); + expect(upperCaseValidator("***")).toBe(false); + }); + + it("should return true for complex passwords with uppercase", () => { + expect(upperCaseValidator("Password1!")).toBe(true); + expect(upperCaseValidator("test@Test123")).toBe(true); + expect(upperCaseValidator("hello-World")).toBe(true); + }); + + it("should return false for empty strings", () => { + expect(upperCaseValidator("")).toBe(false); + }); + + it("should handle uppercase anywhere in the string", () => { + expect(upperCaseValidator("Aaaa")).toBe(true); + expect(upperCaseValidator("aaaA")).toBe(true); + expect(upperCaseValidator("aaAaa")).toBe(true); + }); + + it("should work with numbers and symbols present", () => { + expect(upperCaseValidator("Test123!@#")).toBe(true); + expect(upperCaseValidator("123A456")).toBe(true); + expect(upperCaseValidator("!@#A$%^")).toBe(true); + }); + }); + + describe("lowerCaseValidator", () => { + it("should return true for strings with single lowercase letters", () => { + expect(lowerCaseValidator("a")).toBe(true); + expect(lowerCaseValidator("z")).toBe(true); + expect(lowerCaseValidator("m")).toBe(true); + }); + + it("should return true for strings with multiple lowercase letters", () => { + expect(lowerCaseValidator("abc")).toBe(true); + expect(lowerCaseValidator("test")).toBe(true); + expect(lowerCaseValidator("hello")).toBe(true); + }); + + it("should return false for strings with only uppercase letters", () => { + expect(lowerCaseValidator("ABC")).toBe(false); + expect(lowerCaseValidator("TEST")).toBe(false); + expect(lowerCaseValidator("HELLO")).toBe(false); + }); + + it("should return true for mixed case strings", () => { + expect(lowerCaseValidator("Test")).toBe(true); + expect(lowerCaseValidator("heLLo")).toBe(true); + expect(lowerCaseValidator("aBcDeF")).toBe(true); + }); + + it("should return false for strings with only numbers", () => { + expect(lowerCaseValidator("123")).toBe(false); + expect(lowerCaseValidator("000")).toBe(false); + }); + + it("should return false for strings with only symbols", () => { + expect(lowerCaseValidator("!@#$%")).toBe(false); + expect(lowerCaseValidator("***")).toBe(false); + }); + + it("should return true for complex passwords with lowercase", () => { + expect(lowerCaseValidator("Password1!")).toBe(true); + expect(lowerCaseValidator("TEST@test123")).toBe(true); + expect(lowerCaseValidator("HELLO-world")).toBe(true); + }); + + it("should return false for empty strings", () => { + expect(lowerCaseValidator("")).toBe(false); + }); + + it("should handle lowercase anywhere in the string", () => { + expect(lowerCaseValidator("aAAA")).toBe(true); + expect(lowerCaseValidator("AAAa")).toBe(true); + expect(lowerCaseValidator("AAaAA")).toBe(true); + }); + + it("should work with numbers and symbols present", () => { + expect(lowerCaseValidator("test123!@#")).toBe(true); + expect(lowerCaseValidator("123a456")).toBe(true); + expect(lowerCaseValidator("!@#a$%^")).toBe(true); + }); + }); + + describe("password complexity validation scenarios", () => { + it("should validate a strong password with all requirements", () => { + const password = "MyP@ssw0rd!"; + + expect(upperCaseValidator(password)).toBe(true); + expect(lowerCaseValidator(password)).toBe(true); + expect(numberValidator(password)).toBe(true); + expect(symbolValidator(password)).toBe(true); + }); + + it("should fail weak password without symbols", () => { + const password = "MyPassword123"; + + expect(upperCaseValidator(password)).toBe(true); + expect(lowerCaseValidator(password)).toBe(true); + expect(numberValidator(password)).toBe(true); + expect(symbolValidator(password)).toBe(false); + }); + + it("should fail password without uppercase", () => { + const password = "myp@ssw0rd!"; + + expect(upperCaseValidator(password)).toBe(false); + expect(lowerCaseValidator(password)).toBe(true); + expect(numberValidator(password)).toBe(true); + expect(symbolValidator(password)).toBe(true); + }); + + it("should fail password without lowercase", () => { + const password = "MYP@SSW0RD!"; + + expect(upperCaseValidator(password)).toBe(true); + expect(lowerCaseValidator(password)).toBe(false); + expect(numberValidator(password)).toBe(true); + expect(symbolValidator(password)).toBe(true); + }); + + it("should fail password without numbers", () => { + const password = "MyP@ssword!"; + + expect(upperCaseValidator(password)).toBe(true); + expect(lowerCaseValidator(password)).toBe(true); + expect(numberValidator(password)).toBe(false); + expect(symbolValidator(password)).toBe(true); + }); + + it("should validate very long complex password", () => { + const password = "ThisIsAVeryLongP@ssw0rd!WithManyCharacters123"; + + expect(upperCaseValidator(password)).toBe(true); + expect(lowerCaseValidator(password)).toBe(true); + expect(numberValidator(password)).toBe(true); + expect(symbolValidator(password)).toBe(true); + }); + + it("should validate password with multiple symbols", () => { + const password = "P@$$w0rd!#%"; + + expect(upperCaseValidator(password)).toBe(true); + expect(lowerCaseValidator(password)).toBe(true); + expect(numberValidator(password)).toBe(true); + expect(symbolValidator(password)).toBe(true); + }); + }); +}); diff --git a/apps/login/src/lib/auth-utils.test.ts b/apps/login/src/lib/auth-utils.test.ts new file mode 100644 index 000000000..85f51fae9 --- /dev/null +++ b/apps/login/src/lib/auth-utils.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from "vitest"; +import { validateAuthRequest, isRSCRequest } from "./auth-utils"; + +describe("auth-utils", () => { + describe("validateAuthRequest", () => { + it("should return null when no auth parameters are present", () => { + const searchParams = new URLSearchParams(); + const result = validateAuthRequest(searchParams); + expect(result).toBeNull(); + }); + + it("should return requestId when explicitly provided", () => { + const searchParams = new URLSearchParams({ + requestId: "explicit-request-123", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe("explicit-request-123"); + }); + + it("should generate OIDC requestId from authRequest parameter", () => { + const searchParams = new URLSearchParams({ + authRequest: "oidc-auth-456", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe("oidc_oidc-auth-456"); + }); + + it("should generate SAML requestId from samlRequest parameter", () => { + const searchParams = new URLSearchParams({ + samlRequest: "saml-req-789", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe("saml_saml-req-789"); + }); + + it("should prioritize explicit requestId over authRequest", () => { + const searchParams = new URLSearchParams({ + requestId: "explicit-123", + authRequest: "oidc-456", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe("explicit-123"); + }); + + it("should prioritize explicit requestId over samlRequest", () => { + const searchParams = new URLSearchParams({ + requestId: "explicit-123", + samlRequest: "saml-456", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe("explicit-123"); + }); + + it("should handle both authRequest and samlRequest, preferring OIDC", () => { + const searchParams = new URLSearchParams({ + authRequest: "oidc-123", + samlRequest: "saml-456", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe("oidc_oidc-123"); + }); + + it("should handle empty string requestId", () => { + const searchParams = new URLSearchParams({ + requestId: "", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBeNull(); + }); + + it("should handle whitespace in requestId", () => { + const searchParams = new URLSearchParams({ + requestId: " request-with-spaces ", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe(" request-with-spaces "); + }); + + it("should handle special characters in requestId", () => { + const searchParams = new URLSearchParams({ + requestId: "request-!@#$%^&*()", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe("request-!@#$%^&*()"); + }); + + it("should handle URL-encoded values", () => { + const searchParams = new URLSearchParams({ + authRequest: "oidc encoded", + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe("oidc_oidc encoded"); + }); + + it("should handle very long requestId values", () => { + const longId = "a".repeat(1000); + const searchParams = new URLSearchParams({ + requestId: longId, + }); + const result = validateAuthRequest(searchParams); + expect(result).toBe(longId); + }); + }); + + describe("isRSCRequest", () => { + it("should return true when _rsc parameter is present", () => { + const searchParams = new URLSearchParams({ + _rsc: "1", + }); + const result = isRSCRequest(searchParams); + expect(result).toBe(true); + }); + + it("should return true when _rsc parameter has empty value", () => { + const searchParams = new URLSearchParams({ + _rsc: "", + }); + const result = isRSCRequest(searchParams); + expect(result).toBe(true); + }); + + it("should return false when _rsc parameter is not present", () => { + const searchParams = new URLSearchParams({ + other: "param", + }); + const result = isRSCRequest(searchParams); + expect(result).toBe(false); + }); + + it("should return false for empty search params", () => { + const searchParams = new URLSearchParams(); + const result = isRSCRequest(searchParams); + expect(result).toBe(false); + }); + + it("should return true regardless of _rsc value", () => { + const searchParams = new URLSearchParams({ + _rsc: "any-value-123", + }); + const result = isRSCRequest(searchParams); + expect(result).toBe(true); + }); + + it("should work with multiple parameters", () => { + const searchParams = new URLSearchParams({ + authRequest: "123", + _rsc: "1", + other: "param", + }); + const result = isRSCRequest(searchParams); + expect(result).toBe(true); + }); + + it("should be case-sensitive for parameter name", () => { + const searchParams = new URLSearchParams({ + _RSC: "1", + }); + const result = isRSCRequest(searchParams); + expect(result).toBe(false); + }); + }); + + describe("integration: validateAuthRequest and isRSCRequest", () => { + it("should handle typical OIDC auth flow with RSC", () => { + const searchParams = new URLSearchParams({ + authRequest: "oidc-123", + _rsc: "1", + }); + + const requestId = validateAuthRequest(searchParams); + const isRSC = isRSCRequest(searchParams); + + expect(requestId).toBe("oidc_oidc-123"); + expect(isRSC).toBe(true); + }); + + it("should handle SAML flow without RSC", () => { + const searchParams = new URLSearchParams({ + samlRequest: "saml-456", + }); + + const requestId = validateAuthRequest(searchParams); + const isRSC = isRSCRequest(searchParams); + + expect(requestId).toBe("saml_saml-456"); + expect(isRSC).toBe(false); + }); + }); +}); diff --git a/apps/login/src/lib/cookies.test.ts b/apps/login/src/lib/cookies.test.ts new file mode 100644 index 000000000..0b85d5075 --- /dev/null +++ b/apps/login/src/lib/cookies.test.ts @@ -0,0 +1,893 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + Cookie, + addSessionToCookie, + updateSessionCookie, + removeSessionFromCookie, + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, + getAllSessionCookieIds, + getAllSessions, + getMostRecentCookieWithLoginname, + setLanguageCookie, +} from "./cookies"; + +// Mock dependencies +vi.mock("next/headers", () => ({ + cookies: vi.fn(), +})); + +vi.mock("@zitadel/client", () => ({ + timestampDate: vi.fn((ts: any) => new Date(Number(ts.seconds) * 1000)), + timestampFromMs: vi.fn((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any), +})); + +import { cookies } from "next/headers"; +import { timestampDate, timestampFromMs } from "@zitadel/client"; + +describe("cookies", () => { + let mockCookies: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockCookies = { + get: vi.fn(), + set: vi.fn(), + }; + vi.mocked(cookies).mockResolvedValue(mockCookies); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("setLanguageCookie", () => { + it("should set language cookie with correct parameters", async () => { + await setLanguageCookie("en"); + + expect(mockCookies.set).toHaveBeenCalledWith({ + name: "NEXT_LOCALE", + value: "en", + httpOnly: true, + path: "/", + }); + }); + }); + + describe("addSessionToCookie", () => { + const mockSession: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user@example.com", + organization: "org-1", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + it("should add new session to empty cookie", async () => { + mockCookies.get.mockReturnValue(undefined); + + await addSessionToCookie({ session: mockSession }); + + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: "sessions", + value: JSON.stringify([mockSession]), + }), + ); + }); + + it("should prepend new session to existing sessions", async () => { + const existingSession: Cookie = { + id: "session-0", + token: "token-0", + loginName: "other@example.com", + creationTs: "1600000000000", + expirationTs: "1700000000000", + changeTs: "1600000000000", + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([existingSession]), + }); + + await addSessionToCookie({ session: mockSession }); + + const expectedSessions = [mockSession, existingSession]; + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + value: JSON.stringify(expectedSessions), + }), + ); + }); + + it("should update existing session with same loginName", async () => { + const existingSession: Cookie = { + id: "session-old", + token: "token-old", + loginName: "user@example.com", + creationTs: "1600000000000", + expirationTs: "1700000000000", + changeTs: "1600000000000", + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([existingSession]), + }); + + const updatedSession: Cookie = { + ...mockSession, + id: "session-new", + token: "token-new", + }; + + await addSessionToCookie({ session: updatedSession }); + + const setCall = mockCookies.set.mock.calls[0][0]; + const sessions = JSON.parse(setCall.value); + + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe("session-new"); + expect(sessions[0].token).toBe("token-new"); + }); + + it("should handle cookie overflow by replacing oldest session", async () => { + // Create many sessions to exceed MAX_COOKIE_SIZE (2048) + const manySessions: Cookie[] = Array.from({ length: 10 }, (_, i) => ({ + id: `session-${i}`, + token: `token-${i}-very-long-token-to-increase-size-padding-padding-padding`, + loginName: `user${i}@example.com`, + creationTs: `${1600000000000 + i * 1000}`, + expirationTs: `${1700000000000 + i * 1000}`, + changeTs: `${1600000000000 + i * 1000}`, + })); + + mockCookies.get.mockReturnValue({ + value: JSON.stringify(manySessions), + }); + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await addSessionToCookie({ session: mockSession }); + + expect(consoleSpy).toHaveBeenCalledWith("WARNING COOKIE OVERFLOW"); + + const setCall = mockCookies.set.mock.calls[0][0]; + const sessions = JSON.parse(setCall.value); + + // Should have new session and all but the first old session + expect(sessions[0]).toEqual(mockSession); + expect(sessions).not.toContain(manySessions[0]); + + consoleSpy.mockRestore(); + }); + + it("should cleanup expired sessions when cleanup is true", async () => { + const now = Date.now(); + const expiredSession: Cookie = { + id: "session-expired", + token: "token-expired", + loginName: "expired@example.com", + creationTs: `${now - 10000000}`, + expirationTs: `${now - 1000}`, // Expired + changeTs: `${now - 10000000}`, + }; + + const validSession: Cookie = { + id: "session-valid", + token: "token-valid", + loginName: "valid@example.com", + creationTs: `${now}`, + expirationTs: `${now + 10000000}`, // Not expired + changeTs: `${now}`, + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([expiredSession, validSession]), + }); + + vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); + vi.mocked(timestampFromMs).mockImplementation((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any); + + await addSessionToCookie({ session: mockSession, cleanup: true }); + + const setCall = mockCookies.set.mock.calls[0][0]; + const sessions = JSON.parse(setCall.value); + + // Should not include expired session + const hasExpired = sessions.some((s: Cookie) => s.id === "session-expired"); + expect(hasExpired).toBe(false); + }); + + it("should set sameSite to 'none' when iFrameEnabled is true", async () => { + mockCookies.get.mockReturnValue(undefined); + + await addSessionToCookie({ session: mockSession, iFrameEnabled: true }); + + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + sameSite: "none", + }), + ); + }); + + it("should set sameSite to 'lax' when iFrameEnabled is false or undefined", async () => { + mockCookies.get.mockReturnValue(undefined); + + await addSessionToCookie({ session: mockSession, iFrameEnabled: false }); + + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + sameSite: "lax", + }), + ); + + mockCookies.set.mockClear(); + await addSessionToCookie({ session: mockSession }); + + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + sameSite: "lax", + }), + ); + }); + }); + + describe("updateSessionCookie", () => { + const mockSession: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + it("should update existing session by id", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([mockSession]), + }); + + const updatedSession: Cookie = { + ...mockSession, + token: "new-token", + changeTs: "1700000001000", + }; + + await updateSessionCookie({ + id: "session-1", + session: updatedSession, + }); + + const setCall = mockCookies.set.mock.calls[0][0]; + const sessions = JSON.parse(setCall.value); + + expect(sessions[0].token).toBe("new-token"); + expect(sessions[0].changeTs).toBe("1700000001000"); + }); + + it("should throw error if session id not found", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([mockSession]), + }); + + await expect( + updateSessionCookie({ + id: "non-existent-id", + session: mockSession, + }), + ).rejects.toThrow("updateSessionCookie: session id not found"); + }); + + it("should cleanup expired sessions when cleanup is true", async () => { + const now = Date.now(); + const expiredSession: Cookie = { + id: "session-expired", + token: "token-expired", + loginName: "expired@example.com", + creationTs: `${now - 10000000}`, + expirationTs: `${now - 1000}`, + changeTs: `${now - 10000000}`, + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([mockSession, expiredSession]), + }); + + vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); + vi.mocked(timestampFromMs).mockImplementation((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any); + + await updateSessionCookie({ + id: "session-1", + session: { ...mockSession, token: "updated" }, + cleanup: true, + }); + + const setCall = mockCookies.set.mock.calls[0][0]; + const sessions = JSON.parse(setCall.value); + + const hasExpired = sessions.some((s: Cookie) => s.id === "session-expired"); + expect(hasExpired).toBe(false); + }); + + it("should respect iFrameEnabled parameter", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([mockSession]), + }); + + await updateSessionCookie({ + id: "session-1", + session: mockSession, + iFrameEnabled: true, + }); + + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + sameSite: "none", + }), + ); + }); + }); + + describe("removeSessionFromCookie", () => { + const session1: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user1@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + const session2: Cookie = { + id: "session-2", + token: "token-2", + loginName: "user2@example.com", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000001000", + }; + + it("should remove session by id", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + await removeSessionFromCookie({ session: session1 }); + + const setCall = mockCookies.set.mock.calls[0][0]; + const sessions = JSON.parse(setCall.value); + + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe("session-2"); + }); + + it("should handle removing non-existent session gracefully", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1]), + }); + + const nonExistentSession: Cookie = { + ...session2, + id: "non-existent", + }; + + await removeSessionFromCookie({ session: nonExistentSession }); + + const setCall = mockCookies.set.mock.calls[0][0]; + const sessions = JSON.parse(setCall.value); + + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe("session-1"); + }); + + it("should cleanup expired sessions when cleanup is true", async () => { + const now = Date.now(); + const expiredSession: Cookie = { + id: "session-expired", + token: "token-expired", + loginName: "expired@example.com", + creationTs: `${now - 10000000}`, + expirationTs: `${now - 1000}`, + changeTs: `${now - 10000000}`, + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2, expiredSession]), + }); + + vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); + vi.mocked(timestampFromMs).mockImplementation((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any); + + await removeSessionFromCookie({ session: session1, cleanup: true }); + + const setCall = mockCookies.set.mock.calls[0][0]; + const sessions = JSON.parse(setCall.value); + + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe("session-2"); + }); + + it("should respect iFrameEnabled parameter", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + await removeSessionFromCookie({ + session: session1, + iFrameEnabled: true, + }); + + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + sameSite: "none", + }), + ); + }); + }); + + describe("getMostRecentSessionCookie", () => { + it("should return session with most recent changeTs", async () => { + const session1: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user1@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + const session2: Cookie = { + id: "session-2", + token: "token-2", + loginName: "user2@example.com", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000005000", // Most recent + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + const result = await getMostRecentSessionCookie(); + + expect(result.id).toBe("session-2"); + }); + + it("should reject when no session cookie exists", async () => { + mockCookies.get.mockReturnValue(undefined); + + await expect(getMostRecentSessionCookie()).rejects.toBe("no session cookie found"); + }); + + it("should handle single session", async () => { + const session: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session]), + }); + + const result = await getMostRecentSessionCookie(); + + expect(result.id).toBe("session-1"); + }); + }); + + describe("getSessionCookieById", () => { + const session1: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user1@example.com", + organization: "org-1", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + const session2: Cookie = { + id: "session-2", + token: "token-2", + loginName: "user2@example.com", + organization: "org-2", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000001000", + }; + + it("should find session by id", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + const result = await getSessionCookieById({ sessionId: "session-1" }); + + expect(result.id).toBe("session-1"); + expect(result.loginName).toBe("user1@example.com"); + }); + + it("should filter by organization when provided", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + const result = await getSessionCookieById({ + sessionId: "session-2", + organization: "org-2", + }); + + expect(result.id).toBe("session-2"); + expect(result.organization).toBe("org-2"); + }); + + it("should reject if session not found", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1]), + }); + + await expect(getSessionCookieById({ sessionId: "non-existent" })).rejects.toBeTypeOf("undefined"); + }); + + it("should reject if organization doesn't match", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1]), + }); + + await expect( + getSessionCookieById({ + sessionId: "session-1", + organization: "wrong-org", + }), + ).rejects.toBeTypeOf("undefined"); + }); + + it("should reject when no session cookie exists", async () => { + mockCookies.get.mockReturnValue(undefined); + + await expect(getSessionCookieById({ sessionId: "session-1" })).rejects.toBeTypeOf("undefined"); + }); + }); + + describe("getSessionCookieByLoginName", () => { + const session1: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user1@example.com", + organization: "org-1", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + const session2: Cookie = { + id: "session-2", + token: "token-2", + loginName: "user2@example.com", + organization: "org-2", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000001000", + }; + + it("should find session by loginName", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + const result = await getSessionCookieByLoginName({ + loginName: "user1@example.com", + }); + + expect(result.id).toBe("session-1"); + expect(result.loginName).toBe("user1@example.com"); + }); + + it("should filter by organization when provided", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + const result = await getSessionCookieByLoginName({ + loginName: "user2@example.com", + organization: "org-2", + }); + + expect(result.id).toBe("session-2"); + expect(result.organization).toBe("org-2"); + }); + + it("should reject if session not found", async () => { + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1]), + }); + + await expect(getSessionCookieByLoginName({ loginName: "nonexistent@example.com" })).rejects.toBe( + "no cookie found with loginName: nonexistent@example.com", + ); + }); + + it("should reject when no session cookie exists", async () => { + mockCookies.get.mockReturnValue(undefined); + + await expect(getSessionCookieByLoginName({ loginName: "user@example.com" })).rejects.toBe("no session cookie found"); + }); + }); + + describe("getAllSessionCookieIds", () => { + it("should return all session IDs", async () => { + const sessions: Cookie[] = [ + { + id: "session-1", + token: "token-1", + loginName: "user1@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }, + { + id: "session-2", + token: "token-2", + loginName: "user2@example.com", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000001000", + }, + ]; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify(sessions), + }); + + const result = await getAllSessionCookieIds(); + + expect(result).toEqual(["session-1", "session-2"]); + }); + + it("should filter expired sessions when cleanup is true", async () => { + const now = Date.now(); + const sessions: Cookie[] = [ + { + id: "session-valid", + token: "token-valid", + loginName: "valid@example.com", + creationTs: `${now}`, + expirationTs: `${now + 10000000}`, + changeTs: `${now}`, + }, + { + id: "session-expired", + token: "token-expired", + loginName: "expired@example.com", + creationTs: `${now - 10000000}`, + expirationTs: `${now - 1000}`, + changeTs: `${now - 10000000}`, + }, + ]; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify(sessions), + }); + + vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); + vi.mocked(timestampFromMs).mockImplementation((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any); + + const result = await getAllSessionCookieIds(true); + + expect(result).toEqual(["session-valid"]); + }); + + it("should return empty array when no sessions exist", async () => { + mockCookies.get.mockReturnValue(undefined); + + const result = await getAllSessionCookieIds(); + + expect(result).toEqual([]); + }); + }); + + describe("getAllSessions", () => { + it("should return all sessions", async () => { + const sessions: Cookie[] = [ + { + id: "session-1", + token: "token-1", + loginName: "user1@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }, + { + id: "session-2", + token: "token-2", + loginName: "user2@example.com", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000001000", + }, + ]; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify(sessions), + }); + + const result = await getAllSessions(); + + expect(result).toEqual(sessions); + }); + + it("should filter expired sessions when cleanup is true", async () => { + const now = Date.now(); + const validSession: Cookie = { + id: "session-valid", + token: "token-valid", + loginName: "valid@example.com", + creationTs: `${now}`, + expirationTs: `${now + 10000000}`, + changeTs: `${now}`, + }; + + const expiredSession: Cookie = { + id: "session-expired", + token: "token-expired", + loginName: "expired@example.com", + creationTs: `${now - 10000000}`, + expirationTs: `${now - 1000}`, + changeTs: `${now - 10000000}`, + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([validSession, expiredSession]), + }); + + vi.mocked(timestampDate).mockImplementation((ts: any) => new Date(Number(ts.seconds) * 1000)); + vi.mocked(timestampFromMs).mockImplementation((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any); + + const result = await getAllSessions(true); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("session-valid"); + }); + + it("should return empty array when no sessions exist", async () => { + mockCookies.get.mockReturnValue(undefined); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const result = await getAllSessions(); + + expect(result).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith("getAllSessions: No session cookie found, returning empty array"); + + consoleSpy.mockRestore(); + }); + }); + + describe("getMostRecentCookieWithLoginname", () => { + it("should return most recent session for loginName", async () => { + const session1: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + const session2: Cookie = { + id: "session-2", + token: "token-2", + loginName: "user@example.com", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000005000", // Most recent + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + const result = await getMostRecentCookieWithLoginname({ + loginName: "user@example.com", + }); + + expect(result.id).toBe("session-2"); + }); + + it("should filter by organization when provided", async () => { + const session1: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user@example.com", + organization: "org-1", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000005000", + }; + + const session2: Cookie = { + id: "session-2", + token: "token-2", + loginName: "user@example.com", + organization: "org-2", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000010000", // More recent but different org + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + const result = await getMostRecentCookieWithLoginname({ + loginName: "user@example.com", + organization: "org-1", + }); + + expect(result.id).toBe("session-1"); + expect(result.organization).toBe("org-1"); + }); + + it("should return most recent session when no loginName provided", async () => { + const session1: Cookie = { + id: "session-1", + token: "token-1", + loginName: "user1@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + const session2: Cookie = { + id: "session-2", + token: "token-2", + loginName: "user2@example.com", + creationTs: "1700000001000", + expirationTs: "1800000001000", + changeTs: "1700000005000", + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session1, session2]), + }); + + const result = await getMostRecentCookieWithLoginname({}); + + expect(result.id).toBe("session-2"); + }); + + it("should reject when no matching session found", async () => { + const session: Cookie = { + id: "session-1", + token: "token-1", + loginName: "other@example.com", + creationTs: "1700000000000", + expirationTs: "1800000000000", + changeTs: "1700000000000", + }; + + mockCookies.get.mockReturnValue({ + value: JSON.stringify([session]), + }); + + await expect(getMostRecentCookieWithLoginname({ loginName: "user@example.com" })).rejects.toBe( + "Could not get the context or retrieve a session", + ); + }); + + it("should reject when no session cookie exists", async () => { + mockCookies.get.mockReturnValue(undefined); + + await expect(getMostRecentCookieWithLoginname({ loginName: "user@example.com" })).rejects.toBe( + "Could not read session cookie", + ); + }); + }); +}); diff --git a/apps/login/src/lib/cookies.ts b/apps/login/src/lib/cookies.ts index 0bf8ac390..1be4c58ee 100644 --- a/apps/login/src/lib/cookies.ts +++ b/apps/login/src/lib/cookies.ts @@ -127,7 +127,7 @@ export async function updateSessionCookie({ return setSessionHttpOnlyCookie(sessions, iFrameEnabled); } } else { - throw "updateSessionCookie: session id now found"; + throw "updateSessionCookie: session id not found"; } } diff --git a/apps/login/src/lib/fingerprint.test.ts b/apps/login/src/lib/fingerprint.test.ts new file mode 100644 index 000000000..ef77c0075 --- /dev/null +++ b/apps/login/src/lib/fingerprint.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { v4 as uuidv4 } from "uuid"; +import { + getFingerprintId, + setFingerprintIdCookie, + getFingerprintIdCookie, + getOrSetFingerprintId, + getUserAgent, +} from "./fingerprint"; + +// Mock dependencies +vi.mock("uuid"); +vi.mock("next/headers", () => ({ + cookies: vi.fn(), + headers: vi.fn(), +})); +vi.mock("next/server", () => ({ + userAgent: vi.fn(), +})); +vi.mock("@zitadel/client", () => ({ + create: vi.fn(), +})); + +import { cookies, headers } from "next/headers"; +import { userAgent } from "next/server"; +import { create } from "@zitadel/client"; + +describe("fingerprint", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getFingerprintId", () => { + it("should generate a UUID v4", async () => { + const mockUuid = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(uuidv4).mockReturnValue(mockUuid as any); + + const result = await getFingerprintId(); + + expect(result).toBe(mockUuid); + expect(uuidv4).toHaveBeenCalledOnce(); + }); + + it("should generate unique IDs on multiple calls", async () => { + const uuid1 = "550e8400-e29b-41d4-a716-446655440000"; + const uuid2 = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + + vi.mocked(uuidv4) + .mockReturnValueOnce(uuid1 as any) + .mockReturnValueOnce(uuid2 as any); + + const result1 = await getFingerprintId(); + const result2 = await getFingerprintId(); + + expect(result1).toBe(uuid1); + expect(result2).toBe(uuid2); + expect(result1).not.toBe(result2); + }); + }); + + describe("setFingerprintIdCookie", () => { + it("should set cookie with correct name and value", async () => { + const mockSet = vi.fn(); + const mockCookies = { set: mockSet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const fingerprintId = "test-fingerprint-id"; + await setFingerprintIdCookie(fingerprintId); + + expect(mockSet).toHaveBeenCalledWith({ + name: "fingerprintId", + value: fingerprintId, + httpOnly: true, + path: "/", + maxAge: 31536000, + }); + }); + + it("should set httpOnly flag to true", async () => { + const mockSet = vi.fn(); + const mockCookies = { set: mockSet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + await setFingerprintIdCookie("test-id"); + + const callArgs = mockSet.mock.calls[0][0]; + expect(callArgs.httpOnly).toBe(true); + }); + + it("should set cookie path to root", async () => { + const mockSet = vi.fn(); + const mockCookies = { set: mockSet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + await setFingerprintIdCookie("test-id"); + + const callArgs = mockSet.mock.calls[0][0]; + expect(callArgs.path).toBe("/"); + }); + + it("should set maxAge to 1 year (31536000 seconds)", async () => { + const mockSet = vi.fn(); + const mockCookies = { set: mockSet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + await setFingerprintIdCookie("test-id"); + + const callArgs = mockSet.mock.calls[0][0]; + expect(callArgs.maxAge).toBe(31536000); + expect(callArgs.maxAge).toBe(365 * 24 * 60 * 60); + }); + + it("should handle special characters in fingerprint ID", async () => { + const mockSet = vi.fn(); + const mockCookies = { set: mockSet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const specialId = "test-id-with-!@#$%^&*()"; + await setFingerprintIdCookie(specialId); + + const callArgs = mockSet.mock.calls[0][0]; + expect(callArgs.value).toBe(specialId); + }); + + it("should handle empty fingerprint ID", async () => { + const mockSet = vi.fn(); + const mockCookies = { set: mockSet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + await setFingerprintIdCookie(""); + + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ + value: "", + }), + ); + }); + + it("should handle very long fingerprint IDs", async () => { + const mockSet = vi.fn(); + const mockCookies = { set: mockSet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const longId = "a".repeat(1000); + await setFingerprintIdCookie(longId); + + const callArgs = mockSet.mock.calls[0][0]; + expect(callArgs.value).toBe(longId); + }); + }); + + describe("getFingerprintIdCookie", () => { + it("should retrieve fingerprint cookie", async () => { + const mockGet = vi.fn().mockReturnValue({ + name: "fingerprintId", + value: "test-fingerprint", + }); + const mockCookies = { get: mockGet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const result = await getFingerprintIdCookie(); + + expect(mockGet).toHaveBeenCalledWith("fingerprintId"); + expect(result).toEqual({ + name: "fingerprintId", + value: "test-fingerprint", + }); + }); + + it("should return undefined if cookie doesn't exist", async () => { + const mockGet = vi.fn().mockReturnValue(undefined); + const mockCookies = { get: mockGet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const result = await getFingerprintIdCookie(); + + expect(result).toBeUndefined(); + }); + + it("should handle null cookie value", async () => { + const mockGet = vi.fn().mockReturnValue(null); + const mockCookies = { get: mockGet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const result = await getFingerprintIdCookie(); + + expect(result).toBeNull(); + }); + }); + + describe("getOrSetFingerprintId", () => { + it("should return existing cookie value if present", async () => { + const existingId = "existing-fingerprint-id"; + const mockGet = vi.fn().mockReturnValue({ + name: "fingerprintId", + value: existingId, + }); + const mockCookies = { get: mockGet, set: vi.fn() }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const result = await getOrSetFingerprintId(); + + expect(result).toBe(existingId); + expect(mockCookies.set).not.toHaveBeenCalled(); + }); + + it("should generate and set new ID if cookie doesn't exist", async () => { + const newUuid = "new-generated-uuid"; + const mockGet = vi.fn().mockReturnValue(undefined); + const mockSet = vi.fn(); + const mockCookies = { get: mockGet, set: mockSet }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + vi.mocked(uuidv4).mockReturnValue(newUuid as any); + + const result = await getOrSetFingerprintId(); + + expect(result).toBe(newUuid); + expect(mockSet).toHaveBeenCalledWith({ + name: "fingerprintId", + value: newUuid, + httpOnly: true, + path: "/", + maxAge: 31536000, + }); + }); + + it("should not regenerate ID on subsequent calls with existing cookie", async () => { + const existingId = "persistent-id"; + const mockGet = vi.fn().mockReturnValue({ + name: "fingerprintId", + value: existingId, + }); + const mockCookies = { get: mockGet, set: vi.fn() }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const result1 = await getOrSetFingerprintId(); + const result2 = await getOrSetFingerprintId(); + + expect(result1).toBe(existingId); + expect(result2).toBe(existingId); + expect(uuidv4).not.toHaveBeenCalled(); + }); + }); + + describe("getUserAgent", () => { + it("should create user agent with all components", async () => { + const mockFingerprintId = "test-fingerprint"; + const mockGet = vi.fn().mockReturnValue({ + name: "fingerprintId", + value: mockFingerprintId, + }); + const mockCookies = { get: mockGet, set: vi.fn() }; + vi.mocked(cookies).mockResolvedValue(mockCookies as any); + + const mockHeaders = new Map([ + ["user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"], + ["x-forwarded-for", "192.168.1.1"], + ]); + vi.mocked(headers).mockResolvedValue({ + get: (key: string) => mockHeaders.get(key) ?? null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: { type: "desktop", vendor: "Dell", model: "XPS" }, + engine: { name: "Blink", version: "120.0" }, + os: { name: "Windows", version: "10" }, + browser: { name: "Chrome", version: "120.0" }, + } as any); + + const mockUserAgent = { + ip: "192.168.1.1", + header: { + "user-agent": { + values: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64)"], + }, + }, + description: "Chrome, 120.0, desktop, Dell, XPS, , Blink, 120.0, , Windows, 10, ", + fingerprintId: mockFingerprintId, + }; + vi.mocked(create).mockReturnValue(mockUserAgent as any); + + const result = await getUserAgent(); + + expect(result).toEqual(mockUserAgent); + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + fingerprintId: mockFingerprintId, + ip: "192.168.1.1", + }), + ); + }); + + it("should use x-forwarded-for header for IP if available", async () => { + const mockGet = vi.fn().mockReturnValue({ + value: "test-id", + }); + vi.mocked(cookies).mockResolvedValue({ get: mockGet, set: vi.fn() } as any); + + const mockHeaders = new Map([ + ["x-forwarded-for", "203.0.113.1"], + ["remoteAddress", "192.168.1.1"], + ]); + vi.mocked(headers).mockResolvedValue({ + get: (key: string) => mockHeaders.get(key) ?? null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: {}, + engine: {}, + os: {}, + browser: {}, + } as any); + + await getUserAgent(); + + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ip: "203.0.113.1", + }), + ); + }); + + it("should fallback to remoteAddress if x-forwarded-for is not available", async () => { + const mockGet = vi.fn().mockReturnValue({ + value: "test-id", + }); + vi.mocked(cookies).mockResolvedValue({ get: mockGet, set: vi.fn() } as any); + + const mockHeaders = new Map([["remoteAddress", "192.168.1.100"]]); + vi.mocked(headers).mockResolvedValue({ + get: (key: string) => mockHeaders.get(key) ?? null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: {}, + engine: {}, + os: {}, + browser: {}, + } as any); + + await getUserAgent(); + + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ip: "192.168.1.100", + }), + ); + }); + + it("should use empty string for IP if no headers available", async () => { + const mockGet = vi.fn().mockReturnValue({ + value: "test-id", + }); + vi.mocked(cookies).mockResolvedValue({ get: mockGet, set: vi.fn() } as any); + + const mockHeaders = new Map(); + vi.mocked(headers).mockResolvedValue({ + get: (key: string) => mockHeaders.get(key) ?? null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: {}, + engine: {}, + os: {}, + browser: {}, + } as any); + + await getUserAgent(); + + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ip: "", + }), + ); + }); + + it("should parse user agent header values", async () => { + const mockGet = vi.fn().mockReturnValue({ + value: "test-id", + }); + vi.mocked(cookies).mockResolvedValue({ get: mockGet, set: vi.fn() } as any); + + const userAgentString = "Mozilla/5.0,AppleWebKit/537.36,Chrome/120.0"; + const mockHeaders = new Map([["user-agent", userAgentString]]); + vi.mocked(headers).mockResolvedValue({ + get: (key: string) => mockHeaders.get(key) ?? null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: {}, + engine: {}, + os: {}, + browser: {}, + } as any); + + await getUserAgent(); + + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + header: { + "user-agent": { + values: ["Mozilla/5.0", "AppleWebKit/537.36", "Chrome/120.0"], + }, + }, + }), + ); + }); + + it("should build description from device components", async () => { + const mockGet = vi.fn().mockReturnValue({ + value: "test-id", + }); + vi.mocked(cookies).mockResolvedValue({ get: mockGet, set: vi.fn() } as any); + + vi.mocked(headers).mockResolvedValue({ + get: () => null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: { type: "mobile", vendor: "Apple", model: "iPhone 15" }, + engine: { name: "WebKit", version: "605.1.15" }, + os: { name: "iOS", version: "17.0" }, + browser: { name: "Safari", version: "17.0" }, + } as any); + + await getUserAgent(); + + const createCall = vi.mocked(create).mock.calls[0]?.[1]; + expect(createCall).toBeDefined(); + expect(createCall?.description).toContain("Safari"); + expect(createCall?.description).toContain("17.0"); + expect(createCall?.description).toContain("mobile"); + expect(createCall?.description).toContain("Apple"); + expect(createCall?.description).toContain("iPhone 15"); + expect(createCall?.description).toContain("WebKit"); + expect(createCall?.description).toContain("iOS"); + }); + + it("should handle missing device components gracefully", async () => { + const mockGet = vi.fn().mockReturnValue({ + value: "test-id", + }); + vi.mocked(cookies).mockResolvedValue({ get: mockGet, set: vi.fn() } as any); + + vi.mocked(headers).mockResolvedValue({ + get: () => null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: {}, + engine: {}, + os: {}, + browser: {}, + } as any); + + await getUserAgent(); + + expect(create).toHaveBeenCalled(); + const createCall = vi.mocked(create).mock.calls[0]?.[1]; + expect(createCall?.description).toBeDefined(); + }); + + it("should handle partial device information", async () => { + const mockGet = vi.fn().mockReturnValue({ + value: "test-id", + }); + vi.mocked(cookies).mockResolvedValue({ get: mockGet, set: vi.fn() } as any); + + vi.mocked(headers).mockResolvedValue({ + get: () => null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: { vendor: "Samsung" }, + engine: { name: "Blink" }, + os: { name: "Android" }, + browser: { name: "Chrome" }, + } as any); + + await getUserAgent(); + + const createCall = vi.mocked(create).mock.calls[0]?.[1]; + expect(createCall).toBeDefined(); + expect(createCall?.description).toContain("Samsung"); + expect(createCall?.description).toContain("Blink"); + expect(createCall?.description).toContain("Android"); + expect(createCall?.description).toContain("Chrome"); + }); + + it("should generate new fingerprint ID if none exists", async () => { + const newUuid = "newly-generated-fingerprint"; + const mockGet = vi.fn().mockReturnValue(undefined); + const mockSet = vi.fn(); + vi.mocked(cookies).mockResolvedValue({ get: mockGet, set: mockSet } as any); + vi.mocked(uuidv4).mockReturnValue(newUuid as any); + + vi.mocked(headers).mockResolvedValue({ + get: () => null, + } as any); + + vi.mocked(userAgent).mockReturnValue({ + device: {}, + engine: {}, + os: {}, + browser: {}, + } as any); + + await getUserAgent(); + + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + fingerprintId: newUuid, + }), + ); + }); + }); +}); diff --git a/apps/login/src/lib/idp.test.ts b/apps/login/src/lib/idp.test.ts new file mode 100644 index 000000000..7f6793558 --- /dev/null +++ b/apps/login/src/lib/idp.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect } from "vitest"; +import { idpTypeToSlug, idpTypeToIdentityProviderType } from "./idp"; +import { IDPType } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; +import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; + +describe("idp type mapping utilities", () => { + describe("idpTypeToSlug", () => { + it("should map GITHUB to 'github'", () => { + const result = idpTypeToSlug(IdentityProviderType.GITHUB); + expect(result).toBe("github"); + }); + + it("should map GITHUB_ES to 'github_es'", () => { + const result = idpTypeToSlug(IdentityProviderType.GITHUB_ES); + expect(result).toBe("github_es"); + }); + + it("should map GITLAB to 'gitlab'", () => { + const result = idpTypeToSlug(IdentityProviderType.GITLAB); + expect(result).toBe("gitlab"); + }); + + it("should map GITLAB_SELF_HOSTED to 'gitlab_es'", () => { + const result = idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED); + expect(result).toBe("gitlab_es"); + }); + + it("should map APPLE to 'apple'", () => { + const result = idpTypeToSlug(IdentityProviderType.APPLE); + expect(result).toBe("apple"); + }); + + it("should map GOOGLE to 'google'", () => { + const result = idpTypeToSlug(IdentityProviderType.GOOGLE); + expect(result).toBe("google"); + }); + + it("should map AZURE_AD to 'azure'", () => { + const result = idpTypeToSlug(IdentityProviderType.AZURE_AD); + expect(result).toBe("azure"); + }); + + it("should map SAML to 'saml'", () => { + const result = idpTypeToSlug(IdentityProviderType.SAML); + expect(result).toBe("saml"); + }); + + it("should map OAUTH to 'oauth'", () => { + const result = idpTypeToSlug(IdentityProviderType.OAUTH); + expect(result).toBe("oauth"); + }); + + it("should map OIDC to 'oidc'", () => { + const result = idpTypeToSlug(IdentityProviderType.OIDC); + expect(result).toBe("oidc"); + }); + + it("should map LDAP to 'ldap'", () => { + const result = idpTypeToSlug(IdentityProviderType.LDAP); + expect(result).toBe("ldap"); + }); + + it("should map JWT to 'jwt'", () => { + const result = idpTypeToSlug(IdentityProviderType.JWT); + expect(result).toBe("jwt"); + }); + + it("should throw error for unknown identity provider type", () => { + // Using a value that doesn't match any known type + const unknownType = 9999 as IdentityProviderType; + + expect(() => idpTypeToSlug(unknownType)).toThrow("Unknown identity provider type"); + }); + + it("should throw error for undefined type", () => { + const undefinedType = undefined as any as IdentityProviderType; + expect(() => idpTypeToSlug(undefinedType)).toThrow(); + }); + + it("should handle all defined IdentityProviderType enum values", () => { + // This test ensures all enum values are handled + const handledTypes = [ + IdentityProviderType.GITHUB, + IdentityProviderType.GITHUB_ES, + IdentityProviderType.GITLAB, + IdentityProviderType.GITLAB_SELF_HOSTED, + IdentityProviderType.APPLE, + IdentityProviderType.GOOGLE, + IdentityProviderType.AZURE_AD, + IdentityProviderType.SAML, + IdentityProviderType.OAUTH, + IdentityProviderType.OIDC, + IdentityProviderType.LDAP, + IdentityProviderType.JWT, + ]; + + handledTypes.forEach((type) => { + expect(() => idpTypeToSlug(type)).not.toThrow(); + }); + }); + + it("should return consistent slugs for the same input", () => { + const type = IdentityProviderType.GITHUB; + const result1 = idpTypeToSlug(type); + const result2 = idpTypeToSlug(type); + + expect(result1).toBe(result2); + }); + + it("should return lowercase slugs", () => { + const types = [ + IdentityProviderType.GITHUB, + IdentityProviderType.GOOGLE, + IdentityProviderType.AZURE_AD, + IdentityProviderType.SAML, + ]; + + types.forEach((type) => { + const slug = idpTypeToSlug(type); + expect(slug).toBe(slug.toLowerCase()); + }); + }); + }); + + describe("idpTypeToIdentityProviderType", () => { + it("should map IDP_TYPE_GITHUB to GITHUB", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_GITHUB); + expect(result).toBe(IdentityProviderType.GITHUB); + }); + + it("should map IDP_TYPE_GITHUB_ES to GITHUB_ES", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_GITHUB_ES); + expect(result).toBe(IdentityProviderType.GITHUB_ES); + }); + + it("should map IDP_TYPE_GITLAB to GITLAB", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_GITLAB); + expect(result).toBe(IdentityProviderType.GITLAB); + }); + + it("should map IDP_TYPE_GITLAB_SELF_HOSTED to GITLAB_SELF_HOSTED", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_GITLAB_SELF_HOSTED); + expect(result).toBe(IdentityProviderType.GITLAB_SELF_HOSTED); + }); + + it("should map IDP_TYPE_APPLE to APPLE", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_APPLE); + expect(result).toBe(IdentityProviderType.APPLE); + }); + + it("should map IDP_TYPE_GOOGLE to GOOGLE", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_GOOGLE); + expect(result).toBe(IdentityProviderType.GOOGLE); + }); + + it("should map IDP_TYPE_AZURE_AD to AZURE_AD", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_AZURE_AD); + expect(result).toBe(IdentityProviderType.AZURE_AD); + }); + + it("should map IDP_TYPE_SAML to SAML", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_SAML); + expect(result).toBe(IdentityProviderType.SAML); + }); + + it("should map IDP_TYPE_OAUTH to OAUTH", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_OAUTH); + expect(result).toBe(IdentityProviderType.OAUTH); + }); + + it("should map IDP_TYPE_OIDC to OIDC", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_OIDC); + expect(result).toBe(IdentityProviderType.OIDC); + }); + + it("should map IDP_TYPE_JWT to JWT", () => { + const result = idpTypeToIdentityProviderType(IDPType.IDP_TYPE_JWT); + expect(result).toBe(IdentityProviderType.JWT); + }); + + it("should throw error for unknown IDP type", () => { + // Using a value that doesn't match any known type + const unknownType = 9999 as IDPType; + + expect(() => idpTypeToIdentityProviderType(unknownType)).toThrow("Unknown identity provider type"); + }); + + it("should throw error for undefined type", () => { + const undefinedType = undefined as any as IDPType; + expect(() => idpTypeToIdentityProviderType(undefinedType)).toThrow(); + }); + + it("should handle all defined IDPType enum values", () => { + const handledTypes = [ + IDPType.IDP_TYPE_GITHUB, + IDPType.IDP_TYPE_GITHUB_ES, + IDPType.IDP_TYPE_GITLAB, + IDPType.IDP_TYPE_GITLAB_SELF_HOSTED, + IDPType.IDP_TYPE_APPLE, + IDPType.IDP_TYPE_GOOGLE, + IDPType.IDP_TYPE_AZURE_AD, + IDPType.IDP_TYPE_SAML, + IDPType.IDP_TYPE_OAUTH, + IDPType.IDP_TYPE_OIDC, + IDPType.IDP_TYPE_JWT, + ]; + + handledTypes.forEach((type) => { + expect(() => idpTypeToIdentityProviderType(type)).not.toThrow(); + }); + }); + + it("should return consistent results for the same input", () => { + const type = IDPType.IDP_TYPE_GITHUB; + const result1 = idpTypeToIdentityProviderType(type); + const result2 = idpTypeToIdentityProviderType(type); + + expect(result1).toBe(result2); + }); + + it("should return valid IdentityProviderType enum values", () => { + const types = [IDPType.IDP_TYPE_GITHUB, IDPType.IDP_TYPE_GOOGLE, IDPType.IDP_TYPE_AZURE_AD]; + + types.forEach((type) => { + const result = idpTypeToIdentityProviderType(type); + expect(Object.values(IdentityProviderType)).toContain(result); + }); + }); + }); + + describe("round-trip conversions", () => { + it("should successfully convert IDPType -> IdentityProviderType -> slug", () => { + const idpType = IDPType.IDP_TYPE_GITHUB; + const identityProviderType = idpTypeToIdentityProviderType(idpType); + const slug = idpTypeToSlug(identityProviderType); + + expect(slug).toBe("github"); + }); + + it("should handle enterprise providers in round-trip", () => { + const idpType = IDPType.IDP_TYPE_AZURE_AD; + const identityProviderType = idpTypeToIdentityProviderType(idpType); + const slug = idpTypeToSlug(identityProviderType); + + expect(slug).toBe("azure"); + }); + + it("should handle self-hosted variants", () => { + const githubES = IDPType.IDP_TYPE_GITHUB_ES; + const gitlabSH = IDPType.IDP_TYPE_GITLAB_SELF_HOSTED; + + const githubESType = idpTypeToIdentityProviderType(githubES); + const gitlabSHType = idpTypeToIdentityProviderType(gitlabSH); + + const githubESSlug = idpTypeToSlug(githubESType); + const gitlabSHSlug = idpTypeToSlug(gitlabSHType); + + expect(githubESSlug).toBe("github_es"); + expect(gitlabSHSlug).toBe("gitlab_es"); + }); + + it("should handle protocol-based providers", () => { + const providers = [ + { idpType: IDPType.IDP_TYPE_OIDC, expectedSlug: "oidc" }, + { idpType: IDPType.IDP_TYPE_OAUTH, expectedSlug: "oauth" }, + { idpType: IDPType.IDP_TYPE_SAML, expectedSlug: "saml" }, + { idpType: IDPType.IDP_TYPE_JWT, expectedSlug: "jwt" }, + ]; + + providers.forEach(({ idpType, expectedSlug }) => { + const identityProviderType = idpTypeToIdentityProviderType(idpType); + const slug = idpTypeToSlug(identityProviderType); + expect(slug).toBe(expectedSlug); + }); + }); + + it("should handle all supported providers in complete round-trip", () => { + const allProviders = [ + { idpType: IDPType.IDP_TYPE_GITHUB, slug: "github" }, + { idpType: IDPType.IDP_TYPE_GITHUB_ES, slug: "github_es" }, + { idpType: IDPType.IDP_TYPE_GITLAB, slug: "gitlab" }, + { idpType: IDPType.IDP_TYPE_GITLAB_SELF_HOSTED, slug: "gitlab_es" }, + { idpType: IDPType.IDP_TYPE_APPLE, slug: "apple" }, + { idpType: IDPType.IDP_TYPE_GOOGLE, slug: "google" }, + { idpType: IDPType.IDP_TYPE_AZURE_AD, slug: "azure" }, + { idpType: IDPType.IDP_TYPE_SAML, slug: "saml" }, + { idpType: IDPType.IDP_TYPE_OAUTH, slug: "oauth" }, + { idpType: IDPType.IDP_TYPE_OIDC, slug: "oidc" }, + { idpType: IDPType.IDP_TYPE_JWT, slug: "jwt" }, + ]; + + allProviders.forEach(({ idpType, slug }) => { + const identityProviderType = idpTypeToIdentityProviderType(idpType); + const actualSlug = idpTypeToSlug(identityProviderType); + expect(actualSlug).toBe(slug); + }); + }); + }); + + describe("slug uniqueness", () => { + it("should generate unique slugs for different providers", () => { + const types = [ + IdentityProviderType.GITHUB, + IdentityProviderType.GITLAB, + IdentityProviderType.GOOGLE, + IdentityProviderType.APPLE, + IdentityProviderType.AZURE_AD, + IdentityProviderType.SAML, + IdentityProviderType.OIDC, + IdentityProviderType.OAUTH, + IdentityProviderType.LDAP, + IdentityProviderType.JWT, + ]; + + const slugs = types.map((type) => idpTypeToSlug(type)); + const uniqueSlugs = new Set(slugs); + + expect(uniqueSlugs.size).toBe(slugs.length); + }); + + it("should use consistent naming convention for enterprise variants", () => { + const githubES = idpTypeToSlug(IdentityProviderType.GITHUB_ES); + const gitlabSH = idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED); + + // Both use _es suffix for enterprise/self-hosted + expect(githubES).toContain("_es"); + expect(gitlabSH).toContain("_es"); + }); + }); + + describe("error handling", () => { + it("should provide clear error messages", () => { + const unknownType = 9999 as IdentityProviderType; + + expect(() => idpTypeToSlug(unknownType)).toThrow(/Unknown identity provider type/); + }); + + it("should handle undefined gracefully with error", () => { + expect(() => idpTypeToSlug(undefined as any)).toThrow(); + expect(() => idpTypeToIdentityProviderType(undefined as any)).toThrow(); + }); + + it("should handle null gracefully with error", () => { + expect(() => idpTypeToSlug(null as any)).toThrow(); + expect(() => idpTypeToIdentityProviderType(null as any)).toThrow(); + }); + }); +}); diff --git a/apps/login/src/lib/oidc.test.ts b/apps/login/src/lib/oidc.test.ts new file mode 100644 index 000000000..c53f20d9d --- /dev/null +++ b/apps/login/src/lib/oidc.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { loginWithOIDCAndSession } from "./oidc"; +import * as sessionModule from "./session"; +import * as zitadelModule from "./zitadel"; +import * as loginnameModule from "./server/loginname"; + +vi.mock("./session"); +vi.mock("./zitadel"); +vi.mock("./server/loginname"); + +describe("loginWithOIDCAndSession", () => { + const mockServiceUrl = "https://zitadel.example.com"; + const mockAuthRequest = "auth-123"; + const mockSessionId = "session-123"; + + let mockSessions: any[]; + let mockCookies: any[]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + + mockSessions = [ + { + id: mockSessionId, + factors: { + user: { + id: "user-123", + loginName: "test@example.com", + organizationId: "org-123", + }, + password: { + verifiedAt: { seconds: BigInt(Math.floor(Date.now() / 1000)) }, + }, + }, + }, + ]; + + mockCookies = [ + { + id: mockSessionId, + token: "token-123", + loginName: "test@example.com", + creationTs: new Date().toISOString(), + expirationTs: new Date(Date.now() + 3600000).toISOString(), + changeTs: new Date().toISOString(), + }, + ]; + }); + + it("should redirect to callback URL when session is valid", async () => { + vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true); + vi.mocked(zitadelModule.createCallback).mockResolvedValue({ + callbackUrl: "https://app.example.com/callback", + } as any); + + const result = await loginWithOIDCAndSession({ + serviceUrl: mockServiceUrl, + authRequest: mockAuthRequest, + sessionId: mockSessionId, + sessions: mockSessions, + sessionCookies: mockCookies, + }); + + expect(result).toEqual({ redirect: "https://app.example.com/callback" }); + }); + + it("should redirect to re-authenticate when session is invalid", async () => { + vi.mocked(sessionModule.isSessionValid).mockResolvedValue(false); + vi.mocked(loginnameModule.sendLoginname).mockResolvedValue({ + redirect: "/password", + }); + + const result = await loginWithOIDCAndSession({ + serviceUrl: mockServiceUrl, + authRequest: mockAuthRequest, + sessionId: mockSessionId, + sessions: mockSessions, + sessionCookies: mockCookies, + }); + + expect(result).toEqual({ redirect: "/password" }); + expect(loginnameModule.sendLoginname).toHaveBeenCalledWith({ + loginName: "test@example.com", + organization: "org-123", + requestId: `oidc_${mockAuthRequest}`, + }); + }); + + it("should return error when session not found", async () => { + const result = await loginWithOIDCAndSession({ + serviceUrl: mockServiceUrl, + authRequest: mockAuthRequest, + sessionId: "nonexistent", + sessions: mockSessions, + sessionCookies: mockCookies, + }); + + expect(result).toEqual({ error: "Session not found or invalid" }); + }); + + it("should return error when cookie not found", async () => { + vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true); + + const result = await loginWithOIDCAndSession({ + serviceUrl: mockServiceUrl, + authRequest: mockAuthRequest, + sessionId: mockSessionId, + sessions: mockSessions, + sessionCookies: [], + }); + + expect(result).toEqual({ error: "Session not found or invalid" }); + }); + + it("should handle error code 9 with default redirect", async () => { + vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true); + vi.mocked(zitadelModule.createCallback).mockRejectedValue({ code: 9 }); + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + defaultRedirectUri: "https://default.example.com", + } as any); + + const result = await loginWithOIDCAndSession({ + serviceUrl: mockServiceUrl, + authRequest: mockAuthRequest, + sessionId: mockSessionId, + sessions: mockSessions, + sessionCookies: mockCookies, + }); + + expect(result).toEqual({ redirect: "https://default.example.com" }); + }); + + it("should redirect to /signedin when error code 9 and no default URI", async () => { + vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true); + vi.mocked(zitadelModule.createCallback).mockRejectedValue({ code: 9 }); + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({} as any); + + const result = await loginWithOIDCAndSession({ + serviceUrl: mockServiceUrl, + authRequest: mockAuthRequest, + sessionId: mockSessionId, + sessions: mockSessions, + sessionCookies: mockCookies, + }); + + expect(result).toHaveProperty("redirect"); + if ("redirect" in result) { + expect(result.redirect).toContain("/signedin"); + expect(result.redirect).toContain("loginName=test%40example.com"); + expect(result.redirect).toContain("organization=org-123"); + } + }); + + it("should return unknown error for non-code-9 errors", async () => { + vi.mocked(sessionModule.isSessionValid).mockResolvedValue(true); + vi.mocked(zitadelModule.createCallback).mockRejectedValue({ + code: 13, + message: "Internal error", + }); + + const result = await loginWithOIDCAndSession({ + serviceUrl: mockServiceUrl, + authRequest: mockAuthRequest, + sessionId: mockSessionId, + sessions: mockSessions, + sessionCookies: mockCookies, + }); + + expect(result).toEqual({ error: "Unknown error occurred" }); + }); +}); diff --git a/apps/login/src/lib/saml.test.ts b/apps/login/src/lib/saml.test.ts new file mode 100644 index 000000000..1e9ab57ef --- /dev/null +++ b/apps/login/src/lib/saml.test.ts @@ -0,0 +1,582 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { getSAMLFormUID, setSAMLFormCookie, getSAMLFormCookie, loginWithSAMLAndSession } from "./saml"; + +// Mock dependencies +vi.mock("uuid"); +vi.mock("next/headers", () => ({ + cookies: vi.fn(), +})); +vi.mock("./session", () => ({ + isSessionValid: vi.fn(), +})); +vi.mock("@/lib/server/loginname", () => ({ + sendLoginname: vi.fn(), +})); +vi.mock("@/lib/zitadel", () => ({ + createResponse: vi.fn(), + getLoginSettings: vi.fn(), +})); +vi.mock("@zitadel/client", () => ({ + create: vi.fn((schema, data) => data), +})); + +import { v4 as uuidv4 } from "uuid"; +import { cookies } from "next/headers"; +import { isSessionValid } from "./session"; +import { sendLoginname } from "@/lib/server/loginname"; +import { createResponse, getLoginSettings } from "@/lib/zitadel"; + +describe("saml", () => { + let mockCookies: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockCookies = { + get: vi.fn(), + set: vi.fn(), + }; + vi.mocked(cookies).mockResolvedValue(mockCookies); + + // Suppress console logs during tests + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getSAMLFormUID", () => { + it("should generate a UUID v4", async () => { + const mockUuid = "saml-uid-123-456"; + vi.mocked(uuidv4).mockReturnValue(mockUuid as any); + + const result = await getSAMLFormUID(); + + expect(result).toBe(mockUuid); + expect(uuidv4).toHaveBeenCalledOnce(); + }); + + it("should generate unique UIDs on multiple calls", async () => { + const uuid1 = "saml-uid-1"; + const uuid2 = "saml-uid-2"; + + vi.mocked(uuidv4) + .mockReturnValueOnce(uuid1 as any) + .mockReturnValueOnce(uuid2 as any); + + const result1 = await getSAMLFormUID(); + const result2 = await getSAMLFormUID(); + + expect(result1).toBe(uuid1); + expect(result2).toBe(uuid2); + expect(result1).not.toBe(result2); + }); + }); + + describe("setSAMLFormCookie", () => { + const mockUid = "test-saml-uid"; + + beforeEach(() => { + vi.mocked(uuidv4).mockReturnValue(mockUid as any); + }); + + it("should set SAML form cookie with correct parameters", async () => { + const samlValue = "SAMLRequest=encodedvalue"; + + const uid = await setSAMLFormCookie(samlValue); + + expect(uid).toBe(mockUid); + expect(mockCookies.set).toHaveBeenCalledWith({ + name: mockUid, + value: samlValue, + httpOnly: true, + secure: expect.any(Boolean), + sameSite: "lax", + path: "/", + maxAge: 5 * 60, + }); + }); + + it("should set httpOnly to true", async () => { + await setSAMLFormCookie("test-value"); + + const callArgs = mockCookies.set.mock.calls[0][0]; + expect(callArgs.httpOnly).toBe(true); + }); + + it("should set sameSite to 'lax'", async () => { + await setSAMLFormCookie("test-value"); + + const callArgs = mockCookies.set.mock.calls[0][0]; + expect(callArgs.sameSite).toBe("lax"); + }); + + it("should set path to root", async () => { + await setSAMLFormCookie("test-value"); + + const callArgs = mockCookies.set.mock.calls[0][0]; + expect(callArgs.path).toBe("/"); + }); + + it("should set maxAge to 5 minutes (300 seconds)", async () => { + await setSAMLFormCookie("test-value"); + + const callArgs = mockCookies.set.mock.calls[0][0]; + expect(callArgs.maxAge).toBe(300); + expect(callArgs.maxAge).toBe(5 * 60); + }); + + it("should warn when value exceeds 4000 characters", async () => { + const largeSamlValue = "x".repeat(5000); + + await setSAMLFormCookie(largeSamlValue); + + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("SAML form cookie value is large")); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("5000 characters")); + }); + + it("should handle empty SAML value", async () => { + const uid = await setSAMLFormCookie(""); + + expect(uid).toBe(mockUid); + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + value: "", + }), + ); + }); + + it("should handle very long SAML values", async () => { + const longValue = "x".repeat(10000); + + const uid = await setSAMLFormCookie(longValue); + + expect(uid).toBe(mockUid); + expect(console.warn).toHaveBeenCalled(); + }); + + it("should throw error when cookie setting fails", async () => { + const error = new Error("Cookie set failed"); + mockCookies.set.mockRejectedValue(error); + + await expect(setSAMLFormCookie("test-value")).rejects.toThrow("Failed to set SAML form cookie"); + }); + + it("should log error with details when cookie setting fails", async () => { + const error = new Error("Storage quota exceeded"); + mockCookies.set.mockRejectedValue(error); + + await expect(setSAMLFormCookie("test-value")).rejects.toThrow(); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to set SAML form cookie"), + expect.objectContaining({ + error, + uid: mockUid, + }), + ); + }); + }); + + describe("getSAMLFormCookie", () => { + it("should retrieve SAML form cookie by UID", async () => { + const uid = "test-saml-uid"; + const cookieValue = "SAMLRequest=encodedvalue"; + + mockCookies.get.mockReturnValue({ + name: uid, + value: cookieValue, + }); + + const result = await getSAMLFormCookie(uid); + + expect(result).toBe(cookieValue); + expect(mockCookies.get).toHaveBeenCalledWith(uid); + }); + + it("should return null if cookie not found", async () => { + mockCookies.get.mockReturnValue(undefined); + + const result = await getSAMLFormCookie("non-existent-uid"); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("SAML form cookie not found")); + }); + + it("should return null if cookie has empty value", async () => { + mockCookies.get.mockReturnValue({ + name: "test-uid", + value: "", + }); + + const result = await getSAMLFormCookie("test-uid"); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("empty value")); + }); + + it("should handle errors gracefully", async () => { + const error = new Error("Cookie read failed"); + mockCookies.get.mockImplementation(() => { + throw error; + }); + + const result = await getSAMLFormCookie("test-uid"); + + expect(result).toBeNull(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Error retrieving SAML form cookie"), error); + }); + + it("should handle null cookie value", async () => { + mockCookies.get.mockReturnValue({ + name: "test-uid", + value: null, + }); + + const result = await getSAMLFormCookie("test-uid"); + + expect(result).toBeNull(); + }); + + it("should retrieve large SAML values", async () => { + const largeValue = "x".repeat(5000); + mockCookies.get.mockReturnValue({ + name: "test-uid", + value: largeValue, + }); + + const result = await getSAMLFormCookie("test-uid"); + + expect(result).toBe(largeValue); + }); + }); + + describe("loginWithSAMLAndSession", () => { + const mockSession: any = { + id: "session-123", + factors: { + user: { + loginName: "user@example.com", + organizationId: "org-123", + }, + }, + }; + + const mockCookie: any = { + id: "session-123", + token: "session-token-123", + loginName: "user@example.com", + }; + + const baseParams = { + serviceUrl: "https://example.com", + samlRequest: "saml-request-id", + sessionId: "session-123", + sessions: [mockSession], + sessionCookies: [mockCookie], + }; + + it("should return redirect URL for valid session", async () => { + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockResolvedValue({ + url: "https://sp.example.com/acs", + } as any); + + const result = await loginWithSAMLAndSession(baseParams); + + expect(result).toEqual({ + redirect: "https://sp.example.com/acs", + }); + }); + + it("should validate session using isSessionValid", async () => { + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockResolvedValue({ + url: "https://sp.example.com/acs", + } as any); + + await loginWithSAMLAndSession(baseParams); + + expect(isSessionValid).toHaveBeenCalledWith({ + serviceUrl: baseParams.serviceUrl, + session: mockSession, + }); + }); + + it("should call createResponse with session info", async () => { + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockResolvedValue({ + url: "https://sp.example.com/acs", + } as any); + + await loginWithSAMLAndSession(baseParams); + + expect(createResponse).toHaveBeenCalledWith({ + serviceUrl: baseParams.serviceUrl, + req: expect.objectContaining({ + samlRequestId: "saml-request-id", + responseKind: { + case: "session", + value: { + sessionId: "session-123", + sessionToken: "session-token-123", + }, + }, + }), + }); + }); + + it("should redirect to re-authentication if session is invalid", async () => { + vi.mocked(isSessionValid).mockResolvedValue(false); + vi.mocked(sendLoginname).mockResolvedValue({ + redirect: "/loginname", + }); + + const result = await loginWithSAMLAndSession(baseParams); + + expect(sendLoginname).toHaveBeenCalledWith({ + loginName: "user@example.com", + organization: "org-123", + requestId: "saml_saml-request-id", + }); + expect(result).toEqual({ + redirect: "/loginname", + }); + }); + + it("should return error if session not found", async () => { + const params = { + ...baseParams, + sessionId: "non-existent-session", + }; + + const result = await loginWithSAMLAndSession(params); + + expect(result).toEqual({ + error: "Session not found or invalid", + }); + }); + + it("should return error if session cookie not found", async () => { + const params = { + ...baseParams, + sessionCookies: [], + }; + + vi.mocked(isSessionValid).mockResolvedValue(true); + + const result = await loginWithSAMLAndSession(params); + + expect(result).toEqual({ + error: "Session not found or invalid", + }); + }); + + it("should return error if createResponse returns no URL", async () => { + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockResolvedValue({} as any); + + const result = await loginWithSAMLAndSession(baseParams); + + expect(result).toEqual({ + error: "An error occurred!", + }); + }); + + it("should handle SAML request already used (error code 9)", async () => { + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockRejectedValue({ code: 9 }); + vi.mocked(getLoginSettings).mockResolvedValue({ + defaultRedirectUri: "https://example.com/default", + } as any); + + const result = await loginWithSAMLAndSession(baseParams); + + expect(result).toEqual({ + redirect: "https://example.com/default", + }); + }); + + it("should fallback to /signedin if no defaultRedirectUri (error code 9)", async () => { + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockRejectedValue({ code: 9 }); + vi.mocked(getLoginSettings).mockResolvedValue({} as any); + + const result = await loginWithSAMLAndSession(baseParams); + + expect(result).toEqual({ + redirect: expect.stringContaining("/signedin"), + }); + }); + + it("should include loginName and organization in /signedin redirect", async () => { + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockRejectedValue({ code: 9 }); + vi.mocked(getLoginSettings).mockResolvedValue({} as any); + + const result = await loginWithSAMLAndSession(baseParams); + + expect(result).toEqual({ + redirect: expect.stringContaining("loginName=user%40example.com"), + }); + expect(result).toEqual({ + redirect: expect.stringContaining("organization=org-123"), + }); + }); + + it("should return error for unknown errors from createResponse", async () => { + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockRejectedValue(new Error("Network error")); + + const result = await loginWithSAMLAndSession(baseParams); + + expect(result).toEqual({ + error: "Unknown error occurred", + }); + }); + + it("should handle session without user factors", async () => { + const sessionWithoutUser = { + id: "session-123", + factors: {}, + } as any; + + const params = { + ...baseParams, + sessions: [sessionWithoutUser], + }; + + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockResolvedValue({ + url: "https://sp.example.com/acs", + } as any); + + const result = await loginWithSAMLAndSession(params); + + expect(result).toEqual({ + redirect: "https://sp.example.com/acs", + }); + }); + + it("should handle multiple sessions with correct selection", async () => { + const otherSession: any = { + id: "session-456", + factors: { + user: { + loginName: "other@example.com", + }, + }, + }; + + const params = { + ...baseParams, + sessions: [otherSession, mockSession], + }; + + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockResolvedValue({ + url: "https://sp.example.com/acs", + } as any); + + await loginWithSAMLAndSession(params); + + expect(isSessionValid).toHaveBeenCalledWith({ + serviceUrl: baseParams.serviceUrl, + session: mockSession, + }); + }); + + it("should match session cookie by ID", async () => { + const otherCookie: any = { + id: "session-456", + token: "other-token", + loginName: "other@example.com", + }; + + const params = { + ...baseParams, + sessionCookies: [otherCookie, mockCookie], + }; + + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockResolvedValue({ + url: "https://sp.example.com/acs", + } as any); + + await loginWithSAMLAndSession(params); + + expect(createResponse).toHaveBeenCalledWith({ + serviceUrl: baseParams.serviceUrl, + req: expect.objectContaining({ + responseKind: { + case: "session", + value: { + sessionId: "session-123", + sessionToken: "session-token-123", + }, + }, + }), + }); + }); + + it("should handle error code 9 without loginName", async () => { + const sessionWithoutLoginName = { + id: "session-123", + factors: { + user: { + organizationId: "org-123", + }, + }, + } as any; + + const params = { + ...baseParams, + sessions: [sessionWithoutLoginName], + }; + + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockRejectedValue({ code: 9 }); + vi.mocked(getLoginSettings).mockResolvedValue({} as any); + + const result = await loginWithSAMLAndSession(params); + + expect(result).toEqual({ + redirect: expect.stringContaining("/signedin"), + }); + expect(result).toEqual({ + redirect: expect.not.stringContaining("loginName="), + }); + }); + + it("should handle error code 9 without organization", async () => { + const sessionWithoutOrg = { + id: "session-123", + factors: { + user: { + loginName: "user@example.com", + }, + }, + } as any; + + const params = { + ...baseParams, + sessions: [sessionWithoutOrg], + }; + + vi.mocked(isSessionValid).mockResolvedValue(true); + vi.mocked(createResponse).mockRejectedValue({ code: 9 }); + vi.mocked(getLoginSettings).mockResolvedValue({} as any); + + const result = await loginWithSAMLAndSession(params); + + expect(result).toEqual({ + redirect: expect.stringContaining("/signedin"), + }); + expect(result).toEqual({ + redirect: expect.not.stringContaining("organization="), + }); + }); + }); +}); diff --git a/apps/login/src/lib/session.test.ts b/apps/login/src/lib/session.test.ts index 0ff1451c3..a9d75ee08 100644 --- a/apps/login/src/lib/session.test.ts +++ b/apps/login/src/lib/session.test.ts @@ -20,24 +20,20 @@ import { isSessionValid } from "./session"; import * as verifyHelperModule from "./verify-helper"; import * as zitadelModule from "./zitadel"; -// Mock the zitadel client timestampDate function vi.mock("@zitadel/client", () => ({ timestampDate: vi.fn(), })); -// Mock the zitadel module vi.mock("./zitadel", () => ({ listAuthenticationMethodTypes: vi.fn(), getLoginSettings: vi.fn(), getUserByID: vi.fn(), })); -// Mock the verify-helper module vi.mock("./verify-helper", () => ({ shouldEnforceMFA: vi.fn(), })); -// Mock environment variables const originalEnv = process.env; describe("isSessionValid", () => { @@ -51,10 +47,16 @@ describe("isSessionValid", () => { // @ts-ignore - delete is OK for test environment variables delete process.env.EMAIL_VERIFICATION; - // Setup timestampDate mock to return valid dates + // Setup timestampDate mock to match actual behavior: + // - Returns Invalid Date for malformed timestamps (empty object, invalid seconds) + // - Throws for null/undefined + // - Returns valid Date for proper timestamps vi.mocked(timestampDate).mockImplementation((timestamp: any) => { - if (!timestamp || !timestamp.seconds) { - return new Date(); // Return current date for invalid timestamps + if (timestamp === null || timestamp === undefined) { + throw new TypeError(`Cannot read properties of ${timestamp} (reading 'seconds')`); + } + if (!timestamp.seconds || typeof timestamp.seconds !== "bigint") { + return new Date("invalid"); // Returns Invalid Date for malformed timestamps } return new Date(Number(timestamp.seconds) * 1000); }); @@ -1148,5 +1150,316 @@ describe("isSessionValid", () => { expect(result).toBe(true); }); + + test("should handle session with null expirationDate", async () => { + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + expirationDate: null, + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [], + } as any); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(true); + }); + + test("should handle session expiring exactly now", async () => { + const verifiedTimestamp = createMockTimestamp(); + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Create timestamp for current moment (0ms offset) + const nowTimestamp = createMockTimestamp(0); + + const session = createMockSession({ + expirationDate: nowTimestamp, + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [], + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + // Session expiring exactly now should be considered expired + expect(result).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith("Session is expired", expect.any(String)); + consoleSpy.mockRestore(); + }); + + test("should handle API errors gracefully when fetching login settings", async () => { + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [], + } as any); + + // Simulate API error + vi.mocked(zitadelModule.getLoginSettings).mockRejectedValue(new Error("API connection failed")); + + // Should throw or handle error appropriately + await expect(isSessionValid({ serviceUrl: mockServiceUrl, session })).rejects.toThrow(); + }); + + test("should handle API errors gracefully when listing auth method types", async () => { + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + }, + }); + + // Simulate API error + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockRejectedValue(new Error("API connection failed")); + + // Should throw or handle error appropriately + await expect(isSessionValid({ serviceUrl: mockServiceUrl, session })).rejects.toThrow(); + }); + + test("should handle malformed timestamp in session", async () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + expirationDate: { seconds: "invalid" } as any, + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [], + } as any); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + // Malformed timestamps are handled gracefully, returning false for invalid sessions + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + expect(result).toBe(false); + consoleSpy.mockRestore(); + }); + + test("should handle session with all MFA factors verified", async () => { + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + totp: { + verifiedAt: verifiedTimestamp, + }, + otpEmail: { + verifiedAt: verifiedTimestamp, + }, + otpSms: { + verifiedAt: verifiedTimestamp, + }, + webAuthN: { + verifiedAt: verifiedTimestamp, + }, + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [ + AuthenticationMethodType.TOTP, + AuthenticationMethodType.OTP_EMAIL, + AuthenticationMethodType.OTP_SMS, + AuthenticationMethodType.U2F, + ], + } as any); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: true, + forceMfaLocalOnly: false, + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(true); + }); + + test("should handle session with very old verifiedAt timestamps", async () => { + // Timestamp from 10 years ago + const oldTimestamp = { + seconds: BigInt(Math.floor((Date.now() - 10 * 365 * 24 * 60 * 60 * 1000) / 1000)), + }; + + const futureExpiration = createMockTimestamp(); + + const session = createMockSession({ + expirationDate: futureExpiration, + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: oldTimestamp, + }, + password: { + verifiedAt: oldTimestamp, + }, + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [], + } as any); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + // Old verification timestamps are still valid as long as session hasn't expired + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(true); + }); + + test("should handle session with future verifiedAt timestamps", async () => { + // Timestamp 1 hour in the future + const futureTimestamp = createMockTimestamp(3600000); + + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: futureTimestamp, + }, + password: { + verifiedAt: futureTimestamp, + }, + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [], + } as any); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + // Future timestamps should still be considered valid + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(true); + }); + + test("should handle concurrent calls to isSessionValid", async () => { + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [], + } as any); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + // Make multiple concurrent calls + const promises = Array(5) + .fill(null) + .map(() => isSessionValid({ serviceUrl: mockServiceUrl, session })); + + const results = await Promise.all(promises); + + // All should return same result + expect(results.every((r) => r === true)).toBe(true); + }); }); }); diff --git a/apps/login/src/lib/verify-helper.test.ts b/apps/login/src/lib/verify-helper.test.ts index 8094b6570..6bb21dcdb 100644 --- a/apps/login/src/lib/verify-helper.test.ts +++ b/apps/login/src/lib/verify-helper.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { shouldEnforceMFA } from "./verify-helper"; +import { cookies } from "next/headers"; +import { getFingerprintIdCookie } from "./fingerprint"; +import crypto from "crypto"; // Mock function to create timestamps - following the same pattern as session.test.ts function createMockTimestamp(offsetMs = 3600000): any { @@ -320,3 +323,546 @@ describe("shouldEnforceMFA", () => { }); }); }); + +import { + checkPasswordChangeRequired, + checkEmailVerified, + checkEmailVerification, + checkMFAFactors, + checkUserVerification, +} from "./verify-helper"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; + +// Mock dependencies +vi.mock("@zitadel/client", () => ({ + timestampDate: vi.fn((ts: any) => new Date(Number(ts.seconds) * 1000)), + timestampFromMs: vi.fn((ms: number) => ({ seconds: BigInt(Math.floor(ms / 1000)) }) as any), +})); + +vi.mock("next/headers", () => ({ + cookies: vi.fn(), +})); + +vi.mock("./fingerprint", () => ({ + getFingerprintIdCookie: vi.fn(), +})); + +vi.mock("./zitadel", () => ({ + getUserByID: vi.fn(), +})); + +vi.mock("crypto", () => ({ + default: { + createHash: vi.fn(() => ({ + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => "mockedhash123"), + })), + }, +})); + +describe("checkPasswordChangeRequired", () => { + const mockSession: any = { + id: "session-123", + factors: { + user: { + id: "user-123", + loginName: "user@example.com", + organizationId: "org-123", + }, + }, + }; + + const createTimestamp = (daysAgo: number): any => { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + return { + seconds: BigInt(Math.floor(date.getTime() / 1000)), + nanos: 0, + }; + }; + + it("should redirect if password change is required on user", () => { + const humanUser: any = { + passwordChangeRequired: true, + passwordChanged: createTimestamp(0), + }; + + const result = checkPasswordChangeRequired(undefined, mockSession, humanUser); + + expect(result).toEqual({ + redirect: expect.stringContaining("/password/change"), + }); + }); + + it("should redirect if password is expired based on maxAgeDays", () => { + const expirySettings: any = { + maxAgeDays: BigInt(30), + }; + + const humanUser: any = { + passwordChangeRequired: false, + passwordChanged: createTimestamp(35), // 35 days ago + }; + + const result = checkPasswordChangeRequired(expirySettings, mockSession, humanUser); + + expect(result).toEqual({ + redirect: expect.stringContaining("/password/change"), + }); + }); + + it("should not redirect if password is not expired", () => { + const expirySettings: any = { + maxAgeDays: BigInt(30), + }; + + const humanUser: any = { + passwordChangeRequired: false, + passwordChanged: createTimestamp(20), // 20 days ago + }; + + const result = checkPasswordChangeRequired(expirySettings, mockSession, humanUser); + + expect(result).toBeUndefined(); + }); + + it("should include organization in redirect params", () => { + const humanUser: any = { + passwordChangeRequired: true, + }; + + const result = checkPasswordChangeRequired(undefined, mockSession, humanUser, "custom-org"); + + expect(result?.redirect).toContain("organization="); + }); + + it("should include requestId in redirect params", () => { + const humanUser: any = { + passwordChangeRequired: true, + }; + + const result = checkPasswordChangeRequired(undefined, mockSession, humanUser, undefined, "request-123"); + + expect(result?.redirect).toContain("requestId=request-123"); + }); + + it("should handle no expiry settings", () => { + const humanUser: any = { + passwordChangeRequired: false, + passwordChanged: createTimestamp(100), + }; + + const result = checkPasswordChangeRequired(undefined, mockSession, humanUser); + + expect(result).toBeUndefined(); + }); + + it("should handle no password changed timestamp", () => { + const expirySettings: any = { + maxAgeDays: BigInt(30), + }; + + const humanUser: any = { + passwordChangeRequired: false, + }; + + const result = checkPasswordChangeRequired(expirySettings, mockSession, humanUser); + + expect(result).toBeUndefined(); + }); +}); + +describe("checkEmailVerified", () => { + const mockSession: any = { + factors: { + user: { + id: "user-123", + loginName: "user@example.com", + organizationId: "org-123", + }, + }, + }; + + it("should redirect if email is not verified", () => { + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerified(mockSession, humanUser); + + expect(result).toEqual({ + redirect: expect.stringContaining("/verify"), + }); + }); + + it("should not redirect if email is verified", () => { + const humanUser: any = { + email: { + isVerified: true, + }, + }; + + const result = checkEmailVerified(mockSession, humanUser); + + expect(result).toBeUndefined(); + }); + + it("should include userId in verify redirect", () => { + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerified(mockSession, humanUser); + + expect(result?.redirect).toContain("userId=user-123"); + }); + + it("should include send=true parameter", () => { + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerified(mockSession, humanUser); + + expect(result?.redirect).toContain("send=true"); + }); + + it("should include organization in redirect", () => { + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerified(mockSession, humanUser, "custom-org"); + + expect(result?.redirect).toContain("organization=custom-org"); + }); + + it("should include requestId in redirect", () => { + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerified(mockSession, humanUser, undefined, "request-123"); + + expect(result?.redirect).toContain("requestId=request-123"); + }); + + it("should handle no email on user", () => { + const humanUser: any = {}; + + const result = checkEmailVerified(mockSession, humanUser); + + expect(result).toEqual({ + redirect: expect.stringContaining("/verify"), + }); + }); +}); + +describe("checkEmailVerification", () => { + const mockSession: any = { + factors: { + user: { + loginName: "user@example.com", + organizationId: "org-123", + }, + }, + }; + + const originalEnv = process.env.EMAIL_VERIFICATION; + + afterEach(() => { + process.env.EMAIL_VERIFICATION = originalEnv; + }); + + it("should redirect if email not verified and EMAIL_VERIFICATION is true", () => { + process.env.EMAIL_VERIFICATION = "true"; + + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerification(mockSession, humanUser); + + expect(result).toEqual({ + redirect: expect.stringContaining("/verify"), + }); + }); + + it("should not redirect if EMAIL_VERIFICATION is not true", () => { + process.env.EMAIL_VERIFICATION = "false"; + + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerification(mockSession, humanUser); + + expect(result).toBeUndefined(); + }); + + it("should not redirect if email is verified", () => { + process.env.EMAIL_VERIFICATION = "true"; + + const humanUser: any = { + email: { + isVerified: true, + }, + }; + + const result = checkEmailVerification(mockSession, humanUser); + + expect(result).toBeUndefined(); + }); + + it("should include send=true parameter", () => { + process.env.EMAIL_VERIFICATION = "true"; + + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerification(mockSession, humanUser); + + expect(result?.redirect).toContain("send=true"); + }); + + it("should include organization in redirect", () => { + process.env.EMAIL_VERIFICATION = "true"; + + const humanUser: any = { + email: { + isVerified: false, + }, + }; + + const result = checkEmailVerification(mockSession, humanUser, "custom-org"); + + expect(result?.redirect).toContain("organization=custom-org"); + }); +}); + +describe("checkUserVerification", () => { + let mockCookies: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockCookies = { + get: vi.fn(), + }; + vi.mocked(cookies).mockResolvedValue(mockCookies); + }); + + it("should return true if verification hash matches", async () => { + vi.mocked(getFingerprintIdCookie).mockResolvedValue({ + name: "fingerprintId", + value: "fingerprint-123", + } as any); + + mockCookies.get.mockReturnValue({ + value: "mockedhash123", + }); + + const result = await checkUserVerification("user-123"); + + expect(result).toBe(true); + }); + + it("should return false if fingerprint cookie not found", async () => { + vi.mocked(getFingerprintIdCookie).mockResolvedValue(undefined); + + const result = await checkUserVerification("user-123"); + + expect(result).toBe(false); + }); + + it("should return false if fingerprint cookie has no value", async () => { + vi.mocked(getFingerprintIdCookie).mockResolvedValue({ + name: "fingerprintId", + value: "", + } as any); + + const result = await checkUserVerification("user-123"); + + expect(result).toBe(false); + }); + + it("should return false if verification cookie not found", async () => { + vi.mocked(getFingerprintIdCookie).mockResolvedValue({ + name: "fingerprintId", + value: "fingerprint-123", + } as any); + + mockCookies.get.mockReturnValue(undefined); + + const result = await checkUserVerification("user-123"); + + expect(result).toBe(false); + }); + + it("should return false if verification hash does not match", async () => { + vi.mocked(getFingerprintIdCookie).mockResolvedValue({ + name: "fingerprintId", + value: "fingerprint-123", + } as any); + + mockCookies.get.mockReturnValue({ + value: "wronghash", + }); + + const result = await checkUserVerification("user-123"); + + expect(result).toBe(false); + }); + + it("should create hash from userId and fingerprint", async () => { + vi.mocked(getFingerprintIdCookie).mockResolvedValue({ + name: "fingerprintId", + value: "fingerprint-456", + } as any); + + mockCookies.get.mockReturnValue({ + value: "mockedhash123", + }); + + await checkUserVerification("user-456"); + + expect(crypto.createHash).toHaveBeenCalledWith("sha256"); + }); +}); + +describe("checkMFAFactors", () => { + const mockSession: any = { + id: "session-123", + factors: { + user: { + id: "user-123", + loginName: "user@example.com", + organizationId: "org-123", + }, + }, + }; + + const mockLoginSettings: any = { + forceMfa: false, + forceMfaLocalOnly: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + it("should not require MFA if authenticated with passkey", async () => { + const sessionWithPasskey = { + ...mockSession, + factors: { + ...mockSession.factors, + webAuthN: { + verifiedAt: createMockTimestamp(), + userVerified: true, + }, + }, + }; + + const result = await checkMFAFactors("https://example.com", sessionWithPasskey, mockLoginSettings, []); + + expect(result).toBeUndefined(); + }); + + it("should redirect to TOTP if only TOTP is available", async () => { + const authMethods = [AuthenticationMethodType.TOTP]; + + const result = await checkMFAFactors("https://example.com", mockSession, mockLoginSettings, authMethods); + + expect(result).toEqual({ + redirect: expect.stringContaining("/otp/time-based"), + }); + }); + + it("should redirect to SMS OTP if only OTP_SMS is available", async () => { + const authMethods = [AuthenticationMethodType.OTP_SMS]; + + const result = await checkMFAFactors("https://example.com", mockSession, mockLoginSettings, authMethods); + + expect(result).toEqual({ + redirect: expect.stringContaining("/otp/sms"), + }); + }); + + it("should redirect to Email OTP if only OTP_EMAIL is available", async () => { + const authMethods = [AuthenticationMethodType.OTP_EMAIL]; + + const result = await checkMFAFactors("https://example.com", mockSession, mockLoginSettings, authMethods); + + expect(result).toEqual({ + redirect: expect.stringContaining("/otp/email"), + }); + }); + + it("should redirect to U2F if only U2F is available", async () => { + const authMethods = [AuthenticationMethodType.U2F]; + + const result = await checkMFAFactors("https://example.com", mockSession, mockLoginSettings, authMethods); + + expect(result).toEqual({ + redirect: expect.stringContaining("/u2f"), + }); + }); + + it("should redirect to MFA selection page if multiple factors available", async () => { + const authMethods = [AuthenticationMethodType.TOTP, AuthenticationMethodType.OTP_SMS]; + + const result = await checkMFAFactors("https://example.com", mockSession, mockLoginSettings, authMethods); + + expect(result).toEqual({ + redirect: expect.stringContaining("/mfa?"), + }); + }); + + it("should include organization in redirect params", async () => { + const authMethods = [AuthenticationMethodType.TOTP]; + + const result = await checkMFAFactors("https://example.com", mockSession, mockLoginSettings, authMethods, "custom-org"); + + expect(result?.redirect).toContain("organization=custom-org"); + }); + + it("should include requestId in redirect params", async () => { + const authMethods = [AuthenticationMethodType.TOTP]; + + const result = await checkMFAFactors( + "https://example.com", + mockSession, + mockLoginSettings, + authMethods, + undefined, + "request-123", + ); + + expect(result?.redirect).toContain("requestId=request-123"); + }); + + it("should ignore non-MFA authentication methods", async () => { + const authMethods = [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.PASSKEY]; + + const result = await checkMFAFactors("https://example.com", mockSession, mockLoginSettings, authMethods); + + expect(result).toBeUndefined(); + }); +});