diff --git a/frontend/src/api/backend/base.ts b/frontend/src/api/backend/base.ts index d56f120c7..e91376d44 100644 --- a/frontend/src/api/backend/base.ts +++ b/frontend/src/api/backend/base.ts @@ -46,7 +46,7 @@ function buildBody(data?: Record): string | undefined { } } -async function processResponse(response: Response) { +async function processResponse(response: Response, preserveResponseKeys = false) { const payload = await response.json(); if (!response.ok) { if (response.status === 401) { @@ -59,7 +59,7 @@ async function processResponse(response: Response) { typeof payload.error.messageI18n !== "undefined" ? payload.error.messageI18n : payload.error.message, ); } - return camelizeKeys(payload) as any; + return (preserveResponseKeys ? payload : camelizeKeys(payload)) as any; } interface GetArgs { @@ -97,9 +97,10 @@ interface PostArgs { params?: queryString.StringifiableRecord; data?: any; noAuth?: boolean; + preserveResponseKeys?: boolean; } -export async function post({ url, params, data, noAuth }: PostArgs, abortController?: AbortController) { +export async function post({ url, params, data, noAuth, preserveResponseKeys }: PostArgs, abortController?: AbortController) { const apiUrl = buildUrl({ url, params }); const method = "POST"; @@ -126,7 +127,7 @@ export async function post({ url, params, data, noAuth }: PostArgs, abortControl const signal = abortController?.signal; const response = await fetch(apiUrl, { method, headers, body, signal }); - return processResponse(response); + return processResponse(response, preserveResponseKeys); } interface PutArgs { diff --git a/frontend/src/api/backend/testHttpCertificate.test.ts b/frontend/src/api/backend/testHttpCertificate.test.ts new file mode 100644 index 000000000..ecbe1dbed --- /dev/null +++ b/frontend/src/api/backend/testHttpCertificate.test.ts @@ -0,0 +1,92 @@ +import AuthStore from "src/modules/AuthStore"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "./base"; +import { testHttpCertificate } from "./testHttpCertificate"; + +const fetchMock = vi.fn(); +const reply = (payload: unknown, status = 200) => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status })); +}; +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + fetchMock.mockReset(); +}); +describe("certificate HTTP result keys", () => { + it.each([ + "test-domain.example", + "test--domain.example", + "xn--bcher-kva.example", + "one.two-three.example", + "_acme-challenge.example", + "Test-Domain.example", + "plain.example", + ])("preserves the exact response key %s", async (domain) => { + const result = { [domain]: "wrong-data" }; + reply(result); + expect(await testHttpCertificate([domain])).toEqual(result); + }); + it("keeps colliding spellings and their distinct statuses", async () => { + const result = { "test-domain.example": "ok", "test--domain.example": "no-host" }; + reply(result); + expect(await testHttpCertificate(Object.keys(result))).toEqual(result); + }); + it("sends unchanged domains with the normal request headers", async () => { + const domains = ["test-domain.example", "test--domain.example"]; + reply({}); + await testHttpCertificate(domains); + expect(fetchMock).toHaveBeenCalledWith("/api/nginx/certificates/test-http", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ domains }), + signal: undefined, + }); + }); + it("preserves an empty result", async () => { + reply({}); + expect(await testHttpCertificate([])).toEqual({}); + }); + it.each([ + [400, { message: "invalid domain" }, "invalid domain"], + [500, { message: "fallback", messageI18n: "certificate-error" }, "certificate-error"], + ])("preserves error handling for status %s", async (status, error, message) => { + reply({ error }, status); + await expect(testHttpCertificate(["test-domain.example"])).rejects.toThrow(message); + }); + it("still clears authentication on 401", async () => { + const clear = vi.spyOn(AuthStore, "clear"); + const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {}); + reply({ error: { message: "unauthorized" } }, 401); + await expect(testHttpCertificate(["test-domain.example"])).rejects.toThrow("unauthorized"); + expect(clear).toHaveBeenCalledOnce(); + expect(reload).toHaveBeenCalledOnce(); + }); + it.each([ + ["get", api.get], + ["post", api.post], + ["put", api.put], + ["del", api.del], + ] as const)("keeps default %s response normalization", async (_name, method) => { + reply({ domain_names: ["test-domain.example"], nested_value: { created_on: "today" } }); + expect(await method({ url: "/control" })).toEqual({ + domainNames: ["test-domain.example"], + nestedValue: { createdOn: "today" }, + }); + }); + it("preserves POST abort forwarding and request normalization", async () => { + const controller = new AbortController(); + reply({ domain_names: [] }); + await api.post({ url: "/control", data: { domainNames: ["a-b.example"] } }, controller); + expect(fetchMock).toHaveBeenCalledWith( + "/api/control", + expect.objectContaining({ + body: JSON.stringify({ domain_names: ["a-b.example"] }), + signal: controller.signal, + }), + ); + }); +}); diff --git a/frontend/src/api/backend/testHttpCertificate.ts b/frontend/src/api/backend/testHttpCertificate.ts index 23dfa4b7d..9687d55af 100644 --- a/frontend/src/api/backend/testHttpCertificate.ts +++ b/frontend/src/api/backend/testHttpCertificate.ts @@ -3,6 +3,7 @@ import * as api from "./base"; export async function testHttpCertificate(domains: string[]): Promise> { return await api.post({ url: "/nginx/certificates/test-http", + preserveResponseKeys: true, data: { domains, }, diff --git a/frontend/src/modals/HTTPCertificateModal.test.tsx b/frontend/src/modals/HTTPCertificateModal.test.tsx new file mode 100644 index 000000000..65d1aaed0 --- /dev/null +++ b/frontend/src/modals/HTTPCertificateModal.test.tsx @@ -0,0 +1,57 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import EasyModal from "ez-modal-react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { showHTTPCertificateModal } from "./HTTPCertificateModal"; + +const fetchMock = vi.fn(); +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + fetchMock.mockReset(); +}); +async function openAndTest(domains: string[], payload: Record) { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })); + render( + + + , + ); + await act(async () => { + showHTTPCertificateModal(); + }); + const input = await screen.findByRole("combobox", { expanded: false }); + for (const domain of domains) { + fireEvent.change(input, { target: { value: domain } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter", keyCode: 13 }); + await waitFor(() => expect(input).toHaveValue("")); + } + fireEvent.click(screen.getByRole("button", { name: "Test" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ domains }); +} +describe("mounted HTTP certificate modal", () => { + it("renders a hyphenated result without changing its label", async () => { + await openAndTest(["test-domain.example"], { "test-domain.example": "wrong-data" }); + expect(await screen.findByText("test-domain.example:")).toBeVisible(); + expect(screen.queryByText("testDomain.example:")).not.toBeInTheDocument(); + }); + it("renders both collision-prone domains with distinct results", async () => { + await openAndTest(["test-domain.example", "test--domain.example"], { + "test-domain.example": "ok", + "test--domain.example": "no-host", + }); + const first = await screen.findByText("test-domain.example:"); + const second = await screen.findByText("test--domain.example:"); + expect( + first.parentElement?.querySelector('[data-translation-id="certificates.http.reachability-ok"]'), + ).not.toBeNull(); + expect( + second.parentElement?.querySelector('[data-translation-id="certificates.http.reachability-not-resolved"]'), + ).not.toBeNull(); + }); +});