diff --git a/apps/admin/src/lib/api/auth.test.ts b/apps/admin/src/lib/api/auth.test.ts index 966a4d2e3..1d56ad3ed 100644 --- a/apps/admin/src/lib/api/auth.test.ts +++ b/apps/admin/src/lib/api/auth.test.ts @@ -1,60 +1,96 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const { post, requestInterceptors } = vi.hoisted(() => ({ - post: vi.fn().mockResolvedValue({ data: { accessToken: "token" } }), - requestInterceptors: [] as Array<(config: Record) => Record>, -})); +const { create, loadAccessToken, post, removeAccessToken, requestInterceptors, saveAdminApiEnvironment } = vi.hoisted( + () => { + const hoistedPost = vi.fn(); + return { + create: vi.fn(() => ({ + post: hoistedPost, + interceptors: { + request: { + use: (fn: (config: Record) => Record) => { + requestInterceptors.push(fn); + }, + }, + }, + })), + loadAccessToken: vi.fn(), + post: hoistedPost, + removeAccessToken: vi.fn(), + requestInterceptors: [] as Array<(config: Record) => Record>, + saveAdminApiEnvironment: vi.fn(), + }; + }, +); vi.mock("axios", () => ({ default: { - create: () => ({ - post, - interceptors: { - request: { - use: (fn: (config: Record) => Record) => { - requestInterceptors.push(fn); - }, - }, - }, - }), + create, }, })); -describe("adminSignInApi", () => { +vi.mock("@/lib/auth/environment", () => ({ + getApiBaseUrlForEnvironment: (environment: string) => + environment === "dev" ? "https://api.stage.solid-connection.com" : "https://api.solid-connection.com", + resolveEnvironmentFromEmail: (email: string) => (email.endsWith("@dev.solid-connection.com") ? "dev" : "prod"), +})); + +vi.mock("@/lib/env", () => ({ + resolveActiveApiBaseUrl: () => "https://api.solid-connection.com", +})); + +vi.mock("@/lib/utils/localStorage", () => ({ + loadAccessToken, + removeAccessToken, + saveAdminApiEnvironment, +})); + +import { adminSignInApi, adminSignOutApi, reissueAccessTokenApi } from "./auth"; + +describe("어드민 인증 API", () => { beforeEach(() => { - post.mockClear(); - localStorage.clear(); + loadAccessToken.mockReset(); + post.mockReset(); + removeAccessToken.mockReset(); + saveAdminApiEnvironment.mockReset(); }); - afterEach(() => { - vi.restoreAllMocks(); + it("어드민 refresh token 쿠키를 주고받도록 credentials를 포함한다", () => { + expect(create).toHaveBeenCalledWith({ + withCredentials: true, + }); }); - it("dev 이메일 로그인 요청은 localStorage 저장 성공 여부와 무관하게 stage baseURL로 전송된다", async () => { - const setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { - throw new Error("storage unavailable"); - }); + it("어드민 전용 로그인 API를 이메일 환경에 맞춰 호출한다", async () => { + post.mockResolvedValue({ data: { accessToken: "access-token" } }); - const { adminSignInApi } = await import("./auth"); await adminSignInApi("admin@dev.solid-connection.com", "password"); + expect(removeAccessToken).toHaveBeenCalledOnce(); + expect(saveAdminApiEnvironment).toHaveBeenCalledWith("dev"); expect(post).toHaveBeenCalledWith( - "/auth/email/sign-in", + "/admin/auth/sign-in", { email: "admin@dev.solid-connection.com", password: "password" }, { baseURL: "https://api.stage.solid-connection.com" }, ); + }); + + it("어드민 전용 재발급 API를 호출한다", async () => { + post.mockResolvedValue({ data: { accessToken: "reissued-access-token" } }); - setItemSpy.mockRestore(); + await reissueAccessTokenApi(); + + expect(post).toHaveBeenCalledWith("/admin/auth/reissue"); }); - it("prod 이메일 로그인 요청은 prod baseURL로 전송된다", async () => { - const { adminSignInApi } = await import("./auth"); - await adminSignInApi("admin@solid-connection.com", "password"); + it("어드민 전용 로그아웃 API에 access token을 전달한다", async () => { + loadAccessToken.mockReturnValue("access-token"); + post.mockResolvedValue({ data: undefined }); - expect(post).toHaveBeenCalledWith( - "/auth/email/sign-in", - { email: "admin@solid-connection.com", password: "password" }, - { baseURL: "https://api.solid-connection.com" }, - ); + await adminSignOutApi(); + + expect(post).toHaveBeenCalledWith("/admin/auth/sign-out", undefined, { + headers: { Authorization: "Bearer access-token" }, + }); }); }); diff --git a/apps/admin/src/lib/api/auth.ts b/apps/admin/src/lib/api/auth.ts index 970f86670..ae246cc56 100644 --- a/apps/admin/src/lib/api/auth.ts +++ b/apps/admin/src/lib/api/auth.ts @@ -8,10 +8,11 @@ const authAxiosInstance = axios.create({ withCredentials: true, }); +const ADMIN_AUTH_PATH = "/admin/auth"; + authAxiosInstance.interceptors.request.use((config) => { const newConfig = { ...config }; - // Respect an explicitly-provided baseURL (e.g. adminSignInApi pinning the just-resolved - // environment) instead of always re-deriving it from potentially-unavailable storage. + // 로그인에서 직접 지정한 baseURL을 우선하고, 나머지 요청은 저장된 어드민 환경을 사용한다. newConfig.baseURL = config.baseURL ?? resolveActiveApiBaseUrl(); return newConfig; }); @@ -19,28 +20,24 @@ authAxiosInstance.interceptors.request.use((config) => { export const adminSignInApi = (email: string, password: string): Promise> => { const environment = resolveEnvironmentFromEmail(email); - // Clear any previous environment's token before switching, so a stale token can never - // ride along to the newly resolved environment's API (e.g. dev token leaking to prod). removeAccessToken(); saveAdminApiEnvironment(environment); - // Pin this request's baseURL directly instead of relying on a localStorage round trip: - // if storage is unavailable, the request still goes to the correctly resolved environment. return authAxiosInstance.post( - "/auth/email/sign-in", + `${ADMIN_AUTH_PATH}/sign-in`, { email, password }, { baseURL: getApiBaseUrlForEnvironment(environment) }, ); }; export const reissueAccessTokenApi = (): Promise> => { - return authAxiosInstance.post("/auth/reissue"); + return authAxiosInstance.post(`${ADMIN_AUTH_PATH}/reissue`); }; export const adminSignOutApi = (): Promise> => { const accessToken = loadAccessToken(); - return authAxiosInstance.post("/auth/sign-out", undefined, { + return authAxiosInstance.post(`${ADMIN_AUTH_PATH}/sign-out`, undefined, { headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, }); }; diff --git a/apps/admin/src/lib/utils/localStorage.test.ts b/apps/admin/src/lib/utils/localStorage.test.ts new file mode 100644 index 000000000..972ff6431 --- /dev/null +++ b/apps/admin/src/lib/utils/localStorage.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { loadAccessToken, removeAccessToken, saveAccessToken } from "./localStorage"; + +const ADMIN_ACCESS_TOKEN_KEY = "adminAccessToken"; + +describe("어드민 access token localStorage 저장", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("어드민 전용 키(adminAccessToken)로 저장한다", () => { + saveAccessToken("access-token"); + + expect(localStorage.getItem(ADMIN_ACCESS_TOKEN_KEY)).toBe("access-token"); + }); + + it("어드민 전용 키(adminAccessToken)에서 조회한다", () => { + localStorage.setItem(ADMIN_ACCESS_TOKEN_KEY, "access-token"); + + expect(loadAccessToken()).toBe("access-token"); + }); + + it("어드민 전용 키(adminAccessToken)를 제거한다", () => { + localStorage.setItem(ADMIN_ACCESS_TOKEN_KEY, "access-token"); + + removeAccessToken(); + + expect(localStorage.getItem(ADMIN_ACCESS_TOKEN_KEY)).toBeNull(); + }); + + it("웹 앱의 레거시 accessToken 키는 더 이상 사용하지 않는다", () => { + saveAccessToken("access-token"); + + expect(localStorage.getItem("accessToken")).toBeNull(); + }); +}); diff --git a/apps/admin/src/lib/utils/localStorage.ts b/apps/admin/src/lib/utils/localStorage.ts index b1776bc74..4cc69a355 100644 --- a/apps/admin/src/lib/utils/localStorage.ts +++ b/apps/admin/src/lib/utils/localStorage.ts @@ -1,10 +1,11 @@ import type { AdminApiEnvironment } from "@/lib/auth/environment"; +const ADMIN_ACCESS_TOKEN_KEY = "adminAccessToken"; const ADMIN_API_ENVIRONMENT_KEY = "adminApiEnvironment"; export const loadAccessToken = () => { try { - return localStorage.getItem("accessToken"); + return localStorage.getItem(ADMIN_ACCESS_TOKEN_KEY); } catch (err) { console.error("Could not load access token", err); return null; @@ -13,7 +14,7 @@ export const loadAccessToken = () => { export const saveAccessToken = (token: string) => { try { - localStorage.setItem("accessToken", token); + localStorage.setItem(ADMIN_ACCESS_TOKEN_KEY, token); } catch (err) { console.error("Could not save access token", err); } @@ -21,7 +22,7 @@ export const saveAccessToken = (token: string) => { export const removeAccessToken = () => { try { - localStorage.removeItem("accessToken"); + localStorage.removeItem(ADMIN_ACCESS_TOKEN_KEY); } catch (err) { console.error("Could not remove access token", err); } diff --git a/apps/admin/src/types/auth.ts b/apps/admin/src/types/auth.ts index 46fa8d298..a89f995bd 100644 --- a/apps/admin/src/types/auth.ts +++ b/apps/admin/src/types/auth.ts @@ -1,6 +1,5 @@ export interface AdminSignInResponse { accessToken: string; - refreshToken?: string; } export interface ReissueAccessTokenResponse { diff --git a/apps/web/src/utils/localStorageUtils.ts b/apps/web/src/utils/localStorageUtils.ts deleted file mode 100644 index 49f8345a7..000000000 --- a/apps/web/src/utils/localStorageUtils.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @deprecated 이 함수들은 더 이상 사용되지 않습니다. - * 대신 useAuthStore의 setAccessToken, clearAccessToken을 사용하세요. - * Zustand persist가 자동으로 localStorage를 관리합니다. - * - * @example - * // ❌ 사용하지 마세요: - * import { saveAccessTokenToLS } from "@/utils/localStorageUtils"; - * saveAccessTokenToLS(token); - * - * // ✅ 대신 이렇게 사용하세요: - * import useAuthStore from "@/lib/zustand/useAuthStore"; - * const { setAccessToken } = useAuthStore(); - * setAccessToken(token); - */ - -/** - * @deprecated useAuthStore의 setAccessToken을 사용하세요 - */ -export const saveAccessTokenToLS = (token: string) => { - localStorage.setItem("accessToken", token); -}; - -/** - * @deprecated useAuthStore의 clearAccessToken을 사용하세요 - */ -export const removeAccessTokenToLS = () => { - localStorage.removeItem("accessToken"); -}; - -/** - * @deprecated useAuthStore의 accessToken 상태를 직접 읽으세요 - */ -export const getAccessTokenFromLS = (): string | null => { - if (typeof window === "undefined") return null; - return localStorage.getItem("accessToken"); -};