diff --git a/tests/framework/captcha/CaptchaActionTest.php b/tests/framework/captcha/CaptchaActionTest.php new file mode 100644 index 00000000000..0734b1b0605 --- /dev/null +++ b/tests/framework/captcha/CaptchaActionTest.php @@ -0,0 +1,387 @@ +mockWebApplication([ + 'controllerMap' => [ + 'test' => 'yii\web\Controller', + ], + 'components' => [ + 'session' => [ + 'class' => CaptchaTestSession::class, + ], + ], + ]); + } + + private function createAction(array $config = []): CaptchaAction + { + $controller = Yii::$app->createController('test')[0]; + $action = new CaptchaAction('captcha', $controller, $config); + return $action; + } + + public function testInitWithValidFontFile(): void + { + $action = $this->createAction(); + $this->assertFileExists($action->fontFile); + } + + public function testInitWithInvalidFontFileThrowsException(): void + { + $this->expectException(InvalidConfigException::class); + $this->createAction(['fontFile' => '/nonexistent/font.ttf']); + } + + /** + * @dataProvider generateValidationHashProvider + */ + public function testGenerateValidationHash(string $code, int $expectedHash): void + { + $action = $this->createAction(); + $this->assertSame($expectedHash, $action->generateValidationHash($code)); + } + + public static function generateValidationHashProvider(): array + { + return [ + 'single char' => ['a', ord('a')], + 'two chars' => ['ab', ord('a') + (ord('b') << 1)], + 'test' => ['test', ord('t') + (ord('e') << 1) + (ord('s') << 2) + (ord('t') << 3)], + ]; + } + + public function testGenerateValidationHashIsDeterministic(): void + { + $action = $this->createAction(); + $hash1 = $action->generateValidationHash('hello'); + $hash2 = $action->generateValidationHash('hello'); + $this->assertSame($hash1, $hash2); + } + + public function testGenerateValidationHashDifferentStringsDifferentHashes(): void + { + $action = $this->createAction(); + $hash1 = $action->generateValidationHash('abc'); + $hash2 = $action->generateValidationHash('xyz'); + $this->assertNotSame($hash1, $hash2); + } + + public function testGenerateValidationHashIsCaseSensitive(): void + { + $action = $this->createAction(); + $hashLower = $action->generateValidationHash('abc'); + $hashUpper = $action->generateValidationHash('ABC'); + $this->assertNotSame($hashLower, $hashUpper); + } + + public function testGetVerifyCodeWithFixedCode(): void + { + $action = $this->createAction(['fixedVerifyCode' => 'testme']); + $this->assertSame('testme', $action->getVerifyCode()); + } + + public function testGetVerifyCodeWithFixedCodeIgnoresRegenerate(): void + { + $action = $this->createAction(['fixedVerifyCode' => 'testme']); + $this->assertSame('testme', $action->getVerifyCode(true)); + } + + public function testGetVerifyCodeFromSession(): void + { + $action = $this->createAction(); + $code1 = $action->getVerifyCode(); + $code2 = $action->getVerifyCode(); + $this->assertSame($code1, $code2); + } + + public function testGetVerifyCodeRegeneratesWhenRequested(): void + { + $action = $this->createAction(); + $codes = []; + for ($i = 0; $i < 20; $i++) { + $codes[] = $action->getVerifyCode(true); + } + $this->assertGreaterThan(1, count(array_unique($codes))); + } + + public function testGenerateVerifyCodeRespectsDefaultLengthBounds(): void + { + $action = $this->createAction(); + for ($i = 0; $i < 10; $i++) { + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $length = strlen($code); + $this->assertGreaterThanOrEqual(6, $length); + $this->assertLessThanOrEqual(7, $length); + } + } + + public function testGenerateVerifyCodeRespectsCustomLengthBounds(): void + { + $action = $this->createAction(['minLength' => 8, 'maxLength' => 10]); + for ($i = 0; $i < 10; $i++) { + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $length = strlen($code); + $this->assertGreaterThanOrEqual(8, $length); + $this->assertLessThanOrEqual(10, $length); + } + } + + public function testGenerateVerifyCodeClampsMinLengthBelow3(): void + { + $action = $this->createAction(['minLength' => 2, 'maxLength' => 5]); + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $this->assertGreaterThanOrEqual(3, strlen($code)); + $this->assertLessThanOrEqual(5, strlen($code)); + } + + public function testGenerateVerifyCodeDoesNotClampMinLengthAt3(): void + { + $action = $this->createAction(['minLength' => 3, 'maxLength' => 3]); + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $this->assertSame(3, strlen($code)); + } + + public function testGenerateVerifyCodeClampsMaxLengthAbove20(): void + { + $action = $this->createAction(['minLength' => 18, 'maxLength' => 21]); + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $this->assertLessThanOrEqual(20, strlen($code)); + $this->assertGreaterThanOrEqual(18, strlen($code)); + } + + public function testGenerateVerifyCodeDoesNotClampMaxLengthAt20(): void + { + $action = $this->createAction(['minLength' => 20, 'maxLength' => 20]); + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $this->assertSame(20, strlen($code)); + } + + public function testGenerateVerifyCodeAdjustsMaxLengthWhenLessThanMinLength(): void + { + $action = $this->createAction(['minLength' => 10, 'maxLength' => 5]); + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $this->assertSame(10, strlen($code)); + } + + public function testGenerateVerifyCodeEqualMinAndMaxLength(): void + { + $action = $this->createAction(['minLength' => 8, 'maxLength' => 8]); + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $this->assertSame(8, strlen($code)); + } + + public function testGenerateVerifyCodeContainsOnlyValidChars(): void + { + $action = $this->createAction(); + $validChars = 'bcdfghjklmnpqrstvwxyzaeiou'; + for ($i = 0; $i < 10; $i++) { + $code = $this->invokeMethod($action, 'generateVerifyCode'); + $this->assertMatchesRegularExpression('/^[' . $validChars . ']+$/', $code); + } + } + + public function testGetSessionKeyFormat(): void + { + $action = $this->createAction(); + $sessionKey = $this->invokeMethod($action, 'getSessionKey'); + $this->assertSame('__captcha/' . $action->getUniqueId(), $sessionKey); + } + + public function testValidateCorrectCodeCaseSensitive(): void + { + $action = $this->createAction(['fixedVerifyCode' => 'TestCode']); + $this->assertTrue($action->validate('TestCode', true)); + } + + public function testValidateWrongCodeCaseSensitive(): void + { + $action = $this->createAction(['fixedVerifyCode' => 'TestCode']); + $this->assertFalse($action->validate('wrongcode', true)); + } + + public function testValidateCaseSensitiveRejectsDifferentCase(): void + { + $action = $this->createAction(['fixedVerifyCode' => 'TestCode']); + $this->assertFalse($action->validate('testcode', true)); + } + + public function testValidateCaseInsensitiveAcceptsDifferentCase(): void + { + $action = $this->createAction(['fixedVerifyCode' => 'TestCode']); + $this->assertTrue($action->validate('testcode', false)); + } + + public function testValidateCaseInsensitiveRejectsWrongCode(): void + { + $action = $this->createAction(['fixedVerifyCode' => 'TestCode']); + $this->assertFalse($action->validate('wrongcode', false)); + } + + public function testValidateRegeneratesCodeAfterTestLimit(): void + { + $action = $this->createAction(['testLimit' => 2]); + + $code = $action->getVerifyCode(); + + $action->validate('wrong', true); + $codeAfterFirstFail = $action->getVerifyCode(); + $this->assertSame($code, $codeAfterFirstFail); + + $action->validate('wrong', true); + $codeAfterExceedingLimit = $action->getVerifyCode(); + $this->assertNotSame($code, $codeAfterExceedingLimit); + } + + public function testValidateWithUnlimitedTestLimit(): void + { + $action = $this->createAction(['testLimit' => 0]); + + $code = $action->getVerifyCode(); + + for ($i = 0; $i < 5; $i++) { + $action->validate('wrong', true); + } + + $this->assertSame($code, $action->getVerifyCode()); + } + + public function testRunWithRefreshReturnsJsonStructure(): void + { + $_GET[CaptchaAction::REFRESH_GET_VAR] = 1; + + try { + $action = $this->createAction(['fixedVerifyCode' => 'testme']); + Yii::$app->controller = $action->controller; + $result = $action->run(); + + $this->assertArrayHasKey('hash1', $result); + $this->assertArrayHasKey('hash2', $result); + $this->assertArrayHasKey('url', $result); + $this->assertSame($action->generateValidationHash('testme'), $result['hash1']); + $this->assertSame($action->generateValidationHash('testme'), $result['hash2']); + } finally { + unset($_GET[CaptchaAction::REFRESH_GET_VAR]); + } + } + + public function testRunWithRefreshRegeneratesCode(): void + { + $action = $this->createAction(); + Yii::$app->controller = $action->controller; + $codeBefore = $action->getVerifyCode(); + + $_GET[CaptchaAction::REFRESH_GET_VAR] = 1; + + try { + $result = $action->run(); + $codeAfter = $action->getVerifyCode(); + + $this->assertNotSame($codeBefore, $codeAfter); + $this->assertSame($action->generateValidationHash($codeAfter), $result['hash1']); + } finally { + unset($_GET[CaptchaAction::REFRESH_GET_VAR]); + } + } + + public function testRunWithRefreshHash2UsesLowercase(): void + { + $_GET[CaptchaAction::REFRESH_GET_VAR] = 1; + + try { + $action = $this->createAction(['fixedVerifyCode' => 'TestMe']); + Yii::$app->controller = $action->controller; + $result = $action->run(); + + $this->assertSame($action->generateValidationHash('TestMe'), $result['hash1']); + $this->assertSame($action->generateValidationHash('testme'), $result['hash2']); + $this->assertNotSame($result['hash1'], $result['hash2']); + } finally { + unset($_GET[CaptchaAction::REFRESH_GET_VAR]); + } + } + + public function testSetHttpHeadersSetsAllRequiredHeaders(): void + { + $action = $this->createAction(); + $this->invokeMethod($action, 'setHttpHeaders'); + + $headers = Yii::$app->response->headers; + $this->assertSame('public', $headers->get('Pragma')); + $this->assertSame('0', $headers->get('Expires')); + $this->assertSame('must-revalidate, post-check=0, pre-check=0', $headers->get('Cache-Control')); + $this->assertSame('binary', $headers->get('Content-Transfer-Encoding')); + $this->assertSame('image/png', $headers->get('Content-type')); + } + + public function testRunWithoutRefreshReturnsImageAndSetsHeaders(): void + { + if (!function_exists('imagecreatetruecolor')) { + $this->markTestSkipped('GD extension is required.'); + } + + $action = $this->createAction(['fixedVerifyCode' => 'testme']); + $action->imageLibrary = 'gd'; + $result = $action->run(); + + $this->assertNotEmpty($result); + + $headers = Yii::$app->response->headers; + $this->assertSame('image/png', $headers->get('Content-type')); + $this->assertSame('binary', $headers->get('Content-Transfer-Encoding')); + } + + public function testRenderImageByGDReturnsPngData(): void + { + if (!function_exists('imagecreatetruecolor')) { + $this->markTestSkipped('GD extension is required.'); + } + + $action = $this->createAction(); + $action->imageLibrary = 'gd'; + $imageData = $this->invokeMethod($action, 'renderImage', ['testme']); + + $this->assertStringStartsWith("\x89PNG", $imageData); + } + + public function testRenderImageByGDWithTransparentBackground(): void + { + if (!function_exists('imagecreatetruecolor')) { + $this->markTestSkipped('GD extension is required.'); + } + + $action = $this->createAction(['transparent' => true]); + $action->imageLibrary = 'gd'; + $imageData = $this->invokeMethod($action, 'renderImage', ['testme']); + + $this->assertStringStartsWith("\x89PNG", $imageData); + } + + public function testRenderImageThrowsOnUnsupportedLibrary(): void + { + $action = $this->createAction(['fixedVerifyCode' => 'test']); + $action->imageLibrary = 'unsupported'; + + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage("Defined library 'unsupported' is not supported"); + $this->invokeMethod($action, 'renderImage', ['test']); + } +} diff --git a/tests/framework/captcha/CaptchaAssetTest.php b/tests/framework/captcha/CaptchaAssetTest.php new file mode 100644 index 00000000000..7b54b0f304f --- /dev/null +++ b/tests/framework/captcha/CaptchaAssetTest.php @@ -0,0 +1,77 @@ +mockWebApplication(); + } + + /** + * @return View + */ + private function getView() + { + $view = new View(); + $assetDir = Yii::getAlias('@runtime/assets'); + if (!is_dir($assetDir)) { + mkdir($assetDir, 0777, true); + } + $view->setAssetManager(new AssetManager([ + 'basePath' => $assetDir, + 'baseUrl' => '/assets', + 'bundles' => [ + JqueryAsset::class => [ + 'sourcePath' => null, + 'basePath' => null, + 'baseUrl' => '', + 'js' => [], + ], + ], + ])); + + return $view; + } + + public function testRegisterAddsToAssetBundles(): void + { + $view = $this->getView(); + + $bundle = CaptchaAsset::register($view); + + $this->assertInstanceOf(AssetBundle::class, $bundle); + $this->assertArrayHasKey(CaptchaAsset::class, $view->assetBundles); + } + + public function testRegisterIncludesDependency(): void + { + $view = $this->getView(); + + CaptchaAsset::register($view); + + $this->assertArrayHasKey(YiiAsset::class, $view->assetBundles); + } +} diff --git a/tests/framework/captcha/CaptchaTestSession.php b/tests/framework/captcha/CaptchaTestSession.php new file mode 100644 index 00000000000..91dfebc093f --- /dev/null +++ b/tests/framework/captcha/CaptchaTestSession.php @@ -0,0 +1,52 @@ +_data[$offset] ?? null; + } + + public function offsetSet($offset, $value) + { + $this->_data[$offset] = $value; + } + + public function offsetExists($offset) + { + return isset($this->_data[$offset]); + } + + public function offsetUnset($offset) + { + unset($this->_data[$offset]); + } + + public function remove($key) + { + $value = $this->_data[$key] ?? null; + unset($this->_data[$key]); + + return $value; + } +} diff --git a/tests/framework/captcha/CaptchaValidatorTest.php b/tests/framework/captcha/CaptchaValidatorTest.php new file mode 100644 index 00000000000..e531db81cd8 --- /dev/null +++ b/tests/framework/captcha/CaptchaValidatorTest.php @@ -0,0 +1,194 @@ + [ + 'class' => CaptchaAction::className(), + 'fixedVerifyCode' => 'testme', + ], + ]; + } +} + +/** + * @group captcha + */ +class CaptchaValidatorTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + $this->mockWebApplication([ + 'controllerMap' => [ + 'test' => CaptchaValidatorTestController::class, + ], + 'components' => [ + 'session' => [ + 'class' => CaptchaTestSession::class, + ], + ], + ]); + } + + private function createValidator(array $config = []): CaptchaValidator + { + $defaults = ['captchaAction' => 'test/captcha']; + return new CaptchaValidator(array_merge($defaults, $config)); + } + + public function testInitSetsDefaultMessage(): void + { + $validator = $this->createValidator(); + $this->assertSame('The verification code is incorrect.', $validator->message); + } + + public function testInitPreservesCustomMessage(): void + { + $validator = $this->createValidator(['message' => 'Custom error']); + $this->assertSame('Custom error', $validator->message); + } + + public function testValidateCorrectValue(): void + { + $validator = $this->createValidator(); + $result = $this->invokeMethod($validator, 'validateValue', ['testme']); + $this->assertNull($result); + } + + public function testValidateWrongValue(): void + { + $validator = $this->createValidator(); + $result = $this->invokeMethod($validator, 'validateValue', ['wrong']); + $this->assertIsArray($result); + $this->assertSame('The verification code is incorrect.', $result[0]); + } + + public function testValidateArrayValueReturnsError(): void + { + $validator = $this->createValidator(); + $result = $this->invokeMethod($validator, 'validateValue', [['testme']]); + $this->assertIsArray($result); + $this->assertSame('The verification code is incorrect.', $result[0]); + } + + public function testValidateCaseInsensitiveByDefault(): void + { + $validator = $this->createValidator(); + $result = $this->invokeMethod($validator, 'validateValue', ['TESTME']); + $this->assertNull($result); + } + + public function testValidateCaseSensitiveRejectsWrongCase(): void + { + $validator = $this->createValidator(['caseSensitive' => true]); + $result = $this->invokeMethod($validator, 'validateValue', ['TESTME']); + $this->assertIsArray($result); + } + + public function testValidateCaseSensitiveAcceptsCorrectCase(): void + { + $validator = $this->createValidator(['caseSensitive' => true]); + $result = $this->invokeMethod($validator, 'validateValue', ['testme']); + $this->assertNull($result); + } + + public function testCreateCaptchaActionReturnsAction(): void + { + $validator = $this->createValidator(); + $action = $validator->createCaptchaAction(); + $this->assertInstanceOf(CaptchaAction::class, $action); + } + + public function testCreateCaptchaActionThrowsOnInvalidRoute(): void + { + $validator = $this->createValidator(['captchaAction' => 'invalid/route']); + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Invalid CAPTCHA action ID: invalid/route'); + $validator->createCaptchaAction(); + } + + public function testGetClientOptionsContainsRequiredKeys(): void + { + $validator = $this->createValidator(); + $model = FakedValidationModel::createWithAttributes(['attr_captcha' => 'testme']); + $options = $validator->getClientOptions($model, 'attr_captcha'); + + $this->assertArrayHasKey('hash', $options); + $this->assertArrayHasKey('hashKey', $options); + $this->assertArrayHasKey('caseSensitive', $options); + $this->assertArrayHasKey('message', $options); + $this->assertArrayNotHasKey('skipOnEmpty', $options); + } + + public function testGetClientOptionsCaseSensitiveValue(): void + { + $validator = $this->createValidator(['caseSensitive' => true]); + $model = FakedValidationModel::createWithAttributes(['attr_captcha' => 'testme']); + $options = $validator->getClientOptions($model, 'attr_captcha'); + + $this->assertTrue($options['caseSensitive']); + } + + public function testGetClientOptionsCaseInsensitiveValue(): void + { + $validator = $this->createValidator(); + $model = FakedValidationModel::createWithAttributes(['attr_captcha' => 'testme']); + $options = $validator->getClientOptions($model, 'attr_captcha'); + + $this->assertFalse($options['caseSensitive']); + } + + public function testGetClientOptionsWithSkipOnEmpty(): void + { + $validator = $this->createValidator(['skipOnEmpty' => true]); + $model = FakedValidationModel::createWithAttributes(['attr_captcha' => 'testme']); + $options = $validator->getClientOptions($model, 'attr_captcha'); + + $this->assertArrayHasKey('skipOnEmpty', $options); + $this->assertSame(1, $options['skipOnEmpty']); + } + + public function testGetClientOptionsHashKeyContainsActionId(): void + { + $validator = $this->createValidator(); + $model = FakedValidationModel::createWithAttributes(['attr_captcha' => 'testme']); + $options = $validator->getClientOptions($model, 'attr_captcha'); + + $this->assertStringStartsWith('yiiCaptcha/', $options['hashKey']); + } + + public function testClientValidateAttributeReturnsJsString(): void + { + $validator = $this->createValidator(); + $model = FakedValidationModel::createWithAttributes(['attr_captcha' => 'testme']); + $view = $this->getMockBuilder(View::class) + ->onlyMethods(['registerAssetBundle']) + ->getMock(); + $js = $validator->clientValidateAttribute($model, 'attr_captcha', $view); + + $this->assertStringStartsWith('yii.validation.captcha(value, messages, ', $js); + $this->assertStringEndsWith(');', $js); + } +}