diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5363627 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{less,css,yml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8960d84 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: + - '**' + tags-ignore: + - '*.*' + pull_request: + +jobs: + test: + name: "PHPUnit" + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['7.3', '7.4'] + steps: + - name: Check out code into the workspace + uses: actions/checkout@v2 + - name: Setup PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: pcov + - name: Composer cache + uses: actions/cache@v2 + with: + path: ${{ env.HOME }}/.composer/cache + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + - name: Install dependencies + run: composer install -o + - name: Configure matchers + uses: mheap/phpunit-matcher-action@v1 + - name: Run tests + run: composer run-script phpunit-ci + - name: Coverage + run: bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml new file mode 100644 index 0000000..c9f9b2a --- /dev/null +++ b/.github/workflows/code_quality.yml @@ -0,0 +1,42 @@ +name: "Code Quality Check" + +on: + pull_request: + paths: + - "**.php" + - "phpcs.xml" + - ".github/workflows/code_quality.yml" + +jobs: + phpcs: + name: "PHP CodeSniffer" + runs-on: ubuntu-latest + steps: + - name: Check out code into the workspace + uses: actions/checkout@v2 + - name: Run PHPCS + uses: chekalsky/phpcs-action@v1 + phpmd: + name: "PHP MessDetector" + runs-on: ubuntu-latest + steps: + - name: Check out code into the workspace + uses: actions/checkout@v2 + - name: Run PHPMD + uses: GeneaLabs/action-reviewdog-phpmd@1.0.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + level: 'warning' + reporter: github-pr-check + standard: './phpmd.xml' + target_directory: 'src' + phpstan: + name: PHPStan + runs-on: ubuntu-18.04 + steps: + - name: Check out code into the workspace + uses: actions/checkout@v2 + - name: Run PHPStan + uses: docker://oskarstark/phpstan-ga:0.12.88 + with: + args: analyse src -c phpstan.neon --memory-limit=1G --no-progress \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdbce05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Composer files. +/vendor +composer.lock +composer.phar + +# Code Quality tools artifacts. +coverage.xml +test-report.xml +phpunit.xml +.php_cs.cache +.phpunit.result.cache + +# Different environment-related files. +.idea +.DS_Store +.settings +.buildpath +.project +.swp +/nbproject +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ecbb4f7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2021 RetailDriver LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 11776b3..4b5fe05 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# url-validator +Validator for RetailCRM projects on Symfony \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1a9cb35 --- /dev/null +++ b/composer.json @@ -0,0 +1,53 @@ +{ + "name": "retailcrm/validator", + "description": "Validator for symfony projects", + "type": "library", + "keywords": ["Validator", "RetailCRM"], + "homepage": "https://www.retailcrm.ru/", + "license": "MIT", + "authors": [ + { + "name": "RetailCRM", + "email": "support@retailcrm.ru" + } + ], + "require": { + "php": ">=7.3", + "ext-json": "*", + "symfony/validator": "^5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.7", + "squizlabs/php_codesniffer": "3.*", + "phpstan/phpstan": "^0.12.92", + "phpmd/phpmd": "^2.10" + }, + "support": { + "email": "support@retailcrm.ru" + }, + "autoload": { + "psr-4": { "RetailCrm\\": "src/" } + }, + "extra": { + "branch-alias": { + "dev-master": "0.1-dev" + } + }, + "config": { + "bin-dir": "vendor/bin", + "process-timeout": 600 + }, + "scripts": { + "phpunit": "./vendor/bin/phpunit -c phpunit.xml.dist --coverage-text", + "phpunit-ci": "@php -dpcov.enabled=1 -dpcov.directory=. -dpcov.exclude=\"~vendor~\" ./vendor/bin/phpunit --teamcity -c phpunit.xml.dist", + "phpmd": "./vendor/bin/phpmd src text ./phpmd.xml", + "phpcs": "./vendor/bin/phpcs -p src --runtime-set testVersion 7.3-8.0 && ./vendor/bin/phpcs -p tests --runtime-set testVersion 7.3-8.0 --warning-severity=0", + "phpstan": "./vendor/bin/phpstan analyse -c phpstan.neon src --memory-limit=-1", + "verify": [ + "@phpcs", + "@phpmd", + "@phpstan", + "@phpunit" + ] + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..2c3bdd9 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + + + + + + + + src/ + tests/ + \ No newline at end of file diff --git a/phpmd.xml b/phpmd.xml new file mode 100644 index 0000000..3cd0206 --- /dev/null +++ b/phpmd.xml @@ -0,0 +1,47 @@ + + + Ruleset + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tests/* + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..7f4adad --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,52 @@ +parameters: + ignoreErrors: + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$authFail\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$domainFail\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$fragmentFail\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$getFileError\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$noValidUrl\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$noValidUrlHost\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$pathFail\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$portFail\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$queryFail\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + + - + message: "#^Access to an undefined property Symfony\\\\Component\\\\Validator\\\\Constraint\\:\\:\\$schemeFail\\.$#" + count: 1 + path: src/Validator/CrmUrlValidator.php + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..1475fdd --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 5 + paths: + - src + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..97a7981 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,36 @@ + + + + + + src + dev + + + + + + + + tests + + + + + + diff --git a/src/Validator/CrmUrl.php b/src/Validator/CrmUrl.php new file mode 100644 index 0000000..647f88a --- /dev/null +++ b/src/Validator/CrmUrl.php @@ -0,0 +1,70 @@ +constraint = $constraint; + $filteredUrl = filter_var($value, FILTER_VALIDATE_URL); + + if (false === $filteredUrl) { + $this->context->buildViolation($constraint->noValidUrl)->addViolation(); + + return; + } + + $urlArray = parse_url($filteredUrl); + + if ($this->checkUrlFormat($urlArray)) { + $mainDomain = $this->getMainDomain($urlArray['host']); + $existInCrm = $this->checkDomains(self::CRM_DOMAINS_URL, $mainDomain); + $existInBox = $this->checkDomains(self::BOX_DOMAINS_URL, $urlArray['host']); + + if (false === $existInCrm && false === $existInBox) { + $this->context->buildViolation($constraint->domainFail)->addViolation(); + } + } + } + + /** + * @param array $crmUrl + * + * @return bool + */ + private function checkUrlFormat(array $crmUrl): bool + { + $checkResult = true; + $checkAuth = $this->checkAuth($crmUrl); + $checkFragment = $this->checkFragment($crmUrl); + $checkHost = $this->checkHost($crmUrl); + $checkPath = $this->checkPath($crmUrl); + $checkPort = $this->checkPort($crmUrl); + $checkQuery = $this->checkQuery($crmUrl); + $checkScheme = $this->checkScheme($crmUrl); + + if ( + !$checkAuth + || !$checkFragment + || !$checkHost + || !$checkPath + || !$checkPort + || !$checkQuery + || !$checkScheme + ) { + $checkResult = false; + } + + return $checkResult; + } + + /** + * @param array $crmUrl + * + * @return bool + */ + private function checkHost(array $crmUrl): bool + { + if (!isset($crmUrl['host'])) { + $this->context->buildViolation($this->constraint->noValidUrlHost)->addViolation(); + + return false; + } + + return true; + } + + /** + * @param array $crmUrl + * + * @return bool + */ + private function checkQuery(array $crmUrl): bool + { + if (isset($crmUrl['query']) && !empty($crmUrl['query'])) { + $this->context->buildViolation($this->constraint->queryFail)->addViolation(); + + return false; + } + + return true; + } + + /** + * @param array $crmUrl + * + * @return bool + */ + private function checkAuth(array $crmUrl): bool + { + if ( + (isset($crmUrl['pass']) && !empty($crmUrl['pass'])) + || (isset($crmUrl['user']) && !empty($crmUrl['user'])) + ) { + $this->context->buildViolation($this->constraint->authFail)->addViolation(); + + return false; + } + + return true; + } + + /** + * @param array $crmUrl + * + * @return bool + */ + private function checkFragment(array $crmUrl): bool + { + if (isset($crmUrl['fragment']) && !empty($crmUrl['fragment'])) { + $this->context->buildViolation($this->constraint->fragmentFail)->addViolation(); + + return false; + } + + return true; + } + + /** + * @param array $crmUrl + * + * @return bool + */ + private function checkScheme(array $crmUrl): bool + { + if (isset($crmUrl['scheme']) && $crmUrl['scheme'] !== 'https') { + $this->context->buildViolation($this->constraint->schemeFail)->addViolation(); + + return false; + } + + return true; + } + + /** + * @param array $crmUrl + * + * @return bool + */ + private function checkPath(array $crmUrl): bool + { + if (isset($crmUrl['path']) && $crmUrl['path'] !== '/' && $crmUrl['path'] !== '') { + $this->context->buildViolation($this->constraint->pathFail)->addViolation(); + + return false; + } + + return true; + } + + /** + * @param array $crmUrl + * + * @return bool + */ + private function checkPort(array $crmUrl): bool + { + if (isset($crmUrl['port']) && !empty($crmUrl['port'])) { + $this->context->buildViolation($this->constraint->portFail)->addViolation(); + + return false; + } + + return true; + } + + /** + * @param string $domainUrl + * + * @return array + */ + private function getValidDomains(string $domainUrl): array + { + try { + $content = json_decode(file_get_contents($domainUrl), true, 512, JSON_THROW_ON_ERROR); + + return array_column($content['domains'], 'domain'); + } catch (JsonException $exception) { + $this->context->buildViolation($this->constraint->getFileError)->addViolation(); + + return []; + } + } + + /** + * @param string $host + * + * @return string + */ + private function getMainDomain(string $host): string + { + $hostArray = explode('.', $host); + unset($hostArray[0]); + + return implode('.', $hostArray); + } + + /** + * @param string $crmDomainsUrl + * @param string $domainHost + * + * @return bool + */ + private function checkDomains(string $crmDomainsUrl, string $domainHost): bool + { + return in_array($domainHost, $this->getValidDomains($crmDomainsUrl), true); + } +} diff --git a/tests/Validator/CrmUrlTest.php b/tests/Validator/CrmUrlTest.php new file mode 100644 index 0000000..32b2b26 --- /dev/null +++ b/tests/Validator/CrmUrlTest.php @@ -0,0 +1,20 @@ +getTargets()); + } +} diff --git a/tests/Validator/CrmUrlValidatorTest.php b/tests/Validator/CrmUrlValidatorTest.php new file mode 100644 index 0000000..68e25d7 --- /dev/null +++ b/tests/Validator/CrmUrlValidatorTest.php @@ -0,0 +1,138 @@ +setConstraint(new CrmUrl()); + $validator->initialize($context); + $validator->validate($validCrm, new CrmUrl()); + + self::assertEmpty($context->getViolations()); + } + } + + public function testValidateFailed(): void + { + $failedUrls = [ + [ + 'url' => 'http://asd.retailcrm.ru', + 'errors' => ['Incorrect protocol. Only https is allowed.'], + ], + [ + 'url' => 'https://test.retailcrm.pro:8080', + 'errors' => ['The port does not need to be specified.'], + ], + [ + 'url' => 'https://raisa.retailcrm.ess', + 'errors' => ['An invalid domain is specified.'], + ], + [ + 'url' => 'https://blabla.simlla.com', + 'errors' => ['An invalid domain is specified.'], + ], + [ + 'url' => 'https:/blabla.simlachat.ru', + 'errors' => [ + 'Incorrect URL.', + ], + ], + [ + 'url' => 'htttps://blabla.ecomlogic.com', + 'errors' => ['Incorrect protocol. Only https is allowed.'], + ], + [ + 'url' => 'https://blabla.ecomlogic.com/test', + 'errors' => ['The domain path must be empty.'], + ], + [ + 'url' => 'htttps://blabla.eecomlogic.com/test', + 'errors' => [ + 'The domain path must be empty.', + 'Incorrect protocol. Only https is allowed.', + ], + ], + [ + 'url' => 'https://test:test@blabla.eecomlogic.com/test?test=test#fragment', + 'errors' => [ + 'No need to provide authorization data.', + 'The fragment should be blank.', + 'The domain path must be empty.', + 'The query must be blank.', + ], + ], + ]; + + $translator = new class () implements TranslatorInterface, LocaleAwareInterface { + use TranslatorTrait; + }; + + $metadata = new LazyLoadingMetadataFactory(); + $factory = new ExecutionContextFactory($translator); + $validator = new CrmUrlValidator(); + + foreach ($failedUrls as $failedUrl) { + $context = new ExecutionContext( + new RecursiveValidator($factory, $metadata, new ConstraintValidatorFactory()), + CrmUrl::class, + $translator + ); + $context->setConstraint(new CrmUrl()); + $validator->initialize($context); + $validator->validate($failedUrl['url'], new CrmUrl()); + + foreach ($failedUrl['errors'] as $key => $error) { + self::assertEquals($context->getViolations()->get($key)->getMessage(), $failedUrl['errors'][$key]); + } + + self::assertCount($context->getViolations()->count(), $failedUrl['errors']); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1 @@ +