Optimize API client headers by method - only set Content-Type and CSRF header as needed
- Only set Content-Type header for requests with a body (POST, PUT, DELETE with body) - Only set X-BanGUI-Request CSRF header for mutating methods (POST, PUT, DELETE, PATCH) - GET, HEAD, OPTIONS requests no longer include unnecessary headers, reducing CORS preflights - Update Web-Development.md to clarify conditional header behavior - Add comprehensive tests for header behavior by HTTP method This reduces unnecessary CORS preflight requests on GET endpoints while maintaining CSRF protection on state-mutating requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { ApiError, get, isAuthError, setUnauthorizedHandler } from "../client";
|
||||
import type { Mock } from "vitest";
|
||||
import { ApiError, get, post, put, del, isAuthError, setUnauthorizedHandler } from "../client";
|
||||
|
||||
describe("api/client", () => {
|
||||
beforeEach(() => {
|
||||
@@ -7,6 +8,10 @@ describe("api/client", () => {
|
||||
setUnauthorizedHandler(null);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Authorization tests
|
||||
// =========================================================================
|
||||
|
||||
it("invokes unauthorized handler for 401 responses", async () => {
|
||||
const handler = vi.fn();
|
||||
setUnauthorizedHandler(handler);
|
||||
@@ -58,4 +63,158 @@ describe("api/client", () => {
|
||||
expect(isAuthError(new ApiError(401, "Unauthorized"))).toBe(true);
|
||||
expect(isAuthError(new ApiError(403, "Forbidden"))).toBe(true);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Header tests: Content-Type
|
||||
// =========================================================================
|
||||
|
||||
it("does not set Content-Type header for GET requests", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ data: "test" }),
|
||||
});
|
||||
|
||||
await get("/test");
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets Content-Type header for POST requests with body", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ data: "test" }),
|
||||
});
|
||||
|
||||
await post("/test", { key: "value" });
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("sets Content-Type header for PUT requests with body", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ data: "test" }),
|
||||
});
|
||||
|
||||
await put("/test", { key: "value" });
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("sets Content-Type header for DELETE requests with body", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
});
|
||||
|
||||
await del("/test", { key: "value" });
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("does not set Content-Type header for DELETE requests without body", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
});
|
||||
|
||||
await del("/test");
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Header tests: X-BanGUI-Request (CSRF)
|
||||
// =========================================================================
|
||||
|
||||
it("does not set X-BanGUI-Request header for GET requests", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ data: "test" }),
|
||||
});
|
||||
|
||||
await get("/test");
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["X-BanGUI-Request"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets X-BanGUI-Request header for POST requests", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ data: "test" }),
|
||||
});
|
||||
|
||||
await post("/test", { key: "value" });
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["X-BanGUI-Request"]).toBe("1");
|
||||
});
|
||||
|
||||
it("sets X-BanGUI-Request header for PUT requests", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ data: "test" }),
|
||||
});
|
||||
|
||||
await put("/test", { key: "value" });
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["X-BanGUI-Request"]).toBe("1");
|
||||
});
|
||||
|
||||
it("sets X-BanGUI-Request header for DELETE requests", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
});
|
||||
|
||||
await del("/test");
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const headers = options?.headers as Record<string, string>;
|
||||
expect(headers?.["X-BanGUI-Request"]).toBe("1");
|
||||
});
|
||||
|
||||
it("includes credentials on all requests", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ data: "test" }),
|
||||
});
|
||||
|
||||
await get("/test");
|
||||
|
||||
const fetchMock = global.fetch as Mock;
|
||||
const options = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(options?.credentials).toBe("include");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@ describe("fetchBansByCountry", () => {
|
||||
await fetchBansByCountry("24h", "all", "fail2ban", "US");
|
||||
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
`${ENDPOINTS.dashboardBansByCountry}?range=24h&country_code=US`
|
||||
`${ENDPOINTS.dashboardBansByCountry}?range=24h&country_code=US`,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,7 +29,9 @@ describe("fetchBansByCountry", () => {
|
||||
await fetchBansByCountry("24h", "all", "fail2ban");
|
||||
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
`${ENDPOINTS.dashboardBansByCountry}?range=24h`
|
||||
`${ENDPOINTS.dashboardBansByCountry}?range=24h`,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -89,14 +89,28 @@ export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||
*/
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
try {
|
||||
const method: string = options.method ?? "GET";
|
||||
const hasBody: boolean = options.body !== undefined && options.body !== null;
|
||||
const isMutatingMethod: boolean = ["POST", "PUT", "DELETE", "PATCH"].includes(method);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
// Only set Content-Type for requests with a body to avoid unnecessary CORS preflights.
|
||||
if (hasBody) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
// Only set CSRF header for state-mutating requests (not for GET/HEAD/OPTIONS).
|
||||
if (isMutatingMethod) {
|
||||
headers["X-BanGUI-Request"] = "1";
|
||||
}
|
||||
|
||||
const response: Response = await fetch(url, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-BanGUI-Request": "1",
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user