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.
This commit is contained in:
@@ -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<AuthApi>;
|
||||
storage?: Partial<IAuthStorage>;
|
||||
userStorage?: Partial<IUserStorage>;
|
||||
} = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Result<RefreshTokenResponse>> {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user