Skip to content
Closed
26 changes: 26 additions & 0 deletions src/app/api/card/[username]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,29 @@
}));
});
});

describe("GET /api/card/[username] production configuration", () => {
const originalNodeEnv = process.env.NODE_ENV;
const originalAppUrl = process.env.APP_URL;

afterEach(() => {

Check failure on line 123 in src/app/api/card/[username]/route.test.ts

View workflow job for this annotation

GitHub Actions / Type Check

Cannot find name 'afterEach'.
process.env.NODE_ENV = originalNodeEnv;

Check failure on line 124 in src/app/api/card/[username]/route.test.ts

View workflow job for this annotation

GitHub Actions / Type Check

Cannot assign to 'NODE_ENV' because it is a read-only property.
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";

Check failure on line 133 in src/app/api/card/[username]/route.test.ts

View workflow job for this annotation

GitHub Actions / Type Check

Cannot assign to 'NODE_ENV' because it is a read-only property.
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");
});
});
8 changes: 7 additions & 1 deletion src/app/api/card/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 0 additions & 13 deletions src/app/api/dashboard/stats/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down
5 changes: 0 additions & 5 deletions src/app/api/dashboard/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
13 changes: 0 additions & 13 deletions src/app/api/dashboard/year/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down
5 changes: 0 additions & 5 deletions src/app/api/dashboard/year/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
52 changes: 0 additions & 52 deletions src/hooks/__tests__/useDashboardData.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
100 changes: 30 additions & 70 deletions src/lib/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<NonNullable<NonNullable<typeof authOptions.callbacks>['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<NonNullable<NonNullable<typeof authOptions.callbacks>['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.');
});
});
});
Loading
Loading