CAPTCHA対応
This commit is contained in:
252
tests/captcha.test.js
Normal file
252
tests/captcha.test.js
Normal file
@@ -0,0 +1,252 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user