import { act } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { safeRedirect } from "@/auth/LoginPage"; import { AuthProvider, resetAuthProbeForTests } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; function jsonResponse(payload: unknown, status = 200, headers: Record = {}): Response { return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json", ...headers }, }); } beforeEach(() => { sessionStorage.clear(); resetAuthProbeForTests(); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); async function flushAll() { for (let i = 0; i < 20; i++) { await act(async () => { vi.advanceTimersByTime(0); await Promise.resolve(); }); } } function renderLoginRoute() { const queryClient = createQueryClient(); const router = createAppRouter(createMemoryHistory({ initialEntries: ["/login"] }), queryClient); render( , ); } test("429 login shows a ticking countdown and keeps submit disabled until it ends", async () => { vi.useFakeTimers(); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { if (String(input) !== "/api/auth/login") return jsonResponse({ error: "not stubbed" }, 404); const body = JSON.parse(String(init?.body)) as { password: string }; if (body.password === "") return jsonResponse({ error: "password required" }, 401); return jsonResponse({ error: "rate limited" }, 429, { "retry-after": "3" }); }); vi.stubGlobal("fetch", fetchMock); renderLoginRoute(); await flushAll(); const input = screen.getByLabelText("Password"); fireEvent.change(input, { target: { value: "wrong" } }); fireEvent.submit(input.closest("form") as HTMLFormElement); await flushAll(); const button = screen.getByRole("button", { name: "Log in" }) as HTMLButtonElement; expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again in 3s."); expect(button.disabled).toBe(true); const callsAtLockout = fetchMock.mock.calls.length; fireEvent.submit(input.closest("form") as HTMLFormElement); await flushAll(); expect(fetchMock.mock.calls.length).toBe(callsAtLockout); act(() => { vi.advanceTimersByTime(1000); }); expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again in 2s."); expect(button.disabled).toBe(true); act(() => { vi.advanceTimersByTime(2000); }); expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again shortly."); expect(button.disabled).toBe(false); }); test("safeRedirect only allows same-origin absolute paths", () => { expect(safeRedirect(undefined)).toBe("/"); expect(safeRedirect("/activity")).toBe("/activity"); expect(safeRedirect("/activity?x=1")).toBe("/activity?x=1"); expect(safeRedirect("//evil.example")).toBe("/"); expect(safeRedirect("https://evil.example")).toBe("/"); expect(safeRedirect("/\\evil.example")).toBe("/"); expect(safeRedirect("/\\\\evil.example")).toBe("/"); expect(safeRedirect("\\evil")).toBe("/"); });