diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index 49f80516..bda78f20 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -115,6 +115,13 @@ jobs: shell: bash - name: Blue-Green Deployment + # 전환 전 확인(scripts/smoke/pre_switch_check.sh)이 스모크 계정 토큰을 만들 때 쓴다. + # 서버가 실제로 쓰는 서명 키는 APPLICATION_PROD 안의 jwt.*.secret 이다. 컨테이너에 넘기는 JWT_*_TOKEN_SECRET 환경변수는 + # Spring 이 jwt.accessToken.secret 으로 읽지 않는다. access 키는 배포 뒤 스모크 full 이 통과해서 같은 값인 것이 확인됐고, + # refresh 키는 아직 확인되지 않아서 여기에는 넘기지 않는다. 배포 뒤 smoke-test.yml 이 refresh 까지 보고 알린다. + env: + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} + SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }} run: | export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" @@ -158,6 +165,19 @@ jobs: echo "Current active environment: $CURRENT (port $CURRENT_PORT)" echo "Deploying to target environment: $TARGET (port $TARGET_PORT)" + # 배포를 끝내지 못하면 prod-latest 를 지금 사용자를 받는 이미지로 되돌린다. 위에서 이미 새 이미지로 옮겼고, + # 그대로 두면 누가 docker-compose up 을 다시 할 때 거절된 빌드가 올라간다. + PREV_IMAGE="" + if [ "$CURRENT" != "none" ]; then + PREV_IMAGE=$(docker inspect --format '{{.Image}}' "ono-app-prod-$CURRENT" 2>/dev/null || true) + fi + restore_latest_tag() { + if [ -n "$PREV_IMAGE" ]; then + $DOCKER tag "$PREV_IMAGE" ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest \ + || echo "::warning::prod-latest 를 이전 이미지로 되돌리지 못했습니다" + fi + } + if [ "$TARGET" = "green" ]; then docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile green up -d app-prod-green else @@ -183,9 +203,29 @@ jobs: if [ "$healthy" = false ]; then echo "ERROR: $TARGET environment failed to become healthy within $timeout seconds" docker-compose -f docker-compose.prod.yml logs --tail=100 app-prod-$TARGET + restore_latest_tag exit 1 fi + # 전환 전 확인. 실패하면 nginx 를 건드리지 않았으니 이전 색이 계속 사용자를 받는다. + if ! bash ./scripts/smoke/pre_switch_check.sh "ono-app-prod-$TARGET" "$TARGET_PORT"; then + if [ "$CURRENT" = "none" ]; then + # 돌고 있는 앱이 없던 복구 배포다. 새 컨테이너를 내려도 나아지는 것이 없고 복구 수단만 막히니 경고만 하고 이어 간다. + echo "::warning::전환 전 확인이 실패했지만 돌고 있던 앱이 없어 그대로 전환합니다. 스모크 결과를 확인해야 합니다" + else + echo "ERROR: $TARGET environment failed the pre-switch check. Nginx stays on $CURRENT" + docker-compose -f docker-compose.prod.yml logs --tail=100 app-prod-$TARGET + # 새 컨테이너는 이름으로 지운다. 남아 있으면 다음 배포가 blue 를 먼저 보고 현재 색을 잘못 읽어, + # 사용자를 받고 있는 색을 다시 만들 수 있다. + docker rm -f "ono-app-prod-$TARGET" || true + if [ -n "$(docker ps -aq --filter "name=^ono-app-prod-$TARGET\$")" ]; then + echo "::error::ono-app-prod-$TARGET 를 지우지 못했습니다. 다음 배포 전에 직접 지워야 합니다. 남아 있으면 다음 배포가 현재 색을 잘못 판단합니다" + fi + restore_latest_tag + exit 1 + fi + fi + echo "Switching Nginx to $TARGET environment (port $TARGET_PORT)..." sudo /opt/ono/scripts/switch-nginx.sh $TARGET_PORT diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 41383f8d..1f6ba889 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -15,10 +15,12 @@ name: Smoke Test # - 보낼 수 있는 요청과 요청 수 상한은 스크립트에 고정되어 있다. # # 필요한 Secrets (없으면 할 수 있는 단계까지만 한다) -# JWT_ACCESS_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL +# JWT_ACCESS_TOKEN_SECRET, JWT_REFRESH_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL # -# JWT_ACCESS_TOKEN_SECRET 은 배포 워크플로가 서버에 넘기는 그 키다(ci-dev.yml, ci-prod.yml). 스모크는 같은 키로 -# 토큰을 직접 만들어 쓰기 때문에 값이 같아야 하고, 따로 복사해 두면 키를 바꿀 때 한쪽만 남아 알림이 계속 울린다. +# JWT_*_TOKEN_SECRET 은 배포 워크플로가 컨테이너에 환경변수로 넘기는 값이지만, 서버가 실제로 쓰는 서명 키는 +# APPLICATION_PROD 안의 jwt.accessToken.secret, jwt.refreshToken.secret 이다(환경변수 이름이 Spring 설정 키와 이어지지 않는다). +# 스모크는 이 시크릿으로 토큰을 직접 만들기 때문에 두 곳의 값이 같아야 한다. 서버 키를 바꾸면 이 시크릿도 같이 바꾼다. +# refresh 키로는 서버에 저장된 적 없는 토큰을 만들어 갱신 경로가 1002 로 답하는지만 본다(서버에 쓰기 없음). # # workflow_run 과 schedule 은 이 파일이 기본 브랜치(main)에 있어야 동작한다. @@ -128,10 +130,11 @@ jobs: SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} SMOKE_LEVEL: ${{ steps.plan.outputs.level }} SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} + SMOKE_REFRESH_TOKEN_SECRET: ${{ secrets.JWT_REFRESH_TOKEN_SECRET }} SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }} run: | if [ "$SMOKE_LEVEL" = "bootstrap" ]; then - unset SMOKE_ACCESS_TOKEN_SECRET + unset SMOKE_ACCESS_TOKEN_SECRET SMOKE_REFRESH_TOKEN_SECRET python3 scripts/smoke/smoke_test.py bootstrap else python3 scripts/smoke/smoke_test.py @@ -144,10 +147,11 @@ jobs: SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} SMOKE_LEVEL: ${{ steps.plan.outputs.level }} SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} + SMOKE_REFRESH_TOKEN_SECRET: ${{ secrets.JWT_REFRESH_TOKEN_SECRET }} SMOKE_USER_ID: ${{ secrets.SMOKE_DEV_USER_ID }} run: | if [ "$SMOKE_LEVEL" = "bootstrap" ]; then - unset SMOKE_ACCESS_TOKEN_SECRET + unset SMOKE_ACCESS_TOKEN_SECRET SMOKE_REFRESH_TOKEN_SECRET python3 scripts/smoke/smoke_test.py bootstrap else python3 scripts/smoke/smoke_test.py diff --git a/scripts/smoke/README.md b/scripts/smoke/README.md index a3d72acf..667a657b 100644 --- a/scripts/smoke/README.md +++ b/scripts/smoke/README.md @@ -11,22 +11,40 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 | 배포 워크플로가 끝난 뒤 (`workflow_run`) | 그 배포의 서버 | 배포 성공이면 `full`, 실패면 `read` | 통과와 실패 모두 Discord 로 | | 매시 17분 (`schedule`) | prod | `read` | 실패로 바뀔 때와 복구될 때. 실패가 이어지면 6시간마다 다시 | | 수동 실행 (`workflow_dispatch`) | 고른 서버 | 고른 단계 | 없음. prod 는 `main` 브랜치에서만 실행된다 | +| prod 배포 중 nginx 전환 직전 (`ci-prod.yml`) | 새 컨테이너 포트 `http://127.0.0.1:8080` 또는 `8081` | actuator health + `read` (토큰 갱신 확인 제외) | 실패하면 전환하지 않고 배포가 실패로 끝난다. 돌고 있던 앱이 없던 복구 배포는 경고만 하고 전환한다 | -배포 워크플로 파일은 건드리지 않았다. 배포가 끝난 것을 받아서 따로 도는 방식이라, 스모크 테스트가 틀려도 배포와 nginx 전환에는 영향이 없다. -맥미니 러너가 아니라 GitHub 호스티드 러너에서 돌기 때문에 운영 기계의 자원도 쓰지 않는다. +위 세 줄은 `smoke-test.yml` 이 GitHub 호스티드 러너에서 돌리고, 스모크 테스트가 틀려도 배포와 nginx 전환에는 영향이 없다. + +마지막 줄은 `pre_switch_check.sh` 를 배포 job(맥미니 러너)이 부른다. 새 컨테이너가 healthy 가 된 뒤, nginx 를 넘기기 전에 +그 컨테이너를 직접 확인하고, 실패하면 새 컨테이너를 내린다. nginx 를 건드리지 않았으니 이전 버전이 계속 사용자를 받는다. + +- **actuator health 는 여기서만 본다.** actuator 는 컨테이너 안 8081 에서만 떠서(`docker-compose.prod.yml` 의 `MANAGEMENT_SERVER_PORT`) 밖에서는 닿지 않는다. db, redis, rabbit 중 하나라도 DOWN 이면 503 이다. 컨테이너 healthcheck 도 같은 주소를 보지만 30초 간격에 3번 실패해야 unhealthy 가 되어서 전환 직전에 한 번 더 본다. 응답 본문에 버전과 디스크 용량이 있어 로그에는 구성요소 이름과 상태만 남긴다. +- **쓰기가 있는 `full` 은 전환 전에 돌리지 않는다.** 전환 전에는 이전 버전이 같은 DB 로 사용자를 받고 있다. `full` 은 전환 뒤 `workflow_run` 으로 돈다. +- **러너에 python3 3.9 이상이 없으면 스모크만 건너뛰고 경고를 남긴다.** 이 확인이 생기기 전과 같은 상태라 배포를 막지 않는다. +- **이 확인을 우회해야 하면** `deploy-prod.yml`(수동 배포)을 쓴다. 거기에는 넣지 않았다. +- **실패하면 새 컨테이너를 이름으로 지우고 `prod-latest` 를 이전 이미지로 되돌린다.** 새 컨테이너가 남아 있으면 다음 배포가 blue 를 먼저 보고 현재 색을 잘못 읽는다. 지우지 못하면 `::error::` 로 남기니 다음 배포 전에 직접 지운다. +- **토큰 갱신 확인은 여기서 하지 않는다.** refresh 서명 키가 서버와 같은지 아직 확인되지 않아서, 틀리면 배포가 막히기만 한다. 배포 뒤 확인이 `1002` 로 통과하는 것을 본 뒤에 넣는다. ## 무엇을 확인하나 | 단계 | 확인 | 요청 수 | |---|---|---:| | `reach` | 토큰 없이 `GET /api/problems/problemCount` 가 `401 (1007)` 인가. Cloudflare, nginx, 앱의 인증 필터까지 닿았다는 뜻이다 | 1 | -| `read` | 스모크 계정 토큰으로 루트 폴더를 읽어 표식 폴더를 확인한 뒤, 앱 화면들이 쓰는 조회 API 15개가 기대한 모양으로 오는가 | 17 | -| `full` | `read` 에 더해 스모크 계정 안에서 폴더 만들기, 조회, 이름 바꾸기, 지우기, 지운 뒤 404 확인. 복습노트 만들기, 조회, 지우기, 지운 뒤 404 확인 | 28 | +| `read` | 토큰 갱신 경로가 `401 (1002)` 로 답하는가. 스모크 계정 토큰으로 루트 폴더를 읽어 표식 폴더를 확인한 뒤, 앱 화면들이 쓰는 조회 API 15개가 기대한 모양으로 오는가 | 18 | +| `full` | `read` 에 더해 스모크 계정 안에서 폴더 만들기, 조회, 이름 바꾸기, 지우기, 지운 뒤 404 확인. 복습노트 만들기, 조회, 지우기, 지운 뒤 404 확인. 이미지 업로드 URL 발급, 오답노트 등록, 조회, 지우기, 지운 뒤 404 확인 | 35 | `read` 의 조회 API 는 폴더 목록과 썸네일, 폴더 단건, 하위 폴더, 오답노트 수, 복습할 문제, 내 오답노트, 폴더 안 오답노트, 복습노트 썸네일과 전체, 풀이 기록 수, 내 스터디룸, 태그, 학습 캘린더, 학습 리포트 요약이다. 운영 main 버전과 develop 버전 양쪽에 다 있는 API 만 골랐다. +**토큰 갱신은 실패 응답으로 확인한다.** `JWT_REFRESH_TOKEN_SECRET` 으로 서버가 발급한 적 없는 refresh token 을 만들어 보낸다. +서명이 맞으면 서버는 `refresh_token` 테이블을 찾아보고 없으니 `1002` 로 끝나고, 저장 전에 끝나서 쓰기가 없다(`JwtTokenService.refreshAccessToken`). +`1001` 이 오면 refresh 서명 키가 어긋난 것이고, 그러면 앱 사용자의 토큰 갱신이 전부 실패한다. +성공 경로는 보지 않는다. 갱신할 때마다 토큰이 바뀌어서 다음 실행에 쓸 토큰을 어딘가에 저장해야 하기 때문이다. + +**오답노트는 이미지 없이 루트 폴더에 등록한다.** `X-App-Version: 99.0.0` 을 붙여서 서버가 구버전 앱으로 보고 XP 를 적립하지 않게 한다. +이미지 업로드 URL 은 발급만 확인한다. 서버가 서명만 하고 S3 에 올리지는 않아서, S3 자격 증명이 틀린 것은 여기서 잡히지 않는다. + ## 운영 서버에 영향이 없는 이유 - **보낼 수 있는 요청이 스크립트에 고정되어 있다.** `ALLOWED_REQUESTS` 밖의 요청은 보내기 전에 막는다. 쓰기는 `full` 에서만, 게스트 가입은 `bootstrap` 에서만 허용한다. @@ -35,7 +53,7 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 - **지우는 것은 스모크 흔적뿐이다.** 이번 실행에서 만든 것과, 이름이 `__smoke_run_` 으로 시작하는 지난 실행의 흔적만 지운다. - **요청 수와 시간에 상한이 있다.** 한 번 실행에 80개, 200초를 넘으면 멈춘다. 재시도는 GET 만, 연결 실패와 5xx, 429 에서 최대 3번이다. 쓰기는 재시도하지 않는다. - **쓰기가 있는 실행은 대상별로 한 줄로 선다.** 조회만 하는 매시 실행은 따로 돌아서 서로 밀어내지 않는다. -- **리다이렉트를 따라가지 않고, 토큰은 https 로만 보낸다.** +- **리다이렉트를 따라가지 않고, 토큰은 https 이거나 로컬 주소(전환 전 확인)일 때만 보낸다.** ### 넣지 않은 API 와 이유 @@ -46,7 +64,9 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 | `GET /api/users` | 로그인 미션 기록, 포인트와 마지막 접속 시각 갱신이 같이 돈다. 관리자 DAU 집계에도 잡힌다 | | `GET /api/learning-reports` | 캐시가 없으면 OpenAI 를 부른다 | | `GET /api/achievements` | 조건이 맞으면 업적 행을 INSERT 한다 | -| 문제 등록과 삭제 | 삭제해도 XP 와 미션 기록, develop 의 리마인더 행이 남는다 | +| S3 에 실제로 올리는 업로드 | S3 객체가 생기고, 문제에 붙지 않은 이미지를 지우는 경로를 아직 확인하지 않았다 | +| 이미지를 붙인 오답노트 | 분석 요청(OpenAI)과 S3 삭제 메시지가 생길 수 있다 | +| 토큰 갱신 성공, 로그인 | 위에 적은 이유. 로그인은 스모크 계정(게스트)으로 다시 할 수 없다 | | 태그 만들기 | main 에서 지운 태그와 같은 이름으로 다시 만들면 유니크 인덱스에 걸린다 | | 알림 붙인 복습노트 | Quartz 작업이 생기고, main 은 복습노트를 지워도 그 작업을 지우지 않는다 | | 스터디룸 가입 | 다른 멤버의 피드에 보이고, 탈퇴 로직이 외래키에 막힌다 | @@ -66,16 +86,20 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 그래도 남는 것은 이 정도다. -- `full` 한 번에 스모크 계정의 소프트 삭제된 폴더와 복습노트 행이 하나씩 쌓인다. 배포할 때만 도니 1년에 수십 행 수준이다. +- `full` 한 번에 스모크 계정의 소프트 삭제된 폴더와 복습노트, 오답노트 행이 하나씩 쌓인다. 배포할 때만 도니 1년에 수십 행 수준이다. +- 오답노트를 지워도 되돌려지지 않는 것이 스모크 계정에 남는다. 하루 3건까지의 `mission_log` 행, 미션 진행도, CANCELED 로 바뀐 복습 리마인더 행이다. + XP 는 버전 헤더로 막는다. 스모크 계정은 스터디룸에 들어가 있지 않아서 피드는 생기지 않는다. +- 이미지 업로드 URL 발급이 스모크 계정의 하루 200회 카운터(Redis)를 한 번 올린다. - 스모크 계정 1명이 관리자 사용자 목록과 사용자 수에 잡힌다. - 스모크 계정 자신의 Redis 캐시 키(`TAG_LIST`, `STREAK`, `LEARNING_REPORT_SUMMARY`)가 TTL 로 생긴다. - Prometheus 의 사용자 행동 지표에 게스트 요청으로 잡힌다. 사용자 ID 태그가 없어서 따로 걸러낼 수는 없다. + 매시 토큰 갱신 확인의 401 이 하루 24건 client_error 로 섞인다. - 서버 DB 가 이미 죽어 있으면 조회가 500 이 되어 기존 예외 처리대로 Discord 에러 알림이 나간다(5분 중복 억제). ## 처음 설정하는 순서 1. **이 파일이 `main` 에 들어가야 한다.** `workflow_run` 과 `schedule` 과 수동 실행 버튼 모두 기본 브랜치의 워크플로 파일로 돈다. -2. **토큰 서명 키는 이미 있는 `JWT_ACCESS_TOKEN_SECRET` 을 그대로 쓴다.** 배포 워크플로가 서버에 넘기는 그 키다(`ci-dev.yml`, `ci-prod.yml`). 스모크가 같은 키로 토큰을 만들기 때문에 따로 등록할 것이 없다. 이 키만 있어도 매시 확인이 인증과 DB 조회까지 본다. +2. **토큰 서명 키는 이미 있는 `JWT_ACCESS_TOKEN_SECRET` 과 `JWT_REFRESH_TOKEN_SECRET` 을 그대로 쓴다.** 배포 워크플로가 컨테이너에 넘기는 값이지만, 서버가 실제로 쓰는 서명 키는 `APPLICATION_PROD` 안의 `jwt.accessToken.secret`, `jwt.refreshToken.secret` 이다. 두 값이 같아야 한다. access 키는 2026-09-21 prod `full` 통과로 같은 것이 확인됐고, refresh 키는 첫 배포 뒤 확인에서 `1002` 가 나오는지 봐야 한다. 이 키만 있어도 매시 확인이 인증과 DB 조회까지 본다. 3. **스모크 계정을 만든다.** Actions 에서 `Smoke Test` 를 `prod`, `bootstrap` 으로 한 번 실행한다. 게스트 가입이라 Discord 에 가입 알림이 한 번 온다. 4. **실행 결과 요약에 나온 계정 ID 를 `SMOKE_PROD_USER_ID` 에 등록한다.** 이때부터 `read` 와 `full` 이 스모크 계정으로 돈다. 5. dev 도 같은 방식으로 `SMOKE_DEV_*` 를 등록한다. dev 서버가 켜져 있을 때 한다. @@ -94,6 +118,10 @@ JWT 서명 키를 바꿔도 `JWT_ACCESS_TOKEN_SECRET` 한 곳만 고치면 된 | 토큰이 거절됐습니다 `1009` | 시크릿이 서버 서명 키와 다르다 (develop 이후) | | 토큰이 거절됐습니다 `1005` | 시크릿이 다르거나(main 은 서명이 틀려도 1005) 시계가 어긋났다 | | 토큰이 거절됐습니다 `1007` | 시크릿이 다르거나 서버의 Redis 블랙리스트 조회가 실패했다 | +| 토큰 갱신 경로: refresh token 을 거절했습니다 `1001` | `JWT_REFRESH_TOKEN_SECRET` 이 서버 refresh 서명 키와 다르다. 같다면 앱 사용자의 토큰 갱신이 전부 실패하고 있다 | +| 토큰 갱신 경로: 만료로 봤습니다 `1006` | 러너와 서버의 시계가 어긋났다 | +| 이미지 업로드 URL 발급 `429` | 스모크 계정의 하루 200회 한도에 닿았다. 수동 `full` 을 많이 돌렸는지 본다 | +| 전환 전 확인: actuator health 가 200 이 아닙니다 | 새 컨테이너에서 db, redis, rabbit 중 하나가 DOWN 이다. 배포 로그의 컨테이너 로그를 본다 | | 앱이 정상 응답하지 않습니다 (5xx) | 앱 내부 오류. DB 연결을 먼저 본다 | | 루트 폴더에 표식 폴더가 없습니다 | `SMOKE_USER_ID` 가 스모크 계정이 아니다 | | 응답 모양이 예상과 다릅니다 | 배포된 API 의 응답 형태가 바뀌었다. 앱 파싱도 깨졌을 수 있다 | @@ -107,7 +135,7 @@ JWT 서명 키를 바꿔도 `JWT_ACCESS_TOKEN_SECRET` 한 곳만 고치면 된 # 닿는지만 SMOKE_BASE_URL=https://ono-prod.seungminki.shop SMOKE_LEVEL=reach python3 scripts/smoke/smoke_test.py -# 스크립트 자체 테스트 42개 (가짜 서버로 허용 요청, 표식 확인, 흔적 정리, 요청과 시간 상한까지 본다) +# 스크립트 자체 테스트 61개 (가짜 서버로 허용 요청, 표식 확인, 흔적 정리, 요청과 시간 상한, 전환 전 확인 스크립트까지 본다) python3 -m unittest discover -s scripts/smoke -p 'test_*.py' ``` diff --git a/scripts/smoke/pre_switch_check.sh b/scripts/smoke/pre_switch_check.sh new file mode 100755 index 00000000..a8c507f5 --- /dev/null +++ b/scripts/smoke/pre_switch_check.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# nginx 를 새 컨테이너로 넘기기 직전에 그 컨테이너를 직접 확인한다. ci-prod.yml 배포 job(맥미니 러너)이 부른다. +# +# pre_switch_check.sh <컨테이너 이름> <호스트 포트> +# +# 1. 컨테이너 안 actuator health. 구성요소(db, redis, rabbit) 중 하나라도 DOWN 이면 503 이라 wget 이 실패한다. +# 컨테이너 healthcheck 도 같은 주소를 보지만 30초 간격에 3번 실패해야 unhealthy 가 되어서, 전환 직전에 한 번 더 본다. +# 응답 본문에는 DB 버전과 디스크 용량 같은 값이 들어 있고 Actions 로그는 공개라, 구성요소 이름과 상태만 찍는다. +# 2. 스모크 테스트 read 를 http://127.0.0.1:<포트> 로. Cloudflare 와 nginx 를 거치지 않고 새 버전 앱만 본다. +# 쓰기가 있는 full 은 전환 뒤 smoke-test.yml 이 돌린다. 전환 전에는 이전 버전이 같은 DB 로 사용자를 받고 있다. +# +# 종료 코드 0 이면 전환해도 된다. 0 이 아니면 호출한 쪽이 새 컨테이너를 내리고 전환하지 않는다. +# python3 가 없거나 너무 오래됐으면 스모크 테스트만 건너뛰고 경고를 남긴다. 이 스텝이 생기기 전과 같은 상태라 +# 배포를 막지 않는다. +# +# 필요한 환경 변수: SMOKE_ACCESS_TOKEN_SECRET, SMOKE_REFRESH_TOKEN_SECRET, SMOKE_USER_ID (없으면 할 수 있는 만큼만 한다) +# 시험할 때는 DOCKER 로 docker 대신 쓸 명령을 넣을 수 있다. + +set -uo pipefail + +container="${1:?컨테이너 이름이 필요합니다}" +port="${2:?호스트 포트가 필요합니다}" +docker_cmd="${DOCKER:-docker}" +here="$(cd "$(dirname "$0")" && pwd)" + +echo "=== 전환 전 확인: $container (포트 $port) ===" + +python_ok=false +if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' 2>/dev/null; then + python_ok=true +fi + +if ! health="$($docker_cmd exec "$container" wget -qO- --tries=1 --timeout=10 http://localhost:8081/actuator/health)"; then + echo "[실패] actuator health 가 200 이 아닙니다. db, redis, rabbit 중 하나가 DOWN 이거나 앱이 응답하지 않습니다" + exit 1 +fi +if [ "$python_ok" = true ]; then + HEALTH_JSON="$health" python3 - <<'PY' +import json, os +try: + body = json.loads(os.environ["HEALTH_JSON"]) +except ValueError: + body = None +components = body.get("components") if isinstance(body, dict) else None +if body is None: + print("[통과] actuator health: 200 (본문이 JSON 이 아니라 구성요소는 읽지 못했습니다)") +elif not isinstance(components, dict): + print("[통과] actuator health: 200 (show-details 가 꺼져 있어 구성요소는 보이지 않습니다)") +else: + parts = ", ".join(f"{name} {c.get('status') if isinstance(c, dict) else '?'}" for name, c in sorted(components.items())) + print(f"[통과] actuator health: 200 ({parts})") +PY +else + echo "[통과] actuator health: 200" +fi + +if [ "$python_ok" != true ]; then + echo "::warning::python3 3.9 이상이 없어 전환 전 스모크 테스트를 건너뛰었습니다. 러너에 python3 를 설치해야 합니다" + exit 0 +fi + +SMOKE_BASE_URL="http://127.0.0.1:$port" SMOKE_LEVEL=read python3 "$here/smoke_test.py" diff --git a/scripts/smoke/smoke_test.py b/scripts/smoke/smoke_test.py index d09580b1..134cc5f0 100644 --- a/scripts/smoke/smoke_test.py +++ b/scripts/smoke/smoke_test.py @@ -9,14 +9,17 @@ reach 토큰 없이 한 번 불러서 앱까지 닿는지 본다. read 스모크 전용 게스트 계정으로 앱 화면들이 쓰는 조회 API 를 부른다. - full read 에 더해, 스모크 계정 안에서 폴더와 복습노트를 만들고 조회하고 지운다. + full read 에 더해, 스모크 계정 안에서 폴더와 복습노트, 오답노트를 만들고 조회하고 지운다. + +refresh 서명 키가 있으면 reach 다음에 토큰 갱신 경로도 본다. 서명 키로 직접 만든 refresh token 은 +DB 에 없어서 서버는 항상 1002 를 돌려주고 아무것도 쓰지 않는다. 1001 이 오면 서명 키가 어긋난 것이다. 운영 서버에 지장을 주지 않는 것이 제일 중요한 조건이라 아래를 지킨다. 각 API 를 고른 근거는 README.md 에 있다. - 보낼 수 있는 요청은 ALLOWED_REQUESTS 에 적힌 메서드와 경로뿐이다. 그 밖의 요청은 보내기 전에 막는다. GET /api/users 는 조회처럼 보이지만 로그인 미션 기록과 접속 시각 갱신, DAU 집계가 같이 돌아서 넣지 않았다. -- 쓰기 요청은 full 단계에서만 허용하고, 재시도하지 않는다. +- 쓰기 요청은 full 단계에서만 허용하고, 재시도하지 않는다. 토큰 갱신(POST)만 예외인데, 서버가 쓰기 전에 1002 로 끝난다. - 지우는 것은 이번 실행에서 만든 것과, 이름이 RUN_PREFIX 로 시작하는 지난 실행의 흔적뿐이다. - 스모크 계정 루트 폴더 아래에 MARKER_FOLDER_NAME 폴더가 없으면 스모크 계정이 아니라고 보고 더 이상 요청하지 않는다. SMOKE_USER_ID 를 잘못 넣어 실사용자 계정을 건드리는 일을 막는다. @@ -31,6 +34,7 @@ SMOKE_BASE_URL 필수. 예: https://ono-prod.seungminki.shop SMOKE_LEVEL reach | read | full. 기본 read SMOKE_ACCESS_TOKEN_SECRET 서버의 jwt.accessToken.secret 과 같은 Base64 값 + SMOKE_REFRESH_TOKEN_SECRET 서버의 jwt.refreshToken.secret 과 같은 Base64 값. 없으면 토큰 갱신 확인만 건너뛴다 SMOKE_USER_ID bootstrap 으로 만든 스모크 게스트 계정 ID 시크릿이 없으면 reach 만 한다. 시크릿만 있고 계정 ID 가 없으면 존재하지 않는 사용자 ID 0 으로 @@ -85,15 +89,32 @@ KST = timezone(timedelta(hours=9)) # AuthErrorCase +INVALID_REFRESH_TOKEN = 1001 +REFRESH_TOKEN_NOT_FOUND = 1002 +REFRESH_TOKEN_EXPIRED = 1006 AUTHENTICATION_FAILED = 1007 ACCESS_TOKEN_EXPIRED = 1005 INVALID_ACCESS_TOKEN = 1009 # FolderErrorCase, PracticeNoteErrorCase FOLDER_NOT_FOUND = 5001 PRACTICE_NOTE_NOT_FOUND = 6001 +# ProblemErrorCase +PROBLEM_NOT_FOUND = 4001 + +# 오답노트를 등록할 때만 붙인다. 헤더가 없으면 서버가 구버전 앱으로 보고 능력치 포인트(XP)를 적립한다 +# (LegacyAccrualPolicy). 미션을 받을 수 있는 버전 기준(ono.mission.mission-capable-version, 기본 4.0.0)이 +# 올라가도 계속 신버전으로 읽히도록 크게 둔다. 신버전 요청은 XP 대신 미션 진행도만 올린다. +SMOKE_APP_VERSION = "99.0.0" WRITE_METHODS = ("POST", "PATCH", "DELETE") SIGNUP_PATH = "/api/auth/signup/guest" +REFRESH_PATH = "/api/auth/refresh" +PROBLEM_PATH = "/api/problems/v2" +PRESIGNED_PATH = "/api/fileUpload/presigned-urls" + +# GET 이지만 서버에 흔적이 남는 요청. 쓰기처럼 full 에서만 보내고 재시도하지 않는다. +# presigned URL 발급은 서명만 하고 S3 객체는 만들지 않지만, 사용자별 하루 200회 카운터(Redis)를 호출마다 1 올린다. +WRITE_LIKE_GETS = (PRESIGNED_PATH,) # 보낼 수 있는 요청 전부. 경로는 쿼리스트링을 뺀 값과 통째로 맞아야 한다. ALLOWED_REQUESTS = [ @@ -115,11 +136,16 @@ ("GET", r"/api/tags"), ("GET", r"/api/learning-calendar"), ("GET", r"/api/learning-reports/summary"), + ("GET", r"/api/problems/\d+"), + ("GET", PRESIGNED_PATH), ("POST", r"/api/folders"), ("PATCH", r"/api/folders"), ("DELETE", r"/api/folders"), ("POST", r"/api/practiceNotes"), ("DELETE", r"/api/practiceNotes"), + ("POST", PROBLEM_PATH), + ("DELETE", r"/api/problems"), + ("POST", REFRESH_PATH), ("POST", SIGNUP_PATH), ] @@ -176,9 +202,24 @@ def mint_access_token(secret_b64: str, now: int, user_id: str = NONEXISTENT_USER 서버는 jwt.accessToken.secret 을 Base64 로 디코딩한 바이트를 HMAC 키로 쓴다. """ + payload = {"authority": SMOKE_AUTHORITY, "sub": str(user_id), "iat": now, "exp": now + TOKEN_TTL_SECONDS} + return _sign(secret_b64, payload) + + +def mint_refresh_token(secret_b64: str, now: int) -> str: + """JwtTokenizer.createRefreshToken 과 같은 모양의 토큰을 만든다. + + 서버가 발급한 적 없는 토큰이라 refresh_token 테이블에 없고, 서버는 1002 로 끝낸다. + jti 를 매번 새로 넣어서 우연히라도 저장된 토큰과 같아질 일이 없다. + """ + payload = {"authority": SMOKE_AUTHORITY, "sub": NONEXISTENT_USER_ID, "iat": now, + "jti": secrets.token_hex(16), "exp": now + TOKEN_TTL_SECONDS} + return _sign(secret_b64, payload) + + +def _sign(secret_b64: str, payload: dict) -> str: key = base64.b64decode(secret_b64.strip(), validate=True) header = {"alg": "HS256"} - payload = {"authority": SMOKE_AUTHORITY, "sub": str(user_id), "iat": now, "exp": now + TOKEN_TTL_SECONDS} signing_input = ( _b64url(json.dumps(header, separators=(",", ":")).encode()) + "." @@ -245,20 +286,26 @@ def __init__(self, base_url: str, sleep: Callable[[float], None], budget: Budget self.allow_signup = allow_signup def request(self, method: str, path: str, body=None, query: Mapping[str, object] | None = None, - auth: bool = True) -> Response: + auth: bool = True, extra_headers: Mapping[str, str] | None = None) -> Response: method = method.upper() if not any(m == method and re.fullmatch(p, path) for m, p in ALLOWED_REQUESTS): raise SmokeAbort(f"허용 목록에 없는 요청이라 보내지 않았습니다: {method} {path}") if path == SIGNUP_PATH: if not self.allow_signup: raise SmokeAbort("게스트 가입은 bootstrap 에서만 보낼 수 있습니다") - elif method in WRITE_METHODS and not self.allow_writes: + elif path == REFRESH_PATH: + # 저장된 적 없는 토큰만 보내므로 서버에 쓰기가 없다. 그래서 read 에서도 허용한다. + # 서버에 저장된 진짜 refresh token 을 보내면 토큰이 회전(쓰기)되므로, 없는 사용자(sub 0)로 만든 토큰만 통과시킨다. + token = body.get("refreshToken") if isinstance(body, dict) else None + if auth or not isinstance(token, str) or subject_of(token) != NONEXISTENT_USER_ID: + raise SmokeAbort("토큰 갱신은 없는 사용자로 만든 확인용 토큰만 보낼 수 있습니다") + elif (method in WRITE_METHODS or path in WRITE_LIKE_GETS) and not self.allow_writes: raise SmokeAbort(f"쓰기 요청은 full 단계에서만 보낼 수 있습니다: {method} {path}") url = self.base_url + path if query: url += "?" + urllib.parse.urlencode(query) - headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + headers = {"User-Agent": USER_AGENT, "Accept": "application/json", **(extra_headers or {})} if auth and self.token: headers["Authorization"] = self.token data = None @@ -268,8 +315,10 @@ def request(self, method: str, path: str, body=None, query: Mapping[str, object] res = self._send(method, url, headers, data) attempts = 1 - # 쓰기는 보낸 요청이 서버에 반영됐는지 알 수 없어서 재시도하지 않는다. - while method == "GET" and _should_retry(res) and attempts < MAX_ATTEMPTS: + # 쓰기는 보낸 요청이 서버에 반영됐는지 알 수 없어서 재시도하지 않는다. 흔적이 남는 GET 도 같다. + # 토큰 갱신 확인은 서버에 쓰기가 없어 재시도해도 된다. 막 뜬 새 컨테이너에 두 번째로 가는 요청이라 느릴 수 있다. + retryable = (method == "GET" and path not in WRITE_LIKE_GETS) or path == REFRESH_PATH + while retryable and _should_retry(res) and attempts < MAX_ATTEMPTS: self.sleep(RETRY_DELAY_SECONDS) res = self._send(method, url, headers, data) attempts += 1 @@ -373,6 +422,7 @@ def __init__(self, env: Mapping[str, str], sleep: Callable[[float], None], clock self.base_url = env.get("SMOKE_BASE_URL", "").strip() self.level = env.get("SMOKE_LEVEL", "").strip() or "read" self.secret = env.get("SMOKE_ACCESS_TOKEN_SECRET", "").strip() + self.refresh_secret = env.get("SMOKE_REFRESH_TOKEN_SECRET", "").strip() self.user_id = env.get("SMOKE_USER_ID", "").strip() stamp = datetime.fromtimestamp(clock(), timezone.utc).strftime("%Y%m%dT%H%M%S") self.run_tag = f"{RUN_PREFIX}{stamp}_{secrets.token_hex(2)}" @@ -405,7 +455,7 @@ def validate_config(self) -> str | None: if self.level not in LEVELS: return f"SMOKE_LEVEL 은 {', '.join(LEVELS)} 중 하나여야 합니다: {self.level}" host = urllib.parse.urlsplit(self.base_url).hostname or "" - if self.secret and self.base_url.startswith("http://") and host not in LOCAL_HOSTS: + if (self.secret or self.refresh_secret) and self.base_url.startswith("http://") and host not in LOCAL_HOSTS: return "토큰을 평문으로 보내지 않도록 로컬 주소가 아니면 https:// 만 받습니다" if self.user_id and not re.fullmatch(r"[0-9]+", self.user_id): return "SMOKE_USER_ID 는 숫자여야 합니다" @@ -414,6 +464,11 @@ def validate_config(self) -> str | None: base64.b64decode(self.secret, validate=True) except (binascii.Error, ValueError): return "SMOKE_ACCESS_TOKEN_SECRET 이 Base64 값이 아닙니다" + if self.refresh_secret: + try: + base64.b64decode(self.refresh_secret, validate=True) + except (binascii.Error, ValueError): + return "SMOKE_REFRESH_TOKEN_SECRET 이 Base64 값이 아닙니다" return None def execute(self) -> None: @@ -422,6 +477,10 @@ def execute(self) -> None: return if self.level == "reach": return + if self.refresh_secret: + self.check_refresh(Client(self.base_url, self.sleep, self.budget)) + else: + self.skip("인증: 토큰 갱신 경로", "SMOKE_REFRESH_TOKEN_SECRET 이 없어 건너뛰었습니다") if not self.secret: self.skip("인증과 조회", "SMOKE_ACCESS_TOKEN_SECRET 이 없어 건너뛰었습니다") return @@ -456,6 +515,25 @@ def check_reachable(self, client: Client) -> bool: self.fail(name, describe_unexpected(res), res) return False + def check_refresh(self, client: Client) -> None: + name = "인증: 토큰 갱신 경로 (없는 refresh token 으로 1002)" + token = mint_refresh_token(self.refresh_secret, int(self.clock())) + _mask(token, self.env) + res = client.request("POST", REFRESH_PATH, {"refreshToken": token}, auth=False) + code = res.error_code() + if res.status == 401 and code == REFRESH_TOKEN_NOT_FOUND: + self.ok(name, f"HTTP 401, errorCode {REFRESH_TOKEN_NOT_FOUND}", res) + elif code == INVALID_REFRESH_TOKEN: + self.fail(name, f"refresh token 을 거절했습니다 (errorCode {code}). SMOKE_REFRESH_TOKEN_SECRET 이 서버의 " + "refresh 서명 키(APPLICATION_PROD 의 jwt.refreshToken.secret)와 다를 수 있습니다. " + "서버 키가 바뀐 것이라면 앱 사용자의 기존 refresh token 이 전부 거절됩니다", res) + elif code == REFRESH_TOKEN_EXPIRED: + self.fail(name, f"방금 만든 토큰을 만료로 봤습니다 (errorCode {code}). 러너와 서버의 시계가 어긋났을 수 있습니다", res) + elif res.status == 200: + self.fail(name, "저장된 적 없는 refresh token 으로 갱신에 성공했습니다. 토큰 저장소 확인이 빠졌을 수 있습니다", res) + else: + self.fail(name, describe_unexpected(res), res) + def check_probe_with_nonexistent_user(self) -> None: token = "Bearer " + mint_access_token(self.secret, int(self.clock())) _mask(token, self.env) @@ -514,17 +592,19 @@ def check_reads(self, client: Client, root: dict) -> None: def check_writes(self, client: Client, root: dict) -> None: created_folders: list[int] = [] created_notes: list[int] = [] + created_problems: list[int] = [] try: self.clean_leftovers(client, root) self.folder_flow(client, root["folderId"], created_folders) self.practice_note_flow(client, created_notes) + self.problem_flow(client, root["folderId"], created_problems) except SmokeAbort as e: self.fail("쓰기 흐름 중단", str(e)) finally: - self.cleanup(client, created_folders, created_notes) + self.cleanup(client, created_folders, created_notes, created_problems) def clean_leftovers(self, client: Client, root: dict) -> None: - """지난 실행이 중간에 끊겨 남긴 스모크 폴더와 복습노트를 먼저 지운다.""" + """지난 실행이 중간에 끊겨 남긴 스모크 폴더와 복습노트, 오답노트를 먼저 지운다.""" name = "지난 실행 흔적 정리" folder_ids = [f["folderId"] for f in root["subFolderList"] if isinstance(f, dict) and str(f.get("folderName", "")).startswith(RUN_PREFIX)] @@ -534,7 +614,13 @@ def clean_leftovers(self, client: Client, root: dict) -> None: raise SmokeAbort("흔적 목록을 읽지 못해 쓰기 흐름을 시작하지 않았습니다") note_ids = [n["practiceNoteId"] for n in res.data() if isinstance(n, dict) and str(n.get("practiceTitle", "")).startswith(RUN_PREFIX)] - if not folder_ids and not note_ids: + res = client.request("GET", "/api/problems/user") + if res.status != 200 or not is_list(res.data()): + self.fail(name, describe_unexpected(res) if res.status != 200 else "오답노트 목록 모양이 예상과 다릅니다", res) + raise SmokeAbort("흔적 목록을 읽지 못해 쓰기 흐름을 시작하지 않았습니다") + problem_ids = [p["problemId"] for p in res.data() + if isinstance(p, dict) and str(p.get("memo") or "").startswith(RUN_PREFIX)] + if not folder_ids and not note_ids and not problem_ids: self.ok(name, "남은 흔적 없음", res) return failed = [] @@ -542,10 +628,12 @@ def clean_leftovers(self, client: Client, root: dict) -> None: failed.append(f"폴더 {folder_ids}") if note_ids and self.delete_notes(client, note_ids).status != 200: failed.append(f"복습노트 {note_ids}") + if problem_ids and self.delete_problems(client, problem_ids).status != 200: + failed.append(f"오답노트 {problem_ids}") if failed: self.fail(name, f"지우지 못했습니다: {', '.join(failed)}") else: - self.ok(name, f"폴더 {len(folder_ids)}개, 복습노트 {len(note_ids)}개를 지웠습니다") + self.ok(name, f"폴더 {len(folder_ids)}개, 복습노트 {len(note_ids)}개, 오답노트 {len(problem_ids)}개를 지웠습니다") def folder_flow(self, client: Client, root_id: int, created: list[int]) -> None: folder_name = self.run_tag @@ -600,6 +688,45 @@ def practice_note_flow(self, client: Client, created: list[int]) -> None: self.fail("쓰기: 지운 복습노트가 안 보이는가", f"삭제 후에도 조회됩니다 (HTTP {res.status}). 삭제가 반영되지 않았을 수 있습니다", res) + def problem_flow(self, client: Client, root_id: int, created: list[int]) -> None: + """오답노트를 이미지 없이 등록하고 조회하고 지운다. + + 지워도 되돌려지지 않는 것이 있다. 하루 3건까지의 mission_log 행과 미션 진행도, CANCELED 로 바뀐 복습 + 리마인더 행, soft delete 된 problem 행이 스모크 계정에 남는다. XP 는 SMOKE_APP_VERSION 헤더로 막는다. + """ + res = client.request("GET", PRESIGNED_PATH, query={"count": 1, "contentType": "image/png"}) + urls = res.data() + # 서명된 URL 은 10분 동안 버킷에 올릴 수 있는 값이라, 모양이 틀려도 본문을 로그에 싣지 않는다. + if (res.status == 200 and isinstance(urls, list) and len(urls) == 1 and isinstance(urls[0], dict) + and str(urls[0].get("presignedUrl", "")).startswith("https://") and urls[0].get("fileUrl")): + self.ok("쓰기: 이미지 업로드 URL 발급", "HTTP 200, URL 1개", res) + elif res.status == 200: + self.fail("쓰기: 이미지 업로드 URL 발급", "응답 모양이 예상과 다릅니다 (HTTP 200)", res) + else: + self.fail("쓰기: 이미지 업로드 URL 발급", describe_unexpected(res), res) + + memo = self.run_tag + solved_at = datetime.fromtimestamp(self.clock(), KST).replace(tzinfo=None, microsecond=0).isoformat() + body = {"memo": memo, "reference": None, "folderId": root_id, "solvedAt": solved_at, + "problemImageUrls": [], "answerImageUrls": [], "tagIds": []} + res = client.request("POST", PROBLEM_PATH, body, extra_headers={"X-App-Version": SMOKE_APP_VERSION}) + if not self.expect("쓰기: 오답노트 등록", res, 200, is_count): + raise SmokeAbort("오답노트를 등록하지 못해 쓰기 흐름을 멈췄습니다") + problem_id = res.data() + created.append(problem_id) + + self.expect("쓰기: 등록한 오답노트 조회", client.request("GET", f"/api/problems/{problem_id}"), 200, + lambda v: isinstance(v, dict) and v.get("memo") == memo and v.get("folderId") == root_id) + + if self.expect("쓰기: 오답노트 지우기", self.delete_problems(client, [problem_id]), 200): + res = client.request("GET", f"/api/problems/{problem_id}") + if res.status == 404 and res.error_code() == PROBLEM_NOT_FOUND: + self.ok("쓰기: 지운 오답노트가 안 보이는가", f"HTTP 404, errorCode {PROBLEM_NOT_FOUND}", res) + created.remove(problem_id) + else: + self.fail("쓰기: 지운 오답노트가 안 보이는가", + f"삭제 후에도 조회됩니다 (HTTP {res.status}). 삭제가 반영되지 않았을 수 있습니다", res) + @staticmethod def delete_folders(client: Client, folder_ids: list[int]) -> Response: return client.request("DELETE", "/api/folders", {"deleteFolderIdList": folder_ids}) @@ -608,9 +735,13 @@ def delete_folders(client: Client, folder_ids: list[int]) -> Response: def delete_notes(client: Client, note_ids: list[int]) -> Response: return client.request("DELETE", "/api/practiceNotes", {"deletePracticeIdList": note_ids}) - def cleanup(self, client: Client, folders: list[int], notes: list[int]) -> None: + @staticmethod + def delete_problems(client: Client, problem_ids: list[int]) -> Response: + return client.request("DELETE", "/api/problems", {"deleteProblemIdList": problem_ids}) + + def cleanup(self, client: Client, folders: list[int], notes: list[int], problems: list[int]) -> None: """흐름이 중간에 멈춰 남은 것을 지운다. 여기서도 못 지우면 다음 실행의 흔적 정리가 지운다.""" - if not folders and not notes: + if not folders and not notes and not problems: return leftovers = [] try: @@ -618,6 +749,8 @@ def cleanup(self, client: Client, folders: list[int], notes: list[int]) -> None: leftovers.append(f"폴더 {folders}") if notes and self.delete_notes(client, notes).status not in (200, 404): leftovers.append(f"복습노트 {notes}") + if problems and self.delete_problems(client, problems).status not in (200, 404): + leftovers.append(f"오답노트 {problems}") except SmokeAbort as e: leftovers.append(str(e)) if leftovers: diff --git a/scripts/smoke/test_smoke_test.py b/scripts/smoke/test_smoke_test.py index 4a92b691..5bd7f1e5 100644 --- a/scripts/smoke/test_smoke_test.py +++ b/scripts/smoke/test_smoke_test.py @@ -15,9 +15,11 @@ import json import os import re +import subprocess import sys import tempfile import threading +import time import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -27,6 +29,7 @@ SERVER_SECRET = base64.b64encode(b"s" * 64).decode() OTHER_SECRET = base64.b64encode(b"x" * 64).decode() +REFRESH_SECRET = base64.b64encode(b"r" * 64).decode() NOW = 1_800_000_000 SMOKE_USER = "42" REAL_USER = "7" @@ -54,7 +57,8 @@ def issued_token(user_id: str) -> str: class FakeOnO: """스모크 테스트가 부르는 API 만 흉내 내는 OnO 서버. - 사용자마다 루트 폴더와 하위 폴더, 복습노트를 들고 있고, 소유권과 에러 코드를 실제 서버처럼 돌려준다. + 사용자마다 루트 폴더와 하위 폴더, 복습노트, 오답노트를 들고 있고, 소유권과 에러 코드를 실제 서버처럼 돌려준다. + refresh token 은 서버가 발급해 저장한 것만 갱신되고(stored_refresh_tokens), 나머지는 1002 다. override(method, path_regex) 로 특정 요청의 응답을 바꿔 실패 상황을 만든다. """ @@ -64,8 +68,11 @@ def __init__(self): self.lock = threading.Lock() self.next_id = 100 self.users = {} + self.stored_refresh_tokens = set() + self.now = NOW # 만료 판정 기준. 스크립트를 별도 프로세스로 돌리는 시험은 실제 시각으로 바꾼다 self.add_user(SMOKE_USER, marker=True) self.add_user(REAL_USER, marker=False, extra_folders=["수학", "__smoke_run_looks_like_but_real_user"]) + self.add_problem(REAL_USER, "__smoke_run_looks_like_but_real_user") outer = self class Handler(BaseHTTPRequestHandler): @@ -116,7 +123,14 @@ def add_user(self, user_id, marker, extra_folders=()): folders[fid] = {"folderId": fid, "folderName": name, "parent": root, "deleted": False} nid = self.new_id() notes = {nid: {"practiceNoteId": nid, "practiceTitle": "복습 세트", "deleted": False}} - self.users[user_id] = {"root": root, "folders": folders, "notes": notes} + self.users[user_id] = {"root": root, "folders": folders, "notes": notes, "problems": {}} + self.add_problem(user_id, "근의 공식") + + def add_problem(self, user_id, memo): + user = self.users[user_id] + pid = self.new_id() + user["problems"][pid] = {"problemId": pid, "memo": memo, "folderId": user["root"], "deleted": False} + return pid def live_folder_names(self, user_id): return sorted(f["folderName"] for f in self.users[user_id]["folders"].values() if not f["deleted"]) @@ -124,6 +138,9 @@ def live_folder_names(self, user_id): def live_note_titles(self, user_id): return sorted(n["practiceTitle"] for n in self.users[user_id]["notes"].values() if not n["deleted"]) + def live_problem_memos(self, user_id): + return sorted(p["memo"] for p in self.users[user_id]["problems"].values() if not p["deleted"]) + def override(self, method, path_regex, response, times=None): self.overrides.append({"method": method, "path": path_regex, "response": response, "times": times}) @@ -145,6 +162,21 @@ def dispatch(self, method, full_path, headers, body): self.add_user(uid, marker=False) return ok({"accessToken": issued_token(uid), "refreshToken": "refresh-" + uid}) + if method == "POST" and path == "/api/auth/refresh": + # JwtTokenService.refreshAccessToken: 서명과 만료를 보고, 저장된 토큰인지 찾는다. + token = (body or {}).get("refreshToken") or "" + try: + claims = verify_token(token, REFRESH_SECRET) + except ValueError: # 형식이 깨진 토큰. binascii.Error 도 ValueError 다 + claims = None + if claims is None: + return error(400, 1001) + if claims["exp"] < self.now: + return error(401, 1006) + if token not in self.stored_refresh_tokens: + return error(401, 1002) + return ok({"accessToken": "Bearer new", "refreshToken": "new"}) + auth = headers.get("Authorization") if not auth: return error(401, 1007) @@ -157,7 +189,7 @@ def dispatch(self, method, full_path, headers, body): return ok(0) return error(404, 5001) user = self.users[uid] - folders, notes = user["folders"], user["notes"] + folders, notes, problems = user["folders"], user["notes"], user["problems"] def folder_view(f): parent = folders.get(f["parent"]) @@ -174,6 +206,15 @@ def folder_view(f): if m: f = folders.get(int(m.group(1))) return ok(folder_view(f)) if f and not f["deleted"] else error(404, 5001) + pm = re.fullmatch(r"/api/problems/(\d+)", path) + if pm: + problem = problems.get(int(pm.group(1))) + if not problem or problem["deleted"]: + return error(404, 4001) + return ok({k: problem[k] for k in ("problemId", "memo", "folderId")}) + if path == "/api/fileUpload/presigned-urls": + return ok([{"presignedUrl": "https://bucket.s3.amazonaws.com/x.png?X-Amz-Signature=secretsig", + "fileUrl": "https://bucket.s3.amazonaws.com/x.png"}]) n = re.fullmatch(r"/api/practiceNotes/(\d+)", path) if n: note = notes.get(int(n.group(1))) @@ -186,7 +227,8 @@ def folder_view(f): "/api/folders/thumbnails/V2": page, "/api/problems/problemCount": 0, "/api/problems/review-due": {"dueCount": 0, "overdueCount": 0, "problems": []}, - "/api/problems/user": [], + "/api/problems/user": [{k: p[k] for k in ("problemId", "memo", "folderId")} + for p in problems.values() if not p["deleted"]], "/api/practiceNotes/thumbnail": [{"practiceNoteId": x["practiceNoteId"], "practiceTitle": x["practiceTitle"]} for x in notes.values() if not x["deleted"]], "/api/practiceNotes/thumbnail/V2": page, @@ -234,6 +276,20 @@ def folder_view(f): for i in ids: notes[i]["deleted"] = True return ok("선택한 복습 노트가 삭제되었습니다.") + if method == "POST" and path == "/api/problems/v2": + folder = folders.get(body.get("folderId")) + if not folder or folder["deleted"]: + return error(404, 5001) + pid = self.new_id() + problems[pid] = {"problemId": pid, "memo": body.get("memo"), "folderId": folder["folderId"], "deleted": False} + return ok(pid) + if method == "DELETE" and path == "/api/problems": + ids = body.get("deleteProblemIdList", []) + if any(i not in problems or problems[i]["deleted"] for i in ids): + return error(404, 4001) + for i in ids: + problems[i]["deleted"] = True + return ok("문제 삭제가 완료되었습니다.") return error(404, 9999) @@ -383,6 +439,56 @@ def test_boolean_data_is_not_a_count(self): self.assertEqual(1, self.run_smoke(user_id=None)) +class RefreshTest(SmokeTestBase): + + def refresh_requests(self): + return [r for r in self.server.requests if r[1] == smoke_test.REFRESH_PATH] + + def test_passes_when_server_answers_not_found(self): + self.assertEqual(0, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + (method, _, headers, body), = self.refresh_requests() + self.assertEqual("POST", method) + self.assertNotIn("Authorization", headers) + claims = verify_token(body["refreshToken"], REFRESH_SECRET) + self.assertEqual("0", claims["sub"], "스모크 계정이 아니라 없는 사용자로 만든다") + self.assertIn("jti", claims) + self.assertNotIn(body["refreshToken"], self.server.stored_refresh_tokens) + self.assert_only_allowed_requests() + self.assertEqual(18, len(self.server.requests)) + + def test_runs_even_without_access_secret_or_user(self): + self.assertEqual(0, self.run_smoke(level="read", secret=None, user_id=None, SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + self.assertEqual([("GET", smoke_test.PROBE_PATH), ("POST", smoke_test.REFRESH_PATH)], self.server.paths()) + + def test_reach_level_does_not_check_refresh(self): + self.assertEqual(0, self.run_smoke(level="reach", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + self.assertEqual([], self.refresh_requests()) + + def test_fails_when_refresh_key_differs(self): + smoke = smoke_test.SmokeRun(self.env(SMOKE_REFRESH_TOKEN_SECRET=OTHER_SECRET), self.sleeps.append, lambda: NOW) + smoke.execute() + failed = [r for r in smoke.results if not r.passed] + self.assertEqual(1, len(failed)) + self.assertIn("refresh 서명 키", failed[0].detail) + + def test_fails_when_unknown_token_is_accepted(self): + self.server.override("POST", smoke_test.REFRESH_PATH, ok({"accessToken": "Bearer a", "refreshToken": "b"})) + self.assertEqual(1, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + + def test_server_error_is_retried_because_nothing_is_written(self): + self.server.override("POST", smoke_test.REFRESH_PATH, (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.refresh_requests())) + + def test_recovers_when_new_container_is_slow_once(self): + self.server.override("POST", smoke_test.REFRESH_PATH, (503, {}, b""), times=1) + self.assertEqual(0, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET)) + + def test_refresh_failure_does_not_stop_reads(self): + self.assertEqual(1, self.run_smoke(level="read", SMOKE_REFRESH_TOKEN_SECRET=OTHER_SECRET)) + self.assertIn(("GET", "/api/learning-reports/summary"), self.server.paths()) + + class ReadTest(SmokeTestBase): def test_read_level_passes_and_sends_only_gets(self): @@ -450,13 +556,16 @@ class WriteTest(SmokeTestBase): def test_full_level_creates_and_removes_only_its_own_data(self): before_folders = self.server.live_folder_names(SMOKE_USER) before_notes = self.server.live_note_titles(SMOKE_USER) - real_before = self.server.live_folder_names(REAL_USER) + before_problems = self.server.live_problem_memos(SMOKE_USER) + real_before = (self.server.live_folder_names(REAL_USER), self.server.live_problem_memos(REAL_USER)) self.assertEqual(0, self.run_smoke(level="full")) self.assertEqual(before_folders, self.server.live_folder_names(SMOKE_USER)) self.assertEqual(before_notes, self.server.live_note_titles(SMOKE_USER)) - self.assertEqual(real_before, self.server.live_folder_names(REAL_USER)) + self.assertEqual(before_problems, self.server.live_problem_memos(SMOKE_USER)) + self.assertEqual(real_before, (self.server.live_folder_names(REAL_USER), self.server.live_problem_memos(REAL_USER))) + self.assertIn(("POST", "/api/problems/v2"), self.server.paths()) self.assert_only_allowed_requests() for method, path, headers, body in self.server.requests: if method == "POST" and path == "/api/practiceNotes": @@ -484,11 +593,13 @@ def test_cleans_leftovers_but_never_marker_or_other_folders(self): user["folders"][leftover] = {"folderId": leftover, "folderName": "__smoke_run_old", "parent": user["root"], "deleted": False} note = self.server.new_id() user["notes"][note] = {"practiceNoteId": note, "practiceTitle": "__smoke_run_old", "deleted": False} + self.server.add_problem(SMOKE_USER, "__smoke_run_old") self.assertEqual(0, self.run_smoke(level="full")) self.assertEqual(["__smoke_account__", "공책", "책장"], self.server.live_folder_names(SMOKE_USER)) self.assertEqual(["복습 세트"], self.server.live_note_titles(SMOKE_USER)) + self.assertEqual(["근의 공식"], self.server.live_problem_memos(SMOKE_USER)) deleted_ids = [i for m, p, _, b in self.server.requests if m == "DELETE" for i in (b.get("deleteFolderIdList") or b.get("deletePracticeIdList") or [])] marker_id = next(f["folderId"] for f in user["folders"].values() if f["folderName"] == "__smoke_account__") @@ -521,6 +632,133 @@ def test_request_budget_stops_the_run(self): self.assertLessEqual(len(self.server.requests), 10) +class ProblemWriteTest(SmokeTestBase): + + def test_register_sends_new_app_version_so_no_xp_accrues(self): + self.assertEqual(0, self.run_smoke(level="full")) + register = [r for r in self.server.requests if r[0] == "POST" and r[1] == "/api/problems/v2"] + self.assertEqual(1, len(register)) + _, _, headers, body = register[0] + self.assertEqual(smoke_test.SMOKE_APP_VERSION, headers.get("X-App-Version")) + self.assertTrue(body["memo"].startswith(smoke_test.RUN_PREFIX)) + self.assertEqual([], body["problemImageUrls"], "이미지를 붙이면 분석 요청과 S3 삭제 메시지가 생길 수 있다") + others = [r for r in self.server.requests if not (r[0] == "POST" and r[1] == "/api/problems/v2")] + self.assertTrue(all("X-App-Version" not in h for _, _, h, _ in others), "다른 요청의 동작은 바꾸지 않는다") + + def test_presigned_url_is_not_logged_when_shape_is_unexpected(self): + self.server.override("GET", "/api/fileUpload/presigned-urls", + ok([{"presignedUrl": "http://x/?X-Amz-Signature=secretsig"}])) + with tempfile.TemporaryDirectory() as d: + summary, output = os.path.join(d, "summary.md"), os.path.join(d, "output.txt") + self.assertEqual(1, self.run_smoke(level="full", GITHUB_STEP_SUMMARY=summary, GITHUB_OUTPUT=output)) + for path in (summary, output): + with open(path, encoding="utf-8") as f: + self.assertNotIn("secretsig", f.read()) + self.assertIn(("POST", "/api/problems/v2"), self.server.paths(), "발급 실패는 등록 확인을 막지 않는다") + + def test_presigned_rate_limit_is_reported(self): + self.server.override("GET", "/api/fileUpload/presigned-urls", error(429, 2005)) + self.assertEqual(1, self.run_smoke(level="full")) + # 호출마다 카운터가 오르는 GET 이라 재시도하지 않는다. + self.assertEqual(1, self.server.paths().count(("GET", "/api/fileUpload/presigned-urls"))) + self.assertEqual(["근의 공식"], self.server.live_problem_memos(SMOKE_USER)) + + def test_register_failure_stops_flow_without_leftovers(self): + self.server.override("POST", "/api/problems/v2", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(1, self.server.paths("POST").count(("POST", "/api/problems/v2")), "쓰기는 재시도하지 않는다") + self.assertEqual(["근의 공식"], self.server.live_problem_memos(SMOKE_USER)) + + def test_delete_refused_is_retried_in_cleanup_and_then_by_next_run(self): + self.server.override("DELETE", "/api/problems", error(400, 4002), times=2) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(2, self.server.paths("DELETE").count(("DELETE", "/api/problems")), "흐름의 삭제 한 번, 마무리 정리 한 번") + self.assertEqual(2, len(self.server.live_problem_memos(SMOKE_USER))) + + self.assertEqual(0, self.run_smoke(level="full"), "다음 실행이 흔적을 지운다") + self.assertEqual(["근의 공식"], self.server.live_problem_memos(SMOKE_USER)) + + def test_never_deletes_other_problems(self): + self.server.add_problem(SMOKE_USER, "smoke 가 들어가지만 흔적 이름은 아님") + self.assertEqual(0, self.run_smoke(level="full")) + deleted = [i for m, p, _, b in self.server.requests if m == "DELETE" and p == "/api/problems" + for i in b["deleteProblemIdList"]] + kept = [pid for pid, pr in self.server.users[SMOKE_USER]["problems"].items() + if not pr["memo"].startswith(smoke_test.RUN_PREFIX)] + self.assertFalse(set(deleted) & set(kept)) + + +class PreSwitchCheckTest(SmokeTestBase): + """ci-prod.yml 이 nginx 전환 직전에 부르는 pre_switch_check.sh. docker 는 가짜 명령으로 바꿔 끼운다.""" + + HEALTH_UP = ('{"status":"UP","components":{"db":{"status":"UP","details":{"database":"MySQL","validationQuery":"isValid()"}},' + '"diskSpace":{"status":"UP","details":{"total":994662584320,"free":123456789}},' + '"rabbit":{"status":"UP","details":{"version":"3.13.7"}},"redis":{"status":"UP","details":{"version":"7.4.1"}}}}') + + def run_check(self, health_exit=0, health_body=HEALTH_UP, user_id=SMOKE_USER, fake_python_exit=None): + script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pre_switch_check.sh") + with tempfile.TemporaryDirectory() as d: + docker = os.path.join(d, "docker") + body = os.path.join(d, "health.json") + with open(body, "w", encoding="utf-8") as f: + f.write(health_body) + with open(docker, "w", encoding="utf-8") as f: + f.write(f'#!/bin/sh\necho "$@" >> "{d}/calls"\ncat "{body}"\nexit {health_exit}\n') + os.chmod(docker, 0o755) + port = self.server.url.rsplit(":", 1)[1] + self.server.now = int(time.time()) + env = {k: v for k, v in os.environ.items() if not k.startswith(("SMOKE_", "GITHUB_"))} + env.update(DOCKER=docker, SMOKE_ACCESS_TOKEN_SECRET=SERVER_SECRET, SMOKE_REFRESH_TOKEN_SECRET=REFRESH_SECRET) + if fake_python_exit is not None: + # 버전 확인에서 실패하는 python3 를 PATH 맨 앞에 둔다. 3.9 보다 오래된 python3 가 있는 러너와 같다. + with open(os.path.join(d, "python3"), "w", encoding="utf-8") as f: + f.write(f"#!/bin/sh\nexit {fake_python_exit}\n") + os.chmod(os.path.join(d, "python3"), 0o755) + env["PATH"] = d + os.pathsep + env.get("PATH", "") + if user_id: + env["SMOKE_USER_ID"] = user_id + done = subprocess.run(["bash", script, "ono-app-prod-green", port], env=env, + capture_output=True, text=True, timeout=120) + with open(os.path.join(d, "calls"), encoding="utf-8") as f: + calls = f.read() + return done, calls + + def test_passes_and_runs_read_level_against_the_new_port(self): + done, calls = self.run_check() + self.assertEqual(0, done.returncode, done.stdout + done.stderr) + self.assertIn("exec ono-app-prod-green wget", calls) + self.assertIn("db UP, diskSpace UP, rabbit UP, redis UP", done.stdout) + self.assertIn("(단계: read)", done.stdout) + self.assertEqual(set(), {m for m, _ in self.server.paths()} - {"GET", "POST"}) + self.assertEqual(["/api/auth/refresh"], [p for m, p in self.server.paths() if m == "POST"]) + + def test_does_not_print_health_details(self): + done, _ = self.run_check() + for secret_ish in ("994662584320", "3.13.7", "isValid()"): + self.assertNotIn(secret_ish, done.stdout + done.stderr) + + def test_fails_without_smoke_when_health_is_down(self): + done, _ = self.run_check(health_exit=1, health_body='{"status":"DOWN"}') + self.assertNotEqual(0, done.returncode) + self.assertEqual([], self.server.requests, "health 가 실패하면 앱에 요청을 보내지 않는다") + + def test_fails_when_smoke_fails(self): + done, _ = self.run_check(user_id=REAL_USER) + self.assertNotEqual(0, done.returncode) + self.assertIn("스모크 테스트 실패", done.stdout) + + def test_skips_smoke_with_warning_when_python_is_too_old(self): + done, _ = self.run_check(fake_python_exit=1) + self.assertEqual(0, done.returncode, done.stdout + done.stderr) + self.assertIn("::warning::", done.stdout) + self.assertEqual([], self.server.requests, "스모크를 건너뛰었으니 앱에 요청하지 않는다") + + def test_passes_when_health_details_are_hidden(self): + done, _ = self.run_check(health_body='{"status":"UP"}') + self.assertEqual(0, done.returncode, done.stdout + done.stderr) + self.assertIn("show-details 가 꺼져", done.stdout) + + class ClientGuardTest(SmokeTestBase): def client(self, **kwargs): @@ -529,7 +767,9 @@ def client(self, **kwargs): def test_blocks_requests_outside_allowlist_before_sending(self): client = self.client(token="Bearer x", allow_writes=True) for method, path in [("GET", "/api/users"), ("PATCH", "/api/users"), ("DELETE", "/api/users"), - ("POST", "/api/auth/logout"), ("POST", "/api/problems/v2"), ("GET", "/actuator/health")]: + ("POST", "/api/auth/logout"), ("POST", "/api/problems"), ("DELETE", "/api/problems/all"), + ("POST", "/api/problems/1/analysis"), ("POST", "/api/fileUpload/image"), + ("GET", "/actuator/health")]: with self.assertRaises(smoke_test.SmokeAbort): client.request(method, path) self.assertEqual([], self.server.requests) @@ -539,6 +779,27 @@ def test_blocks_writes_unless_full_level(self): self.client(token="Bearer x").request("POST", "/api/folders", {"folderName": "x"}) self.assertEqual([], self.server.requests) + def test_refresh_is_the_only_write_method_allowed_below_full(self): + client = self.client() + probe = smoke_test.mint_refresh_token(REFRESH_SECRET, NOW) + self.assertEqual(401, client.request("POST", smoke_test.REFRESH_PATH, {"refreshToken": probe}, auth=False).status) + for method, path in [("POST", "/api/problems/v2"), ("DELETE", "/api/problems"), + ("GET", "/api/fileUpload/presigned-urls")]: + with self.assertRaises(smoke_test.SmokeAbort): + client.request(method, path, {}) + self.assertEqual(1, len(self.server.requests)) + + def test_refresh_only_accepts_probe_tokens(self): + # 서버에 저장된 진짜 토큰을 보내면 회전(쓰기)이 일어난다. 실제 사용자로 서명된 토큰, 형식이 다른 값, 인증 헤더를 막는다. + real_user_token = smoke_test._sign(REFRESH_SECRET, {"authority": "ROLE_GUEST", "sub": SMOKE_USER, + "iat": NOW, "exp": NOW + 60}) + client = self.client(token="Bearer x", allow_writes=True) + for body, auth in [({"refreshToken": real_user_token}, False), ({"refreshToken": "refresh-42"}, False), + ({}, False), ({"refreshToken": smoke_test.mint_refresh_token(REFRESH_SECRET, NOW)}, True)]: + with self.assertRaises(smoke_test.SmokeAbort): + client.request("POST", smoke_test.REFRESH_PATH, body, auth=auth) + self.assertEqual([], self.server.requests) + def test_blocks_signup_outside_bootstrap(self): with self.assertRaises(smoke_test.SmokeAbort): self.client(allow_writes=True).request("POST", smoke_test.SIGNUP_PATH) @@ -553,6 +814,9 @@ def test_config_errors(self): self.assertEqual(2, smoke_test.run(self.env(SMOKE_LEVEL="everything"), sleep=self.sleeps.append)) self.assertEqual(2, smoke_test.run(self.env(SMOKE_USER_ID="abc"), sleep=self.sleeps.append)) self.assertEqual(2, smoke_test.run(self.env(SMOKE_ACCESS_TOKEN_SECRET="not base64 !!"), sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run(self.env(SMOKE_REFRESH_TOKEN_SECRET="not base64 !!"), sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run({"SMOKE_BASE_URL": "http://ono-dev.seungminki.shop", + "SMOKE_REFRESH_TOKEN_SECRET": REFRESH_SECRET}, sleep=self.sleeps.append)) self.assertEqual([], self.server.requests)