Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/ci-e2e-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: CI E2E test

on:
pull_request:
branches:
- develop
- main
paths-ignore:
- '**.md'
- 'llm-wiki/**'

permissions:
contents: read

# 같은 PR에 새 커밋이 오면 이전 실행을 취소해 러너 시간을 아낀다.
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
e2e:
name: E2E (${{ matrix.project }})
runs-on: ubuntu-latest
timeout-minutes: 30

strategy:
# 한쪽 뷰포트가 깨져도 나머지 결과를 봐야 원인 범위를 좁힐 수 있다.
fail-fast: false
matrix:
# 서비스가 모바일 우선이라 평소에는 모바일만 돌리고,
# 배포 직전인 main 대상 PR에서만 PC 뷰포트를 함께 확인한다.
project: >-
${{ github.base_ref == 'main'
&& fromJSON('["mobile", "desktop"]')
|| fromJSON('["mobile"]') }}

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

# 설정의 projects가 모두 Chromium 기반이라 브라우저 하나만 받으면 된다.
- name: Install Playwright browser
run: npx playwright install chromium --with-deps

# secrets 이름이 틀리면 값이 빈 문자열로 들어온다. 그러면 테스트는
# 실패가 아니라 건너뛴 채 초록으로 끝나므로 그 전에 멈춘다.
- name: Check secrets
run: |
missing=''
for name in E2E_SUPABASE_URL E2E_SUPABASE_PUBLISHABLE_KEY E2E_SUPABASE_SECRET_KEY E2E_TEST_USER_PASSWORD; do
if [ -z "${!name}" ]; then missing="$missing $name"; fi
done
if [ -n "$missing" ]; then
echo "비어 있는 GitHub Secret:$missing"
exit 1
fi
env:
E2E_SUPABASE_URL: ${{ secrets.E2E_SUPABASE_URL }}
E2E_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.E2E_SUPABASE_PUBLISHABLE_KEY }}
E2E_SUPABASE_SECRET_KEY: ${{ secrets.E2E_SUPABASE_SECRET_KEY }}
E2E_TEST_USER_PASSWORD: ${{ secrets.E2E_TEST_USER_PASSWORD }}

# secrets 이름에 E2E_를 붙여 운영 DB 값과 섞이지 않게 한다.
# 키 이름은 Supabase의 현재 명칭(publishable, secret)을 따른다.
# 앱 환경변수는 예전 명칭을 쓰고 있으므로 여기서 연결한다.
- name: Build
run: npm run build
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.E2E_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.E2E_SUPABASE_PUBLISHABLE_KEY }}

- name: Run Playwright tests
run: npx playwright test --project=${{ matrix.project }}
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.E2E_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.E2E_SUPABASE_PUBLISHABLE_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.E2E_SUPABASE_SECRET_KEY }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the service-role secret out of pull-request code

For same-repository pull requests, this job checks out the proposed merge commit and then runs its Playwright configuration, tests, and Next.js server with the Supabase service-role secret in the environment. Anyone permitted to open a branch PR can therefore add arbitrary Node or server code that transmits this key before the change is reviewed; because the key bypasses RLS, disclosure grants unrestricted access to the test project. Gate this privileged step behind a trusted environment approval or execute only trusted test code rather than passing the secret directly to candidate code.

AGENTS.md reference: AGENTS.md:L105-L109

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

유효한 지적입니다. 메커니즘을 확인했습니다.

pull_request 이벤트가 제안된 병합 커밋의 코드를 실행하고, 그 코드가 SUPABASE_SERVICE_ROLE_KEY가 든 환경에서 돌아가는 것이 맞습니다. fork PR을 배제한 설정은 이 경로를 막지 못합니다. 공격자가 fork가 아니라 같은 저장소의 브랜치를 쓰기 때문입니다.

이번 PR에서는 수용하고 #212로 분리했습니다.

노출되는 값은 개발 Supabase의 secret key 하나이고, 해당 프로젝트에는 실제 고객 데이터가 없습니다(user 행 4개). 악용 가능한 사람도 조직에 push 권한이 있는 인원으로 한정됩니다. 운영에는 닿지 않습니다.

제안하신 environment 승인 게이트는 검토했으나 선택하지 않았습니다. 노출은 막지만 매 PR마다 수동 승인이 생겨, 자동 게이트를 도입하는 이 PR의 목적과 정면으로 부딪힙니다.

구조를 바꾸는 것은 권한 자체를 좁히는 방식이라고 판단해 #212에서 다룹니다. seed와 cleanup을 서버 쪽 진입점 뒤로 옮기고, CI에는 E2E 테스트 계정과 그 데이터만 다룰 수 있는 자격증명만 두는 방향입니다.

다만 이 판단은 "개발 DB에 지킬 데이터가 없다"는 전제에 기대고 있어, 전제가 바뀌면 #212의 우선순위를 올려야 합니다. 수용 근거는 PR 본문에도 남겼습니다.

E2E_TEST_USER_PASSWORD: ${{ secrets.E2E_TEST_USER_PASSWORD }}

# 실패했을 때만 올린다. 리포트로 어떤 케이스가 깨졌는지 보고,
# trace는 `npx playwright show-trace trace.zip`으로 그 시점 화면과 네트워크를 확인한다.
- name: Upload report
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-${{ matrix.project }}-${{ github.run_attempt }}
path: |
playwright-report/
test-results/
retention-days: 7
90 changes: 90 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# E2E 테스트

Notion `테스트 시나리오` DB의 케이스를 Playwright 코드로 옮긴 것이다.
spec 파일은 도메인 단위, `test.describe()`는 시나리오 단위, `test()`는 테스트 케이스 하나에 대응한다.
각 `test()` 위의 주석이 Notion `ID` 속성이다.

## 실행 준비

로그인 이후 흐름을 검증하는 케이스는 Supabase 접속 정보가 필요하다.
앱이 쓰는 값을 그대로 읽으므로 `.env.local`에 아래 값이 있으면 된다.
값이 하나라도 없으면 해당 케이스는 실행되지 않고 건너뛴다.

```
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_KEY
SUPABASE_SERVICE_ROLE_KEY
E2E_TEST_USER_PASSWORD
```

앞의 둘은 브라우저가 쓰는 값이고, service role key는 seed와 cleanup을 위해
Node.js helper에서만 쓴다. 브라우저 코드로 넘기지 않는다.
`E2E_TEST_USER_PASSWORD`는 테스트가 직접 만들고 지우는 계정에만 쓰는 비밀번호다.
실제 사용자 계정과 무관하지만 저장소에 값을 두지 않으려고 환경변수로 분리했다.

**이 설정이 가리키는 프로젝트에 테스트 사용자와 그룹이 생성되고 삭제된다.**
운영과 분리된 개발 프로젝트인지 확인하고 실행한다.
GitHub Actions에서는 GitHub Secrets로 같은 값을 주입한다.
운영 DB 값과 섞이지 않도록 secrets 이름에는 `E2E_`를 붙이고
워크플로의 `env`에서 위 이름으로 연결한다.
키 이름은 Supabase의 현재 명칭인 publishable key와 secret key를 쓴다.
앱 환경변수는 이전 명칭(anon, service role)을 그대로 두었다.

| GitHub Secret | 연결되는 환경변수 |
| ------------------------------ | --------------------------- |
| `E2E_SUPABASE_URL` | `NEXT_PUBLIC_SUPABASE_URL` |
| `E2E_SUPABASE_PUBLISHABLE_KEY` | `NEXT_PUBLIC_SUPABASE_KEY` |
| `E2E_SUPABASE_SECRET_KEY` | `SUPABASE_SERVICE_ROLE_KEY` |
| `E2E_TEST_USER_PASSWORD` | `E2E_TEST_USER_PASSWORD` |

## 실행

```bash
npm run test:e2e # 전체 (mobile + desktop)
npx playwright test --project=mobile # 모바일 뷰포트만
npx playwright test e2e/settlement.spec.ts
npx playwright test --grep '납부 상태'
```

뷰포트는 `mobile`(Pixel 5)과 `desktop`(Desktop Chrome) 두 프로젝트로 나눠 둔다.
서비스가 모바일 우선이라 `mobile`을 기본으로 보고, 둘 다 Chromium 기반이라
브라우저 바이너리는 하나만 설치하면 된다.

## CI

`.github/workflows/ci-e2e-test.yml`이 `develop`과 `main` 대상 PR에서 돌아간다.
`develop` 대상은 `mobile`만, `main` 대상은 `mobile`과 `desktop`을 함께 확인한다.

CI에서는 `npm run dev` 대신 빌드 결과(`npm run start`)를 띄운다.
dev 서버는 경로마다 첫 진입에서 컴파일해 느리고 결과가 흔들리는데,
빌드된 서버는 그렇지 않아 같은 16건이 3분에서 30초 수준으로 줄어든다.
같은 이유로 CI에서만 워커를 2로 올린다.

실패하면 `playwright-report/`와 `test-results/`가 실행 요약 페이지의 Artifacts에 올라간다.
trace는 내려받아 `npx playwright show-trace trace.zip`으로 확인한다.

## 구조

| 파일 | 역할 |
| --------------------- | ------------------------------------------------- |
| `fixtures/env.ts` | `.env` 로딩, 테스트 DB 접속 정보와 실행 조건 판정 |
| `fixtures/seed.ts` | 사용자·그룹·참여자·초대·정산 기록 생성과 cleanup |
| `fixtures/session.ts` | Supabase 세션 생성과 브라우저 주입 |
| `fixtures/ui.ts` | 라벨·참여자 카드·토스트 등 공용 선택자 |
| `fixtures/test.ts` | `seed` fixture를 붙인 `test` |

## 인증 방식

소셜 로그인 화면은 Google과 Kakao가 그리는 화면이라 자동화 대상이 아니다.
테스트는 Supabase에 만든 테스트 계정으로 세션을 발급받아 브라우저 `localStorage`에 넣고 시작한다.
세션 직렬화는 supabase 클라이언트가 직접 하도록 두고 테스트는 그 결과만 옮기므로,
라이브러리의 저장 형식이 바뀌어도 테스트가 따라 깨지지 않는다.

`ProtectRoute`는 `localStorage`의 `user-store`로 로그인 여부를 판단하므로 세션과 함께 주입한다.
앱이 이 값을 직접 채우는 흐름(로그인 콜백)을 검증할 때만 `withUserStore: false`로 둔다.

## 데이터 정리

`seed` fixture는 테스트가 실패해도 만든 데이터를 되돌린다.
화면 조작으로 생기는 그룹처럼 아이디를 미리 알 수 없는 데이터는
`seed.trackNbreadTitle(title)`로 제목을 예약해 두면 cleanup이 찾아서 지운다.
40 changes: 40 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, test } from './fixtures/test'
import { hasTestDatabase, testDatabaseSkipReason } from './fixtures/env'
import { applySession, createSession, readUserStore } from './fixtures/session'

test.describe('접근 제어', () => {
// AUTH-ACCESS-001
test('비로그인 사용자가 보호 경로를 열면 첫 화면으로 이동한다', async ({
page,
}) => {
await page.goto('/home')

await expect(page).toHaveURL('/')
await expect(
page.getByRole('heading', { name: '이번 달 엔빵' }),
).not.toBeVisible()
})
})

test.describe('로그인 콜백 처리', () => {
test.skip(!hasTestDatabase, testDatabaseSkipReason)

// AUTH-CALLBACK-001
test('약관에 동의한 사용자는 인증 콜백 뒤 요청한 초대 경로로 이동한다', async ({
page,
seed,
}) => {
const user = await seed.createUser()
const session = await createSession(user)

// user-store는 콜백 흐름이 직접 채워야 하는 값이므로 주입하지 않는다.
await applySession(page, session, { withUserStore: false })

await page.goto('/auth/callback?next=%2Finvite%2Fsample-code')

await expect(page).toHaveURL('/invite/sample-code')

const userStore = await readUserStore(page)
expect(userStore?.state?.user?.id).toBe(user.id)
})
})
27 changes: 27 additions & 0 deletions e2e/fixtures/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { loadEnvConfig } from '@next/env'

// Playwright는 Next.js와 달리 .env 파일을 자동으로 읽지 않으므로 같은 방식으로 직접 불러온다.
loadEnvConfig(process.cwd(), true, {
info: () => {},
error: () => {},
})

/**
* 앱이 쓰는 Supabase 설정을 그대로 사용한다. 운영과 분리된 개발 프로젝트를 가리킨다.
* service role key는 seed와 cleanup에만 쓰고 브라우저 코드로 넘기지 않는다.
*/
export const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
export const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_KEY ?? ''
export const supabaseServiceRoleKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ??
process.env.SUPABASE_SECRET_KEY ??
'') as string

/** 테스트가 만들고 지우는 계정에만 쓰는 비밀번호. 저장소에 값을 두지 않는다. */
export const testUserPassword = process.env.E2E_TEST_USER_PASSWORD ?? ''

export const hasTestDatabase = Boolean(
supabaseUrl && supabaseAnonKey && supabaseServiceRoleKey && testUserPassword,
)

export const testDatabaseSkipReason =
'NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_KEY, SUPABASE_SERVICE_ROLE_KEY, E2E_TEST_USER_PASSWORD가 없어 데이터가 필요한 케이스를 건너뜁니다.'
Loading