fix(runtime): enforce session and environment contracts
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getApiConfig } from "@/core/net/config/api_config";
|
||||
|
||||
const CONFIG_ENV_KEYS = [
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_WS_BASE_URL",
|
||||
"NEXT_PUBLIC_API_CONNECT_TIMEOUT",
|
||||
"NEXT_PUBLIC_API_RECEIVE_TIMEOUT",
|
||||
"NEXT_PUBLIC_API_SEND_TIMEOUT",
|
||||
] as const;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("getApiConfig", () => {
|
||||
it("keeps local defaults in development", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "development");
|
||||
unsetConfigEnv();
|
||||
|
||||
expect(getApiConfig()).toEqual({
|
||||
baseUrl: "http://172.16.48.49:3002",
|
||||
wsUrl: "ws://172.16.48.49:3002/ws",
|
||||
connectTimeout: 30_000,
|
||||
receiveTimeout: 60_000,
|
||||
sendTimeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["test", "production"])(
|
||||
"requires explicit service URLs in %s",
|
||||
(appEnv) => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", appEnv);
|
||||
unsetConfigEnv();
|
||||
|
||||
expect(() => getApiConfig()).toThrow(
|
||||
`[ApiConfig] Invalid ${appEnv} configuration`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("parses an explicit non-development configuration", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.test");
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_BASE_URL", "wss://api.example.test/ws");
|
||||
vi.stubEnv("NEXT_PUBLIC_API_CONNECT_TIMEOUT", "1000");
|
||||
vi.stubEnv("NEXT_PUBLIC_API_RECEIVE_TIMEOUT", "2000");
|
||||
vi.stubEnv("NEXT_PUBLIC_API_SEND_TIMEOUT", "3000");
|
||||
|
||||
expect(getApiConfig()).toEqual({
|
||||
baseUrl: "https://api.example.test",
|
||||
wsUrl: "wss://api.example.test/ws",
|
||||
connectTimeout: 1000,
|
||||
receiveTimeout: 2000,
|
||||
sendTimeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["not-a-number", "0", "-1", "1.5"])(
|
||||
"rejects invalid timeout %s",
|
||||
(timeout) => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
|
||||
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.test");
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_BASE_URL", "wss://api.example.test/ws");
|
||||
vi.stubEnv("NEXT_PUBLIC_API_CONNECT_TIMEOUT", timeout);
|
||||
|
||||
expect(() => getApiConfig()).toThrow(/connectTimeout/);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects URLs with the wrong protocol", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "ftp://api.example.test");
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_BASE_URL", "https://api.example.test/ws");
|
||||
|
||||
expect(() => getApiConfig()).toThrow(/baseUrl.*wsUrl/);
|
||||
});
|
||||
});
|
||||
|
||||
function unsetConfigEnv(): void {
|
||||
for (const key of CONFIG_ENV_KEYS) vi.stubEnv(key, undefined);
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
* 通过环境变量配置后端服务地址与超时时间。
|
||||
*
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppEnvUtil, type AppEnv } from "@/utils/app-env";
|
||||
|
||||
export type { AppEnv };
|
||||
@@ -30,6 +32,29 @@ export class TimeoutConstants {
|
||||
static readonly longTimeout = 60000;
|
||||
}
|
||||
|
||||
const DEVELOPMENT_API_BASE_URL = "http://172.16.48.49:3002";
|
||||
const DEVELOPMENT_WS_BASE_URL = "ws://172.16.48.49:3002/ws";
|
||||
|
||||
const ApiConfigSchema = z.object({
|
||||
baseUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => hasProtocol(value, ["http:", "https:"]), {
|
||||
message: "must be an http(s) URL",
|
||||
}),
|
||||
wsUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => hasProtocol(value, ["ws:", "wss:"]), {
|
||||
message: "must be a ws(s) URL",
|
||||
}),
|
||||
connectTimeout: z.coerce.number().int().positive(),
|
||||
receiveTimeout: z.coerce.number().int().positive(),
|
||||
sendTimeout: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 解析当前环境
|
||||
*/
|
||||
@@ -41,29 +66,40 @@ export function getAppEnv(): AppEnv {
|
||||
* 获取当前 API 配置
|
||||
*/
|
||||
export function getApiConfig(): ApiConfig {
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://172.16.48.49:3002";
|
||||
const wsUrl =
|
||||
process.env.NEXT_PUBLIC_WS_BASE_URL ?? "ws://172.16.48.49:3002/ws";
|
||||
const appEnv = getAppEnv();
|
||||
const allowDevelopmentDefaults = appEnv === "development";
|
||||
const result = ApiConfigSchema.safeParse({
|
||||
baseUrl:
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ??
|
||||
(allowDevelopmentDefaults ? DEVELOPMENT_API_BASE_URL : undefined),
|
||||
wsUrl:
|
||||
process.env.NEXT_PUBLIC_WS_BASE_URL ??
|
||||
(allowDevelopmentDefaults ? DEVELOPMENT_WS_BASE_URL : undefined),
|
||||
connectTimeout:
|
||||
process.env.NEXT_PUBLIC_API_CONNECT_TIMEOUT ??
|
||||
TimeoutConstants.shortTimeout,
|
||||
receiveTimeout:
|
||||
process.env.NEXT_PUBLIC_API_RECEIVE_TIMEOUT ??
|
||||
TimeoutConstants.longTimeout,
|
||||
sendTimeout:
|
||||
process.env.NEXT_PUBLIC_API_SEND_TIMEOUT ??
|
||||
TimeoutConstants.shortTimeout,
|
||||
});
|
||||
|
||||
const connectTimeout = Number.parseInt(
|
||||
process.env.NEXT_PUBLIC_API_CONNECT_TIMEOUT ?? "30000",
|
||||
10
|
||||
);
|
||||
const receiveTimeout = Number.parseInt(
|
||||
process.env.NEXT_PUBLIC_API_RECEIVE_TIMEOUT ?? "60000",
|
||||
10
|
||||
);
|
||||
const sendTimeout = Number.parseInt(
|
||||
process.env.NEXT_PUBLIC_API_SEND_TIMEOUT ?? "30000",
|
||||
10
|
||||
);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join("; ");
|
||||
throw new Error(`[ApiConfig] Invalid ${appEnv} configuration: ${issues}`);
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
wsUrl,
|
||||
connectTimeout,
|
||||
receiveTimeout,
|
||||
sendTimeout,
|
||||
};
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function hasProtocol(value: string, protocols: readonly string[]): boolean {
|
||||
try {
|
||||
return protocols.includes(new URL(value).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user