Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b65c50c5f9 | ||
|
|
eb37728532 | ||
|
|
c41a7bf3d3 | ||
|
|
774f3a5507 | ||
|
|
6e95e372a8 | ||
|
|
1ccd502e3a |
Vendored
+20
@@ -23,6 +23,26 @@
|
|||||||
"NODE_OPTIONS": "--inspect"
|
"NODE_OPTIONS": "--inspect"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "dev:as 서버 컴포넌트 디버그 (node-terminal)",
|
||||||
|
"command": "pnpm --filter as dev",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "dev:as 통합 디버그 (Server + Client)",
|
||||||
|
"command": "pnpm --filter as dev",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"serverReadyAction": {
|
||||||
|
"action": "debugWithChrome",
|
||||||
|
"pattern": "- Local:\\s+(https?://.+)",
|
||||||
|
"uriFormat": "%s",
|
||||||
|
"webRoot": "${workspaceFolder}/apps/as"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
replaysSessionSampleRate: 1.0,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
integrations: [Sentry.replayIntegration()],
|
||||||
|
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await import("./sentry.server.config");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge") {
|
||||||
|
await import("./sentry.edge.config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
+17
-1
@@ -1,8 +1,24 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ["@common/assets"],
|
transpilePackages: ["@common/assets"],
|
||||||
|
outputFileTracingRoot: path.join(__dirname, "../../"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withSentryConfig(nextConfig, {
|
||||||
|
org: process.env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT,
|
||||||
|
silent: true,
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
tunnelRoute: "/monitoring",
|
||||||
|
webpack: {
|
||||||
|
treeshake: {
|
||||||
|
removeDebugLogging: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true" ? false : true,
|
||||||
|
},
|
||||||
|
automaticVercelMonitors: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
"@faker-js/faker": "^9.9.0",
|
"@faker-js/faker": "^9.9.0",
|
||||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
|
"@opentelemetry/api": "^1.9.1",
|
||||||
|
"@opentelemetry/context-zone": "^2.8.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
||||||
|
"@opentelemetry/instrumentation-fetch": "^0.219.0",
|
||||||
|
"@opentelemetry/instrumentation-xml-http-request": "^0.219.0",
|
||||||
|
"@opentelemetry/sdk-trace-web": "^2.8.0",
|
||||||
|
"@sentry/nextjs": "^10.57.0",
|
||||||
"@tanstack/react-query": "^5.75.5",
|
"@tanstack/react-query": "^5.75.5",
|
||||||
"@tanstack/react-query-devtools": "^5.75.5",
|
"@tanstack/react-query-devtools": "^5.75.5",
|
||||||
"@tanstack/react-table": "^8.21.2",
|
"@tanstack/react-table": "^8.21.2",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||||
|
});
|
||||||
@@ -865,7 +865,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
|||||||
<tr key={f.atflId}>
|
<tr key={f.atflId}>
|
||||||
<td>
|
<td>
|
||||||
{f.orgnlFileNm ? (
|
{f.orgnlFileNm ? (
|
||||||
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
|
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/download/${f.atflId}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
|
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ async function handler(
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Site-Id": SITE_ID,
|
"X-Site-Id": SITE_ID,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// X-Device-Id 중계 로직 (헤더 또는 쿠키에서 획득)
|
||||||
|
const deviceId =
|
||||||
|
request.headers.get("x-device-id") ||
|
||||||
|
request.headers.get("X-Device-Id") ||
|
||||||
|
cookieStore.get("device-id")?.value;
|
||||||
|
if (deviceId) {
|
||||||
|
headers["X-Device-Id"] = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
let body = {};
|
let body = {};
|
||||||
const isRefreshHandler = params.url.includes("refresh");
|
const isRefreshHandler = params.url.includes("refresh");
|
||||||
|
|
||||||
@@ -45,6 +55,11 @@ async function handler(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
console.error(`[BFF Proxy Error] Backend returned status ${result.status} for ${params.url.join("/")}`);
|
||||||
|
try {
|
||||||
|
const errorText = await result.text();
|
||||||
|
console.error(`[BFF Proxy Error Body] ${errorText}`);
|
||||||
|
} catch (err) {}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Invalid Credentials" },
|
{ message: "Invalid Credentials" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
replaysSessionSampleRate: 1.0,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
integrations: [Sentry.replayIntegration()],
|
||||||
|
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await import("./sentry.server.config");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge") {
|
||||||
|
await import("./sentry.edge.config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
+14
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
|
||||||
const subDomain =
|
const subDomain =
|
||||||
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
|
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
|
||||||
@@ -58,4 +59,16 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withSentryConfig(nextConfig, {
|
||||||
|
org: process.env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT,
|
||||||
|
silent: true,
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
tunnelRoute: "/monitoring",
|
||||||
|
webpack: {
|
||||||
|
treeshake: {
|
||||||
|
removeDebugLogging: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true" ? false : true,
|
||||||
|
},
|
||||||
|
automaticVercelMonitors: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test:playwright": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@common/assets": "workspace:*",
|
"@common/assets": "workspace:*",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@sentry/nextjs": "^10.57.0",
|
||||||
"@tanstack/react-query": "^5.75.5",
|
"@tanstack/react-query": "^5.75.5",
|
||||||
"@tanstack/react-query-devtools": "^5.75.5",
|
"@tanstack/react-query-devtools": "^5.75.5",
|
||||||
"@tanstack/react-table": "^8.21.2",
|
"@tanstack/react-table": "^8.21.2",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.0.0",
|
"@eslint/eslintrc": "^3.0.0",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.13.1",
|
"@types/node": "^22.13.1",
|
||||||
"@types/webpack": "^5.28.5",
|
"@types/webpack": "^5.28.5",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright 설정 파일
|
||||||
|
* https://playwright.dev/docs/test-configuration
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
/* 하나의 테스트 파일 내에서 병렬 실행 금지 및 직렬 실행으로 세션 상태 격리 보장 */
|
||||||
|
fullyParallel: false,
|
||||||
|
/* Redis 및 PostgreSQL 공유 자원의 세션 정렬을 위해 워커를 1개로 제한 */
|
||||||
|
workers: 1,
|
||||||
|
/* 테스트 실패 시 재시도 횟수 */
|
||||||
|
retries: 0,
|
||||||
|
/* 각 테스트 타임아웃 (30초) */
|
||||||
|
timeout: 30000,
|
||||||
|
/* 전체 테스트 실행 타임아웃 */
|
||||||
|
globalTimeout: 600000,
|
||||||
|
/* Assertion 타임아웃 */
|
||||||
|
expect: {
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
/* 리포터 설정 (HTML 리포트 및 터미널 요약) */
|
||||||
|
reporter: [["html", { open: "never" }], ["list"]],
|
||||||
|
/* 공유 설정 */
|
||||||
|
use: {
|
||||||
|
/* 테스트 타겟 URL */
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
|
||||||
|
/* 실패한 테스트에 대해서만 트레이스 수집 */
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
/* 스크린샷 캡처 설정 */
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* 프로젝트별 브라우저 구성 (기본적으로 Chromium 위주로 동작 확인) */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* 로컬 서버 자동 기동 설정 */
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120000,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||||
|
});
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
|
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
|
||||||
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
|
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
|
||||||
import { SITE_ID } from "@/shared/config";
|
import { SITE_ID, ENDPOINT } from "@/shared/config";
|
||||||
import { MenuApi } from "../api/menu-service";
|
import { MenuApi } from "../api/menu-service";
|
||||||
import { getCurrentMenu } from "./utils";
|
import { getCurrentMenu } from "./utils";
|
||||||
|
|
||||||
@@ -16,11 +17,10 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||||||
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
|
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
|
||||||
useMenuStore((state) => state);
|
useMenuStore((state) => state);
|
||||||
|
|
||||||
const getCurrentMenuInfo = async () => {
|
const { data: menuRoles } = useQuery({
|
||||||
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
|
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||||
if (!menuRoles) return;
|
queryFn: () => MenuApi.getMenuRoles(SITE_ID),
|
||||||
return getCurrentMenu(menuRoles, pathname ?? "");
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const getPageMenuAuthority = async () => {
|
const getPageMenuAuthority = async () => {
|
||||||
const menuCd = getCookie("menu-code-key");
|
const menuCd = getCookie("menu-code-key");
|
||||||
@@ -36,10 +36,14 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
if (menuRoles) {
|
||||||
const currentMenu = await getCurrentMenuInfo();
|
const currentMenu = getCurrentMenu(menuRoles, pathname ?? "");
|
||||||
if (currentMenu) setCurrentMenuInfo(currentMenu);
|
if (currentMenu) setCurrentMenuInfo(currentMenu);
|
||||||
|
}
|
||||||
|
}, [menuRoles, pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
const response = await getPageMenuAuthority();
|
const response = await getPageMenuAuthority();
|
||||||
if (response?.length) setPageMenuAuthority(response[0]!);
|
if (response?.length) setPageMenuAuthority(response[0]!);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// 테스트 순차 실행 보장 (전 단계 상태가 영향을 줄 수 있으므로 serial 모드로 설정)
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
const adminId = process.env.TEST_ADMIN_ID || "admin1gmail";
|
||||||
|
const adminPassword = process.env.TEST_ADMIN_PW || "test1234!";
|
||||||
|
|
||||||
|
const DEVICE_PC = "device-pc-chrome-001";
|
||||||
|
const DEVICE_MOBILE = "device-mobile-safari-002";
|
||||||
|
const DEVICE_TABLET = "device-tablet-firefox-003";
|
||||||
|
|
||||||
|
// 디바이스별로 완전히 분리된 HTTP 요청 컨텍스트를 생성하여 세션 격리를 모사하는 헬퍼 함수
|
||||||
|
async function createDeviceContext(playwrightRequest: any, deviceId: string) {
|
||||||
|
return await playwrightRequest.newContext({
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
"X-Device-Id": deviceId,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1초 이상의 간격을 두어 토큰 발급/갱신 시 만료 시각(초)이 다르게 생성되도록 유도하는 지연 함수
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
|
||||||
|
// 응답의 Set-Cookie 헤더 배열에서 특정 쿠키 값을 추출하는 헬퍼 함수
|
||||||
|
function getCookieValue(
|
||||||
|
headers: { name: string; value: string }[],
|
||||||
|
name: string,
|
||||||
|
): string | null {
|
||||||
|
const cookieHeaders = headers.filter(
|
||||||
|
(h) => h.name.toLowerCase() === "set-cookie",
|
||||||
|
);
|
||||||
|
for (const header of cookieHeaders) {
|
||||||
|
const parts = header.value.split(";")[0].split("=");
|
||||||
|
if (parts[0].trim() === name) {
|
||||||
|
return parts[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답의 Set-Cookie 헤더에서 특정 쿠키의 속성(httpOnly, sameSite 등) 존재 유무를 확인하는 헬퍼 함수
|
||||||
|
function hasCookieAttribute(
|
||||||
|
headers: { name: string; value: string }[],
|
||||||
|
name: string,
|
||||||
|
attribute: string,
|
||||||
|
): boolean {
|
||||||
|
const cookieHeaders = headers.filter(
|
||||||
|
(h) => h.name.toLowerCase() === "set-cookie",
|
||||||
|
);
|
||||||
|
for (const header of cookieHeaders) {
|
||||||
|
const parts = header.value.split(";");
|
||||||
|
const cookieName = parts[0].split("=")[0].trim();
|
||||||
|
if (cookieName === name) {
|
||||||
|
return parts
|
||||||
|
.slice(1)
|
||||||
|
.some((p) => p.trim().toLowerCase() === attribute.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트", () => {
|
||||||
|
|
||||||
|
test("1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정", async ({ request }) => {
|
||||||
|
const response = await request.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
headers: { "X-Device-Id": DEVICE_PC },
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
const headers = response.headersArray();
|
||||||
|
const refreshToken = getCookieValue(headers, "refresh-token");
|
||||||
|
expect(refreshToken).not.toBeNull();
|
||||||
|
expect(refreshToken).not.toBe("");
|
||||||
|
|
||||||
|
// httpOnly 속성이 설정되었는지 검증
|
||||||
|
const isHttpOnly = hasCookieAttribute(headers, "refresh-token", "httponly");
|
||||||
|
expect(isHttpOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2. PC/모바일/태블릿 독립 refresh-token 발급", async ({ playwright }) => {
|
||||||
|
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
|
||||||
|
const tabletContext = await createDeviceContext(playwright.request, DEVICE_TABLET);
|
||||||
|
|
||||||
|
const pcRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
const pcToken = getCookieValue(pcRes.headersArray(), "refresh-token");
|
||||||
|
|
||||||
|
await delay(1000); // 1초 간격 지연
|
||||||
|
|
||||||
|
const mobileRes = await mobileContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
const mobileToken = getCookieValue(mobileRes.headersArray(), "refresh-token");
|
||||||
|
|
||||||
|
await delay(1000); // 1초 간격 지연
|
||||||
|
|
||||||
|
const tabletRes = await tabletContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
const tabletToken = getCookieValue(tabletRes.headersArray(), "refresh-token");
|
||||||
|
|
||||||
|
expect(pcToken).not.toBeNull();
|
||||||
|
expect(mobileToken).not.toBeNull();
|
||||||
|
expect(tabletToken).not.toBeNull();
|
||||||
|
|
||||||
|
// 다중 세션 환경에서는 디바이스마다 리프레시 토큰이 고유해야 함
|
||||||
|
expect(pcToken).not.toBe(mobileToken);
|
||||||
|
expect(pcToken).not.toBe(tabletToken);
|
||||||
|
expect(mobileToken).not.toBe(tabletToken);
|
||||||
|
|
||||||
|
await pcContext.dispose();
|
||||||
|
await mobileContext.dispose();
|
||||||
|
await tabletContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3. Silent Refresh RTR - 새 토큰 교체", async ({ playwright }) => {
|
||||||
|
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
|
||||||
|
// 로그인으로 최초 토큰 발급
|
||||||
|
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
const firstToken = getCookieValue(loginRes.headersArray(), "refresh-token");
|
||||||
|
expect(firstToken).not.toBeNull();
|
||||||
|
|
||||||
|
await delay(1000); // 1초 간격 지연
|
||||||
|
|
||||||
|
// 갱신 시도 (RTR 동작)
|
||||||
|
const refreshRes = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(refreshRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const secondToken = getCookieValue(refreshRes.headersArray(), "refresh-token");
|
||||||
|
expect(secondToken).not.toBeNull();
|
||||||
|
|
||||||
|
// 이전 토큰과 달라져야 함
|
||||||
|
expect(secondToken).not.toBe(firstToken);
|
||||||
|
|
||||||
|
await pcContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4. PC RTR 갱신 후 모바일 세션 독립 유지", async ({ playwright }) => {
|
||||||
|
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
|
||||||
|
|
||||||
|
await pcContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(1000); // 1초 간격 지연
|
||||||
|
|
||||||
|
await mobileContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(1000); // 1초 간격 지연
|
||||||
|
|
||||||
|
// PC 토큰을 RTR 갱신함
|
||||||
|
const pcRefresh = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(pcRefresh.status()).toBe(200);
|
||||||
|
|
||||||
|
// 모바일 세션은 PC와 연동되지 않고 독립적으로 정상 갱신되어야 함
|
||||||
|
const mobileRefresh = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(mobileRefresh.status()).toBe(200);
|
||||||
|
|
||||||
|
await pcContext.dispose();
|
||||||
|
await mobileContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5. Replay Attack - 구 토큰 재주입 → 400", async ({ playwright }) => {
|
||||||
|
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
|
||||||
|
// 로그인 후 구 토큰 확보
|
||||||
|
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
const oldToken = getCookieValue(loginRes.headersArray(), "refresh-token");
|
||||||
|
|
||||||
|
await delay(1000); // 1초 간격 지연
|
||||||
|
|
||||||
|
// 1차 갱신을 수행하여 서버(Redis)의 저장 값을 갱신
|
||||||
|
const refreshRes1 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(refreshRes1.status()).toBe(200);
|
||||||
|
|
||||||
|
// 구 토큰을 명시적으로 세팅하여 재사용 시도
|
||||||
|
const replayContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
await replayContext.storageState({
|
||||||
|
cookies: [
|
||||||
|
{
|
||||||
|
name: "refresh-token",
|
||||||
|
value: oldToken!,
|
||||||
|
domain: "localhost",
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const replayRes = await replayContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
// 사용 완료된 토큰 재주입은 거부되어야 함 (백엔드는 401 반환)
|
||||||
|
expect(replayRes.status()).toBe(401);
|
||||||
|
|
||||||
|
await pcContext.dispose();
|
||||||
|
await replayContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6. refresh-token 없이 silent refresh → 401", async ({ playwright }) => {
|
||||||
|
const cleanContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
const res = await cleanContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
|
||||||
|
// 토큰 부재 시 401 반환
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.message).toBe("Can't refresh");
|
||||||
|
|
||||||
|
await cleanContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7. 잘못된 비밀번호 → 401", async ({ request }) => {
|
||||||
|
const res = await request.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: "WrongPassword999!" },
|
||||||
|
headers: { "X-Device-Id": DEVICE_PC },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.message).toBe("Invalid Credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("8. 존재하지 않는 ID → 401", async ({ request }) => {
|
||||||
|
const res = await request.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: "non_existent_user_xyz", password: adminPassword },
|
||||||
|
headers: { "X-Device-Id": DEVICE_PC },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.message).toBe("Invalid Credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("9. 로그아웃 후 쿠키 전체 삭제", async ({ playwright }) => {
|
||||||
|
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
await pcContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그아웃 호출
|
||||||
|
const logoutRes = await pcContext.post("/api/auth/logout");
|
||||||
|
expect(logoutRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// 클라이언트의 인증 쿠키들이 소멸되었는지 검증
|
||||||
|
const state = await pcContext.storageState();
|
||||||
|
const refreshCookie = state.cookies.find((c) => c.name === "refresh-token");
|
||||||
|
const accessCookie = state.cookies.find((c) => c.name === "access-token");
|
||||||
|
const userCookie = state.cookies.find((c) => c.name === "user-id");
|
||||||
|
|
||||||
|
expect(refreshCookie).toBeUndefined();
|
||||||
|
expect(accessCookie).toBeUndefined();
|
||||||
|
expect(userCookie).toBeUndefined();
|
||||||
|
|
||||||
|
await pcContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("10. PC 로그아웃 후 모바일 세션 독립 유지", async ({ playwright }) => {
|
||||||
|
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
|
||||||
|
|
||||||
|
await pcContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
await mobileContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
// PC 로그아웃
|
||||||
|
await pcContext.post("/api/auth/logout");
|
||||||
|
|
||||||
|
// 모바일은 PC의 로그아웃과 무관하게 독립적으로 유효하게 동작해야 함
|
||||||
|
const mobileRefresh = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(mobileRefresh.status()).toBe(200);
|
||||||
|
|
||||||
|
await pcContext.dispose();
|
||||||
|
await mobileContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("11. UI 로그인 후 httpOnly 쿠키 E2E 확인", async ({ page }) => {
|
||||||
|
// UI 로그인 페이지 접속
|
||||||
|
await page.goto("/auth/login");
|
||||||
|
|
||||||
|
// Form 필드 입력
|
||||||
|
await page.locator("input#id").fill(adminId);
|
||||||
|
await page.locator("input#password").fill(adminPassword);
|
||||||
|
|
||||||
|
// 디바이스 ID 쿠키 설정 시뮬레이션
|
||||||
|
await page.context().addCookies([
|
||||||
|
{
|
||||||
|
name: "device-id",
|
||||||
|
value: DEVICE_PC,
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 로그인 버튼 클릭
|
||||||
|
await page.locator(".login_btn").click();
|
||||||
|
|
||||||
|
// 성공 시 대시보드 페이지 이동 대기
|
||||||
|
await page.waitForURL("**/dashboard");
|
||||||
|
|
||||||
|
// E2E 보안 요구사항: 클라이언트 브라우저 JavaScript(document.cookie)로는 httpOnly 쿠키를 읽을 수 없어야 함
|
||||||
|
const clientCookies = await page.evaluate(() => document.cookie);
|
||||||
|
expect(clientCookies).not.toContain("refresh-token");
|
||||||
|
|
||||||
|
// 실제 브라우저의 HTTP Context 쿠키에는 저장되어 있어야 함
|
||||||
|
const contextCookies = await page.context().cookies();
|
||||||
|
const refreshCookie = contextCookies.find((c) => c.name === "refresh-token");
|
||||||
|
expect(refreshCookie).toBeDefined();
|
||||||
|
expect(refreshCookie?.httpOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("12. 동일 계정 3개 브라우저 동시 로그인", async ({ playwright }) => {
|
||||||
|
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
|
||||||
|
const tabletContext = await createDeviceContext(playwright.request, DEVICE_TABLET);
|
||||||
|
|
||||||
|
const pcRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
expect(pcRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const mobileRes = await mobileContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
expect(mobileRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const tabletRes = await tabletContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
expect(tabletRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// 3개 브라우저 세션 모두 정상 리프레시 수행 가능 확인
|
||||||
|
const pcRef = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(pcRef.status()).toBe(200);
|
||||||
|
|
||||||
|
const mobRef = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(mobRef.status()).toBe(200);
|
||||||
|
|
||||||
|
const tabRef = await tabletContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(tabRef.status()).toBe(200);
|
||||||
|
|
||||||
|
await pcContext.dispose();
|
||||||
|
await mobileContext.dispose();
|
||||||
|
await tabletContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("13. sameSite=Strict 쿠키 속성 확인", async ({ request }) => {
|
||||||
|
const response = await request.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
headers: { "X-Device-Id": DEVICE_PC },
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
const headers = response.headersArray();
|
||||||
|
const isStrict = hasCookieAttribute(headers, "refresh-token", "samesite=strict");
|
||||||
|
expect(isStrict).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("14. 연속 3회 RTR 갱신 - 4개 고유 토큰", async ({ playwright }) => {
|
||||||
|
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||||
|
const tokens: string[] = [];
|
||||||
|
|
||||||
|
// 로그인 (1차 토큰 발급)
|
||||||
|
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||||
|
data: { userId: adminId, password: adminPassword },
|
||||||
|
});
|
||||||
|
const t1 = getCookieValue(loginRes.headersArray(), "refresh-token");
|
||||||
|
expect(t1).not.toBeNull();
|
||||||
|
tokens.push(t1!);
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// 1차 갱신 (2차 토큰 발급)
|
||||||
|
const r1 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(r1.status()).toBe(200);
|
||||||
|
const t2 = getCookieValue(r1.headersArray(), "refresh-token");
|
||||||
|
expect(t2).not.toBeNull();
|
||||||
|
tokens.push(t2!);
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// 2차 갱신 (3차 토큰 발급)
|
||||||
|
const r2 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(r2.status()).toBe(200);
|
||||||
|
const t3 = getCookieValue(r2.headersArray(), "refresh-token");
|
||||||
|
expect(t3).not.toBeNull();
|
||||||
|
tokens.push(t3!);
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// 3차 갱신 (4차 토큰 발급)
|
||||||
|
const r3 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||||
|
expect(r3.status()).toBe(200);
|
||||||
|
const t4 = getCookieValue(r3.headersArray(), "refresh-token");
|
||||||
|
expect(t4).not.toBeNull();
|
||||||
|
tokens.push(t4!);
|
||||||
|
|
||||||
|
// 4개 토큰이 모두 고유한지 검증 (Set의 중복 제거 크기로 판별)
|
||||||
|
const uniqueTokens = new Set(tokens);
|
||||||
|
expect(uniqueTokens.size).toBe(4);
|
||||||
|
|
||||||
|
await pcContext.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
pnpm exec playwright test tests/multi-device-auth.spec.ts --project=chromium
|
||||||
|
|
||||||
|
|
||||||
|
cd frontend/apps/cs
|
||||||
|
pnpm test:playwright
|
||||||
|
|
||||||
|
|
||||||
|
💡 요약 및 권장 가이드 device-id의 유지 처리
|
||||||
|
로그아웃 시 파기 대상: 인증 토큰 쿠키 (access-token, refresh-token)
|
||||||
|
로그아웃 시 유지 대상: 기기 식별자 쿠키 및 로컬 스토리지 데이터 (device-id)
|
||||||
|
따라서 현재 구성하신 대로 쿠키/로컬 스토리지 상의 device-id는 로그아웃 시 지우지 않고 브라우저에 영속적으로 남겨두는 설계가 가장 안전하고 올바른 아키텍처입니다.
|
||||||
|
|
||||||
|
|
||||||
|
[WebServer]
|
||||||
|
[WebServer] > cs@0.1.0 dev /Users/changwng/workspace.ona/frontend/apps/cs
|
||||||
|
[WebServer] > next dev
|
||||||
|
[WebServer]
|
||||||
|
[WebServer] ⚠ Warning: Next.js inferred your workspace root, but it may not be correct.
|
||||||
|
[WebServer] We detected multiple lockfiles and selected the directory of /Users/changwng/package-lock.json as the root directory.
|
||||||
|
[WebServer] To silence this warning, set `outputFileTracingRoot` in your Next.js config, or consider removing one of the lockfiles if it's not needed.
|
||||||
|
[WebServer] See https://nextjs.org/docs/app/api-reference/config/next-config-js/output#caveats for more information.
|
||||||
|
[WebServer] Detected additional lockfiles:
|
||||||
|
[WebServer] * /Users/changwng/workspace.ona/frontend/pnpm-lock.yaml
|
||||||
|
[WebServer]
|
||||||
|
[WebServer] ▲ Next.js 15.5.18
|
||||||
|
[WebServer] - Local: http://localhost:3000
|
||||||
|
[WebServer] - Network: http://192.168.0.250:3000
|
||||||
|
[WebServer] - Environments: .env.local
|
||||||
|
[WebServer] - Experiments (use with caution):
|
||||||
|
[WebServer] · proxyTimeout: 600000
|
||||||
|
[WebServer]
|
||||||
|
[WebServer] ✓ Starting...
|
||||||
|
[WebServer] ✓ Ready in 1733ms
|
||||||
|
[WebServer] ✓ Compiled /middleware in 114ms (161 modules)
|
||||||
|
[WebServer] ○ Compiling /dashboard ...
|
||||||
|
[WebServer] ✓ Compiled /dashboard in 4.3s (6804 modules)
|
||||||
|
|
||||||
|
Running 14 tests using 1 worker
|
||||||
|
|
||||||
|
[WebServer] GET /dashboard 200 in 4913ms
|
||||||
|
[WebServer] ○ Compiling /api/proxy/[...url] ...
|
||||||
|
[WebServer] ✓ Compiled /api/proxy/[...url] in 10.2s (7070 modules)
|
||||||
|
[WebServer] [BFF Proxy Error] Backend returned status 401 for user-service/login
|
||||||
|
[WebServer] [BFF Proxy Error Body] {"status":400,"message":"해당 사용자가 없습니다.","code":"B001"}
|
||||||
|
[WebServer] POST /api/proxy/user-service/login 401 in 10766ms
|
||||||
|
✘ 1 [chromium] › tests/multi-device-auth.spec.ts:64:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정 (10.8s)
|
||||||
|
- 2 [chromium] › tests/multi-device-auth.spec.ts:81:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 2. PC/모바일/태블릿 독립 refresh-token 발급
|
||||||
|
- 3 [chromium] › tests/multi-device-auth.spec.ts:115:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 3. Silent Refresh RTR - 새 토큰 교체
|
||||||
|
- 4 [chromium] › tests/multi-device-auth.spec.ts:138:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 4. PC RTR 갱신 후 모바일 세션 독립 유지
|
||||||
|
- 5 [chromium] › tests/multi-device-auth.spec.ts:161:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 5. Replay Attack - 구 토큰 재주입 → 400
|
||||||
|
- 6 [chromium] › tests/multi-device-auth.spec.ts:195:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 6. refresh-token 없이 silent refresh → 401
|
||||||
|
- 7 [chromium] › tests/multi-device-auth.spec.ts:207:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 7. 잘못된 비밀번호 → 401
|
||||||
|
- 8 [chromium] › tests/multi-device-auth.spec.ts:217:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 8. 존재하지 않는 ID → 401
|
||||||
|
- 9 [chromium] › tests/multi-device-auth.spec.ts:227:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 9. 로그아웃 후 쿠키 전체 삭제
|
||||||
|
- 10 [chromium] › tests/multi-device-auth.spec.ts:250:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 10. PC 로그아웃 후 모바일 세션 독립 유지
|
||||||
|
- 11 [chromium] › tests/multi-device-auth.spec.ts:272:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 11. UI 로그인 후 httpOnly 쿠키 E2E 확인
|
||||||
|
- 12 [chromium] › tests/multi-device-auth.spec.ts:306:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 12. 동일 계정 3개 브라우저 동시 로그인
|
||||||
|
- 13 [chromium] › tests/multi-device-auth.spec.ts:341:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 13. sameSite=Strict 쿠키 속성 확인
|
||||||
|
- 14 [chromium] › tests/multi-device-auth.spec.ts:353:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 14. 연속 3회 RTR 갱신 - 4개 고유 토큰
|
||||||
|
|
||||||
|
|
||||||
|
1) [chromium] › tests/multi-device-auth.spec.ts:64:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정
|
||||||
|
|
||||||
|
Error: expect(received).toBe(expected) // Object.is equality
|
||||||
|
|
||||||
|
Expected: 200
|
||||||
|
Received: 401
|
||||||
|
|
||||||
|
67 | headers: { "X-Device-Id": DEVICE_PC },
|
||||||
|
68 | });
|
||||||
|
> 69 | expect(response.status()).toBe(200);
|
||||||
|
| ^
|
||||||
|
70 |
|
||||||
|
71 | const headers = response.headersArray();
|
||||||
|
72 | const refreshToken = getCookieValue(headers, "refresh-token");
|
||||||
|
at /Users/changwng/workspace.ona/frontend/apps/cs/tests/multi-device-auth.spec.ts:69:31
|
||||||
|
|
||||||
|
Error Context: test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/error-context.md
|
||||||
|
|
||||||
|
attachment #2: trace (application/zip) ─────────────────────────────────────────────────────────
|
||||||
|
test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/trace.zip
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
pnpm exec playwright show-trace test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/trace.zip
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# apps/st/src/shared/config/env.ts 참고
|
||||||
|
NEXT_PUBLIC_PORT=3000
|
||||||
|
NEXT_PUBLIC_PROXY_HOST=http://localhost:3003
|
||||||
|
NEXT_PUBLIC_SITE_ID=AS
|
||||||
|
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3003
|
||||||
|
NEXT_PUBLIC_SERVER_API_URL=http://localhost:8000
|
||||||
|
SERVER_API_URL=http://localhost:8000
|
||||||
|
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
|
||||||
|
|
||||||
|
NEXT_PUBLIC_ST_SERVICE_URL=https://apigateway.apps.rsonesoft.com
|
||||||
|
NEXT_PUBLIC_ST_REALGRID_KEY=upVcPE+wPOmtLjqyBIh9RkM/nBOseBrflwxYpzGZyYm9cY8amGDkiHqyYT2U1Yh3Dufv8SUhNy63kJZj9DH/cmSe3PbBoyaMZTR5mNBsNuyY63NNzIfzqxzwYuagP76LkxValpjT3ck=
|
||||||
|
|
||||||
|
# 401 시 브라우저 리다이렉트 대상
|
||||||
|
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
|
||||||
|
|
||||||
|
# Sentry Configuration
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=http://f43f0aeff1cae8ea3f50a57f766ae37a@localhost:9001/2
|
||||||
|
NEXT_PUBLIC_SENTRY_ENVIRONMENT=local
|
||||||
|
SENTRY_ORG=sentry
|
||||||
|
SENTRY_PROJECT=as-frontend
|
||||||
|
SENTRY_AUTH_TOKEN=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
|
||||||
|
|
||||||
|
# OpenTelemetry Tracing Configuration
|
||||||
|
OTEL_SERVICE_NAME="as-frontend"
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4320/v1/traces"
|
||||||
|
|
||||||
|
NEXT_PUBLIC_SENTRY_DEBUG=true
|
||||||
@@ -24,6 +24,21 @@ class Client {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.axiosInstance.interceptors.request.use((config) => {
|
this.axiosInstance.interceptors.request.use((config) => {
|
||||||
|
// X-Device-Id 자동 주입 로직 (기기에 고유한 UUID 세팅 및 쿠키/로컬스토리지 보관)
|
||||||
|
let deviceId = Cookies.get("device-id");
|
||||||
|
if (!deviceId && typeof window !== "undefined") {
|
||||||
|
deviceId = localStorage.getItem("device-id") || undefined;
|
||||||
|
if (!deviceId) {
|
||||||
|
deviceId = generateUUID();
|
||||||
|
localStorage.setItem("device-id", deviceId);
|
||||||
|
}
|
||||||
|
Cookies.set("device-id", deviceId, { expires: 365, path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceId) {
|
||||||
|
config.headers["X-Device-Id"] = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
const token = Cookies.get(CLAIM_NAME);
|
const token = Cookies.get(CLAIM_NAME);
|
||||||
const userId = Cookies.get(AUTH_USER_ID);
|
const userId = Cookies.get(AUTH_USER_ID);
|
||||||
|
|
||||||
@@ -87,3 +102,16 @@ class Client {
|
|||||||
|
|
||||||
export type { ClientConfig };
|
export type { ClientConfig };
|
||||||
export { Client };
|
export { Client };
|
||||||
|
|
||||||
|
// 기기 식별을 위한 안전한 v4 UUID 생성 폴백 헬퍼 함수
|
||||||
|
function generateUUID(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+5171
-7713
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user