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.
This commit is contained in:
@@ -3,7 +3,7 @@ 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 } from "@/shared/api";
|
||||
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { AuthProvider, useAuth } from "./auth";
|
||||
@@ -167,4 +167,164 @@ describe("AuthProvider", () => {
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user