Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -97,6 +101,7 @@ type ResolvedConfig = {
abTests?: ABTestConfig[];
additionalTargetingSignals?: TargetingSignals;
timeout?: string;
reportMisconfiguration?: boolean | { site?: string };
};

const DCN_DEFAULTS = {
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 11 additions & 2 deletions lib/core/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -74,7 +83,7 @@ async function fetch<T>(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) {
Expand All @@ -92,5 +101,5 @@ async function fetch<T>(path: string, config: ResolvedConfig, init?: RequestInit
return data;
}

export { fetch, buildRequest };
export { fetch, buildRequest, FetchError };
export default fetch;
271 changes: 271 additions & 0 deletions lib/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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"'),
})
);
});
});
});
11 changes: 10 additions & 1 deletion lib/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,7 +48,15 @@ class OptableSDK {

async initialize(): Promise<void> {
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) {
Expand Down
Loading