/* * 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; let metaService: jest.Mocked; 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; metaService = app.get(MetaService) as jest.Mocked; }); 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) { 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) { 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, expectMeta: Partial) { 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) { 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', }), ); }); }); }); }); });