fix: keep session when refresh fails on network error or 5xx (WealthLedger/frontend#97)
This commit was merged in pull request #100.
This commit is contained in:
@@ -7,7 +7,12 @@ type AuthContextValue = {
|
||||
logout: () => Promise<void>;
|
||||
ready: boolean;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
retry: () => void;
|
||||
signedOut: boolean;
|
||||
/**
|
||||
Refresh failed with a network error or 5xx; the refresh cookie may still be valid.
|
||||
*/
|
||||
unavailable: boolean;
|
||||
user: undefined | User;
|
||||
};
|
||||
|
||||
|
||||
@@ -92,8 +92,10 @@ describe("AuthProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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()));
|
||||
it("settles unauthenticated and clears cached data when cold-start refresh is rejected with 401", async () => {
|
||||
server.use(http.post("*/api/v1/auth/refresh", () =>
|
||||
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||
));
|
||||
const queryClient = createTestQueryClient();
|
||||
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
||||
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
|
||||
@@ -103,12 +105,54 @@ describe("AuthProvider", () => {
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
|
||||
expect(result.current.user).toBeUndefined();
|
||||
expect(result.current.unavailable).toBe(false);
|
||||
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 () => {
|
||||
it.each([
|
||||
["a transport error", () => HttpResponse.error()],
|
||||
["a 502 from the proxy", () => new HttpResponse(null, { status: 502 })],
|
||||
["a 503 problem", () => HttpResponse.json({ code: "INTERNAL_SERVER_ERROR" }, { status: 503 })],
|
||||
])("settles as unavailable and keeps cached data when cold-start refresh gets %s", async (_name, failure) => {
|
||||
server.use(http.post("*/api/v1/auth/refresh", failure));
|
||||
const queryClient = createTestQueryClient();
|
||||
queryClient.setQueryData(["cached-query"], { value: "kept" });
|
||||
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(result.current.unavailable).toBe(true);
|
||||
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "kept" });
|
||||
});
|
||||
|
||||
it("restores the session when retry succeeds after an outage", async () => {
|
||||
let isOutage = true;
|
||||
server.use(http.post("*/api/v1/auth/refresh", () =>
|
||||
isOutage
|
||||
? new HttpResponse(null, { status: 502 })
|
||||
: HttpResponse.json({
|
||||
accessToken: "restored-token",
|
||||
user: { email: "user@example.com", id: "user-1" },
|
||||
}),
|
||||
));
|
||||
const queryClient = createTestQueryClient();
|
||||
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.unavailable).toBe(true));
|
||||
isOutage = false;
|
||||
act(() => result.current.retry());
|
||||
|
||||
await waitFor(() => expect(result.current.user?.id).toBe("user-1"));
|
||||
expect(result.current.unavailable).toBe(false);
|
||||
expect(await requestAuthorization()).toBe("Bearer restored-token");
|
||||
});
|
||||
|
||||
it("keeps the original protected 401 and the session when serialized refresh has a transport error", async () => {
|
||||
let refreshCalls = 0;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () => {
|
||||
@@ -137,10 +181,11 @@ describe("AuthProvider", () => {
|
||||
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();
|
||||
// An unreachable server does not prove the session ended; the next 401 refreshes again.
|
||||
await waitFor(() => expect(result.current.unavailable).toBe(true));
|
||||
expect(result.current.user?.id).toBe("user-1");
|
||||
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "stale" });
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "bank-card-1" }]);
|
||||
});
|
||||
|
||||
it("clears connection status and financial cache independently on logout", async () => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||
|
||||
import { type User } from "./auth-context";
|
||||
|
||||
// A 5xx or network error means the server is unreachable, not that the session ended.
|
||||
const HTTP_SERVER_ERROR = 500;
|
||||
|
||||
type Authentication
|
||||
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
@@ -32,6 +35,8 @@ function useAuthProviderVm() {
|
||||
const accessTokenRef = useRef<string | undefined>(undefined);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
const unavailableRef = useRef(false);
|
||||
|
||||
const generationRef = useRef(0);
|
||||
const userRef = useRef<undefined | User>(undefined);
|
||||
@@ -49,12 +54,18 @@ function useAuthProviderVm() {
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const markUnavailable = useCallback((isUnavailable: boolean) => {
|
||||
unavailableRef.current = isUnavailable;
|
||||
setUnavailable(isUnavailable);
|
||||
}, []);
|
||||
|
||||
const applyAuthentication = useCallback(
|
||||
(authentication: Authentication) => {
|
||||
const changedIdentity
|
||||
= userRef.current && userRef.current.id !== authentication.user.id;
|
||||
|
||||
setSignedOut(false);
|
||||
markUnavailable(false);
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
|
||||
if (changedIdentity) {
|
||||
@@ -67,16 +78,17 @@ function useAuthProviderVm() {
|
||||
userRef.current = authentication.user;
|
||||
setUser(authentication.user);
|
||||
},
|
||||
[queryClient],
|
||||
[markUnavailable, queryClient],
|
||||
);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
markUnavailable(false);
|
||||
generationRef.current += 1;
|
||||
userRef.current = undefined;
|
||||
setUser(undefined);
|
||||
accessTokenRef.current = undefined;
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
}, [markUnavailable, queryClient]);
|
||||
|
||||
const refresh = useCallback((): Promise<boolean> => {
|
||||
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
||||
@@ -86,11 +98,12 @@ function useAuthProviderVm() {
|
||||
if (started !== generationRef.current) return false;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
const { data, response } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
if (started !== generationRef.current) return false;
|
||||
|
||||
if (!data) {
|
||||
clearSession();
|
||||
if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true);
|
||||
else clearSession();
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -100,7 +113,7 @@ function useAuthProviderVm() {
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
if (started === generationRef.current) clearSession();
|
||||
if (started === generationRef.current) markUnavailable(true);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -112,7 +125,7 @@ function useAuthProviderVm() {
|
||||
refreshPromiseRef.current = pending;
|
||||
|
||||
return pending;
|
||||
}, [applyAuthentication, clearSession, serialize]);
|
||||
}, [applyAuthentication, clearSession, markUnavailable, serialize]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().finally(() => setReady(true));
|
||||
@@ -146,6 +159,7 @@ function useAuthProviderVm() {
|
||||
clear: clearSession,
|
||||
generation: () => generationRef.current,
|
||||
refresh,
|
||||
unavailable: () => unavailableRef.current,
|
||||
});
|
||||
});
|
||||
useEffect(() => () => configureAuthClient(), []);
|
||||
@@ -157,7 +171,9 @@ function useAuthProviderVm() {
|
||||
ready,
|
||||
register: (email: string, password: string) =>
|
||||
authenticate("/api/v1/auth/register", email, password),
|
||||
retry: () => void refresh(),
|
||||
signedOut,
|
||||
unavailable,
|
||||
user,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user