252 lines
7.9 KiB
TypeScript
252 lines
7.9 KiB
TypeScript
import { http, HttpResponse } from "msw";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import { AccessDeniedError, ApiError, UNKNOWN_API_ERROR_MESSAGE } 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.each([
|
|
["malformed JSON", () => new HttpResponse("not-json", { status: 401 })],
|
|
["empty body", () => new HttpResponse(null, { status: 401 })],
|
|
["schema mismatch", () => HttpResponse.json({ success: false, data: null, errorProperty: null }, { status: 401 })],
|
|
])("clears the session once for concurrent protected 401 responses with %s", async (_label, responseFactory) => {
|
|
// Given
|
|
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
|
const { client, clearSession, onAuthExpired } = createTestClient();
|
|
server.use(http.get(`${apiBaseUrl}/protected`, responseFactory));
|
|
|
|
// When
|
|
const results = await Promise.allSettled([
|
|
client.request({
|
|
path: "/protected",
|
|
responseSchema: valueSchema,
|
|
authentication: "required",
|
|
}),
|
|
client.request({
|
|
path: "/protected",
|
|
responseSchema: valueSchema,
|
|
authentication: "required",
|
|
}),
|
|
]);
|
|
|
|
// Then
|
|
for (const result of results) {
|
|
expect(result.status).toBe("rejected");
|
|
if (result.status === "rejected") {
|
|
expect(result.reason).toBeInstanceOf(ApiError);
|
|
expect(result.reason).toMatchObject({ status: 401, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
|
|
}
|
|
}
|
|
expect(clearSession).toHaveBeenCalledTimes(1);
|
|
expect(onAuthExpired).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|