253 lines
7.0 KiB
JavaScript
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();
|
|
});
|
|
});
|