From 4f983387ac1351c8d606fdb162a699eb5bb9630f Mon Sep 17 00:00:00 2001 From: Stanley Cheung Date: Wed, 11 Feb 2026 15:34:59 +0800 Subject: [PATCH] fix: auth refresh token issue --- apps/web/e2e/auth-projects.spec.ts | 2 +- apps/web/e2e/editor-cloud-sync.spec.ts | 2 +- apps/web/src/lib/auth/api-client.test.ts | 80 ++++++++++++++++++++++++ apps/web/src/lib/auth/api-client.ts | 24 ++++++- 4 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/lib/auth/api-client.test.ts diff --git a/apps/web/e2e/auth-projects.spec.ts b/apps/web/e2e/auth-projects.spec.ts index a3108e93..edc147e8 100644 --- a/apps/web/e2e/auth-projects.spec.ts +++ b/apps/web/e2e/auth-projects.spec.ts @@ -17,7 +17,7 @@ test("login loads backend projects list on /projects", async ({ context, page }) }, ]); - await page.route("**/api/auth/refresh", async (route) => { + await page.route("**/api/auth/user/refresh", async (route) => { await route.fulfill({ status: 401, contentType: "application/json", diff --git a/apps/web/e2e/editor-cloud-sync.spec.ts b/apps/web/e2e/editor-cloud-sync.spec.ts index 45ea5901..fbd456cd 100644 --- a/apps/web/e2e/editor-cloud-sync.spec.ts +++ b/apps/web/e2e/editor-cloud-sync.spec.ts @@ -57,7 +57,7 @@ async function mockAuthAndCloudEditorApis({ page: Page; withPutRoute?: boolean; }) { - await page.route("**/api/auth/refresh", async (route: Route) => { + await page.route("**/api/auth/user/refresh", async (route: Route) => { await route.fulfill({ status: 200, contentType: "application/json", diff --git a/apps/web/src/lib/auth/api-client.test.ts b/apps/web/src/lib/auth/api-client.test.ts new file mode 100644 index 00000000..c83bf4b5 --- /dev/null +++ b/apps/web/src/lib/auth/api-client.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { BackendAuthError, backendAuthApi } from "./api-client"; + +const originalFetch = globalThis.fetch; +const originalBaseUrl = process.env.NEXT_PUBLIC_AUTH_API_BASE_URL; + +describe("backend auth api client", () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_AUTH_API_BASE_URL = "http://localhost:3001/api"; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (typeof originalBaseUrl === "string") { + process.env.NEXT_PUBLIC_AUTH_API_BASE_URL = originalBaseUrl; + } else { + delete process.env.NEXT_PUBLIC_AUTH_API_BASE_URL; + } + }); + + test("refresh uses POST /auth/user/refresh and unwraps data payload", async () => { + let requestUrl = ""; + let requestMethod = ""; + let requestCredentials = ""; + + globalThis.fetch = (async (input, init) => { + requestUrl = String(input); + requestMethod = init?.method ?? "GET"; + requestCredentials = String(init?.credentials ?? ""); + + return new Response( + JSON.stringify({ + status: "success", + data: { + token: "jwt-access-token", + user: { + id: "1", + email: "user@example.com", + }, + }, + }), + { + status: 201, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + const response = await backendAuthApi.refresh(); + + expect(requestUrl).toBe("http://localhost:3001/api/auth/user/refresh"); + expect(requestMethod).toBe("POST"); + expect(requestCredentials).toBe("include"); + expect(response.token).toBe("jwt-access-token"); + expect(response.user.email).toBe("user@example.com"); + }); + + test("throws BackendAuthError when backend returns wrapped error in 2xx", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + status: "error", + message: "Invalid refresh token", + statusCode: 401, + }), + { + status: 201, + headers: { "Content-Type": "application/json" }, + }, + )) as typeof fetch; + + const refreshPromise = backendAuthApi.refresh(); + + await expect(refreshPromise).rejects.toBeInstanceOf(BackendAuthError); + await expect(refreshPromise).rejects.toMatchObject({ + message: "Invalid refresh token", + statusCode: 401, + }); + }); +}); diff --git a/apps/web/src/lib/auth/api-client.ts b/apps/web/src/lib/auth/api-client.ts index 7caaffda..964423d4 100644 --- a/apps/web/src/lib/auth/api-client.ts +++ b/apps/web/src/lib/auth/api-client.ts @@ -44,6 +44,17 @@ export class BackendAuthError extends Error { } } +const isWrappedErrorPayload = ( + payload: unknown, +): payload is { status: "error"; message?: string; statusCode?: number } => { + if (!payload || typeof payload !== "object") { + return false; + } + + const candidate = payload as { status?: unknown }; + return candidate.status === "error"; +}; + const unwrapSuccessPayload = (payload: TAuthSuccessPayload): T => { if (payload && typeof payload === "object" && "data" in payload) { return payload.data; @@ -100,6 +111,15 @@ async function request({ payload = null; } + if (isWrappedErrorPayload(payload)) { + const { message, statusCode } = extractError({ + payload, + defaultMessage: "Request failed", + defaultStatusCode: response.status, + }); + throw new BackendAuthError({ message, statusCode }); + } + if (!response.ok) { const { message, statusCode } = extractError({ payload, @@ -148,8 +168,8 @@ export const backendAuthApi = { }), refresh: () => request<{ token: string; user: TBackendUser }>({ - path: "/auth/refresh", - method: "GET", + path: "/auth/user/refresh", + method: "POST", }), logout: () => request<{ message?: string }>({