feat(ai-character): 관리자 인증 셸 구현

This commit is contained in:
Yu Sung
2026-07-27 15:04:03 +09:00
parent ba43dd829e
commit 5356bd1e6a
82 changed files with 6017 additions and 18 deletions

View File

@@ -0,0 +1,215 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessDeniedError } from "../api-error";
import { createApiClient } from "../client";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("authenticated API requests", () => {
test("sends Korean language without bearer authentication to login", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let acceptLanguage: string | null = null;
let authorization: string | null = null;
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, ({ request }) => {
acceptLanguage = request.headers.get("Accept-Language");
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/admin/member/login",
method: "POST",
headers: {
Authorization: "Bearer caller-supplied-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "admin@test.com", password: "secret" }),
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(acceptLanguage).toBe("ko");
expect(authorization).toBeNull();
});
test("sends bearer authentication to a protected request", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let authorization: string | null = null;
server.use(
http.get(`${apiBaseUrl}/api/v2/protected`, ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/api/v2/protected",
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(authorization).toBe("Bearer header.payload.signature");
});
test("sends bearer authentication to logout", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let authorization: string | null = null;
server.use(
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/member/logout",
method: "POST",
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(authorization).toBe("Bearer header.payload.signature");
});
test("clears the session and redirects once for each concurrent 401 burst", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const clearSession = vi.fn();
const onAuthExpired = vi.fn();
const client = createApiClient({
getToken: () => "header.payload.signature",
clearSession,
onAuthExpired,
});
server.use(
http.get(`${apiBaseUrl}/protected`, () =>
HttpResponse.json(
{
success: false,
message: "인증 정보가 없습니다.",
data: null,
errorProperty: null,
},
{ status: 401 },
),
),
);
// When
const firstBurst = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
const secondBurst = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
// Then
expect(firstBurst).toHaveLength(2);
expect(secondBurst).toHaveLength(2);
expect(clearSession).toHaveBeenCalledTimes(2);
expect(onAuthExpired).toHaveBeenCalledTimes(2);
});
test("surfaces access denied without clearing the session", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client, clearSession, onAuthExpired } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/protected`, () =>
HttpResponse.json(
{
success: false,
message: "접근 권한이 없습니다.",
data: null,
errorProperty: null,
},
{ status: 403 },
),
),
);
// When
const request = client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
});
// Then
await expect(request).rejects.toBeInstanceOf(AccessDeniedError);
expect(clearSession).not.toHaveBeenCalled();
expect(onAuthExpired).not.toHaveBeenCalled();
});
test("does not write sensitive request data to the console", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient("jwt.secret.value");
const multipartBody = new FormData();
multipartBody.append("request", JSON.stringify({ password: "secret-password" }));
multipartBody.append("file", new File(["private-content"], "private.txt"));
const consoleDebug = vi.spyOn(console, "debug");
const consoleError = vi.spyOn(console, "error");
const consoleInfo = vi.spyOn(console, "info");
const consoleLog = vi.spyOn(console, "log");
const consoleWarn = vi.spyOn(console, "warn");
server.use(
http.post(`${apiBaseUrl}/api/v2/protected`, () =>
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
),
);
// When
await client.request({
path: "/api/v2/protected?signedUrl=https%3A%2F%2Fcdn.example.com%2Fprivate%3Fsignature%3Dabc",
method: "POST",
body: multipartBody,
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(consoleDebug).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();
expect(consoleInfo).not.toHaveBeenCalled();
expect(consoleLog).not.toHaveBeenCalled();
expect(consoleWarn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { vi } from "vitest";
import { z } from "zod";
import { createApiClient } from "../client";
export const apiBaseUrl = "https://api.example.com";
export const valueSchema = z.object({ value: z.string() });
export type TestClient = {
readonly client: ReturnType<typeof createApiClient>;
readonly clearSession: ReturnType<typeof vi.fn>;
readonly onAuthExpired: ReturnType<typeof vi.fn>;
};
export function createTestClient(token = "header.payload.signature"): TestClient {
let currentToken: string | null = token;
const clearSession = vi.fn(() => {
currentToken = null;
});
const onAuthExpired = vi.fn();
return {
client: createApiClient({
getToken: () => currentToken,
clearSession,
onAuthExpired,
}),
clearSession,
onAuthExpired,
};
}

View File

@@ -0,0 +1,117 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ApiError } from "../api-error";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("API client", () => {
test("accepts a successful envelope without errorProperty", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/public`, () =>
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
),
);
// When
const response = await client.request({
path: "/public",
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(response).toEqual({ value: "ok" });
});
test("accepts a successful envelope with null errorProperty", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/public`, () =>
HttpResponse.json({
success: true,
message: null,
data: { value: "ok" },
errorProperty: null,
}),
),
);
// When
const response = await client.request({
path: "/public",
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(response).toEqual({ value: "ok" });
});
test.each([400, 404, 405, 415, 500])(
"preserves a Korean server error envelope for status %i",
async (status) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
const message = `서버 오류 ${status}`;
const errorProperty = "request";
server.use(
http.get(`${apiBaseUrl}/error`, () =>
HttpResponse.json(
{ success: false, message, data: null, errorProperty },
{ status },
),
),
);
// When
const request = client.request({
path: "/error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toMatchObject({ status, message, errorProperty });
},
);
test("exposes server errors as ApiError", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/error`, () =>
HttpResponse.json(
{
success: false,
message: "잘못된 요청입니다.",
data: null,
errorProperty: null,
},
{ status: 400 },
),
),
);
// When
const request = client.request({
path: "/error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toBeInstanceOf(ApiError);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import { createPageParams, type PageData } from "../pagination";
describe("createPageParams", () => {
test("uses documented default page and size", () => {
// Given
const request = {};
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams).toEqual({ page: 0, size: 20 });
});
test("clamps size to the documented lower bound", () => {
// Given
const request = { size: 1 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(20);
});
test("clamps size to the documented upper bound", () => {
// Given
const request = { size: 51 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(50);
});
test("keeps a provided page unchanged", () => {
// Given
const request = { page: 3, size: 20 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.page).toBe(3);
});
test("defines the documented page response shape", () => {
// Given
const page: PageData<string> = {
totalCount: 1,
page: 0,
size: 20,
hasNext: false,
items: ["루나"],
};
// When
const firstItem = page.items[0];
// Then
expect(firstItem).toBe("루나");
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { ApiError } from "../api-error";
import { createQueryClient, shouldRetryQuery } from "../query-client";
describe("TanStack Query defaults", () => {
test("does not retry unauthenticated query errors", () => {
// Given
const error = new ApiError({
status: 401,
message: "인증 정보가 없습니다.",
errorProperty: null,
});
// When
const shouldRetry = shouldRetryQuery(0, error);
// Then
expect(shouldRetry).toBe(false);
});
test("does not retry access-denied query errors", () => {
// Given
const error = new ApiError({
status: 403,
message: "접근 권한이 없습니다.",
errorProperty: null,
});
// When
const shouldRetry = shouldRetryQuery(0, error);
// Then
expect(shouldRetry).toBe(false);
});
test("limits retries for other query errors", () => {
// Given
const error = new Error("network failure");
// When
const shouldRetry = shouldRetryQuery(2, error);
// Then
expect(shouldRetry).toBe(false);
});
test("disables mutation retries", () => {
// Given
const queryClient = createQueryClient();
// When
const retry = queryClient.getDefaultOptions().mutations?.retry;
// Then
expect(retry).toBe(false);
});
});