Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 71 additions & 35 deletions apps/admin/src/lib/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Record<string, unknown>>,
}));
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<string, unknown>) => Record<string, unknown>) => {
requestInterceptors.push(fn);
},
},
},
})),
loadAccessToken: vi.fn(),
post: hoistedPost,
removeAccessToken: vi.fn(),
requestInterceptors: [] as Array<(config: Record<string, unknown>) => Record<string, unknown>>,
saveAdminApiEnvironment: vi.fn(),
};
},
);

vi.mock("axios", () => ({
default: {
create: () => ({
post,
interceptors: {
request: {
use: (fn: (config: Record<string, unknown>) => Record<string, unknown>) => {
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" },
});
});
});
15 changes: 6 additions & 9 deletions apps/admin/src/lib/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,36 @@ 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;
});

export const adminSignInApi = (email: string, password: string): Promise<AxiosResponse<AdminSignInResponse>> => {
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<AxiosResponse<ReissueAccessTokenResponse>> => {
return authAxiosInstance.post("/auth/reissue");
return authAxiosInstance.post(`${ADMIN_AUTH_PATH}/reissue`);
};

export const adminSignOutApi = (): Promise<AxiosResponse<void>> => {
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,
});
};
37 changes: 37 additions & 0 deletions apps/admin/src/lib/utils/localStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
7 changes: 4 additions & 3 deletions apps/admin/src/lib/utils/localStorage.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,15 +14,15 @@ 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);
}
};

export const removeAccessToken = () => {
try {
localStorage.removeItem("accessToken");
localStorage.removeItem(ADMIN_ACCESS_TOKEN_KEY);
} catch (err) {
console.error("Could not remove access token", err);
}
Expand Down
1 change: 0 additions & 1 deletion apps/admin/src/types/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export interface AdminSignInResponse {
accessToken: string;
refreshToken?: string;
}

export interface ReissueAccessTokenResponse {
Expand Down
37 changes: 0 additions & 37 deletions apps/web/src/utils/localStorageUtils.ts

This file was deleted.

Loading