From 9972e78dc8e75374f4e36a60ff26df6dc6a8c7f5 Mon Sep 17 00:00:00 2001 From: Hafiz Ahmed Zia <246515321+hafizahmedzia@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:36:32 +0500 Subject: [PATCH] fix: preserve hostname keys in HTTP certificate test results Preserve raw response keys for the HTTP certificate test endpoint so hostname punctuation and distinct result entries survive normalization. Add API and mounted-modal regression coverage for the exact candidate reviewed against develop 56ab784117abb89dc7b81e39d4e7e23e33989084. Related: NginxProxyManager/nginx-proxy-manager#5849 AI assistance: Codex assisted with the implementation, tests, independent review and contribution text. --- frontend/src/api/backend/base.ts | 9 +- .../api/backend/testHttpCertificate.test.ts | 92 +++++++++++++++++++ .../src/api/backend/testHttpCertificate.ts | 1 + .../src/modals/HTTPCertificateModal.test.tsx | 57 ++++++++++++ 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 frontend/src/api/backend/testHttpCertificate.test.ts create mode 100644 frontend/src/modals/HTTPCertificateModal.test.tsx 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(); + }); +});