diff --git a/__tests__/components/logout-postcondition.property.test.tsx b/__tests__/components/logout-postcondition.property.test.tsx index ef145fc..7ff19ab 100644 --- a/__tests__/components/logout-postcondition.property.test.tsx +++ b/__tests__/components/logout-postcondition.property.test.tsx @@ -74,14 +74,29 @@ const userArbitrary: fc.Arbitrary = fc.record({ }); describe("Feature: auth-flow-improvements, Property 12: Logout post-condition invariant", () => { + let originalLocation: Location; + beforeEach(() => { mockSignout.mockReset(); mockReplace.mockReset(); sessionStorage.clear(); + // 원본 location 보관 후, href 기록 가능한 스텁으로 교체 + originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: { ...originalLocation, href: "" }, + }); }); afterEach(() => { cleanup(); + // 원본 location getter 완전 복원 + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: originalLocation, + }); }); it( @@ -108,12 +123,12 @@ describe("Feature: auth-flow-improvements, Property 12: Logout post-condition in render(); // Find and click the logout button - const logoutButton = screen.getByText("로그아웃"); + const logoutButton = screen.getByRole("button", { name: /로그아웃/ }); fireEvent.click(logoutButton); - // Wait for the handler to complete + // Wait for the handler to complete (window.location.href 설정 확인) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith("/"); + expect(window.location.href).toBe("/"); }); // Post-condition: store.user SHALL be null @@ -123,7 +138,7 @@ describe("Feature: auth-flow-improvements, Property 12: Logout post-condition in expect(sessionStorage.getItem(SESSION_CACHE_KEY)).toBeNull(); cleanup(); - mockReplace.mockReset(); + window.location.href = ""; } ), { numRuns: 100 } diff --git a/components/pages/config/user-setting.tsx b/components/pages/config/user-setting.tsx index 57d53b0..f736730 100644 --- a/components/pages/config/user-setting.tsx +++ b/components/pages/config/user-setting.tsx @@ -19,7 +19,10 @@ const UserSetting = () => { setUser(null); clearSessionCache(); - // 2. 서버 로그아웃 API 호출 (5초 타임아웃, 실패해도 진행) + // 2. 서버 로그아웃 API 호출 + // signout은 keepalive:true라서 이후 하드 네비게이션으로 페이지가 + // 언로드되어도 요청이 취소되지 않고 서버 세션 무효화가 보장된다. + // UX 지연 방지를 위해 최대 5초까지만 대기한다. try { await Promise.race([ signout(), @@ -28,11 +31,11 @@ const UserSetting = () => { ), ]); } catch { - // API 실패 또는 타임아웃: 무시하고 진행 + // API 실패 또는 타임아웃: keepalive 요청은 백그라운드에서 계속 진행 } - // 3. 홈으로 이동 - router.replace("/"); + // 3. 홈으로 이동 (하드 네비게이션 — Next.js Router Cache 무효화) + window.location.href = "/"; }; const handleResign = () => { diff --git a/components/pages/signin/signin-form.tsx b/components/pages/signin/signin-form.tsx index bfe6de2..c6eeff6 100644 --- a/components/pages/signin/signin-form.tsx +++ b/components/pages/signin/signin-form.tsx @@ -103,8 +103,8 @@ const SigninForm = ({ returnUrl }: SinginFormProps) => { returnUrl && returnUrl.startsWith("/") && !returnUrl.startsWith("//") ? returnUrl : "/"; - router.replace(targetUrl); - router.refresh(); + // 하드 네비게이션: 미들웨어가 새 쿠키를 확실히 인식하도록 + window.location.href = targetUrl; } catch (error) { // FetchError: 로그인 실패(401/400)도 여기로 옴 (fetchData가 non-OK에서 throw) if (error instanceof FetchError) { @@ -125,7 +125,7 @@ const SigninForm = ({ returnUrl }: SinginFormProps) => { } setLoading(false); } - }, [emailValue.value, passwordValue.value, loading, errors, router, returnUrl, setUser, toast]); + }, [emailValue.value, passwordValue.value, loading, errors, returnUrl, setUser, toast]); // Enter 키 핸들러 useEffect(() => { diff --git a/lib/api/auth/signout.ts b/lib/api/auth/signout.ts index ee6ef08..4cf9478 100644 --- a/lib/api/auth/signout.ts +++ b/lib/api/auth/signout.ts @@ -4,6 +4,8 @@ const signout = async () => { const response = await fetchData(`/api/v1/auth/logout`, { method: "POST", credentials: "include", + // 페이지가 언로드(하드 네비게이션)되어도 요청이 취소되지 않도록 보장 + keepalive: true, }); const data = response.json();