Files
Gnuboard7/tests/Playwright
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
..

Playwright E2E (코어 영역)

G7 코어의 Playwright (TypeScript) 기반 결정론적 E2E 인프라.

빠른 시작

# PowerShell (개발 환경)
$env:PLAYWRIGHT_BASE_URL='https://g7.dev'
npm run test:e2e            # 전체 spec
npm run test:e2e:smoke      # @smoke 태그 (--grep @smoke)
npm run test:e2e:ui         # Playwright UI 모드 (디버깅)

# 특정 spec / 디렉토리만
npx playwright test tests/Playwright/specs/smoke/admin-base.spec.ts
npx playwright test tests/Playwright/specs/layout-editor --workers=2
# Bash (CI/Linux)
PLAYWRIGHT_BASE_URL=https://g7.dev npx playwright test

루트 package.json 이 제공하는 E2E 스크립트는 test:e2e · test:e2e:smoke · test:e2e:ui 3개다. 그 밖의 범위 지정은 npx playwright test 에 경로나 --grep 을 직접 준다.

브라우저는 Chromium 전용이다 (playwright.config.ts 의 projects 에 chromium 하나). Firefox/WebKit 도입 조건은 docs/testing/e2e-testing.md §3 "브라우저 범위" 참조.

Base URL 해석 우선순위 (하드코딩 회피)

playwright.config.ts 의 resolveBaseUrl() 가 다음 순서로 결정:

  1. PLAYWRIGHT_BASE_URL 환경변수 — CI / 명시적 오버라이드 (최우선)
  2. .env 의 APP_URL — 단 http://localhost 류는 fallback 부적합 (Apache vhost 미경유)
  3. 그 외 — 명시 에러 (의도적으로 base URL 을 지정하지 않으면 spec 미실행)

도메인이 가변(개발자/CI/운영 환경별 다름)이므로 fallback 하드코딩 금지.

디렉토리 구조

tests/Playwright/
├── fixtures/
│   ├── auth.ts                        (Sanctum 토큰 발급 + addInitScript 주입)
│   ├── ecommerce-auth.ts              (구매자 세션 fixture)
│   ├── layout-editor.ts               (편집기 진입/저장 헬퍼)
│   ├── seed-layout.ts                 (시드 화면 준비/정리)
│   └── seed-layouts/                  (시드 화면 원본 JSON)
├── global-setup.ts                    (시드 화면 설치)
├── global-teardown.ts                 (시드 화면 제거)
├── specs/
│   ├── smoke/                         (핵심 화면 — 14 파일)
│   ├── admin/                         (관리자 화면 — 17 파일)
│   ├── layout-editor/                 (레이아웃 편집기 — 46 파일)
│   ├── pagination/                    (목록 페이지네이션 — 4 파일)
│   ├── search/                        (검색 — 1 파일)
│   └── *.spec.ts                      (엔진/렌더링 회귀 — 14 파일)
├── .auth/                             (gitignore — storageState 캐시)
└── README.md                          (본 문서)

@smoke 표기는 70곳(25개 파일)이며, 그중 1곳은 test.describe 단위라 --grep @smoke 로 선택되는 테스트 수는 표기 수보다 많다. 파일·건수는 변하므로 판단이 필요할 때 직접 센다.

코어/확장 분리 원칙

기존 PHPUnit / Vitest 와 동일 원칙. 모듈/플러그인/템플릿 E2E spec 을 코어 디렉토리에 작성 금지.

영역 spec 위치 config 위치
코어 tests/Playwright/specs/ playwright.config.ts (루트)
모듈 modules/_bundled/{id}/tests/Playwright/specs/ 모듈 자체 playwright.config.ts
플러그인 plugins/_bundled/{id}/tests/Playwright/specs/ 플러그인 자체
템플릿 templates/_bundled/{id}/tests/Playwright/specs/ 템플릿 자체

확장이 자체 E2E spec 을 추가하는 sample skeleton 은 modules/_bundled/sirsoft-ecommerce/tests/Playwright/ 참조.

인증 가드 (PlaywrightIssueToken 3중 보안)

app/Console/Commands/PlaywrightIssueToken.php 는 다음 3중 가드를 강제한다:

  1. CLI 한정 — php_sapi_name() === 'cli' (production 웹 요청에서 절대 도달 불가)
  2. G7_PLAYWRIGHT_BYPASS=1 환경변수 옵트인 — .env 영구 수정 불요, 인라인 부착만으로 활성화
  3. APP_DEBUG=true inline override — production + debug=false 환경에서도 토큰 발급 가능

호출은 fixtures/auth.ts 의 issueToken() 헬퍼가 자동으로 G7_PLAYWRIGHT_BYPASS=1 을 부착하므로 spec 작성자는 가드를 인지할 필요 없음.

import { issueToken, authenticatePage } from '../../fixtures/auth';

test('...', async ({ page }) => {
  const token = issueToken('core.templates.layouts.edit');
  await authenticatePage(page, token);
  await page.goto('/admin/dashboard');
});

권한 식별자는 임의 string — 코어/모듈/플러그인 공통 API. Permission::firstOrCreate 가 자동 생성.

데이터 생성 위치 — 책임 분리 매트릭스

E2E spec 이 "특정 유저 + 특정 역할 + 특정 도메인 상황" 에서 동작하려면 백엔드 데이터 생성 코드가 필요하다. 그 코드는 데이터를 소유한 영역에 위치해야 한다 — 기존 Seeder/Factory 분리 원칙과 동일.

데이터 종류 책임 영역 위치
코어 권한 (core.*), 역할, 유저, Sanctum 토큰 코어 app/Console/Commands/PlaywrightIssueToken.php
모듈 권한 (sirsoft-ecommerce.*) 모듈 (코어 커맨드의 --permissions= 임의 식별자) —
모듈 도메인 데이터 (상품/카테고리/주문) 모듈 modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php
플러그인 도메인 데이터 (결제 키 등) 플러그인 plugins/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php
외부 의존 (토스 결제창 응답 등) spec 안 mock page.route('https://api.tosspayments.com/**', ...)

핵심 원칙: 코어는 모듈 도메인을 모른다. 모듈 도메인 시드 커맨드를 코어에 두면 코어↔모듈 의존이 뒤집힘 (정적 검사 위반).

시나리오 매니페스트 ↔ spec 매핑

시나리오 매니페스트(tests/scenarios/*.yaml)의 축과 spec 은 파일명으로 대응시킨다 — layout-editor-table-editor.yaml ↔ specs/layout-editor/table-editor.spec.ts 처럼 매니페스트 이름에서 도메인 접두어를 뗀 형태가 spec 파일명이 된다.

각 spec 은 test.describe.parallel(axisName) 으로 묶고 matrix 배열로 매개변수화한다. 케이스 docblock 에 // @scenario k1=v1, k2=v2 / // @effects e1, e2 를 마킹하면 매니페스트의 cross_product · effects 와 자동으로 대조된다.

@scenario 의 축 구분자는 쉼표뿐이다. k1=v1 × k2=v2 처럼 곱셈 기호로 적으면 첫 축만 인식되고 나머지는 조용히 누락된다.

외부 의존 시나리오 — mock-first 패턴 (결제 등)

토스페이먼츠 등 외부 도메인 결제 흐름은 결정론적 자동화에 mock-first 전략 필수.

// 토스 SDK 호출 가로채기 — 결제창 띄우지 않고 즉시 success callback URL 로 redirect
await page.route('https://js.tosspayments.com/**', route => route.fulfill({
  contentType: 'application/javascript',
  body: `
    window.TossPayments = () => ({
      requestPayment: async (method, params) => {
        window.location.href = params.successUrl
          + '?paymentKey=test_pk_xxx&orderId=' + params.orderId + '&amount=' + params.amount;
      }
    });
  `,
}));

// 토스 confirm API mock — 가맹점 측 callback 흐름 검증
await page.route('https://api.tosspayments.com/v1/payments/confirm', route => route.fulfill({
  status: 200, contentType: 'application/json',
  body: JSON.stringify({
    paymentKey: 'test_pk_xxx', orderId: 'order_001', status: 'DONE',
    method: '카드', totalAmount: 10000, approvedAt: new Date().toISOString(),
  }),
}));

분기별(성공/실패/취소/금액 불일치/중복) 가맹점 측 처리(주문 상태 전환, 재고 차감, 알림 발송) 정확성 검증. 실 카드 결제 자동화는 권장하지 않음 — 수동 검증.

회귀 / Flaky 처리

  • retries: process.env.CI ? 2 : 0 — CI 에서만 자동 retry
  • trace: 'retain-on-failure' — 실패 시 trace.zip 보존 (npx playwright show-trace ...)
  • 2주 내 2회 이상 fail = @flaky 태그 격리 + RCA

디버깅 워크플로우

# UI 모드 — 단계별 step 시각화
$env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npm run test:e2e:ui

# 특정 spec 만 실행
PLAYWRIGHT_BASE_URL=https://g7.dev npx playwright test tests/Playwright/specs/layout-editor/auth-guard-and-locales.spec.ts

# trace 열기
npx playwright show-trace test-results/<...>/trace.zip

Playwright 가 회귀를 발견하면 → Claude chrome-devtools-mcp 로 라이브 재현/진단 → spec 으로 승격.

참고

  • 가이드: docs/testing/e2e-testing.md
  • 시나리오 매니페스트: tests/scenarios/ (레이아웃 편집기 축은 layout-editor-*.yaml)
  • 단위 시뮬레이션 (Vitest): resources/js/core/template-engine/__tests__/
  • 모듈 sample skeleton: modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md