CI: feature/user-project-ownership #16

Merged
jaybe merged 1 commits from feature/user-project-ownership into main 2025-11-30 14:13:30 +00:00
82 changed files with 2817 additions and 28 deletions
+135 -2
View File
@@ -12,7 +12,9 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@prisma/client": "^6.19.0",
"bcryptjs": "^2.4.3",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3",
@@ -26,6 +28,8 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
@@ -2789,6 +2793,13 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -2828,6 +2839,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/jszip": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
@@ -2838,6 +2860,13 @@
"jszip": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
@@ -3924,6 +3953,12 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -3992,6 +4027,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -4573,6 +4614,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
@@ -6639,6 +6689,28 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -6667,6 +6739,27 @@
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7040,6 +7133,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -7047,6 +7176,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
@@ -7211,7 +7346,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nano-spawn": {
@@ -8342,7 +8476,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
+4
View File
@@ -18,7 +18,9 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@prisma/client": "^6.19.0",
"bcryptjs": "^2.4.3",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3",
@@ -33,6 +35,8 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jszip": "^3.4.0",
"@types/bcryptjs": "^2.4.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
+15
View File
@@ -24,6 +24,21 @@ model Project {
updatedAt DateTime @updatedAt
assets Asset[]
// 인증 도입을 위한 관계: 프로젝트는 특정 User 가 소유할 수 있다.
userId String?
user User? @relation(fields: [userId], references: [id])
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
tokenVersion Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projects Project[]
}
model Asset {
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { verifyPassword, signAccessToken } from "@/features/auth/authCrypto";
// /api/auth/login
// - 이메일/비밀번호를 검증해 로그인시키고,
// - JWT 액세스 토큰을 httpOnly 쿠키에 설정한 뒤, 비밀번호 해시를 제외한 유저 정보를 반환한다.
const prisma = new PrismaClient();
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => null as any);
const emailRaw = body?.email;
const password: string | undefined = body?.password;
if (typeof emailRaw !== "string" || typeof password !== "string") {
return NextResponse.json({ message: "email 과 password 는 필수입니다." }, { status: 400 });
}
const email = emailRaw.trim().toLowerCase();
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
}
const ok = await verifyPassword(password, user.passwordHash);
if (!ok) {
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
}
const token = await signAccessToken({ id: user.id, email: user.email, tokenVersion: user.tokenVersion });
const { passwordHash: _ph, ...safeUser } = user;
const res = NextResponse.json(safeUser, { status: 200 });
res.cookies.set("pb_access", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
return res;
} catch (error: any) {
return NextResponse.json(
{ message: "로그인 처리 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
// /api/auth/logout
// - 클라이언트의 pb_access 세션 쿠키를 제거해 로그아웃을 수행한다.
export async function POST(_request: Request) {
const res = NextResponse.json({ message: "로그아웃 되었습니다." }, { status: 200 });
// pb_access 쿠키를 즉시 만료시켜 로그아웃 상태로 만든다.
res.cookies.set("pb_access", "", {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 0,
});
return res;
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { verifyAccessToken } from "@/features/auth/authCrypto";
// /api/auth/me
// - 요청 쿠키에 포함된 JWT(pb_access)를 검증하고,
// - 유효한 경우 토큰에서 식별 가능한 유저 정보를 반환한다.
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
export async function GET(request: Request) {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) {
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
}
const payload = await verifyAccessToken(token);
if (!payload) {
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
}
const user = {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
return NextResponse.json(user, { status: 200 });
}
+70
View File
@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
// /api/auth/signup
// - 이메일/비밀번호를 받아 새 유저를 생성하고,
// - JWT 액세스 토큰을 httpOnly 쿠키에 설정한 뒤, 비밀번호 해시를 제외한 유저 정보를 반환한다.
const prisma = new PrismaClient();
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => null as any);
const emailRaw = body?.email;
const password: string | undefined = body?.password;
if (typeof emailRaw !== "string" || typeof password !== "string") {
return NextResponse.json({ message: "email 과 password 는 필수입니다." }, { status: 400 });
}
const email = emailRaw.trim().toLowerCase();
if (password.length < 8) {
return NextResponse.json({ message: "비밀번호는 최소 8자 이상이어야 합니다." }, { status: 400 });
}
// 이미 존재하는 이메일인지 확인한다.
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ message: "이미 가입된 이메일입니다." }, { status: 409 });
}
// 비밀번호를 해시한 뒤 유저를 생성한다.
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: {
email,
passwordHash,
tokenVersion: 1,
},
});
const token = await signAccessToken({
id: user.id,
email: user.email,
tokenVersion: user.tokenVersion,
});
const { passwordHash: _ph, ...safeUser } = user;
const res = NextResponse.json(safeUser, { status: 201 });
res.cookies.set("pb_access", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
return res;
} catch (error: any) {
return NextResponse.json(
{ message: "회원가입 처리 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
+60 -3
View File
@@ -1,16 +1,60 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
const prisma = new PrismaClient();
export async function GET(_request: Request, context: { params: { slug: string } | Promise<{ slug: string }> }) {
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
export async function GET(
request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 조회하려면 로그인이 필요합니다." }, { status: 401 });
}
const { slug } = await context.params;
const project = await prisma.project.findUnique({
where: { slug },
});
if (!project) {
if (!project || (project.userId && project.userId !== authUser.id)) {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
@@ -18,12 +62,25 @@ export async function GET(_request: Request, context: { params: { slug: string }
}
export async function DELETE(
_request: Request,
request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 삭제하려면 로그인이 필요합니다." }, { status: 401 });
}
const { slug } = await context.params;
try {
const project = await prisma.project.findUnique({
where: { slug },
});
if (!project || (project.userId && project.userId !== authUser.id)) {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
await prisma.project.delete({
where: { slug },
});
+60 -1
View File
@@ -1,11 +1,53 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
const prisma = new PrismaClient();
export async function GET(_request: Request) {
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
export async function GET(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }, { status: 401 });
}
try {
const projects = await prisma.project.findMany({
where: { userId: authUser.id },
orderBy: { createdAt: "desc" },
take: 50,
select: {
@@ -28,6 +70,11 @@ export async function GET(_request: Request) {
}
export async function POST(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 저장하려면 로그인이 필요합니다." }, { status: 401 });
}
const body = await request.json();
const { title, slug, contentJson } = body;
@@ -36,6 +83,17 @@ export async function POST(request: Request) {
}
try {
const existing = await prisma.project.findUnique({
where: { slug },
});
if (existing && existing.userId && existing.userId !== authUser.id) {
return NextResponse.json(
{ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." },
{ status: 409 },
);
}
const project = await prisma.project.upsert({
where: { slug },
update: {
@@ -46,6 +104,7 @@ export async function POST(request: Request) {
title,
slug,
contentJson,
userId: authUser.id,
},
});
+34
View File
@@ -3,6 +3,7 @@
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
import {
DndContext,
@@ -70,6 +71,7 @@ import { PropertiesSidebar } from "./panels/PropertiesSidebar";
import { EditorCanvas } from "./EditorCanvas";
export default function EditorPage() {
const router = useRouter();
const blocks = useEditorStore((state) => state.blocks);
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
@@ -138,6 +140,27 @@ export default function EditorPage() {
const canUndo = historyLength > 0;
const canRedo = futureLength > 0;
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!res.ok && res.status === 401 && !cancelled) {
router.push("/login");
}
} catch {
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
const [menuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
@@ -286,6 +309,17 @@ export default function EditorPage() {
});
if (!response.ok) {
if (response.status === 409) {
try {
const data = await response.json();
if (data && typeof (data as any).message === "string") {
setProjectMessage((data as any).message as string);
return;
}
} catch {
}
}
setProjectMessage("프로젝트 저장에 실패했습니다.");
return;
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
// 로그인 페이지 컴포넌트
// - 이메일/비밀번호를 입력받아 /api/auth/login 으로 요청을 전송한다.
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/projects");
}
} catch {
// ignore
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
try {
const data = await res.json();
setError(data?.message ?? "로그인에 실패했습니다. 다시 시도해 주세요.");
} catch {
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
}
setLoading(false);
return;
}
// 성공 시에는 /projects 로 이동한다.
router.push("/projects");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
<h1 className="text-lg font-semibold mb-1"></h1>
<p className="text-xs text-slate-400 mb-4"> .</p>
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
<div className="space-y-1">
<label htmlFor="email" className="block text-slate-200">
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
required
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-slate-200">
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading}
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
>
{loading ? "로그인 중..." : "로그인"}
</button>
</form>
<p className="mt-4 text-[11px] text-slate-400">
{" "}
<Link href="/signup" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
</Link>
.
</p>
</div>
</main>
);
}
+23
View File
@@ -3,15 +3,38 @@
import type { CSSProperties } from "react";
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
export default function PreviewPage() {
const router = useRouter();
const blocks = useEditorStore((state) => state.blocks);
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!res.ok && res.status === 401 && !cancelled) {
router.push("/login");
}
} catch {
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
useEffect(() => {
if (typeof window === "undefined") return;
+59 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
FilePlus2,
Trash2,
@@ -22,8 +23,10 @@ interface ProjectListItem {
}
export default function ProjectsPage() {
const router = useRouter();
const [projects, setProjects] = useState<ProjectListItem[]>([]);
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
@@ -34,11 +37,21 @@ export default function ProjectsPage() {
const load = async () => {
try {
setStatus("loading");
setErrorMessage(null);
const res = await fetch("/api/projects");
if (!res.ok) {
if (!cancelled) {
setStatus("error");
if (res.status === 401) {
setErrorMessage("프로젝트 목록을 불러오는 중 인증 오류가 발생했습니다. 다시 로그인해 주세요.");
router.push("/login");
} else {
setErrorMessage(
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
);
}
}
return;
}
@@ -47,10 +60,12 @@ export default function ProjectsPage() {
if (!cancelled) {
setProjects(Array.isArray(data) ? data : []);
setStatus("idle");
setErrorMessage(null);
}
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage("프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
}
};
@@ -83,6 +98,7 @@ export default function ProjectsPage() {
if (!res.ok) {
setStatus("error");
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
@@ -93,8 +109,31 @@ export default function ProjectsPage() {
window.localStorage.removeItem(`pb:project:${slug}`);
window.localStorage.removeItem(`pb:autosave:${slug}`);
}
setStatus("idle");
setErrorMessage(null);
} catch {
setStatus("error");
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", {
method: "POST",
});
if (!res.ok) {
setStatus("error");
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
router.push("/login");
} catch {
setStatus("error");
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
};
@@ -127,6 +166,7 @@ export default function ProjectsPage() {
if (okSlugs.length === 0) {
setStatus("error");
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
@@ -142,9 +182,16 @@ export default function ProjectsPage() {
if (okSlugs.length !== slugs.length) {
setStatus("error");
setErrorMessage(
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
);
} else {
setStatus("idle");
setErrorMessage(null);
}
} catch {
setStatus("error");
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
};
@@ -156,6 +203,15 @@ export default function ProjectsPage() {
<p className="text-xs text-slate-400"> </p>
</div>
<div className="flex items-center gap-2 text-xs">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 px-3 py-1 text-slate-200 hover:bg-slate-900"
onClick={() => {
void handleLogout();
}}
>
</button>
<Link
href="/editor"
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
@@ -167,7 +223,9 @@ export default function ProjectsPage() {
</header>
<section className="flex-1 px-6 py-4 overflow-auto">
{status === "error" && (
<p className="text-xs text-red-300 mb-3"> . .</p>
<p className="text-xs text-red-300 mb-3">
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
</p>
)}
{projects.length === 0 && status === "idle" && (
<p className="text-xs text-slate-400"> . .</p>
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
// 회원가입 페이지 컴포넌트
// - 이메일/비밀번호를 입력받아 /api/auth/signup 으로 요청을 전송한다.
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
export default function SignupPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/projects");
}
} catch {
// ignore
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
try {
const data = await res.json();
setError(data?.message ?? "회원가입에 실패했습니다. 다시 시도해 주세요.");
} catch {
setError("회원가입에 실패했습니다. 다시 시도해 주세요.");
}
setLoading(false);
return;
}
router.push("/projects");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
<h1 className="text-lg font-semibold mb-1"></h1>
<p className="text-xs text-slate-400 mb-4"> .</p>
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
<div className="space-y-1">
<label htmlFor="email" className="block text-slate-200">
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
required
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-slate-200">
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading}
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
>
{loading ? "회원가입 중..." : "회원가입"}
</button>
</form>
<p className="mt-4 text-[11px] text-slate-400">
{" "}
<Link href="/login" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
</Link>
.
</p>
</div>
</main>
);
}
+146
View File
@@ -0,0 +1,146 @@
import bcrypt from "bcryptjs";
import jwt, { JwtPayload } from "jsonwebtoken";
import { randomBytes, createCipheriv, createDecipheriv, createHash } from "crypto";
// 인증/보안 관련 공통 유틸리티 함수들을 제공한다.
// - 비밀번호 해시/검증 (bcrypt 기반)
// - JWT 액세스 토큰 발급/검증
// - 민감정보 JSON 암호화/복호화 (AES-256-GCM)
export interface AccessTokenPayload extends JwtPayload {
sub: string;
email: string;
tokenVersion: number;
}
export interface JwtUserLike {
id: string;
email: string;
tokenVersion: number;
}
// 내부에서 사용할 JWT 시크릿을 가져온다.
// - 설정되지 않은 경우에는 명확한 에러를 던져 조기에 문제를 발견한다.
function getJwtSecret(): string {
const secret = process.env.AUTH_JWT_SECRET;
if (!secret || secret.length === 0) {
throw new Error("AUTH_JWT_SECRET 환경변수가 설정되지 않았습니다.");
}
return secret;
}
// 암호화 키 원본 문자열로부터 32바이트 AES 키를 파생한다.
// - 키 문자열 길이에 관계없이 SHA-256 해시를 사용해 32바이트를 만든다.
function deriveEncryptionKey(): Buffer {
const raw = process.env.AUTH_ENCRYPTION_KEY;
if (!raw || raw.length === 0) {
throw new Error("AUTH_ENCRYPTION_KEY 환경변수가 설정되지 않았습니다.");
}
return createHash("sha256").update(raw).digest();
}
// 최소 비밀번호 길이 (보안 요구사항: 8자 이상)
const MIN_PASSWORD_LENGTH = 8;
// 비밀번호를 bcrypt 해시로 변환한다.
export async function hashPassword(plain: string): Promise<string> {
if (typeof plain !== "string" || plain.length < MIN_PASSWORD_LENGTH) {
throw new Error("비밀번호는 최소 8자 이상이어야 합니다.");
}
// bcryptjs 기본 라운드 수(10)를 사용한다. 필요 시 환경에 맞게 조정 가능.
const saltRounds = 10;
return await bcrypt.hash(plain, saltRounds);
}
// 평문 비밀번호와 저장된 해시가 일치하는지 검증한다.
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
if (!hash) return false;
if (!plain || plain.length < MIN_PASSWORD_LENGTH) {
// 너무 짧은 비밀번호는 즉시 false 를 반환해 타이밍 공격을 줄인다.
return false;
}
return await bcrypt.compare(plain, hash);
}
// 유저 정보를 기반으로 JWT 액세스 토큰을 발급한다.
export async function signAccessToken(user: JwtUserLike): Promise<string> {
const secret = getJwtSecret();
const payload: AccessTokenPayload = {
sub: user.id,
email: user.email,
tokenVersion: user.tokenVersion,
};
// 만료 시간은 기본 15분으로 설정한다.
const token = jwt.sign(payload, secret, {
algorithm: "HS256",
expiresIn: "15m",
});
return token;
}
// JWT 액세스 토큰을 검증하고, 유효하면 페이로드를 반환한다.
// - 검증 실패(시그니처 오류, 만료 등) 시 null 을 반환한다.
export async function verifyAccessToken(token: string): Promise<AccessTokenPayload | null> {
const secret = getJwtSecret();
try {
const decoded = jwt.verify(token, secret) as AccessTokenPayload;
if (!decoded || typeof decoded.sub !== "string") {
return null;
}
return decoded;
} catch {
return null;
}
}
// JSON 객체를 AES-256-GCM 으로 암호화한다.
// - 결과는 iv.tag.ciphertext 형태의 base64 조합 문자열이다.
export async function encryptJson<T extends object>(value: T): Promise<string> {
const key = deriveEncryptionKey();
const iv = randomBytes(12); // GCM 권장 IV 길이 96bit
const plain = JSON.stringify(value);
const cipher = createCipheriv("aes-256-gcm", key, iv);
let encrypted = cipher.update(plain, "utf8", "base64");
encrypted += cipher.final("base64");
const authTag = cipher.getAuthTag();
const ivB64 = iv.toString("base64");
const tagB64 = authTag.toString("base64");
return `${ivB64}.${tagB64}.${encrypted}`;
}
// AES-256-GCM 으로 암호화된 JSON 문자열을 복호화한다.
export async function decryptJson<T = unknown>(ciphertext: string): Promise<T> {
const key = deriveEncryptionKey();
try {
const parts = ciphertext.split(".");
if (parts.length !== 3) {
throw new Error("잘못된 암호문 형식입니다.");
}
const [ivB64, tagB64, dataB64] = parts;
const iv = Buffer.from(ivB64, "base64");
const authTag = Buffer.from(tagB64, "base64");
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(dataB64, "base64", "utf8");
decrypted += decipher.final("utf8");
return JSON.parse(decrypted) as T;
} catch (error) {
// 복호화 실패 시 명시적인 에러를 던져 상위에서 처리할 수 있게 한다.
throw new Error("민감정보 복호화에 실패했습니다.");
}
}
+266
View File
@@ -0,0 +1,266 @@
import "dotenv/config";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
const BASE_URL = "http://localhost";
// PrismaClient 를 실제 DB 대신 메모리 기반 user 저장소를 사용하는 목으로 대체한다.
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 Auth API 테스트를 실행할 수 있다.
const inMemoryUsers: any[] = [];
vi.mock("@/generated/prisma/client", () => {
class PrismaClientMock {
user = {
findUnique: async ({ where: { email } }: any) => {
return inMemoryUsers.find((u) => u.email === email) ?? null;
},
create: async ({ data }: any) => {
const now = new Date();
const user = {
id: String(inMemoryUsers.length + 1),
createdAt: now,
updatedAt: now,
...data,
};
inMemoryUsers.push(user);
return user;
},
};
}
return { PrismaClient: PrismaClientMock };
});
beforeEach(() => {
// 각 테스트 전에 메모리 유저 저장소를 초기화한다.
inMemoryUsers.length = 0;
process.env.AUTH_JWT_SECRET = "test-jwt-secret-api";
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
});
describe("/api/auth", () => {
describe("POST /api/auth/signup", () => {
it("새 이메일과 8자 이상 비밀번호로 회원가입하면 User 가 생성되고 JWT 쿠키가 설정되어야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
const payload = { email: "user@example.com", password: "securePass1" };
const res = await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(201);
const json = (await res.json()) as any;
expect(json.email).toBe(payload.email);
expect(json.id).toBeDefined();
// 응답에는 passwordHash 가 포함되면 안 된다.
expect(json.passwordHash).toBeUndefined();
// 메모리 저장소에도 유저가 1명 생성되어야 한다.
expect(inMemoryUsers.length).toBe(1);
expect(inMemoryUsers[0].email).toBe(payload.email);
expect(typeof inMemoryUsers[0].passwordHash).toBe("string");
// JWT 세션 쿠키가 설정되어야 한다.
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
});
it("비밀번호가 8자 미만이면 400 과 에러 메시지를 반환해야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
const res = await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "short@example.com", password: "short" }),
}),
);
expect(res.status).toBe(400);
const json = (await res.json()) as any;
expect(json.message).toBeDefined();
});
it("이미 존재하는 이메일로 회원가입하면 409 를 반환해야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
// 먼저 한 번 성공적으로 가입
await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dup@example.com", password: "securePass1" }),
}),
);
// 같은 이메일로 다시 요청
const res = await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dup@example.com", password: "anotherPass1" }),
}),
);
expect(res.status).toBe(409);
const json = (await res.json()) as any;
expect(json.message).toBeDefined();
});
});
describe("POST /api/auth/login", () => {
it("올바른 이메일/비밀번호로 로그인하면 200 과 JWT 쿠키를 반환해야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
const { POST: login } = await import("@/app/api/auth/login/route");
const email = "login@example.com";
const password = "securePass1";
// 사전 회원가입
await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}),
);
const res = await login(
new Request(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.email).toBe(email);
expect(json.id).toBeDefined();
expect(json.passwordHash).toBeUndefined();
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
});
it("잘못된 이메일 또는 비밀번호로 로그인하면 401 을 반환해야 한다", async () => {
const { POST: signup } = await import("@/app/api/auth/signup/route");
const { POST: login } = await import("@/app/api/auth/login/route");
const email = "wrong@example.com";
const password = "securePass1";
// 사전 회원가입
await signup(
new Request(`${BASE_URL}/api/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}),
);
// 존재하지 않는 이메일
const res1 = await login(
new Request(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "nope@example.com", password }),
}),
);
expect(res1.status).toBe(401);
// 비밀번호 불일치
const res2 = await login(
new Request(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: "wrongPass1" }),
}),
);
expect(res2.status).toBe(401);
});
});
describe("POST /api/auth/logout", () => {
it("로그아웃 시 pb_access 쿠키를 제거하는 Set-Cookie 헤더를 반환해야 한다", async () => {
const { POST: logout } = await import("@/app/api/auth/logout/route");
const res = await logout(
new Request(`${BASE_URL}/api/auth/logout`, {
method: "POST",
headers: {
cookie: "pb_access=dummy.token.value",
},
}),
);
expect(res.status).toBe(200);
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
expect(setCookie?.toLowerCase()).toContain("max-age=0");
});
it("쿠키가 없어도 200 과 쿠키 제거 헤더를 반환해야 한다", async () => {
const { POST: logout } = await import("@/app/api/auth/logout/route");
const res = await logout(
new Request(`${BASE_URL}/api/auth/logout`, {
method: "POST",
}),
);
expect(res.status).toBe(200);
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
expect(setCookie?.toLowerCase()).toContain("max-age=0");
});
});
describe("GET /api/auth/me", () => {
it("유효한 JWT 쿠키가 있을 때 현재 로그인 유저 정보를 반환해야 한다", async () => {
const { GET: me } = await import("@/app/api/auth/me/route");
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1 };
const token = await signAccessToken(user);
const res = await me(
new Request(`${BASE_URL}/api/auth/me`, {
headers: {
cookie: `pb_access=${token}`,
},
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.id).toBe(user.id);
expect(json.email).toBe(user.email);
expect(json.passwordHash).toBeUndefined();
});
it("JWT 쿠키가 없거나 잘못된 경우 401 을 반환해야 한다", async () => {
const { GET: me } = await import("@/app/api/auth/me/route");
const res1 = await me(new Request(`${BASE_URL}/api/auth/me`));
expect(res1.status).toBe(401);
const res2 = await me(
new Request(`${BASE_URL}/api/auth/me`, {
headers: { cookie: "pb_access=invalid.token.here" },
}),
);
expect(res2.status).toBe(401);
});
});
});
+218 -12
View File
@@ -1,5 +1,6 @@
import "dotenv/config";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { signAccessToken } from "@/features/auth/authCrypto";
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
@@ -58,9 +59,13 @@ vi.mock("@/generated/prisma/client", () => {
const [removed] = inMemoryProjects.splice(index, 1);
return removed;
},
findMany: async ({ orderBy, take, select }: any = {}) => {
findMany: async ({ where, orderBy, take, select }: any = {}) => {
let items = [...inMemoryProjects];
if (where?.userId) {
items = items.filter((p) => p.userId === where.userId);
}
if (orderBy?.createdAt === "desc") {
items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
@@ -91,6 +96,23 @@ vi.mock("@/generated/prisma/client", () => {
const BASE_URL = "http://localhost";
const TEST_USER = { id: "user-1", email: "projects@example.com", tokenVersion: 1 };
const OTHER_USER = { id: "user-2", email: "projects2@example.com", tokenVersion: 1 };
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
const token = await signAccessToken(user);
return { cookie: `pb_access=${token}` };
}
async function buildAuthHeaders() {
return buildAuthHeadersFor(TEST_USER);
}
beforeEach(() => {
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
inMemoryProjects.length = 0;
});
describe("/api/projects", () => {
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
const payload = {
@@ -107,10 +129,12 @@ describe("/api/projects", () => {
const { POST: createProject } = await import("@/app/api/projects/route");
const headers = await buildAuthHeaders();
const createResponse = await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(payload),
}),
);
@@ -127,8 +151,10 @@ describe("/api/projects", () => {
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
const getHeaders = await buildAuthHeaders();
const getResponse = await getProjectBySlug(
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
new Request(`${BASE_URL}/api/projects/${payload.slug}`, { headers: getHeaders }),
{ params: { slug: payload.slug } } as any,
);
@@ -153,10 +179,12 @@ describe("/api/projects", () => {
contentJson: [],
};
const headers = await buildAuthHeaders();
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(firstPayload),
}),
);
@@ -166,14 +194,16 @@ describe("/api/projects", () => {
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(secondPayload),
}),
);
const { GET: listProjects } = await import("@/app/api/projects/route");
const res = await listProjects(new Request(`${BASE_URL}/api/projects`));
const listHeaders = await buildAuthHeaders();
const res = await listProjects(new Request(`${BASE_URL}/api/projects`, { headers: listHeaders }));
expect(res.status).toBe(200);
const list = (await res.json()) as any[];
@@ -193,25 +223,29 @@ describe("/api/projects", () => {
contentJson: [],
};
const headers = await buildAuthHeaders();
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(payload),
}),
);
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
const deleteHeaders = await buildAuthHeaders();
const deleteResponse = await deleteProject(
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE" }),
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: deleteHeaders }),
{ params: { slug } } as any,
);
expect(deleteResponse.status).toBe(200);
const getAfterDelete = await getProjectBySlug(
new Request(`${BASE_URL}/api/projects/${slug}`),
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: deleteHeaders }),
{ params: { slug } } as any,
);
@@ -247,10 +281,12 @@ describe("/api/projects", () => {
],
};
const headers = await buildAuthHeaders();
const firstRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(firstPayload),
}),
);
@@ -260,7 +296,7 @@ describe("/api/projects", () => {
const secondRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(secondPayload),
}),
);
@@ -272,4 +308,174 @@ describe("/api/projects", () => {
expect(updated.title).toBe("두 번째 제목");
expect(updated.contentJson).toEqual(secondPayload.contentJson);
});
it("GET /api/projects 는 현재 로그인 유저의 프로젝트만 반환해야 한다", async () => {
const { POST: createProject, GET: listProjects } = await import("@/app/api/projects/route");
const slugA = "user-a-project";
const slugB = "user-b-project";
const headersA = await buildAuthHeadersFor(TEST_USER);
const headersB = await buildAuthHeadersFor(OTHER_USER);
const payloadA = {
title: "유저 A 프로젝트",
slug: slugA,
contentJson: [],
};
const payloadB = {
title: "유저 B 프로젝트",
slug: slugB,
contentJson: [],
};
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headersA },
body: JSON.stringify(payloadA),
}),
);
await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headersB },
body: JSON.stringify(payloadB),
}),
);
const resA = await listProjects(
new Request(`${BASE_URL}/api/projects`, { headers: headersA }),
);
expect(resA.status).toBe(200);
const listA = (await resA.json()) as any[];
const slugsA = listA.map((p) => p.slug);
expect(slugsA).toContain(slugA);
expect(slugsA).not.toContain(slugB);
const resB = await listProjects(
new Request(`${BASE_URL}/api/projects`, { headers: headersB }),
);
expect(resB.status).toBe(200);
const listB = (await resB.json()) as any[];
const slugsB = listB.map((p) => p.slug);
expect(slugsB).toContain(slugB);
expect(slugsB).not.toContain(slugA);
});
it("다른 유저가 이미 소유한 slug 로 POST 하면 409 를 반환해야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");
const slug = "shared-slug";
const ownerPayload = {
title: "소유자 프로젝트",
slug,
contentJson: [],
};
const otherPayload = {
title: "다른 유저 프로젝트",
slug,
contentJson: [],
};
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
const firstRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", ...ownerHeaders },
body: JSON.stringify(ownerPayload),
}),
);
expect(firstRes.status).toBe(201);
const secondRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", ...otherHeaders },
body: JSON.stringify(otherPayload),
}),
);
expect(secondRes.status).toBe(409);
});
it("GET /api/projects/[slug] 는 소유자가 아닌 유저에게 404 를 반환해야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
const slug = "owner-only-slug";
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
const payload = {
title: "소유자 프로젝트",
slug,
contentJson: [],
};
const createRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", ...ownerHeaders },
body: JSON.stringify(payload),
}),
);
expect(createRes.status).toBe(201);
const resOther = await getProjectBySlug(
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: otherHeaders }),
{ params: { slug } } as any,
);
expect(resOther.status).toBe(404);
const resOwner = await getProjectBySlug(
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }),
{ params: { slug } } as any,
);
expect(resOwner.status).toBe(200);
});
it("DELETE /api/projects/[slug] 는 소유자가 아닌 유저가 호출하면 404 를 반환하고 실제 데이터는 삭제되지 않아야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
const slug = "owner-delete-slug";
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
const payload = {
title: "삭제 소유자 프로젝트",
slug,
contentJson: [],
};
const createRes = await handleProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", ...ownerHeaders },
body: JSON.stringify(payload),
}),
);
expect(createRes.status).toBe(201);
const deleteResOther = await deleteProject(
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: otherHeaders }),
{ params: { slug } } as any,
);
expect(deleteResOther.status).toBe(404);
const resOwner = await getProjectBySlug(
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }),
{ params: { slug } } as any,
);
expect(resOwner.status).toBe(200);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { test, expect } from "@playwright/test";
// 인증 라우트 가드 E2E
// - 비로그인 사용자가 보호된 페이지에 접근하면 /login 으로 리다이렉트되어야 한다.
test("비로그인 사용자가 /projects 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
// /api/projects 응답을 401 으로 목 처리해 비로그인 상태를 시뮬레이션한다.
await page.route("**/api/projects", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "로그인이 필요합니다." }),
});
});
await page.goto("/projects");
// 최종적으로 로그인 페이지의 헤더가 보여야 한다.
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("비로그인 사용자가 /editor 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
// /api/auth/me 를 401 으로 응답하도록 목 처리해 비로그인 상태를 시뮬레이션한다.
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "인증이 필요합니다." }),
});
});
await page.goto("/editor");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "인증이 필요합니다." }),
});
});
await page.goto("/preview");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
const email = `e2e+${Date.now()}@example.com`;
const password = "securePass1";
// 백엔드/DB 에 의존하지 않고 프론트 동작만 검증하기 위해 관련 API 를 모두 목 처리한다.
// isLoggedIn 플래그를 두고, 회원가입 전에는 /api/auth/me 가 401 을, 회원가입 후에는 200 을 반환하도록 시뮬레이션한다.
let isLoggedIn = false;
await page.route("**/api/auth/signup", async (route) => {
isLoggedIn = true;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ id: "user-1", email }),
});
});
await page.route("**/api/auth/me", async (route) => {
if (!isLoggedIn) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "인증이 필요합니다." }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "user-1", email, tokenVersion: 1 }),
});
});
await page.route("**/api/projects", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
// 1) 회원가입
await page.goto("/signup");
await page.getByLabel("이메일").fill(email);
await page.getByLabel("비밀번호").fill(password);
await page.getByRole("button", { name: "회원가입" }).click();
// /projects 로 이동했는지, 헤더가 보이는지 확인
await expect(page).toHaveURL(/\/projects/);
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
await page.goto("/editor");
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
// 3) /preview 접근 확인
await page.goto("/preview");
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
});
+141
View File
@@ -0,0 +1,141 @@
import { test, expect } from "@playwright/test";
test("서로 다른 유저는 /projects 에서 자신의 프로젝트만 볼 수 있어야 한다", async ({ page }) => {
type Project = {
id: string;
title: string;
slug: string;
status: string;
createdAt: string;
updatedAt: string;
};
let currentUser: "A" | "B" = "A";
const projectsA: Project[] = [];
const projectsB: Project[] = [];
let nextId = 1;
const nowIso = () => new Date().toISOString();
await page.route("**/api/auth/me", async (route) => {
const email = currentUser === "A" ? "user-a@example.com" : "user-b@example.com";
const id = currentUser === "A" ? "user-a" : "user-b";
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id, email, tokenVersion: 1 }),
});
});
await page.route("**/api/projects*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
const ownerProjects = currentUser === "A" ? projectsA : projectsB;
const otherProjects = currentUser === "A" ? projectsB : projectsA;
if (url.pathname === "/api/projects" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(ownerProjects),
});
return;
}
if (url.pathname === "/api/projects" && method === "POST") {
const bodyText = request.postData() ?? "{}";
const body = JSON.parse(bodyText) as Partial<Project> & { title?: string; slug?: string };
const slug = body.slug ?? "";
const title = body.title ?? "";
const conflict = otherProjects.find((p) => p.slug === slug);
if (conflict) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
});
return;
}
const existingIndex = ownerProjects.findIndex((p) => p.slug === slug);
const now = nowIso();
let project: Project;
if (existingIndex >= 0) {
const existing = ownerProjects[existingIndex];
project = { ...existing, title: title || existing.title, updatedAt: now };
ownerProjects[existingIndex] = project;
} else {
project = {
id: String(nextId++),
title: title || "제목 없음",
slug,
status: "DRAFT",
createdAt: now,
updatedAt: now,
};
ownerProjects.push(project);
}
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(project),
});
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
});
});
// 1) 유저 A: 에디터에서 프로젝트를 저장하고, /projects 에서 자신의 프로젝트만 보여야 한다.
await page.goto("/editor");
const propertiesSidebarA = page.getByTestId("properties-sidebar");
await propertiesSidebarA.getByLabel("프로젝트 제목").fill("유저 A 프로젝트");
await propertiesSidebarA.getByLabel("프로젝트 주소 (slug)").fill("user-a-project");
await page.getByRole("button", { name: "메뉴 ▼" }).click();
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
await expect(page).toHaveURL(/\/_?projects/);
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
await expect(page.getByText("user-a-project")).toBeVisible();
await expect(page.getByText("user-b-project")).toHaveCount(0);
// 2) 유저 B: currentUser 전환 후 별도 프로젝트를 저장하고, /projects 에서 자신의 것만 보여야 한다.
currentUser = "B";
await page.goto("/editor");
const propertiesSidebarB = page.getByTestId("properties-sidebar");
await propertiesSidebarB.getByLabel("프로젝트 제목").fill("유저 B 프로젝트");
await propertiesSidebarB.getByLabel("프로젝트 주소 (slug)").fill("user-b-project");
await page.getByRole("button", { name: "메뉴 ▼" }).click();
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
await expect(page).toHaveURL(/\/_?projects/);
await expect(page.getByText("유저 B 프로젝트")).toBeVisible();
await expect(page.getByText("user-b-project")).toBeVisible();
await expect(page.getByText("유저 A 프로젝트")).toHaveCount(0);
// 3) 다시 유저 A 로 전환했을 때, /projects 에서는 유저 A 의 프로젝트만 보여야 한다.
currentUser = "A";
await page.goto("/projects");
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
await expect(page.getByText("user-a-project")).toBeVisible();
await expect(page.getByText("유저 B 프로젝트")).toHaveCount(0);
});
+9
View File
@@ -88,6 +88,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 자동저장", () => {
it("blocks 와 projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다", () => {
const storageProto = Object.getPrototypeOf(window.localStorage);
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 버튼 블록 스타일", () => {
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
+9
View File
@@ -86,6 +86,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
render(<EditorPage />);
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 이미지 블록 스타일", () => {
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
@@ -90,6 +90,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function openJsonModal() {
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
+9
View File
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
render(<EditorPage />);
+9
View File
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
+9
View File
@@ -76,6 +76,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
function setupThreeBlocks() {
const blocks: Block[] = [
{
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
// editorStore 는 이미 여러 유닛 테스트에서 사용 중이므로 실제 store 를 그대로 사용해도 무방하다.
describe("EditorPage 인증 가드", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
// 기타 요청은 200 으로 처리.
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<EditorPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+9
View File
@@ -73,6 +73,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 페이지/캔버스 배경", () => {
it("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
const { container } = render(<EditorPage />);
+9
View File
@@ -89,6 +89,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
afterEach(() => {
cleanup();
});
+93 -7
View File
@@ -96,6 +96,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 프로젝트 저장/불러오기", () => {
it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue({
@@ -135,7 +144,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
),
).toBe(true);
});
const localKey = "pb:project:save-test-slug";
@@ -147,7 +160,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(Array.isArray(parsed.contentJson)).toBe(true);
expect(parsed.contentJson[0].id).toBe("blk_1");
const [url, options] = fetchMock.mock.calls[0] as any;
const projectCall = fetchMock.mock.calls.find(
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
) as any;
const [url, options] = projectCall;
expect(url).toBe("/api/projects");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
@@ -162,6 +179,55 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(message).toBeTruthy();
});
it("서버가 409 를 반환하면 이미 사용 중인 프로젝트 주소라는 에러 메시지를 보여줘야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects" && method === "POST") {
return Promise.resolve({
ok: false,
status: 409,
json: async () => ({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
} as any);
}
// 인증 가드용 /api/auth/me 등은 성공 응답만 내려주면 된다.
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({}),
} as any);
});
vi.stubGlobal("fetch", fetchMock);
const { rerender } = render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "충돌 테스트 프로젝트" } });
fireEvent.change(slugInput, { target: { value: "conflict-slug" } });
rerender(<EditorPage />);
const saveButton = screen.getByText("저장 (로컬 + 서버)");
fireEvent.click(saveButton);
const message = await screen.findByText("이미 다른 사용자가 사용 중인 프로젝트 주소입니다.");
expect(message).toBeTruthy();
});
it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => {
const slug = "local-test-slug";
const localKey = `pb:project:${slug}`;
@@ -210,7 +276,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
});
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
expect(fetchMock).not.toHaveBeenCalled();
const serverCalls = fetchMock.mock.calls.filter(
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
);
expect(serverCalls.length).toBe(0);
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
expect(message).toBeTruthy();
@@ -253,10 +323,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
fireEvent.click(loadButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
fetchMock.mock.calls.some(
([url]: any[]) => url === `/api/projects/${slug}`,
),
).toBe(true);
});
const [url] = fetchMock.mock.calls[0] as any;
const serverCall = fetchMock.mock.calls.find(
([url]: any[]) => url === `/api/projects/${slug}`,
) as any;
const [url] = serverCall;
expect(url).toBe(`/api/projects/${slug}`);
await waitFor(() => {
@@ -293,10 +371,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
fireEvent.click(deleteMenuItem);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
),
).toBe(true);
});
const [url, options] = fetchMock.mock.calls[0] as any;
const deleteCall = fetchMock.mock.calls.find(
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
) as any;
const [url, options] = deleteCall;
expect(url).toBe(`/api/projects/${slug}`);
expect(options.method).toBe("DELETE");
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
// 섹션 배경 이미지 TDD
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
+9
View File
@@ -76,6 +76,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 섹션 레이아웃 속성", () => {
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
const section: Block = {
+9
View File
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 선택 포커스", () => {
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
const blocks: Block[] = [
+9
View File
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("EditorPage - 텍스트 블록 스타일", () => {
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
describe("EditorPage - 비디오 블록 스타일", () => {
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import LoginPage from "@/app/login/page";
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
describe("LoginPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/login" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ id: "1", email: "user@example.com" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "로그인" });
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
await waitFor(() => {
const loginCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/login" && options?.method === "POST";
});
expect(loginCall).toBeDefined();
});
const loginCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/login") as any;
const [url, options] = loginCall;
expect(url).toBe("/api/auth/login");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(options.body);
expect(body.email).toBe("user@example.com");
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
it("로그인 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/login" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "로그인" });
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
fireEvent.change(passwordInput, { target: { value: "wrongpass" } });
fireEvent.click(submitButton);
const errorText = await screen.findByText(/이메일 또는 비밀번호가 올바르지 않습니다./);
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
});
+9
View File
@@ -56,6 +56,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("PreviewPage - 자동 복원", () => {
it("pb:autosave:<slug> 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => {
const savedBlocks: Block[] = [
+9
View File
@@ -39,6 +39,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
afterEach(() => {
cleanup();
});
+19 -2
View File
@@ -52,6 +52,15 @@ vi.mock("next/link", () => {
};
});
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: vi.fn(),
}),
};
});
describe("PreviewPage - ZIP Export", () => {
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
render(<PreviewPage />);
@@ -83,10 +92,18 @@ describe("PreviewPage - ZIP Export", () => {
fireEvent.click(exportButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
),
).toBe(true);
});
const [url, options] = fetchMock.mock.calls[0] as any;
const exportCall = fetchMock.mock.calls.find(
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
) as any;
const [url, options] = exportCall;
expect(url).toBe("/api/export");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import PreviewPage from "@/app/preview/page";
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
describe("PreviewPage 인증 가드", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
// 기타 요청은 200 으로 처리하되, 테스트에서는 사용하지 않는다.
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<PreviewPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+235
View File
@@ -2,6 +2,16 @@ import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import ProjectsPage from "@/app/projects/page";
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
// ProjectsPage 프로젝트 목록 TDD
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
@@ -228,4 +238,229 @@ describe("ProjectsPage - 프로젝트 목록", () => {
confirmSpy.mockRestore();
});
it("/api/projects 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/logout" && method === "POST") {
return Promise.resolve(new Response(JSON.stringify({ message: "ok" }), { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
await waitFor(() => {
const logoutCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/logout" && options?.method === "POST";
});
expect(logoutCall).toBeDefined();
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
it("/api/projects 가 500 을 반환하면 에러 메시지를 표시해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response("server error", {
status: 500,
headers: { "Content-Type": "text/plain" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
expect(
await screen.findByText(
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
),
).toBeTruthy();
});
it("단일 프로젝트 삭제가 실패하면 에러 메시지를 표시해야 한다", async () => {
const projects = [
{
id: "1",
title: "삭제 실패 프로젝트",
slug: "delete-fail-project",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/projects/delete-fail-project" && method === "DELETE") {
return Promise.resolve(new Response("fail", { status: 500 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
render(<ProjectsPage />);
expect(await screen.findByText("삭제 실패 프로젝트")).toBeTruthy();
const deleteButton = screen.getByText("삭제");
fireEvent.click(deleteButton);
expect(
await screen.findByText("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."),
).toBeTruthy();
// 삭제 실패이므로 여전히 목록에 남아 있어야 한다.
expect(screen.getByText("삭제 실패 프로젝트")).toBeTruthy();
confirmSpy.mockRestore();
});
it("일괄 삭제에서 일부만 성공하면 에러 메시지를 표시해야 한다", async () => {
const projects = [
{
id: "1",
title: "성공 프로젝트",
slug: "success-project",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
{
id: "2",
title: "실패 프로젝트",
slug: "fail-project",
status: "DRAFT",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/projects/success-project" && method === "DELETE") {
return Promise.resolve(new Response(null, { status: 200 }));
}
if (url === "/api/projects/fail-project" && method === "DELETE") {
return Promise.resolve(new Response("fail", { status: 500 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
render(<ProjectsPage />);
expect(await screen.findByText("성공 프로젝트")).toBeTruthy();
expect(await screen.findByText("실패 프로젝트")).toBeTruthy();
const checkboxSuccess = screen.getByLabelText("성공 프로젝트 선택");
const checkboxFail = screen.getByLabelText("실패 프로젝트 선택");
fireEvent.click(checkboxSuccess);
fireEvent.click(checkboxFail);
const bulkDeleteButton = screen.getByText("선택 삭제");
fireEvent.click(bulkDeleteButton);
expect(
await screen.findByText(
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
),
).toBeTruthy();
// 성공 프로젝트는 목록에서 사라지고, 실패 프로젝트는 남아 있어야 한다.
expect(screen.queryByText("성공 프로젝트")).toBeNull();
expect(screen.getByText("실패 프로젝트")).toBeTruthy();
confirmSpy.mockRestore();
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import SignupPage from "@/app/signup/page";
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
describe("SignupPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/signup" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ id: "1", email: "new@example.com" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
await waitFor(() => {
const signupCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/signup" && options?.method === "POST";
});
expect(signupCall).toBeDefined();
});
const signupCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/signup") as any;
const [url, options] = signupCall;
expect(url).toBe("/api/auth/signup");
expect(options.method).toBe("POST");
expect(options.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(options.body);
expect(body.email).toBe("new@example.com");
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
it("회원가입 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/signup" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ message: "이미 가입된 이메일입니다." }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "dup@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
const errorText = await screen.findByText(/이미 가입된 이메일입니다./);
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach } from "vitest";
// 앞으로 구현할 인증/보안 유틸리티 모듈을 대상으로 TDD를 진행한다.
// - 비밀번호 해시/검증 (bcrypt 기반)
// - JWT 액세스 토큰 발급/검증
// - 민감정보 JSON 암호화/복호화
import {
hashPassword,
verifyPassword,
signAccessToken,
verifyAccessToken,
encryptJson,
decryptJson,
} from "@/features/auth/authCrypto";
interface TestUser {
id: string;
email: string;
tokenVersion: number;
}
describe("authCrypto 유틸리티", () => {
beforeEach(() => {
// 테스트 환경에서 사용할 JWT/암호화 키를 설정한다.
process.env.AUTH_JWT_SECRET = "test-jwt-secret-key";
// 32바이트 키를 hex 로 표현 (예시)
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
});
describe("비밀번호 해시/검증", () => {
it("8자 이상 비밀번호를 bcrypt 해시로 안전하게 저장하고, 검증에 성공해야 한다", async () => {
const password = "securePass1";
const hash = await hashPassword(password);
expect(hash).toBeTypeOf("string");
expect(hash).not.toBe(password);
const ok = await verifyPassword(password, hash);
const fail = await verifyPassword("wrongPass", hash);
expect(ok).toBe(true);
expect(fail).toBe(false);
});
it("8자 미만 비밀번호는 해시 시도 시 오류를 발생시켜야 한다", async () => {
const shortPassword = "short"; // 5자
await expect(hashPassword(shortPassword)).rejects.toThrow();
});
});
describe("JWT 액세스 토큰", () => {
it("유저 정보를 기반으로 JWT 액세스 토큰을 발급하고, 검증 시 동일한 정보가 나와야 한다", async () => {
const user: TestUser = {
id: "user-1",
email: "user@example.com",
tokenVersion: 1,
};
const token = await signAccessToken(user);
expect(token).toBeTypeOf("string");
expect(token.length).toBeGreaterThan(10);
const payload = await verifyAccessToken(token);
expect(payload).not.toBeNull();
expect(payload?.sub).toBe(user.id);
expect(payload?.email).toBe(user.email);
expect(payload?.tokenVersion).toBe(user.tokenVersion);
const anyPayload = payload as any;
const iat = anyPayload.iat;
const exp = anyPayload.exp;
expect(typeof iat).toBe("number");
expect(typeof exp).toBe("number");
expect(exp > iat).toBe(true);
});
it("잘못된 토큰이나 시크릿으로 검증할 경우 null 을 반환해야 한다", async () => {
const user: TestUser = {
id: "user-2",
email: "user2@example.com",
tokenVersion: 3,
};
const token = await signAccessToken(user);
// 시크릿을 바꿔서 검증 실패 상황을 만든다.
process.env.AUTH_JWT_SECRET = "other-secret";
const payload = await verifyAccessToken(token);
expect(payload).toBeNull();
});
});
describe("민감정보 JSON 암호화/복호화", () => {
it("JSON 객체를 암호화한 뒤 복호화하면 원래 객체와 동일해야 한다", async () => {
const original = {
cardToken: "tok_123",
customerId: "cus_456",
meta: { plan: "pro", country: "KR" },
};
const ciphertext = await encryptJson(original);
expect(ciphertext).toBeTypeOf("string");
expect(ciphertext).not.toContain("tok_123");
expect(ciphertext).not.toContain("cus_456");
const decrypted = await decryptJson<typeof original>(ciphertext);
expect(decrypted).toEqual(original);
});
it("암호화 문자열이 손상되었거나 키가 잘못된 경우 복호화 시 오류를 발생시켜야 한다", async () => {
const original = { secret: "value" };
const ciphertext = await encryptJson(original);
// 중간 일부를 잘라 손상시킨다.
const broken = ciphertext.slice(0, Math.floor(ciphertext.length / 2));
await expect(decryptJson(broken as string)).rejects.toThrow();
// 키를 바꿔서 복호화 시도
process.env.AUTH_ENCRYPTION_KEY = "ffffffffffffffffffffffffffffffff";
await expect(decryptJson(ciphertext)).rejects.toThrow();
});
});
});
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster