Files
certremind/tests/captcha.test.js
2026-05-25 11:46:42 +09:00

253 lines
7.0 KiB
JavaScript

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();
});
});