Merge branch 'fix/post-m4-validation' - post-M4 validation fixes

This commit is contained in:
Ken Yasue
2026-06-25 01:11:38 +02:00
5 changed files with 44 additions and 5 deletions

View File

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser'; import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService, createUserRepository } from '@/lib/api/services'; import { createProjectService, createUserRepository } from '@/lib/api/services';
import { validateProjectMemberRole } from '@/lib/validators/projectValidator';
import { UnauthorizedError, NotFoundError } from '@/lib/errors'; import { UnauthorizedError, NotFoundError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError'; import { handleApiError, jsonError } from '@/lib/api/handleError';
import type { ProjectMemberRole } from '@/lib/types';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@ -44,9 +44,9 @@ export async function POST(
} }
const email = String(body.email ?? ''); const email = String(body.email ?? '');
const role = ( const role = validateProjectMemberRole(
typeof body.role === 'string' ? body.role : 'member' typeof body.role === 'string' ? body.role : 'member'
) as ProjectMemberRole; );
// メールアドレスからユーザーを解決する // メールアドレスからユーザーを解決する
const targetUser = createUserRepository().findByEmail(email); const targetUser = createUserRepository().findByEmail(email);

View File

@ -50,8 +50,15 @@ export function verifySessionToken(token: string): number | null {
try { try {
const payload = JSON.parse( const payload = JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf-8') Buffer.from(encoded, 'base64url').toString('utf-8')
) as { uid?: unknown }; ) as { uid?: unknown; iat?: unknown };
if (typeof payload.uid !== 'number') return null; if (typeof payload.uid !== 'number') return null;
// サーバ側でも有効期限を検証する(クッキーのmaxAgeだけに依存しない)
if (
typeof payload.iat !== 'number' ||
Date.now() - payload.iat > SESSION_MAX_AGE_SECONDS * 1000
) {
return null;
}
return payload.uid; return payload.uid;
} catch { } catch {
return null; return null;

View File

@ -1,5 +1,5 @@
import { ValidationError } from '@/lib/errors'; import { ValidationError } from '@/lib/errors';
import type { ProjectStatus } from '@/lib/types'; import type { ProjectMemberRole, ProjectStatus } from '@/lib/types';
const MAX_NAME_LENGTH = 200; const MAX_NAME_LENGTH = 200;
const MAX_DESCRIPTION_LENGTH = 2000; const MAX_DESCRIPTION_LENGTH = 2000;
@ -9,6 +9,7 @@ const VALID_STATUSES: ProjectStatus[] = [
'completed', 'completed',
'archived', 'archived',
]; ];
const VALID_MEMBER_ROLES: ProjectMemberRole[] = ['admin', 'member', 'guest'];
export interface ProjectCreateInput { export interface ProjectCreateInput {
name: string; name: string;
@ -64,3 +65,10 @@ export function validateProjectUpdate(input: ProjectUpdateInput): void {
throw new ValidationError('無効なステータスです', 'status'); throw new ValidationError('無効なステータスです', 'status');
} }
} }
export function validateProjectMemberRole(role: string): ProjectMemberRole {
if (!VALID_MEMBER_ROLES.includes(role as ProjectMemberRole)) {
throw new ValidationError('無効なメンバーロールです', 'role');
}
return role as ProjectMemberRole;
}

View File

@ -1,6 +1,18 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import crypto from 'node:crypto';
import { createSessionToken, verifySessionToken } from '@/lib/auth/session'; import { createSessionToken, verifySessionToken } from '@/lib/auth/session';
function forgeToken(uid: number, iat: number): string {
const secret = process.env.SESSION_SECRET!;
const payload = JSON.stringify({ uid, iat });
const encoded = Buffer.from(payload, 'utf-8').toString('base64url');
const signature = crypto
.createHmac('sha256', secret)
.update(encoded)
.digest('base64url');
return `${encoded}.${signature}`;
}
describe('session token', () => { describe('session token', () => {
it('round-trips a user id through create and verify', () => { it('round-trips a user id through create and verify', () => {
const token = createSessionToken(42); const token = createSessionToken(42);
@ -34,6 +46,15 @@ describe('session token', () => {
expect(verifySessionToken('')).toBeNull(); expect(verifySessionToken('')).toBeNull();
}); });
it('returns null for an expired token (stale iat)', () => {
const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000;
expect(verifySessionToken(forgeToken(42, eightDaysAgo))).toBeNull();
});
it('accepts a freshly-issued token', () => {
expect(verifySessionToken(forgeToken(42, Date.now()))).toBe(42);
});
it('returns null when the payload uid is not a number', () => { it('returns null when the payload uid is not a number', () => {
const encoded = Buffer.from( const encoded = Buffer.from(
JSON.stringify({ uid: 'not-a-number', iat: Date.now() }) JSON.stringify({ uid: 'not-a-number', iat: Date.now() })

View File

@ -22,11 +22,14 @@ export default defineConfig({
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],
include: ['repositories/**', 'services/**'],
exclude: [ exclude: [
'node_modules/**', 'node_modules/**',
'dist/**', 'dist/**',
'.next/**', '.next/**',
'.steering/**', '.steering/**',
'app/**',
'components/**',
'**/*.config.{ts,js,mjs}', '**/*.config.{ts,js,mjs}',
'**/types/**', '**/types/**',
'tests/**', 'tests/**',