fix(runtime): enforce session and environment contracts

This commit is contained in:
2026-07-16 19:56:15 +08:00
parent acabf165cd
commit cc06ed034d
7 changed files with 444 additions and 203 deletions
@@ -112,4 +112,27 @@ describe("AuthRepository facebook identity", () => {
expect(storage.setDeviceId).toHaveBeenCalledWith("device-1");
expect(userStorage.setUserId).toHaveBeenCalledWith("guest-1");
});
it("does not report guest psid login success when session persistence fails", async () => {
const storageError = new Error("guest token write failed");
const facebookPsidLogin = vi.fn(async () =>
FacebookPsidLoginResponse.fromJson({
token: "guest-token",
matchedBy: "psid_incomplete",
fbPsid: "psid-1",
hasCompleteFacebookIdentity: false,
isGuest: true,
userId: "guest-1",
}),
);
const { repository, storage } = createRepository({ facebookPsidLogin });
vi.mocked(storage.setGuestToken).mockResolvedValueOnce(
Result.err(storageError),
);
const result = await repository.facebookPsidLogin({ psid: "psid-1" });
expect(Result.isErr(result)).toBe(true);
expect(storage.setLoginProvider).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
GuestLoginResponse,
LoginResponse,
LoginStatus,
RefreshTokenResponse,
} from "@/data/dto/auth";
import { AuthRepository } from "@/data/repositories/auth_repository";
@@ -27,6 +29,7 @@ function createRepository(input: {
...input.storage,
} as unknown as IAuthStorage;
const userStorage = {
setUser: vi.fn(async () => Result.ok(undefined)),
setUserId: vi.fn(async () => Result.ok(undefined)),
clearUserData: vi.fn(async () => Result.ok(undefined)),
...input.userStorage,
@@ -49,6 +52,12 @@ afterEach(() => {
});
describe("AuthRepository session Result handling", () => {
const loginResponse = LoginResponse.from({
token: "login-token",
refreshToken: "refresh-token",
user: { id: "user-1", username: "Elio" },
});
it("returns a failed auth cleanup Result while still attempting user cleanup", async () => {
const clearError = new Error("auth cleanup failed");
const { repository, userStorage } = createRepository({
@@ -149,4 +158,83 @@ describe("AuthRepository session Result handling", () => {
);
expect(storage.setLoginProvider).not.toHaveBeenCalled();
});
it("does not report email login success when the login token cannot be stored", async () => {
const storageError = new Error("login token write failed");
const { repository, storage, userStorage } = createRepository({
api: { emailLogin: vi.fn(async () => loginResponse) },
storage: {
setLoginToken: vi.fn(async () => Result.err(storageError)),
},
});
const result = await repository.emailLogin({
email: "elio@example.test",
password: "password",
});
expect(Result.isErr(result)).toBe(true);
expect(Result.isErr(result) && result.error.message).toBe(
storageError.message,
);
expect(storage.setRefreshToken).not.toHaveBeenCalled();
expect(userStorage.setUserId).not.toHaveBeenCalled();
expect(storage.setLoginProvider).not.toHaveBeenCalled();
});
it("writes the provider only after all other session identity fields", async () => {
const storageError = new Error("user id write failed");
const { repository, storage, userStorage } = createRepository({
api: { emailLogin: vi.fn(async () => loginResponse) },
userStorage: {
setUserId: vi.fn(async () => Result.err(storageError)),
},
});
const result = await repository.emailLogin({
email: "elio@example.test",
password: "password",
});
expect(Result.isErr(result)).toBe(true);
expect(storage.setLoginToken).toHaveBeenCalledWith("login-token");
expect(storage.setRefreshToken).toHaveBeenCalledWith("refresh-token");
expect(userStorage.setUserId).toHaveBeenCalledWith("user-1");
expect(storage.setLoginProvider).not.toHaveBeenCalled();
expect(userStorage.setUser).not.toHaveBeenCalled();
});
it("keeps login successful when the non-critical user cache fails", async () => {
const cacheError = new Error("user cache write failed");
const { repository, storage, userStorage } = createRepository({
api: { emailLogin: vi.fn(async () => loginResponse) },
userStorage: {
setUser: vi.fn(async () => Result.err(cacheError)),
},
});
const result = await repository.emailLogin({
email: "elio@example.test",
password: "password",
});
expect(result).toEqual(Result.ok(loginResponse));
expect(storage.setLoginProvider).toHaveBeenCalledWith(LoginStatus.Email);
expect(userStorage.setUser).toHaveBeenCalledOnce();
});
it("keeps a current-user response successful when its cache write throws", async () => {
const { repository } = createRepository({
api: { getCurrentUser: vi.fn(async () => loginResponse.user) },
userStorage: {
setUser: vi.fn(async () => {
throw new Error("storage unavailable");
}),
},
});
const result = await repository.getCurrentUser();
expect(result).toEqual(Result.ok(loginResponse.user));
});
});
+41 -34
View File
@@ -284,15 +284,21 @@ export class AuthRepository implements IAuthRepository {
});
if (data.isGuest || !data.hasCompleteFacebookIdentity) {
await this.storage.setGuestToken(data.token);
await this.storage.setLoginProvider(LoginStatus.Guest);
const tokenResult = await this.storage.setGuestToken(data.token);
if (Result.isErr(tokenResult)) throw tokenResult.error;
if (input.deviceId) {
await this.storage.setDeviceId(input.deviceId);
const deviceResult = await this.storage.setDeviceId(input.deviceId);
if (Result.isErr(deviceResult)) throw deviceResult.error;
}
const userId = data.userId || data.user?.id || "";
if (userId) {
await this.userStorage.setUserId(userId);
const userIdResult = await this.userStorage.setUserId(userId);
if (Result.isErr(userIdResult)) throw userIdResult.error;
}
const providerResult = await this.storage.setLoginProvider(
LoginStatus.Guest,
);
if (Result.isErr(providerResult)) throw providerResult.error;
return LoginStatus.Guest;
}
@@ -345,13 +351,7 @@ export class AuthRepository implements IAuthRepository {
async getCurrentUser(): Promise<Result<User>> {
return Result.wrap(async () => {
const user = await this.api.getCurrentUser();
const writeResult = await this.userStorage.setUser(user.toJson());
if (!writeResult.success) {
log.warn(
"[AuthRepository] failed to cache current user",
writeResult.error,
);
}
await this._cacheUser(user);
return user;
});
}
@@ -360,7 +360,7 @@ export class AuthRepository implements IAuthRepository {
/**
* 社交登录的公共包装:调 API → 调 `_saveLoginData` 持久化。
* `_saveLoginData` 内部全部 best-effort,存储写失败不抛错
* 会话关键数据写入失败时,登录 Result 必须失败
*/
private async _socialLogin(
provider: LoginStatusT,
@@ -374,35 +374,42 @@ export class AuthRepository implements IAuthRepository {
}
/**
* 持久化登录态:login token → refresh token → User 对象 → userId
* 全部 best-effort,错误用 `Logger.warn` 记录但不让登录「失败」——
* 调用方已经从 API 拿到了 LoginResponse,缓存只是离线加速。
* 持久化登录态:login token → refresh token → userId → provider
* 这些字段共同决定会话是否可恢复,任一写入失败都会让登录 Result 失败;
* provider 最后写入,作为会话完成标记。完整 User 对象仅用于离线展示,
* 缓存失败只记录告警,不影响已经建立的会话。
*/
private async _saveLoginData(
data: LoginResponse,
provider: LoginStatusT,
): Promise<void> {
const r1 = await this.storage.setLoginToken(data.token);
if (!r1.success) {
log.warn("[AuthRepository] setLoginToken failed", r1.error);
}
const providerResult = await this.storage.setLoginProvider(provider);
if (!providerResult.success) {
log.warn("[AuthRepository] setLoginProvider failed", providerResult.error);
}
const tokenResult = await this.storage.setLoginToken(data.token);
if (Result.isErr(tokenResult)) throw tokenResult.error;
if (data.refreshToken) {
const r2 = await this.storage.setRefreshToken(data.refreshToken);
if (!r2.success) {
log.warn("[AuthRepository] setRefreshToken failed", r2.error);
const refreshTokenResult = await this.storage.setRefreshToken(
data.refreshToken,
);
if (Result.isErr(refreshTokenResult)) throw refreshTokenResult.error;
}
const userIdResult = await this.userStorage.setUserId(data.user.id);
if (Result.isErr(userIdResult)) throw userIdResult.error;
const providerResult = await this.storage.setLoginProvider(provider);
if (Result.isErr(providerResult)) throw providerResult.error;
await this._cacheUser(data.user);
}
private async _cacheUser(user: User): Promise<void> {
try {
const userResult = await this.userStorage.setUser(user.toJson());
if (Result.isErr(userResult)) {
log.warn("[AuthRepository] setUser failed", userResult.error);
}
}
const r3 = await this.userStorage.setUser(data.user.toJson());
if (!r3.success) {
log.warn("[AuthRepository] setUser failed", r3.error);
}
const r4 = await this.userStorage.setUserId(data.user.id);
if (!r4.success) {
log.warn("[AuthRepository] setUserId failed", r4.error);
} catch (error) {
log.warn("[AuthRepository] setUser threw", error);
}
}