import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../src/server/app.js'; import { env } from '../src/server/config/env.js'; import { query } from '../src/server/db/pool.js'; import { hash, verify } from '@node-rs/argon2'; import * as otplib from 'otplib'; vi.mock('../src/server/db/pool.js', () => ({ pool: { connect: vi.fn(), }, query: vi.fn(), })); vi.mock('@node-rs/argon2', () => ({ hash: vi.fn(async () => 'hashed-password'), verify: vi.fn(async () => true), })); vi.mock('otplib', () => ({ verify: vi.fn(async () => ({ valid: true })), })); const USER_ID = '11111111-1111-4111-8111-111111111111'; function csrfHeaders() { return { 'Content-Type': 'application/json', Cookie: 'certremind_csrf=csrf-token', 'x-csrf-token': 'csrf-token', }; } function enableCaptcha(provider = 'turnstile') { env.captchaProvider = provider; env.captchaSiteKey = 'site-key'; env.captchaSecretKey = 'secret-key'; env.captchaVerifyTimeoutMs = 3000; } function mockCaptchaResponse(success) { globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ success }), })); } function mockUserRow({ otpSecret = null } = {}) { query.mockResolvedValueOnce({ rows: [ { user_id: USER_ID, username: 'alice', display_name: 'Alice', password_hash: 'hashed-password', otp_secret: otpSecret, }, ], }); } describe('auth CAPTCHA', () => { beforeEach(() => { env.captchaProvider = 'off'; env.captchaSiteKey = ''; env.captchaSecretKey = ''; env.captchaVerifyTimeoutMs = 3000; query.mockReset(); hash.mockClear(); verify.mockClear(); otplib.verify.mockClear(); vi.unstubAllGlobals(); }); it('returns disabled CAPTCHA public config by default', async () => { const app = createApp(); const response = await app.request('/api/auth/captcha'); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ enabled: false, provider: null, siteKey: null, }); }); it('keeps registration compatible when CAPTCHA is disabled', async () => { query .mockResolvedValueOnce({ rows: [{ user_id: USER_ID, username: 'alice', display_name: 'Alice' }], }) .mockResolvedValueOnce({ rows: [{ session_id: 'session-1' }] }); const app = createApp(); const response = await app.request('/api/auth/register', { method: 'POST', headers: csrfHeaders(), body: JSON.stringify({ displayName: 'Alice', username: 'alice', password: 'very-secure-password', }), }); expect(response.status).toBe(201); expect(hash).toHaveBeenCalledWith('very-secure-password', expect.any(Object)); expect(query).toHaveBeenCalledTimes(2); }); it('rejects registration with missing CAPTCHA before hashing or inserting', async () => { enableCaptcha(); const app = createApp(); const response = await app.request('/api/auth/register', { method: 'POST', headers: csrfHeaders(), body: JSON.stringify({ displayName: 'Alice', username: 'alice', password: 'very-secure-password', }), }); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: 'CAPTCHA認証に失敗しました', }); expect(hash).not.toHaveBeenCalled(); expect(query).not.toHaveBeenCalled(); }); it('continues registration after successful provider verification', async () => { enableCaptcha('hcaptcha'); mockCaptchaResponse(true); query .mockResolvedValueOnce({ rows: [{ user_id: USER_ID, username: 'alice', display_name: 'Alice' }], }) .mockResolvedValueOnce({ rows: [{ session_id: 'session-1' }] }); const app = createApp(); const response = await app.request('/api/auth/register', { method: 'POST', headers: csrfHeaders(), body: JSON.stringify({ displayName: 'Alice', username: 'alice', password: 'very-secure-password', captchaToken: 'captcha-token', }), }); expect(response.status).toBe(201); expect(fetch).toHaveBeenCalledWith( 'https://api.hcaptcha.com/siteverify', expect.objectContaining({ method: 'POST' }), ); expect(hash).toHaveBeenCalled(); expect(query).toHaveBeenCalledTimes(2); }); it('rejects login when provider verification fails before user lookup', async () => { enableCaptcha(); mockCaptchaResponse(false); const app = createApp(); const response = await app.request('/api/auth/login', { method: 'POST', headers: csrfHeaders(), body: JSON.stringify({ username: 'alice', password: 'very-secure-password', captchaToken: 'captcha-token', }), }); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: 'CAPTCHA認証に失敗しました', }); expect(query).not.toHaveBeenCalled(); expect(verify).not.toHaveBeenCalled(); }); it('rejects login when provider verification cannot be reached', async () => { enableCaptcha(); globalThis.fetch = vi.fn(async () => { throw new Error('network unavailable'); }); const app = createApp(); const response = await app.request('/api/auth/login', { method: 'POST', headers: csrfHeaders(), body: JSON.stringify({ username: 'alice', password: 'very-secure-password', captchaToken: 'captcha-token', }), }); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: 'CAPTCHA認証に失敗しました', }); expect(query).not.toHaveBeenCalled(); expect(verify).not.toHaveBeenCalled(); }); it('requires CAPTCHA on both TOTP login posts', async () => { enableCaptcha(); mockCaptchaResponse(true); mockUserRow({ otpSecret: 'otp-secret' }); const app = createApp(); const firstResponse = await app.request('/api/auth/login', { method: 'POST', headers: csrfHeaders(), body: JSON.stringify({ username: 'alice', password: 'very-secure-password', captchaToken: 'captcha-token-1', }), }); expect(firstResponse.status).toBe(401); await expect(firstResponse.json()).resolves.toEqual({ totpRequired: true }); expect(fetch).toHaveBeenCalledTimes(1); query.mockClear(); verify.mockClear(); otplib.verify.mockClear(); const secondResponse = await app.request('/api/auth/login', { method: 'POST', headers: csrfHeaders(), body: JSON.stringify({ username: 'alice', password: 'very-secure-password', otp: '123456', }), }); expect(secondResponse.status).toBe(400); await expect(secondResponse.json()).resolves.toMatchObject({ error: 'CAPTCHA認証に失敗しました', }); expect(query).not.toHaveBeenCalled(); expect(verify).not.toHaveBeenCalled(); expect(otplib.verify).not.toHaveBeenCalled(); }); });