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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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

Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 함께 띄운다
```
Expand Down Expand Up @@ -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` |

### 자주 쓰는 스크립트

Expand Down
38 changes: 19 additions & 19 deletions prisma/seed/admins.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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: {
Expand All @@ -33,12 +30,15 @@ export async function seedAdmins(prisma: PrismaClient): Promise<void> {
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})`,
);
}
9 changes: 8 additions & 1 deletion prisma/seed/credential-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}자, 영문 대소문자·숫자·._- 만 허용`,
);
Expand Down
25 changes: 8 additions & 17 deletions prisma/seed/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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이 다음 시드를 막는다.
*/
Expand All @@ -28,26 +27,18 @@ async function seedSellerCredential(
accountId: bigint,
username: string,
): Promise<void> {
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[];
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
21 changes: 20 additions & 1 deletion src/test/seed-credential-policy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
},
);
});
28 changes: 28 additions & 0 deletions src/test/seed-reset-scope.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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' });
Expand Down
Loading