diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 7c2f0be1..01cd109c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -16,7 +16,8 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - check: + # check(필수 체크)는 아래 잡들을 모으는 집계 잡이다. 잡을 나눠 나란히 돌리고, 테스트는 샤드로 쪼갠다. + static: runs-on: ubuntu-latest timeout-minutes: 10 @@ -25,16 +26,35 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js (24.x) & Yarn cache + id: node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "24.x" cache: "yarn" + # node_modules를 통째로 캐시한다 — 공개 레포 PR은 yarn hardened 모드라 설치가 약 49초(push 28초)다. 적중하면 설치를 건너뛴다 + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' run: | corepack enable yarn install --immutable + # 캐시 적중이면 postinstall(prisma generate)이 돌지 않는다 — 생성 클라이언트(src/generated)는 캐시 밖이다 + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + - name: GraphQL codegen run: yarn graphql:codegen @@ -52,18 +72,260 @@ jobs: - name: SDL description coverage check run: yarn docs:check - - name: Script unit tests - run: yarn test:scripts - # 아키텍처 의존 규칙 게이트(순환·Prisma-ban·레이어). 로컬 pre-push(validate:push)뿐 아니라 # 보호된 check status에서도 강제해야 --no-verify/Husky 미설치 환경의 우회를 막는다. - name: Architecture check (dependency-cruiser) run: yarn arch:check - # 전체 회귀·커버리지 임계는 CI(check·coverage-report)가 맡는다 — 로컬 pre-push는 변경에 닿는 spec만 돌린다. - - name: Test with coverage - run: yarn test:cov + scripts: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js (24.x) & Yarn cache + id: node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + cache: "yarn" + + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} + + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' + run: | + corepack enable + yarn install --immutable + + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + + - name: Script unit tests + run: yarn test:scripts + + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js (24.x) & Yarn cache + id: node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + cache: "yarn" + + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} + + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' + run: | + corepack enable + yarn install --immutable + + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + + - name: Build (연속 2회 — 증분 캐시 오염 회귀 검출) + run: | + yarn graphql:docs + yarn build + # 왜 2회인가: deleteOutDir이 dist를 지우는데 tsbuildinfo가 dist 밖에 있으면 + # 캐시만 살아남아 tsc가 "전부 최신"으로 오판, emit을 건너뛰고 dist가 빈 채로 + # 남는다. clean 체크아웃 1회 빌드로는 이 상태가 재현되지 않아 PR #259 회귀가 + # 8일간 CI를 그대로 통과했다. 2회차가 그 상태를 만들어 준다. + yarn build + test -f dist/main.js + + # 전체 회귀를 샤드로 나눠 돌린다. 임계는 샤드마다 끄고('--coverageThreshold={}') coverage-report가 합친 맵으로 검사한다 — + # 샤드 하나의 커버리지는 다른 샤드가 덮은 파일을 0으로 세서 늘 미달이다. + # --coverage는 남긴다: jest.config.js가 이 플래그로 LanguageService 모드를 고른다(빼면 branches 수가 달라진다). + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + env: + SHARD: ${{ matrix.shard }} + SHARD_TOTAL: 2 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js (24.x) & Yarn cache + id: node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + cache: "yarn" + + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} + + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' + run: | + corepack enable + yarn install --immutable + + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + + - name: Test shard (coverage) + run: | + mkdir -p coverage + yarn jest --ci --coverage --shard="$SHARD/$SHARD_TOTAL" --json --outputFile=coverage/report.json --testLocationInResults '--coverageThreshold={}' --coverageReporters=text-summary + + # 테스트가 실패해도 올린다 — coverage-report가 실패 내역을 PR 댓글에 싣는다 + - name: Upload shard result + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-shard-${{ matrix.shard }} + path: coverage/report.json + retention-days: 7 # 하루 뒤 coverage-report만 재실행해도 샤드가 남아 있게 + overwrite: true # 잡 재실행 때 같은 이름 충돌 방지 + + # 샤드 결과를 합쳐 임계를 검사하고(테스트 실패·샤드 누락·임계 미달이면 실패) Codecov에 올린다. 테스트가 실패해도 돌아 + # PR 댓글에 실패를 보인다. push 실행은 합친 report.json을 기준 아티팩트로 남기고, PR 실행은 base 브랜치의 그 아티팩트와 비교한다. + coverage-report: + needs: test + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read # base 브랜치 push 실행의 기준 아티팩트 조회 + pull-requests: write + checks: write + env: + SHARD_TOTAL: 2 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js (24.x) & Yarn cache + id: node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + cache: "yarn" + + - name: node_modules cache + id: modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + .yarn/install-state.gz + key: node-modules-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }} + + - name: Install dependencies + if: steps.modules.outputs.cache-hit != 'true' + run: | + corepack enable + yarn install --immutable + + - name: Prisma generate (node_modules 캐시 적중) + if: steps.modules.outputs.cache-hit == 'true' + run: | + corepack enable + yarn prisma:generate + + - name: Download shard results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: coverage-shard-* + path: coverage/shards + + - name: Merge coverage (임계는 jest.config.js) + run: yarn coverage:merge coverage/shards coverage + # PR 비교의 기준. 업로드가 실패해도 배포(main CI 성공)를 막지 않는다 — 다음 push가 다시 올린다 + - name: Upload base coverage (push) + if: github.event_name == 'push' + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report-json + path: coverage/report.json + retention-days: 90 + overwrite: true + + # 기준은 이 레포 base 브랜치의 성공한 push 실행에서만 받는다 — PR 실행의 아티팩트는 PR 코드가 만든 것이라 믿지 않는다. + # base 커밋의 실행이 먼저, 없으면 그 브랜치의 최근 실행. 그래도 없으면 head를 기준 자리에 둔다(차이 0으로 표시). + # 기준 경로는 늘 같아야 한다 — 액션은 입력 경로로 댓글을 식별해 경로가 바뀌면 댓글을 새로 단다. + - name: Fetch base coverage + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage/report.json') != '' }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.base_ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + runs() { + gh api "repos/$REPO/actions/workflows/pr-check.yml/runs?event=push&status=success&branch=$BASE_REF$1" \ + --jq '.workflow_runs[] | select(.head_repository.full_name == env.REPO) | .id' || true + } + mkdir -p coverage/base + for run in $( { runs "&head_sha=$BASE_SHA&per_page=1"; runs "&per_page=5"; } | awk '!seen[$0]++'); do + if gh run download "$run" -R "$REPO" -n coverage-report-json -D coverage/base; then + echo "기준: $BASE_REF push 실행 $run" + exit 0 + fi + done + echo "::notice::$BASE_REF의 기준 커버리지 아티팩트가 없어 head를 기준으로 둔다(차이 0)" + cp coverage/report.json coverage/base/report.json + + - name: Coverage Report (jest-coverage-report) + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage/report.json') != '' }} + uses: ArtiomTr/jest-coverage-report-action@7f750dd50f5585533321eb7ebc482b936b49a5d4 # v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + coverage-file: coverage/report.json + base-coverage-file: coverage/base/report.json + annotations: failed-tests + + # 댓글 액션 뒤에 둔다 — codecov-action은 업로드 토큰(CC_TOKEN)을 GITHUB_ENV로 내보내 뒤 단계 env에 남긴다 - name: Upload coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2 with: @@ -76,16 +338,22 @@ jobs: fail_ci_if_error: true verbose: true - - name: Build (연속 2회 — 증분 캐시 오염 회귀 검출) + # 필수 체크 check. 건너뛴 잡은 GitHub에서 성공으로 보고되고(선행 잡이 실패하면 needs만 건 잡은 skipped), 그 성공이 필수 체크를 + # 통과시킨다 — 그래서 if: always()로 늘 돌고 선행 결과가 전부 success인지 직접 본다. 건너뜀·취소도 실패다. + check: + if: always() + needs: [static, scripts, build, test, coverage-report] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + + steps: + - name: Require every needed job to succeed + env: + NEEDS: ${{ toJSON(needs) }} run: | - yarn graphql:docs - yarn build - # 왜 2회인가: deleteOutDir이 dist를 지우는데 tsbuildinfo가 dist 밖에 있으면 - # 캐시만 살아남아 tsc가 "전부 최신"으로 오판, emit을 건너뛰고 dist가 빈 채로 - # 남는다. clean 체크아웃 1회 빌드로는 이 상태가 재현되지 않아 PR #259 회귀가 - # 8일간 CI를 그대로 통과했다. 2회차가 그 상태를 만들어 준다. - yarn build - test -f dist/main.js + echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + echo "$NEEDS" | jq -e 'length > 0 and all(.[]; .result == "success")' > /dev/null # arm64 앱 이미지(홈서버 맥미니). check와 나란히 돌려 배포까지 걸리는 시간을 줄인다 — Deploy는 이 워크플로(CI) 전체가 # 성공해야 시작하므로, check가 실패한 커밋은 이미지가 올라가도 배포되지 않는다. @@ -133,38 +401,6 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - coverage-report: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - pull-requests: write - checks: write - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup Node.js (24.x) & Yarn cache - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "24.x" - cache: "yarn" - - - name: Install dependencies - run: | - corepack enable - yarn install --immutable - - - name: Coverage Report (jest-coverage-report) - uses: ArtiomTr/jest-coverage-report-action@7f750dd50f5585533321eb7ebc482b936b49a5d4 # v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - test-script: yarn jest --coverage --ci - skip-step: install - annotations: failed-tests - pr-title: if: github.event_name == 'pull_request' runs-on: ubuntu-latest diff --git a/README.en.md b/README.en.md index 3610968e..6c6af120 100644 --- a/README.en.md +++ b/README.en.md @@ -130,7 +130,7 @@ Customers end up hopping between platforms, combining screenshots, edits, and ex - Static checks: **ESLint** (strict rules plus the `boundaries` plugin enforcing feature boundaries) and **Prettier**, run by **Husky** and **lint-staged** before commit and push. - Structural checks: **dependency-cruiser** gates layer direction and forbids cycles; **knip** reports unused code. - Commit messages: **commitlint** enforces Conventional Commits. -- Coverage, security, dependencies: **Codecov** (patch 80%; global thresholds statements 96%, branches 86%), **CodeQL**, **Dependabot** +- Coverage, security, dependencies: **Codecov** (patch 80%; global thresholds statements 97%, branches 92%), **CodeQL**, **Dependabot** - Repo-specific gates - `dto:check`: SDL inputs ↔ DTO classes stay in sync - `docs:check`: SDL description coverage @@ -369,12 +369,12 @@ extend type Query { ```bash yarn test # everything (Docker required) yarn test src/features/order # a single domain -yarn test:cov # coverage (thresholds: statements 96 / branches 86 / functions 92 / lines 96) +yarn test:cov # coverage (thresholds: statements 97 / branches 92 / functions 97 / lines 98) yarn test:scripts # infrastructure specs yarn test:push --dry-run # the Jest scope pre-push would run ``` -The pre-push hook runs every static check but only the Jest specs the change reaches. Changes the import graph cannot track (SDL, prisma, test infrastructure, dependencies, global wiring) fall back to the full suite. Full regression and the coverage thresholds belong to the required CI `check`. The development machine doubles as the production Mac mini and the test containers share its VM with production, so full test runs (`yarn validate`, `yarn test:cov`) go one at a time. The reasoning is in [architecture conventions §9](./docs/guide/architecture-conventions.md) (Korean). +The pre-push hook runs every static check but only the Jest specs the change reaches. Changes the import graph cannot track (SDL, prisma, test infrastructure, dependencies, global wiring) fall back to the full suite. Full regression runs in the CI `test` shards, the coverage thresholds are checked by `coverage-report` on the merged shard results, and the required `check` collects both. The development machine doubles as the production Mac mini and the test containers share its VM with production, so full test runs (`yarn validate`, `yarn test:cov`) go one at a time. The reasoning is in [architecture conventions §9](./docs/guide/architecture-conventions.md) (Korean). When a checker or gate is added, the **refutation cases** (proving it actually blocks what it should) are the core of its tests. CI uses the same testcontainers setup, so there is little difference between local and CI environments. @@ -405,14 +405,26 @@ docker compose --profile edge up -d # cloudflared (TUNNEL_ ### Workflows -| Workflow | Trigger | Role | -| -------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pr-check.yml` | PR · push (main/develop) | codegen, tsc, lint, docs/arch gates, dto:check (warning only; the hard gate is the pre-push hook), infrastructure specs, integration tests, coverage, and two consecutive builds (cache regression check) | -| `codeql.yml` | PR · push · weekly | CodeQL static security analysis | -| `knip.yml` · `nestjs-doctor.yml` | PR | comments with unused-code and NestJS health reports (advisory) | -| `pr-check.yml` `image` job | PR (build only) · main push (push) | builds an arm64 image alongside the tests and pushes `ghcr.io/caquick/caquick-be:` (no mutable tags) | -| `deploy.yml` | main **CI fully succeeds** · manual (rollback sha, CI-passed only) | the self-hosted runner (Mac mini) writes `.env` and `app.env` (mode 600) from secrets, then pull → migrate → worker → api → readiness wait → observability, and notifies Discord | -| `discord-notify.yml` | PR · push · issue | Discord notifications | +| Workflow | Trigger | Role | +| -------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pr-check.yml` | PR · push (main/develop) | runs static checks, infrastructure specs, the build, the test shards and the coverage merge as parallel jobs; `check` collects the results (see job layout below) | +| `codeql.yml` | PR · push · weekly | CodeQL static security analysis | +| `knip.yml` · `nestjs-doctor.yml` | PR | comments with unused-code and NestJS health reports (advisory) | +| `pr-check.yml` `image` job | PR (build only) · main push (push) | builds an arm64 image alongside the tests and pushes `ghcr.io/caquick/caquick-be:` (no mutable tags) | +| `deploy.yml` | main **CI fully succeeds** · manual (rollback sha, CI-passed only) | the self-hosted runner (Mac mini) writes `.env` and `app.env` (mode 600) from secrets, then pull → migrate → worker → api → readiness wait → observability, and notifies Discord | +| `discord-notify.yml` | PR · push · issue | Discord notifications | + +### CI job layout (`pr-check.yml`) + +- Jobs run in parallel and the required `check` collects their results. + - `static`: codegen, tsc, lint, dto:check (warning only; the hard gate is the pre-push hook), docs:check, arch:check + - `scripts`: infrastructure specs (`test:scripts`) + - `build`: two consecutive builds (incremental-cache regression check) + - `test`: the full Jest suite split into 2 shards (per-shard thresholds off) + - `coverage-report`: merges the shard coverage, checks it against the `jest.config.js` thresholds (`scripts/merge-coverage.ts`) and uploads to Codecov. On a PR it also comments a comparison against the base branch +- `check` treats skipped jobs as failures, because GitHub reports a skipped job as successful and that would satisfy a required check. +- The comparison baseline is the artifact uploaded by develop/main push runs. It is only taken from successful push runs of the base branch in this repository; without one the comment shows no delta. +- `node_modules` is cached by `yarn.lock`, `package.json`, the Yarn config and release, OS and Node version. On a hit only `prisma generate` runs instead of the install. ### Flow diff --git a/README.md b/README.md index 19b9839d..9ee22137 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ - 정적 검사: **ESLint**(strict 규칙 + `boundaries` 플러그인으로 feature 경계 강제) · **Prettier**. **Husky**와 **lint-staged**가 커밋·push 전에 실행합니다. - 구조 검사: **dependency-cruiser**(레이어 방향·순환 의존 금지)가 게이트로 막고, **knip**은 사용하지 않는 코드를 리포트합니다. - 커밋 메시지: **commitlint**가 Conventional Commits 형식을 검사합니다. -- 커버리지·보안·의존성: **Codecov**(patch 80%, 전역 statements 96% · branches 86%) · **CodeQL** · **Dependabot** +- 커버리지·보안·의존성: **Codecov**(patch 80%, 전역 statements 97% · branches 92%) · **CodeQL** · **Dependabot** - 이 레포만의 게이트 - `dto:check`: SDL input ↔ DTO class 동기화 - `docs:check`: SDL description 커버리지 @@ -369,12 +369,12 @@ extend type Query { ```bash yarn test # 전체 실행 (Docker 필요) yarn test src/features/order # 특정 도메인만 실행 -yarn test:cov # 커버리지 측정 (임계값: statements 96 / branches 86 / functions 92 / lines 96) +yarn test:cov # 커버리지 측정 (임계값: statements 97 / branches 92 / functions 97 / lines 98) yarn test:scripts # 인프라 spec 실행 yarn test:push --dry-run # pre-push가 돌릴 jest 범위 확인 ``` -pre-push는 정적 검사를 모두 돌리고, jest는 변경에 닿는 spec만 실행합니다. SDL·prisma·테스트 인프라·의존성·전역 배선처럼 import 그래프로 추적할 수 없는 변경이면 전체를 돌립니다. 전체 회귀와 커버리지 임계는 CI `check`(필수 체크)가 맡습니다. 개발 머신이 운영 맥미니를 겸하고 테스트 컨테이너가 운영과 같은 VM을 나눠 쓰므로, 전체 테스트(`yarn validate`·`yarn test:cov`)는 한 번에 하나만 돌립니다. 근거는 [아키텍처 컨벤션 §9](./docs/guide/architecture-conventions.md#9-테스트-규약)에 있습니다. +pre-push는 정적 검사를 모두 돌리고, jest는 변경에 닿는 spec만 실행합니다. SDL·prisma·테스트 인프라·의존성·전역 배선처럼 import 그래프로 추적할 수 없는 변경이면 전체를 돌립니다. 전체 회귀는 CI `test` 샤드가, 커버리지 임계는 샤드 결과를 합치는 `coverage-report`가 맡고, 필수 체크 `check`가 둘을 모읍니다. 개발 머신이 운영 맥미니를 겸하고 테스트 컨테이너가 운영과 같은 VM을 나눠 쓰므로, 전체 테스트(`yarn validate`·`yarn test:cov`)는 한 번에 하나만 돌립니다. 근거는 [아키텍처 컨벤션 §9](./docs/guide/architecture-conventions.md#9-테스트-규약)에 있습니다. 검사기와 게이트를 만들 때는 "막아야 할 것을 실제로 막는지"를 확인하는 **반증 케이스**를 테스트의 본체로 둡니다. CI도 같은 testcontainers 구성으로 실행하므로 로컬과 환경 차이가 거의 없습니다. @@ -405,14 +405,26 @@ docker compose --profile edge up -d # cloudflared (TUNNEL_ ### 워크플로우 -| Workflow | Trigger | 역할 | -| -------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pr-check.yml` | PR · push (main/develop) | codegen, tsc, lint, docs/arch 게이트, dto:check(경고만 — 하드 게이트는 pre-push), 인프라 spec, 전체 테스트, 커버리지, 빌드 2회(캐시 회귀 검출)를 실행합니다 | -| `codeql.yml` | PR · push · 주간 | CodeQL 정적 보안 분석을 실행합니다 | -| `knip.yml` · `nestjs-doctor.yml` | PR | 사용하지 않는 코드와 NestJS 점검 결과를 코멘트로 남깁니다(advisory) | -| `pr-check.yml` `image` job | PR(빌드만) · main push(GHCR 푸시) | 테스트와 나란히 arm64 이미지를 빌드해 `ghcr.io/caquick/caquick-be:`로 푸시합니다(가변 태그는 두지 않습니다) | -| `deploy.yml` | main **CI 전체 성공** · 수동(롤백 sha, CI 성공분만) | 셀프호스트 러너(맥미니)가 secrets로 `.env`·`app.env`(600)를 만들고 pull → migrate → worker → api → ready 대기 → 관측 순서로 배포한 뒤 Discord에 알립니다 | -| `discord-notify.yml` | PR · push · issue | Discord에 알림을 보냅니다 | +| Workflow | Trigger | 역할 | +| -------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pr-check.yml` | PR · push (main/develop) | 정적 검사, 인프라 spec, 빌드, 테스트 샤드, 커버리지 병합을 잡으로 나눠 나란히 실행하고 `check`가 결과를 모읍니다(아래 잡 구성) | +| `codeql.yml` | PR · push · 주간 | CodeQL 정적 보안 분석을 실행합니다 | +| `knip.yml` · `nestjs-doctor.yml` | PR | 사용하지 않는 코드와 NestJS 점검 결과를 코멘트로 남깁니다(advisory) | +| `pr-check.yml` `image` job | PR(빌드만) · main push(GHCR 푸시) | 테스트와 나란히 arm64 이미지를 빌드해 `ghcr.io/caquick/caquick-be:`로 푸시합니다(가변 태그는 두지 않습니다) | +| `deploy.yml` | main **CI 전체 성공** · 수동(롤백 sha, CI 성공분만) | 셀프호스트 러너(맥미니)가 secrets로 `.env`·`app.env`(600)를 만들고 pull → migrate → worker → api → ready 대기 → 관측 순서로 배포한 뒤 Discord에 알립니다 | +| `discord-notify.yml` | PR · push · issue | Discord에 알림을 보냅니다 | + +### CI 잡 구성 (`pr-check.yml`) + +- 잡을 나눠 나란히 돌리고, 필수 체크 `check`가 결과를 모읍니다. + - `static`: codegen, tsc, lint, dto:check(경고만 — 하드 게이트는 pre-push), docs:check, arch:check + - `scripts`: 인프라 spec(`test:scripts`) + - `build`: 빌드 2회(증분 캐시 회귀 검출) + - `test`: jest 전체를 2개 샤드로 나눠 실행(샤드별 임계는 끔) + - `coverage-report`: 샤드 커버리지를 합쳐 `jest.config.js`의 임계로 검사하고(`scripts/merge-coverage.ts`) Codecov에 올림. PR이면 base 브랜치 기준과 비교한 댓글을 남김 +- `check`는 건너뛴 잡도 실패로 봅니다. GitHub는 건너뛴 잡을 성공으로 보고해 필수 체크를 통과시키기 때문입니다. +- 커버리지 비교 기준은 develop·main push 실행이 올린 아티팩트입니다. 이 레포 base 브랜치의 성공한 push 실행에서만 받고, 없으면 차이 없이 표시합니다. +- `node_modules`는 `yarn.lock`·`package.json`·yarn 설정과 릴리즈·OS·node 버전을 키로 캐시합니다. 적중하면 설치 대신 `prisma generate`만 실행합니다. ### 흐름 diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 444ac56d..dc580e88 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -105,6 +105,7 @@ - 액세스 토큰은 **RS256**, 공개키는 `/.well-known/jwks.json`(kid = RFC 7638 썸프린트). 서명·검증 키는 `authConfig` 하나가 해석한다(raw env 재파싱 금지). - **정상 경로는 DB를 읽지 않는다.** 전략은 서명이 유효한 토큰의 클레임(role·mustChangePassword)을 신뢰한다. - 정지·탈퇴·비밀번호 변경(관리자 초기화 포함)은 **트랜잭션 커밋 뒤** Redis 블랙리스트에 등록한다(`auth:blk:*`, 액세스 TTL만큼). 등록 실패는 던지지 않고 경보 + 완전성 표식 삭제 → 전략이 DB 폴백. worker가 60초마다 재구축·조정하고 표식을 세운다. Redis `maxmemory`가 있으면 `noeviction`이어야 한다(표식만 살아남는 축출 정책은 위험). +- 비밀번호 변경은 **자격증명 버전**(`password_updated_at`, ms)으로 가른다. 로그인은 비밀번호를 검증할 때 읽은 버전을 세션(`credential_version`)과 액세스 토큰(`cv`)에 싣고, 회전은 확인한 세션의 버전을 그대로 넘긴다. refresh는 세션 버전이 현재와 다르면 그 세션을 폐기하고 거절하며, 블랙리스트·DB 폴백은 `cv`가 변경 시각보다 작은 토큰을 막는다(`cv`가 없는 옛 토큰만 `iat` 초 비교). 그래서 변경 트랜잭션과 겹쳐 전 세션 폐기를 비껴간 로그인·회전도 살아남지 못한다. - **왜 fail-open이 아닌가.** Redis 장애 때 "막지 못함"이 아니라 "DB로 다시 봄"이라 보안 후퇴가 없다. 대신 경보가 간다. ### 경보 @@ -129,17 +130,17 @@ **반증이 본체.** 검사기·게이트·가드는 "막아야 할 것을 실제로 막는지"가 테스트다 — 현재 코드에서 통과하는 것은 오탐이 없다는 뜻일 뿐이다. 입력 공간이 열거 가능하면(SDL 자리, 상태 전이, 에러 종류) `it.each` 전수 표로 고정한다. 일회성 검증 스크립트도 "0건"을 믿기 전에 대상 수를 찍고 일부러 걸리는 항목을 넣어 본다. 로그·API 응답은 필터 전 원본을 먼저 본다. -**정합성 도구.** `yarn validate:push`(Husky pre-push) = lint → tsc → `dto:check`(SDL input ↔ DTO 필드 일치. DTO 없는 input은 기본 lenient 모드에서 정보만, `--strict`에서 오류) → `docs:check`(SDL description 커버리지 — 자명한 필드 `id`·`createdAt`류·`*Id`/`*Ids`는 제외, 임계는 현재 달성치로 고정해 회귀만 차단) → `arch:check`(순환·Prisma-ban·레이어) → `test:scripts:push` → `test:push`. 정적 검사는 전부 돌고, `test:scripts`(scripts/*.spec)는 그 입력(`scripts/**`·`infra/**`·`.github/**`·`.husky/**`·도커 파일·jest 설정·의존성·`tsconfig*.json`)이 바뀌었을 때만 돈다(CI `check`는 항상). jest 범위만 `scripts/pre-push-test-plan.ts`가 기준 커밋(`origin/develop` merge-base, 없으면 `origin/main`, `PRE_PUSH_BASE`로 지정) 대비 작업 트리 변경으로 고른다(`yarn test:push --dry-run`으로 미리 본다). +**정합성 도구.** `yarn validate:push`(Husky pre-push) = lint → tsc → `dto:check`(SDL input ↔ DTO 필드 일치. DTO 없는 input은 기본 lenient 모드에서 정보만, `--strict`에서 오류) → `docs:check`(SDL description 커버리지 — 자명한 필드 `id`·`createdAt`류·`*Id`/`*Ids`는 제외, 임계는 현재 달성치로 고정해 회귀만 차단) → `arch:check`(순환·Prisma-ban·레이어) → `test:scripts:push` → `test:push`. 정적 검사는 전부 돌고, `test:scripts`(scripts/*.spec)는 그 입력(`scripts/**`·`infra/**`·`.github/**`·`.husky/**`·도커 파일·jest 설정·의존성·`tsconfig*.json`)이 바뀌었을 때만 돈다(CI `scripts` 잡은 항상). jest 범위만 `scripts/pre-push-test-plan.ts`가 기준 커밋(`origin/develop` merge-base, 없으면 `origin/main`, `PRE_PUSH_BASE`로 지정) 대비 작업 트리 변경으로 고른다(`yarn test:push --dry-run`으로 미리 본다). - **related**: `src/**/*.ts`만 바뀌면 `jest --findRelatedTests`로 그 파일을 import 그래프로 끌어오는 spec만 돈다. 소스를 파일로 읽어 검사하는 게이트 spec(`src/test/*.spec`, `fs`를 import하는 spec — 모델 소유권·경계 read·잠금 순서 등)은 그래프로 이어지지 않으므로 항상 붙인다. - **full**: 그래프가 못 보는 변경은 전체로 되돌린다. SDL(`*.graphql`, 스키마로 로드), `prisma/**`·`prisma.config.ts`, 테스트 인프라(`src/test/**`·`test/**`), `jest.config.js`, `package.json`(의존성·스크립트), 의존성(`yarn.lock`·`.yarnrc.yml`·`.yarn/**`), `tsconfig*.json`, 전역 배선(`src/config`·`src/global`·`app.module`·`main` — 모듈 배선 spec 전반에 닿는다), src `.ts` 삭제·이름 변경(없어진 모듈의 사용처는 역추적할 수 없다), src 변경 40개 초과, 분류 목록에 없는 파일(`.gitignore`·`nest-cli.json`은 `build-config.spec`이 읽는다), 기준 커밋 해석 실패. - **none**: 문서·`.github`·`infra`·`terraform`·도커·정적 검사 설정(`.dependency-cruiser.cjs`는 `arch:check`가 이미 돈다)·`scripts/**`(`test:scripts`가 전부 돌리고, src는 scripts를 import하지 않는다). -- 커버리지는 로컬에서 재지 않는다. 부분 실행의 커버리지는 임계와 비교할 수 없어서다. 전체 회귀와 임계(statements 96 / branches 86 / functions 92 / lines 96)는 CI `check`(필수 체크)의 `test:cov`가 맡고, `yarn validate`(정적 검사 → `test:cov`)는 수동 전체 검증용으로 남는다. +- 커버리지는 로컬에서 재지 않는다. 부분 실행의 커버리지는 임계와 비교할 수 없어서다. 같은 이유로 CI도 jest를 샤드로 나눠 돌리되 샤드별 임계는 끄고(`'--coverageThreshold={}'`), `coverage-report` 잡이 샤드 결과를 합쳐(`scripts/merge-coverage.ts`) `jest.config.js`의 임계(statements 97 / branches 92 / functions 97 / lines 98)로 검사한다. 테스트 실패·샤드 누락·임계 미달이면 실패하고, 필수 체크 `check`가 이 결과를 모은다. `yarn validate`(정적 검사 → `test:cov`)는 수동 전체 검증용으로 남는다. - ts-jest 변환 모드는 커버리지 여부로 갈린다(`jest.config.js`). `--coverage` 실행만 LanguageService 모드(`isolatedModules:false`)다 — transpile 모드의 데코레이터 메타데이터 가드식이 분기로 잡혀 branches 임계가 깨지기 때문이다. 나머지는 transpile 모드로, 파일별 타입 검사와 전이 의존 mtime 캐시 키가 없어 콜드 실행이 3배 이상 빠르다. 타입 오류는 jest가 아니라 `tsc --noEmit`(validate:push·CI)이 잡는다. -CI `check` 잡은 정적 검사를 개별 스텝으로 돌리되 `dto:check`는 `--warning`(이관 중이라 경고만)이라, `git push --no-verify`로 pre-push를 건너뛰면 SDL↔DTO 드리프트가 CI를 통과할 수 있다 — pre-push를 우회하지 않는 것이 규칙이다. `knip`(dead code)·`nestjs-doctor`는 PR 코멘트만(advisory, 오탐 있음). +CI `static` 잡은 정적 검사를 개별 스텝으로 돌리되 `dto:check`는 `--warning`(이관 중이라 경고만)이라, `git push --no-verify`로 pre-push를 건너뛰면 SDL↔DTO 드리프트가 CI를 통과할 수 있다 — pre-push를 우회하지 않는 것이 규칙이다. `knip`(dead code)·`nestjs-doctor`는 PR 코멘트만(advisory, 오탐 있음). -**운영 호스트 보호.** 개발 머신이 운영 맥미니를 겸하고, testcontainers(MySQL·Redis)가 운영 컨테이너와 같은 OrbStack VM(4CPU·8GB)을 나눠 쓴다. 전체 jest(330스위트, 실DB 145개)를 pre-push마다 돌리던 시절 한 번에 5.7~12.7분이 걸렸고, 그동안 운영 응답이 느려지고 부하성 간헐 실패가 났다. 그래서 pre-push는 위처럼 범위를 좁히고 전체는 CI에 맡긴다. 맥미니에서 전체 테스트(`yarn validate`·`yarn test:cov`·경로 없는 `yarn test`)를 돌려야 하면 **한 번에 하나만** 돌린다(여러 세션·에이전트가 동시에 띄우지 않는다). jest는 globalSetup에서 호스트 락(127.0.0.1:47391 포트를 여는 것, 프로세스가 죽으면 OS가 푼다)을 잡아 같은 호스트의 실행을 하나로 줄 세우고, 로컬 워커는 4개로 제한한다(`jest.config.js`, 실측 근거는 주석). CI와 watch 모드는 락을 잡지 않는다. 부하 중에 난 간헐 실패는 결함으로 단정하기 전에 단독 재실행으로 확인한다. +**운영 호스트 보호.** 개발 머신이 운영 맥미니를 겸하고, testcontainers(MySQL·Redis)가 운영 컨테이너와 같은 OrbStack VM(4CPU·8GB)을 나눠 쓴다. 전체 jest(330스위트, 실DB 145개)를 pre-push마다 돌리던 시절 한 번에 5.7~12.7분이 걸렸고, 그동안 운영 응답이 느려지고 부하성 간헐 실패가 났다. 그래서 pre-push는 위처럼 범위를 좁히고 전체는 CI에 맡긴다. 맥미니에서 전체 테스트(`yarn validate`·`yarn test:cov`·경로 없는 `yarn test`)를 돌려야 하면 **한 번에 하나만** 돌린다(여러 세션·에이전트가 동시에 띄우지 않는다). jest는 globalSetup에서 호스트 락(127.0.0.1:47391 포트를 여는 것, 프로세스가 죽으면 OS가 푼다)을 잡아 같은 호스트의 실행을 하나로 줄 세우고, 로컬 워커는 4개로 제한한다(`jest.config.js`, 실측 근거는 주석). CI와 watch 모드는 락을 잡지 않는다. 부하 중에 난 간헐 실패는 결함으로 단정하기 전에 단독 재실행으로 확인한다. supertest로 부르는 앱은 `listenOnLoopback`(`src/test/http-app.ts`)으로 127.0.0.1에 먼저 연다 — 와일드카드로 열면 macOS에서 다른 프로세스가 같은 포트를 127.0.0.1로 잡아 응답을 가로챈다(`http-app.spec`이 반증과 사용처 전수를 고정). --- diff --git a/jest.config.js b/jest.config.js index b2c8ce05..931bf2eb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ // 앱(src) jest 설정. scripts/ spec은 jest.scripts.config.js가 따로 돈다. // // ts-jest 변환 모드를 커버리지 여부로 가른다. -// - 커버리지 실행(CI test:cov·coverage-report): LanguageService 모드(isolatedModules:false). transpile 모드의 데코레이터 +// - 커버리지 실행(CI test 샤드·test:cov): LanguageService 모드(isolatedModules:false). transpile 모드의 데코레이터 // 메타데이터 가드식(`typeof X !== "undefined" && X`)이 분기로 잡혀 branches 임계가 깨진다(a93efac). // - 나머지(pre-push·경로 지정 실행): transpile 모드. LanguageService 모드는 파일마다 타입 검사를 하고 캐시 키에 전이 의존 // 파일의 mtime을 넣어 콜드 실행이 3배 이상 느리다. 타입 검사는 validate:push·CI의 tsc --noEmit이 한다. @@ -43,7 +43,7 @@ module.exports = { '!test/**', ], coverageThreshold: { - global: { statements: 96, branches: 86, functions: 92, lines: 96 }, + global: { statements: 97, branches: 92, functions: 97, lines: 98 }, }, coverageDirectory: '../coverage', testEnvironment: 'node', diff --git a/jest.scripts.config.js b/jest.scripts.config.js index 4f7ddc2d..577ea24c 100644 --- a/jest.scripts.config.js +++ b/jest.scripts.config.js @@ -1,7 +1,7 @@ // scripts/ 전용 jest 설정. // // 왜 별도인가: package.json의 jest는 rootDir이 src라 scripts/ 아래 spec을 아예 -// 수집하지 않는다. 앱 커버리지 임계치(statements 96 등)에 빌드 도구를 섞고 싶지도 +// 수집하지 않는다. 앱 커버리지 임계치(statements 97 등)에 빌드 도구를 섞고 싶지도 // 않아서, 실행만 분리하고 커버리지 집계에서는 제외한다. module.exports = { rootDir: 'scripts', diff --git a/package.json b/package.json index ccb6c0e8..4b329171 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:scripts": "jest --config jest.scripts.config.js", "test:push": "ts-node scripts/pre-push-test-plan.ts", "test:scripts:push": "ts-node scripts/pre-push-test-plan.ts --scripts", + "coverage:merge": "ts-node scripts/merge-coverage.ts", "arch:check": "depcruise --config .dependency-cruiser.cjs src", "knip": "knip --no-config-hints", "validate": "yarn lint && npx tsc --noEmit && yarn dto:check && yarn docs:check && yarn arch:check && yarn test:scripts && yarn test:cov", @@ -93,6 +94,9 @@ "@types/amqplib": "^0.10", "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", + "@types/istanbul-lib-coverage": "2.0.6", + "@types/istanbul-lib-report": "3.0.3", + "@types/istanbul-reports": "3.0.4", "@types/jest": "^30", "@types/node": "^25.8.0", "@types/passport": "^1", @@ -108,6 +112,9 @@ "eslint-plugin-prettier": "^5.5.6", "globals": "^17.11.0", "husky": "^9.1.7", + "istanbul-lib-coverage": "3.2.2", + "istanbul-lib-report": "3.0.1", + "istanbul-reports": "3.2.0", "jest": "^30", "knip": "^6.32.2", "lint-staged": "^17.3.0", diff --git a/prisma/migrations/20261004144805_refresh_session_credential_version/migration.sql b/prisma/migrations/20261004144805_refresh_session_credential_version/migration.sql new file mode 100644 index 00000000..6456ab79 --- /dev/null +++ b/prisma/migrations/20261004144805_refresh_session_credential_version/migration.sql @@ -0,0 +1,7 @@ +-- 세션이 발급될 때의 자격증명 버전(password_updated_at). refresh가 현재 값과 비교해, 비밀번호 변경과 겹쳐 +-- 발급된 세션(변경 트랜잭션의 전 세션 폐기를 비껴간 로그인·회전)을 거절한다. +-- AlterTable +ALTER TABLE `auth_refresh_session` ADD COLUMN `credential_version` DATETIME(3) NULL; + +-- 백필하지 않는다: 이미 경쟁으로 살아남은 세션이 있어도 행만으로는 가를 수 없다. null 세션은 비밀번호를 바꾼 적이 있는 +-- 계정이면 다음 refresh에서 폐기되고(1회 재로그인), 바꾼 적이 없는 계정(경쟁 불가)은 null끼리 일치해 이어진다. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5b739c3e..d37abe2b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -302,6 +302,9 @@ model AuthRefreshSession { replaced_by_session_id BigInt? @db.UnsignedBigInt + // 발급 근거가 된 자격증명 버전(account_credential.password_updated_at). 현재 값과 다르면 refresh를 거부한다. + credential_version DateTime? @db.DateTime(3) + created_at DateTime @default(now()) @db.DateTime(3) updated_at DateTime @updatedAt @db.DateTime(3) deleted_at DateTime? @db.DateTime(3) diff --git a/scripts/deploy-workflow.spec.ts b/scripts/deploy-workflow.spec.ts index 9d82e625..c7e9d563 100644 --- a/scripts/deploy-workflow.spec.ts +++ b/scripts/deploy-workflow.spec.ts @@ -1,4 +1,13 @@ -import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parse } from 'yaml'; @@ -20,6 +29,10 @@ interface Job { 'runs-on': string | string[]; environment?: string; if?: string; + needs?: string | string[]; + strategy?: { 'fail-fast'?: boolean; matrix: Record }; + env?: Record; + permissions?: Record; steps: Step[]; } interface Triggers { @@ -30,6 +43,7 @@ interface Triggers { } interface Workflow { on: Triggers; + permissions?: Record; concurrency?: Record; jobs: Record; } @@ -235,6 +249,227 @@ describe('pr-check.yml run 블록', () => { }); }); +/** shell을 지정하지 않은 run 블록과 같은 셸(bash -e {0}, 실행 로그로 확인)로 실제로 돌린다. */ +function runStep(run: string, env: Record, cwd?: string) { + return spawnSync('bash', ['-e', '-c', run], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf8', + }); +} + +describe('pr-check.yml 잡 구성', () => { + const wf = workflow('.github/workflows/pr-check.yml'); + const { check, test, 'coverage-report': coverage } = wf.jobs; + const list = (needs?: string | string[]) => [needs ?? []].flat(); + const step = (job: Job, name: string) => { + const found = job.steps.find((s) => s.name === name); + if (!found) throw new Error(`step 없음: ${name}`); + return found; + }; + + it('필수 체크 check는 if: always()로 늘 돌고, push·PR 모두에서 도는 잡(image·pr-title 제외)을 전부 기다린다', () => { + expect(check.if).toBe('always()'); + const others = Object.keys(wf.jobs).filter( + (name) => !['check', 'image', 'pr-title'].includes(name), + ); + expect(list(check.needs).sort()).toEqual(others.sort()); + }); + + // 건너뛴 잡은 GitHub에서 성공으로 보고된다 — 선행 잡이 실패해 skipped가 된 잡이 필수 체크를 통과시키지 않게 결과를 직접 본다 + it.each([ + ['전부 success면 통과', 0, ['success', 'success', 'success']], + ['반증: failure가 하나라도 있으면 실패', 1, ['success', 'failure']], + ['반증: skipped도 실패', 1, ['success', 'skipped']], + ['반증: cancelled도 실패', 1, ['cancelled', 'success']], + ['반증: needs가 비면 실패', 1, []], + ])('check 집계: %s(종료 코드 %i)', (_label, status, results) => { + const gate = check.steps.find((s) => s.run)!; + expect(gate.env?.NEEDS).toBe('${{ toJSON(needs) }}'); + const needs = Object.fromEntries( + results.map((result, i) => [`job${i}`, { result, outputs: {} }]), + ); + + expect(runStep(gate.run!, { NEEDS: JSON.stringify(needs) }).status).toBe( + status, + ); + }); + + it('test는 fail-fast 없는 샤드 행렬이고, coverage-report는 테스트가 실패해도(취소만 제외) 같은 샤드 수로 합친다', () => { + expect(list(coverage.needs)).toEqual(['test']); + expect(coverage.if).toBe('${{ !cancelled() }}'); + expect(test.strategy?.['fail-fast']).toBe(false); + const shards = test.strategy?.matrix.shard; + expect(shards).toEqual([1, 2]); + expect(Number(test.env?.SHARD_TOTAL)).toBe(shards?.length); + expect(Number(coverage.env?.SHARD_TOTAL)).toBe(shards?.length); + const run = test.steps.find((s) => s.run?.includes('jest'))?.run; + // --coverage는 LanguageService 모드 선택, 샤드별 임계는 끄고 합친 맵으로 검사, 액션 입력 형식(--json·위치) + expect(run).toContain(' --coverage '); + expect(run).toContain('--shard="$SHARD/$SHARD_TOTAL"'); + expect(run).toContain("'--coverageThreshold={}'"); + expect(run).toContain( + '--json --outputFile=coverage/report.json --testLocationInResults', + ); + expect(step(coverage, 'Merge coverage (임계는 jest.config.js)').run).toBe( + 'yarn coverage:merge coverage/shards coverage', + ); + }); + + it('의존성 캐시 키는 yarn.lock·package.json·yarn 설정·릴리즈·OS·아키텍처·node 버전을 담고, 적중하면 설치 대신 prisma generate(postinstall)를 돈다', () => { + const cached = Object.entries(wf.jobs).filter(([, job]) => + job.steps.some((s) => s.uses?.startsWith('actions/cache@')), + ); + expect(cached.map(([name]) => name).sort()).toEqual( + ['build', 'coverage-report', 'scripts', 'static', 'test'].sort(), + ); + for (const [, job] of cached) { + const cache = job.steps.find((s) => s.uses?.startsWith('actions/cache@')); + const key = String(cache?.with?.key); + for (const part of [ + '${{ runner.os }}', + '${{ runner.arch }}', + '${{ steps.node.outputs.node-version }}', + // .yarnrc.yml(nodeLinker·yarnPath)·yarn 릴리즈만 바뀌어도 낡은 설치를 복원하지 않게 + "${{ hashFiles('yarn.lock', 'package.json', '.yarnrc.yml', '.yarn/releases/**') }}", + ]) + expect(key).toContain(part); + expect( + job.steps.find((s) => s.uses?.startsWith('actions/setup-node@'))?.id, + ).toBe('node'); + expect(step(job, 'Install dependencies')).toMatchObject({ + if: "steps.modules.outputs.cache-hit != 'true'", + run: expect.stringContaining('yarn install --immutable'), + }); + expect( + step(job, 'Prisma generate (node_modules 캐시 적중)'), + ).toMatchObject({ + if: "steps.modules.outputs.cache-hit == 'true'", + run: expect.stringContaining('yarn prisma:generate'), + }); + } + }); + + it('반증: Codecov 업로드는 기준 받기·댓글 액션 뒤에 둔다 — codecov-action이 업로드 토큰을 GITHUB_ENV로 뒤 단계에 남긴다', () => { + const at = (prefix: string) => + coverage.steps.findIndex((s) => + (s.uses ?? s.name ?? '').startsWith(prefix), + ); + expect(at('codecov/codecov-action@')).toBeGreaterThan( + at('ArtiomTr/jest-coverage-report-action@'), + ); + expect(at('codecov/codecov-action@')).toBeGreaterThan( + at('Fetch base coverage'), + ); + }); + + it('반증: actions: read는 기준 아티팩트를 받는 coverage-report에만 준다', () => { + expect(wf.permissions).toEqual({ contents: 'read' }); + for (const [name, job] of Object.entries(wf.jobs)) { + expect({ name, actions: job.permissions?.actions }).toEqual({ + name, + actions: name === 'coverage-report' ? 'read' : undefined, + }); + } + }); + + describe('기준 커버리지 받기', () => { + const fetch = step(coverage, 'Fetch base coverage'); + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'base-coverage-')); + mkdirSync(join(dir, 'coverage')); + writeFileSync(join(dir, 'coverage/report.json'), '{"head":true}'); + mkdirSync(join(dir, 'bin')); + // gh 대역: api는 조회 종류별 run id를 내고, run download는 HAS에 든 run만 성공한다 + writeFileSync( + join(dir, 'bin/gh'), + [ + '#!/bin/bash', + 'echo "$*" >> "$GH_LOG"', + '[ "$1" = api ] && { [ -n "$API_FAIL" ] && exit 1; case "$2" in *head_sha=*) printf "%s\\n" $EXACT;; *) printf "%s\\n" $LATEST;; esac; exit 0; }', + 'for id in $HAS; do [ "$3" = "$id" ] && { echo "{\\"base\\":$id}" > "${@: -1}/report.json"; exit 0; }; done', + 'exit 1', + ].join('\n'), + ); + chmodSync(join(dir, 'bin/gh'), 0o755); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('반증: 이 레포 base 브랜치의 성공한 push 실행만 조회한다 — PR 실행의 아티팩트는 PR 코드가 만든 것이다', () => { + expect(fetch.run).toContain( + 'actions/workflows/pr-check.yml/runs?event=push&status=success&branch=$BASE_REF', + ); + expect(fetch.run).toContain( + 'select(.head_repository.full_name == env.REPO)', + ); + expect(fetch.env).toMatchObject({ + REPO: '${{ github.repository }}', + BASE_REF: '${{ github.base_ref }}', + BASE_SHA: '${{ github.event.pull_request.base.sha }}', + }); + // 기준 경로는 고정 — 액션은 입력 경로로 댓글을 식별해 경로가 바뀌면 댓글을 새로 단다 + expect( + coverage.steps.find((s) => s.uses?.startsWith('ArtiomTr/'))?.with, + ).toMatchObject({ + 'coverage-file': 'coverage/report.json', + 'base-coverage-file': 'coverage/base/report.json', + }); + }); + + it.each([ + [ + 'base 커밋의 실행이 먼저', + { EXACT: '11', LATEST: '13 12', HAS: '11 12' }, + '{"base":11}', + ['11'], + ], + [ + '그 실행에 아티팩트가 없으면 브랜치 최근 실행(중복은 한 번만)', + { EXACT: '11', LATEST: '11 12', HAS: '12' }, + '{"base":12}', + ['11', '12'], + ], + [ + '반증: 어디에도 없으면 head를 기준 자리에 둔다', + { EXACT: '', LATEST: '13', HAS: '' }, + '{"head":true}', + ['13'], + ], + [ + '반증: 조회가 실패해도 head로 이어간다', + { API_FAIL: '1', HAS: '' }, + '{"head":true}', + [], + ], + ])('%s', (_label, gh, base, tried) => { + const log = join(dir, 'gh.log'); + const result = runStep( + fetch.run!, + { + ...gh, + GH_LOG: log, + PATH: `${join(dir, 'bin')}:${process.env.PATH}`, + REPO: 'CaQuick/caquick-be', + BASE_REF: 'develop', + BASE_SHA: 'abc', + }, + dir, + ); + + expect(result.status).toBe(0); + expect( + readFileSync(join(dir, 'coverage/base/report.json'), 'utf8').trim(), + ).toBe(base); + const downloads = readFileSync(log, 'utf8') + .split('\n') + .filter((line) => line.startsWith('run download')) + .map((line) => line.split(' ')[2]); + expect(downloads).toEqual(tried); + }); + }); +}); + describe('infra/deploy.sh', () => { const script = read('infra/deploy.sh'); diff --git a/scripts/merge-coverage.spec.ts b/scripts/merge-coverage.spec.ts new file mode 100644 index 00000000..8a7a3630 --- /dev/null +++ b/scripts/merge-coverage.spec.ts @@ -0,0 +1,243 @@ +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; + +import { + createCoverageMap, + type CoverageMapData, + type FileCoverageData, +} from 'istanbul-lib-coverage'; + +import { + loadThresholds, + mergeCoverage, + parseThresholds, + type Thresholds, +} from './merge-coverage'; + +// CI coverage-report가 샤드 결과를 합쳐 임계를 거는 유일한 지점이다 — 부분 결과가 통과하지 못하는지가 본체다. +const ROOT = join(__dirname, '..'); +const A = join(ROOT, 'src/a.ts'); +const B = join(ROOT, 'src/b.ts'); + +const loc = (line: number) => ({ + start: { line, column: 0 }, + end: { line, column: 10 }, +}); + +/** 문장 2·함수 1·분기 1(경로 2)인 파일. hits = [문장0, 문장1, 함수0, 분기 경로0, 분기 경로1] */ +function file(path: string, hits: number[]): FileCoverageData { + const [s0, s1, f0, b0, b1] = hits; + return { + path, + statementMap: { 0: loc(1), 1: loc(2) }, + fnMap: { 0: { name: 'f', decl: loc(1), loc: loc(1), line: 1 } }, + branchMap: { + 0: { loc: loc(2), type: 'if', locations: [loc(2), loc(3)], line: 2 }, + }, + s: { 0: s0, 1: s1 }, + f: { 0: f0 }, + b: { 0: [b0, b1] }, + }; +} +const FULL = [1, 1, 1, 1, 1]; +const NONE = [0, 0, 0, 0, 0]; +const PARTIAL = [1, 0, 1, 1, 0]; + +/** jest --json 출력의 모양. jest는 샤드가 읽지 않은 파일도 collectCoverageFrom으로 0을 채워 넣는다 */ +function shard( + files: FileCoverageData[], + { passed, failed = 0 }: { passed: number; failed?: number }, +): Record { + return { + success: failed === 0, + startTime: 1000 + passed, + numTotalTests: passed + failed, + numPassedTests: passed, + numFailedTests: failed, + numRuntimeErrorTestSuites: 0, + snapshot: { failure: false, total: 0 }, + testResults: [ + { + name: join(ROOT, `src/${passed}-${failed}.spec.ts`), + assertionResults: [ + { status: failed ? 'failed' : 'passed', title: '케이스' }, + ], + }, + ], + coverageMap: Object.fromEntries(files.map((f) => [f.path, f])), + }; +} + +const LOOSE: Thresholds = { + statements: 75, + branches: 75, + functions: 100, + lines: 75, +}; + +describe('merge-coverage', () => { + let shardDir: string; + let outDir: string; + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'merge-coverage-')); + shardDir = join(dir, 'shards'); + outDir = join(dir, 'out'); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + const put = (n: number, report: Record) => { + mkdirSync(join(shardDir, `coverage-shard-${n}`), { recursive: true }); + writeFileSync( + join(shardDir, `coverage-shard-${n}`, 'report.json'), + JSON.stringify(report), + ); + }; + const report = () => + JSON.parse(readFileSync(join(outDir, 'report.json'), 'utf8')) as Record< + string, + unknown + > & { coverageMap: CoverageMapData; testResults: unknown[] }; + const merge = (shardTotal: number, thresholds: Thresholds) => + mergeCoverage({ shardDir, outDir, shardTotal, thresholds }); + + it('두 샤드를 합치면 한 번에 돈 결과와 수치가 같고, 테스트 수를 더해 report.json·coverage-final.json·lcov.info를 쓴다', () => { + put(1, shard([file(A, FULL), file(B, NONE)], { passed: 2 })); + put(2, shard([file(A, NONE), file(B, PARTIAL)], { passed: 3 })); + const single = createCoverageMap({ + [A]: file(A, FULL), + [B]: file(B, PARTIAL), + }) + .getCoverageSummary() + .toJSON(); + + expect(merge(2, LOOSE)).toEqual({ errors: [], summary: single }); + expect(single.statements).toMatchObject({ covered: 3, total: 4, pct: 75 }); + const merged = report(); + expect(merged).toMatchObject({ + success: true, + startTime: 1002, + numTotalTests: 5, + numPassedTests: 5, + numFailedTests: 0, + snapshot: { failure: false, total: 0 }, + }); + expect(merged.testResults).toHaveLength(2); + expect( + createCoverageMap(merged.coverageMap).getCoverageSummary().toJSON(), + ).toEqual(single); + expect(existsSync(join(outDir, 'coverage-final.json'))).toBe(true); + // Codecov가 받는 경로는 지금처럼 저장소 루트 기준 상대 경로 + expect(readFileSync(join(outDir, 'lcov.info'), 'utf8')).toContain( + `SF:${relative(process.cwd(), B)}\n`, + ); + }); + + it('반증: 샤드 하나만으로는 임계 미달로 실패한다 — 다른 샤드가 덮은 파일이 0으로 잡힌다', () => { + put(1, shard([file(A, FULL), file(B, NONE)], { passed: 2 })); + + expect(merge(1, LOOSE).errors).toEqual([ + 'statements 50% — 임계 75% 미달', + 'branches 50% — 임계 75% 미달', + 'functions 50% — 임계 100% 미달', + 'lines 50% — 임계 75% 미달', + ]); + }); + + it('반증: 샤드 결과가 모자라면 실패하고 report.json의 success도 거짓으로 쓴다', () => { + put(1, shard([file(A, FULL), file(B, FULL)], { passed: 2 })); + + expect(merge(2, {}).errors).toEqual(['샤드 결과 1개 — 2개여야 한다']); + expect(report().success).toBe(false); + }); + + it('반증: 실패한 테스트가 있으면 실패하고, 그때도 report.json을 써서 실패 내역을 남긴다', () => { + put(1, shard([file(A, FULL), file(B, NONE)], { passed: 2 })); + put(2, shard([file(A, NONE), file(B, FULL)], { passed: 2, failed: 1 })); + + expect(merge(2, LOOSE).errors).toEqual([ + '테스트 실패 — 실패 1건, 실행 오류 스위트 0개', + ]); + const merged = report(); + expect(merged).toMatchObject({ + success: false, + numFailedTests: 1, + numTotalTests: 5, + }); + expect(merged.testResults[1]).toMatchObject({ + assertionResults: [{ status: 'failed' }], + }); + }); + + it('반증: coverageMap이 없는 샤드는 실패한다 — 빈 맵은 istanbul이 임계 비교를 통과하는 값으로 센다', () => { + put(1, { ...shard([], { passed: 2 }), coverageMap: undefined }); + + expect(merge(1, { statements: 1 }).errors).toEqual([ + 'coverageMap이 없는 샤드가 있다(--coverage 누락)', + 'statements Unknown% — 임계 1% 미달', + ]); + }); + + describe('CLI(yarn coverage:merge) 종료 코드 — CI 잡의 성패가 이것으로 갈린다', () => { + it.each([ + ['샤드가 다 있고 jest.config.js 임계를 넘으면 0', [1, 2], '2', 0, true], + ['반증: 샤드가 모자라면 1, report.json은 남는다', [1], '2', 1, true], + ['반증: SHARD_TOTAL이 없으면 2', [1, 2], '', 2, false], + ])( + '%s', + (_label, shards, total, status, written) => { + if (shards.includes(1)) + put(1, shard([file(A, FULL), file(B, NONE)], { passed: 1 })); + if (shards.includes(2)) + put(2, shard([file(A, NONE), file(B, FULL)], { passed: 1 })); + + const result = spawnSync('yarn', ['coverage:merge', shardDir, outDir], { + cwd: ROOT, + env: { ...process.env, SHARD_TOTAL: total }, + encoding: 'utf8', + }); + + expect({ status: result.status, stderr: result.stderr }).toMatchObject({ + status, + }); + expect(existsSync(join(outDir, 'report.json'))).toBe(written); + }, + 60_000, + ); + }); +}); + +describe('임계 설정', () => { + it('jest.config.js의 global 임계를 그대로 읽는다', () => { + const config = require('../jest.config.js') as { + coverageThreshold: { global: Thresholds }; + }; + expect(loadThresholds()).toEqual(config.coverageThreshold.global); + }); + + it.each([ + [undefined], + [{}], + [{ global: {} }], + [{ global: { statements: 90 }, './src/a.ts': { lines: 90 } }], + [{ global: { statements: -10 } }], + [{ global: { statement: 90 } }], + [{ global: { lines: '90' } }], + ])( + '반증: %j는 거절한다 — 여기서 검사하지 못하는 형식을 조용히 통과시키지 않는다', + (config) => { + expect(() => parseThresholds(config)).toThrow( + '지원하지 않는 coverageThreshold', + ); + }, + ); +}); diff --git a/scripts/merge-coverage.ts b/scripts/merge-coverage.ts new file mode 100644 index 00000000..562280dd --- /dev/null +++ b/scripts/merge-coverage.ts @@ -0,0 +1,179 @@ +/** + * CI 테스트 샤드의 jest --json 결과를 한 벌로 합친다 — 커버리지 맵 병합, 테스트 결과 합산, 임계 검사. + * + * 왜: 샤드 하나의 커버리지는 다른 샤드가 덮은 파일을 0으로 세서 임계와 비교할 수 없다. 그래서 샤드는 임계를 끄고 + * ('--coverageThreshold={}') 돌리고, 임계는 합친 맵으로 여기서 검사한다. 값은 jest.config.js 한 곳에서 읽는다. + * + * 사용: SHARD_TOTAL=<샤드 수> yarn coverage:merge <샤드 디렉터리> <출력 디렉터리> + * 샤드 디렉터리의 하위 폴더마다 report.json(jest --coverage --json --testLocationInResults 출력)이 있어야 한다. + * 출력은 report.json(jest-coverage-report-action 입력)·coverage-final.json·lcov.info. + * 테스트 실패·샤드 누락·임계 미달이면 exit 1 — 그때도 report.json은 먼저 써서 PR 댓글에 실패가 보이게 한다. + */ + +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +import { + createCoverageMap, + type CoverageMapData, + type CoverageSummaryData, +} from 'istanbul-lib-coverage'; +import { createContext } from 'istanbul-lib-report'; +import { create } from 'istanbul-reports'; + +const METRICS = ['statements', 'branches', 'functions', 'lines'] as const; +export type Thresholds = Partial>; + +type Report = Record & { coverageMap?: CoverageMapData }; + +export interface MergeResult { + errors: string[]; + summary: CoverageSummaryData; +} + +/** jest coverageThreshold에서 global의 양수 퍼센트만 받는다 — 경로별 그룹·음수(미커버 개수)는 여기서 검사하지 못해 거절한다. */ +export function parseThresholds(coverageThreshold: unknown): Thresholds { + const { global, ...rest } = (coverageThreshold ?? {}) as Record< + string, + Record + >; + const entries = Object.entries(global ?? {}); + const ok = + Object.keys(rest).length === 0 && + entries.length > 0 && + entries.every( + ([key, value]) => + (METRICS as readonly string[]).includes(key) && + typeof value === 'number' && + value > 0, + ); + if (!ok) { + throw new Error( + `지원하지 않는 coverageThreshold: ${JSON.stringify(coverageThreshold)}`, + ); + } + return global; +} + +export function loadThresholds(): Thresholds { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const config = require('../jest.config.js') as { + coverageThreshold?: unknown; + }; + return parseThresholds(config.coverageThreshold); +} + +/** 수는 더하고(startTime은 가장 이른 값), success는 AND, 다른 참/거짓은 OR, 배열은 잇고, 객체(snapshot)는 같은 규칙으로 */ +function combine(a: unknown, b: unknown, key: string): unknown { + if (a === undefined) return b; + if (b === undefined) return a; + if (key === 'startTime') return Math.min(Number(a), Number(b)); + if (key === 'success') return a === true && b === true; + if (typeof a === 'number' && typeof b === 'number') return a + b; + if (typeof a === 'boolean' && typeof b === 'boolean') return a || b; + if (Array.isArray(a) && Array.isArray(b)) + return [...(a as unknown[]), ...(b as unknown[])]; + if (a && b && typeof a === 'object' && typeof b === 'object') { + const x = a as Record; + const y = b as Record; + const keys = new Set([...Object.keys(x), ...Object.keys(y)]); + return Object.fromEntries( + [...keys].map((k) => [k, combine(x[k], y[k], k)]), + ); + } + return a; +} + +function readShards(shardDir: string): Report[] { + if (!existsSync(shardDir)) return []; + return readdirSync(shardDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(shardDir, entry.name, 'report.json')) + .filter((file) => existsSync(file)) + .sort() + .map((file) => JSON.parse(readFileSync(file, 'utf8')) as Report); +} + +export function mergeCoverage(opts: { + shardDir: string; + outDir: string; + shardTotal: number; + thresholds: Thresholds; +}): MergeResult { + const shards = readShards(opts.shardDir); + const errors: string[] = []; + if (shards.length !== opts.shardTotal) { + errors.push(`샤드 결과 ${shards.length}개 — ${opts.shardTotal}개여야 한다`); + } + // 맵 없는 샤드는 그 샤드가 덮은 파일이 빠진 채 합쳐진다 — 커버리지를 안 잰 실행을 통과시키지 않는다 + if (shards.some((shard) => !shard.coverageMap)) { + errors.push('coverageMap이 없는 샤드가 있다(--coverage 누락)'); + } + + const map = createCoverageMap({}); + for (const shard of shards) map.merge(shard.coverageMap ?? {}); + const tests = shards + .map(({ coverageMap: _coverageMap, ...rest }) => rest) + .reduce>( + (acc, shard) => combine(acc, shard, '') as Record, + {}, + ); + const passed = tests.success === true && errors.length === 0; + if (shards.length > 0 && tests.success !== true) { + errors.push( + `테스트 실패 — 실패 ${Number(tests.numFailedTests ?? 0)}건, 실행 오류 스위트 ${Number(tests.numRuntimeErrorTestSuites ?? 0)}개`, + ); + } + + mkdirSync(opts.outDir, { recursive: true }); + writeFileSync( + join(opts.outDir, 'report.json'), + JSON.stringify({ ...tests, success: passed, coverageMap: map.toJSON() }), + ); + const context = createContext({ dir: opts.outDir, coverageMap: map }); + create('json').execute(context); + create('lcovonly').execute(context); + + const summary = map.getCoverageSummary().toJSON(); + for (const [metric, min] of Object.entries(opts.thresholds)) { + const { pct } = summary[metric as keyof CoverageSummaryData]; + // 빈 맵의 pct는 'Unknown'(문자열)이라 pct < min이 거짓이 된다 — 부정형으로 비교해 미달로 센다 + if (!(pct >= min)) errors.push(`${metric} ${pct}% — 임계 ${min}% 미달`); + } + return { errors, summary }; +} + +function main(): void { + const [shardDir, outDir] = process.argv.slice(2); + const shardTotal = Number(process.env.SHARD_TOTAL); + if (!shardDir || !outDir || !Number.isInteger(shardTotal) || shardTotal < 1) { + console.error( + '사용: SHARD_TOTAL=<샤드 수> yarn coverage:merge <샤드 디렉터리> <출력 디렉터리>', + ); + process.exit(2); + } + const thresholds = loadThresholds(); + const { errors, summary } = mergeCoverage({ + shardDir, + outDir, + shardTotal, + thresholds, + }); + for (const metric of METRICS) { + const { covered, total, pct } = summary[metric]; + const min = thresholds[metric]; + console.log( + `${metric.padEnd(10)} ${pct}% (${covered}/${total})${min ? ` 임계 ${min}%` : ''}`, + ); + } + for (const error of errors) console.error(`✖ ${error}`); + process.exit(errors.length > 0 ? 1 : 0); +} + +if (require.main === module) main(); diff --git a/scripts/pre-push-test-plan.ts b/scripts/pre-push-test-plan.ts index ca2c48bc..0ac330d0 100644 --- a/scripts/pre-push-test-plan.ts +++ b/scripts/pre-push-test-plan.ts @@ -2,7 +2,7 @@ * pre-push 테스트 계획 — 기준 커밋 대비 변경으로 jest 범위(full·related·none)를 고른다. * * 왜: 전체 jest(실DB 스위트 포함)는 운영과 같은 맥미니에서 5.7~12.7분을 쓰고 운영 컨테이너와 - * CPU·메모리를 다툰다. 전체 회귀·커버리지는 CI `check`가 필수 체크로 이미 돌리므로, 로컬은 + * CPU·메모리를 다툰다. 전체 회귀·커버리지는 CI(test 샤드·coverage-report, 필수 체크 check가 모음)가 이미 돌리므로, 로컬은 * import 그래프로 닿는 spec만 돌리고 그래프가 못 보는 변경은 보수적으로 전체로 되돌린다. * * 사용: yarn test:push (yarn validate:push의 마지막 단계) @@ -57,7 +57,7 @@ const NONE_RULES: RegExp[] = [ /^(\.coderabbit\.yaml|codecov\.yml|spectaql\.yml|\.dependency-cruiser\.cjs)$/, ]; -// scripts/*.spec이 읽는 입력 — 이것이 바뀔 때만 push 전에 test:scripts를 돌린다(CI check는 항상 돌린다). +// scripts/*.spec이 읽는 입력 — 이것이 바뀔 때만 push 전에 test:scripts를 돌린다(CI scripts 잡은 항상 돌린다). // 운영 VM에 Grafana·Alloy·MySQL 컨테이너를 띄우고 push마다 약 24초를 쓰는데, 대부분의 기능 변경은 여기에 닿지 않는다. const SCRIPT_TEST_RULES: RegExp[] = [ /^scripts\//, diff --git a/src/features/auth/auth.service.spec.ts b/src/features/auth/auth.service.spec.ts index 185e0e79..c12f3759 100644 --- a/src/features/auth/auth.service.spec.ts +++ b/src/features/auth/auth.service.spec.ts @@ -255,6 +255,7 @@ describe('AuthService', () => { typ: 'access', role: 'USER', mustChangePassword: false, + cv: 0, }); }); diff --git a/src/features/auth/auth.service.ts b/src/features/auth/auth.service.ts index 045fb733..a48c5c33 100644 --- a/src/features/auth/auth.service.ts +++ b/src/features/auth/auth.service.ts @@ -42,7 +42,10 @@ export class AuthService { throw new DomainException('ACCOUNT_NOT_ACTIVE'); } - const accessToken = this.tokens.signAccessToken(account); + const accessToken = this.tokens.signAccessToken( + account, + account.credential?.password_updated_at ?? null, + ); return { accessToken, tokenType: 'Bearer', diff --git a/src/features/auth/controllers/jwks.controller.spec.ts b/src/features/auth/controllers/jwks.controller.spec.ts index 3cc5b1ee..4ae3b0c0 100644 --- a/src/features/auth/controllers/jwks.controller.spec.ts +++ b/src/features/auth/controllers/jwks.controller.spec.ts @@ -11,6 +11,7 @@ import { RAW_RESPONSE_PATHS, } from '@/global/interceptors/api-response.interceptor'; import { testAuthConfig } from '@/test/auth-config'; +import { listenOnLoopback } from '@/test/http-app'; const KEYS = generateEphemeralKeyMaterial(); @@ -82,7 +83,7 @@ describe('JWKS HTTP 응답', () => { app = module.createNestApplication(); // main.ts와 같은 배선(같은 상수) app.useGlobalInterceptors(new ApiResponseInterceptor(RAW_RESPONSE_PATHS)); - await app.init(); + await listenOnLoopback(app); }); afterAll(async () => { diff --git a/src/features/auth/repositories/refresh-session.repository.interface.ts b/src/features/auth/repositories/refresh-session.repository.interface.ts index df4feb23..8825ea8c 100644 --- a/src/features/auth/repositories/refresh-session.repository.interface.ts +++ b/src/features/auth/repositories/refresh-session.repository.interface.ts @@ -4,13 +4,14 @@ import type { AuthRefreshSession } from '@/generated/prisma/client'; export const REFRESH_SESSION_REPOSITORY = Symbol('REFRESH_SESSION_REPOSITORY'); export interface IRefreshSessionRepository { - /** 토큰은 hash만 저장한다. */ + /** 토큰은 hash만 저장한다. credentialVersion = 발급 근거가 된 password_updated_at(refresh가 현재 값과 비교한다). */ createRefreshSession(args: { accountId: bigint; tokenHash: string; userAgent?: string; ipAddress?: string; expiresAt: Date; + credentialVersion: Date | null; }): Promise; findActiveRefreshSessionByHash( @@ -24,6 +25,7 @@ export interface IRefreshSessionRepository { userAgent?: string; ipAddress?: string; newExpiresAt: Date; + credentialVersion: Date | null; }): Promise; revokeRefreshSession(sessionId: bigint): Promise; diff --git a/src/features/auth/repositories/refresh-session.repository.spec.ts b/src/features/auth/repositories/refresh-session.repository.spec.ts index a5656559..4d11db64 100644 --- a/src/features/auth/repositories/refresh-session.repository.spec.ts +++ b/src/features/auth/repositories/refresh-session.repository.spec.ts @@ -1,4 +1,7 @@ import { ClockService } from '@/common/providers/clock.service'; +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; import { RefreshSessionRepository } from '@/features/auth/repositories/refresh-session.repository'; import type { PrismaClient } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; @@ -8,6 +11,8 @@ import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.bui describe('RefreshSessionRepository (real DB)', () => { let repo: RefreshSessionRepository; + let admins: AccountAdminRepository; + let auditLogs: AuditLogRepository; let prisma: PrismaClient; let clock: ClockService; @@ -16,10 +21,14 @@ describe('RefreshSessionRepository (real DB)', () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ providers: [ RefreshSessionRepository, + AccountAdminRepository, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, { provide: ClockService, useValue: clock }, ], }); repo = module.get(RefreshSessionRepository); + admins = module.get(AccountAdminRepository); + auditLogs = module.get(AUDIT_LOG_REPOSITORY); prisma = p; }); @@ -32,6 +41,10 @@ describe('RefreshSessionRepository (real DB)', () => { await truncateAll(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('createRefreshSession', () => { it('refresh session을 생성한다', async () => { const account = await createAccount(prisma); @@ -43,6 +56,7 @@ describe('RefreshSessionRepository (real DB)', () => { userAgent: 'test-agent', ipAddress: '1.2.3.4', expiresAt, + credentialVersion: null, }); expect(session.account_id).toBe(account.id); @@ -57,11 +71,26 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, tokenHash: 'b'.repeat(64), expiresAt: new Date(Date.now() + 3600_000), + credentialVersion: null, }); expect(session.user_agent).toBeNull(); expect(session.ip_address).toBeNull(); }); + + it('넘겨받은 자격증명 버전을 저장한다', async () => { + const account = await createAccount(prisma); + const version = new Date('2026-10-04T00:00:00.123Z'); + + const session = await repo.createRefreshSession({ + accountId: account.id, + tokenHash: 'v'.repeat(64), + expiresAt: new Date(Date.now() + 3600_000), + credentialVersion: version, + }); + + expect(session.credential_version).toEqual(version); + }); }); describe('findActiveRefreshSessionByHash', () => { @@ -119,6 +148,7 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, newTokenHash: 'f'.repeat(64), newExpiresAt: new Date(Date.now() + 3600_000), + credentialVersion: null, }); expect(newSession.token_hash).toBe('f'.repeat(64)); @@ -142,11 +172,30 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, newTokenHash: 'k'.repeat(64), newExpiresAt: new Date(Date.now() + 3600_000), + credentialVersion: null, }); expect(newSession.user_agent).toBeNull(); expect(newSession.ip_address).toBeNull(); }); + + it('새 세션에 넘겨받은 자격증명 버전을 저장한다', async () => { + const account = await createAccount(prisma); + const oldSession = await createRefreshSession(prisma, { + account_id: account.id, + }); + const version = new Date('2026-10-04T00:00:00.123Z'); + + const newSession = await repo.rotateRefreshSession({ + currentSessionId: oldSession.id, + accountId: account.id, + newTokenHash: 'w'.repeat(64), + newExpiresAt: new Date(Date.now() + 3600_000), + credentialVersion: version, + }); + + expect(newSession.credential_version).toEqual(version); + }); }); describe('revokeRefreshSession', () => { @@ -197,6 +246,7 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, tokenHash: 'h'.repeat(64), expiresAt: new Date(Date.now() + 60_000), + credentialVersion: null, }), ).rejects.toThrowDomain(403); expect( @@ -223,6 +273,7 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, newTokenHash: 'n'.repeat(64), newExpiresAt: new Date(Date.now() + 60_000), + credentialVersion: null, }), ).rejects.toThrowDomain(403); expect( @@ -235,5 +286,64 @@ describe('RefreshSessionRepository (real DB)', () => { }); expect(same.revoked_at).toBeNull(); }); + + /** 이 워커 DB에서 행 잠금을 기다리는 트랜잭션이 생길 때까지. */ + async function waitForLockWait(): Promise { + for (let i = 0; i < 250; i++) { + const [{ n }] = await prisma.$queryRaw<{ n: bigint }[]>` + SELECT COUNT(*) AS n FROM information_schema.innodb_trx t + JOIN information_schema.processlist p ON p.id = t.trx_mysql_thread_id + WHERE t.trx_state = 'LOCK WAIT' AND p.db = DATABASE()`; + if (n > 0) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error('잠금 대기가 관측되지 않았다'); + } + + // 정지 트랜잭션이 계정 행을 잡은 채(전 세션 폐기 뒤, 커밋 전) 발급이 끼어든다 + it('정지 트랜잭션과 겹친 발급은 잠금을 기다렸다가 거절된다 — 정지된 계정에 살아 있는 세션이 남지 않는다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + let issuing: Promise | undefined; + const recordAudit = auditLogs.recordAudit.bind(auditLogs); + jest + .spyOn(auditLogs, 'recordAudit') + .mockImplementationOnce(async (tx, entry) => { + issuing = repo + .createRefreshSession({ + accountId: account.id, + tokenHash: 'r'.repeat(64), + expiresAt: new Date(Date.now() + 60_000), + credentialVersion: null, + }) + .then( + () => null, + (error: unknown) => error, + ); + await waitForLockWait(); + return recordAudit(tx, entry); + }); + + await admins.updateAccountStatus({ + accountId: account.id, + from: 'ACTIVE', + to: 'SUSPENDED', + revokeSessions: true, + invalidTransitionCode: 'ONLY_ACTIVE_CAN_BE_SUSPENDED', + audit: { + actorAccountId: account.id, + storeId: null, + targetType: 'ACCOUNT', + targetId: account.id, + action: 'STATUS_CHANGE', + }, + }); + + expect(await issuing).toThrowDomain('ACCOUNT_NOT_ACTIVE'); + expect( + await prisma.authRefreshSession.count({ + where: { account_id: account.id, revoked_at: null }, + }), + ).toBe(0); + }); }); }); diff --git a/src/features/auth/repositories/refresh-session.repository.ts b/src/features/auth/repositories/refresh-session.repository.ts index dd331bb5..0f76bc09 100644 --- a/src/features/auth/repositories/refresh-session.repository.ts +++ b/src/features/auth/repositories/refresh-session.repository.ts @@ -19,6 +19,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository { userAgent?: string; ipAddress?: string; expiresAt: Date; + credentialVersion: Date | null; }): Promise { return this.prisma.$transaction(async (tx) => { await this.assertAccountActiveForUpdate(tx, args.accountId); @@ -29,6 +30,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository { user_agent: args.userAgent ?? null, ip_address: args.ipAddress ?? null, expires_at: args.expiresAt, + credential_version: args.credentialVersion, }, }); }); @@ -72,6 +74,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository { userAgent?: string; ipAddress?: string; newExpiresAt: Date; + credentialVersion: Date | null; }): Promise { return this.prisma.$transaction(async (tx) => { const now = this.clock.now(); @@ -84,6 +87,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository { user_agent: args.userAgent ?? null, ip_address: args.ipAddress ?? null, expires_at: args.newExpiresAt, + credential_version: args.credentialVersion, }, }); diff --git a/src/features/auth/services/blacklist-rebuild.service.spec.ts b/src/features/auth/services/blacklist-rebuild.service.spec.ts index 437e4103..aa9dd117 100644 --- a/src/features/auth/services/blacklist-rebuild.service.spec.ts +++ b/src/features/auth/services/blacklist-rebuild.service.spec.ts @@ -11,7 +11,6 @@ import type { PrismaClient } from '@/generated/prisma/client'; import { AlertService } from '@/global/alerting'; import { BLACKLIST_READY_TTL_SECONDS, - credentialCutoffSec, TokenBlacklistService, } from '@/global/auth/blacklist'; import { TEST_AUTH_CONFIG } from '@/test/auth-config'; @@ -137,7 +136,7 @@ describe('BlacklistRebuildService (real DB + real Redis)', () => { await expect(blacklist.lookup(suspended)).resolves.toEqual({ ready: true, status: 'SUSPENDED', - credentialCutoffSec: null, + credentialCutoffMs: null, }); // 탈퇴 버전도 status_changed_at(단조) — deleted_at이 아니다 expect(await statusValue(deleted.id)).toBe( @@ -145,11 +144,11 @@ describe('BlacklistRebuildService (real DB + real Redis)', () => { ); await expect(blacklist.lookup(changed)).resolves.toMatchObject({ status: null, - credentialCutoffSec: credentialCutoffSec(IN_WINDOW), + credentialCutoffMs: IN_WINDOW.getTime(), }); expect(await statusOf(oldSuspended)).toBeNull(); await expect(blacklist.lookup(oldChanged)).resolves.toMatchObject({ - credentialCutoffSec: null, + credentialCutoffMs: null, }); }); @@ -335,7 +334,7 @@ describe('BlacklistRebuildService (real DB + real Redis)', () => { await expect(blacklist.lookup(BigInt(1))).resolves.toEqual({ ready: true, status: null, - credentialCutoffSec: null, + credentialCutoffMs: null, }); }); diff --git a/src/features/auth/services/credential-auth.service.ts b/src/features/auth/services/credential-auth.service.ts index 9d057262..ce34a354 100644 --- a/src/features/auth/services/credential-auth.service.ts +++ b/src/features/auth/services/credential-auth.service.ts @@ -87,8 +87,10 @@ export class CredentialAuthService { const now = this.clock.now(); await this.credentials.updateLastLogin(credential.account_id, now); + // 버전은 검증한 행의 것 — 검증 뒤 커밋된 변경이 있으면 이 세션·토큰은 버전이 낡아 막힌다 const { accessToken } = await this.tokens.issueAuthTokens({ accountId: credential.account_id, + credentialVersion: credential.password_updated_at, req: args.req, res: args.res, }); diff --git a/src/features/auth/services/credential-version.service.spec.ts b/src/features/auth/services/credential-version.service.spec.ts new file mode 100644 index 00000000..ccb5d078 --- /dev/null +++ b/src/features/auth/services/credential-version.service.spec.ts @@ -0,0 +1,405 @@ +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import argon2 from 'argon2'; +import type { Request, Response } from 'express'; +import type Redis from 'ioredis'; + +import { ClockService } from '@/common/providers/clock.service'; +import { sha256Hex } from '@/common/utils/crypto'; +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { AuthService } from '@/features/auth/auth.service'; +import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; +import { AccountCredentialRepository } from '@/features/auth/repositories/account-credential.repository'; +import { ACCOUNT_CREDENTIAL_REPOSITORY } from '@/features/auth/repositories/account-credential.repository.interface'; +import { AccountRepository } from '@/features/auth/repositories/account.repository'; +import { ACCOUNT_REPOSITORY } from '@/features/auth/repositories/account.repository.interface'; +import { RefreshSessionRepository } from '@/features/auth/repositories/refresh-session.repository'; +import { REFRESH_SESSION_REPOSITORY } from '@/features/auth/repositories/refresh-session.repository.interface'; +import { AdminAccountService } from '@/features/auth/services/auth-admin-account.service'; +import { + CredentialAuthService, + type CredentialRole, +} from '@/features/auth/services/credential-auth.service'; +import { TokenService } from '@/features/auth/services/token.service'; +import { JwtBearerStrategy } from '@/features/auth/strategies/jwt-bearer.strategy'; +import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; +import { AdminSellerService } from '@/features/store/services/store-admin-seller.service'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { AlertService } from '@/global/alerting'; +import type { AccessTokenPayload } from '@/global/auth'; +import { + BLACKLIST_READY_KEY, + TokenBlacklistService, +} from '@/global/auth/blacklist/token-blacklist.service'; +import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; +import { TEST_AUTH_CONFIG } from '@/test/auth-config'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { connectTestRedis } from '@/test/db/redis-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createAccountCredential } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { redisTestProviders } from '@/test/redis'; + +const PASSWORD = 'Current!Pass1'; +const NEW_PASSWORD = 'Changed!Pass2'; + +const jwt = new JwtService({ + privateKey: TEST_AUTH_CONFIG.jwtKeys.privateKeyPem, + publicKey: TEST_AUTH_CONFIG.jwtKeys.publicKeyPem, + signOptions: { + algorithm: 'RS256', + issuer: TEST_AUTH_CONFIG.jwtIssuer, + audience: TEST_AUTH_CONFIG.jwtAudience, + keyid: TEST_AUTH_CONFIG.jwtKeys.kid, + expiresIn: TEST_AUTH_CONFIG.jwtAccessExpiresSeconds, + }, +}); + +// 비밀번호 변경과 겹친 발급: 변경 트랜잭션(교체 + 전 세션 폐기)이 커밋된 뒤에 만들어진 세션·토큰은 폐기를 비껴간다 +describe('자격증명 버전 — 비밀번호 변경과 겹친 로그인·회전 (real DB + real Redis)', () => { + let prisma: PrismaClient; + let redis: Redis; + let credentialAuth: CredentialAuthService; + let auth: AuthService; + let tokens: TokenService; + let credentials: AccountCredentialRepository; + let refreshSessions: RefreshSessionRepository; + let blacklist: TokenBlacklistService; + let adminAccounts: AdminAccountService; + let adminSellers: AdminSellerService; + let strategy: JwtBearerStrategy; + let passwordHash: string; + const alerts = { notify: jest.fn().mockResolvedValue('sent') }; + + beforeAll(async () => { + redis = await connectTestRedis(); + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + CredentialAuthService, + AuthService, + TokenService, + AdminAccountService, + AdminSellerService, + StoreSellerRepository, + AccountAdminRepository, + TokenBlacklistService, + ClockService, + { provide: ACCOUNT_REPOSITORY, useClass: AccountRepository }, + { + provide: ACCOUNT_CREDENTIAL_REPOSITORY, + useClass: AccountCredentialRepository, + }, + { + provide: REFRESH_SESSION_REPOSITORY, + useClass: RefreshSessionRepository, + }, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + { provide: AlertService, useValue: alerts }, + { provide: JwtService, useValue: jwt }, + { + provide: ConfigService, + useValue: { getOrThrow: () => TEST_AUTH_CONFIG }, + }, + ...redisTestProviders(redis), + ], + }); + prisma = p; + credentialAuth = module.get(CredentialAuthService); + auth = module.get(AuthService); + tokens = module.get(TokenService); + credentials = module.get(ACCOUNT_CREDENTIAL_REPOSITORY); + refreshSessions = module.get(REFRESH_SESSION_REPOSITORY); + blacklist = module.get(TokenBlacklistService); + adminAccounts = module.get(AdminAccountService); + adminSellers = module.get(AdminSellerService); + // Passport Strategy는 생성자에서 super()를 부르므로 validate만 쓰기 위해 prototype에서 만든다 + strategy = Object.assign(Object.create(JwtBearerStrategy.prototype), { + accounts: module.get(ACCOUNT_REPOSITORY), + blacklist, + alerts, + }) as JwtBearerStrategy; + passwordHash = await argon2.hash(PASSWORD, { type: argon2.argon2id }); + }); + + afterAll(async () => { + await redis.quit(); + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + await redis.flushdb(); + await blacklist.markReady(await blacklist.generation()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function jar() { + const set: Record = {}; + const res = { + cookie: (name: string, value: string) => { + set[name] = value; + }, + } as unknown as Response; + return { res, set }; + } + + function reqWith(cookies: Record = {}): Request { + return { cookies, headers: {}, ip: '127.0.0.1' } as unknown as Request; + } + + async function login(role: CredentialRole, username: string) { + const { res, set } = jar(); + const { accessToken } = await credentialAuth.login({ + role, + username, + password: PASSWORD, + req: reqWith(), + res, + }); + return { accessToken, refreshToken: set[REFRESH_COOKIE[role]] }; + } + + async function refresh(role: CredentialRole, refreshToken: string) { + const { res, set } = jar(); + const { accessToken } = await credentialAuth.refresh({ + role, + req: reqWith({ [REFRESH_COOKIE[role]]: refreshToken }), + res, + }); + return { accessToken, refreshToken: set[REFRESH_COOKIE[role]] }; + } + + function sessionOf(refreshToken: string) { + return prisma.authRefreshSession.findFirstOrThrow({ + where: { token_hash: sha256Hex(refreshToken) }, + }); + } + + /** Redis 경로(표식 있음)와 DB 폴백(표식 없음) 둘 다에서 같은 판정이어야 한다. */ + async function expectAccessToken(accessToken: string, blocked: boolean) { + const payload = jwt.verify(accessToken); + for (const path of ['redis', 'db'] as const) { + if (path === 'db') await redis.del(BLACKLIST_READY_KEY); + const result = strategy.validate(payload); + if (blocked) { + await expect(result).rejects.toThrowDomain('INVALID_ACCESS_TOKEN'); + } else { + await expect(result).resolves.toMatchObject({ accountId: payload.sub }); + } + } + await blacklist.markReady(await blacklist.generation()); + } + + async function makeCredential(role: CredentialRole) { + return createAccountCredential(prisma, { + account_type: role, + password_hash: passwordHash, + }); + } + + async function adminActor(): Promise { + return (await createAccount(prisma, { account_type: 'ADMIN' })).id; + } + + // 비밀번호를 바꾸는 세 경로 — 모두 교체·전 세션 폐기를 한 트랜잭션으로 하고 커밋 뒤 블랙리스트에 등록한다 + const CHANGES: Array< + [string, CredentialRole, (accountId: bigint) => Promise] + > = [ + [ + '본인 변경', + 'SELLER', + (accountId) => + credentialAuth.changePassword({ + role: 'SELLER', + accountId, + currentPassword: PASSWORD, + newPassword: NEW_PASSWORD, + req: reqWith(), + }), + ], + [ + '관리자 비밀번호 초기화', + 'ADMIN', + async (accountId) => + adminAccounts.adminResetAdminPassword(await adminActor(), { + accountId: accountId.toString(), + newPassword: NEW_PASSWORD, + }), + ], + [ + '판매자 비밀번호 초기화', + 'SELLER', + async (accountId) => + adminSellers.adminResetSellerPassword(await adminActor(), { + accountId: accountId.toString(), + newPassword: NEW_PASSWORD, + }), + ], + ]; + + describe('로그인 — 비밀번호 검증과 발급 사이에 변경이 커밋된다', () => { + it.each(CHANGES)( + '%s: 그 로그인의 액세스 토큰은 막히고 세션은 refresh에서 폐기된다', + async (_, role, change) => { + const credential = await makeCredential(role); + const updateLastLogin = credentials.updateLastLogin.bind(credentials); + jest + .spyOn(credentials, 'updateLastLogin') + .mockImplementationOnce(async (accountId, now) => { + await change(accountId); + return updateLastLogin(accountId, now); + }); + + const issued = await login(role, credential.username); + + // 변경의 전 세션 폐기를 비껴간 세션이 생겼다 — 경쟁이 재현됐다 + expect((await sessionOf(issued.refreshToken)).revoked_at).toBeNull(); + await expectAccessToken(issued.accessToken, true); + await expect(refresh(role, issued.refreshToken)).rejects.toThrowDomain( + 'INVALID_REFRESH_TOKEN', + ); + expect( + (await sessionOf(issued.refreshToken)).revoked_at, + ).not.toBeNull(); + }, + ); + }); + + describe('refresh 회전 — 세션 확인과 회전 사이에 변경이 커밋된다', () => { + it.each(CHANGES)( + '%s: 회전으로 받은 액세스 토큰은 막히고 새 세션은 다음 refresh에서 폐기된다', + async (_, role, change) => { + const credential = await makeCredential(role); + const first = await login(role, credential.username); + const rotate = + refreshSessions.rotateRefreshSession.bind(refreshSessions); + jest + .spyOn(refreshSessions, 'rotateRefreshSession') + .mockImplementationOnce(async (args) => { + await change(credential.account_id); + return rotate(args); + }); + + const rotated = await refresh(role, first.refreshToken); + + expect((await sessionOf(rotated.refreshToken)).revoked_at).toBeNull(); + await expectAccessToken(rotated.accessToken, true); + await expect(refresh(role, rotated.refreshToken)).rejects.toThrowDomain( + 'INVALID_REFRESH_TOKEN', + ); + expect( + (await sessionOf(rotated.refreshToken)).revoked_at, + ).not.toBeNull(); + }, + ); + }); + + describe('변경과 겹치지 않으면 그대로', () => { + it('변경 이력이 있는 계정도 로그인·연속 회전이 통과하고 세션이 같은 버전을 이어받는다', async () => { + const credential = await makeCredential('SELLER'); + const changedAt = new Date('2026-10-01T00:00:00.123Z'); + await prisma.accountCredential.update({ + where: { account_id: credential.account_id }, + data: { password_updated_at: changedAt }, + }); + await blacklist.blockCredentials(credential.account_id, changedAt); + + const first = await login('SELLER', credential.username); + const second = await refresh('SELLER', first.refreshToken); + const third = await refresh('SELLER', second.refreshToken); + + expect((await sessionOf(third.refreshToken)).credential_version).toEqual( + changedAt, + ); + for (const { accessToken } of [first, second, third]) { + await expectAccessToken(accessToken, false); + } + }); + + it.each(CHANGES)( + '%s 뒤 새 비밀번호로 로그인한 세션·토큰은 통과한다', + async (_, role, change) => { + const credential = await makeCredential(role); + await change(credential.account_id); + + const { res, set } = jar(); + const { accessToken } = await credentialAuth.login({ + role, + username: credential.username, + password: NEW_PASSWORD, + req: reqWith(), + res, + }); + const rotated = await refresh(role, set[REFRESH_COOKIE[role]]); + + await expectAccessToken(accessToken, false); + await expectAccessToken(rotated.accessToken, false); + }, + ); + + it('구매자(OIDC) 세션은 자격증명이 없어(버전 null) 회전이 이어지고 토큰 cv는 0이다', async () => { + const buyer = await createAccount(prisma, { account_type: 'USER' }); + const { res, set } = jar(); + // OIDC 콜백이 부르는 발급과 같은 인자 + await tokens.issueAuthTokens({ + accountId: buyer.id, + credentialVersion: null, + req: reqWith(), + res, + }); + + let refreshToken = set[REFRESH_COOKIE.USER]; + for (let i = 0; i < 2; i++) { + const next = jar(); + const { accessToken } = await auth.refresh( + reqWith({ [REFRESH_COOKIE.USER]: refreshToken }), + next.res, + ); + refreshToken = next.set[REFRESH_COOKIE.USER]; + expect(jwt.verify(accessToken).cv).toBe(0); + await expectAccessToken(accessToken, false); + } + expect((await sessionOf(refreshToken)).credential_version).toBeNull(); + }); + }); + + // 마이그레이션은 버전을 백필하지 않는다 — 기존 세션은 null로 남는다 + describe('버전 기록 전에 발급된 세션', () => { + it.each([ + ['비밀번호를 바꾼 적 없는 계정은 이어진다', null, false], + [ + '비밀번호를 바꾼 적 있는 계정은 폐기·거절된다', + new Date('2026-10-01T00:00:00.123Z'), + true, + ], + ])('%s', async (_, changedAt, rejected) => { + const credential = await makeCredential('SELLER'); + const issued = await login('SELLER', credential.username); + await prisma.authRefreshSession.updateMany({ + data: { credential_version: null }, + }); + await prisma.accountCredential.update({ + where: { account_id: credential.account_id }, + data: { password_updated_at: changedAt }, + }); + + const result = refresh('SELLER', issued.refreshToken); + + if (rejected) { + await expect(result).rejects.toThrowDomain('INVALID_REFRESH_TOKEN'); + expect( + (await sessionOf(issued.refreshToken)).revoked_at, + ).not.toBeNull(); + } else { + await expect(result).resolves.toMatchObject({ + accessToken: expect.any(String), + }); + } + }); + }); +}); diff --git a/src/features/auth/services/oidc-login.service.spec.ts b/src/features/auth/services/oidc-login.service.spec.ts index 35f99ef1..60e09041 100644 --- a/src/features/auth/services/oidc-login.service.spec.ts +++ b/src/features/auth/services/oidc-login.service.spec.ts @@ -254,6 +254,10 @@ describe('OidcLoginService', () => { providerDisplayName: 'Test User', providerProfileImageUrl: 'https://example.com/photo.jpg', }); + // 구매자는 자격증명이 없다 — 세션 버전 null + expect(mockRefreshSessions.createRefreshSession).toHaveBeenCalledWith( + expect.objectContaining({ credentialVersion: null }), + ); expect(mockRes.cookie).toHaveBeenCalledWith( REFRESH_COOKIE.USER, expect.any(String), diff --git a/src/features/auth/services/oidc-login.service.ts b/src/features/auth/services/oidc-login.service.ts index 7632bff9..53bd0ec5 100644 --- a/src/features/auth/services/oidc-login.service.ts +++ b/src/features/auth/services/oidc-login.service.ts @@ -78,8 +78,10 @@ export class OidcLoginService { const account = await this.upsertAccountFromOidc(provider, userInfo); + // OIDC 계정은 구매자라 자격증명(비밀번호)이 없다 const { accessToken } = await this.tokens.issueAuthTokens({ accountId: account.id, + credentialVersion: null, req, res, }); diff --git a/src/features/auth/services/refresh-cookie.service.spec.ts b/src/features/auth/services/refresh-cookie.service.spec.ts index 30e86a50..9953e9f1 100644 --- a/src/features/auth/services/refresh-cookie.service.spec.ts +++ b/src/features/auth/services/refresh-cookie.service.spec.ts @@ -109,7 +109,13 @@ describe('역할별 refresh 쿠키 (real DB)', () => { : (await createAccountCredential(prisma, { account_type: role })) .account_id; const { res, set } = jar(); - await tokens.issueAuthTokens({ accountId, req: reqWith({}), res }); + // 팩토리 자격증명은 변경 이력이 없다(버전 null) + await tokens.issueAuthTokens({ + accountId, + credentialVersion: null, + req: reqWith({}), + res, + }); const raw = Object.values(set)[0]; const session = await prisma.authRefreshSession.findFirstOrThrow({ where: { token_hash: sha256Hex(raw) }, diff --git a/src/features/auth/services/token.service.spec.ts b/src/features/auth/services/token.service.spec.ts index b0b3c8b7..b0735807 100644 --- a/src/features/auth/services/token.service.spec.ts +++ b/src/features/auth/services/token.service.spec.ts @@ -77,14 +77,17 @@ describe('TokenService', () => { }); describe('signAccessToken', () => { - it('신원 클레임(sub·typ·role·mustChangePassword)만 서명한다 — 시간·발급자는 서명 옵션 몫', () => { - const result = service.signAccessToken({ - id: BigInt(42), - status: 'ACTIVE', - account_type: 'USER', - credential: null, - store: null, - }); + it('신원 클레임(sub·typ·role·mustChangePassword·cv)만 서명한다 — 시간·발급자는 서명 옵션 몫', () => { + const result = service.signAccessToken( + { + id: BigInt(42), + status: 'ACTIVE', + account_type: 'USER', + credential: null, + store: null, + }, + null, + ); expect(result).toBe('signed-token'); expect(jwt.sign).toHaveBeenCalledTimes(1); @@ -93,17 +96,21 @@ describe('TokenService', () => { typ: 'access', role: 'USER', mustChangePassword: false, + cv: 0, }); }); it('판매자는 storeId를, 비밀번호 변경 대상은 플래그를 클레임에 담는다', () => { - service.signAccessToken({ - id: BigInt(7), - status: 'ACTIVE', - account_type: 'SELLER', - credential: { must_change_password: true, password_updated_at: null }, - store: { id: BigInt(3) }, - }); + service.signAccessToken( + { + id: BigInt(7), + status: 'ACTIVE', + account_type: 'SELLER', + credential: { must_change_password: true, password_updated_at: null }, + store: { id: BigInt(3) }, + }, + null, + ); expect(jwt.sign).toHaveBeenCalledWith({ sub: '7', @@ -111,8 +118,31 @@ describe('TokenService', () => { role: 'SELLER', mustChangePassword: true, storeId: '3', + cv: 0, }); }); + + it('cv는 넘겨받은 버전(ms)이다 — 계정 행의 password_updated_at을 다시 쓰지 않는다', () => { + const verified = new Date('2026-10-04T00:00:00.123Z'); + + service.signAccessToken( + { + id: BigInt(7), + status: 'ACTIVE', + account_type: 'SELLER', + credential: { + must_change_password: false, + password_updated_at: new Date('2026-10-04T00:00:01.000Z'), + }, + store: null, + }, + verified, + ); + + expect(jwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ cv: verified.getTime() }), + ); + }); }); describe('getAccessExpiresSeconds', () => { @@ -150,6 +180,7 @@ describe('TokenService', () => { const result = await service.issueAuthTokens({ accountId: BigInt(1), + credentialVersion: null, req: mockReq, res: mockRes, }); @@ -160,6 +191,7 @@ describe('TokenService', () => { accountId: BigInt(1), userAgent: 'Mozilla/5.0 TokenSpec', ipAddress: '127.0.0.1', + credentialVersion: null, }), ); expect(mockRes.cookie).toHaveBeenCalledTimes(1); @@ -206,6 +238,7 @@ describe('TokenService', () => { refreshSessions.findActiveRefreshSessionByHash.mockResolvedValue({ id: BigInt(7), account_id: BigInt(10), + credential_version: null, } as never); refreshSessions.rotateRefreshSession.mockResolvedValue({} as never); @@ -222,6 +255,7 @@ describe('TokenService', () => { expect.objectContaining({ currentSessionId: BigInt(7), accountId: BigInt(10), + credentialVersion: null, }), ); expect(mockRes.cookie).toHaveBeenCalledTimes(1); diff --git a/src/features/auth/services/token.service.ts b/src/features/auth/services/token.service.ts index aa6a0a50..cdf70cc6 100644 --- a/src/features/auth/services/token.service.ts +++ b/src/features/auth/services/token.service.ts @@ -21,6 +21,7 @@ import { REFRESH_SESSION_REPOSITORY, type IRefreshSessionRepository, } from '@/features/auth/repositories/refresh-session.repository.interface'; +import type { AuthRefreshSession } from '@/generated/prisma/client'; import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; import type { AccessTokenClaims, @@ -38,24 +39,26 @@ export class TokenService { private readonly accounts: IAccountRepository, ) {} - /** iat·exp·iss·aud·kid는 서명 옵션(JwtModule)이 붙인다 — 여기서는 신원 클레임만 만든다. */ - signAccessToken(account: AccountForJwt): string { + /** + * iat·exp·iss·aud·kid는 서명 옵션(JwtModule)이 붙인다 — 여기서는 신원 클레임만 만든다. + * credentialVersion(cv)은 계정 행에서 다시 꺼내지 않는다 — 자격을 확인한 시점의 값이어야 그 뒤 커밋된 변경이 이 토큰을 막는다. + */ + signAccessToken( + account: AccountForJwt, + credentialVersion: Date | null, + ): string { const claims: AccessTokenClaims = { sub: account.id.toString(), typ: 'access', role: account.account_type, mustChangePassword: account.credential?.must_change_password ?? false, ...(account.store ? { storeId: account.store.id.toString() } : {}), + cv: versionMs(credentialVersion), }; return this.jwt.sign(claims); } - /** 발급 시점의 계정 상태를 클레임에 담기 위해 매번 조회한다(재발급 포함). */ - async signAccessTokenFor(accountId: bigint): Promise { - return this.signAccessToken(await this.requireActiveAccount(accountId)); - } - private async requireActiveAccount( accountId: bigint, ): Promise { @@ -75,13 +78,15 @@ export class TokenService { return this.config.getOrThrow('auth'); } + /** credentialVersion = 호출자가 자격을 확인한 시점의 password_updated_at(로그인은 비밀번호 검증 때 읽은 값). 나머지 클레임은 다시 읽는다. */ async issueAuthTokens(args: { accountId: bigint; + credentialVersion: Date | null; req: Request; res: Response; }): Promise<{ accessToken: string }> { const account = await this.requireActiveAccount(args.accountId); - const accessToken = this.signAccessToken(account); + const accessToken = this.signAccessToken(account, args.credentialVersion); const refreshToken = this.generateRefreshToken(); const refreshHash = this.sha256Hex(refreshToken); @@ -95,6 +100,7 @@ export class TokenService { userAgent: tryUserAgent(args.req), ipAddress: tryClientIp(args.req), expiresAt, + credentialVersion: args.credentialVersion, }); AuthCookie.setRefreshCookie(args.res, account.account_type, { @@ -119,8 +125,25 @@ export class TokenService { return !account || account.account_type === role; } - async assertSessionRole(role: AccountRole, accountId: bigint): Promise { - if (!(await this.hasSessionRole(role, accountId))) { + /** + * 회전 전 확인. 역할이 다르면 세션을 건드리지 않고 거절한다. 자격증명 버전이 다르면 발급 뒤 비밀번호가 바뀐 것이다 — + * 변경 트랜잭션의 전 세션 폐기를 비껴간 세션(확인 뒤 커밋된 변경과 겹친 로그인·회전)이라 폐기하고 거절한다. + * 계정이 없으면(탈퇴) 회전의 상태 확인이 거절한다. + */ + private async assertSessionUsable( + role: AccountRole, + session: AuthRefreshSession, + ): Promise { + const account = await this.accounts.findAccountForJwt(session.account_id); + if (!account) return; + if (account.account_type !== role) { + throw new DomainException('INVALID_REFRESH_TOKEN'); + } + if ( + versionMs(session.credential_version) !== + versionMs(account.credential?.password_updated_at) + ) { + await this.refreshSessions.revokeRefreshSession(session.id); throw new DomainException('INVALID_REFRESH_TOKEN'); } } @@ -141,7 +164,7 @@ export class TokenService { await this.refreshSessions.findActiveRefreshSessionByHash(tokenHash); if (!session) throw new DomainException('INVALID_REFRESH_TOKEN'); - await this.assertSessionRole(role, session.account_id); + await this.assertSessionUsable(role, session); const newRefreshToken = this.generateRefreshToken(); const newTokenHash = this.sha256Hex(newRefreshToken); @@ -156,9 +179,14 @@ export class TokenService { userAgent: tryUserAgent(req), ipAddress: tryClientIp(req), newExpiresAt, + credentialVersion: session.credential_version, }); - const accessToken = await this.signAccessTokenFor(session.account_id); + // 버전은 확인한 세션의 것 — 확인 뒤 커밋된 변경이 있으면 이 토큰은 블랙리스트에, 새 세션은 다음 refresh에서 막힌다 + const accessToken = this.signAccessToken( + await this.requireActiveAccount(session.account_id), + session.credential_version, + ); AuthCookie.setRefreshCookie(res, role, { refreshToken: newRefreshToken, @@ -196,3 +224,8 @@ export class TokenService { return this.authConfig().refreshExpiresInDays; } } + +/** 자격증명 버전의 비교·클레임 값. 변경 이력이 없으면(null) 0. */ +function versionMs(version: Date | null | undefined): number { + return version?.getTime() ?? 0; +} diff --git a/src/features/auth/strategies/jwt-bearer.strategy.spec.ts b/src/features/auth/strategies/jwt-bearer.strategy.spec.ts index c83f0a95..57c1981c 100644 --- a/src/features/auth/strategies/jwt-bearer.strategy.spec.ts +++ b/src/features/auth/strategies/jwt-bearer.strategy.spec.ts @@ -1,6 +1,7 @@ import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; +import { DomainException } from '@/common/errors/error-catalog'; import { ClockService } from '@/common/providers/clock.service'; import { AccountRepository } from '@/features/auth/repositories/account.repository'; import { ACCOUNT_REPOSITORY } from '@/features/auth/repositories/account.repository.interface'; @@ -51,6 +52,33 @@ function buildStrategy(deps: { const clock = new ClockService(); +/** 통과면 '통과', 거절이면 도메인 코드 — 표 한 줄을 단언 하나로. */ +function verdict(result: Promise): Promise { + return result.then( + () => '통과', + (e: unknown) => (e instanceof DomainException ? e.code : String(e)), + ); +} + +/** 자격증명 변경 시각(초 안쪽 ms)과 토큰 표 — Redis 경로와 DB 폴백이 같은 판정을 내야 한다. */ +const CHANGED_AT_MS = NOW * 1000 + 500; +const CREDENTIAL_CASES = [ + [ + 'cv가 변경 1ms 전(같은 초)', + { iat: NOW, cv: CHANGED_AT_MS - 1 }, + 'INVALID_ACCESS_TOKEN', + ], + ['cv가 변경 시각', { iat: NOW, cv: CHANGED_AT_MS }, '통과'], + [ + 'cv 0(변경 이력 없이 발급) — 변경 뒤 초', + { iat: NOW + 1, cv: 0 }, + 'INVALID_ACCESS_TOKEN', + ], + ['cv 없는 옛 토큰 — 변경 전 초', { iat: NOW - 1 }, 'INVALID_ACCESS_TOKEN'], + ['cv 없는 옛 토큰 — 변경과 같은 초(1초 창)', { iat: NOW }, '통과'], + ['cv 없는 옛 토큰 — 변경 뒤 초', { iat: NOW + 1 }, '통과'], +] as const; + describe('JwtBearerStrategy (real DB + real Redis)', () => { let strategy: JwtBearerStrategy; let blacklist: TokenBlacklistService; @@ -164,20 +192,17 @@ describe('JwtBearerStrategy (real DB + real Redis)', () => { }); }); - it('자격증명 변경은 cutoff 전에 발급된 토큰만 막는다 — 같은 초·그 뒤에 받은 새 토큰은 통과', async () => { - await blacklist.blockCredentials(BigInt(42), new Date(NOW * 1000 + 999)); + // cv(발급 근거 버전, ms)로 같은 초 안의 변경 전·후 토큰을 가른다. cv 없는 옛 토큰만 iat(초) 비교 + it.each(CREDENTIAL_CASES)( + '자격증명 변경 뒤 %s %j → %s', + async (_, token, expected) => { + await blacklist.blockCredentials(BigInt(42), new Date(CHANGED_AT_MS)); - await expect( - strategy.validate(payload('42', { iat: NOW - 1 })), - ).rejects.toThrowDomain('INVALID_ACCESS_TOKEN'); - // 변경과 같은 초에 발급된 토큰 — iat가 초 단위라 ms 비교로 밀어내면 새 토큰까지 막는다 - await expect( - strategy.validate(payload('42', { iat: NOW })), - ).resolves.toMatchObject({ accountId: '42' }); - await expect( - strategy.validate(payload('42', { iat: NOW + 1 })), - ).resolves.toMatchObject({ accountId: '42' }); - }); + expect(await verdict(strategy.validate(payload('42', token)))).toBe( + expected, + ); + }, + ); it('반증: 정지 → 복구를 거쳐도 자격증명 cutoff는 살아 옛 토큰을 계속 막는다', async () => { await blacklist.blockCredentials(BigInt(42), AT); @@ -185,10 +210,10 @@ describe('JwtBearerStrategy (real DB + real Redis)', () => { await blacklist.clearStatus(BigInt(42), LATER); await expect( - strategy.validate(payload('42', { iat: NOW - 60 })), + strategy.validate(payload('42', { iat: NOW, cv: AT.getTime() - 1 })), ).rejects.toThrowDomain('INVALID_ACCESS_TOKEN'); await expect( - strategy.validate(payload('42', { iat: NOW })), + strategy.validate(payload('42', { iat: NOW, cv: AT.getTime() })), ).resolves.toMatchObject({ accountId: '42' }); }); @@ -281,23 +306,24 @@ describe('JwtBearerStrategy (real DB + real Redis)', () => { ); }); - it('반증: 비밀번호 변경 전에 발급된 토큰은 DB의 password_updated_at으로도 막힌다 — Redis 없이도 자격증명 변경이 새지 않는다', async () => { - const credential = await createAccountCredential(prisma, { - account_type: 'SELLER', - }); - await prisma.accountCredential.update({ - where: { account_id: credential.account_id }, - data: { password_updated_at: new Date(NOW * 1000) }, - }); - const sub = credential.account_id.toString(); + // Redis 경로와 같은 표 — Redis 없이도 자격증명 변경이 새지 않고, 판정도 같다 + it.each(CREDENTIAL_CASES)( + 'DB의 password_updated_at으로 판정한다: %s %j → %s', + async (_, token, expected) => { + const credential = await createAccountCredential(prisma, { + account_type: 'SELLER', + }); + await prisma.accountCredential.update({ + where: { account_id: credential.account_id }, + data: { password_updated_at: new Date(CHANGED_AT_MS) }, + }); + const sub = credential.account_id.toString(); - await expect( - fallbackStrategy.validate(payload(sub, { iat: NOW - 60 })), - ).rejects.toThrowDomain('INVALID_ACCESS_TOKEN'); - await expect( - fallbackStrategy.validate(payload(sub, { iat: NOW })), - ).resolves.toMatchObject({ accountId: sub }); - }); + expect( + await verdict(fallbackStrategy.validate(payload(sub, token))), + ).toBe(expected); + }, + ); // Redis 경로와 같은 입력 표 — 어느 경로를 타든 응답 코드가 같아야 한다 it.each([ diff --git a/src/features/auth/strategies/jwt-bearer.strategy.ts b/src/features/auth/strategies/jwt-bearer.strategy.ts index fad6bc01..6a506dc5 100644 --- a/src/features/auth/strategies/jwt-bearer.strategy.ts +++ b/src/features/auth/strategies/jwt-bearer.strategy.ts @@ -13,7 +13,7 @@ import { AlertService } from '@/global/alerting'; import type { AccessTokenPayload, JwtUser } from '@/global/auth'; import { type BlacklistLookup, - credentialCutoffSec, + issuedBeforeCredentialChange, TokenBlacklistService, } from '@/global/auth/blacklist'; @@ -67,7 +67,7 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { key: 'auth-blacklist-fallback', detail: error instanceof Error ? error.message : String(error), }); - return this.validateAgainstDb(accountId, payload.iat); + return this.validateAgainstDb(accountId, payload); } if (!lookup.ready) { // Redis가 비었다(초기화·flush). worker 재구축이 표식을 다시 세울 때까지 DB가 정본이다. @@ -77,7 +77,7 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { title: 'Redis 블랙리스트 미구축 — 계정 재조회로 폴백', key: 'auth-blacklist-not-ready', }); - return this.validateAgainstDb(accountId, payload.iat); + return this.validateAgainstDb(accountId, payload); } if (lookup.status !== null) { @@ -88,10 +88,10 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { : 'ACCOUNT_NOT_ACTIVE', ); } - // iat와 cutoff 둘 다 초 단위 — 변경 전에 발급된 토큰만 무효 + // 변경 전 버전으로 발급된 토큰만 무효 if ( - lookup.credentialCutoffSec !== null && - payload.iat < lookup.credentialCutoffSec + lookup.credentialCutoffMs !== null && + issuedBeforeCredentialChange(payload, lookup.credentialCutoffMs) ) { throw new DomainException('INVALID_ACCESS_TOKEN'); } @@ -106,7 +106,7 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { /** 폴백 경로 — 블랙리스트 도입 전 판정(존재·ACTIVE·must_change_password)에 자격증명 변경 시각 비교를 더한다. */ private async validateAgainstDb( accountId: bigint, - issuedAtSec: number, + payload: AccessTokenPayload, ): Promise { const account = await this.accounts.findAccountForJwt(accountId); @@ -119,7 +119,10 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { } const changedAt = account.credential?.password_updated_at; - if (changedAt && issuedAtSec < credentialCutoffSec(changedAt)) { + if ( + changedAt && + issuedBeforeCredentialChange(payload, changedAt.getTime()) + ) { throw new DomainException('INVALID_ACCESS_TOKEN'); } diff --git a/src/features/product/product-admin-banner.graphql b/src/features/product/product-admin-banner.graphql index 718d15a4..2fe27d51 100644 --- a/src/features/product/product-admin-banner.graphql +++ b/src/features/product/product-admin-banner.graphql @@ -69,6 +69,11 @@ type AdminBanner { """ linkCategoryId: ID """ + 링크 대상이 지금 노출 가능한지. 구매자 앱과 같은 기준(상품·매장·카테고리가 활성·미삭제, 상품은 소속 매장까지)이며, + false면 구매자 앱이 이 배너를 건너뛰고 다음 배너를 노출한다. linkType이 NONE·URL이면 항상 true. + """ + linkTargetAvailable: Boolean! + """ 노출 시작 일시. null이면 시작 제한 없음. """ startsAt: DateTime diff --git a/src/features/product/repositories/banner-link-target.helper.ts b/src/features/product/repositories/banner-link-target.helper.ts new file mode 100644 index 00000000..0be1d4d0 --- /dev/null +++ b/src/features/product/repositories/banner-link-target.helper.ts @@ -0,0 +1,18 @@ +import type { Prisma } from '@/generated/prisma/client'; +import { visibleWhere } from '@/prisma'; + +/** + * 링크 대상이 노출 가능한 배너. 구매자 배너 선택과 관리자 linkTargetAvailable이 이 조각 하나를 쓴다 — + * 대상이 내려간 배너를 노출하면 클릭이 죽은 화면으로 떨어지므로 구매자 조회는 다음 배너로 넘어간다. + */ +export const bannerLinkTargetVisibleWhere: Prisma.BannerWhereInput = { + OR: [ + { link_type: { in: ['NONE', 'URL'] } }, + { + link_type: 'PRODUCT', + link_product: { ...visibleWhere, store: visibleWhere }, + }, + { link_type: 'STORE', link_store: visibleWhere }, + { link_type: 'CATEGORY', link_category: visibleWhere }, + ], +}; diff --git a/src/features/product/repositories/product-admin.repository.ts b/src/features/product/repositories/product-admin.repository.ts index a2efc8fa..21834e84 100644 --- a/src/features/product/repositories/product-admin.repository.ts +++ b/src/features/product/repositories/product-admin.repository.ts @@ -7,6 +7,7 @@ import { type AuditEntry, type IAuditLogRepository, } from '@/features/audit-log'; +import { bannerLinkTargetVisibleWhere } from '@/features/product/repositories/banner-link-target.helper'; import { type Banner, type BannerPlacement, @@ -185,6 +186,18 @@ export class ProductAdminRepository { return this.prisma.banner.findFirst({ where: { id: bannerId } }); } + /** 구매자 배너 선택과 같은 조각으로 거른다 — 규칙을 TS로 다시 쓰면 둘이 어긋난다. */ + async findLinkTargetVisibleBannerIds( + bannerIds: bigint[], + ): Promise> { + if (bannerIds.length === 0) return new Set(); + const rows = await this.prisma.banner.findMany({ + where: { id: { in: bannerIds }, AND: [bannerLinkTargetVisibleWhere] }, + select: { id: true }, + }); + return new Set(rows.map((row) => row.id)); + } + // 조작과 감사 기록을 한 트랜잭션으로 — 조작만 커밋되고 기록이 빠지는 상태를 막는다. async createBanner( diff --git a/src/features/product/repositories/product.repository.spec.ts b/src/features/product/repositories/product.repository.spec.ts index 89d29aa3..32d00e8f 100644 --- a/src/features/product/repositories/product.repository.spec.ts +++ b/src/features/product/repositories/product.repository.spec.ts @@ -235,18 +235,18 @@ describe('ProductRepository (real DB)', () => { }); }); - describe('findProductById (active만)', () => { - it('is_active: false면 반환 안함', async () => { + describe('findProductByIdIncludingInactive', () => { + it('inactive 상품도 반환', async () => { const store = await createStore(prisma); const inactive = await createProduct(prisma, { store_id: store.id, is_active: false, }); - const result = await repo.findProductById({ + const result = await repo.findProductByIdIncludingInactive({ productId: inactive.id, storeId: store.id, }); - expect(result).toBeNull(); + expect(result?.id).toBe(inactive.id); }); it('store_id 불일치면 null', async () => { @@ -254,7 +254,7 @@ describe('ProductRepository (real DB)', () => { const storeB = await createStore(prisma); const product = await createProduct(prisma, { store_id: storeA.id }); - const result = await repo.findProductById({ + const result = await repo.findProductByIdIncludingInactive({ productId: product.id, storeId: storeB.id, }); @@ -303,7 +303,7 @@ describe('ProductRepository (real DB)', () => { data: { deleted_at: new Date() }, }); - const result = await repo.findProductById({ + const result = await repo.findProductByIdIncludingInactive({ productId: product.id, storeId: store.id, }); @@ -314,21 +314,6 @@ describe('ProductRepository (real DB)', () => { }); }); - describe('findProductByIdIncludingInactive', () => { - it('inactive 상품도 반환', async () => { - const store = await createStore(prisma); - const inactive = await createProduct(prisma, { - store_id: store.id, - is_active: false, - }); - const result = await repo.findProductByIdIncludingInactive({ - productId: inactive.id, - storeId: store.id, - }); - expect(result?.id).toBe(inactive.id); - }); - }); - describe('createProduct / updateProduct / softDeleteProduct', () => { it('createProduct는 store_id를 결합하여 생성', async () => { const store = await createStore(prisma); diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 43a7df09..e3c7f105 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -5,6 +5,7 @@ import { type AuditEntry, type IAuditLogRepository, } from '@/features/audit-log'; +import { bannerLinkTargetVisibleWhere } from '@/features/product/repositories/banner-link-target.helper'; import { type BannerLinkType, type CategoryType, @@ -264,55 +265,6 @@ export class ProductRepository { return Boolean(found); } - async findProductById(args: { productId: bigint; storeId: bigint }) { - return this.prisma.product.findFirst({ - where: { - id: args.productId, - store_id: args.storeId, - is_active: true, - }, - // soft-delete extension은 root만 patch하므로 nested relation에 가드를 명시한다 - include: { - images: { - where: activeWhere, - orderBy: { sort_order: 'asc' }, - }, - product_categories: { - // 링크·대상 카테고리의 soft-delete 가드. is_active는 셀러 화면에서 - // 기존 지정을 계속 보여줘야 하므로 걸지 않는다. - where: { ...activeWhere, category: activeWhere }, - include: { - category: true, - }, - }, - product_tags: { - where: { ...activeWhere, tag: activeWhere }, - include: { - tag: true, - }, - }, - option_groups: { - where: activeWhere, - orderBy: { sort_order: 'asc' }, - include: { - option_items: { - where: activeWhere, - orderBy: { sort_order: 'asc' }, - }, - }, - }, - custom_template: { - include: { - text_tokens: { - where: activeWhere, - orderBy: { sort_order: 'asc' }, - }, - }, - }, - }, - }); - } - async findProductByIdIncludingInactive(args: { productId: bigint; storeId: bigint; @@ -882,6 +834,7 @@ export class ProductRepository { }, include: { text_tokens: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -922,6 +875,7 @@ export class ProductRepository { }, include: { text_tokens: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -1361,28 +1315,7 @@ export class ProductRepository { OR: [{ starts_at: null }, { starts_at: { lte: now } }], AND: [ { OR: [{ ends_at: null }, { ends_at: { gt: now } }] }, - { - // 링크 대상이 내려간(비활성/삭제) 배너를 노출하면 클릭이 죽은 화면으로 - // 떨어지므로 대상 활성까지 확인하고 다음 배너로 넘어간다 - OR: [ - { link_type: { in: ['NONE', 'URL'] } }, - { - link_type: 'PRODUCT', - link_product: { - ...visibleWhere, - store: visibleWhere, - }, - }, - { - link_type: 'STORE', - link_store: visibleWhere, - }, - { - link_type: 'CATEGORY', - link_category: visibleWhere, - }, - ], - }, + bannerLinkTargetVisibleWhere, ], }, select: { diff --git a/src/features/product/services/product-admin-banner-mappers.helper.ts b/src/features/product/services/product-admin-banner-mappers.helper.ts index c2c5be1f..b2cbe37b 100644 --- a/src/features/product/services/product-admin-banner-mappers.helper.ts +++ b/src/features/product/services/product-admin-banner-mappers.helper.ts @@ -2,7 +2,10 @@ import type { AdminBannerOutput } from '@/features/product/types/product-admin-o import type { Banner } from '@/generated/prisma/client'; /** 저장된 링크 값을 그대로 내린다 — 표시·이동 판단은 linkType 기준. */ -export function toAdminBannerOutput(row: Banner): AdminBannerOutput { +export function toAdminBannerOutput( + row: Banner, + linkTargetAvailable: boolean, +): AdminBannerOutput { return { id: row.id.toString(), placement: row.placement, @@ -13,6 +16,7 @@ export function toAdminBannerOutput(row: Banner): AdminBannerOutput { linkProductId: row.link_product_id?.toString() ?? null, linkStoreId: row.link_store_id?.toString() ?? null, linkCategoryId: row.link_category_id?.toString() ?? null, + linkTargetAvailable, startsAt: row.starts_at, endsAt: row.ends_at, sortOrder: row.sort_order, diff --git a/src/features/product/services/product-admin-banner.service.spec.ts b/src/features/product/services/product-admin-banner.service.spec.ts index 222d022f..4b6b4431 100644 --- a/src/features/product/services/product-admin-banner.service.spec.ts +++ b/src/features/product/services/product-admin-banner.service.spec.ts @@ -2,6 +2,7 @@ import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; import { ProductAdminRepository } from '@/features/product/repositories/product-admin.repository'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; import { AdminBannerService } from '@/features/product/services/product-admin-banner.service'; import type { PrismaClient } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; @@ -21,6 +22,7 @@ import { describe('AdminBannerService (real DB)', () => { let service: AdminBannerService; + let buyerRepo: ProductRepository; let prisma: PrismaClient; beforeAll(async () => { @@ -29,11 +31,13 @@ describe('AdminBannerService (real DB)', () => { ...s3TestProviders(), AdminBannerService, ProductAdminRepository, + ProductRepository, AccountAdminRepository, { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, ], }); service = module.get(AdminBannerService); + buyerRepo = module.get(ProductRepository); prisma = p; }); @@ -60,8 +64,11 @@ describe('AdminBannerService (real DB)', () => { placement: 'HOME_MAIN' | 'HOME_SUB' | 'CATEGORY' | 'STORE' | 'SEARCH'; is_active: boolean; link_type: 'NONE' | 'URL' | 'PRODUCT' | 'STORE' | 'CATEGORY'; + link_product_id: bigint | null; link_store_id: bigint | null; + link_category_id: bigint | null; link_url: string | null; + sort_order: number; }> = {}, ) { return prisma.banner.create({ @@ -69,8 +76,11 @@ describe('AdminBannerService (real DB)', () => { placement: overrides.placement ?? 'HOME_MAIN', image_url: 'https://i.example/b.png', link_type: overrides.link_type ?? 'NONE', + link_product_id: overrides.link_product_id ?? null, link_store_id: overrides.link_store_id ?? null, + link_category_id: overrides.link_category_id ?? null, link_url: overrides.link_url ?? null, + sort_order: overrides.sort_order ?? 0, is_active: overrides.is_active ?? true, }, }); @@ -650,6 +660,117 @@ describe('AdminBannerService (real DB)', () => { }); }); + describe('linkTargetAvailable', () => { + // 저장 뒤 대상이 내려간 상황을 직접 만든다 — 저장 시점 검증은 숨겨진 대상을 받지 않는다 + type TargetState = { is_active?: boolean; deleted_at?: Date }; + const inactive: TargetState = { is_active: false }; + const deleted: TargetState = { deleted_at: new Date() }; + const productLink = async ( + own: TargetState = {}, + store: TargetState = {}, + ) => ({ + link_type: 'PRODUCT' as const, + link_product_id: ( + await createProduct(prisma, { + ...own, + store_id: (await createStore(prisma, store)).id, + }) + ).id, + }); + const storeLink = async (own: TargetState = {}) => ({ + link_type: 'STORE' as const, + link_store_id: (await createStore(prisma, own)).id, + }); + const categoryLink = async (own: TargetState = {}) => ({ + link_type: 'CATEGORY' as const, + link_category_id: (await createCategory(prisma, own)).id, + }); + + it.each([ + ['NONE', true, () => Promise.resolve({ link_type: 'NONE' as const })], + [ + 'URL', + true, + () => + Promise.resolve({ + link_type: 'URL' as const, + link_url: 'https://caquick.example/e', + }), + ], + ['노출 중인 상품', true, () => productLink()], + ['비활성 상품', false, () => productLink(inactive)], + ['삭제된 상품', false, () => productLink(deleted)], + ['비활성 매장의 상품', false, () => productLink({}, inactive)], + ['삭제된 매장의 상품', false, () => productLink({}, deleted)], + ['노출 중인 매장', true, () => storeLink()], + ['비활성 매장', false, () => storeLink(inactive)], + ['삭제된 매장', false, () => storeLink(deleted)], + ['노출 중인 카테고리', true, () => categoryLink()], + ['비활성 카테고리', false, () => categoryLink(inactive)], + ['삭제된 카테고리', false, () => categoryLink(deleted)], + ])( + '%s 링크면 linkTargetAvailable=%s이고 구매자 조회의 노출 여부와 같다', + async (_label, expected, makeLink) => { + const banner = await makeBanner(await makeLink()); + + const list = await service.adminBanners(await admin()); + const single = await service.adminBanner(await admin(), banner.id); + const shown = await buyerRepo.findHomeBanner({ now: new Date() }); + + expect(list.items[0].linkTargetAvailable).toBe(expected); + expect(single.linkTargetAvailable).toBe(expected); + expect(shown !== null).toBe(expected); + }, + ); + + it('대상이 숨겨진 상위 배너는 구매자 조회가 건너뛰고 관리자 목록에는 false로 드러난다', async () => { + const hidden = await makeBanner({ + ...(await productLink(inactive)), + sort_order: 0, + }); + const next = await makeBanner({ sort_order: 1 }); + + const shown = await buyerRepo.findHomeBanner({ now: new Date() }); + const { items } = await service.adminBanners(await admin()); + + expect(shown?.id).toBe(next.id); + expect( + Object.fromEntries(items.map((b) => [b.id, b.linkTargetAvailable])), + ).toEqual({ + [hidden.id.toString()]: false, + [next.id.toString()]: true, + }); + }); + + it('생성·수정 응답도 그 시점의 대상 상태로 판정한다', async () => { + const target = await createStore(prisma); + const created = await service.adminCreateBanner(await admin(), { + placement: 'HOME_MAIN', + imageUrl: ownedUploadUrl('BANNER_IMAGE', await admin(), 'x.png'), + linkType: 'STORE', + linkStoreId: target.id.toString(), + }); + expect(created.linkTargetAvailable).toBe(true); + + await prisma.store.update({ + where: { id: target.id }, + data: { is_active: false }, + }); + const stale = await service.adminBanner( + await admin(), + BigInt(created.id), + ); + expect(stale.linkTargetAvailable).toBe(false); + + // 대상이 내려간 배너는 링크를 바꿔야 저장된다 — 바뀐 링크로 다시 판정한다 + const updated = await service.adminUpdateBanner(await admin(), { + bannerId: created.id, + linkType: 'NONE', + }); + expect(updated.linkTargetAvailable).toBe(true); + }); + }); + describe('이미지 URL 소유권', () => { const rejected = [ [ diff --git a/src/features/product/services/product-admin-banner.service.ts b/src/features/product/services/product-admin-banner.service.ts index c3f74869..afbc1389 100644 --- a/src/features/product/services/product-admin-banner.service.ts +++ b/src/features/product/services/product-admin-banner.service.ts @@ -88,7 +88,7 @@ export class AdminBannerService extends AdminBaseService { const paged = sliceIdCursorPage(rows, normalized.limit); return { - items: paged.items.map(toAdminBannerOutput), + items: await this.toOutputs(paged.items), totalCount, hasMore: paged.hasMore, nextCursor: paged.nextCursor, @@ -100,7 +100,7 @@ export class AdminBannerService extends AdminBaseService { bannerId: bigint, ): Promise { await this.requireAdminContext(accountId); - return toAdminBannerOutput(await this.requireBanner(bannerId)); + return this.toOutput(await this.requireBanner(bannerId)); } async adminCreateBanner( @@ -158,7 +158,7 @@ export class AdminBannerService extends AdminBaseService { }), ); - return toAdminBannerOutput(row); + return this.toOutput(row); } async adminUpdateBanner( @@ -217,7 +217,7 @@ export class AdminBannerService extends AdminBaseService { ); if (!row) throw new DomainException('BANNER_NOT_FOUND'); - return toAdminBannerOutput(row); + return this.toOutput(row); } async adminDeleteBanner( @@ -239,6 +239,19 @@ export class AdminBannerService extends AdminBaseService { return true; } + /** 링크 대상 노출 가능 여부는 저장 후 대상이 내려가며 바뀌므로 응답마다 다시 판정한다. */ + private async toOutputs(rows: Banner[]): Promise { + const visible = await this.repo.findLinkTargetVisibleBannerIds( + rows.map((row) => row.id), + ); + return rows.map((row) => toAdminBannerOutput(row, visible.has(row.id))); + } + + private async toOutput(row: Banner): Promise { + const [output] = await this.toOutputs([row]); + return output; + } + private async requireBanner(bannerId: bigint): Promise { const row = await this.repo.findBannerById(bannerId); if (!row) throw new DomainException('BANNER_NOT_FOUND'); diff --git a/src/features/product/services/product-seller-custom-template.service.spec.ts b/src/features/product/services/product-seller-custom-template.service.spec.ts index 0eb77fbc..b8368f90 100644 --- a/src/features/product/services/product-seller-custom-template.service.spec.ts +++ b/src/features/product/services/product-seller-custom-template.service.spec.ts @@ -63,6 +63,22 @@ describe('SellerCustomTemplateService (real DB)', () => { }); } + /** 활성 슬롯 A + soft-delete된 슬롯 B. */ + async function createTokensWithDeleted(templateId: bigint) { + const live = await prisma.productCustomTextToken.create({ + data: { template_id: templateId, token_key: 'A', default_text: 'a' }, + }); + await prisma.productCustomTextToken.create({ + data: { + template_id: templateId, + token_key: 'B', + default_text: 'b', + deleted_at: new Date(), + }, + }); + return live; + } + describe('sellerUpsertProductCustomTemplate', () => { it('없는 productId면 404', async () => { const { accountId } = await setupSellerWithProduct(); @@ -100,6 +116,22 @@ describe('SellerCustomTemplateService (real DB)', () => { }); expect(templates).toHaveLength(1); }); + + it('응답 textTokens에서 soft-delete된 슬롯은 제외한다', async () => { + const { accountId, product } = await setupSellerWithProduct(); + const tpl = await createTemplate(product.id); + const live = await createTokensWithDeleted(tpl.id); + + const result = await service.sellerUpsertProductCustomTemplate( + accountId, + { + productId: product.id.toString(), + baseImageUrl: ownedUploadUrl('PRODUCT_IMAGE', accountId, 'b.png'), + }, + ); + + expect(result.textTokens.map((t) => t.id)).toEqual([live.id.toString()]); + }); }); describe('sellerSetProductCustomTemplateActive', () => { @@ -145,6 +177,19 @@ describe('SellerCustomTemplateService (real DB)', () => { }); expect(auditLogs).toHaveLength(1); }); + + it('응답 textTokens에서 soft-delete된 슬롯은 제외한다', async () => { + const { accountId, product } = await setupSellerWithProduct(); + const tpl = await createTemplate(product.id); + const live = await createTokensWithDeleted(tpl.id); + + const result = await service.sellerSetProductCustomTemplateActive( + accountId, + { templateId: tpl.id.toString(), isActive: false }, + ); + + expect(result.textTokens.map((t) => t.id)).toEqual([live.id.toString()]); + }); }); describe('sellerUpsertProductCustomTextToken', () => { diff --git a/src/features/product/services/product-seller-query.service.spec.ts b/src/features/product/services/product-seller-query.service.spec.ts index a7ebc615..45c0a4fb 100644 --- a/src/features/product/services/product-seller-query.service.spec.ts +++ b/src/features/product/services/product-seller-query.service.spec.ts @@ -175,6 +175,27 @@ describe('SellerProductQueryService (real DB)', () => { expect(result.images).toHaveLength(1); }); + it('soft-delete된 상품이면 404', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const deleted = await createSellerProduct(store.id, { + deleted_at: new Date(), + }); + + await expect( + service.sellerProduct(account.id, deleted.id), + ).rejects.toThrowDomain('PRODUCT_NOT_FOUND'); + }); + + it('숨김(is_active=false) 본인 상품도 상세를 반환한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const hidden = await createSellerProduct(store.id, { is_active: false }); + + const result = await service.sellerProduct(account.id, hidden.id); + + expect(result.id).toBe(hidden.id.toString()); + expect(result.isActive).toBe(false); + }); + it('soft-delete된 이미지·옵션 그룹·옵션 아이템은 상세에서 제외한다', async () => { const { account, store } = await setupSellerWithStore(prisma); const product = await createSellerProduct(store.id); diff --git a/src/features/product/services/product-seller-query.service.ts b/src/features/product/services/product-seller-query.service.ts index 2d76889e..34216c31 100644 --- a/src/features/product/services/product-seller-query.service.ts +++ b/src/features/product/services/product-seller-query.service.ts @@ -70,7 +70,8 @@ export class SellerProductQueryService extends SellerBaseService { productId: bigint, ): Promise { const ctx = await this.requireSellerContext(accountId); - const row = await this.productRepository.findProductById({ + // 목록이 숨김 상품도 보여주므로 상세도 is_active와 무관하게 연다 + const row = await this.productRepository.findProductByIdIncludingInactive({ productId, storeId: ctx.storeId, }); diff --git a/src/features/product/types/product-admin-output.type.ts b/src/features/product/types/product-admin-output.type.ts index f7aa671e..1c2106cb 100644 --- a/src/features/product/types/product-admin-output.type.ts +++ b/src/features/product/types/product-admin-output.type.ts @@ -18,6 +18,7 @@ export interface AdminBannerOutput { linkProductId: string | null; linkStoreId: string | null; linkCategoryId: string | null; + linkTargetAvailable: boolean; startsAt: Date | null; endsAt: Date | null; sortOrder: number; diff --git a/src/features/review/dto/inputs/product-reviews.input.ts b/src/features/review/dto/inputs/product-reviews.input.ts index f9bbe3c3..a8f44265 100644 --- a/src/features/review/dto/inputs/product-reviews.input.ts +++ b/src/features/review/dto/inputs/product-reviews.input.ts @@ -30,7 +30,7 @@ export class ProductReviewsInput { @IsOptional() @IsString() @IsNotEmpty() - cursor?: string; + cursor?: string | null; @IsOptional() @IsInt() diff --git a/src/features/review/dto/inputs/store-reviews.input.ts b/src/features/review/dto/inputs/store-reviews.input.ts index 5ed50380..d1159fc1 100644 --- a/src/features/review/dto/inputs/store-reviews.input.ts +++ b/src/features/review/dto/inputs/store-reviews.input.ts @@ -30,7 +30,7 @@ export class StoreReviewsInput { @IsOptional() @IsString() @IsNotEmpty() - cursor?: string; + cursor?: string | null; @IsOptional() @IsInt() diff --git a/src/features/review/services/review-listing.service.spec.ts b/src/features/review/services/review-listing.service.spec.ts index 0a54cad8..53ce6495 100644 --- a/src/features/review/services/review-listing.service.spec.ts +++ b/src/features/review/services/review-listing.service.spec.ts @@ -106,7 +106,7 @@ describe('ReviewListingService (real DB)', () => { args: { photoOnly?: boolean; sort?: 'LATEST' | 'LIKES'; - cursor?: string; + cursor?: string | null; limit?: number; } = {}, accountId?: bigint, @@ -270,6 +270,18 @@ describe('ReviewListingService (real DB)', () => { ); }); + it.each(['LATEST', 'LIKES'] as const)( + 'cursor null은 첫 페이지로 처리한다 (%s)', + async (sort) => { + const target = await makeTarget(); + const review = await makeReview(target); + + const result = await list(kind, target, { sort, cursor: null }); + + expect(result.items.map((r) => r.id)).toEqual([review.id.toString()]); + }, + ); + it('cursor "0"은 페이지를 리셋하지 않고 빈 결과를 반환한다', async () => { const target = await makeTarget(); await makeReview(target); diff --git a/src/features/review/services/review-listing.service.ts b/src/features/review/services/review-listing.service.ts index e5007f3f..1fb1693c 100644 --- a/src/features/review/services/review-listing.service.ts +++ b/src/features/review/services/review-listing.service.ts @@ -29,7 +29,7 @@ import type { interface ListingArgs { photoOnly?: boolean; sort?: ReviewSort; - cursor?: string; + cursor?: string | null; limit?: number; } @@ -115,7 +115,8 @@ export class ReviewListingService { photoOnly, sort, limit, - cursorRaw: input.cursor, + // 명시적 cursor: null도 첫 페이지 요청이다 + cursorRaw: input.cursor ?? undefined, }), this.repo.countReviews({ scope, photoOnly: false }), this.repo.countReviews({ scope, photoOnly: true }), diff --git a/src/features/system/health.controller.spec.ts b/src/features/system/health.controller.spec.ts index f0075958..e220ce0c 100644 --- a/src/features/system/health.controller.spec.ts +++ b/src/features/system/health.controller.spec.ts @@ -13,6 +13,7 @@ import { ApiResponseInterceptor, RAW_RESPONSE_PATHS, } from '@/global/interceptors/api-response.interceptor'; +import { listenOnLoopback } from '@/test/http-app'; function controllerWith(result: ReadinessResult): HealthController { const service = { ready: () => Promise.resolve(result) } as HealthService; @@ -126,7 +127,7 @@ describe('헬스 HTTP 응답 (real app)', () => { app = module.createNestApplication>(); // main.ts와 같은 배선(같은 상수) app.useGlobalInterceptors(new ApiResponseInterceptor(RAW_RESPONSE_PATHS)); - await app.init(); + await listenOnLoopback(app); }); afterAll(async () => { await app.close(); diff --git a/src/features/system/metrics.controller.spec.ts b/src/features/system/metrics.controller.spec.ts index 51ca98fa..e8340b2c 100644 --- a/src/features/system/metrics.controller.spec.ts +++ b/src/features/system/metrics.controller.spec.ts @@ -9,6 +9,7 @@ import { RAW_RESPONSE_PATHS, } from '@/global/interceptors/api-response.interceptor'; import { MetricsService } from '@/global/metrics'; +import { listenOnLoopback } from '@/test/http-app'; /** 제외 목록 밖 경로가 실제로 봉투에 싸이는지 볼 대조군 */ @Controller('envelope-probe') @@ -31,7 +32,7 @@ describe('MetricsController (real app)', () => { }).compile(); app = module.createNestApplication>(); app.useGlobalInterceptors(new ApiResponseInterceptor(RAW_RESPONSE_PATHS)); - await app.init(); + await listenOnLoopback(app); metrics = module.get(MetricsService); }); afterAll(async () => { diff --git a/src/global/auth/blacklist/index.ts b/src/global/auth/blacklist/index.ts index be554466..2851032f 100644 --- a/src/global/auth/blacklist/index.ts +++ b/src/global/auth/blacklist/index.ts @@ -1,7 +1,7 @@ export { BLACKLIST_READY_TTL_SECONDS, type BlacklistLookup, - credentialCutoffSec, + issuedBeforeCredentialChange, TokenBlacklistService, } from '@/global/auth/blacklist/token-blacklist.service'; export { BlacklistModule } from '@/global/auth/blacklist/blacklist.module'; diff --git a/src/global/auth/blacklist/token-blacklist.service.spec.ts b/src/global/auth/blacklist/token-blacklist.service.spec.ts index 3d2f49b1..801ca05e 100644 --- a/src/global/auth/blacklist/token-blacklist.service.spec.ts +++ b/src/global/auth/blacklist/token-blacklist.service.spec.ts @@ -7,7 +7,7 @@ import { AlertService } from '@/global/alerting'; import { BLACKLIST_READY_KEY, BLACKLIST_READY_TTL_SECONDS, - credentialCutoffSec, + issuedBeforeCredentialChange, STATUS_SCAN_PAGE, TokenBlacklistService, } from '@/global/auth/blacklist/token-blacklist.service'; @@ -75,10 +75,6 @@ describe('TokenBlacklistService (real Redis)', () => { alerts.notify.mockClear(); }); - it('credentialCutoffSec는 초 단위 내림 — iat와 같은 정밀도로 비교하기 위해', () => { - expect(credentialCutoffSec(T1)).toBe(1_790_337_600); - }); - // 쓰기는 전부 "버전(변경 시각)이 더 새로울 때만" — 훅·재구축·조정이 어떤 순서로 겹쳐도 최근 변경이 이긴다 describe('상태 키(정지·탈퇴·복구)', () => { it('blockStatus하면 사유가 조회되고 더 새 버전의 clearStatus로 풀린다', async () => { @@ -89,7 +85,7 @@ describe('TokenBlacklistService (real Redis)', () => { await expect(service.lookup(BigInt(7))).resolves.toEqual({ ready: true, status: 'SUSPENDED', - credentialCutoffSec: null, + credentialCutoffMs: null, }); await expect(service.lookup(BigInt(8))).resolves.toMatchObject({ status: null, @@ -137,7 +133,7 @@ describe('TokenBlacklistService (real Redis)', () => { await expect(service.lookup(BigInt(7))).resolves.toEqual({ ready: true, status: null, - credentialCutoffSec: credentialCutoffSec(T1), + credentialCutoffMs: T1.getTime(), }); }); @@ -163,13 +159,14 @@ describe('TokenBlacklistService (real Redis)', () => { }); describe('자격증명 cutoff', () => { - it('변경 시각을 초 단위 cutoff로 저장한다', async () => { + it('변경 시각을 ms 그대로 cutoff로 저장한다', async () => { await service.blockCredentials(BigInt(7), T1); await expect(service.lookup(BigInt(7))).resolves.toEqual({ ready: true, status: null, - credentialCutoffSec: credentialCutoffSec(T1), + credentialCutoffMs: T1.getTime(), }); + expect(await redis.get('auth:blk:cv:7')).toBe(String(T1.getTime())); }); it('반증: cutoff는 낮아지지 않는다 — 재구축의 옛 스냅샷이 최신 변경을 덮지 않게', async () => { @@ -177,14 +174,53 @@ describe('TokenBlacklistService (real Redis)', () => { await service.blockCredentials(BigInt(7), T1); await expect(service.lookup(BigInt(7))).resolves.toMatchObject({ - credentialCutoffSec: credentialCutoffSec(T2), + credentialCutoffMs: T2.getTime(), }); await service.blockCredentials(BigInt(7), T3); await expect(service.lookup(BigInt(7))).resolves.toMatchObject({ - credentialCutoffSec: credentialCutoffSec(T3), + credentialCutoffMs: T3.getTime(), }); }); + + it('같은 초 안의 더 늦은 변경도 cutoff를 올린다(ms 버전)', async () => { + await service.blockCredentials(BigInt(7), T1); + await service.blockCredentials(BigInt(7), new Date(T1.getTime() + 1)); + + await expect(service.lookup(BigInt(7))).resolves.toMatchObject({ + credentialCutoffMs: T1.getTime() + 1, + }); + }); + + // 배포 직후 Redis에는 옛 코드가 쓴 초 단위 키·표식이 남아 있다 — ms로 잘못 읽으면 모든 토큰이 통과한다 + it('반증: 옛 초 단위 키(auth:blk:cr:)와 옛 표식(auth:blk:ready)은 읽지 않는다 — 재구축 전까지 DB 폴백', async () => { + await redis.flushdb(); + await redis.set('auth:blk:ready', '1'); + await redis.set('auth:blk:cr:7', String(Math.floor(T1.getTime() / 1000))); + + await expect(service.lookup(BigInt(7))).resolves.toEqual({ + ready: false, + status: null, + credentialCutoffMs: null, + }); + }); + }); + + describe('issuedBeforeCredentialChange', () => { + const X = T1.getTime(); // 초 안쪽(.500)의 변경 시각 + const SEC = Math.floor(X / 1000); + + it.each([ + ['cv가 변경 1ms 전(같은 초)', { iat: SEC, cv: X - 1 }, true], + ['cv가 변경 시각', { iat: SEC, cv: X }, false], + ['cv가 변경 뒤', { iat: SEC, cv: X + 1 }, false], + ['cv 0(변경 이력 없이 발급)', { iat: SEC + 1, cv: 0 }, true], + ['cv 없는 옛 토큰 — 변경 전 초', { iat: SEC - 1 }, true], + ['cv 없는 옛 토큰 — 변경과 같은 초(1초 창)', { iat: SEC }, false], + ['cv 없는 옛 토큰 — 변경 뒤 초', { iat: SEC + 1 }, false], + ] as const)('%s %j → 막힘 %s', (_, token, blocked) => { + expect(issuedBeforeCredentialChange(token, X)).toBe(blocked); + }); }); // 표식은 임대다 — 재구축이 주기마다 갱신하고, 스냅샷 뒤 쓰기 실패가 끼어들었으면(세대 변화) 세우지 않는다 @@ -275,7 +311,7 @@ describe('TokenBlacklistService (real Redis)', () => { it('두 키 다 액세스 토큰 수명만큼 산다 — 그 뒤엔 토큰 자체가 만료라 볼 필요가 없다', async () => { await service.blockStatus(BigInt(7), 'DELETED', T1); await service.blockCredentials(BigInt(7), T1); - for (const key of ['auth:blk:st:7', 'auth:blk:cr:7']) { + for (const key of ['auth:blk:st:7', 'auth:blk:cv:7']) { const ttl = await redis.ttl(key); expect(ttl).toBeGreaterThan(TTL - 5); expect(ttl).toBeLessThanOrEqual(TTL); @@ -373,7 +409,7 @@ describe('TokenBlacklistService (real Redis)', () => { await expect(broken.lookup(BigInt(1))).resolves.toEqual({ ready: false, status: null, - credentialCutoffSec: null, + credentialCutoffMs: null, }); expect(mget).not.toHaveBeenCalled(); diff --git a/src/global/auth/blacklist/token-blacklist.service.ts b/src/global/auth/blacklist/token-blacklist.service.ts index 1add188a..2888c972 100644 --- a/src/global/auth/blacklist/token-blacklist.service.ts +++ b/src/global/auth/blacklist/token-blacklist.service.ts @@ -16,26 +16,38 @@ export interface BlacklistLookup { /** 재구축 표식이 있는가. 없으면(Redis 초기화·flush·쓰기 실패 직후) 목록이 불완전하므로 호출자는 DB로 폴백해야 한다. */ ready: boolean; status: StatusBlockReason | null; - /** 이 시각(초, JWT iat와 같은 정밀도) 전에 발급된 토큰은 무효. 없으면 null. */ - credentialCutoffSec: number | null; + /** 마지막 자격증명 변경 시각(ms). 그보다 옛 버전으로 발급된 토큰은 무효(issuedBeforeCredentialChange). 없으면 null. */ + credentialCutoffMs: number | null; } /** 두 축을 키로 나눈다 — 정지 등록·복구가 자격증명 cutoff를 덮거나 지우면 안 된다. */ const STATUS_PREFIX = 'auth:blk:st:'; -const CREDENTIAL_PREFIX = 'auth:blk:cr:'; +/** 값은 ms — 초 단위였던 옛 키(`auth:blk:cr:`)와 이름을 달리해 배포 직후 옛 값을 ms로 읽지 않는다. */ +const CREDENTIAL_PREFIX = 'auth:blk:cv:'; export const STATUS_KEY_PATTERN = `${STATUS_PREFIX}*`; /** SCAN 한 페이지 크기(힌트). spec이 이보다 많은 키로 커서 순회를 고정한다. */ export const STATUS_SCAN_PAGE = 100; -/** 목록이 완전하다는 표식. worker의 재구축이 임대처럼 갱신한다 — worker가 죽거나 Redis가 비면 만료돼 전략이 DB로 폴백한다. */ -export const BLACKLIST_READY_KEY = 'auth:blk:ready'; +/** + * 목록이 완전하다는 표식. worker의 재구축이 임대처럼 갱신한다 — worker가 죽거나 Redis가 비면 만료돼 전략이 DB로 폴백한다. + * 자격증명 키와 함께 이름이 바뀌었다 — 새 키가 재구축으로 채워지기 전에는 옛 표식이 남아 있어도 DB로 폴백한다. + */ +export const BLACKLIST_READY_KEY = 'auth:blk:ready:v2'; /** 표식 임대 시간. 재구축 주기(60초)의 3배 — 재구축을 연속으로 놓쳐야 폴백으로 돌아간다(주기와의 관계는 재구축 spec이 고정). */ export const BLACKLIST_READY_TTL_SECONDS = 180; /** 쓰기 실패 세대. 실패마다 올라가고, 재구축은 스냅샷 시점의 세대가 그대로일 때만 표식을 세운다(스냅샷 뒤 실패한 훅과의 경쟁). */ const DIRTY_KEY = 'auth:blk:dirty'; -/** JWT iat는 초 단위다. 내림 + `iat < cutoff` 비교라 변경과 같은 초에 새로 받은 토큰은 통과한다(같은 초의 옛 토큰도 — 1초 창). */ -export function credentialCutoffSec(changedAt: Date): number { - return Math.floor(changedAt.getTime() / 1000); +/** + * 토큰이 자격증명 변경(cutoffMs) 전 버전으로 발급됐는가. cv(발급 근거가 된 password_updated_at, ms)로 비교해 + * 변경과 같은 초에 발급된 옛 토큰도 가른다. cv가 없는 토큰(도입 전 발급분)은 iat(초)로 — 같은 초의 옛 토큰은 통과한다. + */ +export function issuedBeforeCredentialChange( + token: { iat: number; cv?: number }, + cutoffMs: number, +): boolean { + return token.cv !== undefined + ? token.cv < cutoffMs + : token.iat < Math.floor(cutoffMs / 1000); } /** @@ -92,9 +104,9 @@ export class TokenBlacklistService { return this.writeStatus(accountId, 'ACTIVE', changedAt); } - /** 비밀번호 변경·초기화. cutoff 전에 발급된 토큰만 막는다 — 새 비밀번호로 받은 새 토큰은 통과. false = 쓰기 실패. */ + /** 비밀번호 변경·초기화. 변경 전 버전으로 발급된 토큰만 막는다 — 새 비밀번호로 받은 새 토큰은 통과. false = 쓰기 실패. */ async blockCredentials(accountId: bigint, changedAt: Date): Promise { - const cutoff = credentialCutoffSec(changedAt); + const cutoff = changedAt.getTime(); return this.write('credentials', (ttl) => this.redis.eval( SET_IF_NEWER, @@ -118,7 +130,7 @@ export class TokenBlacklistService { if (await this.clearReady()) { this.degradedUntilMs = 0; } else { - return { ready: false, status: null, credentialCutoffSec: null }; + return { ready: false, status: null, credentialCutoffMs: null }; } } const [ready, status, cutoff] = await this.redis.mget( @@ -129,7 +141,7 @@ export class TokenBlacklistService { return { ready: ready !== null, status: parseBlockedStatus(status), - credentialCutoffSec: cutoff === null ? null : Number(cutoff), + credentialCutoffMs: cutoff === null ? null : Number(cutoff), }; } diff --git a/src/global/auth/types/jwt-payload.type.ts b/src/global/auth/types/jwt-payload.type.ts index dcab2258..363efa43 100644 --- a/src/global/auth/types/jwt-payload.type.ts +++ b/src/global/auth/types/jwt-payload.type.ts @@ -11,6 +11,9 @@ export interface AccessTokenClaims { /** 판매자만. 매장이 아직 없으면 없다. */ storeId?: string; + + /** 발급 근거가 된 자격증명 버전(password_updated_at, epoch ms, 없으면 0). 이 클레임 도입 전에 발급된 토큰에는 없다. */ + cv?: number; } /** 검증 후 전략이 받는 payload — 서명 옵션이 채운 시간 클레임이 더해진다. */ diff --git a/src/global/metrics/http-metrics.middleware.spec.ts b/src/global/metrics/http-metrics.middleware.spec.ts index 4b1acb39..37d4df68 100644 --- a/src/global/metrics/http-metrics.middleware.spec.ts +++ b/src/global/metrics/http-metrics.middleware.spec.ts @@ -25,6 +25,7 @@ import { UNMATCHED_ROUTE, } from '@/global/metrics/http-metrics.middleware'; import { MetricsService } from '@/global/metrics/metrics.service'; +import { listenOnLoopback } from '@/test/http-app'; @Controller('items') class ItemsController { @@ -87,7 +88,7 @@ describe('HttpMetricsMiddleware (실제 Express 스택)', () => { imports: [TestAppModule], }).compile(); app = module.createNestApplication>(); - await app.init(); + await listenOnLoopback(app); metrics = app.get(MetricsService); }); afterAll(() => app.close()); diff --git a/src/test/admin-query-null-inputs.spec.ts b/src/test/admin-query-null-inputs.spec.ts index 2611de58..4a48a8e9 100644 --- a/src/test/admin-query-null-inputs.spec.ts +++ b/src/test/admin-query-null-inputs.spec.ts @@ -36,6 +36,7 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { getTestRedisUrl } from '@/test/db/redis-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount } from '@/test/factories'; +import { listenOnLoopback } from '@/test/http-app'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; // 관리자 FE는 '전체' 필터를 필드 생략이 아니라 null로 보낸다. nullable 입력의 null이 Prisma where까지 내려가면 @@ -218,7 +219,7 @@ describe('관리자 Query nullable 입력 null 전수 (real DB)', () => { new GraphQLExceptionFilter(logger, app.get(MetricsService)), ), ); - await app.init(); + await listenOnLoopback(app); }); afterAll(async () => { diff --git a/src/test/http-app.spec.ts b/src/test/http-app.spec.ts new file mode 100644 index 00000000..4a691b32 --- /dev/null +++ b/src/test/http-app.spec.ts @@ -0,0 +1,101 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { createServer, Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { join } from 'node:path'; + +import { Controller, Get, type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import type { App } from 'supertest/types'; + +import { listenOnLoopback } from '@/test/http-app'; + +@Controller('who') +class WhoController { + @Get() + who(): string { + return 'app'; + } +} + +/** 열린 채 남은 서버는 jest를 끝나지 못하게 하므로, 단언이 실패해도 afterEach가 닫도록 열리는 즉시 모은다 */ +const intruders: Server[] = []; + +/** 다른 프로세스가 같은 포트에 서버를 여는 상황. 실패하면 오류 코드를 돌려준다 */ +function bindIntruder(port: number, host?: string): Promise { + const server = createServer((_req, res) => res.end('intruder')); + return new Promise((resolve) => { + server.once('error', (e: NodeJS.ErrnoException) => resolve(e.code ?? '')); + server.listen(port, host, () => { + intruders.push(server); + resolve(server); + }); + }); +} + +const portOf = (app: INestApplication) => + ((app.getHttpServer() as Server).address() as AddressInfo).port; + +// 리눅스(CI)는 와일드카드가 잡은 포트의 127.0.0.1 바인드 자체를 거부한다 — 가로채기는 macOS(맥미니)에서만 난다 +const onMac = process.platform === 'darwin' ? it : it.skip; + +describe('listenOnLoopback', () => { + let app: INestApplication; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [WhoController], + }).compile(); + app = module.createNestApplication>(); + }); + afterEach(async () => { + for (const server of intruders.splice(0)) { + server.closeAllConnections(); + server.close(); + } + await app.close(); + }); + + onMac( + '반증: 와일드카드로 연 앱은 같은 포트를 127.0.0.1로 잡은 서버에 요청을 빼앗긴다', + async () => { + await app.listen(0); // supertest가 닫힌 서버를 여는 방식 + const intruder = await bindIntruder(portOf(app), '127.0.0.1'); + expect(intruder).toBeInstanceOf(Server); + + const res = await request(app.getHttpServer()).get('/who'); + expect(res.text).toBe('intruder'); + }, + ); + + it('127.0.0.1로 열면 같은 포트를 다른 서버가 가로채지 못한다', async () => { + await listenOnLoopback(app); + const port = portOf(app); + + expect(await bindIntruder(port, '127.0.0.1')).toBe('EADDRINUSE'); + // 와일드카드 바인드는 macOS에서 성공하지만 127.0.0.1 요청은 더 구체적인 앱 쪽으로 간다 + await bindIntruder(port); + + const res = await request(app.getHttpServer()).get('/who'); + expect(res.text).toBe('app'); + }); + + it('supertest로 앱을 부르는 spec은 모두 listenOnLoopback으로 연다', () => { + const root = join(__dirname, '..', '..'); + const specs = ['src', 'test'] + .flatMap((dir) => + readdirSync(join(root, dir), { recursive: true, encoding: 'utf8' }) + .filter((f) => /\.(spec|e2e-spec)\.ts$/.test(f)) + .map((f) => join(dir, f)), + ) + .map((path) => ({ path, src: readFileSync(join(root, path), 'utf8') })) + .filter(({ src }) => src.includes('getHttpServer()')); + + expect(specs.length).toBeGreaterThan(1); + expect( + specs + .filter(({ src }) => !src.includes('listenOnLoopback(')) + .map(({ path }) => path), + ).toEqual([]); + }); +}); diff --git a/src/test/http-app.ts b/src/test/http-app.ts new file mode 100644 index 00000000..60b3de33 --- /dev/null +++ b/src/test/http-app.ts @@ -0,0 +1,10 @@ +import type { INestApplication } from '@nestjs/common'; + +/** + * supertest에 넘길 앱은 127.0.0.1에 먼저 연다. supertest는 닫힌 서버를 와일드카드(::)로 열고 127.0.0.1로 요청하는데, + * macOS는 다른 프로세스가 같은 포트를 127.0.0.1로 따로 바인드하게 두고 요청을 그쪽으로 보낸다(맥미니의 에디터 프로세스가 + * 401을 돌려준 간헐 실패). 127.0.0.1에 열어 두면 같은 주소·포트는 다른 프로세스가 잡지 못한다. + */ +export async function listenOnLoopback(app: INestApplication): Promise { + await app.listen(0, '127.0.0.1'); +} diff --git a/src/test/redis.ts b/src/test/redis.ts index 093025a9..94997f5e 100644 --- a/src/test/redis.ts +++ b/src/test/redis.ts @@ -18,7 +18,7 @@ export const NOOP_BLACKLIST_PROVIDER: Provider = { blockCredentials: () => Promise.resolve(true), blockedStatusAccountIds: () => Promise.resolve([]), lookup: () => - Promise.resolve({ ready: true, status: null, credentialCutoffSec: null }), + Promise.resolve({ ready: true, status: null, credentialCutoffMs: null }), generation: () => Promise.resolve('0'), markReady: () => Promise.resolve(true), evictionRisk: () => Promise.resolve(null), diff --git a/src/test/role-routes.spec.ts b/src/test/role-routes.spec.ts index e5dff1bb..9c7b250e 100644 --- a/src/test/role-routes.spec.ts +++ b/src/test/role-routes.spec.ts @@ -8,6 +8,7 @@ import type { App } from 'supertest/types'; import { AppModule } from '@/app.module'; import { PUB_SUB } from '@/global/pubsub'; import { PrismaService } from '@/prisma'; +import { listenOnLoopback } from '@/test/http-app'; // 역할별 리스너 게이트: 컨트롤러가 어느 모듈에 있든 worker에서는 /health·/metrics 밖이 전부 404여야 한다. // module-wiring.spec은 compile만 보므로 HTTP 노출은 실제 앱을 띄워 본다(DB·Redis는 대역, 디스패처·크론은 env로 끔). @@ -63,7 +64,7 @@ describe('역할별 HTTP 노출 (real app)', () => { .useValue(Object.assign(new PubSub(), { close: () => Promise.resolve() })) .compile(); const app = module.createNestApplication>(); - await app.init(); + await listenOnLoopback(app); return app; } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 50bf3c3a..545ed193 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -10,6 +10,7 @@ import { ACCOUNT_REPOSITORY } from './../src/features/auth/repositories/account. import { PrismaService } from './../src/prisma'; import { AccountType } from '@/generated/prisma/client'; +import { listenOnLoopback } from '@/test/http-app'; describe('AppController (e2e)', () => { let app: INestApplication; @@ -63,7 +64,7 @@ describe('AppController (e2e)', () => { .compile(); app = moduleFixture.createNestApplication(); - await app.init(); + await listenOnLoopback(app); jwt = moduleFixture.get(JwtService); }); diff --git a/yarn.lock b/yarn.lock index 0254ccc3..39da4d0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5722,14 +5722,14 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:2.0.6, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 languageName: node linkType: hard -"@types/istanbul-lib-report@npm:*": +"@types/istanbul-lib-report@npm:*, @types/istanbul-lib-report@npm:3.0.3": version: 3.0.3 resolution: "@types/istanbul-lib-report@npm:3.0.3" dependencies: @@ -5738,7 +5738,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.4": +"@types/istanbul-reports@npm:3.0.4, @types/istanbul-reports@npm:^3.0.4": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -8060,6 +8060,9 @@ __metadata: "@types/amqplib": "npm:^0.10" "@types/cookie-parser": "npm:^1.4.10" "@types/express": "npm:^5.0.6" + "@types/istanbul-lib-coverage": "npm:2.0.6" + "@types/istanbul-lib-report": "npm:3.0.3" + "@types/istanbul-reports": "npm:3.0.4" "@types/jest": "npm:^30" "@types/node": "npm:^25.8.0" "@types/passport": "npm:^1" @@ -8087,6 +8090,9 @@ __metadata: graphql-ws: "npm:^6.2.1" husky: "npm:^9.1.7" ioredis: "npm:^5.3.2" + istanbul-lib-coverage: "npm:3.2.2" + istanbul-lib-report: "npm:3.0.1" + istanbul-reports: "npm:3.2.0" jest: "npm:^30" knip: "npm:^6.32.2" lint-staged: "npm:^17.3.0" @@ -12395,7 +12401,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": +"istanbul-lib-coverage@npm:3.2.2, istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b @@ -12415,7 +12421,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-report@npm:^3.0.0": +"istanbul-lib-report@npm:3.0.1, istanbul-lib-report@npm:^3.0.0": version: 3.0.1 resolution: "istanbul-lib-report@npm:3.0.1" dependencies: @@ -12437,7 +12443,7 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.1.3": +"istanbul-reports@npm:3.2.0, istanbul-reports@npm:^3.1.3": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" dependencies: