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; });