diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..b0a579e2
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,46 @@
+# App / Security
+NEXT_PUBLIC_APP_URL=
+COOKIE_PASSWORD=
+RATE_LIMIT_SALT=
+CRON_SECRET=
+
+# Database
+# PostgreSQL 기준입니다.
+# DATABASE_URL: 런타임 연결 URL
+# DIRECT_URL: migration / Prisma CLI용 직접 연결 URL
+DATABASE_URL=
+DIRECT_URL=
+
+# OAuth
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+KAKAO_CLIENT_ID=
+KAKAO_CLIENT_SECRET=
+KAKAO_REDIRECT_URI=
+
+# Supabase Realtime
+NEXT_PUBLIC_SUPABASE_URL=
+NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=
+
+# Cloudflare Media / Webhook
+NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH=
+NEXT_PUBLIC_CLOUDFLARE_STREAM_DOMAIN=
+CLOUDFLARE_ACCOUNT_ID=
+CLOUDFLARE_API_TOKEN=
+CLOUDFLARE_WEBHOOK_SECRET=
+CLOUDFLARE_STREAM_WEBHOOK_SECRET=
+
+# SMS (CoolSMS)
+COOLSMS_API_KEY=
+COOLSMS_API_SECRET=
+COOLSMS_SENDER_NUMBER=
+
+# Email (Resend)
+RESEND_API_KEY=
+
+# Push
+NEXT_PUBLIC_VAPID_PUBLIC_KEY=
+VAPID_PRIVATE_KEY=
+
+# Maps
+NEXT_PUBLIC_KAKAO_MAP_API_KEY=
diff --git a/README.md b/README.md
index 6e33e98b..63814380 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# BoardPort
-BoardPort는 보드게임 거래, 커뮤니티, 채팅 약속, 라이브 방송, 알림, 관리자 운영을 하나의 사용자 흐름으로 연결한 모바일 퍼스트 웹 애플리케이션입니다.
+BoardPort는 보드게임 중고거래에서 자주 분리되는 상품 탐색, 문의, 직거래 약속, 후기, 콘텐츠, 운영 흐름을 하나의 서비스로 연결한 모바일 퍼스트 웹 애플리케이션입니다.
범용 중고거래 앱에서 부족한 보드게임 특화 맥락을 보완하기 위해, 거래·룰 질문·후기·플레이 공유가 자연스럽게 이어지는 흐름에 초점을 맞췄습니다.
@@ -13,11 +13,16 @@ BoardPort는 보드게임 거래, 커뮤니티, 채팅 약속, 라이브 방송,
| Domain | Board Game Marketplace · Community · Live Streaming · Chat |
| Core Stack | Next.js 14 App Router, React 18, TypeScript, Prisma, PostgreSQL, TanStack Query v5, Zustand, Supabase Realtime |
-## Feature Flow
+## Key Engineering Problems
-BoardPort는 거래, 커뮤니티, 방송, 알림, 관리자 운영이 독립적으로 동작하면서 보드게임 도감으로 콘텐츠 맥락을 연결합니다.
+| 문제 | 해결 방향 | 관련 문서 |
+| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
+| 채팅 약속 수락과 상품 예약 상태가 어긋날 수 있음 | 약속 수락, 상품 예약 전환, 다른 대기 약속 취소, 시스템 메시지를 하나의 transaction으로 묶고 `updateMany` 조건으로 동시 수락을 방어 | [Appointment Atomic Transition](./docs/troubleshooting/troubleshooting-appointment-atomic-transition.md) |
+| App Router 모달 상세와 일반 상세의 복귀 문맥이 섞임 | Intercepting Route 모달, 일반 상세, 수정/삭제 복귀를 `returnTo`, `flow`, refresh flag로 분리하고 mixed tree 케이스를 문맥별로 정리 | [Product Modal Routing](./docs/troubleshooting/troubleshooting-product-modal-routing.md) |
+| 외부 동영상 인코딩 이벤트와 게시글 저장 순서가 보장되지 않음 | Cloudflare webhook의 READY 선도착과 error payload를 처리하고, `draftKey`를 실제 게시글 연결 전까지 보존해 READY/FAILED 상태로 수렴 | [Post Video Webhook](./docs/troubleshooting/troubleshooting-post-video-cloudflare-webhook.md) |
+| Server State, UI State, Realtime 이벤트가 섞여 갱신 기준이 분산됨 | Zustand는 UI 상태, TanStack Query는 서버 상태, Realtime은 invalidate/refetch 신호로 분리하고 Route Handler fetch와 Query Key Factory로 재검증 경로를 통일 | [State Management Modernization](./docs/architecture/case-study-state-management-modernization.md) |
-
+각 문서는 릴리즈 전 QA에서 확인한 증상, 원인, 코드 기준, 운영 판단을 중심으로 정리했습니다.
## Demo
@@ -39,6 +44,12 @@ BoardPort의 주요 도메인을 빠르게 훑어보는 전체 흐름입니다.
+## Feature Flow
+
+BoardPort는 거래, 커뮤니티, 방송, 알림, 관리자 운영이 독립적으로 동작하면서 보드게임 도감으로 콘텐츠 맥락을 연결합니다.
+
+
+
## Feature Tour
@@ -178,8 +189,10 @@ App Router의 일반 상세 페이지와 모달 상세 페이지가 같은 데
- [Project Overview](./docs/architecture/boardport-project-overview.md)
- [State Management Modernization](./docs/architecture/case-study-state-management-modernization.md)
-- [Product Modal Routing Troubleshooting](./docs/troubleshooting/troubleshooting-product-modal-routing.md)
- [Appointment Atomic Transition Troubleshooting](./docs/troubleshooting/troubleshooting-appointment-atomic-transition.md)
+- [Post Video Cloudflare Webhook Troubleshooting](./docs/troubleshooting/troubleshooting-post-video-cloudflare-webhook.md)
+- [Product Modal Routing Troubleshooting](./docs/troubleshooting/troubleshooting-product-modal-routing.md)
+- [Access Control Matrix](./docs/operations/access-control-matrix.md)
- [PWA Web Push Routing Troubleshooting](./docs/troubleshooting/troubleshooting-pwa-web-push-routing.md)
## Project Structure
@@ -202,7 +215,7 @@ prisma/ Prisma schema, seed, migration
npm install
```
-2. `.env.local`에 아래 필수 환경 변수를 채웁니다.
+2. `.env.example`을 참고해 `.env.local`에 필수 환경 변수를 채웁니다.
3. 로컬 또는 개발 DB에 Prisma 마이그레이션을 적용합니다.
@@ -228,15 +241,21 @@ npm run dev
실제 값은 `.env` 또는 Vercel Environment Variables에 설정하고 저장소에는 커밋하지 않습니다.
-#### Core
+#### App / Security
+
+| 변수명 | 설명 |
+| --------------------- | --------------------------------------------- |
+| `NEXT_PUBLIC_APP_URL` | 대표 URL, 인증 콜백, 공유 링크 기준 URL |
+| `COOKIE_PASSWORD` | iron-session 쿠키 암호화 키 |
+| `RATE_LIMIT_SALT` | IP 기반 rate limit hash 생성용 서버 시크릿 키 |
+| `CRON_SECRET` | Vercel Cron 호출 인증용 시크릿 키 |
+
+#### Database
-| 변수명 | 설명 |
-| --------------------- | ---------------------------------------------- |
-| `DATABASE_URL` | Prisma 기본 DB 연결 문자열 |
-| `DIRECT_URL` | Prisma 마이그레이션/직접 연결용 DB 연결 문자열 |
-| `COOKIE_PASSWORD` | iron-session 쿠키 암호화 키 |
-| `NEXT_PUBLIC_APP_URL` | 대표 URL, 인증 콜백, 공유 링크 기준 URL |
-| `CRON_SECRET` | Vercel Cron 호출 인증용 시크릿 키 |
+| 변수명 | 설명 |
+| -------------- | ------------------------------------------ |
+| `DATABASE_URL` | 런타임 PostgreSQL 연결 문자열 |
+| `DIRECT_URL` | Prisma 마이그레이션/CLI용 직접 연결 문자열 |
#### Supabase
@@ -245,17 +264,29 @@ npm run dev
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase 프로젝트 URL |
| `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Supabase 공개 anon 키 |
-#### Auth / SMS / Email
+#### OAuth
+
+| 변수명 | 설명 |
+| ---------------------- | --------------------------------- |
+| `GITHUB_CLIENT_ID` | GitHub OAuth 클라이언트 ID |
+| `GITHUB_CLIENT_SECRET` | GitHub OAuth 클라이언트 시크릿 키 |
+| `KAKAO_CLIENT_ID` | Kakao OAuth 클라이언트 ID |
+| `KAKAO_CLIENT_SECRET` | Kakao OAuth 클라이언트 시크릿 키 |
+| `KAKAO_REDIRECT_URI` | Kakao OAuth 리다이렉트 URI |
+
+#### SMS (CoolSMS)
-| 변수명 | 설명 |
-| ----------------------- | -------------------------------- |
-| `KAKAO_CLIENT_ID` | Kakao OAuth 클라이언트 ID |
-| `KAKAO_CLIENT_SECRET` | Kakao OAuth 클라이언트 시크릿 키 |
-| `KAKAO_REDIRECT_URI` | Kakao OAuth 리다이렉트 URI |
-| `COOLSMS_API_KEY` | CoolSMS API 키 |
-| `COOLSMS_API_SECRET` | CoolSMS API 시크릿 키 |
-| `COOLSMS_SENDER_NUMBER` | CoolSMS 발신 번호 |
-| `RESEND_API_KEY` | Resend 이메일 발송 API 키 |
+| 변수명 | 설명 |
+| ----------------------- | --------------------- |
+| `COOLSMS_API_KEY` | CoolSMS API 키 |
+| `COOLSMS_API_SECRET` | CoolSMS API 시크릿 키 |
+| `COOLSMS_SENDER_NUMBER` | CoolSMS 발신 번호 |
+
+#### Email (Resend)
+
+| 변수명 | 설명 |
+| ---------------- | ------------------------- |
+| `RESEND_API_KEY` | Resend 이메일 발송 API 키 |
#### Cloudflare
@@ -268,12 +299,17 @@ npm run dev
| `CLOUDFLARE_WEBHOOK_SECRET` | Cloudflare 웹훅 요청 검증용 시크릿 키 |
| `CLOUDFLARE_STREAM_WEBHOOK_SECRET` | Cloudflare Stream 웹훅 서명 검증용 시크릿 키 |
-#### Push / 지도
+#### Push
+
+| 변수명 | 설명 |
+| ------------------------------ | --------------------- |
+| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Web Push VAPID 공개키 |
+| `VAPID_PRIVATE_KEY` | Web Push VAPID 개인키 |
+
+#### Maps
| 변수명 | 설명 |
| ------------------------------- | ----------------------------- |
-| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Web Push VAPID 공개키 |
-| `VAPID_PRIVATE_KEY` | Web Push VAPID 개인키 |
| `NEXT_PUBLIC_KAKAO_MAP_API_KEY` | 카카오 지도 JavaScript SDK 키 |
## Scripts
diff --git a/app/(app)/(tabs)/streams/page.tsx b/app/(app)/(tabs)/streams/page.tsx
index af033cb1..7ed2e2c1 100644
--- a/app/(app)/(tabs)/streams/page.tsx
+++ b/app/(app)/(tabs)/streams/page.tsx
@@ -44,6 +44,7 @@
* 2026.04.20 임도헌 Modified sm 구간 데스크톱 헤더가 뒤 콘텐츠를 비치지 않도록 반투명 헤더/카테고리 레일 표면을 불투명 톤으로 정리
* 2026.05.08 임도헌 Modified 스트림 조회 범위 타입을 StreamScope 공용 타입으로 교체
* 2026.05.17 임도헌 Modified prefetch 데이터 타입을 InfiniteData로 명시
+ * 2026.06.25 임도헌 Modified 서버 prefetch query key를 조회자/팔로잉 필터 스코프와 일치하도록 정리
*/
import { Suspense } from "react";
import { Metadata } from "next";
@@ -136,30 +137,39 @@ export default async function StreamsPage({ searchParams }: StreamsPageProps) {
sort: recordingSort,
scope: scope === "following" ? "following" : "",
};
+ // 로그인 가드 이후에는 viewerId가 존재하지만, 클라이언트 훅의 query key 스코프와 맞추기 위해 guest fallback을 유지한다.
+ const liveListQueryKey = queryKeys.streams.list(scope, {
+ ...liveQueryParams,
+ viewerId: viewerId ?? "guest",
+ });
+ const recordingListQueryKey = queryKeys.streams.recordingList(
+ recordingSort,
+ {
+ ...recordingQueryParams,
+ followingOnly: scope === "following",
+ viewerId: viewerId ?? "guest",
+ }
+ );
const queryClient = getQueryClient();
const [, unreadCount] = await Promise.all([
// 라이브/다시보기의 서로 다른 쿼리 키/fetcher에 맞춘 현재 모드 목록만 서버 선프리패치
mode === "recordings"
? queryClient.prefetchInfiniteQuery({
- queryKey: queryKeys.streams.recordingList(
- recordingSort,
- recordingQueryParams
- ),
+ queryKey: recordingListQueryKey,
queryFn: () =>
getRecordingsListAction(
recordingSort,
scope === "following",
null,
- recordingQueryParams,
- viewerId
+ recordingQueryParams
),
initialPageParam: null as number | null,
})
: queryClient.prefetchInfiniteQuery({
- queryKey: queryKeys.streams.list(scope, liveQueryParams),
+ queryKey: liveListQueryKey,
queryFn: () =>
- getStreamsListAction(scope, null, liveQueryParams, viewerId),
+ getStreamsListAction(scope, null, liveQueryParams),
initialPageParam: null as number | null,
}),
getUnreadNotificationCount(),
@@ -167,11 +177,7 @@ export default async function StreamsPage({ searchParams }: StreamsPageProps) {
const prefetchData = queryClient.getQueryData<
InfiniteData
- >(
- mode === "recordings"
- ? queryKeys.streams.recordingList(recordingSort, recordingQueryParams)
- : queryKeys.streams.list(scope, liveQueryParams)
- );
+ >(mode === "recordings" ? recordingListQueryKey : liveListQueryKey);
const firstPage = prefetchData?.pages[0];
const isDataEmpty =
mode === "recordings"
@@ -401,4 +407,3 @@ export default async function StreamsPage({ searchParams }: StreamsPageProps) {
);
}
-
diff --git a/app/api/streams/recordings/route.test.ts b/app/api/streams/recordings/route.test.ts
new file mode 100644
index 00000000..340e4813
--- /dev/null
+++ b/app/api/streams/recordings/route.test.ts
@@ -0,0 +1,70 @@
+/**
+ * File Name : app/api/streams/recordings/route.test.ts
+ * Description : 다시보기 목록 API 권한 경계 회귀 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.25 임도헌 Created URL viewerId를 신뢰하지 않는 세션 기준 조회 테스트 추가
+ */
+
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ getSession: vi.fn(),
+ getRecordingsList: vi.fn(),
+}));
+
+vi.mock("@/lib/session", () => ({
+ default: mocks.getSession,
+}));
+
+vi.mock("@/features/stream/service/list", () => ({
+ getRecordingsList: mocks.getRecordingsList,
+}));
+
+describe("GET /api/streams/recordings", () => {
+ beforeEach(() => {
+ mocks.getSession.mockReset();
+ mocks.getRecordingsList.mockReset();
+ });
+
+ it("비로그인 요청의 viewerId query를 조회자 권한으로 사용하지 않는다", async () => {
+ const { GET } = await import("./route");
+ const request = new NextRequest(
+ "http://localhost/api/streams/recordings?followingOnly=true&viewerId=123"
+ );
+
+ mocks.getSession.mockResolvedValue(null);
+
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ recordings: [],
+ nextCursor: null,
+ });
+ expect(mocks.getRecordingsList).not.toHaveBeenCalled();
+ });
+
+ it("세션이 있으면 query viewerId보다 세션 ID를 우선한다", async () => {
+ const { GET } = await import("./route");
+ const request = new NextRequest(
+ "http://localhost/api/streams/recordings?followingOnly=true&viewerId=123&cursor=50"
+ );
+
+ mocks.getSession.mockResolvedValue({ id: 7 });
+ mocks.getRecordingsList.mockResolvedValue([]);
+
+ await GET(request);
+
+ expect(mocks.getRecordingsList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ followingOnly: true,
+ viewerId: 7,
+ cursor: 50,
+ })
+ );
+ });
+});
diff --git a/app/api/streams/recordings/route.ts b/app/api/streams/recordings/route.ts
index 402c6989..41633d37 100644
--- a/app/api/streams/recordings/route.ts
+++ b/app/api/streams/recordings/route.ts
@@ -6,6 +6,7 @@
* History
* Date Author Status Description
* 2026.05.19 임도헌 Created Client queryFn에서 조회용 Server Action을 직접 호출하지 않도록 다시보기 목록 조회 API 분리
+ * 2026.06.25 임도헌 Modified URL viewerId fallback 제거 및 세션 기준 조회자 권한 고정
*/
import { NextRequest, NextResponse } from "next/server";
@@ -52,8 +53,8 @@ export async function GET(request: NextRequest) {
const sort: RecordingSort =
searchParams.get("sort") === "popular" ? "popular" : "latest";
const followingOnly = searchParams.get("followingOnly") === "true";
- const viewerId =
- session?.id ?? parseNullableNumberParam(searchParams.get("viewerId"));
+ // /api 경로는 middleware 인증 가드를 타지 않으므로 URL viewerId를 신뢰하지 않고 세션만 조회자 기준으로 사용한다.
+ const viewerId = session?.id ?? null;
if (!viewerId) {
return NextResponse.json({ recordings: [], nextCursor: null });
diff --git a/app/api/streams/route.test.ts b/app/api/streams/route.test.ts
new file mode 100644
index 00000000..f675a3fb
--- /dev/null
+++ b/app/api/streams/route.test.ts
@@ -0,0 +1,67 @@
+/**
+ * File Name : app/api/streams/route.test.ts
+ * Description : 라이브 방송 목록 API 권한 경계 회귀 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.25 임도헌 Created URL viewerId를 신뢰하지 않는 세션 기준 조회 테스트 추가
+ */
+
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ getSession: vi.fn(),
+ getStreamsList: vi.fn(),
+}));
+
+vi.mock("@/lib/session", () => ({
+ default: mocks.getSession,
+}));
+
+vi.mock("@/features/stream/service/list", () => ({
+ getStreamsList: mocks.getStreamsList,
+}));
+
+describe("GET /api/streams", () => {
+ beforeEach(() => {
+ mocks.getSession.mockReset();
+ mocks.getStreamsList.mockReset();
+ });
+
+ it("비로그인 요청의 viewerId query를 조회자 권한으로 사용하지 않는다", async () => {
+ const { GET } = await import("./route");
+ const request = new NextRequest(
+ "http://localhost/api/streams?scope=following&viewerId=123"
+ );
+
+ mocks.getSession.mockResolvedValue(null);
+
+ const response = await GET(request);
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ streams: [], nextCursor: null });
+ expect(mocks.getStreamsList).not.toHaveBeenCalled();
+ });
+
+ it("세션이 있으면 query viewerId보다 세션 ID를 우선한다", async () => {
+ const { GET } = await import("./route");
+ const request = new NextRequest(
+ "http://localhost/api/streams?scope=following&viewerId=123&cursor=50"
+ );
+
+ mocks.getSession.mockResolvedValue({ id: 7 });
+ mocks.getStreamsList.mockResolvedValue([]);
+
+ await GET(request);
+
+ expect(mocks.getStreamsList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ scope: "following",
+ viewerId: 7,
+ cursor: 50,
+ })
+ );
+ });
+});
diff --git a/app/api/streams/route.ts b/app/api/streams/route.ts
index 3306c2a8..61031e24 100644
--- a/app/api/streams/route.ts
+++ b/app/api/streams/route.ts
@@ -6,6 +6,7 @@
* History
* Date Author Status Description
* 2026.05.19 임도헌 Created Client queryFn에서 조회용 Server Action을 직접 호출하지 않도록 라이브 방송 목록 조회 API 분리
+ * 2026.06.25 임도헌 Modified URL viewerId fallback 제거 및 세션 기준 조회자 권한 고정
*/
import { NextRequest, NextResponse } from "next/server";
@@ -51,8 +52,8 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const scope: StreamScope =
searchParams.get("scope") === "following" ? "following" : "all";
- const viewerId =
- session?.id ?? parseNullableNumberParam(searchParams.get("viewerId"));
+ // /api 경로는 middleware 인증 가드를 타지 않으므로 URL viewerId를 신뢰하지 않고 세션만 조회자 기준으로 사용한다.
+ const viewerId = session?.id ?? null;
if (!viewerId) {
return NextResponse.json({ streams: [], nextCursor: null });
diff --git a/app/api/webhooks/cloudflare/route.test.ts b/app/api/webhooks/cloudflare/route.test.ts
new file mode 100644
index 00000000..16845eb3
--- /dev/null
+++ b/app/api/webhooks/cloudflare/route.test.ts
@@ -0,0 +1,117 @@
+/**
+ * File Name : app/api/webhooks/cloudflare/route.test.ts
+ * Description : Cloudflare Webhook Route 인증 경계 회귀 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.25 임도헌 Created production secret 누락 시 이벤트 처리 중단 테스트 추가
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ db: {
+ liveInput: {
+ findUnique: vi.fn(),
+ },
+ broadcast: {
+ findFirst: vi.fn(),
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ },
+ vodAsset: {
+ upsert: vi.fn(),
+ },
+ postVideo: {
+ findFirst: vi.fn(),
+ update: vi.fn(),
+ },
+ },
+ revalidateTag: vi.fn(),
+ sendLiveStatusFromServer: vi.fn(),
+ sendLiveStartNotifications: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+ default: mocks.db,
+}));
+
+vi.mock("server-only", () => ({}));
+
+vi.mock("next/cache", () => ({
+ revalidateTag: mocks.revalidateTag,
+}));
+
+vi.mock("@/features/stream/service/realtime", () => ({
+ sendLiveStatusFromServer: mocks.sendLiveStatusFromServer,
+}));
+
+vi.mock("@/features/notification/service/live", () => ({
+ sendLiveStartNotifications: mocks.sendLiveStartNotifications,
+}));
+
+function liveConnectedRequest(headers?: HeadersInit) {
+ return new Request("http://localhost/api/webhooks/cloudflare", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ ...headers,
+ },
+ body: JSON.stringify({
+ type: "live_input.connected",
+ liveInput: "live-input-1",
+ }),
+ });
+}
+
+describe("POST /api/webhooks/cloudflare", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("production Stream webhook secret이 없으면 이벤트 처리를 시작하지 않는다", async () => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("CLOUDFLARE_STREAM_WEBHOOK_SECRET", "");
+ vi.stubEnv("CLOUDFLARE_WEBHOOK_SECRET", "destination-secret");
+
+ const { POST } = await import("./route");
+ const response = await POST(
+ liveConnectedRequest({
+ "webhook-signature": "time=1,sig1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ })
+ );
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({
+ ok: false,
+ error: "WEBHOOK_SECRET_NOT_CONFIGURED",
+ });
+ expect(mocks.db.liveInput.findUnique).not.toHaveBeenCalled();
+ expect(mocks.db.broadcast.update).not.toHaveBeenCalled();
+ expect(mocks.sendLiveStatusFromServer).not.toHaveBeenCalled();
+ });
+
+ it("production Destination webhook secret이 없으면 이벤트 처리를 시작하지 않는다", async () => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("CLOUDFLARE_STREAM_WEBHOOK_SECRET", "stream-secret");
+ vi.stubEnv("CLOUDFLARE_WEBHOOK_SECRET", "");
+
+ const { POST } = await import("./route");
+ const response = await POST(liveConnectedRequest());
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({
+ ok: false,
+ error: "WEBHOOK_SECRET_NOT_CONFIGURED",
+ });
+ expect(mocks.db.liveInput.findUnique).not.toHaveBeenCalled();
+ expect(mocks.db.broadcast.update).not.toHaveBeenCalled();
+ expect(mocks.sendLiveStatusFromServer).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/api/webhooks/cloudflare/route.ts b/app/api/webhooks/cloudflare/route.ts
index d97eef05..bcc6968e 100644
--- a/app/api/webhooks/cloudflare/route.ts
+++ b/app/api/webhooks/cloudflare/route.ts
@@ -21,6 +21,7 @@
* 2026.04.05 임도헌 Modified 게시글 동영상 draftKey를 READY 웹훅에서 조기 해제하지 않고 실제 게시글 연결 시점까지 유지
* 2026.05.12 임도헌 Modified 게시글 동영상 READY 선도착/Cloudflare error 웹훅 처리 보강
* 2026.05.17 임도헌 Modified Cloudflare Stream 웹훅 페이로드 타입 명시
+ * 2026.06.25 임도헌 Modified production secret 누락 시 Stream/Destination 웹훅 fail-closed 처리
*/
import "server-only";
@@ -32,6 +33,7 @@ import db from "@/lib/db";
import { sendLiveStatusFromServer } from "@/features/stream/service/realtime";
import { sendLiveStartNotifications } from "@/features/notification/service/live";
import { Prisma } from "@/generated/prisma/client";
+import { isMissingRequiredCloudflareWebhookSecret } from "@/features/stream/utils/webhookAuth";
import type {
CloudflareStreamAssetPayload,
CloudflareVideoListResponse,
@@ -813,8 +815,22 @@ export async function POST(req: Request) {
const sigHeader = req.headers.get("webhook-signature");
const isStreamWebhook = !!sigHeader;
+ // production에서는 secret 누락을 검증 생략으로 처리하지 않고 상태 변경 이벤트를 fail-closed 한다.
if (isStreamWebhook) {
// Stream Webhook → HMAC 서명 검증
+ if (
+ isMissingRequiredCloudflareWebhookSecret({
+ kind: "stream",
+ streamSecret: STREAM_SECRET,
+ destinationSecret: DEST_SECRET,
+ })
+ ) {
+ return NextResponse.json(
+ { ok: false, error: "WEBHOOK_SECRET_NOT_CONFIGURED" },
+ { status: 500 }
+ );
+ }
+
if (STREAM_SECRET) {
const ok = await verifyStreamSignatureWebCrypto(
raw,
@@ -829,6 +845,19 @@ export async function POST(req: Request) {
}
} else {
// Destination Webhook → 인증 헤더 확인 (옵션)
+ if (
+ isMissingRequiredCloudflareWebhookSecret({
+ kind: "destination",
+ streamSecret: STREAM_SECRET,
+ destinationSecret: DEST_SECRET,
+ })
+ ) {
+ return NextResponse.json(
+ { ok: false, error: "WEBHOOK_SECRET_NOT_CONFIGURED" },
+ { status: 500 }
+ );
+ }
+
if (DEST_SECRET && !hasDestinationHeaderSecret(req, DEST_SECRET)) {
return NextResponse.json(
{ ok: false, error: "UNAUTHORIZED" },
@@ -891,4 +920,3 @@ export async function POST(req: Request) {
);
}
}
-
diff --git a/docs/README.md b/docs/README.md
index 268c5c7d..e260dee7 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -17,12 +17,19 @@
3. [troubleshooting/troubleshooting-appointment-atomic-transition.md](./troubleshooting/troubleshooting-appointment-atomic-transition.md)
- 채팅 약속 수락과 상품 예약 상태를 단일 트랜잭션으로 맞춘 사례
-4. [troubleshooting/troubleshooting-pwa-web-push-routing.md](./troubleshooting/troubleshooting-pwa-web-push-routing.md)
- - In-App 알림과 Web Push 중복 제어, Service Worker 라우팅을 정리한 기록
+4. [troubleshooting/troubleshooting-post-video-cloudflare-webhook.md](./troubleshooting/troubleshooting-post-video-cloudflare-webhook.md)
+ - Cloudflare 웹훅과 게시글 저장 순서가 엇갈리는 동영상 상태 수렴 사례
5. [troubleshooting/troubleshooting-product-modal-routing.md](./troubleshooting/troubleshooting-product-modal-routing.md)
- App Router Intercepting Route, 편집 복귀, 모달 히스토리 문제 해결 사례
+추가 사례:
+
+- [operations/access-control-matrix.md](./operations/access-control-matrix.md)
+ - 페이지, Route Handler, Server Action, Webhook 기준의 권한/접근 제어 정리
+- [troubleshooting/troubleshooting-pwa-web-push-routing.md](./troubleshooting/troubleshooting-pwa-web-push-routing.md)
+ - In-App 알림과 Web Push 중복 제어, Service Worker 라우팅을 정리한 기록
+
## 문서 분류
### Architecture
@@ -39,6 +46,8 @@
### Operations
+- [권한 / 접근 제어 매트릭스](./operations/access-control-matrix.md)
+- [Rate Limit / 남용 방지 운영 기준](./operations/rate-limit-policy.md)
- [보안 헤더 / CSP 운영 정책](./operations/security-headers-csp-policy.md)
- [보드게임 데이터 import 운영 기준](./operations/boardgame-data-import-runbook.md)
- [신고 처리와 제재 운영 정책](./operations/report-moderation-policy.md)
diff --git a/docs/operations/access-control-matrix.md b/docs/operations/access-control-matrix.md
new file mode 100644
index 00000000..35bece82
--- /dev/null
+++ b/docs/operations/access-control-matrix.md
@@ -0,0 +1,118 @@
+# 권한 / 접근 제어 매트릭스
+
+BoardPort의 접근 제어는 middleware 한 곳에만 의존하지 않고, 페이지 진입, Route Handler, Server Action, service 계층에서 도메인별로 다시 확인합니다.
+
+이 문서는 면접, 코드 리뷰, 릴리즈 전 점검에서 주요 사용자 역할과 보호 지점을 빠르게 확인하기 위한 운영 기준입니다. 세부 구현은 각 도메인의 `actions`, `service`, `route.ts` 파일을 기준으로 확인합니다.
+
+## 1. 기본 원칙
+
+| 원칙 | 기준 |
+| --------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| 세션 우선 | 조회자 또는 행위자 ID는 클라이언트 입력보다 서버 세션을 우선합니다. |
+| 계층별 재검증 | 보호 페이지라도 Server Action과 Route Handler에서 권한을 다시 확인합니다. |
+| 소유자 기준 | 수정, 삭제, 상태 변경은 작성자 또는 소유자 권한을 service 계층에서 확인합니다. |
+| 관계 기준 | 채팅, 팔로우, 차단, 신고, 방송 접근은 사용자 간 관계와 콘텐츠 상태를 함께 확인합니다. |
+| 외부 요청 fail-closed | Webhook처럼 외부에서 들어오는 요청은 production secret 또는 signature 누락 시 상태 변경 전에 거부합니다. |
+| 캐시와 권한 분리 | TanStack Query key는 개인화 결과를 구분하기 위한 cache identity이고, 권한 판단은 서버 세션과 DB 상태를 기준으로 합니다. |
+
+## 2. 페이지 / API 보호 기준
+
+| 영역 | 진입 경로 | 보호 기준 |
+| ----------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| 공개 영역 | `/`, `/login`, `/create-account`, 공유 이미지 route | 비로그인 접근 허용. 인증 사용자는 guest-only 페이지에서 앱 영역으로 이동 |
+| 앱 영역 | `/products`, `/posts`, `/chat`, `/profile`, `/streams` 등 | middleware에서 로그인 세션 확인 후 비로그인 사용자는 `/login?callbackUrl=...`로 이동 |
+| 관리자 영역 | `/admin/*` | 관리자 권한 확인. 일반 사용자는 관리자 화면에 남을 수 없음 |
+| API Route Handler | `/api/*` | middleware matcher에서 제외되므로 공개 조회 handler는 입력을 검증하고, 개인화/변경 handler는 세션과 권한을 직접 확인 |
+| Server Action | `features/*/actions/*` | 사용자 의도 기반 변경 작업은 action 내부에서 세션을 읽고 service에 actor ID 전달 |
+| Webhook | `/api/webhooks/cloudflare` | Cloudflare Stream signature 또는 Destination secret 기준으로 인증. production secret 누락 시 fail-closed |
+
+## 3. 도메인별 매트릭스
+
+### Product / Marketplace
+
+| 행위 | 허용 대상 | 대표 가드 | 비고 |
+| ------------------- | ---------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| 상품 등록 | 로그인 사용자 | 세션, 사용자 상태, 입력 스키마 | 지역, 카테고리, 이미지 등 도메인 validation 적용 |
+| 상품 수정/삭제 | 상품 작성자 | 세션 userId와 product.userId 비교 | 삭제 시 관련 알림, 리뷰, 채팅 참조 정리 |
+| 좋아요 | 로그인 사용자 | 세션, 차단 관계, 자기 상품 여부 | 개인화 캐시는 viewer별 query key로 분리 |
+| 예약/판매 상태 변경 | 상품 작성자 | 세션, product.userId, 현재 거래 상태 조건 | 예약자/구매자 정보와 관련 약속 상태를 함께 정리 |
+| 상품 목록 조회 | 앱 화면 사용자, 비개인화 직접 요청 | 지역/검색 필터, 서버 세션 기반 viewerId, 차단 관계 | `/products` 화면은 로그인 보호. 직접 API 호출은 서버가 세션 부재 시 `-1` sentinel을 주입한 비개인화 목록으로 처리 |
+
+### Chat / Appointment
+
+| 행위 | 허용 대상 | 대표 가드 | 비고 |
+| -------------- | -------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------- |
+| 채팅방 생성 | 상품 작성자가 아닌 로그인 사용자 | 세션, 상품 존재 여부, 정지 상태, 차단 관계 | 동일 상품/사용자 조합은 기존 방 재사용과 인메모리 lock으로 단일 인스턴스 중복 방어 |
+| 채팅방 조회 | 참여자 | `checkChatRoomAccess` | 참여자가 아니면 상세 진입 불가 |
+| 메시지 전송 | 참여자 | 세션, 방 참여 여부, 차단/정지 상태 | 전송 후 Realtime 브로드캐스트 |
+| 약속 제안 | 참여자 | 세션, 방 참여 여부, 약속 시간/장소 validation | 같은 채팅방의 기존 PENDING 약속 정리 |
+| 약속 수락/거절 | 수신자 | 세션, receiverId, 현재 약속 상태, 상품 거래 상태 | 수락 시 약속과 상품 예약 전환을 transaction으로 처리 |
+
+### Post / Comment / Media
+
+| 행위 | 허용 대상 | 대표 가드 | 비고 |
+| ------------------ | --------------------------------- | ----------------------------------------- | -------------------------------------------------- |
+| 게시글 작성 | 로그인 사용자 | 세션, 입력 스키마, 첨부 draft 소유자 확인 | 이미지/동영상/임베드 블록 지원 |
+| 게시글 수정/삭제 | 게시글 작성자 | 세션 userId와 post.userId 비교 | 삭제 후 목록 cache와 stale cursor 정리 |
+| 댓글/대댓글 작성 | 로그인 사용자 | 세션, 게시글 존재 여부, 차단 관계 | 댓글 목록은 차단 관계를 반영 |
+| 댓글 삭제 | 댓글 작성자 또는 정책상 허용 주체 | 세션과 댓글 작성자 확인 | 상세 권한은 comment service 기준 |
+| 게시글 동영상 연결 | draft 작성자 | draftKey와 userId 동시 확인 | READY 선도착 시 draftKey를 게시글 저장 전까지 보존 |
+
+### Stream / VOD
+
+| 행위 | 허용 대상 | 대표 가드 | 비고 |
+| -------------------- | --------------------------- | ---------------------------- | ----------------------------------------------------------------- |
+| 방송 생성/관리 | 로그인 사용자, 방송 소유자 | 세션, liveInput 소유자 확인 | Cloudflare Stream 연동 |
+| 라이브/VOD 목록 조회 | 로그인 사용자 | Route Handler 내부 세션 ID | query `viewerId` fallback 제거. 비로그인 직접 호출은 빈 목록 반환 |
+| PUBLIC 상세/재생 | 로그인 사용자 | 세션, 차단 관계, 방송 상태 | `/streams`는 로그인 보호 페이지 |
+| FOLLOWERS 상세/재생 | 소유자 또는 팔로워 | 세션, 팔로우 관계 | 팔로우 후 query invalidation으로 목록/잠금 상태 수렴 |
+| PRIVATE 상세/재생 | 소유자 또는 unlock된 사용자 | 세션, private unlock session | 비밀번호 해제 상태는 session 기반으로 확인 |
+| VOD 좋아요/댓글 | 로그인 사용자 | 세션, VOD 상태, 차단 관계 | 좋아요 상태는 viewer별 query key로 분리 |
+
+이 문서에서 PUBLIC은 비로그인 인터넷 공개가 아니라, 로그인한 BoardPort 앱 사용자에게 공개되는 범위를 의미합니다.
+
+### Notification / Push
+
+| 행위 | 허용 대상 | 대표 가드 | 비고 |
+| --------------------------- | ------------------- | ---------------------------------------------- | -------------------------------------------------- |
+| 알림 목록 조회 | 본인 | 세션 userId | 삭제된 콘텐츠 알림은 응답 단계에서 link/image 정리 |
+| 알림 설정 변경 | 본인 | 세션 userId | In-App 설정과 Push 정책 분리 |
+| Push subscription 등록/삭제 | 본인 브라우저 구독 | 세션, endpoint/userId unique 기준 | endpoint와 userId 조합으로 중복 구독 방지 |
+| Push 발송 | 서버 정책 통과 대상 | 알림 타입 설정, quiet hours, subscription 상태 | In-App 알림과 Web Push는 별도 정책으로 처리 |
+
+### Report / Admin
+
+| 행위 | 허용 대상 | 대표 가드 | 비고 |
+| ---------------- | ------------- | -------------------------------------- | -------------------------------------------- |
+| 신고 생성 | 로그인 사용자 | 세션, 대상 존재 여부, 중복 신고 unique | userId, targetType, targetId 기준 중복 방지 |
+| 신고 목록/처리 | 관리자 | 관리자 권한, 처리 상태 | 신고 처리, 콘텐츠 조치, 유저 제재 기록 |
+| 감사 로그 조회 | 관리자 | 관리자 권한 | 처리자, 대상, action, trace URL 기준 추적 |
+| 관리자 화면 접근 | 관리자 | middleware 및 서버 측 권한 확인 | 일반 계정의 관리자 화면 접근 차단 E2E로 확인 |
+
+### Webhook / External Event
+
+| 행위 | 허용 대상 | 대표 가드 | 비고 |
+| ------------------------------ | ------------------------- | ---------------------------------------------------- | ---------------------------------------------- |
+| Cloudflare Stream webhook | Cloudflare 서명 요청 | raw body HMAC, timestamp skew, constant-time compare | `CLOUDFLARE_STREAM_WEBHOOK_SECRET` 필요 |
+| Cloudflare Destination webhook | secret header가 맞는 요청 | destination secret header 확인 | `CLOUDFLARE_WEBHOOK_SECRET` 필요 |
+| production secret 누락 | 허용하지 않음 | `WEBHOOK_SECRET_NOT_CONFIGURED` 500 반환 | DB 갱신, Realtime 송신, 알림 송신 시작 전 차단 |
+| Handshake / empty body | 상태 변경 없음 | 상태 변경 전 조기 응답 | Cloudflare 등록 검증 흐름을 유지 |
+
+## 4. 릴리즈 전 확인 포인트
+
+- `/api/*`가 middleware 보호를 받는다고 가정하지 않았는가?
+- Route Handler에서 조회자 ID를 query/body 입력으로 신뢰하지 않는가?
+- Server Action이 세션 없이 actor ID를 외부 입력으로 받지 않는가?
+- 목록, 상세, 좋아요, 잠금 상태처럼 개인화 응답을 만드는 query key에 viewer scope가 포함되어 있는가?
+- production webhook secret 누락 시 상태 변경 처리가 시작되지 않는가?
+- 관리자 기능은 화면 접근뿐 아니라 action/service 단계에서도 권한을 확인하는가?
+- 차단 관계, 정지 사용자, 삭제된 콘텐츠, 나간 채팅 참여자 같은 비정상 상태가 service 계층에서 방어되는가?
+
+## 5. 함께 보는 문서
+
+- [테스트 전략](./testing-strategy.md)
+- [Rate Limit / 남용 방지 운영 기준](./rate-limit-policy.md)
+- [신고 처리와 제재 운영 정책](./report-moderation-policy.md)
+- [보안 헤더 / CSP 운영 정책](./security-headers-csp-policy.md)
+- [직거래 약속 수락과 상품 상태 원자적 전환](../troubleshooting/troubleshooting-appointment-atomic-transition.md)
+- [게시글 동영상 Cloudflare 웹훅 상태 전환](../troubleshooting/troubleshooting-post-video-cloudflare-webhook.md)
diff --git a/docs/operations/rate-limit-policy.md b/docs/operations/rate-limit-policy.md
new file mode 100644
index 00000000..237fbbe5
--- /dev/null
+++ b/docs/operations/rate-limit-policy.md
@@ -0,0 +1,123 @@
+# Rate Limit / 남용 방지 운영 기준
+
+BoardPort의 rate limit은 모든 API에 같은 숫자를 적용하는 방식이 아니라, 요청 주체, 행위 비용, 대상 리소스, 시간 창을 나누어 적용합니다.
+
+이 문서는 인증, 업로드, 채팅, 신고처럼 반복 호출의 비용이나 피해가 큰 경로를 점검하기 위한 운영 기준입니다. 실제 구현은 각 도메인의 `actions`, `service`, `route.ts` 파일과 배포 환경의 WAF/로그 정책을 함께 확인합니다.
+
+## 1. 기본 원칙
+
+| 원칙 | 기준 |
+| --------------- | ---------------------------------------------------------------------------------------------- |
+| 행위별 제한 | 조회, 검색, 인증, 업로드, 메시지, 신고, 관리자 작업은 같은 한도를 쓰지 않습니다. |
+| 다중 식별자 | 비로그인은 IP, 로그인 사용자는 userId, 채팅은 userId+roomId, 인증은 전화번호/IP 조합을 봅니다. |
+| 비용 우선 | SMS, 메일, 업로드 URL, 외부 API, 상태 변경처럼 비용이나 부작용이 큰 경로를 먼저 제한합니다. |
+| 정상 burst 허용 | 사용자의 짧은 클릭 반복은 어느 정도 허용하되, 지속적인 자동화 요청은 차단합니다. |
+| 권한과 분리 | Rate limit은 남용 완화 장치이고, 권한 판단은 세션, 소유권, 관계, DB 상태로 별도 확인합니다. |
+| 재시도 안내 | 제한 응답은 429와 `Retry-After` 또는 남은 대기 시간을 함께 제공합니다. |
+| 로그 기반 조정 | 조회/검색 계열은 운영 로그와 P99 사용량을 본 뒤 조정합니다. |
+
+## 2. 우선 적용 대상
+
+| 영역 | 초기 기준 | 식별자 | 이유 |
+| ---------------- | ------------------------ | --------------------- | ------------------------------------------------------------ |
+| SMS 인증 발송 | 전화번호별 1회/60초 | phone | CoolSMS 비용과 반복 발송 피해를 줄입니다. |
+| SMS 발송 요청 | IP 10회/1시간 | IP hash | 여러 번호로 분산하는 발송 시도 남용을 줄입니다. |
+| 회원가입 시도 | IP 10회/10분 | IP hash | 유효한 가입 제출의 짧은 시간 자동화 폭주를 완화합니다. |
+| 업로드 URL 발급 | 로그인 세션 필수 | userId | Cloudflare API 토큰을 사용하는 민감 서버 경계를 보호합니다. |
+| 라이브 채팅 | 최근 메시지 수 기반 제한 | userId + streamRoomId | 실시간 도배를 완화합니다. 현재 서비스 계층에서 카운트합니다. |
+
+초기값은 외부 플랫폼의 공개 한도를 그대로 가져오지 않고, BoardPort의 일반 사용량과 자동화 요청 사이에 두었습니다.
+
+SMS 인증은 실제 발송 성공 횟수보다 발송 요청 시도, 토큰 수명, 발송 실패 정합성을 먼저 맞춥니다. 인증번호는 TTL을 가지며, 외부 SMS 발송이 실패하면 기존 유효 토큰을 지우거나 실패한 토큰만 남기지 않습니다. 재요청 제한과 토큰 교체는 동시에 들어온 요청 중 하나만 새 발송 슬롯을 확보하도록 처리합니다.
+
+## 3. 후속 적용 후보
+
+| 영역 | 권장 기준 | 비고 |
+| ------------------------- | ----------------------------------- | ------------------------------------------------------------------------- |
+| 댓글 작성 | 사용자별 10회/분, 60회/시간 | 낙관 UI와 에러 토스트 정책까지 함께 확인한 뒤 적용합니다. |
+| 1:1 채팅 메시지 | 사용자별 5건/10초, 방별 15건/분 | Realtime 중복 전송, 방 단위 UX와 함께 검증합니다. |
+| 신고 생성 | 사용자별 5회/시간, 동일 대상 1회/일 | 중복 신고 unique와 관리자 검토 비용을 함께 고려합니다. |
+| SMS 인증 발송 세부 bucket | 전화번호+IP 3회/30분 | IP 상한 운영 후 특정 번호 반복 발송이 관찰되면 추가합니다. |
+| SMS 인증 검증 실패 | 전화번호+IP 5회/10분 | 6자리 인증번호 반복 대입 방지 후보입니다. |
+| 상품/게시글 등록 | 사용자별 5회/10분, 20회/일 | 정상 판매자와 스팸 등록을 구분하기 위해 운영 로그를 먼저 봅니다. |
+| 회원가입 성공 | IP 5회/24시간 | 공유망 정상 사용자 오탐 가능성이 커서 운영 로그와 정책 설명이 필요합니다. |
+| 소켓 연결/재연결 | 사용자별 3회/10초, 동시 연결 2개 | 탭 중복, 네트워크 재연결, Realtime provider 정책과 함께 검토합니다. |
+| 검색/목록 조회 | 관찰 모드 후 결정 | 정상 탐색을 막기 쉬우므로 로그 기반으로 조정합니다. |
+| 관리자 변경 작업 | 관리자별 20회/분 | 일괄 처리나 스크립트 오작동을 완화합니다. |
+
+## 4. 저장 기준
+
+### IP 식별
+
+프록시 환경에서는 다음 순서로 IP 후보를 읽습니다. 이 값은 신뢰 가능한 프록시 또는 배포 플랫폼이 설정한다는 전제에서만 rate limit 식별자로 사용합니다.
+
+1. `x-forwarded-for`의 첫 번째 IP
+2. `x-real-ip`
+3. `cf-connecting-ip`
+
+IP 원문은 저장하지 않고 `HMAC-SHA-256(ip, RATE_LIMIT_SALT)`처럼 서버 비밀키가 포함된 hash를 저장합니다. 운영 환경에서는 `COOKIE_PASSWORD` 재사용보다 별도 `RATE_LIMIT_SALT`를 설정하는 것을 기준으로 합니다.
+
+### Rate limit store
+
+초기 구현은 DB 기반 정책으로 시작합니다.
+
+- 인증/회원가입처럼 요청량이 낮고 정확한 카운트가 필요한 경로에 적합
+- `kind + keyHash` 단위 transaction advisory lock으로 check-and-record 경쟁을 직렬화
+- 서버 재시작, 다중 인스턴스에서도 상태가 유지됨
+- 만료된 로그는 요청 처리 중 지연 cleanup으로 삭제
+
+조회/검색/소켓처럼 요청량이 많은 경로는 Redis 또는 WAF 기반으로 분리하는 것이 안전합니다.
+
+## 5. 응답 기준
+
+제한에 걸린 요청은 가능한 한 다음 형태로 응답합니다.
+
+- HTTP Route Handler: `429 Too Many Requests`
+- Server Action: 현재는 사용자에게 보여줄 수 있는 일반 메시지를 반환하고, 필요 시 `retryAfterSeconds`를 UI에 연결
+- Header: 가능하면 `Retry-After`
+- 메시지: 내부 한도 수치를 그대로 노출하지 않고 “요청이 많습니다. 잠시 후 다시 시도해주세요.”처럼 안내
+
+권한이 없는 요청은 401/403으로 처리하고, rate limit 응답과 섞지 않습니다.
+
+## 6. BoardPort 적용 단계
+
+### 1차 보강
+
+- SMS 인증번호 TTL
+- SMS 재전송 쿨다운과 IP 기반 최소 발송 요청 제한
+- SMS IP hash 기준 시간당 발송 요청 상한
+- SMS 발송 실패와 동시 재요청 정합성 보강
+- 회원가입 IP hash 기반 단기 제출 제한
+- Cloudflare 이미지 upload URL 발급 세션 가드
+- 관련 단위 테스트와 정책 문서화
+
+### 2차 보강
+
+- 업로드 URL 발급 사용자별 횟수 제한
+- SMS 인증 실패 횟수 제한
+- 회원가입 IP hash 기반 성공 가입 장기 쿼터 검토
+- 신고 생성 사용자별 시간 제한
+- 댓글/1:1 채팅 작성 제한
+
+### 운영 단계
+
+- 검색/목록 조회 관찰 모드
+- WAF/Edge rate limit
+- 소켓 연결 동시성 제한
+- endpoint, actor, 429, retry-after, latency 로그 기반 임계값 조정
+
+## 7. 릴리즈 전 확인 포인트
+
+- 비용성 외부 API를 호출하는 경로에 서버 세션 또는 요청자 검증이 있는가?
+- SMS/메일/업로드 ticket처럼 반복 호출 가능한 경로에 최소 쿨다운이 있는가?
+- IP 기반 제한이 공유망 정상 사용자를 과도하게 막지 않는가?
+- IP 원문을 저장하지 않고 hash/salt 기준으로 다루는가?
+- 429 또는 action error가 클라이언트에서 복구 가능한 메시지로 노출되는가?
+- 권한 검증을 rate limit으로 대체하지 않았는가?
+- 운영 로그를 보고 임계값을 조정할 수 있는가?
+
+## 8. 함께 보는 문서
+
+- [권한 / 접근 제어 매트릭스](./access-control-matrix.md)
+- [보안 헤더 / CSP 운영 정책](./security-headers-csp-policy.md)
+- [테스트 전략](./testing-strategy.md)
diff --git a/docs/operations/testing-strategy.md b/docs/operations/testing-strategy.md
index 6281c274..7b957679 100644
--- a/docs/operations/testing-strategy.md
+++ b/docs/operations/testing-strategy.md
@@ -86,6 +86,7 @@ npm run install:e2e
DB 상태가 필요한 E2E는 `npm run seed:e2e`로 `[E2E]` prefix 기반 테스트 데이터를 먼저 준비한 뒤 실행합니다.
기본 Playwright 실행에서는 seed 기반 테스트를 skip하고, seed를 실행한 뒤 `E2E_SEEDED=1`을 지정했을 때만 함께 실행합니다.
seed 기반 테스트가 끝난 뒤에는 `npm run cleanup:e2e`로 `[E2E]` prefix 콘텐츠와 테스트 계정 알림을 정리합니다.
+특정 spec을 먼저 실행한 뒤 전체 suite를 다시 실행하는 것처럼 Playwright 실행을 나눌 때는 각 실행 전에 `npm run seed:e2e`를 다시 실행합니다. 약속 수락, 상품 수정, 팔로우 테스트는 seed 데이터를 실제로 변경하므로 실행 단위마다 기준 상태를 복원합니다.
```powershell
npm run seed:e2e
diff --git a/features/auth/actions/register.ts b/features/auth/actions/register.ts
index e8293dfe..7e287734 100644
--- a/features/auth/actions/register.ts
+++ b/features/auth/actions/register.ts
@@ -13,15 +13,22 @@
* 2026.01.30 임도헌 Moved app/(auth)/create-account/actions.ts -> features/auth/actions/register.ts
* 2026.04.04 임도헌 Modified 검증/에러 매핑/세션 저장 단계의 인라인 주석 보강
* 2026.05.16 임도헌 Modified 현재 actions 계층 역할에 맞게 파일 설명 정리
+ * 2026.06.27 임도헌 Modified IP hash 기반 회원가입 단기 제출 제한 추가
*/
"use server";
+import { headers } from "next/headers";
import {
createAccountSchema,
type CreateAccountSchema,
} from "@/features/auth/schemas/register";
+import { AUTH_ERRORS } from "@/features/auth/constants";
import { saveUserSession } from "@/features/auth/service/authSession";
import { resolvePostAuthRedirectPath } from "@/features/auth/service/onboarding";
+import {
+ checkAndRecordSignupAttemptByIp,
+ getClientIpFromHeaders,
+} from "@/features/auth/service/rateLimit";
import { createAccount } from "@/features/auth/service/register";
import { sanitizeCallbackUrl } from "@/features/auth/utils/redirect";
import type { ActionState } from "@/features/auth/types";
@@ -30,8 +37,9 @@ import type { ActionState } from "@/features/auth/types";
* 회원가입 폼 제출을 처리
*
* 1. Zod 스키마를 사용하여 입력값을 검증
- * 2. Service 계층을 호출하여 계정을 생성
- * 3. 생성된 유저 ID로 세션을 저장하여 자동 로그인 처리
+ * 2. IP hash 기반 단기 제출 제한을 확인
+ * 3. Service 계층을 호출하여 계정을 생성
+ * 4. 생성된 유저 ID로 세션을 저장하여 자동 로그인 처리
*
* @param {unknown} _prevState - 이전 상태
* @param {FormData} formData - 폼 데이터
@@ -63,7 +71,16 @@ export async function submitCreateAccount(
};
}
- // 2. 계정 생성 (Service)
+ // 2. IP hash 기반 단기 제출 제한
+ const signupLimit = await checkAndRecordSignupAttemptByIp(
+ getClientIpFromHeaders(headers())
+ );
+
+ if (!signupLimit.allowed) {
+ return { success: false, error: AUTH_ERRORS.SIGNUP_RATE_LIMITED };
+ }
+
+ // 3. 계정 생성 (Service)
const result = await createAccount(parsed.data);
if (!result.success) {
diff --git a/features/auth/actions/sms.ts b/features/auth/actions/sms.ts
index 1f7ccebc..54a78925 100644
--- a/features/auth/actions/sms.ts
+++ b/features/auth/actions/sms.ts
@@ -17,10 +17,13 @@
* 2026.01.30 임도헌 Moved app/(auth)/sms/actions.ts -> features/auth/actions/sms.ts
* 2026.04.04 임도헌 Modified 전화번호/SMS 토큰 검증과 세션 저장 단계의 인라인 주석 보강
* 2026.05.16 임도헌 Modified 현재 actions 계층 역할에 맞게 파일 설명 정리
+ * 2026.06.27 임도헌 Modified SMS 발송 시 IP hash 기반 발송 제한 컨텍스트 전달
*/
"use server";
+import { headers } from "next/headers";
import { phoneSchema, tokenSchema } from "@/features/auth/schemas/sms";
+import { getClientIpFromHeaders } from "@/features/auth/service/rateLimit";
import { saveUserSession } from "@/features/auth/service/authSession";
import { resolvePostAuthRedirectPath } from "@/features/auth/service/onboarding";
import {
@@ -51,7 +54,9 @@ export async function sendPhoneToken(
}
// 인증번호 생성 및 문자 발송 위임
- const serviceRes = await createAndSendSmsToken(result.data);
+ const serviceRes = await createAndSendSmsToken(result.data, {
+ clientIp: getClientIpFromHeaders(headers()),
+ });
if (!serviceRes.success) {
return { success: false, error: serviceRes.error };
}
diff --git a/features/auth/constants.ts b/features/auth/constants.ts
index 23e876af..897d79cf 100644
--- a/features/auth/constants.ts
+++ b/features/auth/constants.ts
@@ -11,6 +11,7 @@
* 2026.02.24 임도헌 Modified 카카오 로그인 관련 에러 메시지 추가
* 2026.04.02 임도헌 Modified 이메일 인증/비밀번호 재설정/인증 후 복귀 정책 상수 추가
* 2026.05.16 임도헌 Modified 타입 전용 import를 명시해 런타임 의존성 제거
+ * 2026.06.27 임도헌 Modified SMS 인증번호 TTL, 재전송 쿨다운, 인증 IP 제한 상수 추가
*/
import type { EmailVerifyState } from "@/features/auth/types";
@@ -48,6 +49,22 @@ export const POST_AUTH_BLOCKED_PREFIXES = [
/** SMS 자동 생성 유저명처럼 보완이 필요한 임시 닉네임 패턴 */
export const TEMP_USERNAME_REGEX = /^user_[0-9a-f]{8}$/i;
+/** SMS 인증 정책값 */
+/** SMS 재전송 쿨다운(초) */
+export const SMS_VERIFY_RESEND_COOLDOWN_SECONDS = 60;
+/** SMS 인증 토큰 유효 시간(ms) */
+export const SMS_VERIFY_TOKEN_TTL_MS = 10 * 60 * 1000;
+/** 같은 IP에서 허용하는 SMS 발송 요청 수 */
+export const SMS_SEND_IP_RATE_LIMIT_MAX = 10;
+/** IP 기준 SMS 발송 요청 제한 시간 창(ms) */
+export const SMS_SEND_IP_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
+
+/** 회원가입 제출 rate limit 정책값 */
+/** IP 기준 회원가입 제출 제한 시간 창(ms) */
+export const SIGNUP_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
+/** 같은 IP에서 허용하는 회원가입 제출 수 */
+export const SIGNUP_RATE_LIMIT_MAX = 10;
+
/** 인증 관련 에러 메시지 모음 */
export const AUTH_ERRORS = {
NOT_LOGGED_IN: "로그인이 필요합니다.",
@@ -68,6 +85,10 @@ export const AUTH_ERRORS = {
// SMS
SMS_SEND_FAILED: "SMS 발송에 실패했습니다. 잠시 후 다시 시도해주세요.",
SMS_VERIFY_FAILED: "인증번호가 일치하지 않거나 만료되었습니다.",
+ SMS_RATE_LIMITED: "인증번호를 방금 발송했습니다. 잠시 후 다시 시도해주세요.",
+
+ // Signup abuse control
+ SIGNUP_RATE_LIMITED: "요청이 많습니다. 잠시 후 다시 시도해주세요.",
// GitHub
GITHUB_TOKEN_FAILED: "GitHub 인증 토큰을 받아오지 못했습니다.",
diff --git a/features/auth/service/rateLimit.test.ts b/features/auth/service/rateLimit.test.ts
new file mode 100644
index 00000000..8b7398f7
--- /dev/null
+++ b/features/auth/service/rateLimit.test.ts
@@ -0,0 +1,154 @@
+/**
+ * File Name : features/auth/service/rateLimit.test.ts
+ * Description : 인증 rate limit 유틸 회귀 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.27 임도헌 Created 회원가입 IP hash 기반 단기 제출 제한 테스트 추가
+ * 2026.06.27 임도헌 Modified advisory lock 기반 원자적 기록 테스트 보강
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ SIGNUP_RATE_LIMIT_MAX,
+ SIGNUP_RATE_LIMIT_WINDOW_MS,
+ SMS_SEND_IP_RATE_LIMIT_MAX,
+ SMS_SEND_IP_RATE_LIMIT_WINDOW_MS,
+} from "@/features/auth/constants";
+
+const mocks = vi.hoisted(() => ({
+ db: {
+ $transaction: vi.fn(),
+ $executeRaw: vi.fn(),
+ authRateLimitEvent: {
+ deleteMany: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("server-only", () => ({}));
+
+vi.mock("@/lib/db", () => ({
+ default: mocks.db,
+}));
+
+describe("auth rate limit service", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubEnv("RATE_LIMIT_SALT", "rate-limit-secret");
+
+ mocks.db.$transaction.mockImplementation(async (callback) =>
+ callback(mocks.db)
+ );
+ mocks.db.$executeRaw.mockResolvedValue(0);
+ mocks.db.authRateLimitEvent.deleteMany.mockResolvedValue({ count: 0 });
+ mocks.db.authRateLimitEvent.findMany.mockResolvedValue([]);
+ mocks.db.authRateLimitEvent.create.mockResolvedValue({ id: "event-id" });
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("x-forwarded-for의 첫 번째 IP를 클라이언트 IP로 사용한다", async () => {
+ const { getClientIpFromHeaders } = await import("./rateLimit");
+ const headers = new Headers({
+ "x-forwarded-for": "203.0.113.10, 10.0.0.1",
+ "x-real-ip": "203.0.113.20",
+ });
+
+ expect(getClientIpFromHeaders(headers)).toBe("203.0.113.10");
+ });
+
+ it("회원가입 제출 제한 여유가 있으면 hash만 저장하고 허용한다", async () => {
+ const { checkAndRecordSignupAttemptByIp } = await import("./rateLimit");
+
+ const result = await checkAndRecordSignupAttemptByIp(
+ "203.0.113.10",
+ new Date("2026-06-27T00:00:00.000Z")
+ );
+
+ expect(result).toEqual({ allowed: true });
+ expect(mocks.db.$transaction).toHaveBeenCalledTimes(1);
+ expect(mocks.db.$executeRaw).toHaveBeenCalledTimes(1);
+ expect(mocks.db.authRateLimitEvent.create).toHaveBeenCalledWith({
+ data: {
+ kind: "signup-submit-ip",
+ keyHash: expect.stringMatching(/^[a-f0-9]{64}$/),
+ created_at: new Date("2026-06-27T00:00:00.000Z"),
+ },
+ });
+ expect(mocks.db.authRateLimitEvent.create.mock.calls[0][0].data.keyHash).not
+ .toBe("203.0.113.10");
+ });
+
+ it("회원가입 제출 제한을 초과하면 기록을 추가하지 않고 대기 시간을 반환한다", async () => {
+ const { checkAndRecordSignupAttemptByIp } = await import("./rateLimit");
+ const oldest = new Date("2026-06-27T00:00:30.000Z");
+
+ mocks.db.authRateLimitEvent.findMany.mockResolvedValue(
+ Array.from({ length: SIGNUP_RATE_LIMIT_MAX }, (_, index) => ({
+ created_at: new Date(oldest.getTime() + index * 1000),
+ }))
+ );
+
+ const result = await checkAndRecordSignupAttemptByIp(
+ "203.0.113.10",
+ new Date("2026-06-27T00:09:00.000Z")
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ retryAfterSeconds: Math.ceil(
+ (oldest.getTime() +
+ SIGNUP_RATE_LIMIT_WINDOW_MS -
+ new Date("2026-06-27T00:09:00.000Z").getTime()) /
+ 1000
+ ),
+ });
+ expect(mocks.db.authRateLimitEvent.create).not.toHaveBeenCalled();
+ });
+
+ it("SMS 발송 IP 제한을 초과하면 기록을 추가하지 않고 대기 시간을 반환한다", async () => {
+ const { checkAndRecordSmsSendAttemptByIp } = await import("./rateLimit");
+ const oldest = new Date("2026-06-27T00:10:00.000Z");
+
+ mocks.db.authRateLimitEvent.findMany.mockResolvedValue(
+ Array.from({ length: SMS_SEND_IP_RATE_LIMIT_MAX }, (_, index) => ({
+ created_at: new Date(oldest.getTime() + index * 1000),
+ }))
+ );
+
+ const result = await checkAndRecordSmsSendAttemptByIp(
+ "203.0.113.10",
+ new Date("2026-06-27T00:55:00.000Z")
+ );
+
+ expect(result).toEqual({
+ allowed: false,
+ retryAfterSeconds: Math.ceil(
+ (oldest.getTime() +
+ SMS_SEND_IP_RATE_LIMIT_WINDOW_MS -
+ new Date("2026-06-27T00:55:00.000Z").getTime()) /
+ 1000
+ ),
+ });
+ expect(mocks.db.authRateLimitEvent.create).not.toHaveBeenCalled();
+ });
+
+ it("오래된 이벤트 정리 실패는 현재 요청을 막지 않는다", async () => {
+ const { checkAndRecordSignupAttemptByIp } = await import("./rateLimit");
+
+ mocks.db.authRateLimitEvent.deleteMany.mockRejectedValue(
+ new Error("cleanup failed")
+ );
+
+ const result = await checkAndRecordSignupAttemptByIp("203.0.113.10");
+
+ expect(result).toEqual({ allowed: true });
+ expect(mocks.db.authRateLimitEvent.create).toHaveBeenCalled();
+ });
+});
diff --git a/features/auth/service/rateLimit.ts b/features/auth/service/rateLimit.ts
new file mode 100644
index 00000000..ca06565b
--- /dev/null
+++ b/features/auth/service/rateLimit.ts
@@ -0,0 +1,182 @@
+/**
+ * File Name : features/auth/service/rateLimit.ts
+ * Description : 인증 경로 남용 방지용 rate limit 유틸
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.27 임도헌 Created 회원가입 IP hash 기반 단기 제출 제한 추가
+ * 2026.06.27 임도헌 Modified SMS 발송 IP hash 기반 시간당 제한 추가
+ * 2026.06.27 임도헌 Modified kind/keyHash 단위 transaction advisory lock 적용
+ * 2026.06.29 임도헌 Modified stale event cleanup을 advisory lock transaction 밖으로 분리
+ */
+
+import "server-only";
+import crypto from "node:crypto";
+import db from "@/lib/db";
+import {
+ SIGNUP_RATE_LIMIT_MAX,
+ SIGNUP_RATE_LIMIT_WINDOW_MS,
+ SMS_SEND_IP_RATE_LIMIT_MAX,
+ SMS_SEND_IP_RATE_LIMIT_WINDOW_MS,
+} from "@/features/auth/constants";
+
+const SIGNUP_RATE_LIMIT_KIND = "signup-submit-ip";
+const SMS_SEND_RATE_LIMIT_KIND = "sms-send-ip";
+
+type AuthRateLimitResult =
+ | { allowed: true }
+ | { allowed: false; retryAfterSeconds: number };
+
+/**
+ * 요청 헤더에서 rate limit 식별용 클라이언트 IP 후보를 추출
+ *
+ * @param {Headers} headers - 현재 요청 헤더
+ * @returns {string | null} 식별 가능한 IP 후보
+ */
+export function getClientIpFromHeaders(headers: Headers): string | null {
+ const forwardedFor = headers
+ .get("x-forwarded-for")
+ ?.split(",")
+ .map((value) => value.trim())
+ .find(Boolean);
+
+ return (
+ forwardedFor ??
+ headers.get("x-real-ip")?.trim() ??
+ headers.get("cf-connecting-ip")?.trim() ??
+ null
+ );
+}
+
+/**
+ * rate limit 식별자를 저장용 HMAC hash로 변환
+ *
+ * @param {string} value - IP 등 rate limit 식별자 원문
+ * @returns {string | null} 저장 가능한 hash 값
+ */
+export function hashRateLimitKey(value: string): string | null {
+ const secret = process.env.RATE_LIMIT_SALT ?? process.env.COOKIE_PASSWORD;
+ if (!secret) return null;
+
+ return crypto.createHmac("sha256", secret).update(value).digest("hex");
+}
+
+/**
+ * 인증 rate limit 이벤트를 조회하고 허용 시 현재 요청을 기록
+ *
+ * @param {{ kind: string; key: string | null; limit: number; windowMs: number }} input - 정책 종류, 식별자, 제한 수, 시간 창
+ * @param {Date} now - 테스트와 계산 기준 시각
+ * @returns {Promise} 허용 여부와 제한 시 남은 대기 시간
+ */
+async function checkAndRecordAuthRateLimitEvent(
+ input: {
+ kind: string;
+ key: string | null;
+ limit: number;
+ windowMs: number;
+ },
+ now: Date = new Date()
+): Promise {
+ if (!input.key) return { allowed: true };
+
+ const keyHash = hashRateLimitKey(input.key);
+ if (!keyHash) return { allowed: true };
+
+ const windowStart = new Date(now.getTime() - input.windowMs);
+
+ try {
+ await db.authRateLimitEvent.deleteMany({
+ where: {
+ kind: input.kind,
+ created_at: { lt: windowStart },
+ },
+ });
+ } catch (error) {
+ console.warn("[auth rate limit] stale event cleanup failed:", error);
+ }
+
+ return db.$transaction(async (tx) => {
+ // PostgreSQL advisory lock은 애플리케이션이 정한 숫자 key로 잡는 DB 잠금이다.
+ // 여기서는 같은 kind/keyHash 요청만 한 줄로 세워, 동시에 limit을 통과하고
+ // 각각 기록되는 check-and-record 경쟁을 막는다.
+ await tx.$executeRaw`
+ SELECT pg_advisory_xact_lock(hashtext(${`${input.kind}:${keyHash}`}))
+ `;
+
+ const recentAttempts = await tx.authRateLimitEvent.findMany({
+ where: {
+ kind: input.kind,
+ keyHash,
+ created_at: { gte: windowStart },
+ },
+ orderBy: { created_at: "asc" },
+ select: { created_at: true },
+ });
+
+ if (recentAttempts.length >= input.limit) {
+ const resetAt =
+ recentAttempts[0].created_at.getTime() + input.windowMs;
+ const retryAfterSeconds = Math.max(
+ 1,
+ Math.ceil((resetAt - now.getTime()) / 1000)
+ );
+
+ return { allowed: false, retryAfterSeconds };
+ }
+
+ await tx.authRateLimitEvent.create({
+ data: {
+ kind: input.kind,
+ keyHash,
+ created_at: now,
+ },
+ });
+
+ return { allowed: true };
+ });
+}
+
+/**
+ * 회원가입 제출에 대한 IP 기준 단기 rate limit을 확인하고 기록
+ *
+ * @param {string | null} ip - 요청 IP 후보
+ * @param {Date} now - 테스트와 계산 기준 시각
+ * @returns {Promise} 허용 여부와 제한 시 남은 대기 시간
+ */
+export async function checkAndRecordSignupAttemptByIp(
+ ip: string | null,
+ now: Date = new Date()
+): Promise {
+ return checkAndRecordAuthRateLimitEvent(
+ {
+ kind: SIGNUP_RATE_LIMIT_KIND,
+ key: ip,
+ limit: SIGNUP_RATE_LIMIT_MAX,
+ windowMs: SIGNUP_RATE_LIMIT_WINDOW_MS,
+ },
+ now
+ );
+}
+
+/**
+ * SMS 인증 발송에 대한 IP 기준 rate limit을 확인하고 기록
+ *
+ * @param {string | null} ip - 요청 IP 후보
+ * @param {Date} now - 테스트와 계산 기준 시각
+ * @returns {Promise} 허용 여부와 제한 시 남은 대기 시간
+ */
+export async function checkAndRecordSmsSendAttemptByIp(
+ ip: string | null,
+ now: Date = new Date()
+): Promise {
+ return checkAndRecordAuthRateLimitEvent(
+ {
+ kind: SMS_SEND_RATE_LIMIT_KIND,
+ key: ip,
+ limit: SMS_SEND_IP_RATE_LIMIT_MAX,
+ windowMs: SMS_SEND_IP_RATE_LIMIT_WINDOW_MS,
+ },
+ now
+ );
+}
diff --git a/features/auth/service/sms.test.ts b/features/auth/service/sms.test.ts
new file mode 100644
index 00000000..d2eeddb3
--- /dev/null
+++ b/features/auth/service/sms.test.ts
@@ -0,0 +1,332 @@
+/**
+ * File Name : features/auth/service/sms.test.ts
+ * Description : SMS 인증 토큰 TTL/쿨다운 정합성 회귀 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.27 임도헌 Created SMS 만료/쿨다운/발송 실패 롤백 테스트 추가
+ * 2026.06.29 임도헌 Modified SMS 인증 전 User.phone 점유, userId 잔존, 목적 혼용 방지 테스트 추가
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AUTH_ERRORS } from "@/features/auth/constants";
+
+const mocks = vi.hoisted(() => ({
+ db: {
+ sMSToken: {
+ deleteMany: vi.fn(),
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ updateMany: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ user: {
+ update: vi.fn(),
+ upsert: vi.fn(),
+ },
+ },
+ sendSMS: vi.fn(),
+ generateUniqueSmsToken: vi.fn(),
+ checkAndRecordSmsSendAttemptByIp: vi.fn(),
+}));
+
+vi.mock("server-only", () => ({}));
+
+vi.mock("@/lib/db", () => ({
+ default: mocks.db,
+}));
+
+vi.mock("@/features/auth/utils/smsSender", () => ({
+ sendSMS: mocks.sendSMS,
+}));
+
+vi.mock("@/features/auth/service/token", () => ({
+ generateUniqueSmsToken: mocks.generateUniqueSmsToken,
+}));
+
+vi.mock("@/features/auth/service/rateLimit", () => ({
+ checkAndRecordSmsSendAttemptByIp: mocks.checkAndRecordSmsSendAttemptByIp,
+}));
+
+describe("SMS verification service", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-06-27T00:00:00.000Z"));
+ vi.clearAllMocks();
+
+ mocks.db.sMSToken.deleteMany.mockResolvedValue({ count: 0 });
+ mocks.db.sMSToken.create.mockResolvedValue({ id: 1 });
+ mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 1 });
+ mocks.db.sMSToken.delete.mockResolvedValue({ id: 1 });
+ mocks.db.user.upsert.mockResolvedValue({
+ id: 10,
+ phone: "01012345678",
+ bannedAt: null,
+ bannedUntil: null,
+ });
+ mocks.sendSMS.mockResolvedValue(undefined);
+ mocks.generateUniqueSmsToken.mockResolvedValue("654321");
+ mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({
+ allowed: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("재전송 쿨다운 안에서는 새 SMS를 발송하지 않는다", async () => {
+ const { createAndSendSmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue({
+ id: 1,
+ token: "123456",
+ phone: "01012345678",
+ userId: 10,
+ created_at: new Date("2026-06-27T00:00:00.000Z"),
+ expires_at: new Date("2026-06-27T00:10:00.000Z"),
+ });
+
+ const result = await createAndSendSmsToken("01012345678");
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ code: "SMS_RATE_LIMITED",
+ });
+ expect(mocks.generateUniqueSmsToken).not.toHaveBeenCalled();
+ expect(mocks.sendSMS).not.toHaveBeenCalled();
+ });
+
+ it("동시 재요청으로 발송 슬롯을 확보하지 못하면 SMS를 발송하지 않는다", async () => {
+ const { createAndSendSmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue({
+ id: 1,
+ token: "123456",
+ phone: "01012345678",
+ userId: 10,
+ created_at: new Date("2026-06-26T23:58:00.000Z"),
+ expires_at: new Date("2026-06-27T00:08:00.000Z"),
+ });
+ mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 0 });
+
+ const result = await createAndSendSmsToken("01012345678");
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ code: "SMS_RATE_LIMITED",
+ });
+ expect(mocks.sendSMS).not.toHaveBeenCalled();
+ });
+
+ it("IP 기준 SMS 발송 제한에 걸리면 새 토큰을 만들지 않는다", async () => {
+ const { createAndSendSmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue(null);
+ mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({
+ allowed: false,
+ retryAfterSeconds: 120,
+ });
+
+ const result = await createAndSendSmsToken("01012345678", {
+ clientIp: "203.0.113.10",
+ });
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ code: "SMS_RATE_LIMITED",
+ });
+ expect(mocks.generateUniqueSmsToken).not.toHaveBeenCalled();
+ expect(mocks.db.sMSToken.create).not.toHaveBeenCalled();
+ expect(mocks.sendSMS).not.toHaveBeenCalled();
+ });
+
+ it("최초 SMS 발송은 인증 전 User를 만들지 않고 토큰만 저장한다", async () => {
+ const { createAndSendSmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue(null);
+
+ const result = await createAndSendSmsToken("01012345678");
+
+ expect(result).toEqual({ success: true });
+ expect(mocks.db.sMSToken.create).toHaveBeenCalledWith({
+ data: {
+ token: "654321",
+ phone: "01012345678",
+ expires_at: new Date("2026-06-27T00:10:00.000Z"),
+ },
+ });
+ expect(mocks.db.user.upsert).not.toHaveBeenCalled();
+ });
+
+ it("기존 프로필 인증 토큰을 로그인 SMS로 갱신할 때 userId 연결을 끊는다", async () => {
+ const { createAndSendSmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue({
+ id: 1,
+ token: "123456",
+ phone: "01012345678",
+ userId: 10,
+ created_at: new Date("2026-06-26T23:58:00.000Z"),
+ expires_at: new Date("2026-06-27T00:08:00.000Z"),
+ });
+
+ const result = await createAndSendSmsToken("01012345678");
+
+ expect(result).toEqual({ success: true });
+ expect(mocks.db.sMSToken.updateMany).toHaveBeenCalledWith({
+ where: {
+ id: 1,
+ created_at: { lte: new Date("2026-06-26T23:59:00.000Z") },
+ },
+ data: {
+ token: "654321",
+ phone: "01012345678",
+ userId: null,
+ created_at: new Date("2026-06-27T00:00:00.000Z"),
+ expires_at: new Date("2026-06-27T00:10:00.000Z"),
+ },
+ });
+ });
+
+ it("SMS 발송 실패 시 이전 유효 토큰을 복구한다", async () => {
+ const { createAndSendSmsToken } = await import("./sms");
+
+ const previous = {
+ id: 1,
+ token: "123456",
+ phone: "01012345678",
+ userId: 10,
+ created_at: new Date("2026-06-26T23:58:00.000Z"),
+ expires_at: new Date("2026-06-27T00:08:00.000Z"),
+ };
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue(previous);
+ mocks.sendSMS.mockRejectedValue(new Error("provider failed"));
+
+ const result = await createAndSendSmsToken("01012345678");
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_SEND_FAILED,
+ code: "SMS_SEND_FAILED",
+ });
+ expect(mocks.db.sMSToken.updateMany).toHaveBeenLastCalledWith({
+ where: {
+ id: previous.id,
+ token: "654321",
+ created_at: new Date("2026-06-27T00:00:00.000Z"),
+ },
+ data: {
+ token: previous.token,
+ phone: previous.phone,
+ userId: previous.userId,
+ created_at: previous.created_at,
+ expires_at: previous.expires_at,
+ },
+ });
+ });
+
+ it("최초 SMS 발송 실패 시 새로 만든 토큰을 정리한다", async () => {
+ const { createAndSendSmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue(null);
+ mocks.sendSMS.mockRejectedValue(new Error("provider failed"));
+
+ const result = await createAndSendSmsToken("01012345678");
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_SEND_FAILED,
+ code: "SMS_SEND_FAILED",
+ });
+ expect(mocks.db.sMSToken.deleteMany).toHaveBeenLastCalledWith({
+ where: { phone: "01012345678", token: "654321" },
+ });
+ });
+
+ it("만료된 SMS 인증번호는 검증을 거부하고 정리한다", async () => {
+ const { verifySmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue({
+ id: 1,
+ userId: 10,
+ phone: "01012345678",
+ expires_at: new Date("2026-06-26T23:59:59.000Z"),
+ user: {
+ id: 10,
+ bannedAt: null,
+ bannedUntil: null,
+ },
+ });
+
+ const result = await verifySmsToken("01012345678", "123456");
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_VERIFY_FAILED,
+ });
+ expect(mocks.db.sMSToken.delete).toHaveBeenCalledWith({
+ where: { id: 1 },
+ });
+ });
+
+ it("SMS 인증 성공 시 전화번호 기준 User를 찾거나 생성한 뒤 로그인 ID를 반환한다", async () => {
+ const { verifySmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue({
+ id: 1,
+ userId: null,
+ phone: "01012345678",
+ expires_at: new Date("2026-06-27T00:10:00.000Z"),
+ user: null,
+ });
+
+ const result = await verifySmsToken("01012345678", "123456");
+
+ expect(mocks.db.user.upsert).toHaveBeenCalledWith({
+ where: { phone: "01012345678" },
+ update: {},
+ create: {
+ username: expect.stringMatching(/^user_[0-9a-f]{8}$/),
+ phone: "01012345678",
+ },
+ select: { id: true, phone: true, bannedAt: true, bannedUntil: true },
+ });
+ expect(mocks.db.sMSToken.delete).toHaveBeenCalledWith({
+ where: { id: 1 },
+ });
+ expect(result).toEqual({ success: true, data: { userId: 10 } });
+ });
+
+ it("프로필 인증 토큰은 SMS 로그인 검증에서 소비하지 않는다", async () => {
+ const { verifySmsToken } = await import("./sms");
+
+ mocks.db.sMSToken.findUnique.mockResolvedValue({
+ id: 1,
+ userId: 20,
+ phone: "01012345678",
+ expires_at: new Date("2026-06-27T00:10:00.000Z"),
+ user: {
+ id: 20,
+ phone: "01012345678",
+ bannedAt: null,
+ bannedUntil: null,
+ },
+ });
+
+ const result = await verifySmsToken("01012345678", "123456");
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_VERIFY_FAILED,
+ });
+ expect(mocks.db.user.upsert).not.toHaveBeenCalled();
+ expect(mocks.db.sMSToken.delete).not.toHaveBeenCalled();
+ });
+});
diff --git a/features/auth/service/sms.ts b/features/auth/service/sms.ts
index 4d12e99b..27bc1edf 100644
--- a/features/auth/service/sms.ts
+++ b/features/auth/service/sms.ts
@@ -10,59 +10,162 @@
* 2026.01.25 임도헌 Modified 주석 보강
* 2026.02.08 임도헌 Modified 로그인 시 정지(Ban) 체크 및 만료 시 자동 해제 로직 추가
* 2026.04.04 임도헌 Modified SMS 토큰 발급/소모 단계의 인라인 주석 보강
+ * 2026.06.27 임도헌 Modified SMS 토큰 TTL, 재전송/IP 쿨다운, 발송 실패 롤백 처리 추가
+ * 2026.06.29 임도헌 Modified SMS 로그인 토큰의 인증 전 User 생성 방지, userId 잔존, 목적 혼용 방지
*/
import "server-only";
import crypto from "crypto";
import { sendSMS } from "@/features/auth/utils/smsSender";
import { generateUniqueSmsToken } from "@/features/auth/service/token";
-import { AUTH_ERRORS } from "@/features/auth/constants";
+import { checkAndRecordSmsSendAttemptByIp } from "@/features/auth/service/rateLimit";
+import {
+ AUTH_ERRORS,
+ SMS_VERIFY_RESEND_COOLDOWN_SECONDS,
+ SMS_VERIFY_TOKEN_TTL_MS,
+} from "@/features/auth/constants";
import db from "@/lib/db";
+import { isUniqueConstraintError } from "@/lib/errors";
import type { ServiceResult } from "@/lib/types";
+type SmsSendFailureCode = "SMS_SEND_FAILED" | "SMS_RATE_LIMITED";
+
/**
* 전화번호로 인증 토큰 생성 및 SMS 발송을 수행
- * 기존 토큰이 있다면 삭제하고 새로 생성
+ * 기존 유효 토큰이 있다면 쿨다운을 확인하고, 발송 실패 시 이전 토큰 상태를 복구
*
* @param {string} phone - 검증된 전화번호 (하이픈 없는 숫자)
- * @returns {Promise} 성공 여부
+ * @param {{ clientIp?: string | null }} [options] - SMS IP rate limit 계산에 사용할 요청 컨텍스트
+ * @returns {Promise>} 성공 여부
*/
export async function createAndSendSmsToken(
- phone: string
-): Promise> {
- try {
- // 중복 없는 6자리 인증 토큰 생성
- const token = await generateUniqueSmsToken();
+ phone: string,
+ options: { clientIp?: string | null } = {}
+): Promise> {
+ const now = new Date();
+ const cooldownCutoff = new Date(
+ now.getTime() - SMS_VERIFY_RESEND_COOLDOWN_SECONDS * 1000
+ );
- // 같은 번호의 기존 미사용 토큰 정리
+ try {
await db.sMSToken.deleteMany({
- where: { user: { phone } },
+ where: { expires_at: { lt: now } },
});
- // 토큰 저장 및 phone 기준 임시 계정 연결
- await db.sMSToken.create({
- data: {
- token,
- phone,
- user: {
- connectOrCreate: {
- where: { phone },
- create: {
- username: `user_${crypto.randomBytes(4).toString("hex")}`,
- phone,
- },
- },
- },
+ const previousToken = await db.sMSToken.findUnique({
+ where: { phone },
+ select: {
+ id: true,
+ token: true,
+ phone: true,
+ userId: true,
+ created_at: true,
+ expires_at: true,
},
});
+ if (previousToken && previousToken.created_at > cooldownCutoff) {
+ return {
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ code: "SMS_RATE_LIMITED",
+ };
+ }
+
+ const ipLimit = await checkAndRecordSmsSendAttemptByIp(
+ options.clientIp ?? null
+ );
+ if (!ipLimit.allowed) {
+ return {
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ code: "SMS_RATE_LIMITED",
+ };
+ }
+
+ // 중복 없는 6자리 인증 토큰 생성
+ const token = await generateUniqueSmsToken();
+ const expiresAt = new Date(now.getTime() + SMS_VERIFY_TOKEN_TTL_MS);
+ let createdNewToken = false;
+
+ if (previousToken) {
+ const updateResult = await db.sMSToken.updateMany({
+ where: {
+ id: previousToken.id,
+ created_at: { lte: cooldownCutoff },
+ },
+ data: {
+ token,
+ phone,
+ userId: null,
+ created_at: now,
+ expires_at: expiresAt,
+ },
+ });
+
+ if (updateResult.count === 0) {
+ return {
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ code: "SMS_RATE_LIMITED",
+ };
+ }
+ } else {
+ // 인증 전에는 User.phone을 점유하지 않고 발송 토큰만 저장
+ await db.sMSToken.create({
+ data: {
+ token,
+ phone,
+ expires_at: expiresAt,
+ },
+ });
+ createdNewToken = true;
+ }
+
// 토큰 저장 후 실제 SMS 발송
- await sendSMS(phone, token);
+ try {
+ await sendSMS(phone, token);
+ } catch (error) {
+ if (previousToken) {
+ await db.sMSToken.updateMany({
+ where: {
+ id: previousToken.id,
+ token,
+ created_at: now,
+ },
+ data: {
+ token: previousToken.token,
+ phone: previousToken.phone,
+ userId: previousToken.userId,
+ created_at: previousToken.created_at,
+ expires_at: previousToken.expires_at,
+ },
+ });
+ } else if (createdNewToken) {
+ await db.sMSToken.deleteMany({
+ where: { phone, token },
+ });
+ }
+
+ throw error;
+ }
return { success: true };
} catch (error) {
+ if (isUniqueConstraintError(error, ["phone"])) {
+ return {
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ code: "SMS_RATE_LIMITED",
+ };
+ }
+
console.error("SMS Send Error:", error);
- return { success: false, error: AUTH_ERRORS.SMS_SEND_FAILED };
+ return {
+ success: false,
+ error: AUTH_ERRORS.SMS_SEND_FAILED,
+ code: "SMS_SEND_FAILED",
+ };
}
}
@@ -84,8 +187,9 @@ export async function verifySmsToken(
id: true,
userId: true,
phone: true,
+ expires_at: true,
user: {
- select: { id: true, bannedAt: true, bannedUntil: true },
+ select: { id: true, phone: true, bannedAt: true, bannedUntil: true },
},
},
});
@@ -95,7 +199,27 @@ export async function verifySmsToken(
return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED };
}
- const user = verifiedToken.user;
+ if (verifiedToken.expires_at < new Date()) {
+ await db.sMSToken.delete({ where: { id: verifiedToken.id } });
+ return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED };
+ }
+
+ // 프로필 전화번호 변경용 토큰은 로그인 검증에서 소비하지 않음
+ if (verifiedToken.userId !== null) {
+ return { success: false, error: AUTH_ERRORS.SMS_VERIFY_FAILED };
+ }
+
+ const user =
+ verifiedToken.user ??
+ (await db.user.upsert({
+ where: { phone },
+ update: {},
+ create: {
+ username: `user_${crypto.randomBytes(4).toString("hex")}`,
+ phone,
+ },
+ select: { id: true, phone: true, bannedAt: true, bannedUntil: true },
+ }));
// 정지 상태 확인 및 만료 시 지연 해제
if (user.bannedAt) {
@@ -118,5 +242,5 @@ export async function verifySmsToken(
// 검증 성공 후 토큰 1회 소모
await db.sMSToken.delete({ where: { id: verifiedToken.id } });
- return { success: true, data: { userId: verifiedToken.userId } };
+ return { success: true, data: { userId: user.id } };
}
diff --git a/features/stream/actions/list.ts b/features/stream/actions/list.ts
index f3bdd0b0..64a76bb2 100644
--- a/features/stream/actions/list.ts
+++ b/features/stream/actions/list.ts
@@ -21,6 +21,7 @@
* 2026.05.08 임도헌 Modified 목록 응답 타입과 조회 범위 타입을 features/stream/types.ts로 이동
* 2026.05.15 임도헌 Modified 유저 채널 다시보기 무한스크롤 액션 추가
* 2026.05.18 임도헌 Modified 채널 다시보기 추가 페이지에서도 현재 사용자 좋아요 여부를 유지하도록 viewerId 전달
+ * 2026.06.25 임도헌 Modified 목록 액션의 조회자 권한 판단을 서버 세션 기준으로 고정
*/
"use server";
@@ -90,17 +91,15 @@ function applyChannelVodAccess(
* @param {StreamScope} scope - 조회 범위 ("all" | "following")
* @param {number | null} cursor - 이전 페이지의 마지막 방송 ID
* @param {Record} searchParams - 카테고리 및 키워드 필터 조건
- * @param {number | null} viewerId - 조회자 ID (팔로잉 목록 확인용)
* @returns {Promise} 평탄화된 방송 목록과 페이징 커서 반환
*/
export async function getStreamsListAction(
scope: StreamScope,
cursor: number | null,
- searchParams: Record,
- viewerId: number | null
+ searchParams: Record
): Promise {
const session = await getSession();
- const userId = session?.id ?? viewerId;
+ const userId = session?.id ?? null;
if (!userId) return { streams: [], nextCursor: null };
@@ -127,16 +126,16 @@ export async function getStreamsListAction(
* - 정렬 기준(latest/popular)과 팔로잉 전용 필터를 service 계층에 위임
* - 카테고리/키워드 검색 파라미터를 공백 정규화 후 전달
* - 무한 스크롤용 recordings 배열과 다음 커서(nextCursor)를 반환
+ * - 조회자 권한 판단은 서버 세션만 신뢰
*/
export async function getRecordingsListAction(
sort: "latest" | "popular",
followingOnly: boolean,
cursor: number | null,
- searchParams: Record,
- viewerId: number | null
+ searchParams: Record
): Promise {
const session = await getSession();
- const userId = session?.id ?? viewerId;
+ const userId = session?.id ?? null;
if (!userId) return { recordings: [], nextCursor: null };
diff --git a/features/stream/hooks/useRecordingPagination.ts b/features/stream/hooks/useRecordingPagination.ts
index 6f456d18..addee129 100644
--- a/features/stream/hooks/useRecordingPagination.ts
+++ b/features/stream/hooks/useRecordingPagination.ts
@@ -10,6 +10,7 @@
* 2026.03.31 임도헌 Modified export 훅 역할과 반환값이 바로 보이도록 JSDoc 보강
* 2026.04.02 임도헌 Modified 다시보기 페이징 훅 파라미터/반환 타입 설명 보강
* 2026.05.19 임도헌 Modified Client queryFn 초기 렌더의 조회용 Server Action 호출 오류를 피하도록 Route Handler fetch로 전환
+ * 2026.06.25 임도헌 Modified viewerId URL 전달 제거 및 조회자/팔로잉 필터별 query key 스코프 분리
*/
"use client";
@@ -42,9 +43,10 @@ function buildRecordingsApiUrl({
sort,
followingOnly,
searchParams,
- viewerId,
cursor,
-}: UseRecordingPaginationParams & { cursor: number | null }): string {
+}: Omit & {
+ cursor: number | null;
+}): string {
const params = new URLSearchParams({ sort });
if (followingOnly) {
@@ -55,10 +57,6 @@ function buildRecordingsApiUrl({
params.set("cursor", String(cursor));
}
- if (viewerId !== undefined && viewerId !== null) {
- params.set("viewerId", String(viewerId));
- }
-
const category = searchParams.category;
const keyword = searchParams.keyword;
@@ -108,7 +106,11 @@ export function useRecordingPagination({
viewerId,
}: UseRecordingPaginationParams): UseRecordingPaginationResult {
// 정렬/검색 조건이 바뀌면 목록 캐시도 별도 스코프로 분리
- const queryKey = queryKeys.streams.recordingList(sort, searchParams);
+ const queryKey = queryKeys.streams.recordingList(sort, {
+ ...searchParams,
+ followingOnly,
+ viewerId: viewerId ?? "guest",
+ });
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSuspenseInfiniteQuery({
@@ -120,7 +122,6 @@ export function useRecordingPagination({
sort,
followingOnly,
searchParams,
- viewerId,
cursor: pageParam as number | null,
})
);
diff --git a/features/stream/hooks/useStreamPagination.ts b/features/stream/hooks/useStreamPagination.ts
index e173280f..c5f897ca 100644
--- a/features/stream/hooks/useStreamPagination.ts
+++ b/features/stream/hooks/useStreamPagination.ts
@@ -13,6 +13,7 @@
* 2026.04.17 임도헌 Modified 현재 훅이 담당하는 범위가 무한 스크롤 페이징 중심으로 읽히도록 설명을 최신화
* 2026.05.08 임도헌 Modified 스트림 조회 범위 타입을 StreamScope 공용 타입으로 교체
* 2026.05.19 임도헌 Modified Client queryFn 초기 렌더의 조회용 Server Action 호출 오류를 피하도록 Route Handler fetch로 전환
+ * 2026.06.25 임도헌 Modified viewerId URL 전달 제거 및 조회자별 query key 스코프 분리
*/
"use client";
@@ -47,19 +48,16 @@ export interface UseStreamPaginationResult {
function buildStreamsApiUrl({
scope,
searchParams,
- viewerId,
cursor,
-}: UseStreamPaginationParams & { cursor: number | null }): string {
+}: Omit & {
+ cursor: number | null;
+}): string {
const params = new URLSearchParams({ scope });
if (cursor !== null) {
params.set("cursor", String(cursor));
}
- if (viewerId !== undefined && viewerId !== null) {
- params.set("viewerId", String(viewerId));
- }
-
const category = searchParams.category;
const keyword = searchParams.keyword;
@@ -107,7 +105,10 @@ export function useStreamPagination({
searchParams,
viewerId,
}: UseStreamPaginationParams): UseStreamPaginationResult {
- const queryKey = queryKeys.streams.list(scope, searchParams);
+ const queryKey = queryKeys.streams.list(scope, {
+ ...searchParams,
+ viewerId: viewerId ?? "guest",
+ });
// TanStack Query를 활용한 무한 스크롤 쿼리 구성
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
@@ -119,7 +120,6 @@ export function useStreamPagination({
buildStreamsApiUrl({
scope,
searchParams,
- viewerId,
cursor: pageParam as number | null,
})
);
diff --git a/features/stream/utils/webhookAuth.test.ts b/features/stream/utils/webhookAuth.test.ts
new file mode 100644
index 00000000..3d4a32d3
--- /dev/null
+++ b/features/stream/utils/webhookAuth.test.ts
@@ -0,0 +1,55 @@
+/**
+ * File Name : features/stream/utils/webhookAuth.test.ts
+ * Description : Cloudflare Webhook 인증 정책 회귀 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.25 임도헌 Created production secret 누락 fail-closed 정책 테스트 추가
+ */
+
+import { describe, expect, it } from "vitest";
+import { isMissingRequiredCloudflareWebhookSecret } from "./webhookAuth";
+
+describe("isMissingRequiredCloudflareWebhookSecret", () => {
+ it("production Stream webhook에서 HMAC secret이 없으면 누락으로 판단한다", () => {
+ expect(
+ isMissingRequiredCloudflareWebhookSecret({
+ kind: "stream",
+ streamSecret: "",
+ destinationSecret: "destination-secret",
+ nodeEnv: "production",
+ })
+ ).toBe(true);
+ });
+
+ it("production Destination webhook에서 destination secret이 없으면 누락으로 판단한다", () => {
+ expect(
+ isMissingRequiredCloudflareWebhookSecret({
+ kind: "destination",
+ streamSecret: "stream-secret",
+ destinationSecret: "",
+ nodeEnv: "production",
+ })
+ ).toBe(true);
+ });
+
+ it("secret이 설정되어 있거나 production이 아니면 누락으로 판단하지 않는다", () => {
+ expect(
+ isMissingRequiredCloudflareWebhookSecret({
+ kind: "stream",
+ streamSecret: "stream-secret",
+ destinationSecret: "",
+ nodeEnv: "production",
+ })
+ ).toBe(false);
+ expect(
+ isMissingRequiredCloudflareWebhookSecret({
+ kind: "destination",
+ streamSecret: "",
+ destinationSecret: "",
+ nodeEnv: "test",
+ })
+ ).toBe(false);
+ });
+});
diff --git a/features/stream/utils/webhookAuth.ts b/features/stream/utils/webhookAuth.ts
new file mode 100644
index 00000000..821ba88e
--- /dev/null
+++ b/features/stream/utils/webhookAuth.ts
@@ -0,0 +1,36 @@
+/**
+ * File Name : features/stream/utils/webhookAuth.ts
+ * Description : Cloudflare Webhook 인증 정책 유틸
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.25 임도헌 Created 웹훅 secret 누락 시 production fail-closed 정책 분리
+ */
+
+export type CloudflareWebhookKind = "stream" | "destination";
+
+/**
+ * 운영 환경에서는 실제 상태 변경 웹훅에 필요한 secret이 비어 있으면 요청을 거부한다.
+ *
+ * @param params.kind - Stream signature 웹훅인지 Destination header 웹훅인지
+ * @param params.streamSecret - Stream HMAC 검증용 secret
+ * @param params.destinationSecret - Destination header 검증용 secret
+ * @param params.nodeEnv - 현재 Node 환경
+ * @returns production에서 해당 웹훅 secret이 필수인데 누락되었는지 여부
+ */
+export function isMissingRequiredCloudflareWebhookSecret({
+ kind,
+ streamSecret,
+ destinationSecret,
+ nodeEnv = process.env.NODE_ENV,
+}: {
+ kind: CloudflareWebhookKind;
+ streamSecret: string;
+ destinationSecret: string;
+ nodeEnv?: string;
+}) {
+ if (nodeEnv !== "production") return false;
+
+ return kind === "stream" ? !streamSecret : !destinationSecret;
+}
diff --git a/features/user/actions/phone.ts b/features/user/actions/phone.ts
index 7ac5c101..3032af09 100644
--- a/features/user/actions/phone.ts
+++ b/features/user/actions/phone.ts
@@ -6,11 +6,14 @@
* History
* Date Author Status Description
* 2026.01.24 임도헌 Created Action 정의
+ * 2026.06.27 임도헌 Modified 프로필 SMS 발송에 IP rate limit 컨텍스트 전달
*/
"use server";
+import { headers } from "next/headers";
import getSession from "@/lib/session";
import { phoneSchema, tokenSchema } from "@/features/auth/schemas/sms";
+import { getClientIpFromHeaders } from "@/features/auth/service/rateLimit";
import {
sendProfilePhoneTokenService,
verifyProfilePhoneTokenService,
@@ -31,7 +34,9 @@ export async function sendProfilePhoneTokenAction(formData: FormData) {
}
// 2. Service 호출 (중복 확인 및 SMS 발송)
- return await sendProfilePhoneTokenService(session.id, parsed.data);
+ return await sendProfilePhoneTokenService(session.id, parsed.data, {
+ clientIp: getClientIpFromHeaders(headers()),
+ });
}
/**
diff --git a/features/user/hooks/useFollowToggle.ts b/features/user/hooks/useFollowToggle.ts
index f2738e74..a6451b5f 100644
--- a/features/user/hooks/useFollowToggle.ts
+++ b/features/user/hooks/useFollowToggle.ts
@@ -25,6 +25,7 @@
* 2026.05.08 임도헌 Modified 팔로우 액션 결과 타입 import 경로를 user types로 정리
* 2026.05.16 임도헌 Modified 팔로우/스트림 캐시 갱신 타입을 명시해 any 캐스팅 제거
* 2026.06.17 임도헌 Modified 서버 성공 후 팔로워 전용 접근 상태를 동기화하는 책임을 주석에 명확히 반영
+ * 2026.06.25 임도헌 Modified 기본 팔로잉 스트림 seed key를 조회자별 캐시 스코프와 일치하도록 정리
*/
"use client";
@@ -211,10 +212,18 @@ export function useFollowToggle() {
);
}
- if (res.isFollowing && targetUserStreams.size > 0) {
+ if (
+ res.isFollowing &&
+ targetUserStreams.size > 0 &&
+ opts?.viewerId != null
+ ) {
+ // 기본 팔로잉 탭 seed도 실제 스트림 목록과 같은 조회자별 query key에만 기록한다.
const defaultFollowingKey = queryKeys.streams.list(
"following",
- DEFAULT_STREAM_LIST_FILTERS
+ {
+ ...DEFAULT_STREAM_LIST_FILTERS,
+ viewerId: opts.viewerId,
+ }
);
queryClient.setQueryData>(
diff --git a/features/user/service/phone.test.ts b/features/user/service/phone.test.ts
new file mode 100644
index 00000000..0d4d3858
--- /dev/null
+++ b/features/user/service/phone.test.ts
@@ -0,0 +1,194 @@
+/**
+ * File Name : features/user/service/phone.test.ts
+ * Description : 프로필 휴대폰 인증 SMS 보호 회귀 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.27 임도헌 Created 프로필 SMS 쿨다운/IP 제한/발송 실패 롤백 테스트 추가
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AUTH_ERRORS } from "@/features/auth/constants";
+
+const mocks = vi.hoisted(() => ({
+ db: {
+ sMSToken: {
+ deleteMany: vi.fn(),
+ findFirst: vi.fn(),
+ create: vi.fn(),
+ updateMany: vi.fn(),
+ delete: vi.fn(),
+ },
+ user: {
+ findFirst: vi.fn(),
+ update: vi.fn(),
+ },
+ $transaction: vi.fn(),
+ },
+ sendSMS: vi.fn(),
+ generateUniqueSmsToken: vi.fn(),
+ checkAndRecordSmsSendAttemptByIp: vi.fn(),
+ validateUserStatus: vi.fn(),
+ onVerificationUpdate: vi.fn(),
+ revalidatePath: vi.fn(),
+}));
+
+vi.mock("server-only", () => ({}));
+
+vi.mock("next/cache", () => ({
+ revalidatePath: mocks.revalidatePath,
+}));
+
+vi.mock("@/lib/db", () => ({
+ default: mocks.db,
+}));
+
+vi.mock("@/features/auth/utils/smsSender", () => ({
+ sendSMS: mocks.sendSMS,
+}));
+
+vi.mock("@/features/auth/service/token", () => ({
+ generateUniqueSmsToken: mocks.generateUniqueSmsToken,
+}));
+
+vi.mock("@/features/auth/service/rateLimit", () => ({
+ checkAndRecordSmsSendAttemptByIp: mocks.checkAndRecordSmsSendAttemptByIp,
+}));
+
+vi.mock("@/features/user/service/admin", () => ({
+ validateUserStatus: mocks.validateUserStatus,
+}));
+
+vi.mock("./badge", () => ({
+ badgeChecks: {
+ onVerificationUpdate: mocks.onVerificationUpdate,
+ },
+}));
+
+describe("profile phone verification service", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-06-27T00:00:00.000Z"));
+ vi.clearAllMocks();
+
+ mocks.validateUserStatus.mockResolvedValue({ success: true });
+ mocks.db.user.findFirst.mockResolvedValue(null);
+ mocks.db.sMSToken.deleteMany.mockResolvedValue({ count: 0 });
+ mocks.db.sMSToken.findFirst.mockResolvedValue(null);
+ mocks.db.sMSToken.create.mockResolvedValue({ id: 1 });
+ mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 1 });
+ mocks.sendSMS.mockResolvedValue(undefined);
+ mocks.generateUniqueSmsToken.mockResolvedValue("654321");
+ mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({
+ allowed: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("재전송 쿨다운 안에서는 프로필 SMS를 발송하지 않는다", async () => {
+ const { sendProfilePhoneTokenService } = await import("./phone");
+
+ mocks.db.sMSToken.findFirst.mockResolvedValue({
+ id: 1,
+ token: "123456",
+ phone: "01012345678",
+ userId: 10,
+ created_at: new Date("2026-06-27T00:00:00.000Z"),
+ expires_at: new Date("2026-06-27T00:10:00.000Z"),
+ });
+
+ const result = await sendProfilePhoneTokenService(10, "01012345678");
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ });
+ expect(mocks.generateUniqueSmsToken).not.toHaveBeenCalled();
+ expect(mocks.sendSMS).not.toHaveBeenCalled();
+ });
+
+ it("IP 기준 SMS 발송 제한에 걸리면 프로필 토큰을 만들지 않는다", async () => {
+ const { sendProfilePhoneTokenService } = await import("./phone");
+
+ mocks.checkAndRecordSmsSendAttemptByIp.mockResolvedValue({
+ allowed: false,
+ retryAfterSeconds: 120,
+ });
+
+ const result = await sendProfilePhoneTokenService(10, "01012345678", {
+ clientIp: "203.0.113.10",
+ });
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ });
+ expect(mocks.generateUniqueSmsToken).not.toHaveBeenCalled();
+ expect(mocks.db.sMSToken.create).not.toHaveBeenCalled();
+ expect(mocks.sendSMS).not.toHaveBeenCalled();
+ });
+
+ it("동시 재요청으로 프로필 SMS 발송 슬롯을 확보하지 못하면 발송하지 않는다", async () => {
+ const { sendProfilePhoneTokenService } = await import("./phone");
+
+ mocks.db.sMSToken.findFirst.mockResolvedValue({
+ id: 1,
+ token: "123456",
+ phone: "01012345678",
+ userId: 10,
+ created_at: new Date("2026-06-26T23:58:00.000Z"),
+ expires_at: new Date("2026-06-27T00:08:00.000Z"),
+ });
+ mocks.db.sMSToken.updateMany.mockResolvedValue({ count: 0 });
+
+ const result = await sendProfilePhoneTokenService(10, "01012345678");
+
+ expect(result).toEqual({
+ success: false,
+ error: AUTH_ERRORS.SMS_RATE_LIMITED,
+ });
+ expect(mocks.sendSMS).not.toHaveBeenCalled();
+ });
+
+ it("프로필 SMS 발송 실패 시 이전 유효 토큰을 복구한다", async () => {
+ const { sendProfilePhoneTokenService } = await import("./phone");
+
+ const previous = {
+ id: 1,
+ token: "123456",
+ phone: "01012345678",
+ userId: 10,
+ created_at: new Date("2026-06-26T23:58:00.000Z"),
+ expires_at: new Date("2026-06-27T00:08:00.000Z"),
+ };
+
+ mocks.db.sMSToken.findFirst.mockResolvedValue(previous);
+ mocks.sendSMS.mockRejectedValue(new Error("provider failed"));
+
+ const result = await sendProfilePhoneTokenService(10, "01012345678");
+
+ expect(result).toEqual({
+ success: false,
+ error:
+ "인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.",
+ });
+ expect(mocks.db.sMSToken.updateMany).toHaveBeenLastCalledWith({
+ where: {
+ id: previous.id,
+ token: "654321",
+ created_at: new Date("2026-06-27T00:00:00.000Z"),
+ },
+ data: {
+ token: previous.token,
+ phone: previous.phone,
+ userId: previous.userId,
+ created_at: previous.created_at,
+ expires_at: previous.expires_at,
+ },
+ });
+ });
+});
diff --git a/features/user/service/phone.ts b/features/user/service/phone.ts
index d3adfb10..bf48130e 100644
--- a/features/user/service/phone.ts
+++ b/features/user/service/phone.ts
@@ -15,6 +15,7 @@
* 2026.03.05 임도헌 Modified 휴대폰 인증 완료 시의 개인화 캐시 태그 무효화 로직 제거 및 `revalidatePath` 기반 단순화 적용
* 2026.03.07 임도헌 Modified 인증 실패 문구를 구체화(v1.2)
* 2026.03.07 임도헌 Modified 정지 유저 가드 및 SMS 발송 실패 롤백 보강
+ * 2026.06.27 임도헌 Modified 프로필 휴대폰 인증 토큰 TTL, 재전송/IP 쿨다운, 발송 실패 롤백 처리 추가
*/
import "server-only";
@@ -22,6 +23,12 @@ import { revalidatePath } from "next/cache";
import db from "@/lib/db";
import { sendSMS } from "@/features/auth/utils/smsSender";
import { generateUniqueSmsToken } from "@/features/auth/service/token";
+import { checkAndRecordSmsSendAttemptByIp } from "@/features/auth/service/rateLimit";
+import {
+ AUTH_ERRORS,
+ SMS_VERIFY_RESEND_COOLDOWN_SECONDS,
+ SMS_VERIFY_TOKEN_TTL_MS,
+} from "@/features/auth/constants";
import { badgeChecks } from "./badge";
import { isUniqueConstraintError } from "@/lib/errors";
import { validateUserStatus } from "@/features/user/service/admin";
@@ -29,10 +36,16 @@ import type { ServiceResult } from "@/lib/types";
/**
* 프로필 인증용 SMS 발송
+ *
+ * @param {number} userId - 인증을 요청한 사용자 ID
+ * @param {string} phone - 검증할 전화번호
+ * @param {{ clientIp?: string | null }} [options] - SMS IP rate limit 계산에 사용할 요청 컨텍스트
+ * @returns {Promise} 발송 성공 여부
*/
export async function sendProfilePhoneTokenService(
userId: number,
- phone: string
+ phone: string,
+ options: { clientIp?: string | null } = {}
): Promise {
const userStatus = await validateUserStatus(userId);
if (!userStatus.success) {
@@ -48,25 +61,100 @@ export async function sendProfilePhoneTokenService(
return { success: false, error: "이미 사용 중인 전화번호입니다." };
}
- // 2. 기존 토큰 정리 (해당 번호 또는 유저에게 발송된 미사용 토큰 삭제)
+ const now = new Date();
+ const cooldownCutoff = new Date(
+ now.getTime() - SMS_VERIFY_RESEND_COOLDOWN_SECONDS * 1000
+ );
+
await db.sMSToken.deleteMany({
+ where: { expires_at: { lt: now } },
+ });
+
+ const previousToken = await db.sMSToken.findFirst({
where: { OR: [{ phone }, { userId }] },
+ orderBy: { created_at: "desc" },
+ select: {
+ id: true,
+ token: true,
+ phone: true,
+ userId: true,
+ created_at: true,
+ expires_at: true,
+ },
});
+ if (previousToken && previousToken.created_at > cooldownCutoff) {
+ return { success: false, error: AUTH_ERRORS.SMS_RATE_LIMITED };
+ }
+
+ const ipLimit = await checkAndRecordSmsSendAttemptByIp(
+ options.clientIp ?? null
+ );
+ if (!ipLimit.allowed) {
+ return { success: false, error: AUTH_ERRORS.SMS_RATE_LIMITED };
+ }
+
// 3. 새 토큰 생성 및 저장
const token = await generateUniqueSmsToken();
- await db.sMSToken.create({
- data: { token, phone, userId },
- });
+ const expiresAt = new Date(now.getTime() + SMS_VERIFY_TOKEN_TTL_MS);
+ let createdNewToken = false;
+
+ if (previousToken) {
+ const updateResult = await db.sMSToken.updateMany({
+ where: {
+ id: previousToken.id,
+ created_at: { lte: cooldownCutoff },
+ },
+ data: {
+ token,
+ phone,
+ userId,
+ created_at: now,
+ expires_at: expiresAt,
+ },
+ });
+
+ if (updateResult.count === 0) {
+ return { success: false, error: AUTH_ERRORS.SMS_RATE_LIMITED };
+ }
+ } else {
+ await db.sMSToken.create({
+ data: {
+ token,
+ phone,
+ userId,
+ expires_at: expiresAt,
+ },
+ });
+ createdNewToken = true;
+ }
// 4. SMS 발송
try {
await sendSMS(phone, token);
} catch (error) {
console.error("sendProfilePhoneTokenService error:", error);
- await db.sMSToken.deleteMany({
- where: { token, phone, userId },
- });
+ if (previousToken) {
+ await db.sMSToken.updateMany({
+ where: {
+ id: previousToken.id,
+ token,
+ created_at: now,
+ },
+ data: {
+ token: previousToken.token,
+ phone: previousToken.phone,
+ userId: previousToken.userId,
+ created_at: previousToken.created_at,
+ expires_at: previousToken.expires_at,
+ },
+ });
+ } else if (createdNewToken) {
+ await db.sMSToken.deleteMany({
+ where: { token, phone, userId },
+ });
+ }
+
return {
success: false,
error:
@@ -82,6 +170,11 @@ export async function sendProfilePhoneTokenService(
*
* 트랜잭션을 적용하여 토큰 삭제와 유저 정보 갱신이 동시에 성공하거나,
* 동시에 실패(롤백)하도록 보장
+ *
+ * @param {number} userId - 인증을 완료할 사용자 ID
+ * @param {string} phone - 검증할 전화번호
+ * @param {string} token - 사용자가 입력한 인증번호
+ * @returns {Promise} 전화번호 업데이트 성공 여부
*/
export async function verifyProfilePhoneTokenService(
userId: number,
@@ -96,7 +189,7 @@ export async function verifyProfilePhoneTokenService(
// 1. 토큰 조회 (번호, 토큰, 유저 일치 여부)
const verified = await db.sMSToken.findFirst({
where: { token, phone, userId },
- select: { id: true },
+ select: { id: true, expires_at: true },
});
if (!verified) {
@@ -106,6 +199,14 @@ export async function verifyProfilePhoneTokenService(
};
}
+ if (verified.expires_at < new Date()) {
+ await db.sMSToken.delete({ where: { id: verified.id } });
+ return {
+ success: false,
+ error: "전화번호와 인증번호가 일치하지 않습니다.",
+ };
+ }
+
try {
// 2. 트랜잭션 실행 (토큰 소모 + 유저 정보 업데이트)
await db.$transaction(async (tx) => {
diff --git a/lib/cloudflareImages.test.ts b/lib/cloudflareImages.test.ts
new file mode 100644
index 00000000..5bb0ebb5
--- /dev/null
+++ b/lib/cloudflareImages.test.ts
@@ -0,0 +1,113 @@
+/**
+ * File Name : lib/cloudflareImages.test.ts
+ * Description : Cloudflare 이미지 업로드 URL 발급 인증 경계 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.27 임도헌 Created 이미지 direct upload URL 발급 세션/사용자 상태 가드 테스트 추가
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ getSession: vi.fn(),
+ validateUserStatus: vi.fn(),
+}));
+
+vi.mock("server-only", () => ({}));
+
+vi.mock("@/lib/session", () => ({
+ default: mocks.getSession,
+}));
+
+vi.mock("@/features/user/service/admin", () => ({
+ validateUserStatus: mocks.validateUserStatus,
+}));
+
+describe("getUploadUrl", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ });
+
+ it("비로그인 요청은 Cloudflare upload URL 발급을 시작하지 않는다", async () => {
+ const { getUploadUrl } = await import("./cloudflareImages");
+
+ mocks.getSession.mockResolvedValue(null);
+
+ const result = await getUploadUrl();
+
+ expect(result).toEqual({
+ success: false,
+ error: "로그인이 필요합니다.",
+ });
+ expect(mocks.validateUserStatus).not.toHaveBeenCalled();
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("정지 등 사용자 상태 가드에 실패하면 Cloudflare 요청을 보내지 않는다", async () => {
+ const { getUploadUrl } = await import("./cloudflareImages");
+
+ mocks.getSession.mockResolvedValue({ id: 10 });
+ mocks.validateUserStatus.mockResolvedValue({
+ success: false,
+ error: "운영 정책에 의해 이용이 제한된 계정입니다.",
+ });
+
+ const result = await getUploadUrl();
+
+ expect(result).toEqual({
+ success: false,
+ error: "운영 정책에 의해 이용이 제한된 계정입니다.",
+ });
+ expect(mocks.validateUserStatus).toHaveBeenCalledWith(10);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("정상 로그인 사용자는 Cloudflare upload URL을 발급받을 수 있다", async () => {
+ vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "account-id");
+ vi.stubEnv("CLOUDFLARE_API_TOKEN", "api-token");
+
+ const { getUploadUrl } = await import("./cloudflareImages");
+
+ mocks.getSession.mockResolvedValue({ id: 10 });
+ mocks.validateUserStatus.mockResolvedValue({ success: true });
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ result: {
+ uploadURL: "https://upload.example.test",
+ id: "image-id",
+ },
+ }),
+ { status: 200 }
+ )
+ );
+
+ const result = await getUploadUrl();
+
+ expect(result).toEqual({
+ success: true,
+ result: {
+ uploadURL: "https://upload.example.test",
+ id: "image-id",
+ },
+ });
+ expect(fetch).toHaveBeenCalledWith(
+ "https://api.cloudflare.com/client/v4/accounts/account-id/images/v2/direct_upload",
+ {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer api-token",
+ },
+ }
+ );
+ });
+});
diff --git a/lib/cloudflareImages.ts b/lib/cloudflareImages.ts
index 23cc5153..96402a08 100644
--- a/lib/cloudflareImages.ts
+++ b/lib/cloudflareImages.ts
@@ -9,15 +9,43 @@
* 2025.06.12 임도헌 Modified Cloudflare 이미지 업로드용 URL 요청 함수를 lib로 옮김
* 2025.08.22 임도헌 Modified DirectUploadURLResult 타입 도입 및 응답 표준화, 검증 로직 추가
* 2026.01.16 임도헌 Renamed lib/cloudflare/getUploadUrl -> lib/cloudflareImages.ts
+ * 2026.06.27 임도헌 Modified 이미지 direct upload URL 발급 전 세션/사용자 상태 가드 추가
*/
"use server";
+import getSession from "@/lib/session";
+import { validateUserStatus } from "@/features/user/service/admin";
+
type DirectUploadURLResult =
| { success: true; result: { uploadURL: string; id: string } }
| { success: false; error: string };
+/**
+ * Cloudflare Images direct upload URL을 발급
+ *
+ * 로그인 세션과 사용자 상태를 확인한 뒤 Cloudflare API를 호출하고,
+ * 클라이언트에는 Cloudflare API token 대신 direct upload URL만 반환
+ *
+ * @returns {Promise} 업로드 URL 발급 결과
+ */
export async function getUploadUrl(): Promise {
try {
+ const session = await getSession();
+ if (!session?.id) {
+ return {
+ success: false,
+ error: "로그인이 필요합니다.",
+ };
+ }
+
+ const userStatus = await validateUserStatus(session.id);
+ if (!userStatus.success) {
+ return {
+ success: false,
+ error: userStatus.error,
+ };
+ }
+
const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;
const API_TOKEN = process.env.CLOUDFLARE_API_TOKEN!;
diff --git a/prisma/migrations/20260627083000_add_sms_token_expires_at/migration.sql b/prisma/migrations/20260627083000_add_sms_token_expires_at/migration.sql
new file mode 100644
index 00000000..3f6f25cb
--- /dev/null
+++ b/prisma/migrations/20260627083000_add_sms_token_expires_at/migration.sql
@@ -0,0 +1,22 @@
+-- Add SMS token TTL so old verification codes cannot remain valid indefinitely.
+ALTER TABLE "SMSToken" ADD COLUMN "expires_at" TIMESTAMP(3);
+
+UPDATE "SMSToken"
+SET "expires_at" = "created_at" + INTERVAL '10 minutes'
+WHERE "expires_at" IS NULL;
+
+ALTER TABLE "SMSToken" ALTER COLUMN "expires_at" SET NOT NULL;
+
+CREATE INDEX "SMSToken_expires_at_idx" ON "SMSToken"("expires_at");
+
+-- Store hashed actor keys for low-volume auth abuse controls.
+CREATE TABLE "AuthRateLimitEvent" (
+ "id" TEXT NOT NULL,
+ "kind" TEXT NOT NULL,
+ "keyHash" TEXT NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AuthRateLimitEvent_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "AuthRateLimitEvent_kind_keyHash_created_at_idx" ON "AuthRateLimitEvent"("kind", "keyHash", "created_at");
diff --git a/prisma/migrations/20260627090000_add_auth_rate_limit_cleanup_index/migration.sql b/prisma/migrations/20260627090000_add_auth_rate_limit_cleanup_index/migration.sql
new file mode 100644
index 00000000..82cb07a1
--- /dev/null
+++ b/prisma/migrations/20260627090000_add_auth_rate_limit_cleanup_index/migration.sql
@@ -0,0 +1,2 @@
+-- Optimize stale auth rate limit event cleanup by policy kind.
+CREATE INDEX "AuthRateLimitEvent_kind_created_at_idx" ON "AuthRateLimitEvent"("kind", "created_at");
diff --git a/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql b/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql
new file mode 100644
index 00000000..8f02183e
--- /dev/null
+++ b/prisma/migrations/20260629090000_make_sms_token_user_optional/migration.sql
@@ -0,0 +1,2 @@
+-- SMS login tokens should not reserve User.phone before verification succeeds.
+ALTER TABLE "SMSToken" ALTER COLUMN "userId" DROP NOT NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 48b76405..37fd1d4c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -74,6 +74,8 @@
* 2026.04.28 임도헌 Modified 보드게임 인기도/추천 인원/시리즈 요약 메타데이터 필드 추가
* 2026.04.29 임도헌 Modified 보드게임 한국어 검수자 관계와 taxonomy slug 고유 제약 추가
* 2026.04.29 임도헌 Modified 보드게임 카탈로그 도메인 모델/필드 주석 보강
+ * 2026.06.27 임도헌 Modified SMS 인증번호 만료 시각 및 인증 rate limit 이벤트 모델 추가
+ * 2026.06.29 임도헌 Modified SMS 로그인 토큰의 인증 전 User 점유를 막기 위해 userId optional 처리
*/
generator client {
provider = "prisma-client"
@@ -187,9 +189,12 @@ model SMSToken {
phone String? @unique // 대상 전화번호
created_at DateTime @default(now())
updated_at DateTime @updatedAt
+ expires_at DateTime
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- userId Int
+ user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId Int?
+
+ @@index([expires_at])
}
/// 이메일 인증 토큰 저장소
@@ -207,6 +212,18 @@ model EmailToken {
@@index([userId])
}
+/// 인증 경로 rate limit 이벤트
+model AuthRateLimitEvent {
+ id String @id @default(cuid())
+ kind String
+ keyHash String
+ created_at DateTime @default(now())
+
+ @@index([kind, keyHash, created_at])
+ @@index([kind, created_at])
+ @@map("AuthRateLimitEvent")
+}
+
/// 비밀번호 재설정 토큰 저장소
model PasswordResetToken {
id String @id @default(cuid())
diff --git a/scripts/cleanup-e2e.ts b/scripts/cleanup-e2e.ts
index ca11ec50..7f746636 100644
--- a/scripts/cleanup-e2e.ts
+++ b/scripts/cleanup-e2e.ts
@@ -10,6 +10,7 @@
* 2026.05.26 임도헌 Modified E2E 보드게임 도감 seed cleanup 기준 추가
* 2026.05.26 임도헌 Modified E2E 방송/VOD seed cleanup 기준 추가
* 2026.05.26 임도헌 Modified E2E 신고 처리 seed와 감사 로그 cleanup 기준 추가
+ * 2026.06.26 임도헌 Modified E2E 계정 간 팔로우 관계 cleanup 추가
*/
import { existsSync, readFileSync } from "node:fs";
@@ -98,6 +99,15 @@ async function cleanupE2EData() {
});
const e2eUserIds = e2eUsers.map((user) => user.id);
+ const followResult = await db.follow.deleteMany({
+ where: {
+ OR: [
+ { followerId: { in: e2eUserIds } },
+ { followingId: { in: e2eUserIds } },
+ ],
+ },
+ });
+
const notificationResult = await db.notification.deleteMany({
where: {
OR: [
@@ -179,6 +189,7 @@ async function cleanupE2EData() {
});
console.log("[E2E cleanup] removed test data");
+ console.log(`- follows : ${followResult.count}`);
console.log(`- notifications: ${notificationResult.count}`);
console.log(`- reviews : ${reviewResult.count}`);
console.log(`- reports : ${reportResult.count}`);
diff --git a/scripts/seed-e2e.ts b/scripts/seed-e2e.ts
index 7d429e79..ff6de9ff 100644
--- a/scripts/seed-e2e.ts
+++ b/scripts/seed-e2e.ts
@@ -41,9 +41,12 @@ const E2E_CHAT_MESSAGE = `${E2E_PREFIX} 채팅 목록 회귀 메시지`;
const E2E_APPOINTMENT_PRODUCT_TITLE = `${E2E_PREFIX} 약속 수락 상품`;
const E2E_APPOINTMENT_MESSAGE = `${E2E_PREFIX} 약속 수락 회귀 제안`;
const E2E_DELETE_PRODUCT_TITLE = `${E2E_PREFIX} 상품 삭제 복귀 테스트`;
+const E2E_MODAL_EDIT_PRODUCT_TITLE = `${E2E_PREFIX} 모달 수정 복귀 상품`;
+const E2E_MODAL_EDIT_PRODUCT_DESCRIPTION = `${E2E_MODAL_EDIT_PRODUCT_TITLE} 설명입니다.`;
const E2E_REPORT_DESCRIPTION = `${E2E_PREFIX} 관리자 신고 처리 회귀 대상`;
const E2E_BOARDGAME_TITLE = `${E2E_PREFIX} 항해자의 도감`;
const E2E_VOD_TITLE = `${E2E_PREFIX} 다시보기 회귀 방송`;
+const E2E_FOLLOWERS_VOD_TITLE = `${E2E_PREFIX} 팔로워 전용 회귀 방송`;
const E2E_USERS = {
seller: {
@@ -242,6 +245,7 @@ async function createProduct(
sellerId: number;
categoryId: number;
imageUrl?: string;
+ description?: string;
resetTradeState?: boolean;
}
) {
@@ -268,6 +272,8 @@ async function createProduct(
purchase_userId: null,
}
: {}),
+ ...(input.description ? { description: input.description } : {}),
+ completeness: "PERFECT",
hidden_at: null,
...E2E_LOCATION,
},
@@ -300,7 +306,7 @@ async function createProduct(
data: {
title: input.title,
price: 12000,
- description: `${input.title} 설명입니다.`,
+ description: input.description ?? `${input.title} 설명입니다.`,
userId: input.sellerId,
categoryId: input.categoryId,
game_type: "BOARD_GAME",
@@ -308,7 +314,7 @@ async function createProduct(
max_players: 4,
play_time: "30분",
condition: "GOOD",
- completeness: "COMPLETE",
+ completeness: "PERFECT",
has_manual: true,
...E2E_LOCATION,
images: input.imageUrl
@@ -548,7 +554,15 @@ async function createBoardGameSeed(db: PrismaClient, reviewerId: number) {
*
* 외부 Cloudflare 웹훅 없이 앱이 이미 처리 완료된 VOD를 읽는 경로만 검증
*/
-async function createVodSeed(db: PrismaClient, ownerId: number) {
+async function createVodSeed(
+ db: PrismaClient,
+ input: { ownerId: number; initialVisitorId: number }
+) {
+ const { ownerId, initialVisitorId } = input;
+ await db.follow.deleteMany({
+ where: { followerId: initialVisitorId, followingId: ownerId },
+ });
+
const liveInput = await db.liveInput.upsert({
where: { userId: ownerId },
update: {
@@ -604,6 +618,57 @@ async function createVodSeed(db: PrismaClient, ownerId: number) {
ready_at: new Date(),
},
});
+
+ const existingFollowersBroadcast = await db.broadcast.findFirst({
+ where: { liveInputId: liveInput.id, title: E2E_FOLLOWERS_VOD_TITLE },
+ select: { id: true },
+ });
+
+ const followersBroadcast = existingFollowersBroadcast
+ ? await db.broadcast.update({
+ where: { id: existingFollowersBroadcast.id },
+ data: {
+ description:
+ "E2E 회귀 테스트에서 팔로우 후 팔로워 전용 VOD 접근 수렴을 확인하는 방송입니다.",
+ thumbnail: E2E_PRODUCT_IMAGE_URL,
+ visibility: "FOLLOWERS",
+ status: "ENDED",
+ started_at: new Date(Date.now() - 90 * 60 * 1000),
+ ended_at: new Date(Date.now() - 45 * 60 * 1000),
+ },
+ select: { id: true },
+ })
+ : await db.broadcast.create({
+ data: {
+ liveInputId: liveInput.id,
+ title: E2E_FOLLOWERS_VOD_TITLE,
+ description:
+ "E2E 회귀 테스트에서 팔로우 후 팔로워 전용 VOD 접근 수렴을 확인하는 방송입니다.",
+ thumbnail: E2E_PRODUCT_IMAGE_URL,
+ visibility: "FOLLOWERS",
+ status: "ENDED",
+ started_at: new Date(Date.now() - 90 * 60 * 1000),
+ ended_at: new Date(Date.now() - 45 * 60 * 1000),
+ },
+ select: { id: true },
+ });
+
+ await db.vodAsset.upsert({
+ where: { provider_asset_id: "e2e-followers-vod-asset-990002" },
+ update: {
+ broadcastId: followersBroadcast.id,
+ thumbnail_url: E2E_PRODUCT_IMAGE_URL,
+ duration_sec: 1200,
+ ready_at: new Date(),
+ },
+ create: {
+ broadcastId: followersBroadcast.id,
+ provider_asset_id: "e2e-followers-vod-asset-990002",
+ thumbnail_url: E2E_PRODUCT_IMAGE_URL,
+ duration_sec: 1200,
+ ready_at: new Date(),
+ },
+ });
}
/**
@@ -719,6 +784,14 @@ async function seedE2EData() {
imageUrl: E2E_PRODUCT_IMAGE_URL,
resetTradeState: true,
});
+ await createProduct(db, {
+ title: E2E_MODAL_EDIT_PRODUCT_TITLE,
+ sellerId: seller.id,
+ categoryId: category.id,
+ imageUrl: E2E_PRODUCT_IMAGE_URL,
+ description: E2E_MODAL_EDIT_PRODUCT_DESCRIPTION,
+ resetTradeState: true,
+ });
await createChatRoomSeed(db, {
productId: product.id,
sellerId: seller.id,
@@ -734,7 +807,10 @@ async function seedE2EData() {
targetProductId: deleteProduct.id,
});
await createBoardGameSeed(db, admin.id);
- await createVodSeed(db, seller.id);
+ await createVodSeed(db, {
+ ownerId: seller.id,
+ initialVisitorId: buyer.id,
+ });
const [{ _max: maxProductId }, { _max: maxPostId }] = await Promise.all([
db.product.aggregate({ _max: { id: true } }),
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index 043118f8..dd4884be 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -50,12 +50,15 @@ Remove-Item Env:E2E_SEEDED
npm run cleanup:e2e
```
+특정 spec만 먼저 확인한 뒤 같은 터미널에서 전체 suite를 다시 실행할 때는 중간에 `npm run seed:e2e`를 한 번 더 실행합니다. 약속 수락, 상품 수정, 팔로우처럼 seed 상태를 실제로 변경하는 테스트가 있으므로, Playwright 실행 단위마다 seed 기준 상태를 다시 맞춥니다.
+
## 데이터 원칙
- E2E 데이터는 제목, 본문, 알림 문구에 `[E2E]` prefix를 사용합니다.
- `npm run cleanup:e2e`는 `[E2E]` prefix 콘텐츠와 E2E 계정 알림을 정리합니다.
- E2E 전용 계정은 로그인 안정성을 위해 재사용합니다.
- `npm run seed:e2e`는 필요한 계정/콘텐츠/알림이 없으면 생성하고, 이미 있으면 재사용합니다.
+- 상태 변경 E2E를 여러 번 나누어 실행할 때는 각 Playwright 실행 전에 `npm run seed:e2e`로 상품 상태, 약속 상태, 팔로우 관계를 기준 상태로 복원합니다.
- 운영 DB가 아니라 로컬/테스트 DB를 대상으로 실행합니다.
- seed 기반 테스트는 `E2E_SEEDED=1`이 없으면 skip됩니다.
- 실제 외부 서비스 호출이 필요한 Cloudflare, Kakao, Push, SMS, Email 시나리오는 별도 mock 또는 전용 테스트 환경이 준비된 뒤 확장합니다.
@@ -70,7 +73,9 @@ npm run cleanup:e2e
- 상품 삭제 후 `/products` 목록 복귀와 삭제 상세 잔상 방지
- 상품 등록 폼의 필수 입력 validation
- 채팅 목록에서 seed 대화 노출과 채팅 상세 진입
-- 채팅 약속 수락 후 확정 상태와 성공 피드백
+- 채팅 약속 수락 후 확정 상태와 상품 예약 상태 유지
+- 상품 모달 상세에서 수정 후 기존 목록 문맥 복귀와 변경 내용 반영
+- 팔로우 후 팔로워 전용 VOD 잠금 해제, 팔로잉 목록 노출, 상세 접근 수렴
- 보드게임 도감 검색 결과 노출과 상세 진입
- 다시보기 목록에서 seed VOD 노출과 녹화 상세 진입
- 알림 설정 화면의 알림 유형, 방해 금지 시간, 키워드 관리 진입점 렌더링
diff --git a/tests/e2e/chat-appointment.spec.ts b/tests/e2e/chat-appointment.spec.ts
index 251ef50f..3db3a7b6 100644
--- a/tests/e2e/chat-appointment.spec.ts
+++ b/tests/e2e/chat-appointment.spec.ts
@@ -6,6 +6,7 @@
* History
* Date Author Status Description
* 2026.05.26 임도헌 Created 채팅 약속 수락과 상품 예약 전환 성공 피드백 E2E 테스트 추가
+ * 2026.06.26 임도헌 Modified 약속 수락 후 상품 예약 상태가 목록/상세에 유지되는지 검증 추가
*/
import { expect, test } from "@playwright/test";
@@ -23,7 +24,7 @@ test.describe("seeded chat appointment regressions", () => {
"npm run seed:e2e 실행 후 E2E_SEEDED=1일 때만 seed 기반 테스트를 실행합니다."
);
- test("채팅 약속을 수락하면 확정 상태와 성공 피드백을 보여준다", async ({
+ test("채팅 약속을 수락하면 확정 상태와 상품 예약 상태가 유지된다", async ({
page,
}) => {
test.setTimeout(60_000);
@@ -53,5 +54,35 @@ test.describe("seeded chat appointment regressions", () => {
await expect(page.getByText("확정됨").first()).toBeVisible({
timeout: 15_000,
});
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.getByText("확정됨").first()).toBeVisible({
+ timeout: 15_000,
+ });
+
+ await page.goto(
+ `/products?keyword=${encodeURIComponent("약속 수락 상품")}`,
+ { waitUntil: "domcontentloaded" }
+ );
+
+ const productCard = page
+ .getByRole("link")
+ .filter({ hasText: E2E_APPOINTMENT_PRODUCT_TITLE })
+ .first();
+
+ await expect(productCard).toBeVisible({ timeout: 15_000 });
+ await expect(productCard).toContainText(/예약\s*중|예약중/);
+
+ const productHref = await productCard.getAttribute("href");
+ expect(productHref).toMatch(/^\/products\/view\/\d+/);
+
+ await page.goto(productHref!, { waitUntil: "domcontentloaded" });
+
+ await expect(
+ page.getByRole("heading", { name: E2E_APPOINTMENT_PRODUCT_TITLE })
+ ).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText(/예약\s*중|예약중/).first()).toBeVisible({
+ timeout: 15_000,
+ });
});
});
diff --git a/tests/e2e/product-modal-edit.spec.ts b/tests/e2e/product-modal-edit.spec.ts
new file mode 100644
index 00000000..0606ba1e
--- /dev/null
+++ b/tests/e2e/product-modal-edit.spec.ts
@@ -0,0 +1,113 @@
+/**
+ * File Name : tests/e2e/product-modal-edit.spec.ts
+ * Description : seed 기반 상품 모달 수정/복귀 E2E 회귀 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.26 임도헌 Created 상품 목록 모달 상세에서 수정 후 목록 문맥 복귀 검증 추가
+ */
+
+import { expect, type Page, test } from "@playwright/test";
+import {
+ E2E_SELLER,
+ isSeededE2EEnabled,
+ loginWithEmail,
+} from "./helpers/e2eAuth";
+
+const E2E_MODAL_EDIT_PRODUCT_TITLE = "[E2E] 모달 수정 복귀 상품";
+
+async function openProductOwnerEditAction(page: Page) {
+ const menuButton = page.getByLabel("상품 관리 메뉴 열기");
+ const editAction = page
+ .getByRole("menuitem", { name: "수정하기" })
+ .or(page.getByRole("button", { name: "수정하기" }))
+ .first();
+
+ await expect(menuButton).toBeVisible({ timeout: 15_000 });
+
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ await menuButton.click();
+ try {
+ await expect(editAction).toBeVisible({ timeout: 3_000 });
+ return editAction;
+ } catch {
+ await page.waitForTimeout(500);
+ }
+ }
+
+ await expect(editAction).toBeVisible({ timeout: 5_000 });
+ return editAction;
+}
+
+test.describe("seeded product modal edit regressions", () => {
+ test.skip(
+ !isSeededE2EEnabled(),
+ "npm run seed:e2e 실행 후 E2E_SEEDED=1일 때만 seed 기반 테스트를 실행합니다."
+ );
+
+ test("상품 모달 상세에서 수정 후 원래 목록 문맥으로 복귀한다", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+
+ const listPath = `/products?keyword=${encodeURIComponent("모달 수정 복귀")}`;
+ const nextDescription = `[E2E] 모달 수정 복귀 설명 ${Date.now()}`;
+
+ await loginWithEmail(page, E2E_SELLER, listPath, { timeout: 30_000 });
+
+ const productCard = page
+ .getByRole("link")
+ .filter({ hasText: E2E_MODAL_EDIT_PRODUCT_TITLE })
+ .first();
+
+ await expect(productCard).toBeVisible({ timeout: 15_000 });
+ await productCard.click();
+
+ const dialog = page.getByRole("dialog", { name: "제품 상세" });
+ await expect(dialog).toBeVisible({ timeout: 15_000 });
+ await expect(
+ dialog.getByRole("heading", { name: E2E_MODAL_EDIT_PRODUCT_TITLE })
+ ).toBeVisible();
+
+ const editAction = await openProductOwnerEditAction(page);
+ await editAction.click();
+
+ await page.waitForURL(/\/products\/view\/\d+\/edit/, {
+ timeout: 15_000,
+ waitUntil: "domcontentloaded",
+ });
+ expect(new URL(page.url()).searchParams.get("flow")).toBe("modal-edit");
+
+ const descriptionInput = page.getByPlaceholder(
+ "제품의 상태, 특이사항 등을 자세히 적어주세요."
+ );
+ await expect(descriptionInput).toBeVisible({ timeout: 15_000 });
+ await descriptionInput.fill(nextDescription);
+
+ await page.getByRole("button", { name: "수정하기" }).click();
+
+ await expect(
+ page.getByText("제품 정보가 수정되었습니다. 변경 내용이 상세 페이지에 반영됩니다.")
+ ).toBeVisible({ timeout: 15_000 });
+ await expect(dialog).toBeVisible({ timeout: 15_000 });
+ await expect(dialog.getByText(nextDescription)).toBeVisible({
+ timeout: 15_000,
+ });
+
+ await page.getByRole("button", { name: "닫기" }).click();
+ await page.waitForURL(
+ (url) =>
+ url.pathname === "/products" &&
+ url.searchParams.get("keyword") === "모달 수정 복귀",
+ { timeout: 15_000, waitUntil: "domcontentloaded" }
+ );
+
+ await expect(
+ page
+ .getByRole("link")
+ .filter({ hasText: E2E_MODAL_EDIT_PRODUCT_TITLE })
+ .first()
+ ).toBeVisible({ timeout: 15_000 });
+ });
+});
diff --git a/tests/e2e/stream-follow-access.spec.ts b/tests/e2e/stream-follow-access.spec.ts
new file mode 100644
index 00000000..541db62f
--- /dev/null
+++ b/tests/e2e/stream-follow-access.spec.ts
@@ -0,0 +1,87 @@
+/**
+ * File Name : tests/e2e/stream-follow-access.spec.ts
+ * Description : seed 기반 팔로우 후 팔로워 전용 VOD 접근 수렴 E2E 테스트
+ * Author : 임도헌
+ *
+ * History
+ * Date Author Status Description
+ * 2026.06.26 임도헌 Created 팔로우 후 팔로워 전용 VOD 잠금/목록/상세 접근 수렴 검증 추가
+ */
+
+import { expect, test } from "@playwright/test";
+import {
+ E2E_BUYER,
+ isSeededE2EEnabled,
+ loginWithEmail,
+} from "./helpers/e2eAuth";
+
+const E2E_FOLLOWERS_VOD_TITLE = "[E2E] 팔로워 전용 회귀 방송";
+const E2E_STREAMER_USERNAME = "e2e_seller";
+
+test.describe("seeded stream follow access regressions", () => {
+ test.skip(
+ !isSeededE2EEnabled(),
+ "npm run seed:e2e 실행 후 E2E_SEEDED=1일 때만 seed 기반 테스트를 실행합니다."
+ );
+
+ test("팔로우 후 팔로워 전용 VOD 잠금과 팔로잉 목록이 수렴한다", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+
+ await loginWithEmail(
+ page,
+ E2E_BUYER,
+ `/profile/${E2E_STREAMER_USERNAME}/channel`,
+ { timeout: 30_000 }
+ );
+
+ const channelVodCard = page
+ .getByRole("link")
+ .filter({ hasText: E2E_FOLLOWERS_VOD_TITLE })
+ .first();
+
+ await expect(channelVodCard).toBeVisible({ timeout: 15_000 });
+ await expect(channelVodCard).toContainText("팔로워 전용 방송입니다");
+
+ const channelFollowButton = page.locator("#channel-follow-button");
+
+ await expect(channelFollowButton).toBeVisible();
+ await expect(channelFollowButton).toHaveAttribute("aria-pressed", "false");
+
+ await channelFollowButton.click();
+
+ await expect(channelFollowButton).toHaveAttribute("aria-pressed", "true", {
+ timeout: 15_000,
+ });
+ await expect(channelFollowButton).toHaveText("팔로우 취소");
+ await expect(
+ channelVodCard.getByText("팔로워 전용 방송입니다")
+ ).toHaveCount(0, { timeout: 15_000 });
+
+ await page.goto(
+ `/streams?mode=recordings&scope=following&keyword=${encodeURIComponent("팔로워 전용 회귀")}`,
+ { waitUntil: "domcontentloaded" }
+ );
+
+ const followingVodCard = page
+ .getByRole("link")
+ .filter({ hasText: E2E_FOLLOWERS_VOD_TITLE })
+ .first();
+
+ await expect(followingVodCard).toBeVisible({ timeout: 15_000 });
+ await expect(
+ followingVodCard.getByText("팔로워 전용 방송입니다")
+ ).toHaveCount(0);
+
+ const vodHref = await followingVodCard.getAttribute("href");
+ expect(vodHref).toMatch(/^\/streams\/\d+\/recording/);
+
+ await page.goto(vodHref!, { waitUntil: "domcontentloaded" });
+
+ await expect(
+ page.getByRole("heading", { level: 1, name: E2E_FOLLOWERS_VOD_TITLE })
+ ).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole("heading", { name: "댓글" })).toBeVisible();
+ });
+});