From eb37728532ab0b9c82e2b2ee5d1717460981375d Mon Sep 17 00:00:00 2001 From: jangwoong Date: Wed, 8 Jul 2026 10:46:49 +0900 Subject: [PATCH] =?UTF-8?q?[cs=20frontend]=20playwright=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/package.json | 4 +- apps/cs/playwright-report/index.html | 90 +++++ apps/cs/playwright.config.ts | 60 ++++ apps/cs/tests/multi-device-auth.spec.ts | 415 ++++++++++++++++++++++++ docs/01.playweight_test_login.md | 84 +++++ 5 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 apps/cs/playwright-report/index.html create mode 100644 apps/cs/playwright.config.ts create mode 100644 apps/cs/tests/multi-device-auth.spec.ts create mode 100644 docs/01.playweight_test_login.md 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 + + + + +
+ + + \ 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