forked from mirrors/misskey
* 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>
623 lines
17 KiB
TypeScript
623 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',
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|