mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(auth): keep the page when sign-out is refused
The sign-out mutation took every HTTP answer as success. A refused logout (403 from the demo guard, a 429, a 5xx) wiped the client caches and showed the sign-in page while the session cookie stayed valid, so the record pages opened again on the next visit. The call now goes through apiFetch, which throws on a non-2xx answer, and the error toast says the person is still signed in instead of naming a network error.
This commit is contained in:
1 parent
31f296a03e
commit
6d8f533deb
13 files changed
+123
-7
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2890,
|
||||
"total": 9216,
|
||||
"total": 9215,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -1474,7 +1474,7 @@
|
||||
"hooks/use-account-switch.ts": 2,
|
||||
"hooks/use-ai-capability.ts": 3,
|
||||
"hooks/use-ai-run.ts": 1,
|
||||
"hooks/use-auth.ts": 34,
|
||||
"hooks/use-auth.ts": 33,
|
||||
"hooks/use-chart-domain-stats.ts": 1,
|
||||
"hooks/use-chart-overlay-prefs.ts": 2,
|
||||
"hooks/use-coach-message-results.ts": 1,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Signing out: what the browser is left with when the server answers.
|
||||
*
|
||||
* `useMutation` is replaced by a stand-in that hands back the options, so the
|
||||
* test drives `mutationFn`, `onSuccess` and `onError` the way React Query
|
||||
* does, against the real fetch wrapper and a stubbed `fetch`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface LogoutOptions {
|
||||
mutationFn: () => Promise<void>;
|
||||
onSuccess: () => void;
|
||||
onError: (failure: unknown) => void;
|
||||
}
|
||||
|
||||
const captured: { options: LogoutOptions | null } = { options: null };
|
||||
const push = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const showErrorToast = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: () => ({ clear }),
|
||||
useMutation: (options: LogoutOptions) => {
|
||||
captured.options = options;
|
||||
return { mutate: vi.fn(), isPending: false };
|
||||
},
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push, replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/lib/errors/error-toast", () => ({
|
||||
showErrorToast: (...args: unknown[]) => showErrorToast(...args),
|
||||
}));
|
||||
vi.mock("@/lib/pwa/query-persister", () => ({
|
||||
clearOfflineCachesForSessionEnd: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { useLogout } from "../use-auth";
|
||||
|
||||
const fetchMock =
|
||||
vi.fn<(input: string, init?: RequestInit) => Promise<Response>>();
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** What React Query does with the options: run, then settle one way. */
|
||||
async function settle(options: LogoutOptions): Promise<void> {
|
||||
try {
|
||||
await options.mutationFn();
|
||||
} catch (failure) {
|
||||
options.onError(failure);
|
||||
return;
|
||||
}
|
||||
options.onSuccess();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
captured.options = null;
|
||||
});
|
||||
|
||||
describe("useLogout", () => {
|
||||
it("clears the caches and opens the sign-in page once the server signed out", async () => {
|
||||
fetchMock.mockResolvedValue(json(200, { data: { loggedOut: true } }));
|
||||
|
||||
useLogout();
|
||||
await settle(captured.options!);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/auth/logout",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(clear).toHaveBeenCalledTimes(1);
|
||||
expect(push).toHaveBeenCalledWith("/auth/login");
|
||||
expect(showErrorToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays on the page and says so when the server refuses", async () => {
|
||||
// The demo instance answered 403 here; the page showed the sign-in form
|
||||
// while the session cookie still opened every record page.
|
||||
fetchMock.mockResolvedValue(json(403, { data: null, error: "Forbidden" }));
|
||||
|
||||
useLogout();
|
||||
await settle(captured.options!);
|
||||
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(showErrorToast).toHaveBeenCalledWith(
|
||||
"auth.signOutFailed",
|
||||
expect.objectContaining({ cause: expect.anything() }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useRouter } from "next/navigation";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch";
|
||||
import { apiGet, apiFetch, ApiError } from "@/lib/api/api-fetch";
|
||||
import { adoptRecordFenceState } from "@/lib/api/record-fence";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
@@ -649,15 +649,15 @@ export function useLogout() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
// Throws on any non-2xx answer: a refused sign-out leaves the session
|
||||
// alive, and showing the sign-in page then would claim otherwise.
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
router.push("/auth/login");
|
||||
},
|
||||
// v1.16.4 — a network-failed logout used to do nothing at all (the
|
||||
// menu closed, the session stayed); a toast names the failure.
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("common.networkError"), { cause: failure }),
|
||||
showErrorToast(t("auth.signOutFailed"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user