Files
frontend/src/features/auth/model/auth.test.tsx
T
ksv741 edd54892cd fix(auth): isolate session races and financial caches
TD-001 and TD-002: serialize cookie writes, ignore stale responses and reset financial queries on identity changes. Backend cookie lifecycle regression: ec5780d.
2026-09-21 21:09:23 +03:00

331 lines
11 KiB
TypeScript

import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { describe, expect, it } from "vitest";
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AuthProvider, useAuth } from "./auth";
const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: { query: { includeArchived: "false" } },
}).queryKey;
describe("AuthProvider", () => {
it("authorizes requests after restoring the initial session", async () => {
let authorization: string | null = null;
server.use(
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "restored-token",
user: { id: "user-1", email: "user@example.com" },
}),
),
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json([]);
}),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const { result, rerender } = renderHook(
() => ({ ...useAuth(), queryClient: useQueryClient() }),
{ wrapper },
);
await waitFor(() => expect(result.current.ready).toBe(true));
await apiClient.GET("/api/v1/brokerage-accounts");
expect(authorization).toBe("Bearer restored-token");
queryClient.setQueryData(["rerender-proof"], { value: "preserved" });
rerender();
expect(result.current.queryClient).toBe(queryClient);
expect(result.current.queryClient.getQueryData(["rerender-proof"])).toEqual(
{ value: "preserved" },
);
});
it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => {
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeNull();
expect(result.current.accessToken).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
});
it("keeps the original protected 401 when serialized refresh has a transport error", async () => {
let refreshCalls = 0;
server.use(
http.post("*/api/v1/auth/refresh", () => {
refreshCalls += 1;
return refreshCalls === 1
? HttpResponse.json({
accessToken: "restored-token",
user: { id: "user-1", email: "user@example.com" },
})
: HttpResponse.error();
}),
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
),
);
const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() =>
expect(result.current.accessToken).toBe("restored-token"),
);
const response = await apiClient.GET("/api/v1/brokerage-accounts");
expect(response.response.status).toBe(401);
await waitFor(() => expect(result.current.user).toBeNull());
expect(result.current.accessToken).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
});
it("clears connection status and financial cache independently on logout", async () => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" },
}),
),
);
const queryClient = createTestQueryClient();
const statusKey = apiQueryClient.queryOptions(
"get",
"/api/v1/tbank-connection",
).queryKey;
const portfolioKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
queryClient.setQueryData(statusKey, {
connected: true,
connectedAt: "2026-08-14T00:00:00Z",
});
queryClient.setQueryData(portfolioKey, { source: "current-connection" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.accessToken).toBe("test-token"));
expect(queryClient.getQueryData(bankCardsKey)).toEqual([
{ id: "bank-card-1" },
]);
await act(() => result.current.logout());
expect(result.current.user).toBeNull();
expect(result.current.accessToken).toBeNull();
expect(queryClient.getQueryData(statusKey)).toBeUndefined();
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
});
it.each([
[false, false],
[true, false],
[false, true],
[true, true],
])(
"serializes late refresh (failure=%s, login=%s)",
async (failure, loginAfterLogout) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let calls = 0;
const order: string[] = [];
server.use(
http.post("*/api/v1/auth/refresh", async () => {
if (++calls > 1) {
order.push("refresh-start");
await pending;
order.push("refresh-cookie");
}
return failure && calls > 1
? HttpResponse.error()
: HttpResponse.json({
accessToken: "a",
user: { id: "a", email: "a@example.com" },
});
}),
http.post("*/api/v1/auth/logout", () => {
order.push("logout-cookie");
return new HttpResponse(null, { status: 204 });
}),
http.post("*/api/v1/auth/login", () => {
order.push("login-cookie");
return HttpResponse.json({
accessToken: "b",
user: { id: "b", email: "b@example.com" },
});
}),
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true));
const oldRequest = apiClient
.GET("/api/v1/brokerage-accounts")
.catch(() => null);
await waitFor(() => expect(order).toContain("refresh-start"));
let logout!: Promise<void>;
act(() => {
logout = result.current.logout();
});
await waitFor(() => expect(result.current.user).toBeNull());
let login!: Promise<void>;
act(() => {
login = loginAfterLogout
? result.current.login("b@example.com", "password")
: Promise.resolve();
});
await act(async () => {
release();
await Promise.all([oldRequest, logout, login]);
});
expect(result.current.user?.id ?? null).toBe(
loginAfterLogout ? "b" : null,
);
expect(order).toEqual([
"refresh-start",
"refresh-cookie",
"logout-cookie",
...(loginAfterLogout ? ["login-cookie"] : []),
]);
},
);
it("preserves same-user cache and removes inactive and pending data on refresh identity change", async () => {
let identity = "a";
server.use(
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: identity,
user: { id: identity, email: `${identity}@example.com` },
}),
),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true));
queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
await act(() => result.current.refresh());
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
let release!: (value: string) => void;
const pending = queryClient
.fetchQuery({
queryKey: ["pending-finance"],
queryFn: () =>
new Promise<string>((resolve) => {
release = resolve;
}),
})
.catch(() => null);
identity = "b";
await act(() => result.current.refresh());
release("a-data");
await pending;
expect(result.current.user?.id).toBe("b");
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
expect(queryClient.getQueryData(["pending-finance"])).toBeUndefined();
});
it("replaces an observed financial query when refresh changes identity", async () => {
let identity = "a";
server.use(
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: identity,
user: { id: identity, email: `${identity}@example.com` },
}),
),
http.get("*/api/v1/bank-cards", () =>
HttpResponse.json([{ id: `${identity}-card` }]),
),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = (props) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{props.children}</AuthProvider>
</QueryClientProvider>
);
const { result } = renderHook(
() => ({
auth: useAuth(),
cards: useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: { query: { includeArchived: "false" } },
}),
),
}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("a-card"),
);
identity = "b";
await act(() => result.current.auth.refresh());
await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("b-card"),
);
});
});