import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { detectColorScheme, parseRkeyFromUri, FALLBACK_THEME, resolveTheme, } from "../theme-resolution.js"; import { logger } from "../logger.js"; vi.mock("../logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, })); describe("detectColorScheme", () => { it("returns 'light' by default when no cookie or hint", () => { expect(detectColorScheme(undefined, undefined)).toBe("light"); }); it("reads atbb-color-scheme=dark from cookie", () => { expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark"); }); it("reads atbb-color-scheme=light from cookie", () => { expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light"); }); it("prefers cookie over client hint", () => { expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light"); }); it("falls back to client hint when no cookie", () => { expect(detectColorScheme(undefined, "dark")).toBe("dark"); }); it("ignores unrecognized hint values and returns 'light'", () => { expect(detectColorScheme(undefined, "no-preference")).toBe("light"); }); it("does not match x-atbb-color-scheme=dark as a cookie prefix", () => { // Before the regex fix, 'x-atbb-color-scheme=dark' would have matched. // The (?:^|;\s*) anchor ensures only cookie-boundary matches are accepted. expect(detectColorScheme("x-atbb-color-scheme=dark", undefined)).toBe("light"); }); }); describe("parseRkeyFromUri", () => { it("extracts rkey from valid AT URI", () => { expect( parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc") ).toBe("3lblthemeabc"); }); it("returns null for URI with no rkey segment", () => { expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull(); }); it("returns null for malformed URI", () => { expect(parseRkeyFromUri("not-a-uri")).toBeNull(); }); it("returns null for empty string", () => { expect(parseRkeyFromUri("")).toBeNull(); }); }); describe("FALLBACK_THEME", () => { it("uses neobrutal-light tokens", () => { expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8"); expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00"); }); it("has light colorScheme", () => { expect(FALLBACK_THEME.colorScheme).toBe("light"); }); it("includes Google Fonts URL for Space Grotesk", () => { expect(FALLBACK_THEME.fontUrls).toEqual( expect.arrayContaining([expect.stringContaining("Space+Grotesk")]) ); }); it("has null cssOverrides", () => { expect(FALLBACK_THEME.cssOverrides).toBeNull(); }); }); describe("resolveTheme", () => { const mockFetch = vi.fn(); const mockLogger = vi.mocked(logger); const APPVIEW = "http://localhost:3001"; beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockLogger.warn.mockClear(); mockLogger.error.mockClear(); }); afterEach(() => { mockFetch.mockReset(); vi.unstubAllGlobals(); }); function policyResponse(overrides: object = {}) { return { ok: true, json: () => Promise.resolve({ defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", allowUserChoice: true, availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, ], ...overrides, }), }; } function themeResponse(colorScheme: "light" | "dark", cid: string) { return { ok: true, json: () => Promise.resolve({ cid, tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, cssOverrides: null, fontUrls: null, colorScheme, }), }; } it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(result.colorScheme).toBe("light"); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("non-ok status"), expect.objectContaining({ operation: "resolveTheme", status: 404 }) ); }); it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(result.colorScheme).toBe("dark"); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("non-ok status"), expect.any(Object) ); }); it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => { mockFetch.mockResolvedValueOnce(policyResponse({ defaultLightThemeUri: null })); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); }); it("returns FALLBACK_THEME when defaultLightThemeUri is malformed (parseRkeyFromUri returns null)", async () => { mockFetch.mockResolvedValueOnce( policyResponse({ defaultLightThemeUri: "malformed-uri" }) ); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); // Only one fetch should happen (policy only — no theme fetch) expect(mockFetch).toHaveBeenCalledTimes(1); }); it("returns FALLBACK_THEME when theme fetch fails (non-ok)", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce({ ok: false, status: 404 }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("non-ok status"), expect.objectContaining({ operation: "resolveTheme", status: 404 }) ); }); it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "WRONG_CID")); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("CID mismatch"), expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" }) ); }); it("resolves the light theme on happy path (no cookie)", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens["color-bg"]).toBe("#fff"); expect(result.colorScheme).toBe("light"); expect(result.cssOverrides).toBeNull(); expect(result.fontUrls).toBeNull(); }); it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("dark", "bafydark")); const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); expect(result.tokens["color-bg"]).toBe("#111"); expect(result.colorScheme).toBe("dark"); expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark")); }); it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce(themeResponse("dark", "bafydark")); const result = await resolveTheme(APPVIEW, undefined, "dark"); expect(result.colorScheme).toBe("dark"); }); it("returns FALLBACK_THEME and logs error on network exception", async () => { mockFetch.mockRejectedValueOnce(new Error("fetch failed")); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("Theme policy fetch failed"), expect.objectContaining({ operation: "resolveTheme" }) ); }); it("re-throws programming errors (TypeError) rather than swallowing them", async () => { // A TypeError from a bug in the code should propagate, not be silently logged. // This TypeError comes from the fetch() mock itself (not from .json()), so it // is caught by the policy-fetch try block and re-thrown as a programming error. mockFetch.mockImplementationOnce(() => { throw new TypeError("Cannot read properties of null"); }); await expect(resolveTheme(APPVIEW, undefined, undefined)).rejects.toThrow(TypeError); }); it("passes cssOverrides and fontUrls through from theme response", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: ".btn { font-weight: 700; }", fontUrls: ["https://fonts.example.com/font.css"], colorScheme: "light", }), }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.cssOverrides).toBe(".btn { font-weight: 700; }"); expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]); }); it("returns FALLBACK_THEME when policy response contains invalid JSON", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("invalid JSON"), expect.objectContaining({ operation: "resolveTheme" }) ); }); it("returns FALLBACK_THEME when theme response contains invalid JSON", async () => { mockFetch .mockResolvedValueOnce(policyResponse()) .mockResolvedValueOnce({ ok: true, json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), }); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("invalid JSON"), expect.objectContaining({ operation: "resolveTheme" }) ); }); it("logs warning when theme URI is not in availableThemes (CID check bypassed)", async () => { mockFetch .mockResolvedValueOnce(policyResponse({ availableThemes: [] })) .mockResolvedValueOnce(themeResponse("light", "bafylight")); await resolveTheme(APPVIEW, undefined, undefined); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("not in availableThemes"), expect.objectContaining({ operation: "resolveTheme", themeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", }) ); }); it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => { // parseRkeyFromUri("at://did/col/../../secret") splits on "/" and returns parts[4] = ".." // ".." fails /^[a-z0-9-]+$/i, so we return FALLBACK_THEME without a theme fetch mockFetch.mockResolvedValueOnce( policyResponse({ defaultLightThemeUri: "at://did/col/../../secret", }) ); const result = await resolveTheme(APPVIEW, undefined, undefined); expect(result.tokens).toEqual(FALLBACK_THEME.tokens); // Only the policy fetch should have been made (no theme fetch) expect(mockFetch).toHaveBeenCalledTimes(1); }); it("resolves theme from live ref (no CID in policy) without logging CID mismatch", async () => { // Live refs have no CID — canonical atbb.space presets ship this way. // The CID integrity check must be skipped when expectedCid is null. mockFetch .mockResolvedValueOnce( policyResponse({ availableThemes: [ { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, // no cid { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, // no cid ], }) ) .mockResolvedValueOnce(themeResponse("light", "bafylight")); const result = await resolveTheme(APPVIEW, undefined, undefined); // Theme resolved successfully — live ref does not trigger CID mismatch expect(result.tokens["color-bg"]).toBe("#fff"); expect(result.colorScheme).toBe("light"); expect(mockLogger.warn).not.toHaveBeenCalledWith( expect.stringContaining("CID mismatch"), expect.any(Object) ); }); });