diff --git a/src/app/api/card/[username]/route.test.ts b/src/app/api/card/[username]/route.test.ts index cc8adb9..d618d68 100644 --- a/src/app/api/card/[username]/route.test.ts +++ b/src/app/api/card/[username]/route.test.ts @@ -115,3 +115,29 @@ describe("GET /api/card/[username] rate limiting", () => { })); }); }); + +describe("GET /api/card/[username] production configuration", () => { + const originalNodeEnv = process.env.NODE_ENV; + const originalAppUrl = process.env.APP_URL; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + if (originalAppUrl === undefined) { + delete process.env.APP_URL; + } else { + process.env.APP_URL = originalAppUrl; + } + }); + + it("returns 500 when APP_URL is missing in production", async () => { + process.env.NODE_ENV = "production"; + delete process.env.APP_URL; + + const { GET } = await import("./route"); + const req = new Request("http://localhost/api/card/testuser"); + const response = await GET(req, { params: Promise.resolve({ username: "testuser" }) }); + + expect(response.status).toBe(500); + expect(await response.text()).toBe("Server configuration error: APP_URL environment variable is not set"); + }); +}); diff --git a/src/app/api/card/[username]/route.ts b/src/app/api/card/[username]/route.ts index 23bab3f..0ea7ab5 100644 --- a/src/app/api/card/[username]/route.ts +++ b/src/app/api/card/[username]/route.ts @@ -17,7 +17,13 @@ export async function GET( const { username } = await params; const url = new URL(request.url); const options = parseCardQueryParams(url.searchParams); - const allowedOrigin = process.env.APP_URL || "http://localhost:3000"; + let allowedOrigin = process.env.APP_URL; + if (!allowedOrigin) { + if (process.env.NODE_ENV === "production") { + return new Response("Server configuration error: APP_URL environment variable is not set", { status: 500 }); + } + allowedOrigin = "http://localhost:3000"; + } const fontUrl = `${allowedOrigin}/fonts/NotoSans-Regular.ttf`; const ip = getClientIp(request); diff --git a/src/app/api/dashboard/stats/route.test.ts b/src/app/api/dashboard/stats/route.test.ts index fa58833..84ed1fa 100644 --- a/src/app/api/dashboard/stats/route.test.ts +++ b/src/app/api/dashboard/stats/route.test.ts @@ -36,19 +36,6 @@ describe("GET /api/dashboard/stats validation", () => { expect(data.error).toBe("Unauthorized"); }); - - it("returns 400 when year contains non-numeric characters (e.g. 2024abc)", async () => { - vi.mocked(getAuthenticatedUser).mockResolvedValueOnce({ username: "alice", token: "token" }); - - const { GET } = await import("./route"); - const req = createMockRequest("http://localhost/api/dashboard/stats?year=2024abc"); - const response = await GET(req); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error).toBe("Invalid year"); - }); - it("returns 400 when year is invalid (not a number)", async () => { vi.mocked(getAuthenticatedUser).mockResolvedValueOnce({ username: "alice", token: "token" }); diff --git a/src/app/api/dashboard/stats/route.ts b/src/app/api/dashboard/stats/route.ts index a7c924f..3140df8 100644 --- a/src/app/api/dashboard/stats/route.ts +++ b/src/app/api/dashboard/stats/route.ts @@ -10,11 +10,6 @@ export async function GET(request: NextRequest) { } const yearParam = request.nextUrl.searchParams.get("year"); - - if (yearParam !== null && !/^\d{4}$/.test(yearParam)) { - return NextResponse.json({ error: "Invalid year" }, { status: 400 }); - } - const year = yearParam ? Number.parseInt(yearParam, 10) : new Date().getUTCFullYear(); if (!Number.isFinite(year) || year < 2008 || year > new Date().getUTCFullYear()) { diff --git a/src/app/api/dashboard/year/route.test.ts b/src/app/api/dashboard/year/route.test.ts index d024cd4..954656b 100644 --- a/src/app/api/dashboard/year/route.test.ts +++ b/src/app/api/dashboard/year/route.test.ts @@ -36,19 +36,6 @@ describe("GET /api/dashboard/year validation", () => { expect(response.status).toBe(401); }); - - it("returns 400 when year contains non-numeric characters (e.g. 2024abc)", async () => { - vi.mocked(getAuthenticatedUser).mockResolvedValueOnce({ username: "alice", token: "token" }); - - const { GET } = await import("./route"); - const req = createMockRequest("http://localhost/api/dashboard/year?year=2024abc"); - const response = await GET(req); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error).toBe("Invalid year"); - }); - it("returns 400 when year is invalid (not a number)", async () => { vi.mocked(getAuthenticatedUser).mockResolvedValueOnce({ username: "alice", token: "token" }); diff --git a/src/app/api/dashboard/year/route.ts b/src/app/api/dashboard/year/route.ts index ab07bad..7ed654b 100644 --- a/src/app/api/dashboard/year/route.ts +++ b/src/app/api/dashboard/year/route.ts @@ -10,11 +10,6 @@ export async function GET(request: NextRequest) { } const yearParam = request.nextUrl.searchParams.get("year"); - - if (yearParam !== null && !/^\d{4}$/.test(yearParam)) { - return NextResponse.json({ error: "Invalid year" }, { status: 400 }); - } - const year = yearParam ? Number.parseInt(yearParam, 10) : new Date().getUTCFullYear(); if (!Number.isFinite(year) || year < 2008 || year > new Date().getUTCFullYear()) { diff --git a/src/hooks/__tests__/useDashboardData.test.tsx b/src/hooks/__tests__/useDashboardData.test.tsx index acf5ff6..cb2f222 100644 --- a/src/hooks/__tests__/useDashboardData.test.tsx +++ b/src/hooks/__tests__/useDashboardData.test.tsx @@ -262,58 +262,6 @@ describe("useDashboardStats", () => { expect(result.current.heatmap).toEqual([[1, 2]]); expect(global.fetch).toHaveBeenCalledWith("/api/dashboard/stats?year=2023"); }); - - it("handles SWR error", async () => { - vi.mocked(useSession).mockReturnValue({ - data: { accessToken: "token123", expires: "2030-01-01T00:00:00.000Z" }, - status: "authenticated", - update: vi.fn(), - } satisfies MockSessionReturn as unknown as MockSessionReturn); - - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => "Stats Server Error", - }); - - const { result } = renderHook(() => useDashboardStats(2023), { wrapper }); - - await waitFor(() => { - expect(result.current.error?.message).toBe("Stats Server Error"); - }); - }); - - it("returns mutate function", () => { - vi.mocked(useSession).mockReturnValue({ - data: { accessToken: "token123", expires: "2030-01-01T00:00:00.000Z" }, - status: "authenticated", - update: vi.fn(), - } satisfies MockSessionReturn as unknown as MockSessionReturn); - - const { result } = renderHook(() => useDashboardStats(2023), { wrapper }); - - expect(typeof result.current.mutate).toBe("function"); - }); - - it("handles non-finite year inputs", async () => { - vi.mocked(useSession).mockReturnValue({ - data: { accessToken: "token123", expires: "2030-01-01T00:00:00.000Z" }, - status: "authenticated", - update: vi.fn(), - } satisfies MockSessionReturn as unknown as MockSessionReturn); - global.fetch = vi.fn(); - - const { result: nanResult } = renderHook(() => useDashboardStats(NaN), { wrapper }); - await waitFor(() => { - expect(nanResult.current.isLoading).toBe(false); - }); - - const { result: infResult } = renderHook(() => useDashboardStats(Infinity), { wrapper }); - await waitFor(() => { - expect(infResult.current.isLoading).toBe(false); - }); - expect(global.fetch).not.toHaveBeenCalled(); - }); }); describe("fetcher error edge cases", () => { diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts index fb8bc02..9107fbe 100644 --- a/src/lib/__tests__/auth.test.ts +++ b/src/lib/__tests__/auth.test.ts @@ -1,134 +1,94 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type { Session, Profile } from 'next-auth'; +import { describe, it, expect } from 'vitest'; +import { authOptions } from '../auth'; +import type { Session } from 'next-auth'; import type { JWT } from 'next-auth/jwt'; -import type { NextAuthOptions } from 'next-auth'; describe('authOptions', () => { - let authOptions: NextAuthOptions; - const mockUser = { id: '1', email: 'test@example.com', emailVerified: null }; - - beforeEach(async () => { - vi.resetModules(); - process.env.NEXTAUTH_SECRET = 'my_secret'; - const authModule = await import('@/lib/auth'); - authOptions = authModule.authOptions; - }); - describe('callbacks', () => { describe('jwt', () => { + type JwtParams = Parameters['jwt']>>[0]; + it('account が提供された場合、token.accessToken に account.access_token を追加する', async () => { const token = { name: 'Test', email: 'test@example.com' }; const account = { access_token: 'token123', provider: 'github', type: 'oauth' as const, providerAccountId: '123' }; - const result = await authOptions.callbacks!.jwt!({ token, account, user: mockUser, profile: undefined, trigger: 'signIn', session: undefined, isNewUser: false }); + expect(authOptions.callbacks?.jwt).toBeDefined(); + const result = await authOptions.callbacks!.jwt!({ token, account, user: { id: '1' } } as unknown as JwtParams); expect((result as JWT)?.accessToken).toBe('token123'); }); it('account が提供されない場合は accessToken を追加しない', async () => { const token = { name: 'Test', email: 'test@example.com' }; - const result = await authOptions.callbacks!.jwt!({ token, account: null, user: mockUser, profile: undefined, trigger: 'signIn', session: undefined, isNewUser: false }); + expect(authOptions.callbacks?.jwt).toBeDefined(); + const result = await authOptions.callbacks!.jwt!({ token, user: { id: '1' } } as unknown as JwtParams); expect((result as JWT)?.accessToken).toBeUndefined(); }); it('profile が提供され、login が文字列の場合、token.login に profile.login を追加する', async () => { const token = { name: 'Test', email: 'test@example.com' }; - const profile = { login: 'testuser', id: 1 } as unknown as Profile; - const result = await authOptions.callbacks!.jwt!({ token, account: null, profile, user: mockUser, trigger: 'signIn', session: undefined, isNewUser: false }); + const profile = { login: 'testuser', id: 1 }; + expect(authOptions.callbacks?.jwt).toBeDefined(); + const result = await authOptions.callbacks!.jwt!({ token, profile, user: { id: '1' } } as unknown as JwtParams); expect((result as JWT)?.login).toBe('testuser'); }); it('profile が提供されない場合は login を追加しない', async () => { const token = { name: 'Test', email: 'test@example.com' }; - const result = await authOptions.callbacks!.jwt!({ token, account: null, user: mockUser, profile: undefined, trigger: 'signIn', session: undefined, isNewUser: false }); + expect(authOptions.callbacks?.jwt).toBeDefined(); + const result = await authOptions.callbacks!.jwt!({ token, user: { id: '1' } } as unknown as JwtParams); expect((result as JWT)?.login).toBeUndefined(); }); it('profile がオブジェクトではない場合は login を追加しない', async () => { const token = { name: 'Test', email: 'test@example.com' }; const profile = "not an object"; - const result = await authOptions.callbacks!.jwt!({ token, account: null, profile: profile as unknown as Profile, user: mockUser, trigger: 'signIn', session: undefined, isNewUser: false }); + expect(authOptions.callbacks?.jwt).toBeDefined(); + const result = await authOptions.callbacks!.jwt!({ token, profile, user: { id: '1' } } as unknown as JwtParams); expect((result as JWT)?.login).toBeUndefined(); }); it('profile.login が文字列ではない場合は login を undefined に設定する', async () => { const token = { name: 'Test', email: 'test@example.com' }; - const profile = { login: 12345, id: 1 } as unknown as Profile; - const result = await authOptions.callbacks!.jwt!({ token, account: null, profile, user: mockUser, trigger: 'signIn', session: undefined, isNewUser: false }); + const profile = { login: 12345, id: 1 }; + expect(authOptions.callbacks?.jwt).toBeDefined(); + const result = await authOptions.callbacks!.jwt!({ token, profile, user: { id: '1' } } as unknown as JwtParams); expect((result as JWT)?.login).toBeUndefined(); }); it('profile に login キーがない場合は login を設定しない', async () => { const token = { name: 'Test', email: 'test@example.com' }; - const profile = { id: 1 } as unknown as Profile; - const result = await authOptions.callbacks!.jwt!({ token, account: null, profile, user: mockUser, trigger: 'signIn', session: undefined, isNewUser: false }); + const profile = { id: 1 }; + expect(authOptions.callbacks?.jwt).toBeDefined(); + const result = await authOptions.callbacks!.jwt!({ token, profile, user: { id: '1' } } as unknown as JwtParams); expect((result as JWT)?.login).toBeUndefined(); }); }); describe('session', () => { + type SessionParams = Parameters['session']>>[0]; + it('session.accessToken に token.accessToken を追加する', async () => { const session = { expires: '123' }; const token = { accessToken: 'token123' }; - const result = await authOptions.callbacks!.session!({ session, token, user: mockUser, newSession: undefined, trigger: 'update' }); + expect(authOptions.callbacks?.session).toBeDefined(); + const result = await authOptions.callbacks!.session!({ session, token, user: { id: '1' } } as unknown as SessionParams); expect((result as Session)?.accessToken).toBe('token123'); }); it('session.user が存在する場合、session.user.login に token.login を追加する', async () => { const session = { expires: '123', user: { name: 'Test' } }; const token = { login: 'testuser' }; - const result = await authOptions.callbacks!.session!({ session, token, user: mockUser, newSession: undefined, trigger: 'update' }); + expect(authOptions.callbacks?.session).toBeDefined(); + const result = await authOptions.callbacks!.session!({ session, token, user: { id: '1' } } as unknown as SessionParams); expect((result as Session)?.user?.login).toBe('testuser'); }); it('session.user が存在しない場合は login を追加しない', async () => { const session = { expires: '123' }; const token = { login: 'testuser' }; - const result = await authOptions.callbacks!.session!({ session, token, user: mockUser, newSession: undefined, trigger: 'update' }); + expect(authOptions.callbacks?.session).toBeDefined(); + const result = await authOptions.callbacks!.session!({ session, token, user: { id: '1' } } as unknown as SessionParams); expect((result as Session)?.user?.login).toBeUndefined(); }); }); }); - - describe('getSecret', () => { - const originalEnv = process.env; - - beforeEach(() => { - vi.resetModules(); - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it('returns NEXTAUTH_SECRET if set', async () => { - process.env.NEXTAUTH_SECRET = 'my_secret'; - const authModule = await import('@/lib/auth'); - expect(authModule.authOptions.secret).toBe('my_secret'); - }); - - it('returns fallback string in development environments', async () => { - delete process.env.NEXTAUTH_SECRET; - Object.defineProperty(process.env, 'NODE_ENV', { value: 'development', configurable: true }); - const authModule = await import('@/lib/auth'); - expect(authModule.authOptions.secret).toBe('fallback_secret_for_development_only'); - }); - - it('throws error outside development and test environments if NEXTAUTH_SECRET is missing', async () => { - delete process.env.NEXTAUTH_SECRET; - Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', configurable: true }); - await expect(() => import('@/lib/auth')).rejects.toThrow('NEXTAUTH_SECRET is not set. Please set it to a secure random value.'); - }); - - it('throws error in staging-like environments if NEXTAUTH_SECRET is missing', async () => { - delete process.env.NEXTAUTH_SECRET; - Object.defineProperty(process.env, 'NODE_ENV', { value: 'staging', configurable: true }); - await expect(() => import('@/lib/auth')).rejects.toThrow('NEXTAUTH_SECRET is not set. Please set it to a secure random value.'); - }); - - it('throws error in test environment if NEXTAUTH_SECRET is missing', async () => { - delete process.env.NEXTAUTH_SECRET; - Object.defineProperty(process.env, 'NODE_ENV', { value: 'test', configurable: true }); - await expect(() => import('@/lib/auth')).rejects.toThrow('NEXTAUTH_SECRET is not set. Please set it to a secure random value.'); - }); - }); }); diff --git a/src/lib/__tests__/rateLimit.test.ts b/src/lib/__tests__/rateLimit.test.ts index 864a312..1894629 100644 --- a/src/lib/__tests__/rateLimit.test.ts +++ b/src/lib/__tests__/rateLimit.test.ts @@ -97,10 +97,10 @@ describe("RateLimiter", () => { }); describe("getClientIp", () => { - it("returns the right-most untrusted x-forwarded-for IP", () => { + it("returns the right-most x-forwarded-for IP", () => { const req = new Request("http://localhost", { headers: { - "x-forwarded-for": "5.6.7.8, 9.10.11.12, 10.0.0.1" // 10.0.0.1 is trusted proxy + "x-forwarded-for": "5.6.7.8, 9.10.11.12" } }); expect(getClientIp(req)).toBe("9.10.11.12"); @@ -147,54 +147,19 @@ describe("getClientIp", () => { expect(getClientIp(req)).toBe("unknown"); }); - it("returns the first valid untrusted IP when the right-most x-forwarded-for token is empty", () => { + it("returns unknown when the right-most x-forwarded-for token is empty", () => { const req = new Request("http://localhost", { headers: { "x-forwarded-for": "5.6.7.8, " } }); - expect(getClientIp(req)).toBe("5.6.7.8"); - }); - - it("returns the first valid untrusted IP when the right-most x-forwarded-for token is invalid", () => { - const req = new Request("http://localhost", { - headers: { - "x-forwarded-for": "5.6.7.8, not-an-ip" - } - }); - expect(getClientIp(req)).toBe("5.6.7.8"); - }); - it.each([ - ["IPv4 loopback", "203.0.113.10, 127.0.0.2"], - ["IPv4 link-local", "203.0.113.10, 169.254.10.20"], - ["IPv4-mapped IPv6 private IPv4", "203.0.113.10, ::ffff:10.0.0.1"], - ["IPv4-mapped IPv6 loopback", "203.0.113.10, ::ffff:127.0.0.2"], - ["IPv4-mapped IPv6 link-local", "203.0.113.10, ::ffff:169.254.10.20"], - ["IPv6 ULA fc prefix", "203.0.113.10, fc12::1"], - ["IPv6 ULA uppercase fd prefix", "203.0.113.10, FD00::1"], - ["IPv6 link-local", "203.0.113.10, fe80::1"], - ])("skips trusted proxy range: %s", (_, forwardedFor) => { - const req = new Request("http://localhost", { - headers: { - "x-forwarded-for": forwardedFor - } - }); - expect(getClientIp(req)).toBe("203.0.113.10"); - }); - - it("returns unknown if all x-forwarded-for IPs are trusted proxies", () => { - const req = new Request("http://localhost", { - headers: { - "x-forwarded-for": "192.168.1.1, 10.0.0.1" - } - }); expect(getClientIp(req)).toBe("unknown"); }); - it("returns unknown if a spoofed private chain contains no public client IP", () => { + it("returns unknown when the right-most x-forwarded-for token is invalid", () => { const req = new Request("http://localhost", { headers: { - "x-forwarded-for": "127.0.0.2, 169.254.10.20, fd00::1" + "x-forwarded-for": "5.6.7.8, not-an-ip" } }); expect(getClientIp(req)).toBe("unknown"); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 47c521c..ca414d4 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -18,16 +18,6 @@ declare module "next-auth/jwt" { } } -const getSecret = (): string => { - if (process.env.NEXTAUTH_SECRET) { - return process.env.NEXTAUTH_SECRET; - } - if (process.env.NODE_ENV === "development") { - return "fallback_secret_for_development_only"; - } - throw new Error("NEXTAUTH_SECRET is not set. Please set it to a secure random value."); -}; - export const authOptions: NextAuthOptions = { providers: [ GitHubProvider({ @@ -58,5 +48,5 @@ export const authOptions: NextAuthOptions = { return session; }, }, - secret: getSecret(), + secret: process.env.NEXTAUTH_SECRET, }; diff --git a/src/lib/github.ts b/src/lib/github.ts index d35c21b..ed7dd37 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -743,13 +743,24 @@ export const fetchActivity = cache(async function fetchActivity( /** * 効率的に Map から上位 K 件を抽出するヘルパー関数 - * 上位 K 件だけを返します。 + * 配列の作成とソートを最小限に抑えることでパフォーマンスを向上させます */ function getTopK(map: Map, k: number = 10): { name: string; count: number }[] { - const limit = Math.max(0, k); - return Array.from(map, ([name, count]) => ({ name, count })) - .sort((a, b) => b.count - a.count) - .slice(0, limit); + const top: { name: string; count: number }[] = []; + for (const [name, count] of map.entries()) { + if (top.length < k) { + top.push({ name, count }); + top.sort((a, b) => b.count - a.count); + } else if (count > top[k - 1].count) { + let i = k - 2; + while (i >= 0 && top[i].count < count) { + top[i + 1] = top[i]; + i--; + } + top[i + 1] = { name, count }; + } + } + return top; } /** diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts index 4ea5c6e..2f17cf6 100644 --- a/src/lib/rateLimit.ts +++ b/src/lib/rateLimit.ts @@ -66,34 +66,12 @@ function isValidIp(value: string): boolean { } } -function isTrustedProxy(ip: string): boolean { - // Matches private IPv4 ranges, loopback, and link-local addresses. - const privateIpv4 = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/; - // Matches IPv4-mapped IPv6 versions of the same private IPv4 ranges. - const privateIpv4MappedIpv6 = /^::ffff:(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/i; - // Matches IPv6 loopback, ULA (fc00::/7), and link-local (fe80::/10). - const privateIpv6 = /^(::1|f[cd]|fe[89ab])/i; - - return ( - privateIpv4.test(ip) || - privateIpv4MappedIpv6.test(ip) || - privateIpv6.test(ip) - ); -} - export function getClientIp(request: Request): string { const forwardedFor = request.headers.get("x-forwarded-for"); if (!forwardedFor) return "unknown"; - const ips = forwardedFor.split(",").map(ip => ip.trim()); - - // Iterate from right to left and return the first non-private IP as the client IP. - for (let i = ips.length - 1; i >= 0; i--) { - const ip = ips[i]; - if (ip && isValidIp(ip) && !isTrustedProxy(ip)) { - return ip; - } - } + const proxyObservedIp = forwardedFor.split(",").at(-1)?.trim(); + if (proxyObservedIp && isValidIp(proxyObservedIp)) return proxyObservedIp; return "unknown"; } diff --git a/vitest.setup.ts b/vitest.setup.ts index a4ea52c..7b0828b 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,3 +1 @@ import '@testing-library/jest-dom'; - -process.env.NEXTAUTH_SECRET ??= 'test-nextauth-secret';