diff --git a/.gitignore b/.gitignore index c07a2ded..f4a7205f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,13 @@ dist-electron release *.local +# Local secrets and databases +.env +.env.*.local +*.sqlite +*.sqlite3 +*.db + # Test coverage coverage .nyc_output diff --git a/electron/main/index.ts b/electron/main/index.ts index 5d060a23..2b4b8035 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -150,20 +150,35 @@ ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => { throw new Error("登录信息格式不正确,请重新输入。"); } - const response = await fetch(FLARUM_TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": getUserAgent(), - }, - body: JSON.stringify({ - identification: credentials.identification, - password: credentials.password, - }), - }); + logger.info({ endpoint: FLARUM_TOKEN_URL }, "Requesting Flarum login token"); + + let response: Response; + try { + response = await fetch(FLARUM_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": getUserAgent(), + }, + body: JSON.stringify({ + identification: credentials.identification, + password: credentials.password, + }), + }); + } catch (err) { + logger.error( + { err, endpoint: FLARUM_TOKEN_URL }, + "Flarum token request failed before response", + ); + throw new Error("无法连接星火论坛,请检查网络后重试。"); + } if (!response.ok) { + logger.warn( + { endpoint: FLARUM_TOKEN_URL, status: response.status }, + "Flarum rejected login token request", + ); throw new Error("论坛登录失败,请检查账号和密码。"); } @@ -174,6 +189,14 @@ ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => { userId === undefined || userId === null ) { + logger.warn( + { + endpoint: FLARUM_TOKEN_URL, + hasToken: typeof data.token === "string" && data.token.length > 0, + hasUserId: userId !== undefined && userId !== null, + }, + "Flarum token response missing required fields", + ); throw new Error("论坛登录响应异常,请稍后重试。"); } diff --git a/src/__tests__/unit/backendApi.test.ts b/src/__tests__/unit/backendApi.test.ts new file mode 100644 index 00000000..33f05106 --- /dev/null +++ b/src/__tests__/unit/backendApi.test.ts @@ -0,0 +1,74 @@ +import axios from "axios"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { exchangeFlarumToken } from "@/modules/backendApi"; + +const axiosMocks = vi.hoisted(() => { + const post = vi.fn(); + return { + instance: { + defaults: { headers: { common: {} as Record } }, + get: vi.fn(), + post, + }, + post, + }; +}); + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), +})); + +vi.mock("axios", () => ({ + default: { + create: vi.fn(() => axiosMocks.instance), + isAxiosError: (error: unknown) => + Boolean((error as { isAxiosError?: boolean }).isAxiosError), + }, +})); + +vi.mock("pino", () => ({ + default: () => loggerMocks, +})); + +describe("backend API auth exchange", () => { + beforeEach(() => { + vi.mocked(axios.create).mockClear(); + axiosMocks.post.mockReset(); + loggerMocks.error.mockReset(); + }); + + it("maps backend connection failures to a user-actionable login error", async () => { + const error = Object.assign(new Error("Network Error"), { + code: "ERR_NETWORK", + isAxiosError: true, + request: {}, + }); + axiosMocks.post.mockRejectedValue(error); + + await expect( + exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }), + ).rejects.toThrow("无法连接星火账号服务,请确认后端服务已启动或稍后重试。"); + expect(loggerMocks.error).toHaveBeenCalledWith( + { + code: "ERR_NETWORK", + message: "Network Error", + status: undefined, + }, + "Spark backend auth exchange failed", + ); + expect(JSON.stringify(loggerMocks.error.mock.calls)).not.toContain("forum-token"); + }); + + it("maps backend server failures to an update-required login error", async () => { + const error = Object.assign(new Error("Request failed with status code 500"), { + isAxiosError: true, + response: { status: 500 }, + }); + axiosMocks.post.mockRejectedValue(error); + + await expect( + exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }), + ).rejects.toThrow("星火账号服务异常,请确认后端数据库迁移已执行后重试。"); + }); +}); diff --git a/src/__tests__/unit/flarumAuth.test.ts b/src/__tests__/unit/flarumAuth.test.ts index 3026e47f..db8bca7b 100644 --- a/src/__tests__/unit/flarumAuth.test.ts +++ b/src/__tests__/unit/flarumAuth.test.ts @@ -32,4 +32,29 @@ describe("requestFlarumToken", () => { expect(axios.post).not.toHaveBeenCalled(); expect(token).toEqual({ token: "forum-token", userId: "42" }); }); + + it("rejects malformed token responses from main-process IPC", async () => { + vi.mocked(window.ipcRenderer.invoke).mockResolvedValue({ + token: "", + user_id: 42, + }); + + await expect( + requestFlarumToken({ identification: "momen", password: "secret" }), + ).rejects.toThrow("论坛登录响应异常,请稍后重试。"); + }); + + it("strips Electron IPC wrapper text from known login errors", async () => { + vi.mocked(window.ipcRenderer.invoke).mockRejectedValue( + new Error( + "Error invoking remote method 'request-flarum-token': Error: 无法连接星火论坛,请检查网络后重试。", + ), + ); + + await expect( + requestFlarumToken({ identification: "momen", password: "secret" }), + ).rejects.toMatchObject({ + message: "无法连接星火论坛,请检查网络后重试。", + }); + }); }); diff --git a/src/modules/backendApi.ts b/src/modules/backendApi.ts index e07eed6f..fbd61d40 100644 --- a/src/modules/backendApi.ts +++ b/src/modules/backendApi.ts @@ -1,4 +1,5 @@ -import axios from "axios"; +import axios, { type AxiosResponse } from "axios"; +import pino from "pino"; import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; import type { @@ -19,9 +20,42 @@ const backend = axios.create({ baseURL: SPARK_BACKEND_BASE_URL, timeout: 10000, }); +const logger = pino({ name: "backendApi" }); type ApiRecord = Record; +const normalizeBackendAuthError = (error: unknown): Error => { + if (!axios.isAxiosError(error)) { + return error instanceof Error ? error : new Error("登录失败,请稍后重试。"); + } + + logger.error( + { + code: error.code, + message: error.message, + status: error.response?.status, + }, + "Spark backend auth exchange failed", + ); + + if (!error.response) { + return new Error("无法连接星火账号服务,请确认后端服务已启动或稍后重试。"); + } + + const status = error.response.status; + if (status === 401) { + return new Error("论坛登录失败,请检查账号和密码。"); + } + if (status === 503) { + return new Error("星火账号服务暂时无法连接论坛,请稍后重试。"); + } + if (status === 500) { + return new Error("星火账号服务异常,请确认后端数据库迁移已执行后重试。"); + } + + return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`); +}; + const asApiRecord = (value: unknown): ApiRecord => { if (value && typeof value === "object" && !Array.isArray(value)) { return value as ApiRecord; @@ -145,10 +179,15 @@ export const exchangeFlarumToken = async (payload: { flarumUserId: string; flarumToken: string; }): Promise => { - const response = await backend.post("/auth/flarum", { - flarum_user_id: payload.flarumUserId, - flarum_token: payload.flarumToken, - }); + let response: AxiosResponse; + try { + response = await backend.post("/auth/flarum", { + flarum_user_id: payload.flarumUserId, + flarum_token: payload.flarumToken, + }); + } catch (error) { + throw normalizeBackendAuthError(error); + } const data = asApiRecord(response.data); return { diff --git a/src/modules/flarumAuth.ts b/src/modules/flarumAuth.ts index 08b1b52c..b136c24b 100644 --- a/src/modules/flarumAuth.ts +++ b/src/modules/flarumAuth.ts @@ -12,15 +12,49 @@ const asRecord = (value: unknown): Record => { return {}; }; +const knownLoginErrorMessages = [ + "无法连接星火论坛,请检查网络后重试。", + "论坛登录失败,请检查账号和密码。", + "论坛登录响应异常,请稍后重试。", + "登录信息格式不正确,请重新输入。", +]; + +const normalizeIpcError = (error: unknown): Error => { + if (!(error instanceof Error)) { + return new Error("登录失败,请稍后重试"); + } + + const knownMessage = knownLoginErrorMessages.find((message) => + error.message.includes(message), + ); + return knownMessage ? new Error(knownMessage) : error; +}; + export const requestFlarumToken = async ( payload: FlarumLoginPayload, ): Promise => { - const data = asRecord( - await window.ipcRenderer.invoke("request-flarum-token", payload), - ); + let data: Record; + try { + data = asRecord( + await window.ipcRenderer.invoke("request-flarum-token", payload), + ); + } catch (error) { + throw normalizeIpcError(error); + } + + const token = data.token; + const userId = data.userId ?? data.user_id; + if ( + typeof token !== "string" || + !token || + userId === undefined || + userId === null + ) { + throw new Error("论坛登录响应异常,请稍后重试。"); + } return { - token: String(data.token || ""), - userId: String(data.userId || data.user_id || ""), + token, + userId: String(userId), }; };