mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
chore(login): add unit tests for core business logic (#11083)
# Which Problems Are Solved - Adds basic unit tests for core business logic - Adds playwright tests for core flows # How the Problems Are Solved - Extends unit tests for session - Adds unit tests for other core business logic (oidc, idp, auth etc) # Additional Changes - N/A --------- Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
co-authored by
Max Peintner
parent
207095f7c1
commit
86cce629c7
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: `${now - 10000000}`,
|
||||
expirationTs: `${now - 1000}`, // Expired
|
||||
changeTs: `${now - 10000000}`,
|
||||
};
|
||||
|
||||
const validSession: Cookie = {
|
||||
id: "session-valid",
|
||||
token: "token-valid",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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<T>: 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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
};
|
||||
|
||||
const session2: Cookie = {
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
};
|
||||
|
||||
const session2: Cookie = {
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
organization: "org-1",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
};
|
||||
|
||||
const session2: Cookie = {
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
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("[email protected]");
|
||||
});
|
||||
|
||||
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: "[email protected]",
|
||||
organization: "org-1",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
};
|
||||
|
||||
const session2: Cookie = {
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("session-1");
|
||||
expect(result.loginName).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("should filter by organization when provided", async () => {
|
||||
mockCookies.get.mockReturnValue({
|
||||
value: JSON.stringify([session1, session2]),
|
||||
});
|
||||
|
||||
const result = await getSessionCookieByLoginName({
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]" })).rejects.toBe(
|
||||
"no cookie found with loginName: [email protected]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject when no session cookie exists", async () => {
|
||||
mockCookies.get.mockReturnValue(undefined);
|
||||
|
||||
await expect(getSessionCookieByLoginName({ loginName: "[email protected]" })).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: "[email protected]",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
},
|
||||
{
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: `${now}`,
|
||||
expirationTs: `${now + 10000000}`,
|
||||
changeTs: `${now}`,
|
||||
},
|
||||
{
|
||||
id: "session-expired",
|
||||
token: "token-expired",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
},
|
||||
{
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: `${now}`,
|
||||
expirationTs: `${now + 10000000}`,
|
||||
changeTs: `${now}`,
|
||||
};
|
||||
|
||||
const expiredSession: Cookie = {
|
||||
id: "session-expired",
|
||||
token: "token-expired",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
};
|
||||
|
||||
const session2: Cookie = {
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
creationTs: "1700000001000",
|
||||
expirationTs: "1800000001000",
|
||||
changeTs: "1700000005000", // Most recent
|
||||
};
|
||||
|
||||
mockCookies.get.mockReturnValue({
|
||||
value: JSON.stringify([session1, session2]),
|
||||
});
|
||||
|
||||
const result = await getMostRecentCookieWithLoginname({
|
||||
loginName: "[email protected]",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("session-2");
|
||||
});
|
||||
|
||||
it("should filter by organization when provided", async () => {
|
||||
const session1: Cookie = {
|
||||
id: "session-1",
|
||||
token: "token-1",
|
||||
loginName: "[email protected]",
|
||||
organization: "org-1",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000005000",
|
||||
};
|
||||
|
||||
const session2: Cookie = {
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
};
|
||||
|
||||
const session2: Cookie = {
|
||||
id: "session-2",
|
||||
token: "token-2",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
creationTs: "1700000000000",
|
||||
expirationTs: "1800000000000",
|
||||
changeTs: "1700000000000",
|
||||
};
|
||||
|
||||
mockCookies.get.mockReturnValue({
|
||||
value: JSON.stringify([session]),
|
||||
});
|
||||
|
||||
await expect(getMostRecentCookieWithLoginname({ loginName: "[email protected]" })).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: "[email protected]" })).rejects.toBe(
|
||||
"Could not read session cookie",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -127,7 +127,7 @@ export async function updateSessionCookie<T>({
|
||||
return setSessionHttpOnlyCookie(sessions, iFrameEnabled);
|
||||
}
|
||||
} else {
|
||||
throw "updateSessionCookie<T>: session id now found";
|
||||
throw "updateSessionCookie<T>: session id not found";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: "[email protected]",
|
||||
organizationId: "org-123",
|
||||
},
|
||||
password: {
|
||||
verifiedAt: { seconds: BigInt(Math.floor(Date.now() / 1000)) },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockCookies = [
|
||||
{
|
||||
id: mockSessionId,
|
||||
token: "token-123",
|
||||
loginName: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -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: "[email protected]",
|
||||
organizationId: "org-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockCookie: any = {
|
||||
id: "session-123",
|
||||
token: "session-token-123",
|
||||
loginName: "[email protected]",
|
||||
};
|
||||
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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: "[email protected]",
|
||||
};
|
||||
|
||||
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: "[email protected]",
|
||||
},
|
||||
},
|
||||
} 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="),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user