mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-25 08:03:32 +00:00
feat: isolate admin sign-in caches, retain cached probe outcomes and clear recovered mailbox issues per measured source
This commit is contained in:
committed by
Devin AI
parent
2033fb8a36
commit
a8ddfc65ec
@@ -0,0 +1,81 @@
|
||||
import { act, type PropsWithChildren } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
login: vi.fn(), confirm: vi.fn(), verify: vi.fn(), saveToken: vi.fn(),
|
||||
onToken: (_value: string): Promise<void> => Promise.resolve(),
|
||||
onCode: (_value: string): void => {},
|
||||
}));
|
||||
vi.mock("@/lib/api/client/auth", () => ({ getAuthConfig: () => Promise.resolve({ captcha: false }), login: state.login, loginConfirm: state.confirm, verifyTwoFA: state.verify }));
|
||||
vi.mock("@/lib/auth/storage", () => ({ setToken: state.saveToken }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("motion/react", () => ({ AnimatePresence: ({ children }: PropsWithChildren) => children, motion: { div: ({ children }: PropsWithChildren) => <div>{children}</div> } }));
|
||||
vi.mock("@/components/captcha/TurnstileModal", () => ({ TurnstileModal: ({ onToken }: { onToken: typeof state.onToken }) => { state.onToken = onToken; return null; } }));
|
||||
vi.mock("@/components/ui/input-otp", () => ({ InputOTP: ({ onChange }: { onChange: typeof state.onCode }) => { state.onCode = onChange; return null; }, InputOTPGroup: () => null, InputOTPSlot: () => null }));
|
||||
|
||||
const token = { access_token: "fixture-access", refresh_token: "fixture-refresh", access_token_expires_at: "2099-01-01T00:00:00Z", refresh_token_expires_at: "2099-01-01T00:00:00Z" };
|
||||
let root: Root;
|
||||
let client: QueryClient;
|
||||
let clear: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(["me"], { id: "previous-admin", admin_permissions: 0xffff });
|
||||
client.setQueryData(["system", "mail-status"], { private: "previous-session" });
|
||||
clear = vi.spyOn(client, "clear");
|
||||
root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter><LoginPage /></MemoryRouter></QueryClientProvider>));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function expectClearedBeforeToken() {
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(0);
|
||||
expect(state.saveToken).toHaveBeenCalledWith(token);
|
||||
expect(clear.mock.invocationCallOrder[0]).toBeLessThan(state.saveToken.mock.invocationCallOrder[0]);
|
||||
}
|
||||
|
||||
describe("admin sign-in session isolation", () => {
|
||||
it("clears cached identity and private data before a direct successful login", async () => {
|
||||
state.login.mockResolvedValue({ code_required: false, token });
|
||||
await act(async () => state.onToken("fixture-captcha"));
|
||||
expectClearedBeforeToken();
|
||||
});
|
||||
|
||||
it("keeps cache during an email-code challenge and clears it on confirmation", async () => {
|
||||
state.login.mockResolvedValue({ code_required: true, session: "fixture-session" });
|
||||
state.confirm.mockResolvedValue(token);
|
||||
await act(async () => state.onToken("fixture-captcha"));
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
await act(async () => state.onCode("123456"));
|
||||
expectClearedBeforeToken();
|
||||
});
|
||||
|
||||
it("keeps cache during a second-factor challenge and clears it on verification", async () => {
|
||||
state.login.mockResolvedValue({ code_required: false, two_fa_required: true, pending_token: "fixture-pending" });
|
||||
state.verify.mockResolvedValue(token);
|
||||
await act(async () => state.onToken("fixture-captcha"));
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
await act(async () => state.onCode("123456"));
|
||||
expectClearedBeforeToken();
|
||||
});
|
||||
|
||||
it("does not replace the session or clear caches on a failed login", async () => {
|
||||
state.login.mockRejectedValue(new Error("fixture-login-failure"));
|
||||
await act(async () => state.onToken("fixture-captcha"));
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(state.saveToken).not.toHaveBeenCalled();
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { AnimatePresence, motion, type Transition } from "motion/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, EyeOff, Loader2, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,6 +41,7 @@ function errorMessage(err: unknown): string {
|
||||
|
||||
export default function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const loc = useLocation();
|
||||
const [phase, setPhase] = useState<"credentials" | "code" | "twofa">("credentials");
|
||||
const [pendingToken, setPendingToken] = useState("");
|
||||
@@ -118,6 +120,7 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
if (res.token) {
|
||||
queryClient.clear();
|
||||
setToken(res.token);
|
||||
const dest = (loc.state as { from?: string } | null)?.from ?? "/";
|
||||
nav(dest, { replace: true });
|
||||
@@ -166,6 +169,7 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
if (!res.access_token) throw new Error("The server returned an unexpected sign-in response.");
|
||||
queryClient.clear();
|
||||
setToken(res as LoginResponse);
|
||||
const dest = (loc.state as { from?: string } | null)?.from ?? "/";
|
||||
nav(dest, { replace: true });
|
||||
@@ -188,6 +192,7 @@ export default function LoginPage() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tok = await verifyTwoFA({ pending_token: pendingToken, code: value });
|
||||
queryClient.clear();
|
||||
setToken(tok);
|
||||
const dest = (loc.state as { from?: string } | null)?.from ?? "/";
|
||||
nav(dest, { replace: true });
|
||||
|
||||
@@ -74,7 +74,15 @@ describe("configuration and service coverage", () => {
|
||||
state.services.data!.data = [{ name: "postgres", ok: true, latency_ms: 5 }];
|
||||
state.services.data!.checked_at = new Date(Date.now() - 300_000).toISOString();
|
||||
expect(render("services")).toContain("Current service coverage unknown");
|
||||
expect(render("services")).toContain("Cached probe");
|
||||
expect(render("services")).toContain("Cached: passed");
|
||||
expect(render("services")).not.toContain("Probe passed");
|
||||
});
|
||||
|
||||
it("retains a cached failure even when the earlier probe supplied no error details", () => {
|
||||
state.services.data!.data = [{ name: "postgres", ok: true, latency_ms: 5 }, { name: "redis", ok: false, latency_ms: 20 }];
|
||||
state.services.isError = true;
|
||||
expect(render("services")).toContain("Cached: passed");
|
||||
expect(render("services")).toContain("Cached: failed");
|
||||
expect(render("services")).not.toContain("Probe passed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,8 +162,8 @@ function ComponentRow({ component: c, cached }: { component: SystemComponentStat
|
||||
<span className="text-xs text-muted-foreground tabular-nums" title="Latency">
|
||||
{c.latency_ms} ms
|
||||
</span>
|
||||
<StatusBadge tone={cached ? "neutral" : c.ok ? "success" : "danger"} className="w-[5.75rem] justify-center">
|
||||
{cached ? "Cached probe" : c.ok ? "Probe passed" : "Probe failed"}
|
||||
<StatusBadge tone={cached ? "neutral" : c.ok ? "success" : "danger"} className="min-w-[5.75rem] justify-center">
|
||||
{cached ? c.ok ? "Cached: passed" : "Cached: failed" : c.ok ? "Probe passed" : "Probe failed"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user