From 64e4a592444da5449f092998030bc22a278adc40 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 30 Nov 2025 23:08:38 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8,=20=EA=B0=80=EC=9E=85=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 137 ++++++++- package.json | 4 + prisma/schema.prisma | 15 + src/app/api/auth/login/route.ts | 55 ++++ src/app/api/auth/logout/route.ts | 19 ++ src/app/api/auth/me/route.ts | 44 +++ src/app/api/auth/signup/route.ts | 70 +++++ src/app/api/projects/[slug]/route.ts | 63 ++++- src/app/api/projects/route.ts | 61 +++- src/app/editor/page.tsx | 34 +++ src/app/login/page.tsx | 131 +++++++++ src/app/preview/page.tsx | 23 ++ src/app/projects/page.tsx | 60 +++- src/app/signup/page.tsx | 130 +++++++++ src/features/auth/authCrypto.ts | 146 ++++++++++ tests/api/auth.spec.ts | 266 ++++++++++++++++++ tests/api/projects.spec.ts | 230 ++++++++++++++- tests/e2e/auth.spec.ts | 112 ++++++++ tests/e2e/projects.spec.ts | 141 ++++++++++ tests/unit/EditorAutosave.spec.tsx | 9 + tests/unit/EditorButtonBlockStyle.spec.tsx | 9 + tests/unit/EditorDividerBlockStyle.spec.tsx | 9 + tests/unit/EditorExportPreview.spec.tsx | 9 + tests/unit/EditorFormBlocksStyle.spec.tsx | 9 + tests/unit/EditorImageBlockStyle.spec.tsx | 9 + tests/unit/EditorJsonExportImport.spec.tsx | 9 + tests/unit/EditorLayoutScroll.spec.tsx | 9 + tests/unit/EditorListBlockStyle.spec.tsx | 9 + tests/unit/EditorMultiSelect.spec.tsx | 9 + tests/unit/EditorPageAuth.spec.tsx | 55 ++++ tests/unit/EditorPageBackground.spec.tsx | 9 + tests/unit/EditorPageShortcuts.spec.tsx | 9 + tests/unit/EditorProjectSaveLoad.spec.tsx | 100 ++++++- .../EditorSectionBackgroundImage.spec.tsx | 9 + tests/unit/EditorSectionLayout.spec.tsx | 9 + tests/unit/EditorSelectionFocus.spec.tsx | 9 + tests/unit/EditorTextBlockStyle.spec.tsx | 9 + tests/unit/EditorVideoBlockStyle.spec.tsx | 9 + tests/unit/LoginPage.spec.tsx | 150 ++++++++++ tests/unit/PreviewAutosave.spec.tsx | 9 + tests/unit/PreviewCanvasPreset.spec.tsx | 9 + tests/unit/PreviewExport.spec.tsx | 21 +- tests/unit/PreviewPageAuth.spec.tsx | 53 ++++ tests/unit/ProjectsPage.spec.tsx | 235 ++++++++++++++++ tests/unit/SignupPage.spec.tsx | 150 ++++++++++ tests/unit/authCrypto.spec.ts | 133 +++++++++ uploads/0fd8e4ad-9560-4c1c-8853-e6dfd9ce74e9 | 1 + uploads/2e4e4c05-0cfd-4b25-ad52-102563ae661b | 1 + uploads/75f01015-8783-4c35-b257-ecadd462ea76 | 1 + uploads/9fd6293b-b8e0-4db8-b1ec-1dcd66c180ec | 1 + uploads/f90b5450-f259-4a8b-9021-bc2b6ba10c8a | 1 + uploads/fd8ae851-2d18-479e-8f5a-88ec3d5fa0aa | 1 + uploads/test-image-1764506510136 | 1 + uploads/test-image-1764506710699 | 1 + uploads/test-image-1764506858400 | 1 + uploads/test-image-1764506987614 | 1 + uploads/test-image-1764507070455 | 1 + uploads/test-image-1764511708892 | 1 + uploads/test-section-bg-1764506510152 | 1 + uploads/test-section-bg-1764506710727 | 1 + uploads/test-section-bg-1764506858417 | 1 + uploads/test-section-bg-1764506987632 | 1 + uploads/test-section-bg-1764507070478 | 1 + uploads/test-section-bg-1764511708907 | 1 + uploads/test-section-bg-video-1764506510177 | 1 + uploads/test-section-bg-video-1764506710778 | 1 + uploads/test-section-bg-video-1764506858451 | 1 + uploads/test-section-bg-video-1764506987655 | 1 + uploads/test-section-bg-video-1764507070528 | 1 + uploads/test-section-bg-video-1764511708929 | 1 + uploads/test-video-1764506510172 | 1 + uploads/test-video-1764506710766 | 1 + uploads/test-video-1764506858440 | 1 + uploads/test-video-1764506987650 | 1 + uploads/test-video-1764507070524 | 1 + uploads/test-video-1764511708924 | 1 + uploads/test-video-poster-1764506510142 | 1 + uploads/test-video-poster-1764506710706 | 1 + uploads/test-video-poster-1764506858407 | 1 + uploads/test-video-poster-1764506987619 | 1 + uploads/test-video-poster-1764507070461 | 1 + uploads/test-video-poster-1764511708899 | 1 + 82 files changed, 2817 insertions(+), 28 deletions(-) create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/signup/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/app/signup/page.tsx create mode 100644 src/features/auth/authCrypto.ts create mode 100644 tests/api/auth.spec.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/projects.spec.ts create mode 100644 tests/unit/EditorPageAuth.spec.tsx create mode 100644 tests/unit/LoginPage.spec.tsx create mode 100644 tests/unit/PreviewPageAuth.spec.tsx create mode 100644 tests/unit/SignupPage.spec.tsx create mode 100644 tests/unit/authCrypto.spec.ts create mode 100644 uploads/0fd8e4ad-9560-4c1c-8853-e6dfd9ce74e9 create mode 100644 uploads/2e4e4c05-0cfd-4b25-ad52-102563ae661b create mode 100644 uploads/75f01015-8783-4c35-b257-ecadd462ea76 create mode 100644 uploads/9fd6293b-b8e0-4db8-b1ec-1dcd66c180ec create mode 100644 uploads/f90b5450-f259-4a8b-9021-bc2b6ba10c8a create mode 100644 uploads/fd8ae851-2d18-479e-8f5a-88ec3d5fa0aa create mode 100644 uploads/test-image-1764506510136 create mode 100644 uploads/test-image-1764506710699 create mode 100644 uploads/test-image-1764506858400 create mode 100644 uploads/test-image-1764506987614 create mode 100644 uploads/test-image-1764507070455 create mode 100644 uploads/test-image-1764511708892 create mode 100644 uploads/test-section-bg-1764506510152 create mode 100644 uploads/test-section-bg-1764506710727 create mode 100644 uploads/test-section-bg-1764506858417 create mode 100644 uploads/test-section-bg-1764506987632 create mode 100644 uploads/test-section-bg-1764507070478 create mode 100644 uploads/test-section-bg-1764511708907 create mode 100644 uploads/test-section-bg-video-1764506510177 create mode 100644 uploads/test-section-bg-video-1764506710778 create mode 100644 uploads/test-section-bg-video-1764506858451 create mode 100644 uploads/test-section-bg-video-1764506987655 create mode 100644 uploads/test-section-bg-video-1764507070528 create mode 100644 uploads/test-section-bg-video-1764511708929 create mode 100644 uploads/test-video-1764506510172 create mode 100644 uploads/test-video-1764506710766 create mode 100644 uploads/test-video-1764506858440 create mode 100644 uploads/test-video-1764506987650 create mode 100644 uploads/test-video-1764507070524 create mode 100644 uploads/test-video-1764511708924 create mode 100644 uploads/test-video-poster-1764506510142 create mode 100644 uploads/test-video-poster-1764506710706 create mode 100644 uploads/test-video-poster-1764506858407 create mode 100644 uploads/test-video-poster-1764506987619 create mode 100644 uploads/test-video-poster-1764507070461 create mode 100644 uploads/test-video-poster-1764511708899 diff --git a/package-lock.json b/package-lock.json index 0bbfb2f..65855e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 3c69f7b..5b4aa7b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd2a22a..53b45bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..62d26a1 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..7662808 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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; +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..86e95d3 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts new file mode 100644 index 0000000..3413ed7 --- /dev/null +++ b/src/app/api/auth/signup/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/projects/[slug]/route.ts b/src/app/api/projects/[slug]/route.ts index c6c5f5b..1b6c076 100644 --- a/src/app/api/projects/[slug]/route.ts +++ b/src/app/api/projects/[slug]/route.ts @@ -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 { + 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 }, }); diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 50e528a..7038adc 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -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 { + 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, }, }); diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 3b876e8..d2d4977 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -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; } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..9e83cf2 --- /dev/null +++ b/src/app/login/page.tsx @@ -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(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 ( +
+
+

로그인

+

프로젝트를 관리하려면 먼저 계정으로 로그인하세요.

+ + {error &&

{error}

} + +
+
+ + 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 + /> +
+ +
+ + 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} + /> +
+ + +
+ +

+ 아직 계정이 없다면 + {" "} + + 회원가입 + + 을 진행해 주세요. +

+
+
+ ); +} diff --git a/src/app/preview/page.tsx b/src/app/preview/page.tsx index 2187bb3..e31e53f 100644 --- a/src/app/preview/page.tsx +++ b/src/app/preview/page.tsx @@ -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; diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 4d91eca..1d85a39 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -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([]); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); + const [errorMessage, setErrorMessage] = useState(null); const [selectedSlugs, setSelectedSlugs] = useState([]); 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() {

저장된 프로젝트들을 한 눈에 볼 수 있는 목록

+
{status === "error" && ( -

프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.

+

+ {errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."} +

)} {projects.length === 0 && status === "idle" && (

아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.

diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..298537e --- /dev/null +++ b/src/app/signup/page.tsx @@ -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(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 ( +
+
+

회원가입

+

새 계정을 생성한 뒤 프로젝트를 저장하고 관리할 수 있습니다.

+ + {error &&

{error}

} + +
+
+ + 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 + /> +
+ +
+ + 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} + /> +
+ + +
+ +

+ 이미 계정이 있다면 + {" "} + + 로그인 + + 으로 이동해 주세요. +

+
+
+ ); +} diff --git a/src/features/auth/authCrypto.ts b/src/features/auth/authCrypto.ts new file mode 100644 index 0000000..a29db2b --- /dev/null +++ b/src/features/auth/authCrypto.ts @@ -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 { + 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 { + 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 { + 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 { + 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(value: T): Promise { + 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(ciphertext: string): Promise { + 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("민감정보 복호화에 실패했습니다."); + } +} diff --git a/tests/api/auth.spec.ts b/tests/api/auth.spec.ts new file mode 100644 index 0000000..0be9fa2 --- /dev/null +++ b/tests/api/auth.spec.ts @@ -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); + }); + }); +}); diff --git a/tests/api/projects.spec.ts b/tests/api/projects.spec.ts index d09b108..8390d29 100644 --- a/tests/api/projects.spec.ts +++ b/tests/api/projects.spec.ts @@ -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); + }); }); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..6d79041 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -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(); +}); diff --git a/tests/e2e/projects.spec.ts b/tests/e2e/projects.spec.ts new file mode 100644 index 0000000..52d5ae2 --- /dev/null +++ b/tests/e2e/projects.spec.ts @@ -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 & { 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); +}); diff --git a/tests/unit/EditorAutosave.spec.tsx b/tests/unit/EditorAutosave.spec.tsx index bf9fdeb..ffa0b86 100644 --- a/tests/unit/EditorAutosave.spec.tsx +++ b/tests/unit/EditorAutosave.spec.tsx @@ -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: 키로 자동 저장해야 한다", () => { const storageProto = Object.getPrototypeOf(window.localStorage); diff --git a/tests/unit/EditorButtonBlockStyle.spec.tsx b/tests/unit/EditorButtonBlockStyle.spec.tsx index e2e9465..db44a18 100644 --- a/tests/unit/EditorButtonBlockStyle.spec.tsx +++ b/tests/unit/EditorButtonBlockStyle.spec.tsx @@ -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[] = [ diff --git a/tests/unit/EditorDividerBlockStyle.spec.tsx b/tests/unit/EditorDividerBlockStyle.spec.tsx index ec9fcce..4ecd9c2 100644 --- a/tests/unit/EditorDividerBlockStyle.spec.tsx +++ b/tests/unit/EditorDividerBlockStyle.spec.tsx @@ -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); diff --git a/tests/unit/EditorExportPreview.spec.tsx b/tests/unit/EditorExportPreview.spec.tsx index dd0c08d..44af753 100644 --- a/tests/unit/EditorExportPreview.spec.tsx +++ b/tests/unit/EditorExportPreview.spec.tsx @@ -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(); diff --git a/tests/unit/EditorFormBlocksStyle.spec.tsx b/tests/unit/EditorFormBlocksStyle.spec.tsx index 0fa5f95..0a994b7 100644 --- a/tests/unit/EditorFormBlocksStyle.spec.tsx +++ b/tests/unit/EditorFormBlocksStyle.spec.tsx @@ -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); diff --git a/tests/unit/EditorImageBlockStyle.spec.tsx b/tests/unit/EditorImageBlockStyle.spec.tsx index 0300894..7a33451 100644 --- a/tests/unit/EditorImageBlockStyle.spec.tsx +++ b/tests/unit/EditorImageBlockStyle.spec.tsx @@ -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[] = [ diff --git a/tests/unit/EditorJsonExportImport.spec.tsx b/tests/unit/EditorJsonExportImport.spec.tsx index ac9df73..7727ff2 100644 --- a/tests/unit/EditorJsonExportImport.spec.tsx +++ b/tests/unit/EditorJsonExportImport.spec.tsx @@ -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); diff --git a/tests/unit/EditorLayoutScroll.spec.tsx b/tests/unit/EditorLayoutScroll.spec.tsx index 55ff537..2cc7f9d 100644 --- a/tests/unit/EditorLayoutScroll.spec.tsx +++ b/tests/unit/EditorLayoutScroll.spec.tsx @@ -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(); diff --git a/tests/unit/EditorListBlockStyle.spec.tsx b/tests/unit/EditorListBlockStyle.spec.tsx index 0f111fc..38d612b 100644 --- a/tests/unit/EditorListBlockStyle.spec.tsx +++ b/tests/unit/EditorListBlockStyle.spec.tsx @@ -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); diff --git a/tests/unit/EditorMultiSelect.spec.tsx b/tests/unit/EditorMultiSelect.spec.tsx index b05a7a1..6dd339c 100644 --- a/tests/unit/EditorMultiSelect.spec.tsx +++ b/tests/unit/EditorMultiSelect.spec.tsx @@ -76,6 +76,15 @@ vi.mock("next/link", () => { }; }); +vi.mock("next/navigation", () => { + return { + __esModule: true, + useRouter: () => ({ + push: vi.fn(), + }), + }; +}); + function setupThreeBlocks() { const blocks: Block[] = [ { diff --git a/tests/unit/EditorPageAuth.spec.tsx b/tests/unit/EditorPageAuth.spec.tsx new file mode 100644 index 0000000..66dc616 --- /dev/null +++ b/tests/unit/EditorPageAuth.spec.tsx @@ -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(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith("/login"); + }); + }); +}); diff --git a/tests/unit/EditorPageBackground.spec.tsx b/tests/unit/EditorPageBackground.spec.tsx index 2d88bf0..1583958 100644 --- a/tests/unit/EditorPageBackground.spec.tsx +++ b/tests/unit/EditorPageBackground.spec.tsx @@ -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(); diff --git a/tests/unit/EditorPageShortcuts.spec.tsx b/tests/unit/EditorPageShortcuts.spec.tsx index 4faece8..bfb4db0 100644 --- a/tests/unit/EditorPageShortcuts.spec.tsx +++ b/tests/unit/EditorPageShortcuts.spec.tsx @@ -89,6 +89,15 @@ vi.mock("next/link", () => { }; }); +vi.mock("next/navigation", () => { + return { + __esModule: true, + useRouter: () => ({ + push: vi.fn(), + }), + }; +}); + afterEach(() => { cleanup(); }); diff --git a/tests/unit/EditorProjectSaveLoad.spec.tsx b/tests/unit/EditorProjectSaveLoad.spec.tsx index 1086bdb..a6e228b 100644 --- a/tests/unit/EditorProjectSaveLoad.spec.tsx +++ b/tests/unit/EditorProjectSaveLoad.spec.tsx @@ -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(); + + 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(); + + 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"); diff --git a/tests/unit/EditorSectionBackgroundImage.spec.tsx b/tests/unit/EditorSectionBackgroundImage.spec.tsx index 1e062c2..d2cd2b5 100644 --- a/tests/unit/EditorSectionBackgroundImage.spec.tsx +++ b/tests/unit/EditorSectionBackgroundImage.spec.tsx @@ -74,6 +74,15 @@ vi.mock("next/link", () => { }; }); +vi.mock("next/navigation", () => { + return { + __esModule: true, + useRouter: () => ({ + push: vi.fn(), + }), + }; +}); + // 섹션 배경 이미지 TDD // - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다. diff --git a/tests/unit/EditorSectionLayout.spec.tsx b/tests/unit/EditorSectionLayout.spec.tsx index 4ab1c3f..edb2bae 100644 --- a/tests/unit/EditorSectionLayout.spec.tsx +++ b/tests/unit/EditorSectionLayout.spec.tsx @@ -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 = { diff --git a/tests/unit/EditorSelectionFocus.spec.tsx b/tests/unit/EditorSelectionFocus.spec.tsx index 646533f..91730ce 100644 --- a/tests/unit/EditorSelectionFocus.spec.tsx +++ b/tests/unit/EditorSelectionFocus.spec.tsx @@ -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[] = [ diff --git a/tests/unit/EditorTextBlockStyle.spec.tsx b/tests/unit/EditorTextBlockStyle.spec.tsx index 229a85a..c5cabf7 100644 --- a/tests/unit/EditorTextBlockStyle.spec.tsx +++ b/tests/unit/EditorTextBlockStyle.spec.tsx @@ -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[] = [ diff --git a/tests/unit/EditorVideoBlockStyle.spec.tsx b/tests/unit/EditorVideoBlockStyle.spec.tsx index daf1c91..70e9ff8 100644 --- a/tests/unit/EditorVideoBlockStyle.spec.tsx +++ b/tests/unit/EditorVideoBlockStyle.spec.tsx @@ -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 를 가져야 한다", () => { diff --git a/tests/unit/LoginPage.spec.tsx b/tests/unit/LoginPage.spec.tsx new file mode 100644 index 0000000..0f713ba --- /dev/null +++ b/tests/unit/LoginPage.spec.tsx @@ -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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me"); + expect(pushMock).toHaveBeenCalledWith("/projects"); + }); + }); +}); diff --git a/tests/unit/PreviewAutosave.spec.tsx b/tests/unit/PreviewAutosave.spec.tsx index 07189bf..1291671 100644 --- a/tests/unit/PreviewAutosave.spec.tsx +++ b/tests/unit/PreviewAutosave.spec.tsx @@ -56,6 +56,15 @@ vi.mock("next/link", () => { }; }); +vi.mock("next/navigation", () => { + return { + __esModule: true, + useRouter: () => ({ + push: vi.fn(), + }), + }; +}); + describe("PreviewPage - 자동 복원", () => { it("pb:autosave: 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => { const savedBlocks: Block[] = [ diff --git a/tests/unit/PreviewCanvasPreset.spec.tsx b/tests/unit/PreviewCanvasPreset.spec.tsx index 72e56ca..0f2215b 100644 --- a/tests/unit/PreviewCanvasPreset.spec.tsx +++ b/tests/unit/PreviewCanvasPreset.spec.tsx @@ -39,6 +39,15 @@ vi.mock("next/link", () => { }; }); +vi.mock("next/navigation", () => { + return { + __esModule: true, + useRouter: () => ({ + push: vi.fn(), + }), + }; +}); + afterEach(() => { cleanup(); }); diff --git a/tests/unit/PreviewExport.spec.tsx b/tests/unit/PreviewExport.spec.tsx index 8f0394d..cd6b376 100644 --- a/tests/unit/PreviewExport.spec.tsx +++ b/tests/unit/PreviewExport.spec.tsx @@ -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(); @@ -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"); diff --git a/tests/unit/PreviewPageAuth.spec.tsx b/tests/unit/PreviewPageAuth.spec.tsx new file mode 100644 index 0000000..55f8472 --- /dev/null +++ b/tests/unit/PreviewPageAuth.spec.tsx @@ -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(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith("/login"); + }); + }); +}); diff --git a/tests/unit/ProjectsPage.spec.tsx b/tests/unit/ProjectsPage.spec.tsx index 4f84778..8d9e4c1 100644 --- a/tests/unit/ProjectsPage.spec.tsx +++ b/tests/unit/ProjectsPage.spec.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); }); diff --git a/tests/unit/SignupPage.spec.tsx b/tests/unit/SignupPage.spec.tsx new file mode 100644 index 0000000..869f336 --- /dev/null +++ b/tests/unit/SignupPage.spec.tsx @@ -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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me"); + expect(pushMock).toHaveBeenCalledWith("/projects"); + }); + }); +}); diff --git a/tests/unit/authCrypto.spec.ts b/tests/unit/authCrypto.spec.ts new file mode 100644 index 0000000..55e01e5 --- /dev/null +++ b/tests/unit/authCrypto.spec.ts @@ -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(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(); + }); + }); +}); diff --git a/uploads/0fd8e4ad-9560-4c1c-8853-e6dfd9ce74e9 b/uploads/0fd8e4ad-9560-4c1c-8853-e6dfd9ce74e9 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/0fd8e4ad-9560-4c1c-8853-e6dfd9ce74e9 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/2e4e4c05-0cfd-4b25-ad52-102563ae661b b/uploads/2e4e4c05-0cfd-4b25-ad52-102563ae661b new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/2e4e4c05-0cfd-4b25-ad52-102563ae661b @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/75f01015-8783-4c35-b257-ecadd462ea76 b/uploads/75f01015-8783-4c35-b257-ecadd462ea76 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/75f01015-8783-4c35-b257-ecadd462ea76 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/9fd6293b-b8e0-4db8-b1ec-1dcd66c180ec b/uploads/9fd6293b-b8e0-4db8-b1ec-1dcd66c180ec new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/9fd6293b-b8e0-4db8-b1ec-1dcd66c180ec @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/f90b5450-f259-4a8b-9021-bc2b6ba10c8a b/uploads/f90b5450-f259-4a8b-9021-bc2b6ba10c8a new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/f90b5450-f259-4a8b-9021-bc2b6ba10c8a @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/fd8ae851-2d18-479e-8f5a-88ec3d5fa0aa b/uploads/fd8ae851-2d18-479e-8f5a-88ec3d5fa0aa new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/fd8ae851-2d18-479e-8f5a-88ec3d5fa0aa @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-image-1764506510136 b/uploads/test-image-1764506510136 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764506510136 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764506710699 b/uploads/test-image-1764506710699 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764506710699 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764506858400 b/uploads/test-image-1764506858400 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764506858400 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764506987614 b/uploads/test-image-1764506987614 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764506987614 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764507070455 b/uploads/test-image-1764507070455 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764507070455 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764511708892 b/uploads/test-image-1764511708892 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764511708892 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764506510152 b/uploads/test-section-bg-1764506510152 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764506510152 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764506710727 b/uploads/test-section-bg-1764506710727 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764506710727 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764506858417 b/uploads/test-section-bg-1764506858417 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764506858417 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764506987632 b/uploads/test-section-bg-1764506987632 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764506987632 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764507070478 b/uploads/test-section-bg-1764507070478 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764507070478 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764511708907 b/uploads/test-section-bg-1764511708907 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764511708907 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764506510177 b/uploads/test-section-bg-video-1764506510177 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764506510177 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764506710778 b/uploads/test-section-bg-video-1764506710778 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764506710778 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764506858451 b/uploads/test-section-bg-video-1764506858451 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764506858451 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764506987655 b/uploads/test-section-bg-video-1764506987655 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764506987655 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764507070528 b/uploads/test-section-bg-video-1764507070528 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764507070528 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764511708929 b/uploads/test-section-bg-video-1764511708929 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764511708929 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-video-1764506510172 b/uploads/test-video-1764506510172 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764506510172 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764506710766 b/uploads/test-video-1764506710766 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764506710766 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764506858440 b/uploads/test-video-1764506858440 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764506858440 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764506987650 b/uploads/test-video-1764506987650 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764506987650 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764507070524 b/uploads/test-video-1764507070524 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764507070524 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764511708924 b/uploads/test-video-1764511708924 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764511708924 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-poster-1764506510142 b/uploads/test-video-poster-1764506510142 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764506510142 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764506710706 b/uploads/test-video-poster-1764506710706 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764506710706 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764506858407 b/uploads/test-video-poster-1764506858407 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764506858407 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764506987619 b/uploads/test-video-poster-1764506987619 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764506987619 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764507070461 b/uploads/test-video-poster-1764507070461 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764507070461 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764511708899 b/uploads/test-video-poster-1764511708899 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764511708899 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file -- 2.52.0