Files
Gnuboard7/tests/Playwright/specs/auth/two-factor-login.spec.ts
T
HeuJung 50007d5cc6 fix(auth): 2단계 인증을 켠 사이트의 로그인 흐름 구현
2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.

원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.

이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.

## 구현

- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
 `ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
 「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
 수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
 코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
 뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
 사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
 못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
 문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.

## 이번 점검에서 함께 고친 것

- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
 그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
 한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
 성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
 같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.

## 버전

코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
2026-09-07 17:08:14 +09:00

521 lines
22 KiB
TypeScript

/**
* 로그인 2단계 인증 종단 검증 (공개 #133).
*
* 2단계 인증을 켜면 서버가 로그인에 **두 가지 형태의 200** 을 돌려준다. 화면이 한 형태만
* 가정하면 영문 오류(`Cannot read properties of undefined`)가 뜨고 로그인이 불가능해지며,
* 관리자 로그인은 서버 오류가 되어 설정을 되돌릴 수단까지 사라진다.
*
* 이 spec 은 사이트 설정과 메일 설정을 **가역적으로** 바꾼다 — `test.afterAll` 이 실패해도
* 되돌아가도록 fixture 가 원복을 담당한다. 되돌리지 못하면 이후 모든 로그인이 막히므로
* 반드시 `mode: 'serial'` 로 한 워커에서만 실행한다.
*
* // @scenario controller=user,admin | two_factor=on | code=valid,invalid | resend=active
* // @effects challenge_response_has_no_token, code_step_rendered_on_challenge, no_raw_typeerror_text, resend_cancels_previous_challenge, non_admin_two_factor_revokes_token
*/
import { test, expect, issueToken } from '../../fixtures/auth';
import {
enableTwoFactorFixture,
ensureTwoFactorUser,
plantTwoFactorCode,
purgeTwoFactorUsers,
restoreTwoFactorFixture,
} from '../../fixtures/two-factor';
test.describe.configure({ mode: 'serial' });
const PASSWORD = 'Passw0rd!2fa';
let adminToken: string;
let fixtureRequest: import('@playwright/test').APIRequestContext | undefined;
let fixtureState: Awaited<ReturnType<typeof enableTwoFactorFixture>>;
let memberEmail: string;
let adminEmail: string;
let nonAdminEmail: string;
test.beforeAll(async ({ playwright }) => {
adminToken = issueToken('core.settings.read', 'core.settings.update');
memberEmail = ensureTwoFactorUser('user', { password: PASSWORD });
adminEmail = ensureTwoFactorUser('admin', { admin: true, password: PASSWORD });
nonAdminEmail = ensureTwoFactorUser('nonadmin', { password: PASSWORD });
// worker 범위 request 컨텍스트 — baseURL·ignoreHTTPSErrors 는 설정에서 온다.
fixtureRequest = await playwright.request.newContext({
baseURL: process.env.PLAYWRIGHT_BASE_URL,
ignoreHTTPSErrors: true,
});
fixtureState = await enableTwoFactorFixture(fixtureRequest, adminToken);
});
test.afterAll(async () => {
// 되돌리지 못한 채 끝나면 사이트의 모든 로그인이 2단계 인증을 요구하게 된다.
if (fixtureState && fixtureRequest) {
await restoreTwoFactorFixture(fixtureRequest, adminToken, fixtureState);
}
await fixtureRequest?.dispose();
// 알려진 비밀번호를 가진 계정(관리자 포함)을 남기지 않는다.
purgeTwoFactorUsers();
});
/**
* 로그인 응답에서 challenge_id 를 읽는다.
*
* @param page Playwright 페이지
* @param urlPart 대기할 요청 경로 조각
* @param submit 제출을 수행하는 함수
*/
async function submitAndReadChallenge(
page: import('@playwright/test').Page,
urlPart: string,
submit: () => Promise<void>
): Promise<string> {
const [response] = await Promise.all([
page.waitForResponse((r) => r.url().includes(urlPart) && r.request().method() === 'POST'),
submit(),
]);
const body = await response.json();
expect(response.status(), '2단계 인증이 켜져 있으면 로그인은 200 챌린지를 돌려준다').toBe(200);
expect(body?.data?.two_factor_required).toBe(true);
// 코드 확인 전에 토큰이 실리면 2단계 인증이 없는 것과 같다.
expect(body?.data?.token, '코드 확인 전에 토큰이 발급되었습니다').toBeFalsy();
return String(body.data.challenge_id);
}
test('사용자 로그인 — 인증번호 단계로 전환되고 코드 확인 후 로그인된다', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
// 2단계 입력이 나타나고 1단계 입력은 사라진다.
const codeInput = page.locator('input[name="two_factor_code"]');
await expect(codeInput).toBeVisible({ timeout: 10_000 });
await expect(page.locator('input[name="email"]')).toHaveCount(0);
// 영문 TypeError 원문이 화면에 남으면 안 된다.
await expect(page.locator('body')).not.toContainText('Cannot read properties of undefined');
// 오답 → 오류 문구, 로그인 미완료
await codeInput.fill('000000');
await page.click('form button[type="submit"]');
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 10_000 });
expect(page.url()).toContain('/login');
// 정답 → 홈 이동
const code = plantTwoFactorCode(challengeId);
await codeInput.fill(code);
await page.click('form button[type="submit"]');
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
timeout: 15_000,
});
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
expect(token, '로그인 완료 후 토큰이 저장되어야 합니다').toBeTruthy();
expect(token).not.toBe('undefined');
});
test('사용자 로그인 — 인증번호 다시 받기는 새 challenge 를 발급하고 입력을 비운다', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const first = await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
const codeInput = page.locator('input[name="two_factor_code"]');
await expect(codeInput).toBeVisible({ timeout: 10_000 });
await codeInput.fill('111111');
const [resendResponse] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/two-factor/resend') && r.request().method() === 'POST'),
page.getByRole('button', { name: /다시|Resend|再送/ }).click(),
]);
const resendBody = await resendResponse.json();
expect(resendResponse.status()).toBe(200);
const second = String(resendBody.data.challenge_id);
expect(second, '재발송이 같은 challenge 를 돌려주면 새 코드가 발송되지 않은 것입니다').not.toBe(first);
// 앞서 입력한 값이 남아 있으면 새 코드를 받았는데 옛 값으로 제출된다.
await expect(codeInput).toHaveValue('');
const code = plantTwoFactorCode(second);
await codeInput.fill(code);
await page.click('form button[type="submit"]');
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
timeout: 15_000,
});
});
/**
* 인증 단계 상태는 전역이라 화면을 떠나도 남는다. 그래서 되돌리는 통로가 셋 다 살아 있어야 한다 —
* 「처음부터」 버튼 · 새로고침 · 다른 화면으로 나갔다 돌아오기. 하나라도 빠지면 사용자가 1단계로
* 돌아오지 못한 채 이미 만료된 challenge 앞에 갇힌다.
*
* 새로고침 축은 잘못 저장된 토큰(`"undefined"`) 회귀도 함께 잡는다 — 그 값이 남으면 이후 요청이
* 전부 401 이 되어 `/login?reason=session_expired` 로 튕겼다(공개 #133 의 두 번째 증상).
*
* // @scenario controller=user | two_factor=on | action=restart
* // @effects restart_resets_to_credential_step, init_actions_reset_two_factor_state
*/
test('인증번호 단계 — 처음부터·새로고침·재진입 모두 1단계로 돌아온다', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const enterCodeStep = async () => {
await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
await expect(page.locator('input[name="two_factor_code"]')).toBeVisible({ timeout: 10_000 });
};
const expectCredentialStep = async () => {
await expect(page.locator('input[name="email"]')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('input[name="two_factor_code"]')).toHaveCount(0);
};
// ① 「처음부터」 버튼
await enterCodeStep();
await page.getByRole('button', { name: /처음부터|Start over|最初から/ }).click();
await expectCredentialStep();
// ② 새로고침 — 잘못된 토큰이 남았다면 여기서 session_expired 로 튕긴다.
await enterCodeStep();
await page.reload({ waitUntil: 'domcontentloaded' });
expect(page.url(), '새로고침이 만료 안내로 튕기면 토큰이 잘못 저장된 것입니다').not.toContain(
'reason=session_expired'
);
const staleToken = await page.evaluate(() => localStorage.getItem('auth_token'));
expect(staleToken, '코드 확인 전에는 토큰이 저장되면 안 됩니다').not.toBe('undefined');
await expectCredentialStep();
// ③ 다른 화면으로 나갔다 돌아오기 — init_actions 리셋이 없으면 2단계가 그대로 남는다.
await enterCodeStep();
await page.goto('/register');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expectCredentialStep();
});
test('관리자 로그인 — 챌린지 응답이 서버 오류가 아니고 코드 확인 후 관리자 화면으로 간다', async ({ page }) => {
await page.goto('/admin/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const challengeId = await submitAndReadChallenge(page, '/api/auth/admin/login', async () => {
await page.fill('input[name="email"]', adminEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('#login_submit_button');
});
const codeInput = page.locator('#two_factor_code');
await expect(codeInput).toBeVisible({ timeout: 10_000 });
await expect(page.locator('body')).not.toContainText('Cannot read properties of undefined');
await codeInput.fill(plantTwoFactorCode(challengeId));
await page.locator('#login_two_factor_submit').click();
await page.waitForFunction(() => !window.location.pathname.startsWith('/admin/login'), {
timeout: 15_000,
});
});
test('관리자 로그인 — 관리자가 아닌 계정은 코드 확인에 성공해도 거부되고 토큰이 남지 않는다', async ({ page }) => {
await page.goto('/admin/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const challengeId = await submitAndReadChallenge(page, '/api/auth/admin/login', async () => {
await page.fill('input[name="email"]', nonAdminEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('#login_submit_button');
});
const codeInput = page.locator('#two_factor_code');
await expect(codeInput).toBeVisible({ timeout: 10_000 });
await codeInput.fill(plantTwoFactorCode(challengeId));
const [verifyResponse] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/admin/login/two-factor') && r.request().method() === 'POST'),
page.locator('#login_two_factor_submit').click(),
]);
expect(verifyResponse.status()).toBe(403);
// 코드 확인 시점에 발급된 토큰이 회수되지 않으면 유효한 세션이 남는다.
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
expect(token, '거부된 로그인이 토큰을 남겼습니다').toBeFalsy();
expect(page.url()).toContain('/admin/login');
});
test('인증번호 단계 — Enter 키는 확인만 보내고 새 challenge 를 발급하지 않는다', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
const codeInput = page.locator('input[name="two_factor_code"]');
await expect(codeInput).toBeVisible({ timeout: 10_000 });
// 세 자리까지는 확인 버튼이 눌리지 않는다.
await codeInput.fill('123');
const verifyButton = page.locator('form button[type="submit"]');
await expect(verifyButton).toBeDisabled();
await codeInput.fill(plantTwoFactorCode(challengeId));
await expect(verifyButton).toBeEnabled();
const posts: string[] = [];
page.on('request', (req) => {
if (req.method() === 'POST' && req.url().includes('/api/auth/')) posts.push(req.url());
});
await codeInput.press('Enter');
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
timeout: 15_000,
});
const verifyCalls = posts.filter((u) => u.includes('/login/two-factor')).length;
const loginCalls = posts.filter((u) => u.endsWith('/api/auth/login')).length;
expect(verifyCalls, 'Enter 가 인증번호 확인을 보내지 않았습니다').toBe(1);
// 상호배타 조건이 빠지면 Enter 가 새 challenge 를 발급해 흐름이 깨진다.
expect(loginCalls, 'Enter 가 비밀번호 단계를 다시 호출했습니다').toBe(0);
});
test('인증번호 단계 — 모바일 폭과 일본어 로케일에서 문구·배치가 깨지지 않는다', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.addInitScript(() => localStorage.setItem('g7_locale', 'ja'));
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
await expect(page.locator('input[name="two_factor_code"]')).toBeVisible({ timeout: 10_000 });
// 다국어 키가 해석되지 않으면 `$t:` 토큰이 그대로 화면에 남는다.
const body = (await page.locator('body').innerText()) ?? '';
expect(body).not.toContain('$t:');
expect(body).not.toContain('auth.two_factor');
// 다시 받기·처음부터 두 버튼이 좁은 폭에서도 컨테이너를 넘지 않는다.
const buttons = page.locator('form button[type="button"]');
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(2);
for (let i = 0; i < count; i += 1) {
const box = await buttons.nth(i).boundingBox();
if (box) {
expect(box.width, '버튼이 375px 화면을 넘칩니다').toBeLessThanOrEqual(375);
}
}
});
test('공개 본인인증 화면으로는 로그인 challenge 를 소진할 수 없다', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
const code = plantTwoFactorCode(challengeId);
const status = await page.evaluate(async (id) => {
const res = await fetch(`/api/identity/challenges/${id}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ code: '135790' }),
});
return res.status;
}, challengeId);
expect(status, '로그인 challenge 가 공개 본인인증 경로로 소진되었습니다').toBe(403);
// 거부는 상태를 바꾸지 않으므로 로그인은 그대로 완료된다.
await page.locator('input[name="two_factor_code"]').fill(code);
await page.click('form button[type="submit"]');
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
timeout: 15_000,
});
});
/**
* 인증번호를 여러 번 틀려도 그 challenge 로 계속 시도할 수 있어야 한다.
*
* 오답 때마다 입력을 비우면 오타 한 글자를 고치려던 사용자가 전체를 다시 친다 —
* 입력을 남기는 것이 확정된 동작이다(레이아웃의 `loginTwoFactor` onError 는 code 를
* 건드리지 않는다). 비우도록 바뀌면 아래 `toHaveValue` 가 red 가 된다.
*
* // @scenario controller=user | two_factor=on | code=invalid,valid
* // @effects invalid_code_retries_until_success
*/
test('인증번호 단계 — 세 번 틀려도 입력이 남고 네 번째 정답으로 로그인된다', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
const codeInput = page.locator('input[name="two_factor_code"]');
await expect(codeInput).toBeVisible({ timeout: 10_000 });
// 본인인증 정책 상한(max_attempts=5) 안이라 세 번까지는 challenge 가 살아 있다.
for (let attempt = 1; attempt <= 3; attempt += 1) {
await codeInput.fill('000000');
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes('/api/auth/login/two-factor') &&
!r.url().includes('/resend') &&
r.request().method() === 'POST'
),
page.click('form button[type="submit"]'),
]);
expect(response.status(), `${attempt}회째 오답이 거부되지 않았습니다`).toBe(401);
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 10_000 });
// 오답 뒤에도 입력은 남는다.
await expect(codeInput).toHaveValue('000000');
expect(page.url()).toContain('/login');
}
await codeInput.fill(plantTwoFactorCode(challengeId));
await page.click('form button[type="submit"]');
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
timeout: 15_000,
});
});
/**
* 로그인이 끝난 직후의 상태 — 2단계 흔적이 남으면 다음에 로그인 화면을 열었을 때
* 비밀번호 단계가 아니라 코드 단계가 뜬다.
*
* // @scenario controller=user | two_factor=on | code=valid
* // @effects session_state_cleared_after_two_factor_success
*/
test('인증 완료 직후 — 2단계 상태가 지워지고 사용자·토큰이 자리 잡는다', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
await page.locator('input[name="two_factor_code"]').fill(plantTwoFactorCode(challengeId));
await page.click('form button[type="submit"]');
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
timeout: 15_000,
});
const state = await page.evaluate(() => {
const w = window as unknown as Record<string, any>;
const globalState = w.__templateApp?.getGlobalState?.() ?? w.G7Core?.state?.get?.() ?? {};
return {
twoFactor: globalState.twoFactor ?? null,
isLoggingIn: globalState.isLoggingIn ?? null,
uuid: String(globalState.currentUser?.uuid ?? ''),
email: String(globalState.currentUser?.email ?? ''),
token: String(localStorage.getItem('auth_token') ?? ''),
};
});
expect(state.twoFactor, '로그인이 끝났는데 2단계 상태가 남아 있습니다').toBeNull();
expect(state.isLoggingIn, '로딩 표시가 켜진 채 남았습니다').toBe(false);
expect(state.uuid.length, '로그인한 사용자가 전역 상태에 실리지 않았습니다').toBeGreaterThan(0);
expect(state.email).toBe(memberEmail);
// `"undefined"` 가 저장되던 결함(#133)은 길이 9 라 존재 검사만으로는 통과한다.
expect(state.token.length, '저장된 토큰이 실제 토큰이 아닙니다').toBeGreaterThan(20);
});
/**
* 확인 버튼을 빠르게 두 번 눌러도 인증 요청은 한 번만 나가야 한다.
*
* 두 번 나가면 두 번째가 이미 소진된 challenge 를 확인하려다 실패해, 로그인은 됐는데
* 오류 문구가 함께 뜨는 상태가 된다.
*
* // @scenario controller=user | two_factor=on | code=valid
* // @effects double_submit_sends_single_verify_request
*/
test('인증번호 단계 — 확인 버튼을 두 번 눌러도 확인 요청은 한 번만 나간다', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const challengeId = await submitAndReadChallenge(page, '/api/auth/login', async () => {
await page.fill('input[name="email"]', memberEmail);
await page.fill('input[name="password"]', PASSWORD);
await page.click('form button[type="submit"]');
});
const codeInput = page.locator('input[name="two_factor_code"]');
await expect(codeInput).toBeVisible({ timeout: 10_000 });
await codeInput.fill(plantTwoFactorCode(challengeId));
const verifyCalls: string[] = [];
page.on('request', (req) => {
if (
req.method() === 'POST' &&
req.url().includes('/api/auth/login/two-factor') &&
!req.url().includes('/resend')
) {
verifyCalls.push(req.url());
}
});
const verifyButton = page.locator('form button[type="submit"]');
const startedAt = Date.now();
await verifyButton.click();
// 두 번째 클릭이 도달하기 전에 버튼이 잠겨야 한다. 잠금은 제출 시퀀스가 세우는
// `isLoggingIn` 이 화면에 반영될 때 걸리므로, 그 반영이 사람의 두 번째 클릭보다
// 빨라야 한다는 뜻이다.
await expect(verifyButton).toBeDisabled({ timeout: 1_000 });
const lockedAfterMs = Date.now() - startedAt;
expect(
lockedAfterMs,
`확인 버튼이 잠기기까지 ${lockedAfterMs}ms 가 걸렸습니다 — 사람의 두 번째 클릭이 그 사이에 들어옵니다`
).toBeLessThan(200);
// 잠긴 뒤의 두 번째 클릭은 브라우저가 비활성 버튼에 전달하지 않는다.
await verifyButton.click({ force: true, timeout: 2_000 }).catch(() => undefined);
await page.waitForFunction(() => !window.location.pathname.startsWith('/login'), {
timeout: 15_000,
});
expect(verifyCalls.length, '확인 요청이 두 번 나갔습니다').toBe(1);
});