paricafe/packages/backend/test/unit/CaptchaService.ts
おさむのひと 64501c69a1
feat(frontend): Botプロテクションの設定変更時は実際に検証を通過しないと保存できないようにする (#15151)
* feat(frontend): CAPTCHAの設定変更時は実際に検証を通過しないと保存できないようにする

* なしでも保存できるようにした

* fix CHANGELOG.md

* フォームが増殖するのを修正

* add comment

* add server-side verify

* fix ci

* fix

* fix

* fix i18n

* add current.ts

* fix text

* fix

* regenerate locales

* fix MkFormFooter.vue

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-01-14 10:57:58 +00:00

622 lines
17 KiB
TypeScript

/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterAll, beforeAll, beforeEach, describe, expect, jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import { Response } from 'node-fetch';
import {
CaptchaError,
CaptchaErrorCode,
captchaErrorCodes,
CaptchaSaveResult,
CaptchaService,
} from '@/core/CaptchaService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { MetaService } from '@/core/MetaService.js';
import { MiMeta } from '@/models/Meta.js';
import { LoggerService } from '@/core/LoggerService.js';
describe('CaptchaService', () => {
let app: TestingModule;
let service: CaptchaService;
let httpRequestService: jest.Mocked<HttpRequestService>;
let metaService: jest.Mocked<MetaService>;
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
],
providers: [
CaptchaService,
LoggerService,
{
provide: HttpRequestService, useFactory: () => ({ send: jest.fn() }),
},
{
provide: MetaService, useFactory: () => ({
fetch: jest.fn(),
update: jest.fn(),
}),
},
],
}).compile();
app.enableShutdownHooks();
service = app.get(CaptchaService);
httpRequestService = app.get(HttpRequestService) as jest.Mocked<HttpRequestService>;
metaService = app.get(MetaService) as jest.Mocked<MetaService>;
});
beforeEach(() => {
httpRequestService.send.mockClear();
metaService.update.mockClear();
metaService.fetch.mockClear();
});
afterAll(async () => {
await app.close();
});
function successMock(result: object) {
httpRequestService.send.mockResolvedValue({
ok: true,
status: 200,
json: async () => (result),
} as Response);
}
function failureHttpMock() {
httpRequestService.send.mockResolvedValue({
ok: false,
status: 400,
} as Response);
}
function failureVerificationMock(result: object) {
httpRequestService.send.mockResolvedValue({
ok: true,
status: 200,
json: async () => (result),
} as Response);
}
async function testCaptchaError(code: CaptchaErrorCode, test: () => Promise<void>) {
try {
await test();
expect(false).toBe(true);
} catch (e) {
expect(e instanceof CaptchaError).toBe(true);
const _e = e as CaptchaError;
expect(_e.code).toBe(code);
}
}
describe('verifyRecaptcha', () => {
test('success', async () => {
successMock({ success: true });
await service.verifyRecaptcha('secret', 'response');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyRecaptcha('secret', null));
});
test('requestFailed', async () => {
failureHttpMock();
await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyRecaptcha('secret', 'response'));
});
test('verificationFailed', async () => {
failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyRecaptcha('secret', 'response'));
});
});
describe('verifyHcaptcha', () => {
test('success', async () => {
successMock({ success: true });
await service.verifyHcaptcha('secret', 'response');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyHcaptcha('secret', null));
});
test('requestFailed', async () => {
failureHttpMock();
await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyHcaptcha('secret', 'response'));
});
test('verificationFailed', async () => {
failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyHcaptcha('secret', 'response'));
});
});
describe('verifyMcaptcha', () => {
const host = 'https://localhost';
test('success', async () => {
successMock({ valid: true });
await service.verifyMcaptcha('secret', 'sitekey', host, 'response');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyMcaptcha('secret', 'sitekey', host, null));
});
test('requestFailed', async () => {
failureHttpMock();
await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response'));
});
test('verificationFailed', async () => {
failureVerificationMock({ valid: false });
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response'));
});
});
describe('verifyTurnstile', () => {
test('success', async () => {
successMock({ success: true });
await service.verifyTurnstile('secret', 'response');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTurnstile('secret', null));
});
test('requestFailed', async () => {
failureHttpMock();
await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyTurnstile('secret', 'response'));
});
test('verificationFailed', async () => {
failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTurnstile('secret', 'response'));
});
});
describe('verifyTestcaptcha', () => {
test('success', async () => {
await service.verifyTestcaptcha('testcaptcha-passed');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTestcaptcha(null));
});
test('verificationFailed', async () => {
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTestcaptcha('testcaptcha-failed'));
});
});
describe('get', () => {
function setupMeta(meta: Partial<MiMeta>) {
metaService.fetch.mockResolvedValue(meta as MiMeta);
}
test('values', async () => {
setupMeta({
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: false,
hcaptchaSiteKey: 'hcaptcha-sitekey',
hcaptchaSecretKey: 'hcaptcha-secret',
mcaptchaSitekey: 'mcaptcha-sitekey',
mcaptchaSecretKey: 'mcaptcha-secret',
mcaptchaInstanceUrl: 'https://localhost',
recaptchaSiteKey: 'recaptcha-sitekey',
recaptchaSecretKey: 'recaptcha-secret',
turnstileSiteKey: 'turnstile-sitekey',
turnstileSecretKey: 'turnstile-secret',
});
const result = await service.get();
expect(result.provider).toBe('none');
expect(result.hcaptcha.siteKey).toBe('hcaptcha-sitekey');
expect(result.hcaptcha.secretKey).toBe('hcaptcha-secret');
expect(result.mcaptcha.siteKey).toBe('mcaptcha-sitekey');
expect(result.mcaptcha.secretKey).toBe('mcaptcha-secret');
expect(result.mcaptcha.instanceUrl).toBe('https://localhost');
expect(result.recaptcha.siteKey).toBe('recaptcha-sitekey');
expect(result.recaptcha.secretKey).toBe('recaptcha-secret');
expect(result.turnstile.siteKey).toBe('turnstile-sitekey');
expect(result.turnstile.secretKey).toBe('turnstile-secret');
});
describe('provider', () => {
test('none', async () => {
setupMeta({
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: false,
});
const result = await service.get();
expect(result.provider).toBe('none');
});
test('hcaptcha', async () => {
setupMeta({
enableHcaptcha: true,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: false,
});
const result = await service.get();
expect(result.provider).toBe('hcaptcha');
});
test('mcaptcha', async () => {
setupMeta({
enableHcaptcha: false,
enableMcaptcha: true,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: false,
});
const result = await service.get();
expect(result.provider).toBe('mcaptcha');
});
test('recaptcha', async () => {
setupMeta({
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: true,
enableTurnstile: false,
enableTestcaptcha: false,
});
const result = await service.get();
expect(result.provider).toBe('recaptcha');
});
test('turnstile', async () => {
setupMeta({
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: true,
enableTestcaptcha: false,
});
const result = await service.get();
expect(result.provider).toBe('turnstile');
});
test('testcaptcha', async () => {
setupMeta({
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: true,
});
const result = await service.get();
expect(result.provider).toBe('testcaptcha');
});
});
});
describe('save', () => {
const host = 'https://localhost';
describe('[success] 検証に成功した時だけ保存できる+他のプロバイダの設定値を誤って更新しない', () => {
beforeEach(() => {
successMock({ success: true, valid: true });
});
async function assertSuccess(promise: Promise<CaptchaSaveResult>, expectMeta: Partial<MiMeta>) {
await expect(promise)
.resolves
.toStrictEqual({ success: true });
const partialParams = metaService.update.mock.calls[0][0];
expect(partialParams).toStrictEqual(expectMeta);
}
test('none', async () => {
await assertSuccess(
service.save('none'),
{
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: false,
},
);
});
test('hcaptcha', async () => {
await assertSuccess(
service.save('hcaptcha', {
sitekey: 'hcaptcha-sitekey',
secret: 'hcaptcha-secret',
captchaResult: 'hcaptcha-passed',
}),
{
enableHcaptcha: true,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: false,
hcaptchaSiteKey: 'hcaptcha-sitekey',
hcaptchaSecretKey: 'hcaptcha-secret',
},
);
});
test('mcaptcha', async () => {
await assertSuccess(
service.save('mcaptcha', {
sitekey: 'mcaptcha-sitekey',
secret: 'mcaptcha-secret',
instanceUrl: host,
captchaResult: 'mcaptcha-passed',
}),
{
enableHcaptcha: false,
enableMcaptcha: true,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: false,
mcaptchaSitekey: 'mcaptcha-sitekey',
mcaptchaSecretKey: 'mcaptcha-secret',
mcaptchaInstanceUrl: host,
},
);
});
test('recaptcha', async () => {
await assertSuccess(
service.save('recaptcha', {
sitekey: 'recaptcha-sitekey',
secret: 'recaptcha-secret',
captchaResult: 'recaptcha-passed',
}),
{
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: true,
enableTurnstile: false,
enableTestcaptcha: false,
recaptchaSiteKey: 'recaptcha-sitekey',
recaptchaSecretKey: 'recaptcha-secret',
},
);
});
test('turnstile', async () => {
await assertSuccess(
service.save('turnstile', {
sitekey: 'turnstile-sitekey',
secret: 'turnstile-secret',
captchaResult: 'turnstile-passed',
}),
{
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: true,
enableTestcaptcha: false,
turnstileSiteKey: 'turnstile-sitekey',
turnstileSecretKey: 'turnstile-secret',
},
);
});
test('testcaptcha', async () => {
await assertSuccess(
service.save('testcaptcha', {
sitekey: 'testcaptcha-sitekey',
secret: 'testcaptcha-secret',
captchaResult: 'testcaptcha-passed',
}),
{
enableHcaptcha: false,
enableMcaptcha: false,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: true,
},
);
});
});
describe('[failure] 検証に失敗した場合は保存できない+設定値の更新そのものが発生しない', () => {
async function assertFailure(code: CaptchaErrorCode, promise: Promise<CaptchaSaveResult>) {
const res = await promise;
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.code).toBe(code);
}
expect(metaService.update).not.toBeCalled();
}
describe('invalidParameters', () => {
test('hcaptcha', async () => {
await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('hcaptcha', {
sitekey: 'hcaptcha-sitekey',
secret: 'hcaptcha-secret',
captchaResult: null,
}),
);
});
test('mcaptcha', async () => {
await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('mcaptcha', {
sitekey: 'mcaptcha-sitekey',
secret: 'mcaptcha-secret',
instanceUrl: host,
captchaResult: null,
}),
);
});
test('recaptcha', async () => {
await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('recaptcha', {
sitekey: 'recaptcha-sitekey',
secret: 'recaptcha-secret',
captchaResult: null,
}),
);
});
test('turnstile', async () => {
await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('turnstile', {
sitekey: 'turnstile-sitekey',
secret: 'turnstile-secret',
captchaResult: null,
}),
);
});
test('testcaptcha', async () => {
await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('testcaptcha', {
captchaResult: null,
}),
);
});
});
describe('requestFailed', () => {
beforeEach(() => {
failureHttpMock();
});
test('hcaptcha', async () => {
await assertFailure(
captchaErrorCodes.requestFailed,
service.save('hcaptcha', {
sitekey: 'hcaptcha-sitekey',
secret: 'hcaptcha-secret',
captchaResult: 'hcaptcha-passed',
}),
);
});
test('mcaptcha', async () => {
await assertFailure(
captchaErrorCodes.requestFailed,
service.save('mcaptcha', {
sitekey: 'mcaptcha-sitekey',
secret: 'mcaptcha-secret',
instanceUrl: host,
captchaResult: 'mcaptcha-passed',
}),
);
});
test('recaptcha', async () => {
await assertFailure(
captchaErrorCodes.requestFailed,
service.save('recaptcha', {
sitekey: 'recaptcha-sitekey',
secret: 'recaptcha-secret',
captchaResult: 'recaptcha-passed',
}),
);
});
test('turnstile', async () => {
await assertFailure(
captchaErrorCodes.requestFailed,
service.save('turnstile', {
sitekey: 'turnstile-sitekey',
secret: 'turnstile-secret',
captchaResult: 'turnstile-passed',
}),
);
});
// testchapchaはrequestFailedがない
});
describe('verificationFailed', () => {
beforeEach(() => {
failureVerificationMock({ success: false, valid: false, 'error-codes': ['code01', 'code02'] });
});
test('hcaptcha', async () => {
await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('hcaptcha', {
sitekey: 'hcaptcha-sitekey',
secret: 'hcaptcha-secret',
captchaResult: 'hccaptcha-passed',
}),
);
});
test('mcaptcha', async () => {
await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('mcaptcha', {
sitekey: 'mcaptcha-sitekey',
secret: 'mcaptcha-secret',
instanceUrl: host,
captchaResult: 'mcaptcha-passed',
}),
);
});
test('recaptcha', async () => {
await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('recaptcha', {
sitekey: 'recaptcha-sitekey',
secret: 'recaptcha-secret',
captchaResult: 'recaptcha-passed',
}),
);
});
test('turnstile', async () => {
await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('turnstile', {
sitekey: 'turnstile-sitekey',
secret: 'turnstile-secret',
captchaResult: 'turnstile-passed',
}),
);
});
test('testcaptcha', async () => {
await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('testcaptcha', {
captchaResult: 'testcaptcha-failed',
}),
);
});
});
});
});
});