diff --git a/README.en.md b/README.en.md index 6c6af120..d6a7427b 100644 --- a/README.en.md +++ b/README.en.md @@ -284,7 +284,7 @@ corepack enable && yarn install # postinstall generates the Prisma clie docker compose up -d # MySQL 3306 · Redis 6379 · RabbitMQ 5672 touch .env # fill it in using the "Environment Variables" table below yarn prisma:migrate:dev # apply migrations -yarn prisma:seed # (optional) seed data +yarn prisma:seed # (optional) seed data. Test accounts admin (admin) and seed-seller-a/b (sellers), password caquick1234! yarn start:dev # api role, http://localhost:4000/graphql APP_ROLE=worker PORT=4001 yarn start:dev # (optional) run a worker too if you want to see events consumed ``` @@ -311,7 +311,6 @@ Operational endpoints | **Docs · Metrics** | `DOCS_ACCESS_TOKEN` (access token for `/gql-docs` and `/rest-docs`, required in production), `METRICS_ACCESS_TOKEN` (Bearer token for `/metrics`, required in production) | | **Alerts (optional)** | `DISCORD_ALERT_WEBHOOK_URL` (log-only when unset), `ALERT_DEDUPE_WINDOW_MS` (default 5 minutes), `BOOT_ALERT_STATE_DIR` (suppression files for boot-failure alerts, default `~/.caquick/boot-alert`) | | **Outbox (optional)** | `OUTBOX_DISPATCH_ENABLED` (only `false` has an effect; enabling is decided by the worker role), `OUTBOX_POLL_INTERVAL_MS`, `OUTBOX_BATCH_SIZE`, `OUTBOX_MAX_ATTEMPTS`, `OUTBOX_PARTITION_CONCURRENCY` | -| **Seed (optional)** | `ADMIN_SEED_USERNAME`, `ADMIN_SEED_PASSWORD`, `SELLER_SEED_PASSWORD` | ### Common Scripts diff --git a/README.md b/README.md index 7b9c315c..526a828c 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ corepack enable && yarn install # postinstall이 Prisma 클라이언트 docker compose up -d # MySQL 3306 · Redis 6379 · RabbitMQ 5672 touch .env # 아래 "필요 환경 변수" 표를 참고해 채운다 yarn prisma:migrate:dev # 마이그레이션 적용 -yarn prisma:seed # (선택) 시드 데이터 +yarn prisma:seed # (선택) 시드 데이터. 테스트 계정 admin(관리자)·seed-seller-a·b(판매자), 비밀번호 caquick1234! yarn start:dev # api 역할, http://localhost:4000/graphql APP_ROLE=worker PORT=4001 yarn start:dev # (선택) 이벤트 소비까지 보려면 worker를 함께 띄운다 ``` @@ -312,7 +312,6 @@ APP_ROLE=worker PORT=4001 yarn start:dev # (선택) 이벤트 소비까지 보 | **경보 (선택)** | `DISCORD_ALERT_WEBHOOK_URL`(없으면 로그로만 남깁니다), `ALERT_DEDUPE_WINDOW_MS`(기본 5분), `BOOT_ALERT_STATE_DIR`(부팅 실패 경보의 억제 파일 위치, 기본 `~/.caquick/boot-alert`) | | **Outbox (선택)** | `OUTBOX_DISPATCH_ENABLED`(`false`로 끄는 용도만 있으며 켜는 것은 worker 역할이 결정), `OUTBOX_POLL_INTERVAL_MS`, `OUTBOX_BATCH_SIZE`, `OUTBOX_MAX_ATTEMPTS`, `OUTBOX_PARTITION_CONCURRENCY` | | **푸시 (선택)** | `EXPO_PUSH_ENABLED`(`true`일 때만 worker가 판매자 앱 푸시를 보내고, 기본 `false`면 소비자가 전송 없이 ack합니다), `EXPO_PUSH_ACCESS_TOKEN`(Expo 액세스 토큰, 없으면 인증 헤더 없이 보냅니다), `EXPO_PUSH_TIMEOUT_MS`(기본 5000) | -| **시드 (선택)** | `ADMIN_SEED_USERNAME`, `ADMIN_SEED_PASSWORD`, `SELLER_SEED_PASSWORD` | ### 자주 쓰는 스크립트 diff --git a/prisma/seed/admins.ts b/prisma/seed/admins.ts index b382da14..b4b0f3de 100644 --- a/prisma/seed/admins.ts +++ b/prisma/seed/admins.ts @@ -1,26 +1,23 @@ /** - * 관리자 시드. env ADMIN_SEED_USERNAME / ADMIN_SEED_PASSWORD가 둘 다 있을 때만 1개 만든다 — - * 비밀번호를 레포에 두지 않기 위해서다. 이메일은 SEED_ADMIN_EMAIL_PREFIX로 시작해 - * resetSeedScope가 자기 영역만 정리한다. + * 관리자 시드. 로컬 테스트용 관리자 1개(SEED_ADMIN_USERNAME / SEED_PASSWORD)를 만든다. + * 이메일은 SEED_ADMIN_EMAIL_PREFIX로 시작해 resetSeedScope가 자기 영역만 정리한다. */ import argon2 from 'argon2'; -import { assertSeedCredential } from './credential-policy'; +import { + SEED_ADMIN_USERNAME, + SEED_PASSWORD, + assertSeedCredential, +} from './credential-policy'; import { SEED_ADMIN_EMAIL_PREFIX } from './idempotent'; import type { PrismaClient } from '@/generated/prisma/client'; export async function seedAdmins(prisma: PrismaClient): Promise { - const username = process.env.ADMIN_SEED_USERNAME?.trim(); - const password = process.env.ADMIN_SEED_PASSWORD; - if (!username || !password) { - console.log( - '[seed] ADMIN_SEED_USERNAME/ADMIN_SEED_PASSWORD 미설정 — 관리자 시드 생략', - ); - return; - } - - assertSeedCredential({ username, password }); + assertSeedCredential({ + username: SEED_ADMIN_USERNAME, + password: SEED_PASSWORD, + }); const admin = await prisma.account.create({ data: { @@ -33,12 +30,15 @@ export async function seedAdmins(prisma: PrismaClient): Promise { await prisma.accountCredential.create({ data: { account_id: admin.id, - username, - password_hash: await argon2.hash(password, { type: argon2.argon2id }), - // 시드 비밀번호는 운영자가 직접 고른 값이라 변경 강제하지 않는다. + username: SEED_ADMIN_USERNAME, + password_hash: await argon2.hash(SEED_PASSWORD, { + type: argon2.argon2id, + }), + // 로컬 테스트 계정이라 변경을 강제하지 않는다. must_change_password: false, }, }); - // username은 env에서 온 값이라 로그에 남기지 않는다(CodeQL: clear-text logging) - console.log(`[seed] 관리자 accountId=${admin.id.toString()}`); + console.log( + `[seed] 관리자 accountId=${admin.id.toString()} (${SEED_ADMIN_USERNAME})`, + ); } diff --git a/prisma/seed/credential-policy.ts b/prisma/seed/credential-policy.ts index 1597bd6b..dbb29281 100644 --- a/prisma/seed/credential-policy.ts +++ b/prisma/seed/credential-policy.ts @@ -9,6 +9,13 @@ import { USERNAME_PATTERN, } from '@/features/auth/constants/auth-admin.constants'; +/** + * 로컬 테스트 계정(관리자 admin, 판매자 seed-seller-a·b)의 공통 비밀번호. + * 시드는 NODE_ENV=production에서 막히고 운영 DB는 외부에 열려 있지 않아, 공개 레포에 있어도 운영 계정이 되지 않는다. + */ +export const SEED_PASSWORD = 'caquick1234!'; +export const SEED_ADMIN_USERNAME = 'admin'; + export function assertSeedCredential(args: { username: string; password: string; @@ -19,7 +26,7 @@ export function assertSeedCredential(args: { username.length > MAX_USERNAME_LENGTH || !USERNAME_PATTERN.test(username) ) { - // 값은 메시지에 싣지 않는다 — env에서 온 자격증명이 오류 로그로 새는 경로(CodeQL clear-text-logging) + // 값은 메시지에 싣지 않는다(CodeQL clear-text-logging) throw new Error( `시드 username 정책 위반: ${MIN_USERNAME_LENGTH}~${MAX_USERNAME_LENGTH}자, 영문 대소문자·숫자·._- 만 허용`, ); diff --git a/prisma/seed/stores.ts b/prisma/seed/stores.ts index c3865479..f4bb7268 100644 --- a/prisma/seed/stores.ts +++ b/prisma/seed/stores.ts @@ -11,15 +11,14 @@ import argon2 from 'argon2'; import type { SeededCategories } from './categories'; -import { assertSeedCredential } from './credential-policy'; +import { SEED_PASSWORD, assertSeedCredential } from './credential-policy'; import { SEED_STORE_NAME_PREFIX } from './idempotent'; import { formatAccountLabel } from '@/common/utils/account-label'; import type { PrismaClient, Product, Store } from '@/generated/prisma/client'; /** - * 시드 판매자 로그인용 자격증명. SELLER_SEED_PASSWORD가 없으면 만들지 않는다 - * (dev 토큰 발급 경로 /auth/dev/issue-token 로도 충분하다). + * 시드 판매자 로그인용 자격증명(비밀번호 SEED_PASSWORD). * 매장 생성 뒤에 호출한다 — resetSeedScope가 [SEED] 매장을 통해 판매자를 찾으므로, * 매장 없이 자격증명만 남으면 고정 username이 다음 시드를 막는다. */ @@ -28,26 +27,18 @@ async function seedSellerCredential( accountId: bigint, username: string, ): Promise { - const password = process.env.SELLER_SEED_PASSWORD; - if (!password) return; - assertSeedCredential({ username, password }); + assertSeedCredential({ username, password: SEED_PASSWORD }); await prisma.accountCredential.create({ data: { account_id: accountId, username, - password_hash: await argon2.hash(password, { type: argon2.argon2id }), + password_hash: await argon2.hash(SEED_PASSWORD, { + type: argon2.argon2id, + }), }, }); } -/** 판매자 생성 경로와 같은 라벨. 자격증명은 비밀번호가 있을 때만 만들어지므로 아이디도 그때만 붙는다. */ -function seedSellerLabel(name: string, username: string): string | null { - return formatAccountLabel( - name, - process.env.SELLER_SEED_PASSWORD ? username : null, - ); -} - export interface SeededStores { stores: Store[]; products: Product[]; @@ -80,7 +71,7 @@ export async function seedStores( const storeA = await prisma.store.create({ data: { seller_account_id: sellerA.id, - seller_label_snapshot: seedSellerLabel(sellerA.name!, 'seed-seller-a'), + seller_label_snapshot: formatAccountLabel(sellerA.name, 'seed-seller-a'), store_name: `${SEED_STORE_NAME_PREFIX}케이크샵 A`, store_phone: '02-1111-2222', address_full: '서울특별시 강남구 테헤란로 1길 10', @@ -138,7 +129,7 @@ export async function seedStores( const storeB = await prisma.store.create({ data: { seller_account_id: sellerB.id, - seller_label_snapshot: seedSellerLabel(sellerB.name!, 'seed-seller-b'), + seller_label_snapshot: formatAccountLabel(sellerB.name, 'seed-seller-b'), store_name: `${SEED_STORE_NAME_PREFIX}도넛샵 B`, store_phone: '02-3333-4444', address_full: '서울특별시 마포구 와우산로 5', diff --git a/src/test/seed-credential-policy.spec.ts b/src/test/seed-credential-policy.spec.ts index e6428f12..9f4b4382 100644 --- a/src/test/seed-credential-policy.spec.ts +++ b/src/test/seed-credential-policy.spec.ts @@ -3,9 +3,14 @@ import 'reflect-metadata'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { assertSeedCredential } from '../../prisma/seed/credential-policy'; +import { + SEED_ADMIN_USERNAME, + SEED_PASSWORD, + assertSeedCredential, +} from '../../prisma/seed/credential-policy'; import { AdminCreateAdminInput } from '@/features/auth/dto/inputs/admin-create-admin.input'; +import { CredentialLoginInput } from '@/features/auth/dto/inputs/credential-login.input'; const PASSWORD = 'Strong!Pass1'; @@ -58,4 +63,18 @@ describe('시드 자격증명 username 정책', () => { assertSeedCredential({ username: 'ops.admin', password: 'weak' }), ).toThrow(/^시드 password 정책 위반/); }); + + it.each([SEED_ADMIN_USERNAME, 'seed-seller-a', 'seed-seller-b'])( + '기본 시드 계정 %s는 시드 정책과 로그인 DTO를 통과한다', + async (username) => { + expect(() => + assertSeedCredential({ username, password: SEED_PASSWORD }), + ).not.toThrow(); + const input = plainToInstance(CredentialLoginInput, { + username, + password: SEED_PASSWORD, + }); + expect(await validate(input)).toHaveLength(0); + }, + ); }); diff --git a/src/test/seed-reset-scope.spec.ts b/src/test/seed-reset-scope.spec.ts index fa48cf97..3f854e03 100644 --- a/src/test/seed-reset-scope.spec.ts +++ b/src/test/seed-reset-scope.spec.ts @@ -1,4 +1,11 @@ +import argon2 from 'argon2'; + +import { seedAdmins } from '../../prisma/seed/admins'; import { seedCategories } from '../../prisma/seed/categories'; +import { + SEED_ADMIN_USERNAME, + SEED_PASSWORD, +} from '../../prisma/seed/credential-policy'; import { SEED_STORE_NAME_PREFIX, resetSeedScope, @@ -56,6 +63,27 @@ describe('resetSeedScope (real DB)', () => { }); } + it('기본 테스트 계정(관리자·판매자)을 만들고 재시드해도 아이디가 겹치지 않는다', async () => { + const usernames = [SEED_ADMIN_USERNAME, 'seed-seller-a', 'seed-seller-b']; + await seedThroughReviews(); + await seedAdmins(prisma); + + await resetSeedScope(prisma); + await seedThroughReviews(); + await seedAdmins(prisma); + + const credentials = await prisma.accountCredential.findMany({ + where: { username: { in: usernames } }, + }); + expect(credentials.map((c) => c.username).sort()).toEqual( + [...usernames].sort(), + ); + for (const c of credentials) { + expect(await argon2.verify(c.password_hash, SEED_PASSWORD)).toBe(true); + expect(c.must_change_password).toBe(false); + } + }); + /** 시드 밖 계정의 주문 1건(품목 1개)을 만든다. productId를 안 주면 시드 밖 매장 상품으로 */ async function outsiderOrder(productId?: bigint) { const outsider = await createAccount(prisma, { account_type: 'USER' });