357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import type { FC, PropsWithChildren } from "react";
|
|
|
|
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { http, HttpResponse } from "msw";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
|
import { createTestQueryClient } from "@/test/query";
|
|
import { resource } from "@/test/resource";
|
|
import { server } from "@/test/server";
|
|
|
|
import { AuthProvider } from "./auth";
|
|
import { useAuth } from "./auth-context";
|
|
|
|
const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
|
|
params: { query: { includeArchived: "false" } },
|
|
}).queryKey;
|
|
|
|
function createAuthWrapper(
|
|
queryClient: ReturnType<typeof createTestQueryClient>,
|
|
): FC<PropsWithChildren> {
|
|
return props => (
|
|
<QueryClientProvider client={queryClient}>
|
|
<AuthProvider>{props.children}</AuthProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
async function expireSession() {
|
|
server.use(http.get("*/api/v1/brokerage-accounts", () =>
|
|
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
|
{ once: true }));
|
|
|
|
try {
|
|
await apiClient.GET("/api/v1/brokerage-accounts");
|
|
}
|
|
catch (error) {
|
|
// A refresh that changes identity cancels the original user's request.
|
|
if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
|
|
}
|
|
}
|
|
|
|
async function requestAuthorization() {
|
|
let authorization: null | string = null;
|
|
server.use(http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
|
authorization = request.headers.get("Authorization");
|
|
|
|
return HttpResponse.json(resource([]));
|
|
}, { once: true }));
|
|
await apiClient.GET("/api/v1/brokerage-accounts");
|
|
|
|
return authorization;
|
|
}
|
|
|
|
describe("AuthProvider", () => {
|
|
it("authorizes requests after restoring the initial session", async () => {
|
|
let authorization: null | string
|
|
|
|
= null;
|
|
|
|
server.use(
|
|
http.post("*/api/v1/auth/refresh", () =>
|
|
HttpResponse.json({
|
|
accessToken: "restored-token",
|
|
user: { email: "user@example.com", id: "user-1" },
|
|
}),
|
|
),
|
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
|
authorization = request.headers.get("Authorization");
|
|
|
|
return HttpResponse.json(resource([]));
|
|
}),
|
|
);
|
|
const queryClient = createTestQueryClient();
|
|
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
|
|
|
const { rerender, result } = 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> = createAuthWrapper(queryClient);
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
|
|
expect(result.current.user).toBeUndefined();
|
|
expect(await requestAuthorization()).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: { email: "user@example.com", id: "user-1" },
|
|
})
|
|
: 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> = createAuthWrapper(queryClient);
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
await waitFor(() =>
|
|
expect(result.current.ready).toBe(true),
|
|
);
|
|
const response = await apiClient.GET("/api/v1/brokerage-accounts");
|
|
|
|
expect(response.response.status).toBe(401);
|
|
await waitFor(() => expect(result.current.user).toBeUndefined());
|
|
expect(await requestAuthorization()).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: { email: "user@example.com", id: "user-1" },
|
|
}),
|
|
),
|
|
);
|
|
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> = createAuthWrapper(queryClient);
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
|
|
expect(queryClient.getQueryData(bankCardsKey)).toEqual([
|
|
{ id: "bank-card-1" },
|
|
]);
|
|
await act(() => result.current.logout());
|
|
|
|
expect(result.current.user).toBeUndefined();
|
|
expect(await requestAuthorization()).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) => {
|
|
const { promise: pending, resolve: release }
|
|
= Promise.withResolvers<void>();
|
|
|
|
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: { email: "a@example.com", id: "a" },
|
|
});
|
|
}),
|
|
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: { email: "b@example.com", id: "b" },
|
|
});
|
|
}),
|
|
http.get("*/api/v1/brokerage-accounts", () =>
|
|
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
|
),
|
|
);
|
|
const queryClient = createTestQueryClient();
|
|
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
|
|
const oldRequest = apiClient
|
|
.GET("/api/v1/brokerage-accounts")
|
|
|
|
// eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query.
|
|
.catch(() => {});
|
|
|
|
await waitFor(() => expect(order).toContain("refresh-start"));
|
|
let logout!: Promise<void>;
|
|
act(() => {
|
|
logout = result.current.logout();
|
|
});
|
|
await waitFor(() => expect(result.current.user).toBeUndefined());
|
|
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: { email: `${identity}@example.com`, id: identity },
|
|
}),
|
|
),
|
|
);
|
|
const queryClient = createTestQueryClient();
|
|
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
|
queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
|
|
await act(() => expireSession());
|
|
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
|
|
let release!: (value: string) => void;
|
|
|
|
const pending = queryClient
|
|
.query({
|
|
queryFn: () => {
|
|
const deferred = Promise.withResolvers<string>();
|
|
release = deferred.resolve;
|
|
|
|
return deferred.promise;
|
|
},
|
|
queryKey: ["pending-finance"],
|
|
})
|
|
// eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query.
|
|
.catch(() => {});
|
|
|
|
identity = "b";
|
|
await act(() => expireSession());
|
|
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: { email: `${identity}@example.com`, id: identity },
|
|
}),
|
|
),
|
|
http.get("*/api/v1/bank-cards", () =>
|
|
HttpResponse.json([{ id: `${identity}-card` }]),
|
|
),
|
|
);
|
|
const queryClient = createTestQueryClient();
|
|
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
|
|
|
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(() => expireSession());
|
|
await waitFor(() =>
|
|
expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
|
|
);
|
|
});
|
|
});
|