mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
126 lines
5.4 KiB
JavaScript
126 lines
5.4 KiB
JavaScript
// SPDX-License-Identifier: 0BSD
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
describe("apiClient CSRF recovery", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
global.window = { location: { origin: "http://127.0.0.1:5173" } };
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("retries a mutating request once after refreshing a stale CSRF token", async () => {
|
|
// Oracle: after a backgrounded web tab, the in-memory CSRF token can
|
|
// disagree with the cookie session. A single 403 CSRF response must
|
|
// refresh the token and retry, not treat the call as an auth logout.
|
|
const { setCsrfToken, getCsrfToken } = await import("../../meshchatx/src/frontend/js/csrfToken.js");
|
|
setCsrfToken("stale-token");
|
|
|
|
const onAuthError = vi.fn();
|
|
const { createApiClient } = await import("../../meshchatx/src/frontend/js/apiClient.js");
|
|
const api = createApiClient({ onAuthError });
|
|
|
|
let postCalls = 0;
|
|
const fetchMock = vi.fn(async (url, init) => {
|
|
const path = String(url);
|
|
if (path.includes("/api/v1/auth/csrf") && (!init?.method || init.method === "GET")) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ "content-type": "application/json" }),
|
|
text: async () => JSON.stringify({ csrf_token: "fresh-token" }),
|
|
};
|
|
}
|
|
if (path.includes("/api/v1/destination/deadbeef/request-path") && init?.method === "POST") {
|
|
postCalls += 1;
|
|
const hdrs = init.headers instanceof Headers ? init.headers : new Headers(init.headers);
|
|
const token = hdrs.get("X-CSRF-Token");
|
|
if (token === "stale-token") {
|
|
return {
|
|
ok: false,
|
|
status: 403,
|
|
headers: new Headers({ "content-type": "application/json" }),
|
|
text: async () => JSON.stringify({ error: "Invalid or missing CSRF token" }),
|
|
};
|
|
}
|
|
if (token === "fresh-token") {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ "content-type": "application/json" }),
|
|
text: async () => JSON.stringify({ message: "ok" }),
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
ok: false,
|
|
status: 500,
|
|
headers: new Headers({ "content-type": "application/json" }),
|
|
text: async () => JSON.stringify({ error: "unexpected" }),
|
|
};
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const result = await api.post("/api/v1/destination/deadbeef/request-path");
|
|
|
|
expect(result.data).toEqual({ message: "ok" });
|
|
expect(postCalls).toBe(2);
|
|
expect(getCsrfToken()).toBe("fresh-token");
|
|
expect(onAuthError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not call onAuthError for CSRF 403 responses", async () => {
|
|
const { setCsrfToken } = await import("../../meshchatx/src/frontend/js/csrfToken.js");
|
|
setCsrfToken("stale-token");
|
|
|
|
const onAuthError = vi.fn();
|
|
const { createApiClient } = await import("../../meshchatx/src/frontend/js/apiClient.js");
|
|
const api = createApiClient({ onAuthError });
|
|
|
|
vi.stubGlobal("fetch", async () => ({
|
|
ok: false,
|
|
status: 403,
|
|
headers: new Headers({ "content-type": "application/json" }),
|
|
text: async () => JSON.stringify({ error: "Invalid or missing CSRF token" }),
|
|
}));
|
|
|
|
// Force refresh itself to fail so retry cannot succeed.
|
|
await expect(api.post("/api/v1/destination/deadbeef/request-path")).rejects.toMatchObject({
|
|
response: { status: 403 },
|
|
});
|
|
expect(onAuthError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still calls onAuthError for real auth 401 responses", async () => {
|
|
const { setCsrfToken } = await import("../../meshchatx/src/frontend/js/csrfToken.js");
|
|
setCsrfToken("good-token");
|
|
|
|
const onAuthError = vi.fn();
|
|
const { createApiClient } = await import("../../meshchatx/src/frontend/js/apiClient.js");
|
|
const api = createApiClient({ onAuthError });
|
|
|
|
vi.stubGlobal("fetch", async () => ({
|
|
ok: false,
|
|
status: 401,
|
|
headers: new Headers({ "content-type": "application/json" }),
|
|
text: async () => JSON.stringify({ error: "Authentication required" }),
|
|
}));
|
|
|
|
await expect(api.get("/api/v1/config")).rejects.toMatchObject({
|
|
response: { status: 401 },
|
|
});
|
|
expect(onAuthError).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("classifies CSRF rejection bodies without treating other 403s as CSRF", async () => {
|
|
const { isCsrfRejection } = await import("../../meshchatx/src/frontend/js/apiClient.js");
|
|
expect(isCsrfRejection(403, { error: "Invalid or missing CSRF token" })).toBe(true);
|
|
expect(isCsrfRejection(403, { error: "Forbidden: client IP not on allowlist" })).toBe(false);
|
|
expect(isCsrfRejection(401, { error: "Invalid or missing CSRF token" })).toBe(false);
|
|
expect(isCsrfRejection(403, null)).toBe(false);
|
|
});
|
|
});
|