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)
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user