Merge branch 'feature/m3-auth-user' - M3 auth & user management (session, AuthService, API, screens, tests)
This commit is contained in:
27
app/api/admin/migrations/route.ts
Normal file
27
app/api/admin/migrations/route.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/api/auth/login/route.ts
Normal file
34
app/api/auth/login/route.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/api/auth/logout/route.ts
Normal file
10
app/api/auth/logout/route.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
14
app/api/auth/me/route.ts
Normal file
14
app/api/auth/me/route.ts
Normal file
@ -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) });
|
||||||
|
}
|
||||||
36
app/api/auth/register/route.ts
Normal file
36
app/api/auth/register/route.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/api/users/me/route.ts
Normal file
36
app/api/users/me/route.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/dashboard/page.tsx
Normal file
27
app/dashboard/page.tsx
Normal file
@ -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 (
|
||||||
|
<main className="min-h-screen bg-gray-50 p-8">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">ダッシュボード</h1>
|
||||||
|
<a href="/profile" className="text-sm text-blue-600 hover:underline">
|
||||||
|
{user.name} さん
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-gray-600">
|
||||||
|
プロジェクト機能は後続マイルストーンで実装されます。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
app/login/page.tsx
Normal file
131
app/login/page.tsx
Normal file
@ -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<Mode>('login');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-8">
|
||||||
|
<div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{mode === 'login' ? 'ログイン' : '新規登録'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
||||||
|
{mode === 'register' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium">
|
||||||
|
表示名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium">
|
||||||
|
メールアドレス
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium">
|
||||||
|
パスワード
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={
|
||||||
|
mode === 'login' ? 'current-password' : 'new-password'
|
||||||
|
}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '処理中...' : mode === 'login' ? 'ログイン' : '登録する'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(mode === 'login' ? 'register' : 'login');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'login' ? '新規登録はこちら' : 'ログイン画面に戻る'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/page.tsx
18
app/page.tsx
@ -1,10 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
const router = useRouter();
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
useEffect(() => {
|
||||||
<h1 className="text-3xl font-bold">シンプルグループウェア</h1>
|
router.replace('/dashboard');
|
||||||
<p className="mt-4 text-gray-600">
|
}, [router]);
|
||||||
プロジェクト単位で情報共有・タスク管理を行えるチームコラボレーションツール
|
return null;
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
146
app/profile/page.tsx
Normal file
146
app/profile/page.tsx
Normal file
@ -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<PublicUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<main className="flex min-h-screen items-center justify-center">
|
||||||
|
<p>読み込み中...</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50 p-8">
|
||||||
|
<div className="mx-auto max-w-md rounded-lg border bg-white p-8 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">プロフィール</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
ログアウト
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium">
|
||||||
|
表示名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium">
|
||||||
|
メールアドレス
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="avatarUrl" className="block text-sm font-medium">
|
||||||
|
アイコン画像URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="avatarUrl"
|
||||||
|
type="url"
|
||||||
|
value={avatarUrl}
|
||||||
|
onChange={(e) => setAvatarUrl(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && (
|
||||||
|
<p className="text-sm text-green-600">プロフィールを更新しました</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
ダッシュボードへ
|
||||||
|
</button>
|
||||||
|
<p className="mt-2 text-center text-xs text-gray-500">
|
||||||
|
ロール: {user?.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
lib/api/handleError.ts
Normal file
28
lib/api/handleError.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
6
lib/auth/constants.ts
Normal file
6
lib/auth/constants.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* セッションCookie関連の定数。
|
||||||
|
* Edge Runtime(middleware)からも安全に参照できるよう、依存を持たない定数のみ配置する。
|
||||||
|
*/
|
||||||
|
export const SESSION_COOKIE = 'session';
|
||||||
|
export const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
||||||
33
lib/auth/getCurrentUser.ts
Normal file
33
lib/auth/getCurrentUser.ts
Normal file
@ -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<User, 'passwordHash'>;
|
||||||
|
|
||||||
|
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<User | null> {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
if (userId === null) return null;
|
||||||
|
const userRepository = new UserRepository(getDb());
|
||||||
|
return userRepository.findById(userId);
|
||||||
|
}
|
||||||
93
lib/auth/session.ts
Normal file
93
lib/auth/session.ts
Normal file
@ -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<number | null> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
55
lib/errors.ts
Normal file
55
lib/errors.ts
Normal file
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
95
lib/validators/userValidator.ts
Normal file
95
lib/validators/userValidator.ts
Normal file
@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
middleware.ts
Normal file
25
middleware.ts
Normal file
@ -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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -18,9 +18,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'npm run migrate && npm run dev',
|
||||||
url: 'http://localhost:3000',
|
url: 'http://localhost:3000',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: false,
|
||||||
timeout: 120 * 1000,
|
timeout: 120 * 1000,
|
||||||
|
env: { SESSION_SECRET: 'e2e-secret' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
124
repositories/UserRepository.ts
Normal file
124
repositories/UserRepository.ts
Normal file
@ -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<UserRow>('SELECT * FROM users WHERE id = @id', {
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
return row ? mapUser(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
findByEmail(email: string): User | null {
|
||||||
|
const row = this.db.get<UserRow>(
|
||||||
|
'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<string, unknown> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
services/AuthService.ts
Normal file
117
services/AuthService.ts
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
81
tests/e2e/auth.spec.ts
Normal file
81
tests/e2e/auth.spec.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
tests/integration/auth-flow.test.ts
Normal file
89
tests/integration/auth-flow.test.ts
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
2
tests/setup.ts
Normal file
2
tests/setup.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// テスト実行時に必要な環境変数のデフォルトを設定する
|
||||||
|
process.env.SESSION_SECRET = process.env.SESSION_SECRET ?? 'test-secret';
|
||||||
44
tests/unit/lib/auth/session.test.ts
Normal file
44
tests/unit/lib/auth/session.test.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
127
tests/unit/repositories/UserRepository.test.ts
Normal file
127
tests/unit/repositories/UserRepository.test.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
177
tests/unit/services/AuthService.test.ts
Normal file
177
tests/unit/services/AuthService.test.ts
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -11,6 +11,14 @@ export default defineConfig({
|
|||||||
'repositories/**/*.{test,spec}.{ts,tsx}',
|
'repositories/**/*.{test,spec}.{ts,tsx}',
|
||||||
'services/**/*.{test,spec}.{ts,tsx}',
|
'services/**/*.{test,spec}.{ts,tsx}',
|
||||||
],
|
],
|
||||||
|
exclude: [
|
||||||
|
'tests/e2e/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'dist/**',
|
||||||
|
'.next/**',
|
||||||
|
'**/node_modules/**',
|
||||||
|
],
|
||||||
|
setupFiles: ['tests/setup.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json', 'html'],
|
reporter: ['text', 'json', 'html'],
|
||||||
|
|||||||
Reference in New Issue
Block a user