diff --git a/apps/cs/package.json b/apps/cs/package.json
index b7e1421d8..9d128263a 100644
--- a/apps/cs/package.json
+++ b/apps/cs/package.json
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "test:playwright": "playwright test"
},
"dependencies": {
"@common/assets": "workspace:*",
@@ -49,6 +50,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.0.0",
+ "@playwright/test": "^1.61.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
diff --git a/apps/cs/playwright-report/index.html b/apps/cs/playwright-report/index.html
new file mode 100644
index 000000000..db6ff1e5a
--- /dev/null
+++ b/apps/cs/playwright-report/index.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+data:application/zip;base64,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
\ No newline at end of file
diff --git a/apps/cs/playwright.config.ts b/apps/cs/playwright.config.ts
new file mode 100644
index 000000000..02f9a249b
--- /dev/null
+++ b/apps/cs/playwright.config.ts
@@ -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",
+ },
+});
diff --git a/apps/cs/tests/multi-device-auth.spec.ts b/apps/cs/tests/multi-device-auth.spec.ts
new file mode 100644
index 000000000..7b2633baa
--- /dev/null
+++ b/apps/cs/tests/multi-device-auth.spec.ts
@@ -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();
+ });
+});
diff --git a/docs/01.playweight_test_login.md b/docs/01.playweight_test_login.md
new file mode 100644
index 000000000..986d46f62
--- /dev/null
+++ b/docs/01.playweight_test_login.md
@@ -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