From 225d232763897cb6d5909ae1fc727b30c34d4d81 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 13 Jul 2026 17:23:54 +0800 Subject: [PATCH] fix(auth): handle logout and refresh results Propagate storage Result failures through logout, guest restoration, token refresh, and user state actors. Clear invalid sessions after failed automatic refreshes and cover both business and guest flows. --- .../interceptor/auth_refresh_interceptor.ts | 119 ++++++++------ .../__tests__/auth_repository.session.test.ts | 152 ++++++++++++++++++ src/data/repositories/auth_repository.ts | 83 ++++++---- .../api/__tests__/http_client.test.ts | 120 +++++++++++++- src/stores/user/user-machine.actors.ts | 3 +- 5 files changed, 388 insertions(+), 89 deletions(-) create mode 100644 src/data/repositories/__tests__/auth_repository.session.test.ts diff --git a/src/core/net/interceptor/auth_refresh_interceptor.ts b/src/core/net/interceptor/auth_refresh_interceptor.ts index aa9cb290..1a70dad8 100644 --- a/src/core/net/interceptor/auth_refresh_interceptor.ts +++ b/src/core/net/interceptor/auth_refresh_interceptor.ts @@ -32,19 +32,17 @@ let isRefreshing = false; let refreshPromise: Promise | null = null; /** - * 获取当前可用的 auth token(登录 token 优先,其次游客 token) + * 读取当前刷新模式对应的 token,不跨登录态回退。 */ -async function getAuthTokenAsync(): Promise { +async function getAuthTokenAsync(isGuestMode: boolean): Promise { const storage = AuthStorage.getInstance(); - const loginResult = await storage.getLoginToken(); - if (Result.isOk(loginResult) && loginResult.data && loginResult.data.length > 0) { - return loginResult.data; - } - const guestResult = await storage.getGuestToken(); - if (Result.isOk(guestResult) && guestResult.data && guestResult.data.length > 0) { - return guestResult.data; - } - return null; + const tokenResult = isGuestMode + ? await storage.getGuestToken() + : await storage.getLoginToken(); + if (Result.isErr(tokenResult)) throw tokenResult.error; + return tokenResult.data && tokenResult.data.length > 0 + ? tokenResult.data + : null; } /** @@ -67,7 +65,8 @@ async function refreshToken( async function refreshLoginToken(baseUrl: string): Promise { const storage = AuthStorage.getInstance(); const refreshResult = await storage.getRefreshToken(); - if (!Result.isOk(refreshResult) || !refreshResult.data) { + if (Result.isErr(refreshResult)) throw refreshResult.error; + if (!refreshResult.data) { throw new ApiError( "HTTP_UNAUTHORIZED", "No refresh token available", @@ -95,12 +94,18 @@ async function refreshLoginToken(baseUrl: string): Promise { data?: { token?: string; refreshToken?: string }; }; - if (json.success && json.data) { - if (json.data.token) await storage.setLoginToken(json.data.token); - if (json.data.refreshToken) await storage.setRefreshToken(json.data.refreshToken); - } else { + if (!json.success || !json.data?.token) { throw new ApiError(ErrorCode.parseError, "Refresh response invalid"); } + + const tokenResult = await storage.setLoginToken(json.data.token); + if (Result.isErr(tokenResult)) throw tokenResult.error; + if (json.data.refreshToken) { + const refreshTokenResult = await storage.setRefreshToken( + json.data.refreshToken, + ); + if (Result.isErr(refreshTokenResult)) throw refreshTokenResult.error; + } } /** @@ -130,12 +135,16 @@ async function refreshGuestToken(baseUrl: string): Promise { data?: { token?: string; deviceId?: string }; }; - if (json.success && json.data) { - if (json.data.token) await storage.setGuestToken(json.data.token); - if (json.data.deviceId) await storage.setDeviceId(json.data.deviceId); - } else { + if (!json.success || !json.data?.token) { throw new ApiError(ErrorCode.parseError, "Guest refresh response invalid"); } + + const tokenResult = await storage.setGuestToken(json.data.token); + if (Result.isErr(tokenResult)) throw tokenResult.error; + if (json.data.deviceId) { + const deviceResult = await storage.setDeviceId(json.data.deviceId); + if (Result.isErr(deviceResult)) throw deviceResult.error; + } } function apiCodeForStatus(status: number): string { @@ -179,41 +188,45 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => { // 已登录则刷新登录 token,否则刷新游客 token const hasLoginTokenResult = await AuthStorage.getInstance().hasLoginToken(); - const isGuestMode = - !Result.isOk(hasLoginTokenResult) || !hasLoginTokenResult.data; + if (Result.isErr(hasLoginTokenResult)) throw hasLoginTokenResult.error; + const isGuestMode = !hasLoginTokenResult.data; - // 并发刷新:等待或启动 - if (isRefreshing && refreshPromise) { - await refreshPromise; - } else { - isRefreshing = true; - const baseUrl = new URL(requestUrl).origin; - refreshPromise = (async () => { - try { - await refreshToken(isGuestMode, baseUrl); - } finally { - isRefreshing = false; - refreshPromise = null; - } - })(); - await refreshPromise; + try { + // 并发刷新:等待或启动 + if (isRefreshing && refreshPromise) { + await refreshPromise; + } else { + isRefreshing = true; + const baseUrl = new URL(requestUrl).origin; + refreshPromise = (async () => { + try { + await refreshToken(isGuestMode, baseUrl); + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + await refreshPromise; + } + + const newToken = await getAuthTokenAsync(isGuestMode); + if (!newToken) { + throw new ApiError( + ErrorCode.unknownError, + "Token refresh failed", + status, + ); + } + + // 刷新成功:更新当前 options;retry 时 tokenInterceptor 也会重新读取最新 token。 + const headers = new Headers(ctx.options.headers); + headers.set("Authorization", `Bearer ${newToken}`); + ctx.options.headers = headers; + } catch (error) { + const clearResult = await AuthStorage.getInstance().clearAuthData(); + if (Result.isErr(clearResult)) throw clearResult.error; + throw error; } - - // 刷新失败 → 清除数据并抛出 - const newToken = await getAuthTokenAsync(); - if (!newToken) { - AuthStorage.getInstance().clearAuthData(); - throw new ApiError( - ErrorCode.unknownError, - "Token refresh failed", - status - ); - } - - // 刷新成功:更新当前 options;retry 时 tokenInterceptor 也会重新读取最新 token。 - const headers = new Headers(ctx.options.headers); - headers.set("Authorization", `Bearer ${newToken}`); - ctx.options.headers = headers; }; /** diff --git a/src/data/repositories/__tests__/auth_repository.session.test.ts b/src/data/repositories/__tests__/auth_repository.session.test.ts new file mode 100644 index 00000000..6e7923b7 --- /dev/null +++ b/src/data/repositories/__tests__/auth_repository.session.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + GuestLoginResponse, + RefreshTokenResponse, +} from "@/data/dto/auth"; +import { AuthRepository } from "@/data/repositories/auth_repository"; +import type { AuthApi } from "@/data/services/api"; +import type { IAuthStorage } from "@/data/storage/auth"; +import type { IUserStorage } from "@/data/storage/user"; +import { Result } from "@/utils"; + +function createRepository(input: { + api?: Partial; + storage?: Partial; + userStorage?: Partial; +} = {}) { + const storage = { + getRefreshToken: vi.fn(async () => Result.ok("refresh-token")), + setLoginToken: vi.fn(async () => Result.ok(undefined)), + setRefreshToken: vi.fn(async () => Result.ok(undefined)), + setGuestToken: vi.fn(async () => Result.ok(undefined)), + setLoginProvider: vi.fn(async () => Result.ok(undefined)), + setDeviceId: vi.fn(async () => Result.ok(undefined)), + clearBusinessAuthData: vi.fn(async () => Result.ok(undefined)), + hasGuestToken: vi.fn(async () => Result.ok(false)), + ...input.storage, + } as unknown as IAuthStorage; + const userStorage = { + setUserId: vi.fn(async () => Result.ok(undefined)), + clearUserData: vi.fn(async () => Result.ok(undefined)), + ...input.userStorage, + } as unknown as IUserStorage; + const api = { + logout: vi.fn(async () => undefined), + ...input.api, + } as AuthApi; + + return { + repository: new AuthRepository(api, storage, userStorage), + api, + storage, + userStorage, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("AuthRepository session Result handling", () => { + it("returns a failed auth cleanup Result while still attempting user cleanup", async () => { + const clearError = new Error("auth cleanup failed"); + const { repository, userStorage } = createRepository({ + storage: { + clearBusinessAuthData: vi.fn(async () => Result.err(clearError)), + }, + }); + + const result = await repository.logout(); + + expect(Result.isErr(result)).toBe(true); + expect(Result.isErr(result) && result.error.message).toBe( + clearError.message, + ); + expect(userStorage.clearUserData).toHaveBeenCalledOnce(); + }); + + it("returns a failed user cleanup Result from logout", async () => { + const clearError = new Error("user cleanup failed"); + const { repository } = createRepository({ + userStorage: { + clearUserData: vi.fn(async () => Result.err(clearError)), + }, + }); + + const result = await repository.logout(); + + expect(Result.isErr(result)).toBe(true); + expect(Result.isErr(result) && result.error.message).toBe( + clearError.message, + ); + }); + + it("propagates refresh-token storage read failures", async () => { + const storageError = new Error("refresh token read failed"); + const refreshToken = vi.fn(); + const { repository } = createRepository({ + api: { refreshToken }, + storage: { + getRefreshToken: vi.fn(async () => Result.err(storageError)), + }, + }); + + const result = await repository.refreshToken(); + + expect(Result.isErr(result)).toBe(true); + expect(Result.isErr(result) && result.error.message).toBe( + storageError.message, + ); + expect(refreshToken).not.toHaveBeenCalled(); + }); + + it("does not report refresh success when a new token cannot be stored", async () => { + const storageError = new Error("login token write failed"); + const response = RefreshTokenResponse.from({ + token: "new-login-token", + refreshToken: "new-refresh-token", + userId: "user-1", + }); + const { repository, storage } = createRepository({ + api: { refreshToken: vi.fn(async () => response) }, + storage: { + setLoginToken: vi.fn(async () => Result.err(storageError)), + }, + }); + + const result = await repository.refreshToken(); + + expect(Result.isErr(result)).toBe(true); + expect(Result.isErr(result) && result.error.message).toBe( + storageError.message, + ); + expect(storage.setRefreshToken).not.toHaveBeenCalled(); + }); + + it("does not report guest login success when session persistence fails", async () => { + const storageError = new Error("guest token write failed"); + const response = GuestLoginResponse.from({ + token: "guest-token", + deviceId: "device-1", + userId: "guest-1", + isDeviceUser: true, + expiresIn: 3600, + user: null, + }); + const { repository, storage } = createRepository({ + api: { guestLogin: vi.fn(async () => response) }, + storage: { + setGuestToken: vi.fn(async () => Result.err(storageError)), + }, + }); + + const result = await repository.guestLogin("device-1"); + + expect(Result.isErr(result)).toBe(true); + expect(Result.isErr(result) && result.error.message).toBe( + storageError.message, + ); + expect(storage.setLoginProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index 41e9cbfc..c443db71 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -112,30 +112,37 @@ export class AuthRepository implements IAuthRepository { } try { - await this.storage.clearBusinessAuthData(); - await this.userStorage.clearUserData(); + const authClearResult = await this.storage.clearBusinessAuthData(); + const userClearResult = await this.userStorage.clearUserData(); + if (Result.isErr(authClearResult)) return authClearResult; + if (Result.isErr(userClearResult)) return userClearResult; } catch (e) { return Result.err(e); } - const deviceId = await deviceIdentifier.getDeviceId(); - const guestResult = await this.guestLogin(deviceId); - if (Result.isOk(guestResult)) return Result.ok(undefined); + try { + const deviceId = await deviceIdentifier.getDeviceId(); + const guestResult = await this.guestLogin(deviceId); + if (Result.isOk(guestResult)) return Result.ok(undefined); - const hasGuestToken = await this.storage.hasGuestToken(); - if (hasGuestToken.success && hasGuestToken.data) { - const providerResult = await this.storage.setLoginProvider( - LoginStatus.Guest, - ); - if (Result.isErr(providerResult)) return providerResult; - log.warn( - "[AuthRepository] guest login failed after logout, falling back to local guest token", - guestResult.error, - ); - return Result.ok(undefined); + const hasGuestToken = await this.storage.hasGuestToken(); + if (Result.isErr(hasGuestToken)) return hasGuestToken; + if (hasGuestToken.data) { + const providerResult = await this.storage.setLoginProvider( + LoginStatus.Guest, + ); + if (Result.isErr(providerResult)) return providerResult; + log.warn( + "[AuthRepository] guest login failed after logout, falling back to local guest token", + guestResult.error, + ); + return Result.ok(undefined); + } + + return Result.err(guestResult.error); + } catch (e) { + return Result.err(e); } - - return Result.err(guestResult.error); } /** @@ -156,11 +163,17 @@ export class AuthRepository implements IAuthRepository { hasUser: !!response.user, userLastMessageAt: response.user?.lastMessageAt, }); - await this.storage.setGuestToken(response.token); - await this.storage.setLoginProvider(LoginStatus.Guest); - await this.storage.setDeviceId(deviceId); + const tokenResult = await this.storage.setGuestToken(response.token); + if (Result.isErr(tokenResult)) throw tokenResult.error; + const providerResult = await this.storage.setLoginProvider( + LoginStatus.Guest, + ); + if (Result.isErr(providerResult)) throw providerResult.error; + const deviceResult = await this.storage.setDeviceId(deviceId); + if (Result.isErr(deviceResult)) throw deviceResult.error; if (response.userId) { - await this.userStorage.setUserId(response.userId); + const userIdResult = await this.userStorage.setUserId(response.userId); + if (Result.isErr(userIdResult)) throw userIdResult.error; } return response; }).catch((e) => { @@ -304,24 +317,26 @@ export class AuthRepository implements IAuthRepository { * 调用 API 成功后写回新的 login token + refresh token。 */ async refreshToken(): Promise> { - const existing = await this.storage.getRefreshToken(); - if (!existing.success || !existing.data) { - return Result.err( - new ApiError( + return Result.wrap(async () => { + const existing = await this.storage.getRefreshToken(); + if (Result.isErr(existing)) throw existing.error; + if (!existing.data) { + throw new ApiError( "HTTP_UNAUTHORIZED", "No refresh token available", ErrorCode.httpUnauthorized, - ), - ); - } - const refreshToken = existing.data; - return Result.wrap(async () => { + ); + } const response = await this.api.refreshToken( - RefreshTokenRequest.from({ refreshToken }), + RefreshTokenRequest.from({ refreshToken: existing.data }), ); - await this.storage.setLoginToken(response.token); + const tokenResult = await this.storage.setLoginToken(response.token); + if (Result.isErr(tokenResult)) throw tokenResult.error; if (response.refreshToken) { - await this.storage.setRefreshToken(response.refreshToken); + const refreshTokenResult = await this.storage.setRefreshToken( + response.refreshToken, + ); + if (Result.isErr(refreshTokenResult)) throw refreshTokenResult.error; } return response; }); diff --git a/src/data/services/api/__tests__/http_client.test.ts b/src/data/services/api/__tests__/http_client.test.ts index 7a95d580..c71e7920 100644 --- a/src/data/services/api/__tests__/http_client.test.ts +++ b/src/data/services/api/__tests__/http_client.test.ts @@ -3,7 +3,7 @@ import { createStorage } from "unstorage"; import memoryDriver from "unstorage/drivers/memory"; import { AuthStorage } from "@/data/storage/auth"; -import { SpAsyncUtil } from "@/utils"; +import { deviceIdentifier, Result, SpAsyncUtil } from "@/utils"; import { ErrorCode } from "../api_result"; import { createHttpClient } from "../http_client"; @@ -29,6 +29,7 @@ describe("createHttpClient", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it("refreshes a business token and replays the original request once after 401", async () => { @@ -143,5 +144,122 @@ describe("createHttpClient", () => { "/api/chat/history", "/api/auth/refresh", ]); + await expect(authStorage.getLoginToken()).resolves.toEqual(Result.ok(null)); + await expect(authStorage.getRefreshToken()).resolves.toEqual(Result.ok(null)); + }); + + it("rejects instead of replaying when the refreshed token cannot be stored", async () => { + const authStorage = AuthStorage.getInstance(); + await authStorage.setLoginToken("old-login-token"); + await authStorage.setRefreshToken("refresh-token"); + vi.spyOn(authStorage, "setLoginToken").mockResolvedValue( + Result.err(new Error("login token write failed")), + ); + + const fetchMock = vi.fn(async (input) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname === "/api/auth/refresh") { + return jsonResponse({ + success: true, + data: { + token: "new-login-token", + refreshToken: "new-refresh-token", + }, + }); + } + return jsonResponse( + { message: "expired" }, + { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const http = createHttpClient(); + await expect(http("/api/chat/history")).rejects.toMatchObject({ + message: "login token write failed", + }); + + expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([ + "/api/chat/history", + "/api/auth/refresh", + ]); + }); + + it("propagates refresh-token read failures instead of treating them as missing", async () => { + const authStorage = AuthStorage.getInstance(); + await authStorage.setLoginToken("old-login-token"); + vi.spyOn(authStorage, "getRefreshToken").mockResolvedValue( + Result.err(new Error("refresh token read failed")), + ); + const fetchMock = vi.fn(async () => + jsonResponse( + { message: "expired" }, + { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const http = createHttpClient(); + await expect(http("/api/chat/history")).rejects.toMatchObject({ + message: "refresh token read failed", + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("propagates login-state read failures without attempting a guest refresh", async () => { + const authStorage = AuthStorage.getInstance(); + await authStorage.setLoginToken("old-login-token"); + vi.spyOn(authStorage, "hasLoginToken").mockResolvedValue( + Result.err(new Error("login state read failed")), + ); + const fetchMock = vi.fn(async () => + jsonResponse( + { message: "expired" }, + { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const http = createHttpClient(); + await expect(http("/api/chat/history")).rejects.toMatchObject({ + message: "login state read failed", + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects a guest refresh when the new guest token cannot be stored", async () => { + const authStorage = AuthStorage.getInstance(); + await authStorage.setGuestToken("old-guest-token"); + vi.spyOn(deviceIdentifier, "getDeviceId").mockResolvedValue("device-1"); + vi.spyOn(authStorage, "setGuestToken").mockResolvedValue( + Result.err(new Error("guest token write failed")), + ); + const fetchMock = vi.fn(async (input) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname === "/api/auth/guest") { + return jsonResponse({ + success: true, + data: { token: "new-guest-token", deviceId: "device-1" }, + }); + } + return jsonResponse( + { message: "expired" }, + { status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const http = createHttpClient(); + await expect(http("/api/chat/history")).rejects.toMatchObject({ + message: "guest token write failed", + }); + + expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([ + "/api/chat/history", + "/api/auth/guest", + ]); + await expect(authStorage.getGuestToken()).resolves.toEqual(Result.ok(null)); }); }); diff --git a/src/stores/user/user-machine.actors.ts b/src/stores/user/user-machine.actors.ts index 4db5a5ca..3655a983 100644 --- a/src/stores/user/user-machine.actors.ts +++ b/src/stores/user/user-machine.actors.ts @@ -87,5 +87,6 @@ export const userFetchActor = fromPromise(async () => { * - onDone / onError 都跑 `clearUser` action(machine 层)—— 失败也清本地 */ export const userLogoutActor = fromPromise(async () => { - await getAuthRepository().logout(); + const result = await getAuthRepository().logout(); + if (Result.isErr(result)) throw result.error; });