diff --git a/lib/config.ts b/lib/config.ts index 68d3075..43682e8 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -76,6 +76,10 @@ type InitConfig = { // When true, a 'pageview' event is fired once after passport init with full page context. // Implies pageContext: true when no pageContext is explicitly configured. initContextual?: boolean; + // When enabled, detects 403/404 responses from the Site config call and fires a + // best-effort profile() call with { error_403: hostname } or { error_404: hostname }. + // Pass true to use the "default-sdk" catch-all source, or { site: "slug" } to override the target source. + reportMisconfiguration?: boolean | { site?: string }; }; type ResolvedConfig = { @@ -97,6 +101,7 @@ type ResolvedConfig = { abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; timeout?: string; + reportMisconfiguration?: boolean | { site?: string }; }; const DCN_DEFAULTS = { @@ -133,6 +138,7 @@ function getConfig(init: InitConfig): ResolvedConfig { abTests: init.abTests, additionalTargetingSignals: init.additionalTargetingSignals, timeout: init.timeout, + reportMisconfiguration: init.reportMisconfiguration, }; if (init.consent?.static) { diff --git a/lib/core/network.ts b/lib/core/network.ts index a6b2aad..3684e37 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -2,6 +2,15 @@ import type { ResolvedConfig } from "../config"; import { default as buildInfo } from "../build.json"; import { LocalStorage } from "./storage"; +class FetchError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = "FetchError"; + this.status = status; + } +} + function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request { const { host, cookies } = config; @@ -74,7 +83,7 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit const data = contentType?.startsWith("application/json") ? await response.json() : await response.text(); if (!response.ok) { - throw new Error(data.error); + throw new FetchError(data?.error ?? response.statusText, response.status); } if (data.passport) { @@ -92,5 +101,5 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit return data; } -export { fetch, buildRequest }; +export { fetch, buildRequest, FetchError }; export default fetch; diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts index 5a4153f..73b3955 100644 --- a/lib/sdk.test.ts +++ b/lib/sdk.test.ts @@ -3,6 +3,8 @@ import { OptableSDK, normalizeTargetingRequest } from "./sdk"; import { TEST_BASE_URL, TEST_HOST, TEST_SITE } from "./test/mocks"; import { DCN_DEFAULTS } from "./config"; import { waitFor } from "./test/utils"; +import { server } from "./test/server"; +import { http, HttpResponse } from "msw"; const defaultConsent = DCN_DEFAULTS.consent; @@ -603,3 +605,272 @@ describe("normalizeTargetingRequest", () => { expect(() => normalizeTargetingRequest(3)).toThrowError(/Expected string or object/); }); }); + +describe("reportMisconfiguration", () => { + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + test("when reportMisconfiguration: true and Site() returns 403, profile is called with error_403", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 403 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: true, + }); + + await sdk["init"]; + + // Should have called profile with error_403 and o=default-sdk + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_403":"localhost"'), + }) + ); + }); + }); + + test("when reportMisconfiguration: true and Site() returns 404, profile is called with error_404", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 404 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: true, + }); + + await sdk["init"]; + + // Should have called profile with error_404 and o=default-sdk + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_404":"localhost"'), + }) + ); + }); + }); + + test("when reportMisconfiguration: false and Site() returns 403, profile is NOT called", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 403 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: false, + }); + + await sdk["init"]; + + // Should NOT have called profile + expect(fetchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("profile"), + }) + ); + }); + + test("when reportMisconfiguration not set and Site() returns 403, profile is NOT called", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 403 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + }); + + await sdk["init"]; + + // Should NOT have called profile + expect(fetchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("profile"), + }) + ); + }); + + test("when reportMisconfiguration: true and Site() returns 500, profile is called with error_500", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 500 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: true, + }); + + await sdk["init"]; + + // Should have called profile with error_500 and o=default-sdk + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_500":"localhost"'), + }) + ); + }); + }); + + test("when reportMisconfiguration: true and Site() throws TypeError (network error), profile is called with error_config", async () => { + // Mock fetch to throw TypeError for config endpoint, but allow profile call through + const originalFetch = window.fetch; + const fetchSpy = jest.spyOn(window, "fetch").mockImplementation((input: any) => { + if (typeof input === "string" && input.includes("/config")) { + return Promise.reject(new TypeError("Failed to fetch")); + } + return originalFetch(input); + }); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: true, + }); + + await sdk["init"]; + + // Should have called profile with error_config and o=default-sdk + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_config":"localhost"'), + }) + ); + }); + + fetchSpy.mockRestore(); + }); + + test("when reportMisconfiguration not set and Site() throws TypeError, profile is NOT called", async () => { + // Mock fetch to throw TypeError for config endpoint + const originalFetch = window.fetch; + const fetchSpy = jest.spyOn(window, "fetch").mockImplementation((input: any) => { + if (typeof input === "string" && input.includes("/config")) { + return Promise.reject(new TypeError("Failed to fetch")); + } + return originalFetch(input); + }); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + }); + + await sdk["init"]; + + // Should NOT have called profile + expect(fetchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("profile"), + }) + ); + + fetchSpy.mockRestore(); + }); + + test("when reportMisconfiguration: { site } and Site() returns 403, profile is called with the custom site slug", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: { site: "my-error-source" }, + }); + + await sdk["init"]; + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=my-error-source"), + _bodyText: expect.stringContaining('"error_403":"localhost"'), + }) + ); + }); + }); + + test("when reportMisconfiguration: {} and Site() returns 403, profile falls back to default-sdk", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: {}, + }); + + await sdk["init"]; + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_403":"localhost"'), + }) + ); + }); + }); +}); diff --git a/lib/sdk.ts b/lib/sdk.ts index 3f4ba40..da22900 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -24,6 +24,7 @@ import { Profile } from "./edge/profile"; import { sha256 } from "js-sha256"; import { Tokenize, TokenizeResponse } from "./edge/tokenize"; import { LocalStorage } from "./core/storage"; +import { FetchError } from "./core/network"; class OptableSDK { public static version = buildInfo.version; @@ -47,7 +48,15 @@ class OptableSDK { async initialize(): Promise { if (this.dcn.initPassport) { - await Site(this.dcn).catch(() => {}); + await Site(this.dcn).catch((err) => { + if (this.dcn.reportMisconfiguration) { + const status = err instanceof FetchError ? err.status : null; + const key = status ? `error_${status}` : "error_config"; + const cfg = this.dcn.reportMisconfiguration; + const reportSite = typeof cfg === "object" ? (cfg.site ?? "default-sdk") : "default-sdk"; + Profile({ ...this.dcn, site: reportSite }, { [key]: window.location.hostname }).catch(() => {}); + } + }); } if (this.dcn.initTargeting) {