import { afterEach, describe, expect, it, vi } from "vitest"; import { RestClientError, requestPublicRestJson, requestRestJson, resolvePinnedRestEndpoint, resolveRestEndpoint, } from "./restClient"; const messages = { invalidEndpoint: "Invalid endpoint.", missingNonce: "Missing nonce.", networkError: "Network error.", timeout: "Timed out.", invalidResponse: "Invalid response.", statusError: (status: number) => `Status ${status}.`, }; const publicMessages = { invalidEndpoint: "Invalid public endpoint.", networkError: "Public network error.", timeout: "Public request timed out.", invalidResponse: "Invalid public response.", statusError: (status: number) => `Public status ${status}.`, }; function validPayload(value: unknown): value is { ok: true } { return ( value !== null && typeof value === "object" && !Array.isArray(value) && "ok" in value && value.ok === true ); } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); describe("resolveRestEndpoint", () => { it("accepts root-relative and exact same-origin absolute endpoints", () => { expect(resolveRestEndpoint("/wp-json/b3-wvs/v1/settings")).toBe( `${window.location.origin}/wp-json/b3-wvs/v1/settings`, ); expect( resolveRestEndpoint( `${window.location.origin}/wp-json/b3-wvs/v1/settings`, ), ).toBe(`${window.location.origin}/wp-json/b3-wvs/v1/settings`); }); it.each([ ["cross-origin", "https://example.com/wp-json/b3-wvs/v1/settings"], ["protocol-relative", "//example.com/wp-json/b3-wvs/v1/settings"], [ "userinfo", `${window.location.protocol}//user:password@${window.location.host}/wp-json/b3-wvs/v1/settings`, ], ["backslash", "/wp-json/b3-wvs/v1\\settings"], ["control character", "/wp-json/b3-wvs/v1\nsettings"], ["document-relative", "wp-json/b3-wvs/v1/settings"], ])("rejects a %s endpoint", (_label, endpoint) => { expect(resolveRestEndpoint(endpoint)).toBeNull(); }); }); describe("resolvePinnedRestEndpoint", () => { const anchorRoute = "/b3-wvs/v1"; const actionRoute = "/b3-wvs/v1/commerce/activate"; it("accepts only the exact sibling for pretty and query REST URLs", () => { expect( resolvePinnedRestEndpoint( `/wp-json${actionRoute}`, `/wp-json${anchorRoute}`, anchorRoute, actionRoute, ), ).toBe(`${window.location.origin}/wp-json${actionRoute}`); expect( resolvePinnedRestEndpoint( `/?rest_route=${encodeURIComponent(actionRoute)}`, `/?rest_route=${encodeURIComponent(anchorRoute)}`, anchorRoute, actionRoute, ), ).toBe( `${window.location.origin}/?rest_route=${encodeURIComponent(actionRoute)}`, ); }); it.each([ ["another same-origin route", "/wp-json/wp/v2/users"], ["an action suffix", `/wp-json${actionRoute}/other`], ["a query on a pretty route", `/wp-json${actionRoute}?force=true`], ])("rejects %s", (_label, endpoint) => { expect( resolvePinnedRestEndpoint( endpoint, `/wp-json${anchorRoute}`, anchorRoute, actionRoute, ), ).toBeNull(); }); it("rejects duplicate rest_route parameters", () => { expect( resolvePinnedRestEndpoint( `/?rest_route=${encodeURIComponent(actionRoute)}&rest_route=%2Fwp%2Fv2%2Fusers`, `/?rest_route=${encodeURIComponent(anchorRoute)}`, anchorRoute, actionRoute, ), ).toBeNull(); }); }); describe("requestRestJson", () => { it("forces same-origin mode, credentials, and the validated nonce", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); vi.stubGlobal("fetch", fetchMock); await requestRestJson({ endpoint: "/wp-json/b3-wvs/v1/settings", nonce: "trusted-nonce", init: { method: "POST", credentials: "include", mode: "cors", redirect: "follow", headers: { "X-WP-Nonce": "caller-value" }, }, validate: validPayload, messages, }); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe(`${window.location.origin}/wp-json/b3-wvs/v1/settings`); expect(init.credentials).toBe("same-origin"); expect(init.mode).toBe("same-origin"); expect(init.redirect).toBe("error"); expect(new Headers(init.headers).get("X-WP-Nonce")).toBe("trusted-nonce"); }); it.each(["", " ", " nonce", "nonce ", "nonce\nvalue"])( "rejects nonce %j before fetch", async (nonce) => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); await expect( requestRestJson({ endpoint: "/wp-json/b3-wvs/v1/settings", nonce, validate: validPayload, messages, }), ).rejects.toThrow("Missing nonce."); expect(fetchMock).not.toHaveBeenCalled(); }, ); it("keeps its timeout active through response body consumption", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); vi.stubGlobal( "fetch", vi.fn((_url: string, init: RequestInit) => Promise.resolve({ ok: true, status: 200, headers: new Headers({ "Content-Type": "application/json" }), text: () => new Promise((_resolve, reject) => { init.signal?.addEventListener("abort", () => reject(init.signal?.reason), ); }), } as unknown as Response), ), ); const request = requestRestJson({ endpoint: "/wp-json/b3-wvs/v1/settings", nonce: "nonce", timeoutMs: 25, validate: validPayload, messages, }); const rejection = expect(request).rejects.toThrow("Timed out."); await vi.advanceTimersByTimeAsync(25); await rejection; }); it("propagates a caller abort and does not call fetch when pre-aborted", async () => { const fetchMock = vi.fn(); const reason = new DOMException("Unmounted.", "AbortError"); vi.stubGlobal("fetch", fetchMock); await expect( requestRestJson({ endpoint: "/wp-json/b3-wvs/v1/settings", nonce: "nonce", signal: AbortSignal.abort(reason), validate: validPayload, messages, }), ).rejects.toBe(reason); expect(fetchMock).not.toHaveBeenCalled(); }); it("reads once and rejects a malformed successful response", async () => { const text = vi.fn().mockResolvedValue(JSON.stringify({ ok: false })); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, status: 200, headers: new Headers({ "Content-Type": "application/json" }), text, } as unknown as Response), ); await expect( requestRestJson({ endpoint: "/wp-json/b3-wvs/v1/settings", nonce: "nonce", validate: validPayload, messages, }), ).rejects.toThrow("Invalid response."); expect(text).toHaveBeenCalledTimes(1); }); }); describe("RestClientError contract", () => { async function rejectedRestError( response: Response, ): Promise { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); try { await requestRestJson({ endpoint: "/wp-json/example/v1/settings", nonce: "nonce", validate: validPayload, messages, }); } catch (error) { expect(error).toBeInstanceOf(RestClientError); return error as RestClientError; } throw new Error("Expected the REST request to reject."); } it.each([ [400, "rest_invalid_param"], [409, "b3_settings_conflict"], ])( "types a %d WordPress REST failure with authoritative metadata", async (status, code) => { const error = await rejectedRestError( jsonResponse( { code, message: "Server-authored error.", data: { status } }, status, ), ); expect(error).toMatchObject({ name: "RestClientError", message: "Server-authored error.", kind: "http", status, code, }); }, ); it("types a 502 proxy failure without manufacturing a REST code", async () => { const error = await rejectedRestError( new Response("Proxy error.", { status: 502, headers: { "Content-Type": "text/plain" }, }), ); expect(error).toMatchObject({ message: "Proxy error.", kind: "http", status: 502, }); expect(error.code).toBeUndefined(); }); it.each([ ["mismatched", 400], ["string", "409"], ["out of range", 600], ["missing", undefined], ])( "keeps Response.status authoritative when the body status is %s", async (_case, bodyStatus) => { const error = await rejectedRestError( jsonResponse( { code: "b3_settings_conflict", message: "Conflict.", data: bodyStatus === undefined ? {} : { status: bodyStatus }, }, 409, ), ); expect(error).toMatchObject({ message: "Conflict.", kind: "http", status: 409, }); expect(error.code).toBeUndefined(); }, ); it("types invalid JSON and invalid success shapes separately from HTTP failures", async () => { const httpError = await rejectedRestError( new Response("{", { status: 502, headers: { "Content-Type": "application/json" }, }), ); expect(httpError).toMatchObject({ message: "Status 502.", kind: "http", status: 502, }); for (const body of ["{", JSON.stringify({ ok: false })]) { const error = await rejectedRestError( new Response(body, { status: 200, headers: { "Content-Type": "application/json" }, }), ); expect(error).toMatchObject({ message: "Invalid response.", kind: "invalid-response", status: 200, }); expect(error.code).toBeUndefined(); } }); it("types transport failures while preserving caller AbortError identity", async () => { vi.stubGlobal( "fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")), ); await expect( requestRestJson({ endpoint: "/wp-json/example/v1/settings", nonce: "nonce", validate: validPayload, messages, }), ).rejects.toMatchObject({ name: "RestClientError", message: "Network error.", kind: "transport", }); const reason = new DOMException("Unmounted.", "AbortError"); await expect( requestRestJson({ endpoint: "/wp-json/example/v1/settings", nonce: "nonce", signal: AbortSignal.abort(reason), validate: validPayload, messages, }), ).rejects.toBe(reason); }); }); describe("requestPublicRestJson", () => { it("uses a same-origin endpoint without a nonce and strips a caller nonce", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); vi.stubGlobal("fetch", fetchMock); await expect( requestPublicRestJson({ endpoint: "/wp-json/b3-wvs/v1/public", init: { credentials: "include", mode: "cors", redirect: "manual", headers: { "X-WP-Nonce": "must-not-leak" }, }, validate: validPayload, messages: publicMessages, }), ).resolves.toEqual({ ok: true }); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe(`${window.location.origin}/wp-json/b3-wvs/v1/public`); expect(init.credentials).toBe("same-origin"); expect(init.mode).toBe("same-origin"); expect(init.redirect).toBe("error"); expect(new Headers(init.headers).has("X-WP-Nonce")).toBe(false); }); it("rejects off-origin endpoints before fetch", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); await expect( requestPublicRestJson({ endpoint: "https://untrusted.example/wp-json/b3-wvs/v1/public", validate: validPayload, messages: publicMessages, }), ).rejects.toThrow("Invalid public endpoint."); expect(fetchMock).not.toHaveBeenCalled(); }); });