diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..6224fb2a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.github +coverage +temp +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.env +**/.env diff --git a/.env.sample b/.env.sample index 81f61006..8f41e0b9 100644 --- a/.env.sample +++ b/.env.sample @@ -18,6 +18,10 @@ LOG_LEVEL=debug # 베이스 URL (images) BASE_URL=http://localhost:3000 +# Socket.IO CORS 허용 도메인 목록 (쉼표로 구분) +# 예: http://localhost:5173,https://your-frontend.com +SOCKET_CORS_ORIGINS= + ### Session ### # 세션 스토어 전용 (DB명 반드시 포함, ?schema 제거) SESSION_STORE_URL="postgresql://user:password@localhost:5432/<디비제목>" @@ -54,4 +58,4 @@ GOOGLE_CLIENT_SECRET= # Google Cloud Console → Authorized Redirect URIs에 등록 필수 # # 2) JWT Secret은 실제 서비스에서는 더 긴 랜덤 문자열 사용 -# 예: openssl rand -base64 32 \ No newline at end of file +# 예: openssl rand -base64 32 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 00000000..5dbdffcc --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,47 @@ +name: CD + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_PRIVATE_KEY }} + port: 22 + command_timeout: 30m + script: | + set -e + cd /home/ec2-user/sprint-5/6-sprint-mission-copy + export GIT_TERMINAL_PROMPT=0 + + # Prefer GitHub secret, fallback to EC2 .env. + if [ -n "${{ secrets.DATABASE_URL }}" ]; then + export DATABASE_URL='${{ secrets.DATABASE_URL }}' + fi + + if [ -z "${DATABASE_URL:-}" ] && [ -f .env ]; then + set -a + . ./.env + set +a + fi + + if [ -z "${DATABASE_URL:-}" ]; then + echo "DATABASE_URL is empty. Set GitHub secret DATABASE_URL or configure EC2 .env." + exit 1 + fi + + git pull --ff-only origin main + npm ci + npx prisma generate + npx prisma migrate deploy + npm run build + pm2 reload infra/ec2/ecosystem.config.cjs --update-env || pm2 start infra/ec2/ecosystem.config.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c733b9a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + ports: + - 5432:5432 + env: + POSTGRES_USER: ci_user + POSTGRES_PASSWORD: ci_password + POSTGRES_DB: ci_test_db + options: >- + --health-cmd "pg_isready -U ci_user -d ci_test_db" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + POSTGRES_USER: ci_user + POSTGRES_PASSWORD: ci_password + POSTGRES_DB: ci_test_db + DATABASE_URL: postgresql://ci_user:ci_password@localhost:5432/ci_test_db + JWT_ACCESS_SECRET: test-access-secret + JWT_REFRESH_SECRET: test-refresh-secret + DEBUG_MODE: "false" + LOG_LEVEL: error + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma Client + run: npx prisma generate + + - name: Apply migrations + run: npx prisma migrate deploy + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f9c72713 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY prisma ./prisma +RUN npx prisma generate + +COPY tsconfig.json ./ +COPY src ./src +COPY public ./public + +RUN npm run build + +RUN mkdir -p /app/public/uploads + +EXPOSE 3000 + +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/app.js"] diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..fda4883f --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,44 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: sprint11-app + depends_on: + postgres: + condition: service_healthy + environment: + PORT: 3000 + NODE_ENV: production + DATABASE_URL: postgresql://sprint11:sprint11@postgres:5432/sprint11 + JWT_ACCESS_SECRET: dev-access-secret + JWT_REFRESH_SECRET: dev-refresh-secret + DEBUG_MODE: "false" + LOG_LEVEL: info + ports: + - "3000:3000" + volumes: + - uploads_data:/app/public/uploads + restart: unless-stopped + + postgres: + image: postgres:16-alpine + container_name: sprint11-postgres + environment: + POSTGRES_USER: sprint11 + POSTGRES_PASSWORD: sprint11 + POSTGRES_DB: sprint11 + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres_data: + uploads_data: diff --git a/infra/ec2/ecosystem.config.cjs b/infra/ec2/ecosystem.config.cjs new file mode 100644 index 00000000..b92bd02c --- /dev/null +++ b/infra/ec2/ecosystem.config.cjs @@ -0,0 +1,11 @@ +module.exports = { + apps: [ + { + script: 'npm', + args: 'start', + env: { + NODE_ENV: 'production', + }, + }, + ], +}; diff --git a/infra/ec2/nginx.conf b/infra/ec2/nginx.conf new file mode 100644 index 00000000..c27914a9 --- /dev/null +++ b/infra/ec2/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name _; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/infra/ec2/secure-group-inbound.png b/infra/ec2/secure-group-inbound.png new file mode 100644 index 00000000..8633de70 Binary files /dev/null and b/infra/ec2/secure-group-inbound.png differ diff --git a/infra/ec2/secure-group-outbound.png b/infra/ec2/secure-group-outbound.png new file mode 100644 index 00000000..8b228bb0 Binary files /dev/null and b/infra/ec2/secure-group-outbound.png differ diff --git a/infra/ec2/start.sh b/infra/ec2/start.sh new file mode 100644 index 00000000..9966ef24 --- /dev/null +++ b/infra/ec2/start.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +npm install +npx prisma generate +npx prisma migrate deploy +npm run build + +pm2 start ecosystem.config.cjs \ No newline at end of file diff --git a/infra/rds/secure-group-inbound.png b/infra/rds/secure-group-inbound.png new file mode 100644 index 00000000..48d28442 Binary files /dev/null and b/infra/rds/secure-group-inbound.png differ diff --git a/infra/rds/secure-group-outbound.png b/infra/rds/secure-group-outbound.png new file mode 100644 index 00000000..2e2036dd Binary files /dev/null and b/infra/rds/secure-group-outbound.png differ diff --git a/infra/s3/policy.png b/infra/s3/policy.png new file mode 100644 index 00000000..8002c85e Binary files /dev/null and b/infra/s3/policy.png differ diff --git a/src/__tests__/article.auth.test.ts b/src/__tests__/article.auth.test.ts index ca217ad5..25cedbb0 100644 --- a/src/__tests__/article.auth.test.ts +++ b/src/__tests__/article.auth.test.ts @@ -4,7 +4,7 @@ import { app } from '../app.js'; import { createUserWithToken, resetDb } from '../test/test-helpers.js'; describe('Article API (인증 필요)', () => { - beforeEach(async () => { + beforeAll(async () => { await resetDb(); }); diff --git a/src/__tests__/article.public.test.ts b/src/__tests__/article.public.test.ts index e6fe40fd..dcee4944 100644 --- a/src/__tests__/article.public.test.ts +++ b/src/__tests__/article.public.test.ts @@ -4,7 +4,7 @@ import prisma from '../config/prisma.js'; import { createUserWithToken, resetDb } from '../test/test-helpers.js'; describe('Article API (public)', () => { - beforeEach(async () => { + beforeAll(async () => { await resetDb(); }); diff --git a/src/__tests__/article.service.unit.test.ts b/src/__tests__/article.service.unit.test.ts new file mode 100644 index 00000000..a0c79829 --- /dev/null +++ b/src/__tests__/article.service.unit.test.ts @@ -0,0 +1,43 @@ +import { jest } from '@jest/globals'; +import { ValidationError } from '../core/error/error-handler.js'; +import { articleRepo } from '../repositories/article-repository.js'; +import { articleService } from '../services/article-service.js'; + +describe('Article service (유닛 단위 테스트)', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test('목록 조회는 정렬/검색 조건을 저장소에 전달한다', async () => { + const spy = jest.spyOn(articleRepo, 'findArticles').mockResolvedValue([]); + + await articleService.list({ + q: '테스트', + offset: '0', + limit: '10', + order: 'recent', + }); + + expect(spy).toHaveBeenCalledTimes(1); + const [where, orderBy, skip, take] = spy.mock.calls[0] as Parameters< + typeof articleRepo.findArticles + >; + + expect(where).toEqual( + expect.objectContaining({ + OR: expect.any(Array), + }) + ); + expect(orderBy).toEqual({ createdAt: 'desc' }); + expect(skip).toBe(0); + expect(take).toBe(10); + }); + + test('지원하지 않는 정렬 키면 ValidationError를 던진다', async () => { + await expect( + articleService.list({ + order: 'unknown-order', + }) + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 95b65e30..2f7f0408 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -3,7 +3,7 @@ import { app } from '../app.js'; import { resetDb } from '../test/test-helpers.js'; describe('Auth API', () => { - beforeEach(async () => { + beforeAll(async () => { await resetDb(); }); diff --git a/src/__tests__/product.auth.test.ts b/src/__tests__/product.auth.test.ts index 0da3570d..d6028f25 100644 --- a/src/__tests__/product.auth.test.ts +++ b/src/__tests__/product.auth.test.ts @@ -4,7 +4,7 @@ import prisma from '../config/prisma.js'; import { createUserWithToken, resetDb } from '../test/test-helpers.js'; describe('Product API (인증 필요)', () => { - beforeEach(async () => { + beforeAll(async () => { await resetDb(); }); diff --git a/src/__tests__/product.public.test.ts b/src/__tests__/product.public.test.ts index 7d4a4698..1d86bf68 100644 --- a/src/__tests__/product.public.test.ts +++ b/src/__tests__/product.public.test.ts @@ -5,7 +5,7 @@ import prisma from '../config/prisma.js'; import { createUserWithToken, resetDb } from '../test/test-helpers.js'; describe('Product API (public)', () => { - beforeEach(async () => { + beforeAll(async () => { await resetDb(); }); diff --git a/src/controllers/notification-controller.ts b/src/controllers/notification-controller.ts index 86ee2f27..e9675505 100644 --- a/src/controllers/notification-controller.ts +++ b/src/controllers/notification-controller.ts @@ -16,7 +16,16 @@ export const notificationController = { // 2) 미읽음 개수 조회 async unreadCount(req: Request, res: Response) { - const count = await notificationService.countUnread(req.user!.id); + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ + success: false, + message: '인증 정보가 없습니다', + }); + } + + const count = await notificationService.countUnread(userId); res.status(200).json({ success: true, diff --git a/src/socket/io.ts b/src/socket/io.ts index 647299b4..d28deb92 100644 --- a/src/socket/io.ts +++ b/src/socket/io.ts @@ -7,10 +7,42 @@ let io: Server | null = null; export const SOCKET_EVENT_NOTIFICATION = 'notification'; +function toOrigin(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + return new URL(trimmed).origin; + } catch { + return null; + } +} + +function getAllowedOrigins(): string[] { + const configured = (process.env.SOCKET_CORS_ORIGINS ?? '') + .split(',') + .map((value) => toOrigin(value)) + .filter((value): value is string => Boolean(value)); + + if (configured.length > 0) { + return Array.from(new Set(configured)); + } + + const defaults = [ + toOrigin(process.env.BASE_URL ?? ''), + toOrigin('http://localhost:3000'), + toOrigin('http://localhost:5173'), + ].filter((value): value is string => Boolean(value)); + + return Array.from(new Set(defaults)); +} + export function initSocket(server: HttpServer) { + const allowedOrigins = getAllowedOrigins(); + io = new Server(server, { cors: { - origin: '*', + origin: allowedOrigins, credentials: true, }, }); @@ -45,6 +77,8 @@ export function initSocket(server: HttpServer) { } export function emitToUser(userId: number, event: string, payload: T) { - if (!io) return; + if (!io) { + throw new Error('Socket.IO server is not initialized'); + } io.to(`user:${userId}`).emit(event, payload); }