From 21b9f03e9d7a8b2df1f6fba55062b70f3f51659b Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 00:36:42 +0200 Subject: [PATCH] feat(m3): auth & user management with session, tests, and e2e - Custom error classes (lib/errors.ts) and signed-cookie session (lib/auth/session.ts, HMAC-SHA256, httpOnly+sameSite=lax) - UserRepository + AuthService (bcrypt hashing, role/status checks, register/login/logout/getCurrentUser/updateProfile) - Auth helpers (getCurrentUser), user validator, API error handler - API routes: register (auto-login), login, logout, me, PATCH users/me, GET admin/migrations (admin-only guard) - middleware.ts redirects unauthenticated page access to /login - Screens: login (login/register toggle), profile, dashboard skeleton, home redirect - vitest: exclude e2e specs, setupFiles for SESSION_SECRET - playwright: run migrate before dev server, set SESSION_SECRET - Unit tests (UserRepository, AuthService, session), integration auth-flow, e2e auth.spec (register/login/logout/protected/401) --- app/api/admin/migrations/route.ts | 27 +++ app/api/auth/login/route.ts | 34 ++++ app/api/auth/logout/route.ts | 10 + app/api/auth/me/route.ts | 14 ++ app/api/auth/register/route.ts | 36 ++++ app/api/users/me/route.ts | 36 ++++ app/dashboard/page.tsx | 27 +++ app/login/page.tsx | 131 +++++++++++++ app/page.tsx | 18 +- app/profile/page.tsx | 146 +++++++++++++++ lib/api/handleError.ts | 28 +++ lib/auth/constants.ts | 6 + lib/auth/getCurrentUser.ts | 33 ++++ lib/auth/session.ts | 93 +++++++++ lib/errors.ts | 55 ++++++ lib/validators/userValidator.ts | 95 ++++++++++ middleware.ts | 25 +++ playwright.config.ts | 5 +- repositories/UserRepository.ts | 124 ++++++++++++ services/AuthService.ts | 117 ++++++++++++ tests/e2e/auth.spec.ts | 81 ++++++++ tests/integration/auth-flow.test.ts | 89 +++++++++ tests/setup.ts | 2 + tests/unit/lib/auth/session.test.ts | 44 +++++ .../unit/repositories/UserRepository.test.ts | 127 +++++++++++++ tests/unit/services/AuthService.test.ts | 177 ++++++++++++++++++ vitest.config.ts | 8 + 27 files changed, 1578 insertions(+), 10 deletions(-) create mode 100644 app/api/admin/migrations/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/users/me/route.ts create mode 100644 app/dashboard/page.tsx create mode 100644 app/login/page.tsx create mode 100644 app/profile/page.tsx create mode 100644 lib/api/handleError.ts create mode 100644 lib/auth/constants.ts create mode 100644 lib/auth/getCurrentUser.ts create mode 100644 lib/auth/session.ts create mode 100644 lib/errors.ts create mode 100644 lib/validators/userValidator.ts create mode 100644 middleware.ts create mode 100644 repositories/UserRepository.ts create mode 100644 services/AuthService.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/integration/auth-flow.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/unit/lib/auth/session.test.ts create mode 100644 tests/unit/repositories/UserRepository.test.ts create mode 100644 tests/unit/services/AuthService.test.ts diff --git a/app/api/admin/migrations/route.ts b/app/api/admin/migrations/route.ts new file mode 100644 index 0000000..234a689 --- /dev/null +++ b/app/api/admin/migrations/route.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; +import { NextResponse } from 'next/server'; +import { Migrator } from '@/lib/db/migrator'; +import { getDb } from '@/lib/db/sqlite'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { UnauthorizedError, ForbiddenError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET() { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + if (user.role !== 'system_admin') { + return handleApiError(new ForbiddenError('管理者のみアクセス可能です')); + } + + try { + const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations'); + const migrator = new Migrator(getDb(), migrationsDir); + return NextResponse.json({ migrations: migrator.getAppliedMigrations() }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..cd8cd51 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/services/AuthService'; +import { UserRepository } from '@/repositories/UserRepository'; +import { getDb } from '@/lib/db/sqlite'; +import { toPublicUser } from '@/lib/auth/getCurrentUser'; +import { setSessionCookie } from '@/lib/auth/session'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function POST(request: NextRequest) { + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const authService = new AuthService(new UserRepository(getDb())); + try { + const { user, token } = authService.login( + String(body.email ?? ''), + String(body.password ?? '') + ); + const response = NextResponse.json( + { user: toPublicUser(user) }, + { status: 200 } + ); + setSessionCookie(response, token); + return response; + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a43083b --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; +import { clearSessionCookie } from '@/lib/auth/session'; + +export const runtime = 'nodejs'; + +export async function POST() { + const response = NextResponse.json({ ok: true }); + clearSessionCookie(response); + return response; +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..ddae202 --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET() { + const user = await getCurrentUser(); + if (!user) { + return handleApiError(new UnauthorizedError()); + } + return NextResponse.json({ user: toPublicUser(user) }); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..4b75e4a --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/services/AuthService'; +import { UserRepository } from '@/repositories/UserRepository'; +import { getDb } from '@/lib/db/sqlite'; +import { toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createSessionToken, setSessionCookie } from '@/lib/auth/session'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function POST(request: NextRequest) { + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const authService = new AuthService(new UserRepository(getDb())); + try { + const user = authService.register({ + name: String(body.name ?? ''), + email: String(body.email ?? ''), + password: String(body.password ?? ''), + }); + // 登録成功と同時にログイン(セッションCookieを設定) + const response = NextResponse.json( + { user: toPublicUser(user) }, + { status: 201 } + ); + setSessionCookie(response, createSessionToken(user.id)); + return response; + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts new file mode 100644 index 0000000..0985338 --- /dev/null +++ b/app/api/users/me/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/services/AuthService'; +import { UserRepository } from '@/repositories/UserRepository'; +import { getDb } from '@/lib/db/sqlite'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function PATCH(request: NextRequest) { + const currentUser = await getCurrentUser(); + if (!currentUser) { + return handleApiError(new UnauthorizedError()); + } + + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const authService = new AuthService(new UserRepository(getDb())); + try { + const updated = authService.updateProfile(currentUser.id, { + name: typeof body.name === 'string' ? body.name : undefined, + email: typeof body.email === 'string' ? body.email : undefined, + avatarUrl: + typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined, + }); + return NextResponse.json({ user: toPublicUser(updated) }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..657490d --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; + +export const dynamic = 'force-dynamic'; + +export default async function DashboardPage() { + const user = await getCurrentUser(); + if (!user) { + redirect('/login'); + } + + return ( +
+
+
+

ダッシュボード

+ + {user.name} さん + +
+

+ プロジェクト機能は後続マイルストーンで実装されます。 +

+
+
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..7946d9b --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; + +type Mode = 'login' | 'register'; + +export default function LoginPage() { + const router = useRouter(); + const [mode, setMode] = useState('login'); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + + const endpoint = + mode === 'login' ? '/api/auth/login' : '/api/auth/register'; + const payload = + mode === 'login' ? { email, password } : { name, email, password }; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + router.push('/dashboard'); + return; + } + + const data = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(data?.error?.message ?? '処理に失敗しました'); + setLoading(false); + } + + return ( +
+
+

+ {mode === 'login' ? 'ログイン' : '新規登録'} +

+ +
+ {mode === 'register' && ( +
+ + setName(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+ )} +
+ + setEmail(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + setPassword(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + minLength={8} + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index b4f65ff..119158f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,12 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + export default function Home() { - return ( -
-

シンプルグループウェア

-

- プロジェクト単位で情報共有・タスク管理を行えるチームコラボレーションツール -

-
- ); + const router = useRouter(); + useEffect(() => { + router.replace('/dashboard'); + }, [router]); + return null; } diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..cd9a30f --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useEffect, useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import type { PublicUser } from '@/lib/auth/getCurrentUser'; + +export default function ProfilePage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [avatarUrl, setAvatarUrl] = useState(''); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + useEffect(() => { + fetch('/api/auth/me') + .then((res) => (res.ok ? res.json() : Promise.reject(res))) + .then((data: { user: PublicUser }) => { + setUser(data.user); + setName(data.user.name); + setEmail(data.user.email); + setAvatarUrl(data.user.avatarUrl ?? ''); + }) + .catch(() => router.push('/login')) + .finally(() => setLoading(false)); + }, [router]); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSaved(false); + const res = await fetch('/api/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, avatarUrl }), + }); + if (res.ok) { + const data = (await res.json()) as { user: PublicUser }; + setUser(data.user); + setSaved(true); + } else { + const data = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(data?.error?.message ?? '更新に失敗しました'); + } + } + + async function onLogout() { + await fetch('/api/auth/logout', { method: 'POST' }); + router.push('/login'); + } + + if (loading) { + return ( +
+

読み込み中...

+
+ ); + } + + return ( +
+
+
+

プロフィール

+ +
+ +
+
+ + setName(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + /> +
+
+ + setEmail(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + /> +
+
+ + setAvatarUrl(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + /> +
+ + {error && ( +

+ {error} +

+ )} + {saved && ( +

プロフィールを更新しました

+ )} + + +
+ + +

+ ロール: {user?.role} +

+
+
+ ); +} diff --git a/lib/api/handleError.ts b/lib/api/handleError.ts new file mode 100644 index 0000000..104d87d --- /dev/null +++ b/lib/api/handleError.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { AppError, ValidationError } from '@/lib/errors'; + +/** + * Service層のエラーをHTTPエラーレスポンスに変換する。 + * 期待されるエラー(AppError)は対応するステータスコードへ、 + * 予期せぬエラーは500として内部情報を隠蔽する。 + */ +export function handleApiError(error: unknown): NextResponse { + if (error instanceof AppError) { + const body: { error: { message: string; field?: string } } = { + error: { message: error.message }, + }; + if (error instanceof ValidationError && error.field) { + body.error.field = error.field; + } + return NextResponse.json(body, { status: error.status }); + } + console.error('Unexpected error:', error); + return NextResponse.json( + { error: { message: '内部エラーが発生しました' } }, + { status: 500 } + ); +} + +export function jsonError(status: number, message: string): NextResponse { + return NextResponse.json({ error: { message } }, { status }); +} diff --git a/lib/auth/constants.ts b/lib/auth/constants.ts new file mode 100644 index 0000000..12788f9 --- /dev/null +++ b/lib/auth/constants.ts @@ -0,0 +1,6 @@ +/** + * セッションCookie関連の定数。 + * Edge Runtime(middleware)からも安全に参照できるよう、依存を持たない定数のみ配置する。 + */ +export const SESSION_COOKIE = 'session'; +export const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; diff --git a/lib/auth/getCurrentUser.ts b/lib/auth/getCurrentUser.ts new file mode 100644 index 0000000..b211a61 --- /dev/null +++ b/lib/auth/getCurrentUser.ts @@ -0,0 +1,33 @@ +import { UserRepository } from '@/repositories/UserRepository'; +import { getDb } from '@/lib/db/sqlite'; +import { getSessionUserId } from '@/lib/auth/session'; +import type { User } from '@/lib/types'; + +/** + * APIレスポンス等で外部に公開するユーザー情報(passwordHashを除く) + */ +export type PublicUser = Omit; + +export function toPublicUser(user: User): PublicUser { + return { + id: user.id, + name: user.name, + email: user.email, + avatarUrl: user.avatarUrl, + role: user.role, + status: user.status, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} + +/** + * 現在のリクエストからログインユーザーを解決する。 + * 未認証の場合は null を返す。 + */ +export async function getCurrentUser(): Promise { + const userId = await getSessionUserId(); + if (userId === null) return null; + const userRepository = new UserRepository(getDb()); + return userRepository.findById(userId); +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..8346fa0 --- /dev/null +++ b/lib/auth/session.ts @@ -0,0 +1,93 @@ +import crypto from 'node:crypto'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS } from './constants'; + +/** + * 署名付きCookieによるステートレスセッション。 + * トークン形式: base64url(payloadJson).base64url(hmacSha256(secret, encodedPayload)) + * payload = { uid, iat } + */ + +function getSecret(): string { + const secret = process.env.SESSION_SECRET; + if (!secret) { + throw new Error('SESSION_SECRET is not configured'); + } + return secret; +} + +function sign(encodedPayload: string): string { + return crypto + .createHmac('sha256', getSecret()) + .update(encodedPayload) + .digest('base64url'); +} + +/** + * ユーザーIDからセッショントークンを生成する + */ +export function createSessionToken(userId: number): string { + const payload = JSON.stringify({ uid: userId, iat: Date.now() }); + const encoded = Buffer.from(payload, 'utf-8').toString('base64url'); + return `${encoded}.${sign(encoded)}`; +} + +/** + * セッショントークンを検証し、ユーザーIDを返す(不正時はnull) + */ +export function verifySessionToken(token: string): number | null { + const parts = token.split('.'); + if (parts.length !== 2) return null; + const [encoded, signature] = parts; + const expected = sign(encoded); + + const received = Buffer.from(signature); + const wanted = Buffer.from(expected); + if (received.length !== wanted.length) return null; + if (!crypto.timingSafeEqual(received, wanted)) return null; + + try { + const payload = JSON.parse( + Buffer.from(encoded, 'base64url').toString('utf-8') + ) as { uid?: unknown }; + if (typeof payload.uid !== 'number') return null; + return payload.uid; + } catch { + return null; + } +} + +/** + * 現在のリクエスト(Server Component / Route Handler)からセッションユーザーIDを取得する + */ +export async function getSessionUserId(): Promise { + const token = (await cookies()).get(SESSION_COOKIE)?.value; + if (!token) return null; + return verifySessionToken(token); +} + +/** + * レスポンスにセッションCookieを設定する + */ +export function setSessionCookie(res: NextResponse, token: string): void { + res.cookies.set(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: process.env.NODE_ENV === 'production', + maxAge: SESSION_MAX_AGE_SECONDS, + }); +} + +/** + * レスポンスのセッションCookieを削除する(ログアウト) + */ +export function clearSessionCookie(res: NextResponse): void { + res.cookies.set(SESSION_COOKIE, '', { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }); +} diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 0000000..977c5cd --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,55 @@ +/** + * アプリケーション全体で使用するカスタムエラー。 + * 各エラーは HTTP ステータスコードを保持し、Route Handler でレスポンスへの変換が容易。 + */ + +export class AppError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = this.constructor.name; + } +} + +export class ValidationError extends AppError { + constructor( + message: string, + public readonly field?: string + ) { + super(message, 400); + this.name = 'ValidationError'; + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = '認証が必要です') { + super(message, 401); + this.name = 'UnauthorizedError'; + } +} + +export class ForbiddenError extends AppError { + constructor(message: string = 'この操作を行う権限がありません') { + super(message, 403); + this.name = 'ForbiddenError'; + } +} + +export class NotFoundError extends AppError { + constructor( + public readonly resource: string, + public readonly id: number | string + ) { + super(`${resource} not found: ${id}`, 404); + this.name = 'NotFoundError'; + } +} + +export class ConflictError extends AppError { + constructor(message: string) { + super(message, 409); + this.name = 'ConflictError'; + } +} diff --git a/lib/validators/userValidator.ts b/lib/validators/userValidator.ts new file mode 100644 index 0000000..401441f --- /dev/null +++ b/lib/validators/userValidator.ts @@ -0,0 +1,95 @@ +import { ValidationError } from '@/lib/errors'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MAX_NAME_LENGTH = 100; +const MIN_PASSWORD_LENGTH = 8; +const MAX_AVATAR_URL_LENGTH = 500; + +export interface RegisterInput { + name: string; + email: string; + password: string; +} + +export interface LoginInput { + email: string; + password: string; +} + +export interface ProfileUpdateInput { + name?: string; + email?: string; + avatarUrl?: string; +} + +export function validateRegister(input: RegisterInput): void { + validateName(input.name); + validateEmail(input.email); + validatePassword(input.password); +} + +export function validateLogin(input: LoginInput): void { + if (!input.email) { + throw new ValidationError('メールアドレスを入力してください', 'email'); + } + if (!input.password) { + throw new ValidationError('パスワードを入力してください', 'password'); + } +} + +export function validateProfileUpdate(input: ProfileUpdateInput): void { + if ( + input.name === undefined && + input.email === undefined && + input.avatarUrl === undefined + ) { + throw new ValidationError('更新対象のフィールドを指定してください'); + } + if (input.name !== undefined) validateName(input.name); + if (input.email !== undefined) validateEmail(input.email); + if ( + input.avatarUrl !== undefined && + input.avatarUrl.length > MAX_AVATAR_URL_LENGTH + ) { + throw new ValidationError( + `アイコン画像URLは${MAX_AVATAR_URL_LENGTH}文字以内で入力してください`, + 'avatarUrl' + ); + } +} + +function validateName(name: string): void { + if (!name || !name.trim()) { + throw new ValidationError('表示名を入力してください', 'name'); + } + if (name.length > MAX_NAME_LENGTH) { + throw new ValidationError( + `表示名は${MAX_NAME_LENGTH}文字以内で入力してください`, + 'name' + ); + } +} + +function validateEmail(email: string): void { + if (!email) { + throw new ValidationError('メールアドレスを入力してください', 'email'); + } + if (!EMAIL_RE.test(email)) { + throw new ValidationError( + 'メールアドレスの形式が正しくありません', + 'email' + ); + } +} + +function validatePassword(password: string): void { + if (!password) { + throw new ValidationError('パスワードを入力してください', 'password'); + } + if (password.length < MIN_PASSWORD_LENGTH) { + throw new ValidationError( + `パスワードは${MIN_PASSWORD_LENGTH}文字以上で入力してください`, + 'password' + ); + } +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..d049c2f --- /dev/null +++ b/middleware.ts @@ -0,0 +1,25 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { SESSION_COOKIE } from '@/lib/auth/constants'; + +/** + * 認証ミドルウェア(Edge Runtime)。 + * セッションCookieの「存在」のみを確認し、未所持なら保護画面を /login へリダイレクトする。 + * HMAC検証・DB参照はNode.js RuntimeのgetCurrentUserで行う(ここでは行わない)。 + * APIルートは各Route Handlerで401を返すため、本ミドルウェアの対象外とする。 + */ +export function middleware(request: NextRequest) { + const token = request.cookies.get(SESSION_COOKIE)?.value; + if (!token) { + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('redirect', request.nextUrl.pathname); + return NextResponse.redirect(loginUrl); + } + return NextResponse.next(); +} + +export const config = { + matcher: [ + // /login, /api, _next 静的 assets, favicon を除外 + '/((?!login|api|_next/static|_next/image|favicon.ico).*)', + ], +}; diff --git a/playwright.config.ts b/playwright.config.ts index 801e49d..f3bf3d2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,9 +18,10 @@ export default defineConfig({ }, ], webServer: { - command: 'npm run dev', + command: 'npm run migrate && npm run dev', url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 120 * 1000, + env: { SESSION_SECRET: 'e2e-secret' }, }, }); diff --git a/repositories/UserRepository.ts b/repositories/UserRepository.ts new file mode 100644 index 0000000..e9580a3 --- /dev/null +++ b/repositories/UserRepository.ts @@ -0,0 +1,124 @@ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import type { User, UserRole, UserStatus } from '@/lib/types'; + +interface UserRow { + id: number; + name: string; + email: string; + password_hash: string | null; + avatar_url: string | null; + role: string; + status: string; + created_at: string; + updated_at: string; +} + +function mapUser(row: UserRow): User { + return { + id: row.id, + name: row.name, + email: row.email, + passwordHash: row.password_hash, + avatarUrl: row.avatar_url, + role: row.role as UserRole, + status: row.status as UserStatus, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export interface CreateUserInput { + name: string; + email: string; + passwordHash: string; + role?: UserRole; +} + +export interface UpdateUserInput { + name?: string; + email?: string; + avatarUrl?: string | null; + role?: UserRole; + status?: UserStatus; +} + +/** + * usersテーブルへのデータアクセスを担うRepository。 + * 直接SQLiteライブラリを触らず、必ずSqliteDatabase経由でSQLを実行する。 + */ +export class UserRepository { + constructor(private readonly db: SqliteDatabase) {} + + findById(id: number): User | null { + const row = this.db.get('SELECT * FROM users WHERE id = @id', { + id, + }); + return row ? mapUser(row) : null; + } + + findByEmail(email: string): User | null { + const row = this.db.get( + 'SELECT * FROM users WHERE email = @email', + { email } + ); + return row ? mapUser(row) : null; + } + + create(input: CreateUserInput): User { + const now = new Date().toISOString(); + const result = this.db.execute( + `INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at) + VALUES (@name, @email, @passwordHash, @avatarUrl, @role, @status, @createdAt, @updatedAt)`, + { + name: input.name, + email: input.email, + passwordHash: input.passwordHash, + avatarUrl: null, + role: input.role ?? 'member', + status: 'active', + createdAt: now, + updatedAt: now, + } + ); + const created = this.findById(Number(result.lastInsertRowid)); + if (!created) { + throw new Error('Failed to create user'); + } + return created; + } + + update(id: number, input: UpdateUserInput): User | null { + const fields: string[] = ['updated_at = @updatedAt']; + const params: Record = { + updatedAt: new Date().toISOString(), + id, + }; + + if (input.name !== undefined) { + fields.push('name = @name'); + params.name = input.name; + } + if (input.email !== undefined) { + fields.push('email = @email'); + params.email = input.email; + } + if (input.avatarUrl !== undefined) { + fields.push('avatar_url = @avatarUrl'); + params.avatarUrl = input.avatarUrl; + } + if (input.role !== undefined) { + fields.push('role = @role'); + params.role = input.role; + } + if (input.status !== undefined) { + fields.push('status = @status'); + params.status = input.status; + } + + this.db.execute( + `UPDATE users SET ${fields.join(', ')} WHERE id = @id`, + params + ); + return this.findById(id); + } +} diff --git a/services/AuthService.ts b/services/AuthService.ts new file mode 100644 index 0000000..2bb422e --- /dev/null +++ b/services/AuthService.ts @@ -0,0 +1,117 @@ +import bcrypt from 'bcrypt'; +import { UserRepository } from '@/repositories/UserRepository'; +import { createSessionToken } from '@/lib/auth/session'; +import { + validateRegister, + validateLogin, + validateProfileUpdate, + type ProfileUpdateInput, +} from '@/lib/validators/userValidator'; +import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors'; +import type { User, UserRole } from '@/lib/types'; + +const BCRYPT_ROUNDS = 10; + +export interface RegisterInput { + name: string; + email: string; + password: string; +} + +export interface LoginResult { + user: User; + token: string; +} + +/** + * 認証・ユーザー管理の業務ロジックを担うService。 + * パスワードはbcryptでハッシュ化し、平文保存しない。 + * セッションCookieの設定はRoute Handler層の責務(AuthServiceはトークン生成まで)。 + */ +export class AuthService { + constructor(private readonly userRepository: UserRepository) {} + + register(input: RegisterInput): User { + validateRegister(input); + const existing = this.userRepository.findByEmail(input.email); + if (existing) { + throw new ConflictError('このメールアドレスは既に使用されています'); + } + const passwordHash = bcrypt.hashSync(input.password, BCRYPT_ROUNDS); + return this.userRepository.create({ + name: input.name, + email: input.email, + passwordHash, + role: 'member', + }); + } + + login(email: string, password: string): LoginResult { + validateLogin({ email, password }); + const user = this.userRepository.findByEmail(email); + if (!user) { + throw new UnauthorizedError( + 'メールアドレスまたはパスワードが正しくありません' + ); + } + if (user.status === 'inactive') { + throw new UnauthorizedError('このアカウントは無効です'); + } + if ( + !user.passwordHash || + !bcrypt.compareSync(password, user.passwordHash) + ) { + throw new UnauthorizedError( + 'メールアドレスまたはパスワードが正しくありません' + ); + } + return { user, token: createSessionToken(user.id) }; + } + + logout(): void { + // セッションCookieの削除はRoute Handler層で行う + } + + getCurrentUser(userId: number): User | null { + return this.userRepository.findById(userId); + } + + updateProfile(userId: number, input: ProfileUpdateInput): User { + validateProfileUpdate(input); + const user = this.userRepository.findById(userId); + if (!user) { + throw new NotFoundError('User', userId); + } + if (input.email && input.email !== user.email) { + const existing = this.userRepository.findByEmail(input.email); + if (existing) { + throw new ConflictError('このメールアドレスは既に使用されています'); + } + } + const updated = this.userRepository.update(userId, { + name: input.name, + email: input.email, + avatarUrl: input.avatarUrl, + }); + if (!updated) { + throw new NotFoundError('User', userId); + } + return updated; + } + + /** テスト/初期データ用途: ロールを直接指定してユーザー作成 */ + createWithRole(input: RegisterInput, role: UserRole): User { + validateRegister(input); + const existing = this.userRepository.findByEmail(input.email); + if (existing) { + throw new ConflictError('このメールアドレスは既に使用されています'); + } + const passwordHash = bcrypt.hashSync(input.password, BCRYPT_ROUNDS); + return this.userRepository.create({ + name: input.name, + email: input.email, + passwordHash, + role, + }); + } +} diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..18f8b6e --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; + +function uniqueEmail(): string { + return `e2e-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}@example.com`; +} + +test.describe('authentication', () => { + test('register lands on the dashboard, then profile and logout', async ({ + page, + }) => { + const email = uniqueEmail(); + + await page.goto('/login'); + await page.getByRole('button', { name: '新規登録はこちら' }).click(); + await page.getByLabel('表示名').fill('E2E User'); + await page.getByLabel('メールアドレス').fill(email); + await page.getByLabel('パスワード').fill('password123'); + await page.getByRole('button', { name: '登録する' }).click(); + + // 登録と同時にログイン → ダッシュボードへ + await expect(page).toHaveURL(/\/dashboard/); + await expect( + page.getByRole('heading', { name: 'ダッシュボード' }) + ).toBeVisible(); + + // プロフィールへ移動しログアウト + await page.getByRole('link', { name: /さん/ }).click(); + await expect(page).toHaveURL(/\/profile/); + await page.getByRole('button', { name: 'ログアウト' }).click(); + await expect(page).toHaveURL(/\/login/); + }); + + test('login with a pre-registered account', async ({ page, request }) => { + const email = uniqueEmail(); + await request.post('/api/auth/register', { + data: { name: 'Pre User', email, password: 'password123' }, + }); + + await page.goto('/login'); + await page.getByLabel('メールアドレス').fill(email); + await page.getByLabel('パスワード').fill('password123'); + await page.getByRole('button', { name: 'ログイン' }).click(); + + await expect(page).toHaveURL(/\/dashboard/); + }); + + test('wrong password shows an error and stays on login', async ({ + page, + request, + }) => { + const email = uniqueEmail(); + await request.post('/api/auth/register', { + data: { name: 'Pre User', email, password: 'password123' }, + }); + + await page.goto('/login'); + await page.getByLabel('メールアドレス').fill(email); + await page.getByLabel('パスワード').fill('wrong-password'); + await page.getByRole('button', { name: 'ログイン' }).click(); + + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page).toHaveURL(/\/login/); + }); + + test('unauthenticated access to a protected page redirects to login', async ({ + browser, + }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto('/dashboard'); + + await expect(page).toHaveURL(/\/login/); + await context.close(); + }); + + test('unauthenticated API request returns 401', async ({ request }) => { + const res = await request.get('/api/auth/me'); + expect(res.status()).toBe(401); + }); +}); diff --git a/tests/integration/auth-flow.test.ts b/tests/integration/auth-flow.test.ts new file mode 100644 index 0000000..2c5810f --- /dev/null +++ b/tests/integration/auth-flow.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { AuthService } from '@/services/AuthService'; +import { verifySessionToken } from '@/lib/auth/session'; +import { UnauthorizedError } from '@/lib/errors'; + +describe('authentication flow (integration)', () => { + let db: SqliteDatabase; + let repo: UserRepository; + let authService: AuthService; + + beforeEach(() => { + db = createMigratedTestDb(); + repo = new UserRepository(db); + authService = new AuthService(repo); + }); + + afterEach(() => { + db.close(); + }); + + it('registers, logs in, resolves the session, and reads the user', () => { + // 1. 登録 + const registered = authService.register({ + name: 'Flow User', + email: 'flow@example.com', + password: 'password123', + }); + expect(registered.id).toBeGreaterThan(0); + + // 2. ログイン → トークン発行 + const { user, token } = authService.login( + 'flow@example.com', + 'password123' + ); + expect(user.id).toBe(registered.id); + + // 3. トークンからユーザーIDを解決(セッション復元) + const resolvedUserId = verifySessionToken(token); + expect(resolvedUserId).toBe(user.id); + + // 4. ユーザーIDから現在ユーザーを取得(getCurrentUser相当) + const current = repo.findById(resolvedUserId!); + expect(current?.email).toBe('flow@example.com'); + }); + + it('rejects login after the account is deactivated', () => { + authService.register({ + name: 'Flow User', + email: 'flow@example.com', + password: 'password123', + }); + + // 一度ログイン成功を確認 + expect(() => + authService.login('flow@example.com', 'password123') + ).not.toThrow(); + + // 無効化 + const user = repo.findByEmail('flow@example.com')!; + repo.update(user.id, { status: 'inactive' }); + + // 無効アカウントではログイン不可 + expect(() => authService.login('flow@example.com', 'password123')).toThrow( + UnauthorizedError + ); + }); + + it('prevents a session token from resolving after profile email changes', () => { + const created = authService.register({ + name: 'Flow User', + email: 'flow@example.com', + password: 'password123', + }); + const { token } = authService.login('flow@example.com', 'password123'); + expect(verifySessionToken(token)).toBe(created.id); + + // プロフィール更新(メール変更)後もトークンはuidベースで有効 + authService.updateProfile(created.id, { + email: 'changed@example.com', + name: 'New Name', + }); + + const resolved = repo.findById(verifySessionToken(token)!); + expect(resolved?.email).toBe('changed@example.com'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..c851064 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,2 @@ +// テスト実行時に必要な環境変数のデフォルトを設定する +process.env.SESSION_SECRET = process.env.SESSION_SECRET ?? 'test-secret'; diff --git a/tests/unit/lib/auth/session.test.ts b/tests/unit/lib/auth/session.test.ts new file mode 100644 index 0000000..ac30155 --- /dev/null +++ b/tests/unit/lib/auth/session.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { createSessionToken, verifySessionToken } from '@/lib/auth/session'; + +describe('session token', () => { + it('round-trips a user id through create and verify', () => { + const token = createSessionToken(42); + + expect(verifySessionToken(token)).toBe(42); + }); + + it('returns null for a tampered signature', () => { + const token = createSessionToken(42); + const [encoded] = token.split('.'); + const tampered = `${encoded}.aW52YWxpZHNpZ25hdHVyZQ`; + + expect(verifySessionToken(tampered)).toBeNull(); + }); + + it('returns null for a tampered payload', () => { + const token = createSessionToken(42); + const [, signature] = token.split('.'); + // payload を別ユーザーIDに書き換えたトークン(署名は元のまま) + const forgedPayload = Buffer.from( + JSON.stringify({ uid: 99, iat: Date.now() }) + ).toString('base64url'); + const forged = `${forgedPayload}.${signature}`; + + expect(verifySessionToken(forged)).toBeNull(); + }); + + it('returns null for a malformed token', () => { + expect(verifySessionToken('not-a-valid-token')).toBeNull(); + expect(verifySessionToken('only.one.too.many')).toBeNull(); + expect(verifySessionToken('')).toBeNull(); + }); + + it('returns null when the payload uid is not a number', () => { + const encoded = Buffer.from( + JSON.stringify({ uid: 'not-a-number', iat: Date.now() }) + ).toString('base64url'); + // 署名は無意味でも構造は整える + expect(verifySessionToken(`${encoded}.${encoded}`)).toBeNull(); + }); +}); diff --git a/tests/unit/repositories/UserRepository.test.ts b/tests/unit/repositories/UserRepository.test.ts new file mode 100644 index 0000000..a18c83c --- /dev/null +++ b/tests/unit/repositories/UserRepository.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; + +describe('UserRepository', () => { + let db: SqliteDatabase; + let repo: UserRepository; + + beforeEach(() => { + db = createMigratedTestDb(); + repo = new UserRepository(db); + }); + + afterEach(() => { + db.close(); + }); + + describe('create', () => { + it('creates a user and returns it with an id and active status', () => { + const user = repo.create({ + name: 'Alice', + email: 'alice@example.com', + passwordHash: 'hashed', + }); + + expect(user.id).toBeGreaterThan(0); + expect(user.name).toBe('Alice'); + expect(user.email).toBe('alice@example.com'); + expect(user.passwordHash).toBe('hashed'); + expect(user.role).toBe('member'); + expect(user.status).toBe('active'); + expect(user.createdAt).toBeTruthy(); + }); + + it('rejects a duplicate email (UNIQUE constraint)', () => { + repo.create({ + name: 'Alice', + email: 'dup@example.com', + passwordHash: 'h', + }); + expect(() => + repo.create({ + name: 'Bob', + email: 'dup@example.com', + passwordHash: 'h', + }) + ).toThrow(); + }); + }); + + describe('findById', () => { + it('returns the user when found', () => { + const created = repo.create({ + name: 'Alice', + email: 'a@example.com', + passwordHash: 'h', + }); + + const found = repo.findById(created.id); + + expect(found?.id).toBe(created.id); + expect(found?.name).toBe('Alice'); + }); + + it('returns null when no user exists for the id', () => { + expect(repo.findById(99999)).toBeNull(); + }); + }); + + describe('findByEmail', () => { + it('returns the user matching the email', () => { + repo.create({ name: 'Alice', email: 'a@example.com', passwordHash: 'h' }); + + const found = repo.findByEmail('a@example.com'); + + expect(found?.name).toBe('Alice'); + }); + + it('returns null when no user matches the email', () => { + expect(repo.findByEmail('nobody@example.com')).toBeNull(); + }); + }); + + describe('update', () => { + it('updates name, email, avatarUrl, role and status', () => { + const created = repo.create({ + name: 'Alice', + email: 'a@example.com', + passwordHash: 'h', + }); + + const updated = repo.update(created.id, { + name: 'Alice2', + email: 'a2@example.com', + avatarUrl: 'https://example.com/avatar.png', + role: 'system_admin', + status: 'inactive', + }); + + expect(updated?.name).toBe('Alice2'); + expect(updated?.email).toBe('a2@example.com'); + expect(updated?.avatarUrl).toBe('https://example.com/avatar.png'); + expect(updated?.role).toBe('system_admin'); + expect(updated?.status).toBe('inactive'); + }); + + it('only updates provided fields', () => { + const created = repo.create({ + name: 'Alice', + email: 'a@example.com', + passwordHash: 'h', + }); + + const updated = repo.update(created.id, { name: 'NewName' }); + + expect(updated?.name).toBe('NewName'); + expect(updated?.email).toBe('a@example.com'); + }); + + it('returns null when updating a non-existent user', () => { + const result = repo.update(99999, { name: 'X' }); + // UPDATE affects 0 rows; findById returns null + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/services/AuthService.test.ts b/tests/unit/services/AuthService.test.ts new file mode 100644 index 0000000..9dd28d9 --- /dev/null +++ b/tests/unit/services/AuthService.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { AuthService } from '@/services/AuthService'; +import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors'; +import { verifySessionToken } from '@/lib/auth/session'; +import bcrypt from 'bcrypt'; + +describe('AuthService', () => { + let db: SqliteDatabase; + let repo: UserRepository; + let authService: AuthService; + + beforeEach(() => { + db = createMigratedTestDb(); + repo = new UserRepository(db); + authService = new AuthService(repo); + }); + + afterEach(() => { + db.close(); + }); + + describe('register', () => { + it('creates a user with a bcrypt-hashed password', () => { + const user = authService.register({ + name: 'Alice', + email: 'alice@example.com', + password: 'password123', + }); + + expect(user.id).toBeGreaterThan(0); + expect(user.passwordHash).not.toBe('password123'); + expect(bcrypt.compareSync('password123', user.passwordHash!)).toBe(true); + expect(user.role).toBe('member'); + expect(user.status).toBe('active'); + }); + + it('throws ConflictError when the email is already registered', () => { + authService.register({ + name: 'Alice', + email: 'dup@example.com', + password: 'password123', + }); + + expect(() => + authService.register({ + name: 'Bob', + email: 'dup@example.com', + password: 'password123', + }) + ).toThrow(ConflictError); + }); + + it('throws ValidationError for a weak password', () => { + expect(() => + authService.register({ + name: 'Alice', + email: 'a@example.com', + password: 'short', + }) + ).toThrow(); + }); + }); + + describe('login', () => { + beforeEach(() => { + authService.register({ + name: 'Alice', + email: 'alice@example.com', + password: 'password123', + }); + }); + + it('returns the user and a verifiable session token on success', () => { + const { user, token } = authService.login( + 'alice@example.com', + 'password123' + ); + + expect(user.email).toBe('alice@example.com'); + expect(verifySessionToken(token)).toBe(user.id); + }); + + it('throws UnauthorizedError when the password is wrong', () => { + expect(() => + authService.login('alice@example.com', 'wrong-password') + ).toThrow(UnauthorizedError); + }); + + it('throws UnauthorizedError when the email does not exist', () => { + expect(() => + authService.login('nobody@example.com', 'password123') + ).toThrow(UnauthorizedError); + }); + + it('throws UnauthorizedError when the account is inactive', () => { + const user = repo.findByEmail('alice@example.com')!; + repo.update(user.id, { status: 'inactive' }); + + expect(() => + authService.login('alice@example.com', 'password123') + ).toThrow(UnauthorizedError); + }); + }); + + describe('getCurrentUser', () => { + it('returns the user for a valid id', () => { + const created = authService.register({ + name: 'Alice', + email: 'alice@example.com', + password: 'password123', + }); + + expect(authService.getCurrentUser(created.id)?.email).toBe( + 'alice@example.com' + ); + }); + + it('returns null for an unknown id', () => { + expect(authService.getCurrentUser(99999)).toBeNull(); + }); + }); + + describe('updateProfile', () => { + it('updates name and email', () => { + const created = authService.register({ + name: 'Alice', + email: 'alice@example.com', + password: 'password123', + }); + + const updated = authService.updateProfile(created.id, { + name: 'Alice New', + email: 'alice2@example.com', + }); + + expect(updated.name).toBe('Alice New'); + expect(updated.email).toBe('alice2@example.com'); + }); + + it('throws ConflictError when changing email to one already in use', () => { + authService.register({ + name: 'Alice', + email: 'alice@example.com', + password: 'password123', + }); + const bob = authService.register({ + name: 'Bob', + email: 'bob@example.com', + password: 'password123', + }); + + expect(() => + authService.updateProfile(bob.id, { email: 'alice@example.com' }) + ).toThrow(ConflictError); + }); + + it('throws NotFoundError when the user does not exist', () => { + expect(() => authService.updateProfile(99999, { name: 'X' })).toThrow( + NotFoundError + ); + }); + }); + + describe('createWithRole', () => { + it('creates a user with the specified role', () => { + const admin = authService.createWithRole( + { name: 'Admin', email: 'admin@example.com', password: 'password123' }, + 'system_admin' + ); + + expect(admin.role).toBe('system_admin'); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 89225fd..4318b8b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,14 @@ export default defineConfig({ 'repositories/**/*.{test,spec}.{ts,tsx}', 'services/**/*.{test,spec}.{ts,tsx}', ], + exclude: [ + 'tests/e2e/**', + 'node_modules/**', + 'dist/**', + '.next/**', + '**/node_modules/**', + ], + setupFiles: ['tests/setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],