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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user