diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cfbd42c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto + +.github export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.gitsplit.yml export-ignore +.php_cs.dist export-ignore +.sonarcloud.properties export-ignore +CHANGELOG.md export-ignore +CODE_OF_CONDUCT.md export-ignore +infection.json.dist export-ignore +phpbench.json export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore +README.md export-ignore +SECURITY.md export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..76a0420 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing + +First of all, **thank you** for contributing. + +Bugs or feature requests can be posted online on the GitHub issues section of the project. + +Few rules to ease code reviews and merges: + +- You MUST follow the [PSR-1](http://www.php-fig.org/psr/psr-1/), [PSR-2](http://www.php-fig.org/psr/psr-2/) and [PSR-4](http://www.php-fig.org/psr/psr-4/) coding standards. +- You MUST run the test suite. +- You MUST write (or update) unit tests when bugs are fixed or features are added. +- You SHOULD write documentation. + +We use [Git-Flow](http://jeffkreeftmeijer.com/2010/why-arent-you-using-git-flow/) to automate our git branching workflow. + +To contribute use [Pull Requests](https://help.github.com/articles/using-pull-requests), please, write commit messages that make sense, and rebase your branch before submitting your PR. + +May be asked to squash your commits too. This is used to "clean" your Pull Request before merging it, avoiding commits such as fix tests, fix 2, fix 3, etc. + +Run test suite +------------ + +* install composer: `curl -s http://getcomposer.org/installer | php` +* install dependencies: `php composer.phar install` +* run tests: `vendor/bin/behat` diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..726574c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: Spomky +patreon: FlorentMorselli diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a6be469 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** + +*Please provide a script that can be used to reproduce the bug.* + +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS (+version): [e.g. iOS (Big Sure)] + - Browser (+version) [e.g. Chrome (85)] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS (+version): [e.g. Android (10)] + - Browser (+version) [e.g. Firefox Android (65)] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..066b2d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..3c84124 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,8 @@ +daysUntilStale: 60 +daysUntilClose: 7 +staleLabel: wontfix +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +closeComment: false diff --git a/.github/workflows/codacy-analysis.yml b/.github/workflows/codacy-analysis.yml new file mode 100644 index 0000000..38799ce --- /dev/null +++ b/.github/workflows/codacy-analysis.yml @@ -0,0 +1,46 @@ +# This workflow checks out code, performs a Codacy security scan +# and integrates the results with the +# GitHub Advanced Security code scanning feature. For more information on +# the Codacy security scan action usage and parameters, see +# https://github.com/codacy/codacy-analysis-cli-action. +# For more information on Codacy Analysis CLI in general, see +# https://github.com/codacy/codacy-analysis-cli. + +name: Codacy Security Scan + +on: + push: + branches: [ v1.0 ] + pull_request: + branches: [ v1.0 ] + +jobs: + codacy-security-scan: + name: Codacy Security Scan + runs-on: ubuntu-latest + steps: + # Checkout the repository to the GitHub Actions runner + - name: Checkout code + uses: actions/checkout@v2 + + # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis + - name: Run Codacy Analysis CLI + uses: codacy/codacy-analysis-cli-action@1.1.0 + with: + # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository + # You can also omit the token and run the tools that support default configurations + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + verbose: true + output: results.sarif + format: sarif + # Adjust severity of non-security issues + gh-code-scanning-compat: true + # Force 0 exit code to allow SARIF file generation + # This will handover control about PR rejection to the GitHub side + max-allowed-issues: 2147483647 + + # Upload the SARIF file generated in the previous step + - name: Upload SARIF results file + uses: github/codeql-action/upload-sarif@v1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml new file mode 100644 index 0000000..5e64fd6 --- /dev/null +++ b/.github/workflows/coding-standards.yml @@ -0,0 +1,32 @@ +name: Coding Standards + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest] + php-versions: ['7.4'] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring, openssl, sqlite3 + coverage: xdebug + + - name: Install Composer dependencies + run: | + composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: PHP-CS-FIXER + run: composer test:syntax diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml new file mode 100644 index 0000000..53018fc --- /dev/null +++ b/.github/workflows/functional-tests.yml @@ -0,0 +1,34 @@ +name: Functional Tests + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ ubuntu-latest ] + php-versions: [ '7.4', '8.0' ] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring, openssl, sqlite3 + coverage: xdebug + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Install dependencies + run: | + composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: Run tests + run: composer test:functional diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml new file mode 100644 index 0000000..9de1414 --- /dev/null +++ b/.github/workflows/mutation-tests.yml @@ -0,0 +1,35 @@ +name: Mutation Testing + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest] + php-versions: ['7.4'] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring, openssl, sqlite3 + coverage: xdebug + + - name: Install Composer dependencies + run: | + composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: Fetch Git base reference + run: git fetch --depth=1 origin $GITHUB_BASE_REF + + - name: Infection + run: composer test:mutations diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 0000000..b4cba22 --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,31 @@ +name: Benchmark + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ ubuntu-latest ] + php-versions: [ '8.0' ] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring, openssl, sqlite3 + coverage: xdebug + + - name: Install Composer dependencies + run: composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: PHPBench + run: vendor/bin/phpbench run -l dots --report aggregate diff --git a/.github/workflows/static-analyze.yml b/.github/workflows/static-analyze.yml new file mode 100644 index 0000000..c30e88c --- /dev/null +++ b/.github/workflows/static-analyze.yml @@ -0,0 +1,32 @@ +name: Static Analyze + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest] + php-versions: ['7.4'] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring, openssl, sqlite3 + coverage: xdebug + + - name: Install Composer dependencies + run: | + composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: PHPStan + run: composer test:typing diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..742b510 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,32 @@ +name: Unit Tests + +on: [push] + +jobs: + tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ ubuntu-latest ] + php-versions: [ '7.4', '8.0' ] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, mbstring, openssl, sqlite3 + coverage: xdebug + + - name: Install Composer dependencies + run: | + composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader + + - name: Run tests + run: composer test:unit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bef3b12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.*.cache +report.md +report.html +composer.lock +infection.log \ No newline at end of file diff --git a/.gitsplit.yml b/.gitsplit.yml new file mode 100644 index 0000000..58185c7 --- /dev/null +++ b/.gitsplit.yml @@ -0,0 +1,10 @@ +splits: + - prefix: "src/library" + target: "https://${GH_TOKEN}@github.com/spomky-labs/web-push-lib.git" + - prefix: "src/bundle" + target: "https://${GH_TOKEN}@github.com/spomky-labs/web-push-bundle.git" + +origins: + - ^master$ + - ^v\d+\.\d+$ + - ^v\d+\.\d+\.\d+.*$ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..476c68a --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,68 @@ +in(__DIR__.'/tests') + ->in(__DIR__.'/src') +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR1' => true, + '@PSR2' => true, + '@PhpCsFixer' => true, + '@Symfony' => true, + '@DoctrineAnnotation' => true, + '@PHP70Migration' => true, + '@PHP71Migration' => true, + 'strict_param' => true, + 'strict_comparison' => true, + 'array_syntax' => ['syntax' => 'short'], + 'array_indentation' => true, + 'ordered_imports' => true, + 'protected_to_private' => true, + 'declare_strict_types' => true, + 'native_function_invocation' => [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + 'strict' => true, + ], + 'mb_str_functions' => true, + 'linebreak_after_opening_tag' => true, + 'combine_consecutive_issets' => true, + 'combine_consecutive_unsets' => true, + 'compact_nullable_typehint' => true, + 'no_superfluous_elseif' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_order' => true, + 'pow_to_exponentiation' => true, + 'simplified_null_return' => true, + 'header_comment' => [ + 'header' => $header, + ], + 'align_multiline_comment' => [ + 'comment_type' => 'all_multiline', + ], + 'php_unit_test_annotation' => [ + 'style' => 'annotation', + ], + 'php_unit_test_case_static_method_calls' => true, + 'method_chaining_indentation' => true, + 'php_unit_expectation' => true, + 'php_unit_test_class_requires_covers' => false, + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => true, + 'import_functions' => true, + ], + ]) + ->setRiskyAllowed(true) + ->setUsingCache(true) + ->setFinder($finder) + ; diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..5a03d5e --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,15 @@ +# Path to sources +sonar.sources=src +#sonar.exclusions= +#sonar.inclusions= + +# Path to tests +#sonar.tests= +#sonar.test.exclusions= +#sonar.test.inclusions= + +# Source encoding +sonar.sourceEncoding=UTF-8 + +# Exclusions for copy-paste detection +#sonar.cpd.exclusions= diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a02436a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +CHANGELOG +========= + +*Only major and minor versions are listed.* + +Version 1.0 +----------- + +First release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..39e908e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@spomky-labs.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at [this page][version] + +[homepage]: https://www.contributor-covenant.org +[version]: https://www.contributor-covenant.org/version/2/0/code_of_conduct/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2eedd70 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020-2021 Spomky-Labs + +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 new file mode 100644 index 0000000..fd5d020 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +Web-Push Framework +================== + +This framework contains PHP libraries and Symfony bundle to allow developers to integrate web-push notifications into their web applications. + +# Status + +![Build Status](https://github.com/Spomky-Labs/web-push/workflows/Unit%20Tests/badge.svg) +![Build Status](https://github.com/Spomky-Labs/web-push/workflows/Functional%20Tests/badge.svg) +![Build Status](https://github.com/Spomky-Labs/web-push/workflows/Mutation%20Testing/badge.svg) + +![Coding Standards](https://github.com/Spomky-Labs/web-push/workflows/Coding%20Standards/badge.svg) +![Static Analyze](https://github.com/Spomky-Labs/web-push/workflows/Static%20Analyze/badge.svg) + +[![Latest Stable Version](https://poser.pugx.org/Spomky-Labs/web-push/v)](//packagist.org/packages/Spomky-Labs/web-push) +[![Total Downloads](https://poser.pugx.org/Spomky-Labs/web-push/downloads)](//packagist.org/packages/Spomky-Labs/web-push) +[![Latest Unstable Version](https://poser.pugx.org/Spomky-Labs/web-push/v/unstable)](//packagist.org/packages/Spomky-Labs/web-push) +[![License](https://poser.pugx.org/Spomky-Labs/web-push/license)](//packagist.org/packages/Spomky-Labs/web-push) + +# Documentation + +The documentation can be read on the following website: https://web-push.spomky-labs.com/ + +# Support + +I bring solutions to your problems and answer your questions. + +If you really love that project, and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more! + +[Become a sponsor](https://github.com/sponsors/Spomky) + +Or + +[![Become a Patreon](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/FlorentMorselli) + +# Contributing + +Requests for new features, bug fixed and all other ideas to make this framework useful are welcome. +If you feel comfortable writing code, you could try to fix [opened issues where help is wanted](https://github.com/Spomky-Labs/web-push?q=label%3A%22help+wanted%22) or [those that are easy to fix](https://github.com/Spomky-Labs/web-push/easy-pick). + +Do not forget to [follow these best practices](.github/CONTRIBUTING.md). + +**If you think you have found a security issue, DO NOT open an issue**. [You MUST submit your issue here](https://gitter.im/Spomky/). + +# Licence + +This software is release under [MIT licence](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b95d386 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | +| < 1.0.x | :x: *unstable releases* | + +## Reporting a Vulnerability + +If you think you have found a security issue, DO NOT open an issue. +You **MUST** submit your issue at https://gitter.im/Spomky/. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7ebce2a --- /dev/null +++ b/composer.json @@ -0,0 +1,101 @@ +{ + "name": "spomky-labs/web-push", + "type": "bundle", + "description": "Web-Push framework for PHP", + "keywords": ["push", "notifications", "web", "WebPush", "Push API", "symfony", "bundle"], + "homepage": "https://github.com/spomky-labs/web-push", + "license": "MIT", + "authors": [ + { + "name": "Spomky-Labs", + "homepage": "https://github.com/spomky-labs" + } + ], + "scripts": { + "test:all": [ + "composer test:unit", + "composer test:functional", + "composer test:typing", + "composer test:syntax", + "composer test:benchmark", + "composer test:mutations" + ], + "test:unit": "./vendor/bin/phpunit --color --group Unit", + "test:functional": "./vendor/bin/phpunit --color --group Functional", + "test:typing": "./vendor/bin/phpstan analyse", + "test:syntax": "./vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --using-cache=no", + "test:benchmark": "./vendor/bin/phpbench run -l dots --report aggregate", + "test:mutations": "./vendor/bin/infection --logger-github --git-diff-filter=AM -s --threads=$(nproc) --min-msi=87 --min-covered-msi=91" + }, + "require": { + "php": ">=7.4", + "ext-json": "*", + "beberlei/assert": "^3.2", + "psr/cache": "^1.0", + "psr/http-client": "^1.0.1", + "psr/http-factory": "^1.0.1", + "psr/http-message": "^1.0.1", + "psr/log": "^1.1", + "symfony/config": "^5.2.1", + "symfony/dependency-injection": "^5.2.1", + "symfony/framework-bundle": "^5.2.1", + "thecodingmachine/safe": "^1.3" + }, + "require-dev": { + "doctrine/dbal": "^2.9|^3.0", + "doctrine/doctrine-bundle": "^2.0", + "doctrine/doctrine-fixtures-bundle": "^3.4", + "doctrine/orm": "^2.6", + "friendsofphp/php-cs-fixer": "^3.0", + "infection/infection": "^0.22", + "lcobucci/jwt": "^4.0", + "matthiasnoback/symfony-config-test": "^4.2", + "matthiasnoback/symfony-dependency-injection-test": "^4.2", + "nyholm/psr7": "^1.3", + "php-http/client-common": "^2.0", + "php-http/discovery": "^1.13", + "php-http/mock-client": "^1.4", + "phpbench/phpbench": "^1.0.0-alpha2", + "phpstan/phpstan": "^0.12.46", + "phpstan/phpstan-beberlei-assert": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/phpunit": "^9.3", + "rector/rector": "^0.10", + "roave/security-advisories": "dev-latest", + "symfony/cache": "^5.2.1", + "symfony/http-client": "^5.2.1", + "symfony/monolog-bundle": "^3.5", + "symfony/phpunit-bridge": "^5.2.1", + "symfony/var-dumper": "^5.2.1", + "symfony/yaml": "^4.4|^5.0", + "thecodingmachine/phpstan-safe-rule": "^1.0", + "web-token/jwt-signature-algorithm-ecdsa": "^2.0" + }, + "autoload": { + "psr-4" : { + "WebPush\\" : "src/library/", + "WebPush\\Bundle\\": "src/bundle/" + } + }, + "autoload-dev": { + "psr-4" : { + "WebPush\\Tests\\" : "tests/" + } + }, + "config": { + "sort-packages": true + }, + "replace": { + "spomky-labs/web-push-lib": "self.version", + "spomky-labs/web-push-bundle": "self.version" + }, + "suggest": { + "ext-mbstring": "Mandatory when using Payload or VAPID extensions", + "ext-openssl": "Mandatory when using Payload or VAPID extensions", + "web-token/jwt-signature-algorithm-ecdsa": "Mandatory if you want to use VAPID using web-token/jwt-framework", + "lcobucci/jwt": "Mandatory if you want to use VAPID using lcobucci/jwt", + "psr/log-implementation": "Recommended to receive logs from the library" + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..9568eaf --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,24 @@ +{ + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": "infection.log" + }, + "mutators": { + "@default": true, + "global-ignoreSourceCodeByRegex": [ + "\\$this->logger.*", + "\\$this->cache->save.*", + "parent::build(\\$container);" + ], + "MBString": { + "settings": { + "mb_substr": false, + "mb_strlen": false + } + } + } +} \ No newline at end of file diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..a572af7 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,36 @@ +{ + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "tests/Benchmark", + "runner.progress": "dots", + "runner.time_unit": "microseconds", + "runner.retry_threshold": 10, + "report.generators": { + "with-params": { + "extends": "aggregate", + "cols": ["subject", "groups", "mean", "params"] + }, + "full": { + "extends": "aggregate", + "cols": ["subject", "groups", "revs", "best", "mean", "mode", "worst", "params"] + }, + "simple": { + "extends": "aggregate", + "cols": ["subject", "groups", "mean"] + } + }, + "report.outputs": { + "all": { + "extends": "html", + "file": "report.html", + "title": "WebPush Performance Test Suite" + }, + "md": { + "extends": "markdown", + "file": "report.md", + "title": "WebPush Performance Test Suite" + } + }, + "core.extensions": [ + "PhpBench\\Extensions\\XDebug\\XDebugExtension" + ] +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..584d241 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,23 @@ +parameters: + level: 8 + paths: + - src + treatPhpDocTypesAsCertain: false + checkMissingIterableValueType: false + ignoreErrors: + - + message: '#Parameter .* of function openssl_pkey_derive expects resource\, string given\.#' + count: 2 + path: src/library/Utils.php + - + message: '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::addDefaultsIfNotSet\(\)\.#' + count: 1 + path: src/bundle/DependencyInjection/Configuration.php +includes: + - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-phpunit/rules.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + - vendor/phpstan/phpstan-beberlei-assert/extension.neon + - vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon + - vendor/phpstan/phpstan/conf/bleedingEdge.neon diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..0bf4eac --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + ./src + + + + + ./tests/ + + + + + + + + + + + + + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..87c2edd --- /dev/null +++ b/rector.php @@ -0,0 +1,23 @@ +parameters(); + $parameters->set(Option::PATHS, [__DIR__ . '/src', __DIR__ . '/tests']); + $parameters->set(Option::SETS, [SetList::DEAD_CODE]); + $parameters->set(Option::PHP_VERSION_FEATURES, PhpVersion::PHP_74); + $parameters->set(Option::AUTO_IMPORT_NAMES, true); + $parameters->set(Option::IMPORT_SHORT_CLASSES, false); + $parameters->set(Option::IMPORT_DOC_BLOCKS, false); + $parameters->set(Option::ENABLE_CACHE, true); + + $services = $containerConfigurator->services(); + $services->set(TypedPropertyRector::class); +}; diff --git a/src/bundle/.gitattributes b/src/bundle/.gitattributes new file mode 100644 index 0000000..3ba84e4 --- /dev/null +++ b/src/bundle/.gitattributes @@ -0,0 +1,5 @@ +* text=auto + +/.github export-ignore +/.gitattributes export-ignore +/README.md export-ignore diff --git a/src/bundle/.github/CONTRIBUTING.md b/src/bundle/.github/CONTRIBUTING.md new file mode 100644 index 0000000..6ce5b9e --- /dev/null +++ b/src/bundle/.github/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing + +This repository is a sub repository of [the WebPush Framework](https://github.com/spomky-labs/web-push) project and is READ ONLY. +Please do not submit any Pull Requests here. It will be automatically closed. diff --git a/src/bundle/.github/FUNDING.yml b/src/bundle/.github/FUNDING.yml new file mode 100644 index 0000000..726574c --- /dev/null +++ b/src/bundle/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: Spomky +patreon: FlorentMorselli diff --git a/src/bundle/.github/PULL_REQUEST_TEMPLATE.md b/src/bundle/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..86b49d5 --- /dev/null +++ b/src/bundle/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +Please do not submit any Pull Requests here. It will be automatically closed. + +You should submit it here: https://github.com/spomky-labs/web-push/pulls diff --git a/src/bundle/.github/stale.yml b/src/bundle/.github/stale.yml new file mode 100644 index 0000000..34ee8d9 --- /dev/null +++ b/src/bundle/.github/stale.yml @@ -0,0 +1,8 @@ +daysUntilStale: 1 +daysUntilClose: 1 +staleLabel: wontfix +markComment: > + This issue has been automatically marked as stale because this repository + is a read-only repository and nobody will take care of it. Please submit it to the main repository. + Thank you for your contributions. +closeComment: false diff --git a/src/bundle/DependencyInjection/Compiler/ExtensionCompilerPass.php b/src/bundle/DependencyInjection/Compiler/ExtensionCompilerPass.php new file mode 100644 index 0000000..3621a3c --- /dev/null +++ b/src/bundle/DependencyInjection/Compiler/ExtensionCompilerPass.php @@ -0,0 +1,40 @@ +hasDefinition(ExtensionManager::class)) { + return; + } + + $definition = $container->getDefinition(ExtensionManager::class); + $taggedServices = $container->findTaggedServiceIds(self::TAG); + foreach ($taggedServices as $id => $attributes) { + $definition->addMethodCall('add', [new Reference($id)]); + } + } +} diff --git a/src/bundle/DependencyInjection/Compiler/LoggerSetterCompilerPass.php b/src/bundle/DependencyInjection/Compiler/LoggerSetterCompilerPass.php new file mode 100644 index 0000000..5e67a4a --- /dev/null +++ b/src/bundle/DependencyInjection/Compiler/LoggerSetterCompilerPass.php @@ -0,0 +1,40 @@ +hasAlias(self::SERVICE)) { + return; + } + + $taggedServices = $container->findTaggedServiceIds(self::TAG); + foreach ($taggedServices as $id => $attributes) { + $definition = $container->getDefinition($id); + $definition->addMethodCall('setLogger', [new Reference(self::SERVICE)]); + } + } +} diff --git a/src/bundle/DependencyInjection/Compiler/PayloadCacheCompilerPass.php b/src/bundle/DependencyInjection/Compiler/PayloadCacheCompilerPass.php new file mode 100644 index 0000000..61d515b --- /dev/null +++ b/src/bundle/DependencyInjection/Compiler/PayloadCacheCompilerPass.php @@ -0,0 +1,46 @@ +processForService($container, AES128GCM::class, 'webpush.payload.aes128gcm.cache', 'webpush.payload.aes128gcm.cache_lifetime'); + $this->processForService($container, AESGCM::class, 'webpush.payload.aesgcm.cache', 'webpush.payload.aesgcm.cache_lifetime'); + } + + private function processForService(ContainerBuilder $container, string $class, string $cache, string $parameter): void + { + if (!$container->hasDefinition($class) || !$container->hasAlias($cache)) { + return; + } + + $cacheLifetime = $container->getParameter($parameter); + $definition = $container->getDefinition($class); + $definition->addMethodCall('setCache', [ + new Reference($cache), + $cacheLifetime, + ]); + } +} diff --git a/src/bundle/DependencyInjection/Compiler/PayloadContentEncodingCompilerPass.php b/src/bundle/DependencyInjection/Compiler/PayloadContentEncodingCompilerPass.php new file mode 100644 index 0000000..ea94409 --- /dev/null +++ b/src/bundle/DependencyInjection/Compiler/PayloadContentEncodingCompilerPass.php @@ -0,0 +1,40 @@ +hasDefinition(PayloadExtension::class)) { + return; + } + + $definition = $container->getDefinition(PayloadExtension::class); + $taggedServices = $container->findTaggedServiceIds(self::TAG); + foreach ($taggedServices as $id => $attributes) { + $definition->addMethodCall('addContentEncoding', [new Reference($id)]); + } + } +} diff --git a/src/bundle/DependencyInjection/Compiler/PayloadPaddingCompilerPass.php b/src/bundle/DependencyInjection/Compiler/PayloadPaddingCompilerPass.php new file mode 100644 index 0000000..1d69745 --- /dev/null +++ b/src/bundle/DependencyInjection/Compiler/PayloadPaddingCompilerPass.php @@ -0,0 +1,56 @@ +processForService($container, AES128GCM::class, 'webpush.payload.aes128gcm.padding'); + $this->processForService($container, AESGCM::class, 'webpush.payload.aesgcm.padding'); + } + + private function processForService(ContainerBuilder $container, string $class, string $parameter): void + { + if (!$container->hasDefinition($class)) { + return; + } + + $padding = $container->getParameter($parameter); + $definition = $container->getDefinition($class); + switch (true) { + case 'none' === $padding: + $definition->addMethodCall('noPadding'); + break; + case 'recommended' === $padding: + $definition->addMethodCall('recommendedPadding'); + break; + case 'max' === $padding: + $definition->addMethodCall('maxPadding'); + break; + case is_int($padding): + $definition->addMethodCall('customPadding', [$padding]); + break; + } + } +} diff --git a/src/bundle/DependencyInjection/Configuration.php b/src/bundle/DependencyInjection/Configuration.php new file mode 100644 index 0000000..44621cf --- /dev/null +++ b/src/bundle/DependencyInjection/Configuration.php @@ -0,0 +1,188 @@ +alias = $alias; + } + + /** + * {@inheritdoc} + */ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder($this->alias); + $treeBuilder->getRootNode() + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('doctrine_mapping') + ->defaultFalse() + ->info('If true, the doctrine schemas will be loaded') + ->end() + ->scalarNode('logger') + ->defaultNull() + ->info('A PSR3 logger to receive logs') + ->end() + ->scalarNode('http_client') + ->defaultValue(ClientInterface::class) + ->info('PSR18 client to send notification to Web Push Services') + ->end() + ->scalarNode('request_factory') + ->defaultValue(RequestFactoryInterface::class) + ->info('PSR17 Request Factory to create requests') + ->end() + ->arrayNode('vapid') + ->canBeEnabled() + ->validate() + ->ifTrue(static function (array $conf): bool { + $wt = $conf['web_token']['enabled'] ? 1 : 0; + $lc = $conf['lcobucci']['enabled'] ? 1 : 0; + $cu = $conf['custom']['enabled'] ? 1 : 0; + + return 1 !== $wt + $lc + $cu; + }) + ->thenInvalid('One, and only one, JWS Provider shall be set') + ->end() + ->children() + ->scalarNode('subject') + ->isRequired() + ->info('The URL of the service or an email address') + ->end() + ->scalarNode('token_lifetime') + ->defaultValue('now +1hour') + ->info('A PSR6 cache pool to enable caching feature') + ->end() + ->arrayNode('web_token') + ->canBeEnabled() + ->children() + ->scalarNode('private_key') + ->isRequired() + ->info('The VAPID private key') + ->end() + ->scalarNode('public_key') + ->isRequired() + ->info('The VAPID public key') + ->end() + ->end() + ->end() + ->arrayNode('lcobucci') + ->canBeEnabled() + ->children() + ->scalarNode('private_key') + ->isRequired() + ->info('The VAPID private key') + ->end() + ->scalarNode('public_key') + ->isRequired() + ->info('The VAPID public key') + ->end() + ->end() + ->end() + ->arrayNode('custom') + ->canBeEnabled() + ->children() + ->scalarNode('id') + ->isRequired() + ->info('The custom JWS Provider service ID') + ->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('payload') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('aes128gcm') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('padding') + ->defaultValue('recommended') + ->info('Length of the padding: none, recommended, max or and integer') + ->validate() + ->ifTrue(static function ($conf): bool { + if (in_array($conf, ['none', 'max', 'recommended'], true)) { + return false; + } + if (!is_int($conf)) { + return true; + } + + return $conf < 0 || $conf > AES128GCM::PADDING_MAX; + }) + ->thenInvalid(sprintf('The padding must have one of the following value: none, recommended, max or an integer between 0 and %d', AES128GCM::PADDING_MAX)) + ->end() + ->end() + ->scalarNode('cache') + ->defaultNull() + ->info('A PSR6 cache pool to enable caching feature') + ->end() + ->scalarNode('cache_lifetime') + ->defaultValue('now + 30min') + ->info('A PSR6 cache pool to enable caching feature') + ->end() + ->end() + ->end() + ->arrayNode('aesgcm') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('padding') + ->defaultValue('recommended') + ->info('Length of the padding: none, recommended, max or and integer') + ->validate() + ->ifTrue(static function ($conf): bool { + if (in_array($conf, ['none', 'max', 'recommended'], true)) { + return false; + } + if (!is_int($conf)) { + return true; + } + + return $conf < 0 || $conf > AESGCM::PADDING_MAX; + }) + ->thenInvalid(sprintf('The padding must have one of the following value: none, recommended, max or an integer between 0 and %d', AESGCM::PADDING_MAX)) + ->end() + ->end() + ->scalarNode('cache') + ->defaultNull() + ->info('A PSR6 cache pool to enable caching feature') + ->end() + ->scalarNode('cache_lifetime') + ->defaultValue('now + 30min') + ->info('A PSR6 cache pool to enable caching feature') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/bundle/DependencyInjection/WebPushExtension.php b/src/bundle/DependencyInjection/WebPushExtension.php new file mode 100644 index 0000000..0187802 --- /dev/null +++ b/src/bundle/DependencyInjection/WebPushExtension.php @@ -0,0 +1,161 @@ +alias = $alias; + } + + public function getAlias(): string + { + return $this->alias; + } + + public function load(array $configs, ContainerBuilder $container): void + { + $processor = new Processor(); + $config = $processor->processConfiguration($this->getConfiguration($configs, $container), $configs); + + $container->registerForAutoconfiguration(\WebPush\Extension::class)->addTag(ExtensionCompilerPass::TAG); + $container->registerForAutoconfiguration(Loggable::class)->addTag(LoggerSetterCompilerPass::TAG); + $container->registerForAutoconfiguration(ContentEncoding::class)->addTag(PayloadContentEncodingCompilerPass::TAG); + + $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config/')); + $loader->load('services.php'); + + if ($config['doctrine_mapping']) { + $container->setParameter('webpush.doctrine_mapping', $config['doctrine_mapping']); + } + $container->setAlias('webpush.http_client', $config['http_client']); + $container->setAlias('webpush.request_factory', $config['request_factory']); + if (null !== $config['logger']) { + $container->setAlias(LoggerSetterCompilerPass::SERVICE, $config['logger']); + } + + $this->configureVapidSection($container, $loader, $config['vapid']); + $this->configurePayloadSection($container, $config['payload']); + } + + public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface + { + return new Configuration($this->alias); + } + + /** + * {@inheritdoc} + */ + public function prepend(ContainerBuilder $container): void + { + $config = $this->getDoctrineBundleConfiguration($container); + if (null === $config) { + return; + } + $config['dbal']['types'] += [ + 'webpush_subscription' => SubscriptionType::class, + ]; + $container->prependExtensionConfig('doctrine', $config); + } + + private function configureVapidSection(ContainerBuilder $container, LoaderInterface $loader, array $config): void + { + if (!$config['enabled']) { + return; + } + + $container->setParameter('webpush.vapid.subject', $config['subject']); + $container->setParameter('webpush.vapid.token_lifetime', $config['token_lifetime']); + $loader->load('vapid.php'); + + switch (true) { + case $config['web_token']['enabled']: + $loader->load('vapid.web_token.php'); + $container->setParameter('webpush.vapid.web_token.private_key', $config['web_token']['private_key']); + $container->setParameter('webpush.vapid.web_token.public_key', $config['web_token']['public_key']); + + break; + case $config['lcobucci']['enabled']: + $loader->load('vapid.lcobucci.php'); + $container->setParameter('webpush.vapid.lcobucci.private_key', $config['lcobucci']['private_key']); + $container->setParameter('webpush.vapid.lcobucci.public_key', $config['lcobucci']['public_key']); + + break; + case $config['custom']['enabled']: + $container->setAlias(JWSProvider::class, $config['custom']['id']); + + break; + } + } + + private function configurePayloadSection(ContainerBuilder $container, array $config): void + { + $container->setParameter('webpush.payload.aesgcm.cache_lifetime', $config['aesgcm']['cache_lifetime']); + $container->setParameter('webpush.payload.aesgcm.padding', $config['aesgcm']['padding']); + if (null !== $config['aesgcm']['cache']) { + $container->setAlias('webpush.payload.aesgcm.cache', $config['aesgcm']['cache']); + } + + $container->setParameter('webpush.payload.aes128gcm.cache_lifetime', $config['aes128gcm']['cache_lifetime']); + $container->setParameter('webpush.payload.aes128gcm.padding', $config['aes128gcm']['padding']); + if (null !== $config['aes128gcm']['cache']) { + $container->setAlias('webpush.payload.aes128gcm.cache', $config['aes128gcm']['cache']); + } + } + + private function getDoctrineBundleConfiguration(ContainerBuilder $container): ?array + { + $bundles = $container->hasParameter('kernel.bundles') ? $container->getParameter('kernel.bundles') : []; + Assertion::isArray($bundles, 'Invalid bundle list'); + if (!array_key_exists('DoctrineBundle', $bundles)) { + return null; + } + $configs = $container->getExtensionConfig('doctrine'); + if (0 === count($configs)) { + return null; + } + + $config = current($configs); + if (!isset($config['dbal'])) { + $config['dbal'] = []; + } + if (!isset($config['dbal']['types'])) { + $config['dbal']['types'] = []; + } + + return $config; + } +} diff --git a/src/bundle/Doctrine/Type/SubscriptionType.php b/src/bundle/Doctrine/Type/SubscriptionType.php new file mode 100644 index 0000000..81a5e1c --- /dev/null +++ b/src/bundle/Doctrine/Type/SubscriptionType.php @@ -0,0 +1,78 @@ +getName(), ['null', Subscription::class]); + } + + return json_encode($value); + } + + /** + * {@inheritdoc} + */ + public function convertToPHPValue($value, AbstractPlatform $platform): ?Subscription + { + if (null === $value || $value instanceof Subscription) { + return $value; + } + try { + return Subscription::createFromString($value); + } catch (Throwable $e) { + throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string'], $e); + } + } + + /** + * {@inheritdoc} + */ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return $platform->getClobTypeDeclarationSQL($column); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'webpush_subscription'; + } + + /** + * {@inheritdoc} + */ + public function requiresSQLCommentHint(AbstractPlatform $platform): bool + { + return true; + } +} diff --git a/src/bundle/LICENSE b/src/bundle/LICENSE new file mode 100644 index 0000000..2eedd70 --- /dev/null +++ b/src/bundle/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020-2021 Spomky-Labs + +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/src/bundle/README.md b/src/bundle/README.md new file mode 100644 index 0000000..4c85a70 --- /dev/null +++ b/src/bundle/README.md @@ -0,0 +1,35 @@ +WebPush Bundle for Symfony +=========================== + +**WebPush Bundle for Symfony** is a **Symfony Bundle** that will help you to send push notifications. + +# Installation + +Install the library with Composer: `composer require spomky-labs/web-push-bundle`. + +# Contribution + +This repository is a sub repository of [the Web-Push Framework](https://github.com/spomky-labs/web-push) project and is **READ ONLY**. + +**Please do not submit any Pull Request here.** +You should go to [the main repository](https://github.com/spomky-labs/web-push) instead. + +# Documentation + +The official documentation is available at https://web-push.spomky-labs.com + +# Support + +I bring solutions to your problems and answer your questions. + +If you really love that project, and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more! + +[Become a sponsor](https://github.com/sponsors/Spomky) + +Or + +[![Become a Patreon](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/FlorentMorselli) + +# Licence + +This project is release under [MIT licence](LICENSE). diff --git a/src/bundle/Resources/config/doctrine-mapping/Subscription.orm.xml b/src/bundle/Resources/config/doctrine-mapping/Subscription.orm.xml new file mode 100644 index 0000000..9c51980 --- /dev/null +++ b/src/bundle/Resources/config/doctrine-mapping/Subscription.orm.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/bundle/Resources/config/services.php b/src/bundle/Resources/config/services.php new file mode 100644 index 0000000..9d70c09 --- /dev/null +++ b/src/bundle/Resources/config/services.php @@ -0,0 +1,50 @@ +services()->defaults() + ->private() + ->autoconfigure() + ->autowire() + ; + + $container->set(ExtensionManager::class); + $container->set(UrgencyExtension::class); + $container->set(TTLExtension::class); + $container->set(TopicExtension::class); + $container->set(PreferAsyncExtension::class); + $container->set(PayloadExtension::class); + $container->set(AESGCM::class); + $container->set(AES128GCM::class); + + $container->set(WebPush::class) + ->args([ + service('webpush.http_client'), + service('webpush.request_factory'), + service(ExtensionManager::class), + ]) + ->public() + ; +}; diff --git a/src/bundle/Resources/config/vapid.lcobucci.php b/src/bundle/Resources/config/vapid.lcobucci.php new file mode 100644 index 0000000..d1282b9 --- /dev/null +++ b/src/bundle/Resources/config/vapid.lcobucci.php @@ -0,0 +1,32 @@ +services()->defaults() + ->private() + ->autoconfigure() + ->autowire() + ; + + $container->set(JWSProvider::class) + ->class(LcobucciProvider::class) + ->args([ + '%webpush.vapid.lcobucci.public_key%', + '%webpush.vapid.lcobucci.private_key%', + ]) + ; +}; diff --git a/src/bundle/Resources/config/vapid.php b/src/bundle/Resources/config/vapid.php new file mode 100644 index 0000000..aa356b2 --- /dev/null +++ b/src/bundle/Resources/config/vapid.php @@ -0,0 +1,35 @@ +services()->defaults() + ->private() + ->autoconfigure() + ->autowire() + ; + + $container->set(VAPIDExtension::class) + ->args([ + '%webpush.vapid.subject%', + service(JWSProvider::class), + ]) + ->call('setTokenExpirationTime', [ + '%webpush.vapid.token_lifetime%', + ]) + ; +}; diff --git a/src/bundle/Resources/config/vapid.web_token.php b/src/bundle/Resources/config/vapid.web_token.php new file mode 100644 index 0000000..5be9ab7 --- /dev/null +++ b/src/bundle/Resources/config/vapid.web_token.php @@ -0,0 +1,32 @@ +services()->defaults() + ->private() + ->autoconfigure() + ->autowire() + ; + + $container->set(JWSProvider::class) + ->class(WebTokenProvider::class) + ->args([ + '%webpush.vapid.web_token.public_key%', + '%webpush.vapid.web_token.private_key%', + ]) + ; +}; diff --git a/src/bundle/WebPushBundle.php b/src/bundle/WebPushBundle.php new file mode 100644 index 0000000..3b920ee --- /dev/null +++ b/src/bundle/WebPushBundle.php @@ -0,0 +1,62 @@ +addCompilerPass(new ExtensionCompilerPass()); + $container->addCompilerPass(new LoggerSetterCompilerPass()); + $container->addCompilerPass(new PayloadContentEncodingCompilerPass()); + $container->addCompilerPass(new PayloadCacheCompilerPass()); + $container->addCompilerPass(new PayloadPaddingCompilerPass()); + + $this->registerMappings($container); + } + + private function registerMappings(ContainerBuilder $container): void + { + if (!class_exists(DoctrineOrmMappingsPass::class)) { + return; + } + + $realPath = realpath(__DIR__.'/Resources/config/doctrine-mapping'); + $mappings = [$realPath => 'WebPush']; + $container->addCompilerPass(DoctrineOrmMappingsPass::createXmlMappingDriver($mappings, [], 'webpush.doctrine_mapping')); + } +} diff --git a/src/bundle/composer.json b/src/bundle/composer.json new file mode 100644 index 0000000..87e1fa1 --- /dev/null +++ b/src/bundle/composer.json @@ -0,0 +1,29 @@ +{ + "name": "spomky-labs/web-push-bundle", + "type": "symfony-bundle", + "description": "Web-Push bundle for Symfony", + "keywords": ["push", "notifications", "web", "WebPush", "Push API"], + "homepage": "https://github.com/Spomky-Labs/web-push", + "license": "MIT", + "authors": [ + { + "name": "Spomky-Labs", + "homepage": "https://github.com/Spomky-Labs" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/web-push-bundle/contributors" + } + ], + "require": { + "spomky-labs/web-push-lib": "^1.0", + "symfony/config": "^5.2.1", + "symfony/dependency-injection": "^5.2.1", + "symfony/framework-bundle": "^5.2.1" + }, + "autoload": { + "psr-4": { + "WebPush\\Bundle\\": "" + } + } +} diff --git a/src/library/.gitattributes b/src/library/.gitattributes new file mode 100644 index 0000000..3ba84e4 --- /dev/null +++ b/src/library/.gitattributes @@ -0,0 +1,5 @@ +* text=auto + +/.github export-ignore +/.gitattributes export-ignore +/README.md export-ignore diff --git a/src/library/.github/CONTRIBUTING.md b/src/library/.github/CONTRIBUTING.md new file mode 100644 index 0000000..6ce5b9e --- /dev/null +++ b/src/library/.github/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing + +This repository is a sub repository of [the WebPush Framework](https://github.com/spomky-labs/web-push) project and is READ ONLY. +Please do not submit any Pull Requests here. It will be automatically closed. diff --git a/src/library/.github/FUNDING.yml b/src/library/.github/FUNDING.yml new file mode 100644 index 0000000..726574c --- /dev/null +++ b/src/library/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: Spomky +patreon: FlorentMorselli diff --git a/src/library/.github/PULL_REQUEST_TEMPLATE.md b/src/library/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..86b49d5 --- /dev/null +++ b/src/library/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +Please do not submit any Pull Requests here. It will be automatically closed. + +You should submit it here: https://github.com/spomky-labs/web-push/pulls diff --git a/src/library/.github/stale.yml b/src/library/.github/stale.yml new file mode 100644 index 0000000..34ee8d9 --- /dev/null +++ b/src/library/.github/stale.yml @@ -0,0 +1,8 @@ +daysUntilStale: 1 +daysUntilClose: 1 +staleLabel: wontfix +markComment: > + This issue has been automatically marked as stale because this repository + is a read-only repository and nobody will take care of it. Please submit it to the main repository. + Thank you for your contributions. +closeComment: false diff --git a/src/library/Action.php b/src/library/Action.php new file mode 100644 index 0000000..63ee874 --- /dev/null +++ b/src/library/Action.php @@ -0,0 +1,80 @@ +action = $action; + $this->title = $title; + } + + public function toString(): string + { + return json_encode($this, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + public static function create(string $action, string $title): self + { + return new self($action, $title); + } + + public function withIcon(string $icon): self + { + $this->icon = $icon; + + return $this; + } + + public function getAction(): string + { + return $this->action; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getIcon(): ?string + { + return $this->icon; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $r = array_filter(get_object_vars($this), static function ($v): bool { + return null !== $v; + }); + + ksort($r); + + return $r; + } +} diff --git a/src/library/Base64Url.php b/src/library/Base64Url.php new file mode 100644 index 0000000..984ad8d --- /dev/null +++ b/src/library/Base64Url.php @@ -0,0 +1,37 @@ +logger = new NullLogger(); + } + + public static function create(): self + { + return new self(); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function add(Extension $extension): self + { + $this->extensions[] = $extension; + $this->logger->debug('Extension added', ['extension' => $extension]); + + return $this; + } + + public function process(RequestInterface $request, Notification $notification, Subscription $subscription): RequestInterface + { + $this->logger->debug('Processing the request'); + foreach ($this->extensions as $extension) { + $request = $extension->process($request, $notification, $subscription); + } + $this->logger->debug('Processing done'); + + return $request; + } +} diff --git a/src/library/LICENSE b/src/library/LICENSE new file mode 100644 index 0000000..2eedd70 --- /dev/null +++ b/src/library/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020-2021 Spomky-Labs + +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/src/library/Loggable.php b/src/library/Loggable.php new file mode 100644 index 0000000..842210d --- /dev/null +++ b/src/library/Loggable.php @@ -0,0 +1,21 @@ +|null + */ + private ?array $vibrate = null; + + public function __construct(string $body) + { + $this->body = $body; + } + + public function toString(): string + { + return json_encode($this, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + public static function create(string $body): self + { + return new self($body); + } + + /** + * @return array + */ + public function getActions(): array + { + return $this->actions; + } + + public function getBody(): string + { + return $this->body; + } + + /** + * @return mixed|null + */ + public function getData() + { + return $this->data; + } + + public function getDir(): ?string + { + return $this->dir; + } + + public function getBadge(): ?string + { + return $this->badge; + } + + public function getIcon(): ?string + { + return $this->icon; + } + + public function getImage(): ?string + { + return $this->image; + } + + public function getLang(): ?string + { + return $this->lang; + } + + public function getRenotify(): ?bool + { + return $this->renotify; + } + + public function isInteractionRequired(): ?bool + { + return $this->requireInteraction; + } + + public function isSilent(): ?bool + { + return $this->silent; + } + + public function getTag(): ?string + { + return $this->tag; + } + + public function getTimestamp(): ?int + { + return $this->timestamp; + } + + /** + * @return array|null + */ + public function getVibrate(): ?array + { + return $this->vibrate; + } + + public function addAction(Action $action): self + { + $this->actions[] = $action; + + return $this; + } + + /** + * @param mixed|null $data + */ + public function withData($data): self + { + $this->data = $data; + + return $this; + } + + public function auto(): self + { + $this->dir = 'auto'; + + return $this; + } + + public function ltr(): self + { + $this->dir = 'ltr'; + + return $this; + } + + public function rtl(): self + { + $this->dir = 'rtl'; + + return $this; + } + + public function withBadge(string $badge): self + { + $this->badge = $badge; + + return $this; + } + + public function withIcon(string $icon): self + { + $this->icon = $icon; + + return $this; + } + + public function withImage(string $image): self + { + $this->image = $image; + + return $this; + } + + public function withLang(string $lang): self + { + $this->lang = $lang; + + return $this; + } + + public function renotify(): self + { + $this->renotify = true; + + return $this; + } + + public function doNotRenotify(): self + { + $this->renotify = false; + + return $this; + } + + public function interactionRequired(): self + { + $this->requireInteraction = true; + + return $this; + } + + public function noInteraction(): self + { + $this->requireInteraction = false; + + return $this; + } + + public function mute(): self + { + $this->silent = true; + + return $this; + } + + public function unmute(): self + { + $this->silent = false; + + return $this; + } + + public function withTag(string $tag): self + { + $this->tag = $tag; + + return $this; + } + + public function withTimestamp(int $timestamp): self + { + $this->timestamp = $timestamp; + + return $this; + } + + public function vibrate(int ...$vibrations): self + { + $this->vibrate = $vibrations; + + return $this; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $r = array_filter(get_object_vars($this), static function ($v): bool { + if (is_array($v) && 0 === count($v)) { + return false; + } + + return null !== $v; + }); + ksort($r); + + return $r; + } +} diff --git a/src/library/Notification.php b/src/library/Notification.php new file mode 100644 index 0000000..a471a0f --- /dev/null +++ b/src/library/Notification.php @@ -0,0 +1,177 @@ + + */ + private array $metadata = []; + + public static function create(): self + { + return new self(); + } + + public function veryLowUrgency(): self + { + $this->urgency = self::URGENCY_VERY_LOW; + + return $this; + } + + public function lowUrgency(): self + { + $this->urgency = self::URGENCY_LOW; + + return $this; + } + + public function normalUrgency(): self + { + $this->urgency = self::URGENCY_NORMAL; + + return $this; + } + + public function highUrgency(): self + { + $this->urgency = self::URGENCY_HIGH; + + return $this; + } + + public function withUrgency(string $urgency): self + { + Assertion::inArray($urgency, [ + self::URGENCY_VERY_LOW, + self::URGENCY_LOW, + self::URGENCY_NORMAL, + self::URGENCY_HIGH, + ], 'Invalid urgency parameter'); + $this->urgency = $urgency; + + return $this; + } + + public function getUrgency(): string + { + return $this->urgency; + } + + public function withPayload(string $payload): self + { + $this->payload = $payload; + + return $this; + } + + public function getPayload(): ?string + { + return $this->payload; + } + + public function withTopic(string $topic): self + { + Assertion::notBlank($topic, 'Invalid topic'); + $this->topic = $topic; + + return $this; + } + + public function getTopic(): ?string + { + return $this->topic; + } + + public function withTTL(int $ttl): self + { + Assertion::greaterOrEqualThan($ttl, 0, 'Invalid TTL'); + $this->ttl = $ttl; + + return $this; + } + + public function getTTL(): int + { + return $this->ttl; + } + + public function sync(): self + { + $this->respondAsync = false; + + return $this; + } + + public function async(): self + { + $this->respondAsync = true; + + return $this; + } + + public function isAsync(): bool + { + return $this->respondAsync; + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * @param mixed $data + */ + public function add(string $key, $data): self + { + $this->metadata[$key] = $data; + + return $this; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->metadata); + } + + /** + * @return mixed + */ + public function get(string $key) + { + Assertion::true($this->has($key), 'Missing metadata'); + + return $this->metadata[$key]; + } +} diff --git a/src/library/Payload/AES128GCM.php b/src/library/Payload/AES128GCM.php new file mode 100644 index 0000000..16d4b81 --- /dev/null +++ b/src/library/Payload/AES128GCM.php @@ -0,0 +1,80 @@ +padding = $padding; + + return $this; + } + + public function maxPadding(): self + { + $this->padding = self::PADDING_MAX; + + return $this; + } + + public function name(): string + { + return self::ENCODING; + } + + protected function getKeyInfo(string $userAgentPublicKey, ServerKey $serverKey): string + { + return 'WebPush: info'."\0".$userAgentPublicKey.$serverKey->getPublicKey(); + } + + protected function getContext(string $userAgentPublicKey, ServerKey $serverKey): string + { + return ''; + } + + protected function addPadding(string $payload): string + { + return str_pad($payload."\2", $this->padding, "\0", STR_PAD_RIGHT); + } + + protected function prepareHeaders(RequestInterface $request, ServerKey $serverKey, string $salt): RequestInterface + { + return $request; + } + + protected function prepareBody(string $encryptedText, ServerKey $serverKey, string $tag, string $salt): string + { + return $salt. + pack('N*', 4096). + pack('C*', mb_strlen($serverKey->getPublicKey(), '8bit')). + $serverKey->getPublicKey(). + $encryptedText. + $tag + ; + } +} diff --git a/src/library/Payload/AESGCM.php b/src/library/Payload/AESGCM.php new file mode 100644 index 0000000..d16371f --- /dev/null +++ b/src/library/Payload/AESGCM.php @@ -0,0 +1,87 @@ +padding = $padding; + + return $this; + } + + public function maxPadding(): self + { + $this->padding = self::PADDING_MAX; + + return $this; + } + + public function name(): string + { + return self::ENCODING; + } + + protected function getKeyInfo(string $userAgentPublicKey, ServerKey $serverKey): string + { + return "Content-Encoding: auth\0"; + } + + protected function getContext(string $userAgentPublicKey, ServerKey $serverKey): string + { + return sprintf('%s%s%s%s', + "P-256\0\0A", + $userAgentPublicKey, + "\0A", + $serverKey->getPublicKey() + ); + } + + protected function addPadding(string $payload): string + { + $payloadLength = mb_strlen($payload, '8bit'); + $paddingLength = max(self::PADDING_NONE, $this->padding - $payloadLength); + + return pack('n*', $paddingLength).str_pad($payload, $this->padding, "\0", STR_PAD_LEFT); + } + + protected function prepareHeaders(RequestInterface $request, ServerKey $serverKey, string $salt): RequestInterface + { + return $request + ->withAddedHeader('Crypto-Key', sprintf('dh=%s', Base64Url::encode($serverKey->getPublicKey()))) + ->withAddedHeader('Encryption', 'salt='.Base64Url::encode($salt)) + ; + } + + protected function prepareBody(string $encryptedText, ServerKey $serverKey, string $tag, string $salt): string + { + return $encryptedText.$tag; + } +} diff --git a/src/library/Payload/AbstractAESGCM.php b/src/library/Payload/AbstractAESGCM.php new file mode 100644 index 0000000..0faf808 --- /dev/null +++ b/src/library/Payload/AbstractAESGCM.php @@ -0,0 +1,214 @@ +logger = new NullLogger(); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function setCache(CacheItemPoolInterface $cache, string $cacheExpirationTime = 'now + 30min'): self + { + $this->cache = $cache; + $this->cacheExpirationTime = $cacheExpirationTime; + + return $this; + } + + public function noPadding(): self + { + $this->padding = self::PADDING_NONE; + + return $this; + } + + public function recommendedPadding(): self + { + $this->padding = self::PADDING_RECOMMENDED; + + return $this; + } + + abstract public function maxPadding(): self; + + public function encode(string $payload, RequestInterface $request, Subscription $subscription): RequestInterface + { + $this->logger->debug('Trying to encode the following payload.'); + Assertion::true($subscription->hasKey('p256dh'), 'The user-agent public key is missing'); + $userAgentPublicKey = Base64Url::decode($subscription->getKey('p256dh')); + $this->logger->debug(sprintf('User-agent public key: %s', Base64Url::encode($userAgentPublicKey))); + + Assertion::true($subscription->hasKey('auth'), 'The user-agent authentication token is missing'); + $userAgentAuthToken = Base64Url::decode($subscription->getKey('auth')); + $this->logger->debug(sprintf('User-agent auth token: %s', Base64Url::encode($userAgentAuthToken))); + + $salt = random_bytes(self::SALT_SIZE); + $this->logger->debug(sprintf('Salt: %s', Base64Url::encode($salt))); + + $serverKey = $this->getServerKey(); + + //IKM + $keyInfo = $this->getKeyInfo($userAgentPublicKey, $serverKey); + $ikm = Utils::computeIKM($keyInfo, $userAgentAuthToken, $userAgentPublicKey, $serverKey->getPrivateKey(), $serverKey->getPublicKey()); + $this->logger->debug(sprintf('IKM: %s', Base64Url::encode($ikm))); + + //PRK + $prk = hash_hmac('sha256', $ikm, $salt, true); + $this->logger->debug(sprintf('PRK: %s', Base64Url::encode($prk))); + + // Context + $context = $this->getContext($userAgentPublicKey, $serverKey); + + // Derive the Content Encryption Key + $contentEncryptionKeyInfo = $this->createInfo($this->name(), $context); + $contentEncryptionKey = mb_substr(hash_hmac('sha256', $contentEncryptionKeyInfo."\1", $prk, true), 0, self::CEK_SIZE, '8bit'); + $this->logger->debug(sprintf('CEK: %s', Base64Url::encode($contentEncryptionKey))); + + // Derive the Nonce + $nonceInfo = $this->createInfo('nonce', $context); + $nonce = mb_substr(hash_hmac('sha256', $nonceInfo."\1", $prk, true), 0, self::NONCE_SIZE, '8bit'); + $this->logger->debug(sprintf('NONCE: %s', Base64Url::encode($nonce))); + + // Padding + $paddedPayload = $this->addPadding($payload); + $this->logger->debug('Payload with padding', ['padded_payload' => $paddedPayload]); + + // Encryption + $tag = ''; + $encryptedText = openssl_encrypt($paddedPayload, 'aes-128-gcm', $contentEncryptionKey, OPENSSL_RAW_DATA, $nonce, $tag); + $this->logger->debug(sprintf('Encrypted payload: %s', Base64Url::encode($encryptedText))); + $this->logger->debug(sprintf('Tag: %s', Base64Url::encode($tag))); + + // Body to be sent + $body = $this->prepareBody($encryptedText, $serverKey, $tag, $salt); + $request->getBody()->write($body); + + $bodyLength = mb_strlen($body, '8bit'); + Assertion::max($bodyLength, 4096, 'The size of payload must not be greater than 4096 bytes.'); + + $request = $this->prepareHeaders($request, $serverKey, $salt); + + return $request + ->withAddedHeader('Content-Length', (string) $bodyLength) + ; + } + + abstract protected function getKeyInfo(string $userAgentPublicKey, ServerKey $serverKey): string; + + abstract protected function getContext(string $userAgentPublicKey, ServerKey $serverKey): string; + + abstract protected function addPadding(string $payload): string; + + abstract protected function prepareBody(string $encryptedText, ServerKey $serverKey, string $tag, string $salt): string; + + abstract protected function prepareHeaders(RequestInterface $request, ServerKey $serverKey, string $salt): RequestInterface; + + private function createInfo(string $type, string $context): string + { + $info = 'Content-Encoding: '; + $info .= $type; + $info .= "\0"; + $info .= $context; + + return $info; + } + + private function getServerKey(): ServerKey + { + $this->logger->debug('Getting key from the cache'); + if (null === $this->cache) { + $this->logger->debug('No cache'); + + return $this->generateServerKey(); + } + $item = $this->cache->getItem($this->cacheKey); + if ($item->isHit()) { + $this->logger->debug('The key is available from the cache.'); + + return $item->get(); + } + $this->logger->debug('No key from the cache'); + $serverKey = $this->generateServerKey(); + $item = $item + ->set($serverKey) + ->expiresAt(new DateTimeImmutable($this->cacheExpirationTime)) + ; + $this->cache->save($item); + $this->logger->debug('Key saved'); + + return $serverKey; + } + + private function generateServerKey(): ServerKey + { + $this->logger->debug('Generating new key pair'); + $keyResource = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + + $details = openssl_pkey_get_details($keyResource); + + Assertion::isArray($details, 'Unable to get the key details'); + + $publicKey = "\4"; + $publicKey .= str_pad($details['ec']['x'], self::SIZE, "\0", STR_PAD_LEFT); + $publicKey .= str_pad($details['ec']['y'], self::SIZE, "\0", STR_PAD_LEFT); + $privateKey = str_pad($details['ec']['d'], self::SIZE, "\0", STR_PAD_LEFT); + $key = new ServerKey($publicKey, $privateKey); + + $this->logger->debug('The key has been created.'); + + return $key; + } +} diff --git a/src/library/Payload/ContentEncoding.php b/src/library/Payload/ContentEncoding.php new file mode 100644 index 0000000..9f8482e --- /dev/null +++ b/src/library/Payload/ContentEncoding.php @@ -0,0 +1,24 @@ +logger = new NullLogger(); + } + + public static function create(): self + { + return new self(); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function addContentEncoding(ContentEncoding $contentEncoding): self + { + $this->contentEncodings[$contentEncoding->name()] = $contentEncoding; + + return $this; + } + + public function process(RequestInterface $request, Notification $notification, Subscription $subscription): RequestInterface + { + $this->logger->debug('Processing with payload'); + $payload = $notification->getPayload(); + if (null === $payload || '' === $payload) { + $this->logger->debug('No payload'); + + return $request + ->withAddedHeader('Content-Length', '0') + ; + } + + $encoder = $this->findEncoder($subscription); + $this->logger->debug(sprintf('Encoder found: %s. Processing with the encoder.', $encoder->name())); + + $request = $request + ->withAddedHeader('Content-Type', 'application/octet-stream') + ->withAddedHeader('Content-Encoding', $encoder->name()) + ; + + return $encoder->encode($payload, $request, $subscription); + } + + private function findEncoder(Subscription $subscription): ContentEncoding + { + $supportedContentEncodings = $subscription->getSupportedContentEncodings(); + foreach ($supportedContentEncodings as $supportedContentEncoding) { + if (array_key_exists($supportedContentEncoding, $this->contentEncodings)) { + return $this->contentEncodings[$supportedContentEncoding]; + } + } + throw new InvalidArgumentException(sprintf('No content encoding found. Supported content encodings for the subscription are: %s', implode(', ', $supportedContentEncodings))); + } +} diff --git a/src/library/Payload/ServerKey.php b/src/library/Payload/ServerKey.php new file mode 100644 index 0000000..dc60bbc --- /dev/null +++ b/src/library/Payload/ServerKey.php @@ -0,0 +1,43 @@ +publicKey = $publicKey; + $this->privateKey = $privateKey; + } + + public function getPublicKey(): string + { + return $this->publicKey; + } + + public function getPrivateKey(): string + { + return $this->privateKey; + } +} diff --git a/src/library/PreferAsyncExtension.php b/src/library/PreferAsyncExtension.php new file mode 100644 index 0000000..3991e30 --- /dev/null +++ b/src/library/PreferAsyncExtension.php @@ -0,0 +1,55 @@ +logger = new NullLogger(); + } + + public static function create(): self + { + return new self(); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function process(RequestInterface $request, Notification $notification, Subscription $subscription): RequestInterface + { + if (!$notification->isAsync()) { + $this->logger->debug('Sending synchronous notification'); + + return $request; + } + + $this->logger->debug('Sending asynchronous notification'); + + return $request + ->withAddedHeader('Prefer', 'respond-async') + ; + } +} diff --git a/src/library/README.md b/src/library/README.md new file mode 100644 index 0000000..6656a35 --- /dev/null +++ b/src/library/README.md @@ -0,0 +1,35 @@ +WebPush Support for PHP +======================= + +**WebPush Support for PHP** is a **PHP library** that will help you to send notifications using the Web-Push protocol. + +# Installation + +Install the library with Composer: `composer require spomky-labs/web-push-lib`. + +# Contribution + +This repository is a sub repository of [the Web-Push Framework](https://github.com/spomky-labs/web-push) project and is **READ ONLY**. + +**Please do not submit any Pull Request here.** +You should go to [the main repository](https://github.com/spomky-labs/web-push) instead. + +# Documentation + +The official documentation is available at https://web-push.spomky-labs.com + +# Support + +I bring solutions to your problems and answer your questions. + +If you really love that project, and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more! + +[Become a sponsor](https://github.com/sponsors/Spomky) + +Or + +[![Become a Patreon](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/FlorentMorselli) + +# Licence + +This project is release under [MIT licence](LICENSE). diff --git a/src/library/StatusReport.php b/src/library/StatusReport.php new file mode 100644 index 0000000..9533f61 --- /dev/null +++ b/src/library/StatusReport.php @@ -0,0 +1,80 @@ +subscription = $subscription; + $this->notification = $notification; + $this->request = $request; + $this->response = $response; + } + + public function getSubscription(): Subscription + { + return $this->subscription; + } + + public function getNotification(): Notification + { + return $this->notification; + } + + public function getRequest(): RequestInterface + { + return $this->request; + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + + public function isSuccess(): bool + { + $code = $this->response->getStatusCode(); + + return $code >= 200 && $code < 300; + } + + public function notificationExpired(): bool + { + $code = $this->response->getStatusCode(); + + return 404 === $code || 410 === $code; + } + + public function getLocation(): string + { + return $this->response->getHeaderLine('location'); + } + + /** + * @return string[] + */ + public function getLinks(): array + { + return $this->response->getHeader('link'); + } +} diff --git a/src/library/Subscription.php b/src/library/Subscription.php new file mode 100644 index 0000000..29b554f --- /dev/null +++ b/src/library/Subscription.php @@ -0,0 +1,164 @@ +endpoint = $endpoint; + $this->keys = []; + } + + public static function create(string $endpoint): self + { + return new self($endpoint); + } + + /** + * @param string[] $contentEncodings + */ + public function withContentEncodings(array $contentEncodings): self + { + $this->supportedContentEncodings = $contentEncodings; + + return $this; + } + + public function getKeys(): array + { + return $this->keys; + } + + public function hasKey(string $key): bool + { + return isset($this->keys[$key]); + } + + public function getKey(string $key): string + { + Assertion::keyExists($this->keys, $key, 'The key does not exist'); + + return $this->keys[$key]; + } + + public function setKey(string $key, string $value): self + { + $this->keys[$key] = $value; + + return $this; + } + + public function getExpirationTime(): ?int + { + return $this->expirationTime; + } + + public function setExpirationTime(?int $expirationTime): self + { + $this->expirationTime = $expirationTime; + + return $this; + } + + public function expiresAt(): ?DateTimeInterface + { + return null === $this->expirationTime ? null : (new DateTimeImmutable())->setTimestamp($this->expirationTime); + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + /** + * @return string[] + */ + public function getSupportedContentEncodings(): array + { + return $this->supportedContentEncodings; + } + + public static function createFromString(string $input): self + { + $data = json_decode($input, true); + Assertion::isArray($data, 'Invalid input'); + + return self::createFromAssociativeArray($data); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'endpoint' => $this->endpoint, + 'supportedContentEncodings' => $this->supportedContentEncodings, + 'keys' => $this->keys, + ]; + } + + /** + * @param array $input + */ + private static function createFromAssociativeArray(array $input): self + { + Assertion::keyExists($input, 'endpoint', 'Invalid input'); + Assertion::string($input['endpoint'], 'Invalid input'); + + $object = new self($input['endpoint']); + if (array_key_exists('supportedContentEncodings', $input)) { + $encodings = $input['supportedContentEncodings']; + Assertion::isArray($encodings, 'Invalid input'); + Assertion::allString($encodings, 'Invalid input'); + $object->withContentEncodings($encodings); + } + if (array_key_exists('expirationTime', $input)) { + Assertion::nullOrInteger($input['expirationTime'], 'Invalid input'); + $object->setExpirationTime($input['expirationTime']); + } + if (array_key_exists('keys', $input)) { + Assertion::isArray($input['keys'], 'Invalid input'); + foreach ($input['keys'] as $k => $v) { + Assertion::string($k, 'Invalid key name'); + Assertion::string($v, 'Invalid key value'); + $object->setKey($k, $v); + } + } + + return $object; + } +} diff --git a/src/library/TTLExtension.php b/src/library/TTLExtension.php new file mode 100644 index 0000000..9d8c728 --- /dev/null +++ b/src/library/TTLExtension.php @@ -0,0 +1,50 @@ +logger = new NullLogger(); + } + + public static function create(): self + { + return new self(); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function process(RequestInterface $request, Notification $notification, Subscription $subscription): RequestInterface + { + $ttl = (string) $notification->getTTL(); + $this->logger->debug('Processing with the TTL extension', ['TTL' => $ttl]); + + return $request + ->withAddedHeader('TTL', $ttl) + ; + } +} diff --git a/src/library/TopicExtension.php b/src/library/TopicExtension.php new file mode 100644 index 0000000..6cea017 --- /dev/null +++ b/src/library/TopicExtension.php @@ -0,0 +1,53 @@ +logger = new NullLogger(); + } + + public static function create(): self + { + return new self(); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function process(RequestInterface $request, Notification $notification, Subscription $subscription): RequestInterface + { + $topic = $notification->getTopic(); + $this->logger->debug('Processing with the Topic extension', ['Topic' => $topic]); + if (null === $topic) { + return $request; + } + + return $request + ->withAddedHeader('Topic', $topic) + ; + } +} diff --git a/src/library/UrgencyExtension.php b/src/library/UrgencyExtension.php new file mode 100644 index 0000000..4989036 --- /dev/null +++ b/src/library/UrgencyExtension.php @@ -0,0 +1,50 @@ +logger = new NullLogger(); + } + + public static function create(): self + { + return new self(); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function process(RequestInterface $request, Notification $notification, Subscription $subscription): RequestInterface + { + $urgency = $notification->getUrgency(); + $this->logger->debug('Processing with the Urgency extension', ['Urgency' => $urgency]); + + return $request + ->withAddedHeader('Urgency', $urgency) + ; + } +} diff --git a/src/library/Utils.php b/src/library/Utils.php new file mode 100644 index 0000000..a966ab9 --- /dev/null +++ b/src/library/Utils.php @@ -0,0 +1,98 @@ +token = $token; + $this->key = $key; + } + + public function getToken(): string + { + return $this->token; + } + + public function getKey(): string + { + return $this->key; + } +} diff --git a/src/library/VAPID/JWSProvider.php b/src/library/VAPID/JWSProvider.php new file mode 100644 index 0000000..7b23129 --- /dev/null +++ b/src/library/VAPID/JWSProvider.php @@ -0,0 +1,22 @@ + $claims + */ + public function computeHeader(array $claims): Header; +} diff --git a/src/library/VAPID/LcobucciProvider.php b/src/library/VAPID/LcobucciProvider.php new file mode 100644 index 0000000..a763b9c --- /dev/null +++ b/src/library/VAPID/LcobucciProvider.php @@ -0,0 +1,93 @@ +publicKey = $publicKey; + $pem = Utils::privateKeyToPEM( + Base64Url::decode($privateKey), + Base64Url::decode($publicKey) + ); + $this->key = InMemory::plainText($pem); + $this->logger = new NullLogger(); + } + + public static function create(string $publicKey, string $privateKey): self + { + return new self($publicKey, $privateKey); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function computeHeader(array $claims): Header + { + $this->logger->debug('Computing the JWS'); + $signer = Sha256::create(); + $header = json_encode(['typ' => 'JWT', 'alg' => 'ES256'], self::JSON_OPTIONS); + $payload = json_encode($claims, self::JSON_OPTIONS); + $dataToSign = sprintf( + '%s.%s', + Base64Url::encode($header), + Base64Url::encode($payload) + ); + $signature = $signer->sign($dataToSign, $this->key); + $token = sprintf( + '%s.%s', + $dataToSign, + Base64Url::encode($signature) + ); + + $this->logger->debug('JWS computed', ['token' => $token, 'key' => $this->publicKey]); + + return new Header( + $token, + $this->publicKey + ); + } +} diff --git a/src/library/VAPID/VAPIDExtension.php b/src/library/VAPID/VAPIDExtension.php new file mode 100644 index 0000000..90d69fd --- /dev/null +++ b/src/library/VAPID/VAPIDExtension.php @@ -0,0 +1,81 @@ +subject = $subject; + $this->jwsProvider = $jwsProvider; + $this->logger = new NullLogger(); + } + + public static function create(string $subject, JWSProvider $jwsProvider): self + { + return new self($subject, $jwsProvider); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function setTokenExpirationTime(string $tokenExpirationTime): self + { + $this->tokenExpirationTime = $tokenExpirationTime; + + return $this; + } + + public function process(RequestInterface $request, Notification $notification, Subscription $subscription): RequestInterface + { + $this->logger->debug('Processing with VAPID header'); + $endpoint = $subscription->getEndpoint(); + $expiresAt = new DateTimeImmutable($this->tokenExpirationTime); + $parsedEndpoint = parse_url($endpoint); + $origin = $parsedEndpoint['scheme'].'://'.$parsedEndpoint['host'].(isset($parsedEndpoint['port']) ? ':'.$parsedEndpoint['port'] : ''); + $claims = [ + 'aud' => $origin, + 'sub' => $this->subject, + 'exp' => $expiresAt->getTimestamp(), + ]; + + $this->logger->debug('Trying to get the header from the cache'); + $header = $this->jwsProvider->computeHeader($claims); + $this->logger->debug('Header from cache', ['header' => $header]); + + return $request + ->withAddedHeader('Authorization', sprintf('vapid t=%s, k=%s', $header->getToken(), $header->getKey())) + ; + } +} diff --git a/src/library/VAPID/WebTokenProvider.php b/src/library/VAPID/WebTokenProvider.php new file mode 100644 index 0000000..5f208ce --- /dev/null +++ b/src/library/VAPID/WebTokenProvider.php @@ -0,0 +1,104 @@ + 'EC', + 'crv' => 'P-256', + 'd' => $privateKey, + 'x' => Base64Url::encode($x), + 'y' => Base64Url::encode($y), + ]); + + $this->signatureKey = $jwk; + $algorithmManager = new AlgorithmManager([new ES256()]); + $this->serializer = new CompactSerializer(); + $this->jwsBuilder = new JWSBuilder($algorithmManager); + $this->logger = new NullLogger(); + } + + public static function create(string $publicKey, string $privateKey): self + { + return new self($publicKey, $privateKey); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function computeHeader(array $claims): Header + { + $this->logger->debug('Computing the JWS'); + $payload = json_encode($claims); + $jws = $this->jwsBuilder->create() + ->withPayload($payload) + ->addSignature($this->signatureKey, ['typ' => 'JWT', 'alg' => 'ES256']) + ->build() + ; + $token = $this->serializer->serialize($jws); + $key = $this->serializePublicKey(); + $this->logger->debug('JWS computed', ['token' => $token, 'key' => $key]); + + return new Header( + $token, + $key + ); + } + + private function serializePublicKey(): string + { + $hexString = '04'; + $hexString .= bin2hex(Base64Url::decode($this->signatureKey->get('x'))); + $hexString .= bin2hex(Base64Url::decode($this->signatureKey->get('y'))); + + return Base64Url::encode(hex2bin($hexString)); + } +} diff --git a/src/library/WebPush.php b/src/library/WebPush.php new file mode 100644 index 0000000..5b1902f --- /dev/null +++ b/src/library/WebPush.php @@ -0,0 +1,65 @@ +client = $client; + $this->requestFactory = $requestFactory; + $this->extensionManager = $extensionManager; + $this->logger = new NullLogger(); + } + + public static function create(ClientInterface $client, RequestFactoryInterface $requestFactory, ExtensionManager $extensionManager): self + { + return new self($client, $requestFactory, $extensionManager); + } + + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function send(Notification $notification, Subscription $subscription): StatusReport + { + $this->logger->debug('Sending notification', ['notification' => $notification, 'subscription' => $subscription]); + $request = $this->requestFactory->createRequest('POST', $subscription->getEndpoint()); + $request = $this->extensionManager->process($request, $notification, $subscription); + $this->logger->debug('Request ready', ['request' => $request]); + + $response = $this->client->sendRequest($request); + $this->logger->debug('Response received', ['response' => $response]); + + return new StatusReport( + $subscription, + $notification, + $request, + $response + ); + } +} diff --git a/src/library/WebPushService.php b/src/library/WebPushService.php new file mode 100644 index 0000000..0259cf2 --- /dev/null +++ b/src/library/WebPushService.php @@ -0,0 +1,19 @@ +=7.4", + "ext-json": "*", + "beberlei/assert": "^3.2", + "psr/cache": "^1.0", + "psr/http-client": "^1.0.1", + "psr/http-factory": "^1.0.1", + "psr/http-message": "^1.0.1", + "psr/log": "^1.1", + "thecodingmachine/safe": "^1.3" + }, + "autoload": { + "psr-4" : { + "WebPush\\" : "" + } + }, + "suggest": { + "ext-mbstring": "Mandatory when using Payload or VAPID extensions", + "ext-openssl": "Mandatory when using Payload or VAPID extensions", + "web-token/jwt-signature-algorithm-ecdsa": "Mandatory if you want to use VAPID using web-token/jwt-framework", + "lcobucci/jwt": "Mandatory if you want to use VAPID using lcobucci/jwt", + "psr/log-implementation": "Recommended to receive logs from the library" + } +} diff --git a/tests/Benchmark/AES128GCMPaddingBench.php b/tests/Benchmark/AES128GCMPaddingBench.php new file mode 100644 index 0000000..1acbb3f --- /dev/null +++ b/tests/Benchmark/AES128GCMPaddingBench.php @@ -0,0 +1,79 @@ +encoder = AES128GCM::create()->noPadding(); + $this->encoderWithRecommendedPadding = AES128GCM::create()->recommendedPadding(); + $this->encoderWithMaximumPadding = AES128GCM::create()->maxPadding(); + + $this->message = Message::create('Hello World!') + ->withLang('en-GB') + ->interactionRequired() + ->withTimestamp(time()) + ->addAction(Action::create('accept', 'Accept')) + ->addAction(Action::create('cancel', 'Cancel')) + ->toString() + ; + $this->subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAABfcsdu1p9BdbYIByt9F76MHcSiuix-ZIiICzAkU9z_p0gnolYLMOi71rqss5pMOZuYJVZLa7rRN58uOgfdsux7k51Ph6KJRFEkf1LqTRMv2d8OhQaL2TR36WUR2d5twzYVwcQJAnTLrhVrWqKVo8ekAonuwyFHDUGzD8oUWpFTK9y2F68","keys":{"auth":"wSfP1pfACMwFesCEfJx4-w","p256dh":"BIlDpD05YLrVPXfANOKOCNSlTvjpb5vdFo-1e0jNcbGlFrP49LyOjYyIIAZIVCDAHEcX-135b859bdsse-PgosU"},"contentEncoding":"aes128gcm"}'); + $this->request = new Request('POST', 'https://www.example.com'); + } + + /** + * @Subject + */ + public function encodeWithoutPadding(): void + { + $this->encoder->encode($this->message, $this->request, $this->subscription); + } + + /** + * @Subject + */ + public function encodeWithRecommendedPadding(): void + { + $this->encoderWithRecommendedPadding->encode($this->message, $this->request, $this->subscription); + } + + /** + * @Subject + */ + public function encodeWithMaximumPadding(): void + { + $this->encoderWithMaximumPadding->encode($this->message, $this->request, $this->subscription); + } +} diff --git a/tests/Benchmark/AESGCMPaddingBench.php b/tests/Benchmark/AESGCMPaddingBench.php new file mode 100644 index 0000000..83a68bf --- /dev/null +++ b/tests/Benchmark/AESGCMPaddingBench.php @@ -0,0 +1,79 @@ +encoder = AESGCM::create()->noPadding(); + $this->encoderWithRecommendedPadding = AESGCM::create()->recommendedPadding(); + $this->encoderWithMaximumPadding = AESGCM::create()->maxPadding(); + + $this->message = Message::create('Hello World!') + ->withLang('en-GB') + ->interactionRequired() + ->withTimestamp(time()) + ->addAction(Action::create('accept', 'Accept')) + ->addAction(Action::create('cancel', 'Cancel')) + ->toString() + ; + $this->subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAABfcsdu1p9BdbYIByt9F76MHcSiuix-ZIiICzAkU9z_p0gnolYLMOi71rqss5pMOZuYJVZLa7rRN58uOgfdsux7k51Ph6KJRFEkf1LqTRMv2d8OhQaL2TR36WUR2d5twzYVwcQJAnTLrhVrWqKVo8ekAonuwyFHDUGzD8oUWpFTK9y2F68","keys":{"auth":"wSfP1pfACMwFesCEfJx4-w","p256dh":"BIlDpD05YLrVPXfANOKOCNSlTvjpb5vdFo-1e0jNcbGlFrP49LyOjYyIIAZIVCDAHEcX-135b859bdsse-PgosU"},"contentEncoding":"aes128gcm"}'); + $this->request = new Request('POST', 'https://www.example.com'); + } + + /** + * @Subject + */ + public function encodeWithoutPadding(): void + { + $this->encoder->encode($this->message, $this->request, $this->subscription); + } + + /** + * @Subject + */ + public function encodeWithRecommendedPadding(): void + { + $this->encoderWithRecommendedPadding->encode($this->message, $this->request, $this->subscription); + } + + /** + * @Subject + */ + public function encodeWithMaximumPadding(): void + { + $this->encoderWithMaximumPadding->encode($this->message, $this->request, $this->subscription); + } +} diff --git a/tests/Benchmark/AbstractBench.php b/tests/Benchmark/AbstractBench.php new file mode 100644 index 0000000..9527ec8 --- /dev/null +++ b/tests/Benchmark/AbstractBench.php @@ -0,0 +1,132 @@ +jwtProvider(); + $vapidExtension = VAPIDExtension::create('mailto:foo@bar.com', $jwsProvider); + + $payloadExtension = PayloadExtension::create() + ->addContentEncoding(AES128GCM::create()->maxPadding()) + ->addContentEncoding(AESGCM::create()->maxPadding()) + ; + + $aes128gcm = AES128GCM::create() + ->setCache(new FilesystemAdapter()) + ->maxPadding() + ; + $aesgcm = AESGCM::create() + ->setCache(new FilesystemAdapter()) + ->maxPadding() + ; + + $payloadExtensionWithCache = PayloadExtension::create() + ->addContentEncoding($aes128gcm) + ->addContentEncoding($aesgcm) + ; + + $extensionManager = ExtensionManager::create() + ->add(TTLExtension::create()) + ->add(TopicExtension::create()) + ->add(UrgencyExtension::create()) + ->add($vapidExtension) + ->add($payloadExtension) + ; + + $extensionManagerWithCache = ExtensionManager::create() + ->add(TTLExtension::create()) + ->add(TopicExtension::create()) + ->add(UrgencyExtension::create()) + ->add($vapidExtension) + ->add($payloadExtensionWithCache) + ; + + $this->webPush = WebPush::create($client, $psr17Factory, $extensionManager); + $this->webPushWithCache = WebPush::create($client, $psr17Factory, $extensionManagerWithCache); + + $this->subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAABfcsdu1p9BdbYIByt9F76MHcSiuix-ZIiICzAkU9z_p0gnolYLMOi71rqss5pMOZuYJVZLa7rRN58uOgfdsux7k51Ph6KJRFEkf1LqTRMv2d8OhQaL2TR36WUR2d5twzYVwcQJAnTLrhVrWqKVo8ekAonuwyFHDUGzD8oUWpFTK9y2F68","keys":{"auth":"wSfP1pfACMwFesCEfJx4-w","p256dh":"BIlDpD05YLrVPXfANOKOCNSlTvjpb5vdFo-1e0jNcbGlFrP49LyOjYyIIAZIVCDAHEcX-135b859bdsse-PgosU"},"contentEncoding":"aes128gcm"}'); + } + + /** + * @Subject + */ + public function sendNotificationWithoutPayload(): void + { + $notification = Notification::create(); + $this->webPush->send($notification, $this->subscription); + } + + /** + * @Subject + */ + public function sendNotificationWithoutPayloadWithCache(): void + { + $notification = Notification::create(); + $this->webPushWithCache->send($notification, $this->subscription); + } + + /** + * @Subject + */ + public function sendNotificationWithPayload(): void + { + $notification = Notification::create() + ->withPayload('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas nisi justo, cursus sed fringilla at, mollis ac velit. Duis vulputate libero eget luctus posuere. Nam in ex turpis. Nullam commodo elit tortor. Phasellus ipsum sapien, venenatis non tellus et, ullamcorper faucibus felis. Nullam quis eleifend diam, ut tincidunt nibh. Ut massa lectus, imperdiet a mollis sed, tempor a arcu. Nulla facilisi.') + ; + $this->webPush->send($notification, $this->subscription); + } + + /** + * @Subject + */ + public function sendNotificationWithPayloadWithCache(): void + { + $notification = Notification::create() + ->withPayload('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas nisi justo, cursus sed fringilla at, mollis ac velit. Duis vulputate libero eget luctus posuere. Nam in ex turpis. Nullam commodo elit tortor. Phasellus ipsum sapien, venenatis non tellus et, ullamcorper faucibus felis. Nullam quis eleifend diam, ut tincidunt nibh. Ut massa lectus, imperdiet a mollis sed, tempor a arcu. Nulla facilisi.') + ; + $this->webPushWithCache->send($notification, $this->subscription); + } + + abstract protected function jwtProvider(): JWSProvider; +} diff --git a/tests/Benchmark/LcobucciBench.php b/tests/Benchmark/LcobucciBench.php new file mode 100644 index 0000000..ba743a4 --- /dev/null +++ b/tests/Benchmark/LcobucciBench.php @@ -0,0 +1,28 @@ +load(__DIR__.'/config/config.yml'); + } +} diff --git a/tests/Bundle/FakeApp/Entity/Subscription.php b/tests/Bundle/FakeApp/Entity/Subscription.php new file mode 100644 index 0000000..e581cf2 --- /dev/null +++ b/tests/Bundle/FakeApp/Entity/Subscription.php @@ -0,0 +1,48 @@ +id; + } + + public static function createFromBaseSubscription(string $input): self + { + $base = BaseSubscription::createFromString($input); + $object = new self($base->getEndpoint()); + $object->withContentEncodings($base->getSupportedContentEncodings()); + foreach ($base->getKeys() as $k => $v) { + $object->setKey($k, $v); + } + + return $object; + } +} diff --git a/tests/Bundle/FakeApp/Entity/User.php b/tests/Bundle/FakeApp/Entity/User.php new file mode 100644 index 0000000..0ca0d8f --- /dev/null +++ b/tests/Bundle/FakeApp/Entity/User.php @@ -0,0 +1,51 @@ +subscription = $subscription; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getSubscription(): Subscription + { + return $this->subscription; + } +} diff --git a/tests/Bundle/FakeApp/Repository/SubscriptionRepository.php b/tests/Bundle/FakeApp/Repository/SubscriptionRepository.php new file mode 100644 index 0000000..4c0c687 --- /dev/null +++ b/tests/Bundle/FakeApp/Repository/SubscriptionRepository.php @@ -0,0 +1,40 @@ + + * + * @method Subscription|null find($id, $lockMode = null, $lockVersion = null) + * @method Subscription|null findOneBy(array $criteria, array $orderBy = null) + * @method Subscription[] findAll() + * @method Subscription[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class SubscriptionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Subscription::class); + } + + public function save(Subscription $object): void + { + $this->getEntityManager()->persist($object); + $this->getEntityManager()->flush(); + } +} diff --git a/tests/Bundle/FakeApp/Repository/UserRepository.php b/tests/Bundle/FakeApp/Repository/UserRepository.php new file mode 100644 index 0000000..c8582eb --- /dev/null +++ b/tests/Bundle/FakeApp/Repository/UserRepository.php @@ -0,0 +1,40 @@ + + * + * @method User|null find($id, $lockMode = null, $lockVersion = null) + * @method User|null findOneBy(array $criteria, array $orderBy = null) + * @method User[] findAll() + * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class UserRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + public function save(User $object): void + { + $this->getEntityManager()->persist($object); + $this->getEntityManager()->flush(); + } +} diff --git a/tests/Bundle/Functional/ExtensionTest.php b/tests/Bundle/Functional/ExtensionTest.php new file mode 100644 index 0000000..508f9aa --- /dev/null +++ b/tests/Bundle/Functional/ExtensionTest.php @@ -0,0 +1,68 @@ +get($class); + static::assertInstanceOf(Extension::class, $service); + } + + public function listOfPayloadExtensions(): array + { + return [ + [ + PreferAsyncExtension::class, + ], + [ + TopicExtension::class, + ], + [ + TTLExtension::class, + ], + [ + UrgencyExtension::class, + ], + [ + PayloadExtension::class, + ], + [ + VAPIDExtension::class, + ], + ]; + } +} diff --git a/tests/Bundle/Functional/NotificationTest.php b/tests/Bundle/Functional/NotificationTest.php new file mode 100644 index 0000000..64a8423 --- /dev/null +++ b/tests/Bundle/Functional/NotificationTest.php @@ -0,0 +1,142 @@ +getContainer()->get(WebPush::class); + /** @var MockClientCallback $responseFactory */ + $responseFactory = self::$container->get(MockClientCallback::class); + $responseFactory->setResponse('', [ + 'http_code' => 201, + ]); + + $subscription = Subscription::createFromString($data); + + $message = Message::create('Hello World!') + ->withLang('en-GB') + ->interactionRequired() + ->withTimestamp(time()) + ->addAction(Action::create('accept', 'Accept')) + ->addAction(Action::create('cancel', 'Cancel')) + ; + + $notification = Notification::create() + ->withTTL(10) + ->withTopic('test') + ->lowUrgency() + ->withPayload($message->toString()) + ; + + $report = $pushService->send($notification, $subscription); + + static::assertEquals(201, $report->getResponse()->getStatusCode()); + + $request = $report->getRequest(); + static::assertEquals([Notification::URGENCY_LOW], $request->getHeader('urgency')); + static::assertEquals([10], $request->getHeader('ttl')); + static::assertEquals(['test'], $request->getHeader('topic')); + static::assertEquals(['application/octet-stream'], $request->getHeader('content-type')); + static::assertEquals(['aesgcm'], $request->getHeader('content-encoding')); + + static::assertTrue($request->hasHeader('crypto-key')); + static::assertTrue($request->hasHeader('encryption')); + static::assertTrue($request->hasHeader('authorization')); + } + + /** + * @test + * @dataProvider listOfSubscriptions + */ + public function iCanSendNotificationsUsingAES128GCMEncryption(string $data): void + { + $kernel = self::bootKernel(); + /** @var WebPush $pushService */ + $pushService = $kernel->getContainer()->get(WebPush::class); + /** @var MockClientCallback $responseFactory */ + $responseFactory = self::$container->get(MockClientCallback::class); + $responseFactory->setResponse('', [ + 'http_code' => 201, + ]); + + $subscription = Subscription::createFromString($data); + $subscription->withContentEncodings(['aes128gcm']); + + $message = Message::create('Hello World!') + ->withLang('en-GB') + ->interactionRequired() + ->withTimestamp(time()) + ->addAction(Action::create('accept', 'Accept')) + ->addAction(Action::create('cancel', 'Cancel')) + ; + + $notification = Notification::create() + ->withTTL(3600) + ->withTopic('FOO') + ->veryLowUrgency() + ->withPayload($message->toString()) + ; + + $report = $pushService->send($notification, $subscription); + + static::assertEquals(201, $report->getResponse()->getStatusCode()); + + $request = $report->getRequest(); + static::assertEquals([Notification::URGENCY_VERY_LOW], $request->getHeader('urgency')); + static::assertEquals([3600], $request->getHeader('ttl')); + static::assertEquals(['FOO'], $request->getHeader('topic')); + static::assertEquals(['application/octet-stream'], $request->getHeader('content-type')); + static::assertEquals(['aes128gcm'], $request->getHeader('content-encoding')); + + static::assertFalse($request->hasHeader('crypto-key')); + static::assertFalse($request->hasHeader('encryption')); + static::assertTrue($request->hasHeader('authorization')); + } + + public function listOfSubscriptions(): array + { + return [ + [ + '{"endpoint":"https:\/\/fcm.googleapis.com\/fcm\/send\/fsTzuK_gGAE:APA91bGOo_qYwoGQoiKt6tM_GX9-jNXU9yGF4stivIeRX4cMZibjiXUAojfR_OfAT36AZ7UgfLbts011308MY7IYUljCxqEKKhwZk0yPjf9XOb-A7usa47gu1t__TfCrvQoXkrTiLuOt","contentEncoding":"aes128gcm","keys":{"p256dh":"BGx19OjV00A00o9DThFSX-q40h6FA3t_UATZLrYvJGHdruyY_6T1ug6gOczcSI2HtjV5NUGZKGmykaucnLuZgY4","auth":"gW9ZePDxvjUILvlYe3Dnug"}}', + ], + [ + '{"endpoint":"https:\/\/updates.push.services.mozilla.com\/wpush\/v2\/gAAAAABf28w8IBzfbD-ckKA6G0PNS5NVzRj8Ui6xsXS3XVE8Fn-l89cTjauuUYflfYO_pf4boI1DVlav2VkZg5YSymJ0jfUpfpUkXKbTk7MCLob7oM3CvSP7t1iCDhGUNBxAoB-kULsc3LGNxE8gZJbc-nFXjITaCAgFjIITDFlWIByMOKU2aUw","contentEncoding":"aesgcm","keys":{"auth":"TjVb1npRZ9OtlHrVecRc5w","p256dh":"BHF4I9ntV7K7MBgkAb-sA1L3YYKw5Q1Gynwz52iK8fjl21UOofhAyGJR-7Tded-ZpPKEPvpGHssHCWqethky65A"}}', + ], + [ + '{"endpoint":"https:\/\/db5p.notify.windows.com\/w\/?token=BQYAAACFSUYbq4Y62SPVdjevSnRSv2TM13ZLddTavvA8uApznqQ%2fiZn7obbFowZNKB552wmkVdaLh04FcJyWsAJ3iB2wxq2tc66CL19q%2fJrOaKAQ0hwUazGqu0BIFTBVeVOwmiGXhs5xpiX3Zl%2ffzOlmevxROP1dCXDKFPuS1RPwo4VMYRZ4JZY6HCvWRmTsB9kK9YtWsGUU%2bQ32pHzkUnPCBmLGZ70ZrJAp%2f9bsnNA3%2b5EsQIUDosvvvV5q8lNX2aiwKGdemndqtouTVkx4Wm436CH8vK0fJtlMGiH0cbt0RvlfEYN0dBkcSo0gdnx8hPebm8g%3d","contentEncoding":"aes128gcm","keys":{"p256dh":"BIreHXM7-HXqZfiuUfKD4o7QGmFkyp3Yz1EQWqOOZVxs9CdE-_2jay4j3s5syS-z4X54EzHtoM3-xMEOkaT4tEY","auth":"wt7wGPYcijytA8DOH17UhQ"}}', + ], + ]; + } +} diff --git a/tests/Bundle/Functional/SubscriptionRepositoryTest.php b/tests/Bundle/Functional/SubscriptionRepositoryTest.php new file mode 100644 index 0000000..cff7d26 --- /dev/null +++ b/tests/Bundle/Functional/SubscriptionRepositoryTest.php @@ -0,0 +1,69 @@ +getContainer()->get('doctrine'); + /** @var EntityManagerInterface $em */ + $em = $registry->getManager(); + $cmf = $em->getMetadataFactory(); + + $schemaTool = new SchemaTool($em); + $schemaTool->createSchema([ + $cmf->getMetadataFor(Subscription::class), + $cmf->getMetadataFor(\WebPush\Subscription::class), + ]); + } + + /** + * @test + */ + public function aSubscriptionCanBeSaved(): void + { + $data = '{"endpoint":"https:\/\/fcm.googleapis.com\/fcm\/send\/fsTzuK_gGAE:APA91bGOo_qYwoGQoiKt6tM_GX9-jNXU9yGF4stivIeRX4cMZibjiXUAojfR_OfAT36AZ7UgfLbts011308MY7IYUljCxqEKKhwZk0yPjf9XOb-A7usa47gu1t__TfCrvQoXkrTiLuOt","contentEncoding":"aes128gcm","keys":{"p256dh":"BGx19OjV00A00o9DThFSX-q40h6FA3t_UATZLrYvJGHdruyY_6T1ug6gOczcSI2HtjV5NUGZKGmykaucnLuZgY4","auth":"gW9ZePDxvjUILvlYe3Dnug"}}'; + $subscription = Subscription::createFromBaseSubscription($data); + + /** @var SubscriptionRepository $subscriptionRepository */ + $subscriptionRepository = self::$kernel->getContainer()->get(SubscriptionRepository::class); + + $subscriptionRepository->save($subscription); + $id = $subscription->getId(); + + $fetched = $subscriptionRepository->findOneBy(['id' => $id]); + + static::assertEquals('https://fcm.googleapis.com/fcm/send/fsTzuK_gGAE:APA91bGOo_qYwoGQoiKt6tM_GX9-jNXU9yGF4stivIeRX4cMZibjiXUAojfR_OfAT36AZ7UgfLbts011308MY7IYUljCxqEKKhwZk0yPjf9XOb-A7usa47gu1t__TfCrvQoXkrTiLuOt', $fetched->getEndpoint()); + static::assertEquals(['aesgcm'], $fetched->getSupportedContentEncodings()); + static::assertNull($fetched->getExpirationTime()); + static::assertEquals(['p256dh', 'auth'], array_keys($fetched->getKeys())); + static::assertEquals('BGx19OjV00A00o9DThFSX-q40h6FA3t_UATZLrYvJGHdruyY_6T1ug6gOczcSI2HtjV5NUGZKGmykaucnLuZgY4', $fetched->getKey('p256dh')); + static::assertEquals('gW9ZePDxvjUILvlYe3Dnug', $fetched->getKey('auth')); + } +} diff --git a/tests/Bundle/Functional/UserRepositoryTest.php b/tests/Bundle/Functional/UserRepositoryTest.php new file mode 100644 index 0000000..9b02c7a --- /dev/null +++ b/tests/Bundle/Functional/UserRepositoryTest.php @@ -0,0 +1,70 @@ +getContainer()->get('doctrine'); + /** @var EntityManagerInterface $em */ + $em = $registry->getManager(); + $cmf = $em->getMetadataFactory(); + + $schemaTool = new SchemaTool($em); + $schemaTool->createSchema([ + $cmf->getMetadataFor(User::class), + ]); + } + + /** + * @test + */ + public function aSubscriptionCanBeSaved(): void + { + $data = '{"endpoint":"https:\/\/fcm.googleapis.com\/fcm\/send\/fsTzuK_gGAE:APA91bGOo_qYwoGQoiKt6tM_GX9-jNXU9yGF4stivIeRX4cMZibjiXUAojfR_OfAT36AZ7UgfLbts011308MY7IYUljCxqEKKhwZk0yPjf9XOb-A7usa47gu1t__TfCrvQoXkrTiLuOt","contentEncoding":"aes128gcm","keys":{"p256dh":"BGx19OjV00A00o9DThFSX-q40h6FA3t_UATZLrYvJGHdruyY_6T1ug6gOczcSI2HtjV5NUGZKGmykaucnLuZgY4","auth":"gW9ZePDxvjUILvlYe3Dnug"}}'; + $subscription = Subscription::createFromBaseSubscription($data); + + /** @var UserRepository $userRepository */ + $userRepository = self::$kernel->getContainer()->get(UserRepository::class); + + $user = new User($subscription); + + $userRepository->save($user); + $id = $user->getId(); + $fetched = $userRepository->findOneBy(['id' => $id]); + + static::assertEquals('https://fcm.googleapis.com/fcm/send/fsTzuK_gGAE:APA91bGOo_qYwoGQoiKt6tM_GX9-jNXU9yGF4stivIeRX4cMZibjiXUAojfR_OfAT36AZ7UgfLbts011308MY7IYUljCxqEKKhwZk0yPjf9XOb-A7usa47gu1t__TfCrvQoXkrTiLuOt', $fetched->getSubscription()->getEndpoint()); + static::assertEquals(['aesgcm'], $fetched->getSubscription()->getSupportedContentEncodings()); + static::assertNull($fetched->getSubscription()->getExpirationTime()); + static::assertEquals(['p256dh', 'auth'], array_keys($fetched->getSubscription()->getKeys())); + static::assertEquals('BGx19OjV00A00o9DThFSX-q40h6FA3t_UATZLrYvJGHdruyY_6T1ug6gOczcSI2HtjV5NUGZKGmykaucnLuZgY4', $fetched->getSubscription()->getKey('p256dh')); + static::assertEquals('gW9ZePDxvjUILvlYe3Dnug', $fetched->getSubscription()->getKey('auth')); + } +} diff --git a/tests/Bundle/MockClientCallback.php b/tests/Bundle/MockClientCallback.php new file mode 100644 index 0000000..7f50713 --- /dev/null +++ b/tests/Bundle/MockClientCallback.php @@ -0,0 +1,34 @@ +body, $this->info); + } + + public function setResponse(string $body, array $info): void + { + $this->body = $body; + $this->info = $info; + } +} diff --git a/tests/Bundle/Unit/BundleTest.php b/tests/Bundle/Unit/BundleTest.php new file mode 100644 index 0000000..064ea7e --- /dev/null +++ b/tests/Bundle/Unit/BundleTest.php @@ -0,0 +1,113 @@ +getContainerExtension()); + } + + /** + * @test + * @dataProvider compilerPasses + */ + public function theBundleHasTheCompilerPass(string $class): void + { + $containerBuilder = new ContainerBuilder(); + $bundle = new WebPushBundle(); + $bundle->build($containerBuilder); + + $passes = $containerBuilder->getCompiler()->getPassConfig()->getPasses(); + $found = false; + foreach ($passes as $pass) { + if ($pass instanceof $class) { + $found = true; + break; + } + } + + static::assertTrue($found, 'Unable to find the compiler pass '.$class); + } + + public function theBundleDoesNotAddDoctrineCompilerPassesIfNotAvailableHasTheCompilerPass(): void + { + ClassExistsMock::withMockedClasses([DoctrineOrmMappingsPass::class => false]); + + $containerBuilder = new ContainerBuilder(); + $bundle = new WebPushBundle(); + $bundle->build($containerBuilder); + + $passes = $containerBuilder->getCompiler()->getPassConfig()->getPasses(); + $found = false; + foreach ($passes as $pass) { + if ($pass instanceof DoctrineOrmMappingsPass) { + $found = true; + break; + } + } + + static::assertFalse($found, 'The compiler pass DoctrineOrmMappingsPass has been found'); + } + + public function compilerPasses(): array + { + return [ + [ExtensionCompilerPass::class], + [LoggerSetterCompilerPass::class], + [PayloadCacheCompilerPass::class], + [PayloadContentEncodingCompilerPass::class], + [PayloadPaddingCompilerPass::class], + [DoctrineOrmMappingsPass::class], + ]; + } +} diff --git a/tests/Bundle/Unit/CompilerPass/ExtensionCompilerPassTest.php b/tests/Bundle/Unit/CompilerPass/ExtensionCompilerPassTest.php new file mode 100644 index 0000000..e5332ed --- /dev/null +++ b/tests/Bundle/Unit/CompilerPass/ExtensionCompilerPassTest.php @@ -0,0 +1,71 @@ +setDefinition(ExtensionManager::class, $collectingService); + + $collectedService = new Definition(); + $collectedService->addTag(ExtensionCompilerPass::TAG); + $this->setDefinition('collected_service', $collectedService); + + $this->compile(); + + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( + ExtensionManager::class, + 'add', + [ + new Reference('collected_service'), + ] + ); + } + + /** + * @test + */ + public function ifTheExtensionManagerDoesNotExistThenTaggedServicesAreAdded(): void + { + $collectedService = new Definition(); + $collectedService->addTag(ExtensionCompilerPass::TAG); + $this->setDefinition('collected_service', $collectedService); + + $this->compile(); + + $this->assertContainerBuilderNotHasService(ExtensionManager::class); + } + + protected function registerCompilerPass(ContainerBuilder $container): void + { + $container->addCompilerPass(new ExtensionCompilerPass()); + } +} diff --git a/tests/Bundle/Unit/CompilerPass/LoggerSetterCompilerPassTest.php b/tests/Bundle/Unit/CompilerPass/LoggerSetterCompilerPassTest.php new file mode 100644 index 0000000..e3cdd8e --- /dev/null +++ b/tests/Bundle/Unit/CompilerPass/LoggerSetterCompilerPassTest.php @@ -0,0 +1,58 @@ +setDefinition(LoggerInterface::class, $collectingService); + $this->container->setAlias(LoggerSetterCompilerPass::SERVICE, LoggerInterface::class); + + $collectedService = new Definition(); + $collectedService->addTag(LoggerSetterCompilerPass::TAG); + $this->setDefinition('collected_service', $collectedService); + + $this->compile(); + + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( + 'collected_service', + 'setLogger', + [ + new Reference(LoggerSetterCompilerPass::SERVICE), + ] + ); + } + + protected function registerCompilerPass(ContainerBuilder $container): void + { + $container->addCompilerPass(new LoggerSetterCompilerPass()); + } +} diff --git a/tests/Bundle/Unit/CompilerPass/PayloadCacheCompilerPassTest.php b/tests/Bundle/Unit/CompilerPass/PayloadCacheCompilerPassTest.php new file mode 100644 index 0000000..fe66e38 --- /dev/null +++ b/tests/Bundle/Unit/CompilerPass/PayloadCacheCompilerPassTest.php @@ -0,0 +1,83 @@ +setDefinition($class, $collectingService); + + $collectedService = new Definition(); + $this->setDefinition(CacheItemPoolInterface::class, $collectedService); + $this->container->setAlias($cacheDefinition, CacheItemPoolInterface::class); + + $this->setParameter($lifetimeParameterName, $lifetimeParameterValue); + + $this->compile(); + + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( + $class, + 'setCache', + [ + new Reference($cacheDefinition), + $lifetimeParameterValue, + ] + ); + } + + public function cacheParameters(): array + { + return [ + [ + AES128GCM::class, + 'webpush.payload.aes128gcm.cache', + 'webpush.payload.aes128gcm.cache_lifetime', + 'now +1 day', + ], + [ + AESGCM::class, + 'webpush.payload.aesgcm.cache', + 'webpush.payload.aesgcm.cache_lifetime', + 'now +1 day', + ], + ]; + } + + protected function registerCompilerPass(ContainerBuilder $container): void + { + $container->addCompilerPass(new PayloadCacheCompilerPass()); + } +} diff --git a/tests/Bundle/Unit/CompilerPass/PayloadContentEncodingCompilerPassTest.php b/tests/Bundle/Unit/CompilerPass/PayloadContentEncodingCompilerPassTest.php new file mode 100644 index 0000000..e205f9e --- /dev/null +++ b/tests/Bundle/Unit/CompilerPass/PayloadContentEncodingCompilerPassTest.php @@ -0,0 +1,71 @@ +setDefinition(PayloadExtension::class, $collectingService); + + $collectedService = new Definition(); + $collectedService->addTag(PayloadContentEncodingCompilerPass::TAG); + $this->setDefinition('collected_service', $collectedService); + + $this->compile(); + + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( + PayloadExtension::class, + 'addContentEncoding', + [ + new Reference('collected_service'), + ] + ); + } + + /** + * @test + */ + public function ifThePayloadExtensionDoesNotExistThenTaggedServicesAreAdded(): void + { + $collectedService = new Definition(); + $collectedService->addTag(PayloadContentEncodingCompilerPass::TAG); + $this->setDefinition('collected_service', $collectedService); + + $this->compile(); + + $this->assertContainerBuilderNotHasService(PayloadExtension::class); + } + + protected function registerCompilerPass(ContainerBuilder $container): void + { + $container->addCompilerPass(new PayloadContentEncodingCompilerPass()); + } +} diff --git a/tests/Bundle/Unit/CompilerPass/PayloadPaddingCompilerPassTest.php b/tests/Bundle/Unit/CompilerPass/PayloadPaddingCompilerPassTest.php new file mode 100644 index 0000000..a860639 --- /dev/null +++ b/tests/Bundle/Unit/CompilerPass/PayloadPaddingCompilerPassTest.php @@ -0,0 +1,117 @@ +setDefinition($class, $collectingService); + $this->setParameter($parameterName, $configValue); + + $this->compile(); + + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( + $class, + $methodName, + $methodParameters + ); + } + + public function paddingConfigurations(): array + { + return [ + [ + AES128GCM::class, + 'webpush.payload.aes128gcm.padding', + 'recommended', + 'recommendedPadding', + [], + ], + [ + AES128GCM::class, + 'webpush.payload.aes128gcm.padding', + 'none', + 'noPadding', + [], + ], + [ + AES128GCM::class, + 'webpush.payload.aes128gcm.padding', + 'max', + 'maxPadding', + [], + ], + [ + AES128GCM::class, + 'webpush.payload.aes128gcm.padding', + 50, + 'customPadding', + [50], + ], + [ + AESGCM::class, + 'webpush.payload.aesgcm.padding', + 'recommended', + 'recommendedPadding', + [], + ], + [ + AESGCM::class, + 'webpush.payload.aesgcm.padding', + 'none', + 'noPadding', + [], + ], + [ + AESGCM::class, + 'webpush.payload.aesgcm.padding', + 'max', + 'maxPadding', + [], + ], + [ + AESGCM::class, + 'webpush.payload.aesgcm.padding', + 50, + 'customPadding', + [50], + ], + ]; + } + + protected function registerCompilerPass(ContainerBuilder $container): void + { + $container->addCompilerPass(new PayloadPaddingCompilerPass()); + } +} diff --git a/tests/Bundle/Unit/Configuration/AES128GCMConfigurationTest.php b/tests/Bundle/Unit/Configuration/AES128GCMConfigurationTest.php new file mode 100644 index 0000000..9b64033 --- /dev/null +++ b/tests/Bundle/Unit/Configuration/AES128GCMConfigurationTest.php @@ -0,0 +1,114 @@ +assertConfigurationIsInvalid( + [ + [ + 'payload' => [ + 'aes128gcm' => [ + 'padding' => $padding, + ], + ], + ], + ], + 'Invalid configuration for path "webpush.payload.aes128gcm.padding": The padding must have one of the following value: none, recommended, max or an integer between 0 and 3993' + ); + } + + public function invalidPaddings(): array + { + return [ + ['min'], + [-1], + [AES128GCM::PADDING_MAX + 1], + ]; + } + + /** + * @test + * + * @param string|int|bool $padding + * @dataProvider validPaddings + */ + public function validPadding($padding): void + { + $this->assertProcessedConfigurationEquals( + [ + [ + 'payload' => [ + 'aes128gcm' => [ + 'padding' => $padding, + ], + ], + ], + ], + [ + 'logger' => null, + 'http_client' => ClientInterface::class, + 'request_factory' => RequestFactoryInterface::class, + 'doctrine_mapping' => false, + 'vapid' => [ + 'enabled' => false, + 'token_lifetime' => 'now +1hour', + 'web_token' => ['enabled' => false], + 'lcobucci' => ['enabled' => false], + 'custom' => ['enabled' => false], + ], + 'payload' => [ + 'aes128gcm' => [ + 'padding' => $padding, + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + 'aesgcm' => [ + 'padding' => 'recommended', + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + ], + ] + ); + } + + public function validPaddings(): array + { + return [ + ['none'], + ['recommended'], + ['max'], + [0], + [AES128GCM::PADDING_MAX], + ]; + } +} diff --git a/tests/Bundle/Unit/Configuration/AESGCMConfigurationTest.php b/tests/Bundle/Unit/Configuration/AESGCMConfigurationTest.php new file mode 100644 index 0000000..3d03255 --- /dev/null +++ b/tests/Bundle/Unit/Configuration/AESGCMConfigurationTest.php @@ -0,0 +1,114 @@ +assertConfigurationIsInvalid( + [ + [ + 'payload' => [ + 'aesgcm' => [ + 'padding' => $padding, + ], + ], + ], + ], + 'Invalid configuration for path "webpush.payload.aesgcm.padding": The padding must have one of the following value: none, recommended, max or an integer between 0 and 4078' + ); + } + + public function invalidPaddings(): array + { + return [ + ['min'], + [-1], + [AESGCM::PADDING_MAX + 1], + ]; + } + + /** + * @test + * + * @param string|int|bool $padding + * @dataProvider validPaddings + */ + public function validPadding($padding): void + { + $this->assertProcessedConfigurationEquals( + [ + [ + 'payload' => [ + 'aesgcm' => [ + 'padding' => $padding, + ], + ], + ], + ], + [ + 'logger' => null, + 'http_client' => ClientInterface::class, + 'request_factory' => RequestFactoryInterface::class, + 'doctrine_mapping' => false, + 'vapid' => [ + 'enabled' => false, + 'token_lifetime' => 'now +1hour', + 'web_token' => ['enabled' => false], + 'lcobucci' => ['enabled' => false], + 'custom' => ['enabled' => false], + ], + 'payload' => [ + 'aes128gcm' => [ + 'padding' => 'recommended', + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + 'aesgcm' => [ + 'padding' => $padding, + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + ], + ] + ); + } + + public function validPaddings(): array + { + return [ + ['none'], + ['recommended'], + ['max'], + [0], + [AESGCM::PADDING_MAX], + ]; + } +} diff --git a/tests/Bundle/Unit/Configuration/AbstractConfigurationTest.php b/tests/Bundle/Unit/Configuration/AbstractConfigurationTest.php new file mode 100644 index 0000000..001bf1b --- /dev/null +++ b/tests/Bundle/Unit/Configuration/AbstractConfigurationTest.php @@ -0,0 +1,31 @@ +assertProcessedConfigurationEquals( + [], + [ + 'logger' => null, + 'http_client' => ClientInterface::class, + 'request_factory' => RequestFactoryInterface::class, + 'doctrine_mapping' => false, + 'vapid' => [ + 'enabled' => false, + 'token_lifetime' => 'now +1hour', + 'web_token' => ['enabled' => false], + 'lcobucci' => ['enabled' => false], + 'custom' => ['enabled' => false], + ], + 'payload' => [ + 'aes128gcm' => [ + 'padding' => 'recommended', + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + 'aesgcm' => [ + 'padding' => 'recommended', + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + ], + ] + ); + } + + /** + * @test + */ + public function invalidIfNoSubjectIsSetWhenVapidIsEnabled(): void + { + $this->assertConfigurationIsInvalid( + [ + [ + 'vapid' => [ + 'enabled' => true, + ], + ], + ], + 'The child config "subject" under "webpush.vapid" must be configured: The URL of the service or an email address' + ); + } + + /** + * @test + */ + public function invalidIfNoJwtProviderIsEnabled(): void + { + $this->assertConfigurationIsInvalid( + [ + [ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'https://foo.bar', + ], + ], + ], + 'Invalid configuration for path "webpush.vapid": One, and only one, JWS Provider shall be set' + ); + } + + /** + * @test + * @dataProvider multipleJwsProvider + */ + public function invalidIfSeveralJwsProviderAreSet(array $conf): void + { + $conf['enabled'] = true; + $conf['subject'] = 'https://foo.bar'; + $this->assertConfigurationIsInvalid( + [ + [ + 'vapid' => $conf, + ], + ], + 'Invalid configuration for path "webpush.vapid": One, and only one, JWS Provider shall be set' + ); + } + + public function multipleJwsProvider(): array + { + return [ + [[ + 'web-token' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + 'lcobucci' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + ]], + [[ + 'lcobucci' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + 'custom' => [ + 'enabled' => true, + 'id' => 'app.service.foo', + ], + ]], + [[ + 'web-token' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + 'custom' => [ + 'enabled' => true, + 'id' => 'app.service.foo', + ], + ]], + [[ + 'web-token' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + 'lcobucci' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + 'custom' => [ + 'enabled' => true, + 'id' => 'app.service.foo', + ], + ]], + ]; + } +} diff --git a/tests/Bundle/Unit/Configuration/CustomJwsProviderConfigurationTest.php b/tests/Bundle/Unit/Configuration/CustomJwsProviderConfigurationTest.php new file mode 100644 index 0000000..8a5bc3f --- /dev/null +++ b/tests/Bundle/Unit/Configuration/CustomJwsProviderConfigurationTest.php @@ -0,0 +1,43 @@ +assertConfigurationIsInvalid( + [ + [ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'https://foo.bar', + 'custom' => [ + 'enabled' => true, + ], + ], + ], + ], + 'The child config "id" under "webpush.vapid.custom" must be configured: The custom JWS Provider service ID' + ); + } +} diff --git a/tests/Bundle/Unit/Configuration/LcobucciConfigurationTest.php b/tests/Bundle/Unit/Configuration/LcobucciConfigurationTest.php new file mode 100644 index 0000000..4d7aae6 --- /dev/null +++ b/tests/Bundle/Unit/Configuration/LcobucciConfigurationTest.php @@ -0,0 +1,121 @@ +assertConfigurationIsInvalid( + [ + [ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'https://foo.bar', + 'lcobucci' => [ + 'enabled' => true, + ], + ], + ], + ], + 'The child config "private_key" under "webpush.vapid.lcobucci" must be configured: The VAPID private key' + ); + } + + /** + * @test + */ + public function invalidIfPublicKeyIsMissing(): void + { + $this->assertConfigurationIsInvalid( + [ + [ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'https://foo.bar', + 'lcobucci' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + ], + ], + ], + ], + 'The child config "public_key" under "webpush.vapid.lcobucci" must be configured: The VAPID public key' + ); + } + + /** + * @test + */ + public function validVapidLcobucciConfiguration(): void + { + $this->assertProcessedConfigurationEquals( + [ + [ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'https://foo.bar', + 'lcobucci' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + ], + ], + ], + [ + 'logger' => null, + 'http_client' => ClientInterface::class, + 'request_factory' => RequestFactoryInterface::class, + 'doctrine_mapping' => false, + 'vapid' => [ + 'enabled' => true, + 'token_lifetime' => 'now +1hour', + 'subject' => 'https://foo.bar', + 'web_token' => ['enabled' => false], + 'lcobucci' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + 'custom' => ['enabled' => false], + ], + 'payload' => [ + 'aes128gcm' => [ + 'padding' => 'recommended', + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + 'aesgcm' => [ + 'padding' => 'recommended', + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + ], + ] + ); + } +} diff --git a/tests/Bundle/Unit/Configuration/WebTokenConfigurationTest.php b/tests/Bundle/Unit/Configuration/WebTokenConfigurationTest.php new file mode 100644 index 0000000..555136f --- /dev/null +++ b/tests/Bundle/Unit/Configuration/WebTokenConfigurationTest.php @@ -0,0 +1,121 @@ +assertConfigurationIsInvalid( + [ + [ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'https://foo.bar', + 'web-token' => [ + 'enabled' => true, + ], + ], + ], + ], + 'The child config "private_key" under "webpush.vapid.web_token" must be configured: The VAPID private key' + ); + } + + /** + * @test + */ + public function invalidIfPublicKeyIsMissing(): void + { + $this->assertConfigurationIsInvalid( + [ + [ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'https://foo.bar', + 'web-token' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + ], + ], + ], + ], + 'The child config "public_key" under "webpush.vapid.web_token" must be configured: The VAPID public key' + ); + } + + /** + * @test + */ + public function validVapidWebTokenConfiguration(): void + { + $this->assertProcessedConfigurationEquals( + [ + [ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'https://foo.bar', + 'web-token' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + ], + ], + ], + [ + 'logger' => null, + 'http_client' => ClientInterface::class, + 'request_factory' => RequestFactoryInterface::class, + 'doctrine_mapping' => false, + 'vapid' => [ + 'enabled' => true, + 'token_lifetime' => 'now +1hour', + 'subject' => 'https://foo.bar', + 'web_token' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + 'lcobucci' => ['enabled' => false], + 'custom' => ['enabled' => false], + ], + 'payload' => [ + 'aes128gcm' => [ + 'padding' => 'recommended', + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + 'aesgcm' => [ + 'padding' => 'recommended', + 'cache' => null, + 'cache_lifetime' => 'now + 30min', + ], + ], + ] + ); + } +} diff --git a/tests/Bundle/Unit/Extension/AbstractExtensionTest.php b/tests/Bundle/Unit/Extension/AbstractExtensionTest.php new file mode 100644 index 0000000..71cf418 --- /dev/null +++ b/tests/Bundle/Unit/Extension/AbstractExtensionTest.php @@ -0,0 +1,30 @@ +load(); + + $this->assertContainerBuilderHasAlias('webpush.logger', LoggerInterface::class); + $this->assertContainerBuilderHasAlias('webpush.http_client', ClientInterface::class); + $this->assertContainerBuilderHasAlias('webpush.request_factory', RequestFactoryInterface::class); + + $this->assertContainerBuilderHasParameter('webpush.payload.aesgcm.cache_lifetime', 'now + 30min'); + $this->assertContainerBuilderHasParameter('webpush.payload.aesgcm.padding', 'recommended'); + $this->assertContainerBuilderHasAlias('webpush.payload.aesgcm.cache', CacheItemPoolInterface::class); + + $this->assertContainerBuilderHasParameter('webpush.payload.aes128gcm.cache_lifetime', 'now + 30min'); + $this->assertContainerBuilderHasParameter('webpush.payload.aes128gcm.padding', 'recommended'); + $this->assertContainerBuilderHasAlias('webpush.payload.aes128gcm.cache', CacheItemPoolInterface::class); + } + + /** + * @test + */ + public function theVapidWebTokenParametersAndAliasesAreSet(): void + { + $this->load([ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'foo@bar.com', + 'token_lifetime' => 'now +1 hour', + 'web-token' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + ], + ]); + + $this->assertContainerBuilderHasParameter('webpush.vapid.subject', 'foo@bar.com'); + $this->assertContainerBuilderHasParameter('webpush.vapid.token_lifetime', 'now +1 hour'); + $this->assertContainerBuilderHasParameter('webpush.vapid.web_token.private_key', Base64Url::encode('00000000000000000000000000000000')); + $this->assertContainerBuilderHasParameter('webpush.vapid.web_token.public_key', Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000')); + } + + /** + * @test + */ + public function theVapidLcobucciParametersAndAliasesAreSet(): void + { + $this->load([ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'foo@bar.com', + 'token_lifetime' => 'now +1 hour', + 'lcobucci' => [ + 'enabled' => true, + 'private_key' => Base64Url::encode('00000000000000000000000000000000'), + 'public_key' => Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000'), + ], + ], + ]); + + $this->assertContainerBuilderHasParameter('webpush.vapid.subject', 'foo@bar.com'); + $this->assertContainerBuilderHasParameter('webpush.vapid.token_lifetime', 'now +1 hour'); + $this->assertContainerBuilderHasParameter('webpush.vapid.lcobucci.private_key', Base64Url::encode('00000000000000000000000000000000')); + $this->assertContainerBuilderHasParameter('webpush.vapid.lcobucci.public_key', Base64Url::encode('00000000000000000000000000000000000000000000000000000000000000000')); + } + + /** + * @test + */ + public function theVapidCustomParametersAndAliasesAreSet(): void + { + $this->load([ + 'vapid' => [ + 'enabled' => true, + 'subject' => 'foo@bar.com', + 'token_lifetime' => 'now +1 hour', + 'custom' => [ + 'enabled' => true, + 'id' => 'custom_service_id', + ], + ], + ]); + + $this->assertContainerBuilderHasParameter('webpush.vapid.subject', 'foo@bar.com'); + $this->assertContainerBuilderHasParameter('webpush.vapid.token_lifetime', 'now +1 hour'); + $this->assertContainerBuilderHasAlias(JWSProvider::class, 'custom_service_id'); + } + + protected function getMinimalConfiguration(): array + { + return [ + 'logger' => LoggerInterface::class, + 'http_client' => ClientInterface::class, + 'request_factory' => RequestFactoryInterface::class, + 'payload' => [ + 'aes128gcm' => [ + 'padding' => 'recommended', + 'cache' => CacheItemPoolInterface::class, + 'cache_lifetime' => 'now + 30min', + ], + 'aesgcm' => [ + 'padding' => 'recommended', + 'cache' => CacheItemPoolInterface::class, + 'cache_lifetime' => 'now + 30min', + ], + ], + ]; + } +} diff --git a/tests/Bundle/WebPushEventListener.php b/tests/Bundle/WebPushEventListener.php new file mode 100644 index 0000000..296e5d3 --- /dev/null +++ b/tests/Bundle/WebPushEventListener.php @@ -0,0 +1,37 @@ +events[] = $report; + } + + /** + * @return StatusReport[] + */ + public function getEvents(): array + { + return $this->events; + } +} diff --git a/tests/Bundle/config/config.yml b/tests/Bundle/config/config.yml new file mode 100644 index 0000000..8bfedcd --- /dev/null +++ b/tests/Bundle/config/config.yml @@ -0,0 +1,76 @@ +services: + WebPush\Tests\Bundle\MockClientCallback: ~ + WebPush\Tests\Bundle\FakeApp\Repository\SubscriptionRepository: + public: true + arguments: + [ '@doctrine' ] + WebPush\Tests\Bundle\FakeApp\Repository\UserRepository: + public: true + arguments: + [ '@doctrine' ] + WebPush\Tests\Bundle\WebPushEventListener: + tags: + - { name: kernel.event_listener, event: WebPush\StatusReport } + Psr\Http\Message\RequestFactoryInterface: '@nyholm.psr7.psr17_factory' + Psr\Http\Message\ResponseFactoryInterface: '@nyholm.psr7.psr17_factory' + Psr\Http\Message\ServerRequestFactoryInterface: '@nyholm.psr7.psr17_factory' + Psr\Http\Message\StreamFactoryInterface: '@nyholm.psr7.psr17_factory' + Psr\Http\Message\UploadedFileFactoryInterface: '@nyholm.psr7.psr17_factory' + Psr\Http\Message\UriFactoryInterface: '@nyholm.psr7.psr17_factory' + nyholm.psr7.psr17_factory: + class: Nyholm\Psr7\Factory\Psr17Factory + +monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + +framework: + test: ~ + secret: 'test' + annotations: ~ + http_client: + mock_response_factory: WebPush\Tests\Bundle\MockClientCallback + +doctrine: + dbal: + default_connection: default + connections: + default: + driver: pdo_sqlite + memory: true + orm: + auto_generate_proxy_classes: true + auto_mapping: true + mappings: + App: + is_bundle: false + type: annotation + dir: '%kernel.project_dir%/' + prefix: 'WebPush\Tests\Bundle\FakeApp\Entity' + alias: App + +webpush: + #logger: Psr\Log\LoggerInterface + doctrine_mapping: true + vapid: + enabled: true + subject: 'https://foo.bar' + #cache: Psr\Cache\CacheItemPoolInterface + #cache_lifetime: '+1 hour' + #token_lifetime: '+2 hour' + web_token: + enabled: true + public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ' + private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU' + #payload: + #aesgcm: + #cache: Psr\Cache\CacheItemPoolInterface + #cache_lifetime: '+1 hour' + #padding: 'recommended' + #aes128gcm: + #cache: Psr\Cache\CacheItemPoolInterface + #cache_lifetime: '+1 hour' + #padding: 'recommended' diff --git a/tests/ComposerJsonTest.php b/tests/ComposerJsonTest.php new file mode 100644 index 0000000..460c1ef --- /dev/null +++ b/tests/ComposerJsonTest.php @@ -0,0 +1,103 @@ +getComposerDependencies(__DIR__.'/../composer.json'); + + foreach ($this->listSubPackages() as $package) { + $packageDependencies = $this->getComposerDependencies(self::SRC_DIR.'/'.$package.'/composer.json'); + foreach ($packageDependencies as $dependency => $version) { + // Skip spomky-labs/* dependencies + if (0 === mb_strpos($dependency, 'spomky-labs/')) { + continue; + } + + $message = sprintf('Dependency "%s" from package "%s" is not defined in root composer.json', $dependency, $package); + static::assertArrayHasKey($dependency, $rootDependencies, $message); + + $message = sprintf('Dependency "%s:%s" from package "%s" requires a different version in the root composer.json', $dependency, $version, $package); + static::assertEquals($version, $rootDependencies[$dependency], $message); + + $usedDependencies[] = $dependency; + } + } + + $unusedDependencies = array_diff(array_keys($rootDependencies), array_unique($usedDependencies)); + $message = sprintf('Dependencies declared in root composer.json, which are not declared in any sub-package: %s', implode($unusedDependencies)); + static::assertCount(0, $unusedDependencies, $message); + } + + /** + * @test + */ + public function rootReplacesSubPackages(): void + { + $rootReplaces = $this->getComposerReplaces(__DIR__.'/../composer.json'); + foreach ($this->listSubPackages() as $package) { + $packageName = $this->getComposerPackageName(self::SRC_DIR.'/'.$package.'/composer.json'); + $message = sprintf('Root composer.json must replace the sub-packages "%s"', $packageName); + static::assertArrayHasKey($packageName, $rootReplaces, $message); + } + } + + private function listSubPackages(): iterable + { + $excluded = ['demo']; + foreach (new DirectoryIterator(self::SRC_DIR) as $dirInfo) { + if ($dirInfo->isDir() && !$dirInfo->isDot() && !in_array($dirInfo->getFilename(), $excluded, true)) { + yield $dirInfo->getFilename(); + } + } + } + + private function getComposerDependencies(string $composerFilePath): array + { + return $this->parseComposerFile($composerFilePath)['require']; + } + + private function getComposerPackageName(string $composerFilePath): string + { + return $this->parseComposerFile($composerFilePath)['name']; + } + + private function getComposerReplaces(string $composerFilePath): array + { + return $this->parseComposerFile($composerFilePath)['replace']; + } + + private function parseComposerFile(string $composerFilePath): array + { + return json_decode(file_get_contents($composerFilePath), true); + } +} diff --git a/tests/Library/Functional/Payload/AES128GCMTest.php b/tests/Library/Functional/Payload/AES128GCMTest.php new file mode 100644 index 0000000..11b7a44 --- /dev/null +++ b/tests/Library/Functional/Payload/AES128GCMTest.php @@ -0,0 +1,364 @@ +customPadding(3994); + } + + /** + * @test + */ + public function paddingLengthToLow(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('Invalid padding size'); + + AES128GCM::create()->customPadding(-1); + } + + /** + * @test + */ + public function missingUserAgentPublicKey(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('The user-agent public key is missing'); + + $request = new Request('POST', 'https://foo.bar'); + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aes128gcm']) + ; + + AES128GCM::create()->encode('', $request, $subscription); + } + + /** + * @test + */ + public function missingUserAgentAuthenticationToken(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('The user-agent authentication token is missing'); + + $request = new Request('POST', 'https://foo.bar'); + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aes128gcm']) + ; + $subscription->setKey('p256dh', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'); + + AES128GCM::create()->encode('', $request, $subscription); + } + + /** + * @test + * + * @see https://tests.peter.sh/push-encryption-verifier/ + */ + public function decryptPayloadCorrectly(): void + { + $body = Base64Url::decode('DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN'); + $userAgentPrivateKey = Base64Url::decode('q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94'); + $userAgentPublicKey = Base64Url::decode('BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'); + $userAgentAuthToken = Base64Url::decode('BTBZMqHH6r4Tts7J_aSIgg'); + $expectedPayload = 'When I grow up, I want to be a watermelon'; + + $request = new Request('POST', 'https://foo.bar', [], $body); + + $payload = $this->decryptRequest($request, $userAgentAuthToken, $userAgentPublicKey, $userAgentPrivateKey, true); + static::assertEquals($expectedPayload, $payload); + } + + /** + * @test + * @dataProvider dataEncryptPayload + * + * @see https://tests.peter.sh/push-encryption-verifier/ + */ + public function encryptPayload(string $userAgentPrivateKey, string $userAgentPublicKey, string $userAgentAuthToken, string $payload, string $padding, CacheItemPoolInterface $cache): void + { + $logger = new TestLogger(); + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aes128gcm']) + ; + $subscription->setKey('p256dh', $userAgentPublicKey); + $subscription->setKey('auth', $userAgentAuthToken); + + $encoder = AES128GCM::create(); + + switch ($padding) { + case 'noPadding': + $encoder->noPadding(); + break; + case 'recommendedPadding': + $encoder->recommendedPadding(); + break; + case 'maxPadding': + $encoder->maxPadding(); + break; + case 'customPadding': + $encoder->customPadding(1024); + break; + default: + break; + } + $encoder->setCache($cache); + static::assertEquals('aes128gcm', $encoder->name()); + + $request = new Request('POST', 'https://foo.bar'); + $encoder + ->setLogger($logger) + ->encode($payload, $request, $subscription) + ; + + $decryptedPayload = $this->decryptRequest( + $request, + Base64Url::decode($userAgentAuthToken), + Base64Url::decode($userAgentPublicKey), + Base64Url::decode($userAgentPrivateKey), + true + ); + + static::assertEquals($payload, $decryptedPayload); + + static::assertGreaterThanOrEqual(13, count($logger->records)); + foreach ($logger->records as $record) { + static::assertEquals('debug', $record['level']); + } + } + + /** + * @test + */ + public function largePayloadForbidden(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('The size of payload must not be greater than 4096 bytes.'); + + $userAgentPrivateKey = 'q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94'; + $userAgentPublicKey = 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'; + $userAgentAuthToken = 'BTBZMqHH6r4Tts7J_aSIgg'; + + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aes128gcm']) + ; + $subscription->setKey('p256dh', $userAgentPublicKey); + $subscription->setKey('auth', $userAgentAuthToken); + + $encoder = AES128GCM::create(); + + static::assertEquals('aes128gcm', $encoder->name()); + $payload = str_pad('', 3994, '0'); + + $request = new Request('POST', 'https://foo.bar'); + $encoder->encode($payload, $request, $subscription); + + $decryptedPayload = $this->decryptRequest( + $request, + Base64Url::decode($userAgentAuthToken), + Base64Url::decode($userAgentPublicKey), + Base64Url::decode($userAgentPrivateKey), + true + ); + + static::assertEquals($payload, $decryptedPayload); + } + + /** + * @return array> + */ + public function dataEncryptPayload(): array + { + $withoutCache = $this->getMissingCache(); + $withCache = $this->getExistingCache(); + $uaPrivateKey = 'q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94'; + $uaPublicKey = 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'; + $uaAuthSecret = 'BTBZMqHH6r4Tts7J_aSIgg'; + $payload = 'When I grow up, I want to be a watermelon'; + + return [ + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'noPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + str_pad('', 3993, '1'), + 'noPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'recommendedPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'maxPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'customPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'noPadding', + $withCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'recommendedPadding', + $withCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'maxPadding', + $withCache, + ], + ]; + } + + private function decryptRequest(RequestInterface $request, string $authSecret, string $receiverPublicKey, string $receiverPrivateKey, bool $inverted = false): string + { + $requestBody = $request->getBody(); + $requestBody->rewind(); + + $ciphertext = $requestBody->getContents(); + + // Salt + $salt = mb_substr($ciphertext, 0, 16, '8bit'); + static::assertEquals(mb_strlen($salt, '8bit'), 16); + + // Record size + $rs = mb_substr($ciphertext, 16, 4, '8bit'); + $rs = unpack('N', $rs)[1]; + static::assertEquals(4096, $rs); + + // idlen + $idlen = ord(mb_substr($ciphertext, 20, 1, '8bit')); + + //keyid + $keyid = mb_substr($ciphertext, 21, $idlen, '8bit'); + + // IKM + $keyInfo = 'WebPush: info'.chr(0).($inverted ? $receiverPublicKey.$keyid : $keyid.$receiverPublicKey); + $ikm = Utils::computeIKM($keyInfo, $authSecret, $keyid, $receiverPrivateKey, $receiverPublicKey); + + // We remove the header + $ciphertext = mb_substr($ciphertext, 16 + 4 + 1 + $idlen, null, '8bit'); + + // We compute the PRK + $prk = hash_hmac('sha256', $ikm, $salt, true); + + $cekInfo = 'Content-Encoding: aes128gcm'.chr(0); + $cek = mb_substr(hash_hmac('sha256', $cekInfo.chr(1), $prk, true), 0, 16, '8bit'); + + $nonceInfo = 'Content-Encoding: nonce'.chr(0); + $nonce = mb_substr(hash_hmac('sha256', $nonceInfo.chr(1), $prk, true), 0, 12, '8bit'); + + $C = mb_substr($ciphertext, 0, -16, '8bit'); + $T = mb_substr($ciphertext, -16, null, '8bit'); + + $rawData = openssl_decrypt($C, 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $T); + + $matches = []; + $r = preg_match('/^(.*)(\x02\x00*)$/', $rawData, $matches); + if (1 !== $r || 3 !== count($matches)) { + throw new InvalidArgumentException('Invalid data'); + } + + return $matches[1]; + } + + private function getMissingCache(): CacheItemPoolInterface + { + return new NullAdapter(); + } + + private function getExistingCache(): CacheItemPoolInterface + { + $cache = new ArrayAdapter(); + $item = $cache->getItem('WEB_PUSH_PAYLOAD_ENCRYPTION'); + $item->set( + new ServerKey( + Base64Url::decode('BNuH4FkvKM50iG9sNLmJxSJL-H5B7KzxdpVOMp8OCmJZIaiZhXWFEolBD3xAXpJbjqMuny5jznfDnjYKueWngnM'), + Base64Url::decode('Bw10H72jYRnlGZQytw8ruC9uJzqkWJqlOyFEEqQqYZ0') + ) + ); + $cache->save($item); + + return $cache; + } +} diff --git a/tests/Library/Functional/Payload/AESGCMTest.php b/tests/Library/Functional/Payload/AESGCMTest.php new file mode 100644 index 0000000..97215b0 --- /dev/null +++ b/tests/Library/Functional/Payload/AESGCMTest.php @@ -0,0 +1,306 @@ +customPadding(4079); + } + + /** + * @test + */ + public function paddingLengthToLow(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('Invalid padding size'); + + AESGCM::create()->customPadding(-1); + } + + /** + * @test + */ + public function missingUserAgentPublicKey(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('The user-agent public key is missing'); + + $request = new Request('POST', 'https://foo.bar'); + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aesgcm']) + ; + + AESGCM::create()->encode('', $request, $subscription); + } + + /** + * @test + */ + public function missingUserAgentAuthenticationToken(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('The user-agent authentication token is missing'); + + $request = new Request('POST', 'https://foo.bar'); + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aesgcm']) + ; + $subscription->setKey('p256dh', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'); + + AESGCM::create()->encode('', $request, $subscription); + } + + /** + * @test + * @dataProvider dataEncryptPayload + * + * @see https://tests.peter.sh/push-encryption-verifier/ + */ + public function encryptPayload(string $userAgentPrivateKey, string $userAgentPublicKey, string $userAgentAuthToken, string $payload, string $padding, CacheItemPoolInterface $cache): void + { + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aesgcm']) + ; + $subscription->setKey('p256dh', $userAgentPublicKey); + $subscription->setKey('auth', $userAgentAuthToken); + + $encoder = AESGCM::create(); + + switch ($padding) { + case 'noPadding': + $encoder->noPadding(); + break; + case 'recommendedPadding': + $encoder->recommendedPadding(); + break; + case 'maxPadding': + $encoder->maxPadding(); + break; + case 'customPadding': + $encoder->customPadding(1024); + break; + default: + break; + } + + $encoder->setCache($cache); + + static::assertEquals('aesgcm', $encoder->name()); + + $request = new Request('POST', 'https://foo.bar'); + $request = $encoder->encode($payload, $request, $subscription); + + $decryptedPayload = $this->decryptRequest( + $request, + Base64Url::decode($userAgentAuthToken), + Base64Url::decode($userAgentPublicKey), + Base64Url::decode($userAgentPrivateKey), + true + ); + + static::assertEquals($payload, $decryptedPayload); + } + + /** + * @test + */ + public function largePayloadForbidden(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('The size of payload must not be greater than 4096 bytes.'); + + $request = new Request('POST', 'https://foo.bar'); + + $subscription = Subscription::create('https://foo.bar'); + $subscription->setKey('p256dh', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'); + $subscription->setKey('auth', 'BTBZMqHH6r4Tts7J_aSIgg'); + + $payload = str_pad('', 4079, '0'); + + AESGCM::create() + ->encode($payload, $request, $subscription) + ; + } + + /** + * @return array> + */ + public function dataEncryptPayload(): array + { + $withoutCache = $this->getMissingCache(); + $withCache = $this->getExistingCache(); + $uaPrivateKey = 'q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94'; + $uaPublicKey = 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'; + $uaAuthSecret = 'BTBZMqHH6r4Tts7J_aSIgg'; + $payload = 'When I grow up, I want to be a watermelon'; + + return [ + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'noPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + str_pad('', 4078, '1'), + 'noPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'recommendedPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'maxPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'customPadding', + $withoutCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'noPadding', + $withCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'recommendedPadding', + $withCache, + ], + [ + $uaPrivateKey, + $uaPublicKey, + $uaAuthSecret, + $payload, + 'maxPadding', + $withCache, + ], + ]; + } + + private function decryptRequest(RequestInterface $request, string $authSecret, string $receiverPublicKey, string $receiverPrivateKey, bool $inverted = false): string + { + $requestBody = $request->getBody(); + $requestBody->rewind(); + + $ciphertext = $requestBody->getContents(); + $salt = Base64Url::decode(mb_substr($request->getHeaderLine('encryption'), 5)); + $keyid = Base64Url::decode(mb_substr($request->getHeaderLine('crypto-key'), 3)); + + $context = sprintf('%s%s%s%s', + "P-256\0\0A", + $inverted ? $receiverPublicKey : $keyid, + "\0A", + $inverted ? $keyid : $receiverPublicKey + ); + + // IKM + $keyInfo = 'Content-Encoding: auth'.chr(0); + $ikm = Utils::computeIKM($keyInfo, $authSecret, $keyid, $receiverPrivateKey, $receiverPublicKey); + + // We compute the PRK + $prk = hash_hmac('sha256', $ikm, $salt, true); + + $cekInfo = 'Content-Encoding: aesgcm'.chr(0).$context; + $cek = mb_substr(hash_hmac('sha256', $cekInfo.chr(1), $prk, true), 0, 16, '8bit'); + + $nonceInfo = 'Content-Encoding: nonce'.chr(0).$context; + $nonce = mb_substr(hash_hmac('sha256', $nonceInfo.chr(1), $prk, true), 0, 12, '8bit'); + + $C = mb_substr($ciphertext, 0, -16, '8bit'); + $T = mb_substr($ciphertext, -16, null, '8bit'); + + $rawData = openssl_decrypt($C, 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $T); + $padding = mb_substr($rawData, 0, 2, '8bit'); + $paddingLength = unpack('n', $padding)[1]; + + return mb_substr($rawData, 2 + $paddingLength, null, '8bit'); + } + + private function getMissingCache(): CacheItemPoolInterface + { + return new NullAdapter(); + } + + private function getExistingCache(): CacheItemPoolInterface + { + $cache = new ArrayAdapter(); + $item = $cache->getItem('WEB_PUSH_PAYLOAD_ENCRYPTION'); + $item->set( + new ServerKey( + Base64Url::decode('BNuH4FkvKM50iG9sNLmJxSJL-H5B7KzxdpVOMp8OCmJZIaiZhXWFEolBD3xAXpJbjqMuny5jznfDnjYKueWngnM'), + Base64Url::decode('Bw10H72jYRnlGZQytw8ruC9uJzqkWJqlOyFEEqQqYZ0') + ) + ); + $cache->save($item); + + return $cache; + } +} diff --git a/tests/Library/Functional/VAPID/HeaderTest.php b/tests/Library/Functional/VAPID/HeaderTest.php new file mode 100644 index 0000000..aa0ee77 --- /dev/null +++ b/tests/Library/Functional/VAPID/HeaderTest.php @@ -0,0 +1,36 @@ +getToken()); + static::assertEquals('key', $header->getKey()); + } +} diff --git a/tests/Library/Functional/VAPID/LcobucciProviderTest.php b/tests/Library/Functional/VAPID/LcobucciProviderTest.php new file mode 100644 index 0000000..14ef023 --- /dev/null +++ b/tests/Library/Functional/VAPID/LcobucciProviderTest.php @@ -0,0 +1,128 @@ +setLogger($logger) + ->computeHeader([ + 'aud' => 'audience', + 'sub' => 'subject', + 'exp' => $expiresAt->getTimestamp(), + ]) + ; + + static::assertStringStartsWith('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJhdWRpZW5jZSIsInN1YiI6InN1YmplY3QiLCJleHAiOjE1ODAyNTM3NTd9.', $header->getToken()); + static::assertEquals($publicKey, $header->getKey()); + } + + /** + * @return array> + */ + public function dataComputeHeader(): array + { + return [ + [ + 'publicKey' => 'BDCgQkzSHClEg4otdckrN-duog2fAIk6O07uijwKr-w-4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM', + 'privateKey' => '870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE', + ], + [ + 'publicKey' => 'BNFEvAnv7SfVGz42xFvdcu-z-W_3FVm_yRSGbEVtxVRRXqCBYJtvngQ8ZN-9bzzamxLjpbw7vuHcHTT2H98LwLM', + 'privateKey' => 'TcP5-SlbNbThgntDB7TQHXLslhaxav8Qqdd_Ar7VuNo', + ], + ]; + } + + /** + * @return array> + */ + public function dataInvalidKey(): array + { + return [ + [ + 'publicKey' => '', + 'privateKey' => str_pad('', 33, "\1"), + 'expectedMessage' => 'Invalid private key size', + ], + [ + 'publicKey' => '', + 'privateKey' => str_pad('', 31, "\1"), + 'expectedMessage' => 'Invalid private key size', + ], + [ + 'publicKey' => str_pad('', 66, "\1"), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key size', + ], + [ + 'publicKey' => str_pad('', 64, "\1"), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key size', + ], + [ + 'publicKey' => str_pad('', 65, "\1"), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key', + ], + [ + 'publicKey' => str_pad("\3", 65, "\1", STR_PAD_RIGHT), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key', + ], + [ + 'publicKey' => str_pad("\5", 65, "\1", STR_PAD_RIGHT), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key', + ], + ]; + } +} diff --git a/tests/Library/Functional/VAPID/VAPIDTest.php b/tests/Library/Functional/VAPID/VAPIDTest.php new file mode 100644 index 0000000..d1d1a92 --- /dev/null +++ b/tests/Library/Functional/VAPID/VAPIDTest.php @@ -0,0 +1,78 @@ +setLogger($logger) + ->setTokenExpirationTime('now +2hours') + ->process($request, $notification, $subscription) + ; + + $vapidHeader = $request->getHeaderLine('authorization'); + static::assertStringStartsWith('vapid t=', $vapidHeader); + $tokenPayload = mb_substr($vapidHeader, 45); + $position = mb_strpos($tokenPayload, '.'); + $tokenPayload = mb_substr($tokenPayload, 0, false === $position ? null : $position); + $tokenPayload = Base64Url::decode($tokenPayload); + $claims = json_decode($tokenPayload, true); + + static::assertArrayHasKey('aud', $claims); + static::assertArrayHasKey('sub', $claims); + static::assertArrayHasKey('exp', $claims); + static::assertEquals('https://foo.bar', $claims['aud']); + static::assertEquals('subject', $claims['sub']); + static::assertGreaterThanOrEqual(time(), $claims['exp']); + + static::assertCount(3, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Processing with VAPID header', $logger->records[0]['message']); + static::assertEquals('debug', $logger->records[1]['level']); + static::assertEquals('Trying to get the header from the cache', $logger->records[1]['message']); + static::assertEquals('debug', $logger->records[2]['level']); + static::assertEquals('Header from cache', $logger->records[2]['message']); + } +} diff --git a/tests/Library/Functional/VAPID/WebTokenProviderTest.php b/tests/Library/Functional/VAPID/WebTokenProviderTest.php new file mode 100644 index 0000000..8d5cba8 --- /dev/null +++ b/tests/Library/Functional/VAPID/WebTokenProviderTest.php @@ -0,0 +1,128 @@ +setLogger($logger) + ->computeHeader([ + 'aud' => 'audience', + 'sub' => 'subject', + 'exp' => $expiresAt->getTimestamp(), + ]) + ; + + static::assertStringStartsWith('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJhdWRpZW5jZSIsInN1YiI6InN1YmplY3QiLCJleHAiOjE1ODAyNTM3NTd9.', $header->getToken()); + static::assertEquals($publicKey, $header->getKey()); + } + + /** + * @return array> + */ + public function dataComputeHeader(): array + { + return [ + [ + 'publicKey' => 'BDCgQkzSHClEg4otdckrN-duog2fAIk6O07uijwKr-w-4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM', + 'privateKey' => '870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE', + ], + [ + 'publicKey' => 'BNFEvAnv7SfVGz42xFvdcu-z-W_3FVm_yRSGbEVtxVRRXqCBYJtvngQ8ZN-9bzzamxLjpbw7vuHcHTT2H98LwLM', + 'privateKey' => 'TcP5-SlbNbThgntDB7TQHXLslhaxav8Qqdd_Ar7VuNo', + ], + ]; + } + + /** + * @return array> + */ + public function dataInvalidKey(): array + { + return [ + [ + 'publicKey' => '', + 'privateKey' => str_pad('', 33, "\1"), + 'expectedMessage' => 'Invalid private key size', + ], + [ + 'publicKey' => '', + 'privateKey' => str_pad('', 31, "\1"), + 'expectedMessage' => 'Invalid private key size', + ], + [ + 'publicKey' => str_pad('', 66, "\1"), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key size', + ], + [ + 'publicKey' => str_pad('', 64, "\1"), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key size', + ], + [ + 'publicKey' => str_pad('', 65, "\1"), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key', + ], + [ + 'publicKey' => str_pad("\3", 65, "\1", STR_PAD_RIGHT), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key', + ], + [ + 'publicKey' => str_pad("\5", 65, "\1", STR_PAD_RIGHT), + 'privateKey' => str_pad('', 32, "\1"), + 'expectedMessage' => 'Invalid public key', + ], + ]; + } +} diff --git a/tests/Library/Functional/WebPushTest.php b/tests/Library/Functional/WebPushTest.php new file mode 100644 index 0000000..4d833e6 --- /dev/null +++ b/tests/Library/Functional/WebPushTest.php @@ -0,0 +1,158 @@ +withContentEncodings(['aesgcm']) + ; + $subscription->setKey('auth', 'wSfP1pfACMwFesCEfJx4-w'); + $subscription->setKey('p256dh', 'BIlDpD05YLrVPXfANOKOCNSlTvjpb5vdFo-1e0jNcbGlFrP49LyOjYyIIAZIVCDAHEcX-135b859bdsse-PgosU'); + + $notification = Notification::create() + ->sync() + ->highUrgency() + ->withTopic('topic') + ->withPayload('Hello World') + ->withTTL(3600) + ; + + $client = new Client(); + $client->addResponse(new Response()); + $service = $this->getService($client); + + $report = $service->send($notification, $subscription); + + $request = $report->getRequest(); + static::assertTrue($request->hasHeader('ttl')); + static::assertTrue($request->hasHeader('topic')); + static::assertTrue($request->hasHeader('urgency')); + static::assertTrue($request->hasHeader('content-type')); + static::assertTrue($request->hasHeader('content-encoding')); + static::assertTrue($request->hasHeader('crypto-key')); + static::assertTrue($request->hasHeader('encryption')); + static::assertTrue($request->hasHeader('content-length')); + static::assertTrue($request->hasHeader('authorization')); + static::assertEquals(['3600'], $request->getHeader('ttl')); + static::assertEquals(['topic'], $request->getHeader('topic')); + static::assertEquals(['high'], $request->getHeader('urgency')); + static::assertEquals(['application/octet-stream'], $request->getHeader('content-type')); + static::assertEquals(['aesgcm'], $request->getHeader('content-encoding')); + static::assertEquals(['4096'], $request->getHeader('content-length')); + static::assertStringStartsWith('dh=', $request->getHeaderLine('crypto-key')); + static::assertStringStartsWith('salt=', $request->getHeaderLine('encryption')); + static::assertStringStartsWith('vapid t=', $request->getHeaderLine('authorization')); + } + + /** + * @test + */ + public function aNotificationCannotBeSent(): void + { + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aes128gcm']) + ; + $subscription->setKey('auth', 'wSfP1pfACMwFesCEfJx4-w'); + $subscription->setKey('p256dh', 'BIlDpD05YLrVPXfANOKOCNSlTvjpb5vdFo-1e0jNcbGlFrP49LyOjYyIIAZIVCDAHEcX-135b859bdsse-PgosU'); + + $notification = Notification::create() + ->sync() + ->highUrgency() + ->withTopic('topic') + ->withPayload('Hello World') + ->withTTL(3600) + ; + + $client = new Client(); + $client->addResponse(new Response()); + $service = $this->getService($client); + + $report = $service->send($notification, $subscription); + + $request = $report->getRequest(); + static::assertTrue($request->hasHeader('ttl')); + static::assertTrue($request->hasHeader('topic')); + static::assertTrue($request->hasHeader('urgency')); + static::assertTrue($request->hasHeader('content-type')); + static::assertTrue($request->hasHeader('content-encoding')); + static::assertFalse($request->hasHeader('crypto-key')); + static::assertFalse($request->hasHeader('encryption')); + static::assertTrue($request->hasHeader('content-length')); + static::assertTrue($request->hasHeader('authorization')); + static::assertEquals(['3600'], $request->getHeader('ttl')); + static::assertEquals(['topic'], $request->getHeader('topic')); + static::assertEquals(['high'], $request->getHeader('urgency')); + static::assertEquals(['application/octet-stream'], $request->getHeader('content-type')); + static::assertEquals(['aes128gcm'], $request->getHeader('content-encoding')); + static::assertEquals(['4095'], $request->getHeader('content-length')); + static::assertStringStartsWith('vapid t=', $request->getHeaderLine('authorization')); + } + + private function getService(ClientInterface $client): WebPush + { + $extensionManager = ExtensionManager::create() + ->add(TTLExtension::create()) + ->add(UrgencyExtension::create()) + ->add(TopicExtension::create()) + ->add(PreferAsyncExtension::create()) + ->add( + PayloadExtension::create() + ->addContentEncoding(AESGCM::create()->maxPadding()) + ->addContentEncoding(AES128GCM::create()->maxPadding()) + ) + ->add(VAPIDExtension::create( + 'http://localhost:8000', + WebTokenProvider::create( + 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ', + 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU' + ) + )) + ; + + $requestFactory = new Psr17Factory(); + + return new WebPush($client, $requestFactory, $extensionManager); + } +} diff --git a/tests/Library/Unit/ActionTest.php b/tests/Library/Unit/ActionTest.php new file mode 100644 index 0000000..d242e1c --- /dev/null +++ b/tests/Library/Unit/ActionTest.php @@ -0,0 +1,60 @@ +getAction()); + static::assertEquals('---TITLE---', $action->getTitle()); + static::assertNull($action->getIcon()); + + $expectedJson = '{"action":"ACTION","title":"---TITLE---"}'; + static::assertEquals($expectedJson, $action->toString()); + static::assertEquals($expectedJson, json_encode($action, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createActionWithIcon(): void + { + $action = Action::create('ACTION', '---TITLE---') + ->withIcon('https://icon.ico') + ; + + static::assertEquals('ACTION', $action->getAction()); + static::assertEquals('---TITLE---', $action->getTitle()); + static::assertEquals('https://icon.ico', $action->getIcon()); + + $expectedJson = '{"action":"ACTION","icon":"https://icon.ico","title":"---TITLE---"}'; + static::assertEquals($expectedJson, $action->toString()); + static::assertEquals($expectedJson, json_encode($action, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } +} diff --git a/tests/Library/Unit/Base64UrlTest.php b/tests/Library/Unit/Base64UrlTest.php new file mode 100644 index 0000000..0828bf6 --- /dev/null +++ b/tests/Library/Unit/Base64UrlTest.php @@ -0,0 +1,124 @@ +> + */ + public function getTestVectors(): array + { + return [ + [ + '000000', 'MDAwMDAw', + ], + [ + "\0\0\0\0", 'AAAAAA', + ], + [ + "\xff", '_w', + ], + [ + "\xff\xff", '__8', + ], + [ + "\xff\xff\xff", '____', + ], + [ + "\xff\xff\xff\xff", '_____w', + ], + [ + "\xfb", '-w', + ], + [ + '', '', + ], + [ + 'f', 'Zg', + ], + [ + 'fo', 'Zm8', + ], + ]; + } + + /** + * @dataProvider getTestBadVectors + * + * @test + */ + public function badInput(string $input): void + { + $decoded = Base64Url::decode($input); + static::assertEquals("\00", $decoded); + } + + /** + * @return array> + */ + public function getTestBadVectors(): array + { + return [ + [ + ' AA', + ], + [ + "\tAA", + ], + [ + "\rAA", + ], + [ + "\nAA", + ], + ]; + } + + /** + * @return array> + */ + public function getTestNonsenseVectors(): array + { + return [ + [ + 'cxr0fdsezrewklerewxoz423ocfsa3bw432yjydsa9lhdsalw', + ], + ]; + } +} diff --git a/tests/Library/Unit/ExtensionManagerTest.php b/tests/Library/Unit/ExtensionManagerTest.php new file mode 100644 index 0000000..b49833c --- /dev/null +++ b/tests/Library/Unit/ExtensionManagerTest.php @@ -0,0 +1,59 @@ +setLogger($logger) + ->add(new TTLExtension()) + ->add(new PreferAsyncExtension()) + ->process($request, $notification, $subscription) + ; + + static::assertCount(4, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Extension added', $logger->records[0]['message']); + static::assertEquals('debug', $logger->records[1]['level']); + static::assertEquals('Extension added', $logger->records[1]['message']); + static::assertEquals('debug', $logger->records[2]['level']); + static::assertEquals('Processing the request', $logger->records[2]['message']); + static::assertEquals('debug', $logger->records[3]['level']); + static::assertEquals('Processing done', $logger->records[3]['message']); + } +} diff --git a/tests/Library/Unit/MessageTest.php b/tests/Library/Unit/MessageTest.php new file mode 100644 index 0000000..f6f035d --- /dev/null +++ b/tests/Library/Unit/MessageTest.php @@ -0,0 +1,228 @@ +getBody()); + static::assertNull($message->getTimestamp()); + static::assertNull($message->getTag()); + static::assertNull($message->getData()); + static::assertNull($message->getBadge()); + static::assertNull($message->getIcon()); + static::assertNull($message->getImage()); + static::assertNull($message->getLang()); + static::assertEquals([], $message->getActions()); + static::assertNull($message->getVibrate()); + static::assertNull($message->getDir()); + static::assertNull($message->isSilent()); + static::assertNull($message->getRenotify()); + static::assertNull($message->isInteractionRequired()); + + $expectedJson = '{"body":"BODY"}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createMessageWithOptions(): void + { + $action = Action::create('A', 'T'); + $message = Message::create('BODY') + ->withTag('TAG') + ->withTimestamp(1604141464) + ->withLang('en-GB') + ->withImage('https://image.svg') + ->withBadge('BADGE') + ->withIcon('https://icon.ico') + ->withData(['foo' => 'BAR', 1, 2, 3]) + ->addAction($action) + ->vibrate(300, 10, 200, 10, 500) + ; + + static::assertEquals('BODY', $message->getBody()); + static::assertEquals(1604141464, $message->getTimestamp()); + static::assertEquals('TAG', $message->getTag()); + static::assertEquals(['foo' => 'BAR', 1, 2, 3], $message->getData()); + static::assertEquals('BADGE', $message->getBadge()); + static::assertEquals('https://icon.ico', $message->getIcon()); + static::assertEquals('https://image.svg', $message->getImage()); + static::assertEquals('en-GB', $message->getLang()); + static::assertEquals([$action], $message->getActions()); + static::assertEquals([300, 10, 200, 10, 500], $message->getVibrate()); + static::assertNull($message->getDir()); + static::assertNull($message->isSilent()); + static::assertNull($message->getRenotify()); + static::assertNull($message->isInteractionRequired()); + + $expectedJson = '{"actions":[{"action":"A","title":"T"}],"badge":"BADGE","body":"BODY","data":{"foo":"BAR","0":1,"1":2,"2":3},"icon":"https://icon.ico","image":"https://image.svg","lang":"en-GB","tag":"TAG","timestamp":1604141464,"vibrate":[300,10,200,10,500]}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createMessageWithAutoDirection(): void + { + $message = Message::create('BODY') + ->auto() + ; + static::assertEquals('auto', $message->getDir()); + + $expectedJson = '{"body":"BODY","dir":"auto"}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createMessageWithLTRDirection(): void + { + $message = Message::create('BODY') + ->ltr() + ; + static::assertEquals('ltr', $message->getDir()); + + $expectedJson = '{"body":"BODY","dir":"ltr"}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createMessageWithRTLDirection(): void + { + $message = Message::create('BODY') + ->rtl() + ; + static::assertEquals('rtl', $message->getDir()); + + $expectedJson = '{"body":"BODY","dir":"rtl"}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createMessageWithInteraction(): void + { + $message = Message::create('BODY') + ->interactionRequired() + ; + static::assertTrue($message->isInteractionRequired()); + + $expectedJson = '{"body":"BODY","requireInteraction":true}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createMessageWithoutInteraction(): void + { + $message = Message::create('BODY') + ->noInteraction() + ; + static::assertFalse($message->isInteractionRequired()); + + $expectedJson = '{"body":"BODY","requireInteraction":false}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createSilentMessage(): void + { + $message = Message::create('BODY') + ->mute() + ; + static::assertTrue($message->isSilent()); + + $expectedJson = '{"body":"BODY","silent":true}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createNonSilentMessage(): void + { + $message = Message::create('BODY') + ->unmute() + ; + static::assertFalse($message->isSilent()); + + $expectedJson = '{"body":"BODY","silent":false}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createWithRenotification(): void + { + $message = Message::create('BODY') + ->renotify() + ; + static::assertTrue($message->getRenotify()); + + $expectedJson = '{"body":"BODY","renotify":true}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * @test + */ + public function createWithoutRenotification(): void + { + $message = Message::create('BODY') + ->doNotRenotify() + ; + static::assertFalse($message->getRenotify()); + + $expectedJson = '{"body":"BODY","renotify":false}'; + static::assertEquals($expectedJson, $message->toString()); + static::assertEquals($expectedJson, json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } +} diff --git a/tests/Library/Unit/NotificationTest.php b/tests/Library/Unit/NotificationTest.php new file mode 100644 index 0000000..5d1d4d1 --- /dev/null +++ b/tests/Library/Unit/NotificationTest.php @@ -0,0 +1,179 @@ +veryLowUrgency() + ->lowUrgency() + ->normalUrgency() + ->highUrgency() + ->withUrgency(Notification::URGENCY_HIGH) + ->withTTL(0) + ->withPayload('payload') + ->withTopic('topic') + ->sync() + ; + + static::assertEquals(Notification::URGENCY_HIGH, $subscription->getUrgency()); + static::assertEquals(0, $subscription->getTTL()); + static::assertEquals('payload', $subscription->getPayload()); + static::assertEquals('topic', $subscription->getTopic()); + static::assertFalse($subscription->isAsync()); + } + + /** + * @test + */ + public function createNotificationWithTTL(): void + { + $subscription = Notification::create() + ->withTTL(3600) + ; + + static::assertEquals(Notification::URGENCY_NORMAL, $subscription->getUrgency()); + static::assertEquals(3600, $subscription->getTTL()); + static::assertEquals(null, $subscription->getPayload()); + static::assertEquals(null, $subscription->getTopic()); + } + + /** + * @test + */ + public function createAsyncNotification(): void + { + $subscription = Notification::create() + ->async() + ; + + static::assertTrue($subscription->isAsync()); + } + + /** + * @test + */ + public function defaultNotificationIsSync(): void + { + $subscription = Notification::create(); + + static::assertFalse($subscription->isAsync()); + } + + /** + * @test + * @dataProvider dataUrgencies + */ + public function urgencies(string $urgency): void + { + $subscription = Notification::create() + ->withUrgency($urgency) + ; + + static::assertEquals($urgency, $subscription->getUrgency()); + } + + /** + * @test + */ + public function invalidUrgency(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid urgency parameter'); + + Notification::create() + ->withUrgency('urgency') + ; + } + + /** + * @test + */ + public function invalidTopic(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid topic'); + + Notification::create() + ->withTopic('') + ; + } + + /** + * @test + */ + public function invalidTTL(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid TTL'); + + Notification::create() + ->withTTL(-1) + ; + } + + /** + * @test + */ + public function createNotificationWithMetadata(): void + { + $notification = Notification::create() + ->add('foo', 'BAR') + ; + + static::assertFalse($notification->has('nope')); + static::assertTrue($notification->has('foo')); + static::assertEquals('BAR', $notification->get('foo')); + static::assertEquals(['foo' => 'BAR'], $notification->getMetadata()); + } + + /** + * @test + */ + public function missingMetadata(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('Missing metadata'); + $notification = Notification::create(); + + $notification->get('fff'); + } + + /** + * @return array> + */ + public function dataUrgencies(): array + { + return [ + [Notification::URGENCY_VERY_LOW], + [Notification::URGENCY_LOW], + [Notification::URGENCY_NORMAL], + [Notification::URGENCY_HIGH], + ]; + } +} diff --git a/tests/Library/Unit/Payload/ServerKeyTest.php b/tests/Library/Unit/Payload/ServerKeyTest.php new file mode 100644 index 0000000..e8fa97a --- /dev/null +++ b/tests/Library/Unit/Payload/ServerKeyTest.php @@ -0,0 +1,50 @@ +setLogger($logger) + ->process($request, $notification, $subscription) + ; + + static::assertEquals('0', $request->getHeaderLine('content-length')); + + static::assertCount(2, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Processing with payload', $logger->records[0]['message']); + static::assertEquals('debug', $logger->records[1]['level']); + static::assertEquals('No payload', $logger->records[1]['message']); + } + + /** + * @test + */ + public function canProcessWithPayload(): void + { + $logger = new TestLogger(); + $notification = Notification::create() + ->withPayload('Payload') + ; + $subscription = Subscription::create('https://foo.bar'); + $subscription->setKey('p256dh', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'); + $subscription->setKey('auth', 'BTBZMqHH6r4Tts7J_aSIgg'); + + $request = new Request('POST', 'https://foo.bar'); + + $request = PayloadExtension::create() + ->setLogger($logger) + ->addContentEncoding(AESGCM::create()) + ->process($request, $notification, $subscription) + ; + + static::assertEquals('application/octet-stream', $request->getHeaderLine('content-type')); + static::assertEquals('aesgcm', $request->getHeaderLine('content-encoding')); + + static::assertCount(2, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Processing with payload', $logger->records[0]['message']); + static::assertEquals('debug', $logger->records[1]['level']); + static::assertEquals('Encoder found: aesgcm. Processing with the encoder.', $logger->records[1]['message']); + } + + /** + * @test + */ + public function unsupportedContentEncoding(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('No content encoding found. Supported content encodings for the subscription are: aesgcm'); + + $request = new Request('POST', 'https://foo.bar'); + + $notification = Notification::create() + ->withPayload('Payload') + ; + $subscription = Subscription::create('https://foo.bar'); + $subscription->setKey('p256dh', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'); + $subscription->setKey('auth', 'BTBZMqHH6r4Tts7J_aSIgg'); + + PayloadExtension::create() + ->process($request, $notification, $subscription) + ; + } +} diff --git a/tests/Library/Unit/StatusReportTest.php b/tests/Library/Unit/StatusReportTest.php new file mode 100644 index 0000000..e75f62d --- /dev/null +++ b/tests/Library/Unit/StatusReportTest.php @@ -0,0 +1,82 @@ + ['https://foo.bar'], + 'link' => ['https://link.1'], + ]); + $report = new StatusReport( + $subscription, + $notification, + $request, + $response + ); + + static::assertSame($subscription, $report->getSubscription()); + static::assertSame($notification, $report->getNotification()); + static::assertSame($request, $report->getRequest()); + static::assertSame($response, $report->getResponse()); + static::assertEquals('https://foo.bar', $report->getLocation()); + static::assertEquals(['https://link.1'], $report->getLinks()); + static::assertEquals($isSuccess, $report->isSuccess()); + static::assertEquals($hasExpired, $report->notificationExpired()); + } + + /** + * @return array[] + */ + public function dataReport(): array + { + return [ + [199, false, false], + [200, true, false], + [201, true, false], + [202, true, false], + [299, true, false], + [300, false, false], + [301, false, false], + [400, false, false], + [403, false, false], + [404, false, true], + [405, false, false], + [409, false, false], + [410, false, true], + [411, false, false], + ]; + } +} diff --git a/tests/Library/Unit/SubscriptionTest.php b/tests/Library/Unit/SubscriptionTest.php new file mode 100644 index 0000000..3e8fe0b --- /dev/null +++ b/tests/Library/Unit/SubscriptionTest.php @@ -0,0 +1,252 @@ + $exception + */ + public function invalidInputCannotBeLoaded(string $input, string $exception, string $message): void + { + $this->expectException($exception); + $this->expectExceptionMessage($message); + + Subscription::createFromString($input); + } + + /** + * @test + */ + public function createSubscriptionFluent(): void + { + $subscription = Subscription::create('https://foo.bar'); + $subscription + ->setKey('p256dh', 'Public key') + ->setKey('auth', 'Authorization Token') + ; + + static::assertEquals('https://foo.bar', $subscription->getEndpoint()); + static::assertEquals('Public key', $subscription->getKey('p256dh')); + static::assertEquals('Authorization Token', $subscription->getKey('auth')); + static::assertEquals(['aesgcm'], $subscription->getSupportedContentEncodings()); + } + + /** + * @test + */ + public function createSubscriptionFromJson(): void + { + $subscription = Subscription::createFromString('{"endpoint": "https://some.pushservice.com/something-unique","keys": {"p256dh":"BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=","auth":"FPssNDTKnInHVndSTdbKFw=="},"expirationTime":1580253757}'); + + static::assertEquals('https://some.pushservice.com/something-unique', $subscription->getEndpoint()); + static::assertEquals('BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=', $subscription->getKey('p256dh')); + static::assertEquals('FPssNDTKnInHVndSTdbKFw==', $subscription->getKey('auth')); + static::assertEquals(['aesgcm'], $subscription->getSupportedContentEncodings()); + static::assertEquals(1580253757, $subscription->getExpirationTime()); + static::assertEquals(DatetimeImmutable::createFromFormat('Y-m-d\TH:i:sP', '2020-01-28T16:22:37-07:00'), $subscription->expiresAt()); + } + + /** + * @test + */ + public function createSubscriptionWithoutExpirationTimeFromJson(): void + { + $subscription = Subscription::createFromString('{"endpoint": "https://some.pushservice.com/something-unique","keys": {"p256dh":"BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=","auth":"FPssNDTKnInHVndSTdbKFw=="}}') + ; + + static::assertEquals('https://some.pushservice.com/something-unique', $subscription->getEndpoint()); + static::assertEquals('BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=', $subscription->getKey('p256dh')); + static::assertEquals('FPssNDTKnInHVndSTdbKFw==', $subscription->getKey('auth')); + static::assertEquals(['aesgcm'], $subscription->getSupportedContentEncodings()); + static::assertNull($subscription->getExpirationTime()); + static::assertNull($subscription->expiresAt()); + } + + /** + * @test + */ + public function invalidExpirationTime(): void + { + static::expectException(InvalidArgumentException::class); + static::expectExceptionMessage('Invalid input'); + + Subscription::createFromString('{"endpoint": "https://some.pushservice.com/something-unique","keys": {"p256dh":"BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=","auth":"FPssNDTKnInHVndSTdbKFw=="},"expirationTime":"Hello World"}'); + } + + /** + * @test + */ + public function createSubscriptionWithAESGCMENCODINGFluent(): void + { + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aesgcm']) + ; + + static::assertEquals('https://foo.bar', $subscription->getEndpoint()); + static::assertEquals(['aesgcm'], $subscription->getSupportedContentEncodings()); + } + + /** + * @test + */ + public function createSubscriptionWithAES128GCMENCODINGFluent(): void + { + $subscription = Subscription::create('https://foo.bar') + ->withContentEncodings(['aes128gcm']) + ; + + static::assertEquals('https://foo.bar', $subscription->getEndpoint()); + static::assertEquals(['aes128gcm'], $subscription->getSupportedContentEncodings()); + } + + /** + * @test + * @dataProvider dataSubscription + * + * @param array $keys + */ + public function createSubscription(string $endpoint, string $contentEncoding, array $keys): void + { + $subscription = Subscription::create($endpoint) + ->withContentEncodings([$contentEncoding]) + ; + foreach ($keys as $k => $v) { + $subscription->setKey($k, $v); + } + + static::assertEquals($endpoint, $subscription->getEndpoint()); + static::assertEquals($keys, $subscription->getKeys()); + static::assertEquals([$contentEncoding], $subscription->getSupportedContentEncodings()); + + $json = json_encode($subscription); + $newSubscription = Subscription::createFromString($json); + + static::assertEquals($endpoint, $newSubscription->getEndpoint()); + static::assertEquals($keys, $newSubscription->getKeys()); + static::assertEquals([$contentEncoding], $newSubscription->getSupportedContentEncodings()); + } + + /** + * @return array|string>> + */ + public function dataSubscription(): array + { + return [ + [ + 'endpoint' => 'https://foo.bar', + 'content_encoding' => 'FOO', + 'keys' => [], + ], + [ + 'endpoint' => 'https://bar.foo', + 'content_encoding' => 'FOO', + 'keys' => [ + 'authToken' => 'bar-foo', + 'publicKey' => 'FOO-BAR', + ], + ], + ]; + } + + /** + * @return array> + */ + public function dataInvalidSubscription(): array + { + return [ + [ + 'input' => json_encode(0), + 'exception' => InvalidArgumentException::class, + 'message' => 'Invalid input', + ], + [ + 'input' => '', + 'exception' => JsonException::class, + 'message' => 'Syntax error', + ], + [ + 'input' => '[]', + 'exception' => InvalidArgumentException::class, + 'message' => 'Invalid input', + ], + [ + 'input' => json_encode([ + 'endpoint' => 0, + ]), + 'exception' => InvalidArgumentException::class, + 'message' => 'Invalid input', + ], + [ + 'input' => json_encode([ + 'endpoint' => 'https://foo.bar', + 'contentEncoding' => 'FOO', + 'keys' => 'foo', + ]), + 'exception' => InvalidArgumentException::class, + 'message' => 'Invalid input', + ], + [ + 'input' => json_encode([ + 'endpoint' => 'https://foo.bar', + 'contentEncoding' => 'FOO', + 'keys' => [ + 12 => 0, + ], + ]), + 'exception' => InvalidArgumentException::class, + 'message' => 'Invalid key name', + ], + [ + 'input' => json_encode([ + 'endpoint' => 'https://foo.bar', + 'contentEncoding' => 'FOO', + 'keys' => [ + 'authToken' => 'BAR', + 'publicKey' => 0, + ], + ]), + 'exception' => InvalidArgumentException::class, + 'message' => 'Invalid key value', + ], + [ + 'input' => json_encode([ + 'endpoint' => 'https://foo.bar', + 'contentEncoding' => 'FOO', + 'keys' => [ + 'authToken' => 'BAR', + 'publicKey' => 0, + ], + 'expirationTime' => 'Monday', + ]), + 'exception' => InvalidArgumentException::class, + 'message' => 'Invalid input', + ], + ]; + } +} diff --git a/tests/Library/Unit/SyncExtensionTest.php b/tests/Library/Unit/SyncExtensionTest.php new file mode 100644 index 0000000..f275bda --- /dev/null +++ b/tests/Library/Unit/SyncExtensionTest.php @@ -0,0 +1,75 @@ +async() + ; + $subscription = Subscription::create('https://foo.bar'); + + $request = PreferAsyncExtension::create() + ->setLogger($logger) + ->process($request, $notification, $subscription) + ; + + static::assertEquals('respond-async', $request->getHeaderLine('prefer')); + static::assertCount(1, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Sending asynchronous notification', $logger->records[0]['message']); + } + + /** + * @test + */ + public function asyncIsNotSetInHeader(): void + { + $logger = new TestLogger(); + $request = new Request('POST', 'https://foo.bar'); + $notification = Notification::create() + ->sync() + ; + $subscription = Subscription::create('https://foo.bar'); + + $request = PreferAsyncExtension::create() + ->setLogger($logger) + ->process($request, $notification, $subscription) + ; + + static::assertFalse($request->hasHeader('prefer')); + static::assertCount(1, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Sending synchronous notification', $logger->records[0]['message']); + } +} diff --git a/tests/Library/Unit/TTLExtensionTest.php b/tests/Library/Unit/TTLExtensionTest.php new file mode 100644 index 0000000..1c646a2 --- /dev/null +++ b/tests/Library/Unit/TTLExtensionTest.php @@ -0,0 +1,67 @@ +withTTL($ttl) + ; + $subscription = Subscription::create('https://foo.bar'); + + $request = TTLExtension::create() + ->setLogger($logger) + ->process($request, $notification, $subscription) + ; + + static::assertEquals($ttl, $request->getHeaderLine('ttl')); + static::assertCount(1, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Processing with the TTL extension', $logger->records[0]['message']); + static::assertEquals($ttl, $logger->records[0]['context']['TTL']); + } + + /** + * @return array> + */ + public function dataTTLIsSetInHeader(): array + { + return [ + [0], + [10], + [3600], + ]; + } +} diff --git a/tests/Library/Unit/TopicExtensionTest.php b/tests/Library/Unit/TopicExtensionTest.php new file mode 100644 index 0000000..96cf917 --- /dev/null +++ b/tests/Library/Unit/TopicExtensionTest.php @@ -0,0 +1,69 @@ +withTopic($topic); + } + + $subscription = Subscription::create('https://foo.bar'); + + $request = TopicExtension::create() + ->setLogger($logger) + ->process($request, $notification, $subscription) + ; + + static::assertEquals($topic, $request->getHeaderLine('topic')); + static::assertCount(1, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Processing with the Topic extension', $logger->records[0]['message']); + static::assertEquals($topic, $logger->records[0]['context']['Topic']); + } + + /** + * @return array> + */ + public function dataTopicIsSetInHeader(): array + { + return [ + [null], + ['topic1'], + ['foo-bar'], + ]; + } +} diff --git a/tests/Library/Unit/UrgencyExtensionTest.php b/tests/Library/Unit/UrgencyExtensionTest.php new file mode 100644 index 0000000..0b0bada --- /dev/null +++ b/tests/Library/Unit/UrgencyExtensionTest.php @@ -0,0 +1,68 @@ +withUrgency($urgency) + ; + $subscription = Subscription::create('https://foo.bar'); + + $request = UrgencyExtension::create() + ->setLogger($logger) + ->process($request, $notification, $subscription) + ; + + static::assertEquals($urgency, $request->getHeaderLine('urgency')); + static::assertCount(1, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Processing with the Urgency extension', $logger->records[0]['message']); + static::assertEquals($urgency, $logger->records[0]['context']['Urgency']); + } + + /** + * @return array> + */ + public function dataUrgencyIsSetInHeader(): array + { + return [ + [Notification::URGENCY_VERY_LOW], + [Notification::URGENCY_LOW], + [Notification::URGENCY_NORMAL], + [Notification::URGENCY_HIGH], + ]; + } +} diff --git a/tests/Library/Unit/UtilsTest.php b/tests/Library/Unit/UtilsTest.php new file mode 100644 index 0000000..31fb11f --- /dev/null +++ b/tests/Library/Unit/UtilsTest.php @@ -0,0 +1,122 @@ +addResponse(new Response(201)); + $requestFactory = new Psr17Factory(); + + $extensionManager = ExtensionManager::create(); + $logger = new TestLogger(); + + $webPush = WebPush::create($client, $requestFactory, $extensionManager); + $report = $webPush + ->setLogger($logger) + ->send($notification, $subscription) + ; + + static::assertCount(3, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Sending notification', $logger->records[0]['message']); + static::assertInstanceOf(Notification::class, $logger->records[0]['context']['notification']); + static::assertInstanceOf(Subscription::class, $logger->records[0]['context']['subscription']); + static::assertEquals('debug', $logger->records[1]['level']); + static::assertEquals('Request ready', $logger->records[1]['message']); + static::assertInstanceOf(RequestInterface::class, $logger->records[1]['context']['request']); + static::assertEquals('debug', $logger->records[2]['level']); + static::assertEquals('Response received', $logger->records[2]['message']); + static::assertInstanceOf(ResponseInterface::class, $logger->records[2]['context']['response']); + static::assertTrue($report->isSuccess()); + static::assertSame($notification, $report->getNotification()); + static::assertSame($subscription, $report->getSubscription()); + } + + /** + * @test + */ + public function aNotificationCanBeSentAsync(): void + { + $subscription = Subscription::create('https://foo.bar'); + $notification = Notification::create(); + + $client = new Client(); + $client->addResponse(new Response(202)); + $requestFactory = new Psr17Factory(); + + $extensionManager = ExtensionManager::create(); + $logger = new TestLogger(); + + $webPush = WebPush::create($client, $requestFactory, $extensionManager); + $report = $webPush + ->setLogger($logger) + ->send($notification, $subscription) + ; + + static::assertCount(3, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Sending notification', $logger->records[0]['message']); + static::assertInstanceOf(Notification::class, $logger->records[0]['context']['notification']); + static::assertInstanceOf(Subscription::class, $logger->records[0]['context']['subscription']); + static::assertEquals('debug', $logger->records[1]['level']); + static::assertEquals('Request ready', $logger->records[1]['message']); + static::assertInstanceOf(RequestInterface::class, $logger->records[1]['context']['request']); + static::assertEquals('debug', $logger->records[2]['level']); + static::assertEquals('Response received', $logger->records[2]['message']); + static::assertInstanceOf(ResponseInterface::class, $logger->records[2]['context']['response']); + static::assertTrue($report->isSuccess()); + static::assertSame($notification, $report->getNotification()); + static::assertSame($subscription, $report->getSubscription()); + } + + /** + * @test + */ + public function aNotificationCannotBeSent(): void + { + $subscription = Subscription::create('https://foo.bar'); + $notification = Notification::create(); + + $client = new Client(); + $client->addResponse(new Response(409)); + $requestFactory = new Psr17Factory(); + + $extensionManager = ExtensionManager::create(); + $logger = new TestLogger(); + + $webPush = WebPush::create($client, $requestFactory, $extensionManager); + $report = $webPush + ->setLogger($logger) + ->send($notification, $subscription) + ; + + static::assertCount(3, $logger->records); + static::assertEquals('debug', $logger->records[0]['level']); + static::assertEquals('Sending notification', $logger->records[0]['message']); + static::assertInstanceOf(Notification::class, $logger->records[0]['context']['notification']); + static::assertInstanceOf(Subscription::class, $logger->records[0]['context']['subscription']); + static::assertEquals('debug', $logger->records[1]['level']); + static::assertEquals('Request ready', $logger->records[1]['message']); + static::assertInstanceOf(RequestInterface::class, $logger->records[1]['context']['request']); + static::assertEquals('debug', $logger->records[2]['level']); + static::assertEquals('Response received', $logger->records[2]['message']); + static::assertInstanceOf(ResponseInterface::class, $logger->records[2]['context']['response']); + static::assertFalse($report->isSuccess()); + static::assertSame($notification, $report->getNotification()); + static::assertSame($subscription, $report->getSubscription()); + } +}