mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-15 17:28:04 +00:00
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 56ab784117.
Related: NginxProxyManager/nginx-proxy-manager#5849
AI assistance: Codex assisted with the implementation, tests, independent
review and contribution text.
This commit is contained in:
1 parent
56ab784117
commit
9972e78dc8
4 files changed
+155
-4
No files matched your search
@@ -46,7 +46,7 @@ function buildBody(data?: Record<string, any>): 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 {
|
||||
|
||||
@@ -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<typeof fetch>();
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import * as api from "./base";
|
||||
export async function testHttpCertificate(domains: string[]): Promise<Record<string, string>> {
|
||||
return await api.post({
|
||||
url: "/nginx/certificates/test-http",
|
||||
preserveResponseKeys: true,
|
||||
data: {
|
||||
domains,
|
||||
},
|
||||
|
||||
@@ -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<typeof fetch>();
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
async function openAndTest(domains: string[], payload: Record<string, string>) {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 }));
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<EasyModal.Provider />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user