This commit is contained in:
おさむのひと 2024-12-20 10:57:48 +09:00
parent 3dade7a577
commit ce7f2054c8
7 changed files with 351 additions and 109 deletions

View file

@ -6,8 +6,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { MiMeta } from '@/models/Meta.js';
export const supportedCaptchaProviders = ['hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'testcaptcha'] as const; export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'testcaptcha'] as const;
export type CaptchaProvider = typeof supportedCaptchaProviders[number]; export type CaptchaProvider = typeof supportedCaptchaProviders[number];
export const captchaErrorCodes = { export const captchaErrorCodes = {
@ -30,14 +32,14 @@ export class CaptchaError extends Error {
} }
} }
export type ValidateSuccess = { export type CaptchaSaveSuccess = {
success: true; success: true;
} }
export type ValidateFailure = { export type CaptchaSaveFailure = {
success: false; success: false;
error: CaptchaError; error: CaptchaError;
} }
export type ValidateResult = ValidateSuccess | ValidateFailure; export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure;
type CaptchaResponse = { type CaptchaResponse = {
success: boolean; success: boolean;
@ -48,6 +50,7 @@ type CaptchaResponse = {
export class CaptchaService { export class CaptchaService {
constructor( constructor(
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private metaService: MetaService,
) { ) {
} }
@ -166,16 +169,15 @@ export class CaptchaService {
} }
/** /**
* captchaからの戻り値を検証します. * captchaの設定を更新します. captchaからの戻り値を検証しpassした場合のみ設定を更新します.
* captchaプロバイダの検証関数に委譲します. * captchaプロバイダの検証関数に委譲します.
* *
* @param provider captchaのプロバイダ
* @param params * @param params
* @param params.provider captchaのプロバイダ * @param params.sitekey hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsitekey.
* @param params.sitekey mcaptchaの場合に指定するsitekey.
* @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret. * @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret.
* @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL. * @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL.
* @param params.captchaResult captchaプロバイダからの戻り値. 使 * @param params.captchaResult captchaプロバイダからの戻り値. 使
*
* @see verifyHcaptcha * @see verifyHcaptcha
* @see verifyMcaptcha * @see verifyMcaptcha
* @see verifyRecaptcha * @see verifyRecaptcha
@ -183,56 +185,70 @@ export class CaptchaService {
* @see verifyTestcaptcha * @see verifyTestcaptcha
*/ */
@bindThis @bindThis
public async verify(params: { public async save(
provider: CaptchaProvider; provider: CaptchaProvider,
sitekey?: string; params?: {
secret?: string; sitekey?: string | null;
instanceUrl?: string; secret?: string | null;
captchaResult?: string | null; instanceUrl?: string | null;
}): Promise<ValidateResult> { captchaResult?: string | null;
if (!supportedCaptchaProviders.includes(params.provider)) { },
): Promise<CaptchaSaveResult> {
if (!supportedCaptchaProviders.includes(provider)) {
return { return {
success: false, success: false,
error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${params.provider}`), error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${provider}`),
}; };
} }
const operation = { const operation = {
none: async () => {
await this.updateMeta(provider, params);
},
hcaptcha: async () => { hcaptcha: async () => {
if (!params.secret) { if (!params?.secret || !params.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and response are required'); throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and captureResult are required');
} }
return this.verifyHcaptcha(params.secret, params.captchaResult); await this.verifyHcaptcha(params.secret, params.captchaResult);
await this.updateMeta(provider, params);
}, },
mcaptcha: async () => { mcaptcha: async () => {
if (!params.secret || !params.sitekey || !params.instanceUrl) { if (!params?.secret || !params.sitekey || !params.instanceUrl || !params.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and response are required'); throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and captureResult are required');
} }
return this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult); await this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult);
await this.updateMeta(provider, params);
}, },
recaptcha: async () => { recaptcha: async () => {
if (!params.secret) { if (!params?.secret || !params.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and response are required'); throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and captureResult are required');
} }
return this.verifyRecaptcha(params.secret, params.captchaResult); await this.verifyRecaptcha(params.secret, params.captchaResult);
await this.updateMeta(provider, params);
}, },
turnstile: async () => { turnstile: async () => {
if (!params.secret) { if (!params?.secret || !params.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and response are required'); throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and captureResult are required');
} }
return this.verifyTurnstile(params.secret, params.captchaResult); await this.verifyTurnstile(params.secret, params.captchaResult);
await this.updateMeta(provider, params);
}, },
testcaptcha: async () => { testcaptcha: async () => {
return this.verifyTestcaptcha(params.captchaResult); if (!params?.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: captureResult are required');
}
await this.verifyTestcaptcha(params.captchaResult);
await this.updateMeta(provider, params);
}, },
}[params.provider]; }[provider];
return operation() return operation()
.then(() => ({ success: true }) as ValidateSuccess) .then(() => ({ success: true }) as CaptchaSaveSuccess)
.catch(err => { .catch(err => {
const error = err instanceof CaptchaError const error = err instanceof CaptchaError
? err ? err
@ -243,5 +259,63 @@ export class CaptchaService {
}; };
}); });
} }
@bindThis
private async updateMeta(
provider: CaptchaProvider,
params?: {
sitekey?: string | null;
secret?: string | null;
instanceUrl?: string | null;
},
) {
const metaPartial: Partial<
Pick<
MiMeta,
('enableHcaptcha' | 'hcaptchaSiteKey' | 'hcaptchaSecretKey') |
('enableMcaptcha' | 'mcaptchaSitekey' | 'mcaptchaSecretKey' | 'mcaptchaInstanceUrl') |
('enableRecaptcha' | 'recaptchaSiteKey' | 'recaptchaSecretKey') |
('enableTurnstile' | 'turnstileSiteKey' | 'turnstileSecretKey') |
('enableTestcaptcha')
>
> = {
enableHcaptcha: provider === 'hcaptcha',
enableMcaptcha: provider === 'mcaptcha',
enableRecaptcha: provider === 'recaptcha',
enableTurnstile: provider === 'turnstile',
enableTestcaptcha: provider === 'testcaptcha',
};
const updateIfNotUndefined = <K extends keyof typeof metaPartial>(key: K, value: typeof metaPartial[K]) => {
if (value !== undefined) {
metaPartial[key] = value;
}
};
switch (provider) {
case 'hcaptcha': {
updateIfNotUndefined('hcaptchaSiteKey', params?.sitekey);
updateIfNotUndefined('hcaptchaSecretKey', params?.secret);
break;
}
case 'mcaptcha': {
updateIfNotUndefined('mcaptchaSitekey', params?.sitekey);
updateIfNotUndefined('mcaptchaSecretKey', params?.secret);
updateIfNotUndefined('mcaptchaInstanceUrl', params?.instanceUrl);
break;
}
case 'recaptcha': {
updateIfNotUndefined('recaptchaSiteKey', params?.sitekey);
updateIfNotUndefined('recaptchaSecretKey', params?.secret);
break;
}
case 'turnstile': {
updateIfNotUndefined('turnstileSiteKey', params?.sitekey);
updateIfNotUndefined('turnstileSecretKey', params?.secret);
break;
}
}
await this.metaService.update(metaPartial);
}
} }

View file

@ -28,7 +28,7 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d
import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_captcha_test from './endpoints/admin/captcha/test.js'; import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.js';
import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
@ -417,7 +417,7 @@ const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-de
const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default };
const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default };
const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default };
const $admin_captcha_test: Provider = { provide: 'ep:admin/captcha/test', useClass: ep___admin_captcha_test.default }; const $admin_captcha_save: Provider = { provide: 'ep:admin/captcha/save', useClass: ep___admin_captcha_save.default };
const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default };
const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default };
const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default };
@ -810,7 +810,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete, $admin_avatarDecorations_delete,
$admin_avatarDecorations_list, $admin_avatarDecorations_list,
$admin_avatarDecorations_update, $admin_avatarDecorations_update,
$admin_captcha_test, $admin_captcha_save,
$admin_deleteAllFilesOfAUser, $admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar, $admin_unsetUserAvatar,
$admin_unsetUserBanner, $admin_unsetUserBanner,
@ -1197,7 +1197,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete, $admin_avatarDecorations_delete,
$admin_avatarDecorations_list, $admin_avatarDecorations_list,
$admin_avatarDecorations_update, $admin_avatarDecorations_update,
$admin_captcha_test, $admin_captcha_save,
$admin_deleteAllFilesOfAUser, $admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar, $admin_unsetUserAvatar,
$admin_unsetUserBanner, $admin_unsetUserBanner,

View file

@ -33,7 +33,7 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d
import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_captcha_test from './endpoints/admin/captcha/test.js'; import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.js';
import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
@ -421,7 +421,7 @@ const eps = [
['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete],
['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list],
['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update],
['admin/captcha/test', ep___admin_captcha_test], ['admin/captcha/test', ep___admin_captcha_save],
['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser],
['admin/unset-user-avatar', ep___admin_unsetUserAvatar], ['admin/unset-user-avatar', ep___admin_unsetUserAvatar],
['admin/unset-user-banner', ep___admin_unsetUserBanner], ['admin/unset-user-banner', ep___admin_unsetUserBanner],

View file

@ -46,17 +46,17 @@ export const paramDef = {
type: 'string', type: 'string',
enum: supportedCaptchaProviders, enum: supportedCaptchaProviders,
}, },
captchaResult: {
type: 'string', nullable: true,
},
sitekey: { sitekey: {
type: 'string', type: 'string', nullable: true,
}, },
secret: { secret: {
type: 'string', type: 'string', nullable: true,
}, },
instanceUrl: { instanceUrl: {
type: 'string', type: 'string', nullable: true,
},
captchaResult: {
type: 'string',
}, },
}, },
required: ['provider'], required: ['provider'],
@ -67,13 +67,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
private captchaService: CaptchaService, private captchaService: CaptchaService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps) => {
const result = await this.captchaService.verify({ const result = await this.captchaService.save(ps.provider, ps.captchaResult, {
provider: ps.provider,
sitekey: ps.sitekey, sitekey: ps.sitekey,
secret: ps.secret, secret: ps.secret,
instanceUrl: ps.instanceUrl, instanceUrl: ps.instanceUrl,
captchaResult: ps.captchaResult,
}); });
if (result.success) { if (result.success) {

View file

@ -10,16 +10,19 @@ import {
CaptchaError, CaptchaError,
CaptchaErrorCode, CaptchaErrorCode,
captchaErrorCodes, captchaErrorCodes,
CaptchaSaveResult,
CaptchaService, CaptchaService,
ValidateResult,
} from '@/core/CaptchaService.js'; } from '@/core/CaptchaService.js';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { MetaService } from '@/core/MetaService.js';
import { MiMeta } from '@/models/Meta.js';
describe('CaptchaService', () => { describe('CaptchaService', () => {
let app: TestingModule; let app: TestingModule;
let service: CaptchaService; let service: CaptchaService;
let httpRequestService: jest.Mocked<HttpRequestService>; let httpRequestService: jest.Mocked<HttpRequestService>;
let metaService: jest.Mocked<MetaService>;
beforeAll(async () => { beforeAll(async () => {
app = await Test.createTestingModule({ app = await Test.createTestingModule({
@ -31,6 +34,9 @@ describe('CaptchaService', () => {
{ {
provide: HttpRequestService, useFactory: () => ({ send: jest.fn() }), provide: HttpRequestService, useFactory: () => ({ send: jest.fn() }),
}, },
{
provide: MetaService, useFactory: () => ({ update: jest.fn() }),
},
], ],
}).compile(); }).compile();
@ -38,10 +44,12 @@ describe('CaptchaService', () => {
service = app.get(CaptchaService); service = app.get(CaptchaService);
httpRequestService = app.get(HttpRequestService) as jest.Mocked<HttpRequestService>; httpRequestService = app.get(HttpRequestService) as jest.Mocked<HttpRequestService>;
metaService = app.get(MetaService) as jest.Mocked<MetaService>;
}); });
beforeEach(() => { beforeEach(() => {
httpRequestService.send.mockClear(); httpRequestService.send.mockClear();
metaService.update.mockClear();
}); });
afterAll(async () => { afterAll(async () => {
@ -76,7 +84,6 @@ describe('CaptchaService', () => {
await test(); await test();
expect(false).toBe(true); expect(false).toBe(true);
} catch (e) { } catch (e) {
console.log(e);
expect(e instanceof CaptchaError).toBe(true); expect(e instanceof CaptchaError).toBe(true);
const _e = e as CaptchaError; const _e = e as CaptchaError;
@ -184,81 +191,194 @@ describe('CaptchaService', () => {
}); });
}); });
describe('validateSettings', () => { describe('save', () => {
const host = 'https://localhost'; const host = 'https://localhost';
describe('success', () => { describe('[success] 検証に成功した時だけ保存できる+他のプロバイダの設定値を誤って更新しない', () => {
beforeEach(() => { beforeEach(() => {
successMock({ success: true, valid: true }); successMock({ success: true, valid: true });
}); });
async function assertSuccess(promise: Promise<ValidateResult>) { async function assertSuccess(promise: Promise<CaptchaSaveResult>, expectMeta: Partial<MiMeta>) {
await expect(promise) await expect(promise)
.resolves .resolves
.toStrictEqual({ success: true }); .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 () => { test('hcaptcha', async () => {
await assertSuccess(service.verify({ provider: 'hcaptcha', secret: 'secret', captchaResult: 'response' })); 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 () => { test('mcaptcha', async () => {
await assertSuccess(service.verify({ await assertSuccess(
provider: 'mcaptcha', service.save('mcaptcha', {
secret: 'secret', sitekey: 'mcaptcha-sitekey',
sitekey: 'sitekey', secret: 'mcaptcha-secret',
instanceUrl: host, instanceUrl: host,
captchaResult: 'response', captchaResult: 'mcaptcha-passed',
})); }),
{
enableHcaptcha: false,
enableMcaptcha: true,
enableRecaptcha: false,
enableTurnstile: false,
enableTestcaptcha: false,
mcaptchaSitekey: 'mcaptcha-sitekey',
mcaptchaSecretKey: 'mcaptcha-secret',
mcaptchaInstanceUrl: host,
},
);
}); });
test('recaptcha', async () => { test('recaptcha', async () => {
await assertSuccess(service.verify({ provider: 'recaptcha', secret: 'secret', captchaResult: 'response' })); 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 () => { test('turnstile', async () => {
await assertSuccess(service.verify({ provider: 'turnstile', secret: 'secret', captchaResult: 'response' })); 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 () => { test('testcaptcha', async () => {
await assertSuccess(service.verify({ provider: 'testcaptcha', captchaResult: 'testcaptcha-passed' })); 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', () => { describe('[failure] 検証に失敗した場合は保存できない+設定値の更新そのものが発生しない', () => {
async function assertFailure(code: CaptchaErrorCode, promise: Promise<ValidateResult>) { async function assertFailure(code: CaptchaErrorCode, promise: Promise<CaptchaSaveResult>) {
const res = await promise; const res = await promise;
expect(res.success).toBe(false); expect(res.success).toBe(false);
if (!res.success) { if (!res.success) {
expect(res.error.code).toBe(code); expect(res.error.code).toBe(code);
} }
expect(metaService.update).not.toBeCalled();
} }
describe('noResponseProvided', () => { describe('invalidParameters', () => {
test('hcaptcha', async () => { test('hcaptcha', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ provider: 'hcaptcha', secret: 'secret', captchaResult: null })); await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('hcaptcha', {
sitekey: 'hcaptcha-sitekey',
secret: 'hcaptcha-secret',
captchaResult: null,
}),
);
}); });
test('mcaptcha', async () => { test('mcaptcha', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ await assertFailure(
provider: 'mcaptcha', captchaErrorCodes.invalidParameters,
secret: 'secret', service.save('mcaptcha', {
sitekey: 'sitekey', sitekey: 'mcaptcha-sitekey',
instanceUrl: host, secret: 'mcaptcha-secret',
captchaResult: null, instanceUrl: host,
})); captchaResult: null,
}),
);
}); });
test('recaptcha', async () => { test('recaptcha', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ provider: 'recaptcha', secret: 'secret', captchaResult: null })); await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('recaptcha', {
sitekey: 'recaptcha-sitekey',
secret: 'recaptcha-secret',
captchaResult: null,
}),
);
}); });
test('turnstile', async () => { test('turnstile', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ provider: 'turnstile', secret: 'secret', captchaResult: null })); await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('turnstile', {
sitekey: 'turnstile-sitekey',
secret: 'turnstile-secret',
captchaResult: null,
}),
);
}); });
test('testcaptcha', async () => { test('testcaptcha', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ provider: 'testcaptcha', captchaResult: null })); await assertFailure(
captchaErrorCodes.invalidParameters,
service.save('testcaptcha', {
captchaResult: null,
}),
);
}); });
}); });
@ -268,29 +388,51 @@ describe('CaptchaService', () => {
}); });
test('hcaptcha', async () => { test('hcaptcha', async () => {
await assertFailure(captchaErrorCodes.requestFailed, service.verify({ provider: 'hcaptcha', secret: 'secret', captchaResult: 'res' })); await assertFailure(
captchaErrorCodes.requestFailed,
service.save('hcaptcha', {
sitekey: 'hcaptcha-sitekey',
secret: 'hcaptcha-secret',
captchaResult: 'hcaptcha-passed',
}),
);
}); });
test('mcaptcha', async () => { test('mcaptcha', async () => {
await assertFailure(captchaErrorCodes.requestFailed, service.verify({ await assertFailure(
provider: 'mcaptcha', captchaErrorCodes.requestFailed,
secret: 'secret', service.save('mcaptcha', {
sitekey: 'sitekey', sitekey: 'mcaptcha-sitekey',
instanceUrl: host, secret: 'mcaptcha-secret',
captchaResult: 'res', instanceUrl: host,
})); captchaResult: 'mcaptcha-passed',
}),
);
}); });
test('recaptcha', async () => { test('recaptcha', async () => {
await assertFailure(captchaErrorCodes.requestFailed, service.verify({ provider: 'recaptcha', secret: 'secret', captchaResult: 'res' })); await assertFailure(
captchaErrorCodes.requestFailed,
service.save('recaptcha', {
sitekey: 'recaptcha-sitekey',
secret: 'recaptcha-secret',
captchaResult: 'recaptcha-passed',
}),
);
}); });
test('turnstile', async () => { test('turnstile', async () => {
await assertFailure(captchaErrorCodes.requestFailed, service.verify({ provider: 'turnstile', secret: 'secret', captchaResult: 'res' })); await assertFailure(
captchaErrorCodes.requestFailed,
service.save('turnstile', {
sitekey: 'turnstile-sitekey',
secret: 'turnstile-secret',
captchaResult: 'turnstile-passed',
}),
);
}); });
// testcaptchaはrequestFailedが発生しない // testchapchaはrequestFailedがない
// test('testcaptcha', () => {});
}); });
describe('verificationFailed', () => { describe('verificationFailed', () => {
@ -299,29 +441,57 @@ describe('CaptchaService', () => {
}); });
test('hcaptcha', async () => { test('hcaptcha', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ provider: 'hcaptcha', secret: 'secret', captchaResult: 'res' })); await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('hcaptcha', {
sitekey: 'hcaptcha-sitekey',
secret: 'hcaptcha-secret',
captchaResult: 'hccaptcha-passed',
}),
);
}); });
test('mcaptcha', async () => { test('mcaptcha', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ await assertFailure(
provider: 'mcaptcha', captchaErrorCodes.verificationFailed,
secret: 'secret', service.save('mcaptcha', {
sitekey: 'sitekey', sitekey: 'mcaptcha-sitekey',
instanceUrl: host, secret: 'mcaptcha-secret',
captchaResult: 'res', instanceUrl: host,
})); captchaResult: 'mcaptcha-passed',
}),
);
}); });
test('recaptcha', async () => { test('recaptcha', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ provider: 'recaptcha', secret: 'secret', captchaResult: 'res' })); await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('recaptcha', {
sitekey: 'recaptcha-sitekey',
secret: 'recaptcha-secret',
captchaResult: 'recaptcha-passed',
}),
);
}); });
test('turnstile', async () => { test('turnstile', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ provider: 'turnstile', secret: 'secret', captchaResult: 'res' })); await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('turnstile', {
sitekey: 'turnstile-sitekey',
secret: 'turnstile-secret',
captchaResult: 'turnstile-passed',
}),
);
}); });
test('testcaptcha', async () => { test('testcaptcha', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ provider: 'testcaptcha', captchaResult: 'testcaptcha-failed' })); await assertFailure(
captchaErrorCodes.verificationFailed,
service.save('testcaptcha', {
captchaResult: 'testcaptcha-failed',
}),
);
}); });
}); });
}); });

View file

@ -258,9 +258,9 @@ watch(captchaResult, async () => {
if (captchaResult.value) { if (captchaResult.value) {
const result = await misskeyApi('admin/captcha/test', { const result = await misskeyApi('admin/captcha/test', {
provider: provider as Misskey.entities.AdminCaptchaTestRequest['provider'], provider: provider as Misskey.entities.AdminCaptchaTestRequest['provider'],
sitekey: sitekey ?? undefined, sitekey: sitekey,
secret: secret ?? undefined, secret: secret,
instanceUrl: botProtectionForm.state.mcaptchaInstanceUrl ?? undefined, instanceUrl: botProtectionForm.state.mcaptchaInstanceUrl,
captchaResult: captchaResult.value, captchaResult: captchaResult.value,
}); });

View file

@ -6586,11 +6586,11 @@ export type operations = {
content: { content: {
'application/json': { 'application/json': {
/** @enum {string} */ /** @enum {string} */
provider: 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha'; provider: 'none' | 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha';
sitekey?: string; captchaResult?: string | null;
secret?: string; sitekey?: string | null;
instanceUrl?: string; secret?: string | null;
captchaResult?: string; instanceUrl?: string | null;
}; };
}; };
}; };