diff --git a/.env b/.env index ea7b4f2..46e6c78 100644 --- a/.env +++ b/.env @@ -1 +1,4 @@ +###> aakb/itkdev-docker configuration ### COMPOSE_PROJECT_NAME=vault-library +COMPOSE_DOMAIN=vault-library.local.itkdev.dk +###< aakb/itkdev-docker configuration ### \ No newline at end of file diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml new file mode 100644 index 0000000..63638c2 --- /dev/null +++ b/.github/workflows/changelog.yaml @@ -0,0 +1,29 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/changelog.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Changelog +### +### Checks that changelog has been updated + +name: Changelog + +on: + pull_request: + +jobs: + changelog: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Git fetch + run: git fetch + + - name: Check that changelog has been updated. + run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml new file mode 100644 index 0000000..f1b0f6e --- /dev/null +++ b/.github/workflows/composer.yaml @@ -0,0 +1,80 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/composer.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Composer +### +### Validates composer.json and checks that it's normalized. +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [ergebnis/composer-normalize](https://github.com/ergebnis/composer-normalize) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev ergebnis/composer-normalize +### ``` +### +### Normalize `composer.json` by running +### +### ``` shell +### docker compose run --rm phpfpm composer normalize +### ``` + +name: Composer + +env: + COMPOSE_USER: runner + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + composer-validate: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer validate --strict + + composer-normalized: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm composer normalize --dry-run + + composer-audit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer audit diff --git a/.github/workflows/github_build_release.yml b/.github/workflows/github_build_release.yml new file mode 100644 index 0000000..b2c083f --- /dev/null +++ b/.github/workflows/github_build_release.yml @@ -0,0 +1,25 @@ +on: + push: + tags: + - "*.*.*" + +name: Create Github Release + +permissions: + contents: write + +jobs: + create-release: + runs-on: ubuntu-latest + env: + COMPOSER_ALLOW_SUPERUSER: 1 + APP_ENV: prod + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create a release in GitHub + run: gh release create ${{ github.ref_name }} --verify-tag --generate-notes + env: + GITHUB_TOKEN: ${{ github.TOKEN }} + shell: bash diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml new file mode 100644 index 0000000..8f0fc25 --- /dev/null +++ b/.github/workflows/markdown.yaml @@ -0,0 +1,44 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/markdown.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Markdown +### +### Lints Markdown files (`**/*.md`) in the project. +### +### [markdownlint-cli configuration +### files](https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration), +### `.markdownlint.jsonc` and `.markdownlintignore`, control what is actually +### linted and how. +### +### #### Assumptions +### +### 1. A docker compose service named `markdownlint` for running `markdownlint` +### (from +### [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli)) +### exists. + +name: Markdown + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + markdown-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm markdownlint markdownlint '**/*.md' diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml new file mode 100644 index 0000000..ec59121 --- /dev/null +++ b/.github/workflows/php.yaml @@ -0,0 +1,85 @@ +name: PHP + +env: + COMPOSE_USER: runner + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + coding-standards: + name: PHP - Check Coding Standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm vendor/bin/phpstan + + unit-tests: + name: Unit tests (${{ matrix.php }}, ${{ matrix.prefer }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: phpfpm + php: "8.3" + prefer: prefer-lowest + - service: phpfpm + php: "8.3" + prefer: prefer-stable + - service: phpfpm84 + php: "8.4" + prefer: prefer-lowest + - service: phpfpm84 + php: "8.4" + prefer: prefer-stable + - service: phpfpm85 + php: "8.5" + prefer: prefer-lowest + - service: phpfpm85 + php: "8.5" + prefer: prefer-stable + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm -e XDEBUG_MODE=coverage ${{ matrix.service }} composer update --${{ matrix.prefer }} + docker compose run --rm -e XDEBUG_MODE=coverage ${{ matrix.service }} vendor/bin/phpunit --coverage-clover=coverage/unit.xml + + - name: Upload coverage to Codecov + if: matrix.service == 'phpfpm' && matrix.prefer == 'prefer-stable' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/unit.xml + fail_ci_if_error: true + flags: unittests diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml deleted file mode 100644 index 0f5a641..0000000 --- a/.github/workflows/pr.yaml +++ /dev/null @@ -1,153 +0,0 @@ -on: pull_request -name: Review -jobs: - test-composer-install: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: [ '8.2', '8.3' ] - name: Validate composer (${{ matrix.php}}) - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php}} - extensions: http, ctype, iconv - coverage: none - - - name: Get composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ matrix.dependency-version }}- - restore-keys: ${{ runner.os }}-composer-${{ matrix.dependency-version }}- - - - name: Validate composer files - run: composer validate composer.json --strict - - - name: Composer install with exported .env variables - run: | - set -a && source .env && set +a - APP_ENV=prod composer install --no-dev -o - - test-suite: - name: Test suite (${{ matrix.php }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: [ '8.2', '8.3' ] - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php}} - extensions: http, ctype, iconv - coverage: xdebug - - - name: Get composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ matrix.dependency-version }}- - restore-keys: ${{ runner.os }}-composer-${{ matrix.dependency-version }}- - - - name: Install Dependencies - run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist - - - name: Test suite - run: ./vendor/bin/phpunit --coverage-clover=coverage/unit.xml - - - name: Upload coverage to Codecov test - uses: codecov/codecov-action@v2 - with: - files: ./coverage/unit.xml - flags: unittests, ${{ matrix.php }} - - php-cs-fixer: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: ['8.2', '8.3'] - name: PHP Coding Standards Fixer (PHP ${{ matrix.php }}) - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP, with composer and extensions - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php}} - extensions: http, ctype, iconv - coverage: none - - - name: Get composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache composer dependencies - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ matrix.php }}-composer- - - - name: Install Dependencies - run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist - - - name: php-cs-fixer - run: phpdbg -qrr ./vendor/bin/php-cs-fixer fix --dry-run - - markdownlint: - name: Markdown Lint - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: Cache yarn packages - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: Yarn install - uses: actions/setup-node@v2 - with: - node-version: '20' - - run: yarn install - - name: markdownlint - run: yarn run coding-standards-check - - changelog: - runs-on: ubuntu-latest - name: Changelog should be updated - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Git fetch - run: git fetch - - - name: Check that changelog has been updated. - run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml new file mode 100644 index 0000000..299d4e1 --- /dev/null +++ b/.github/workflows/yaml.yaml @@ -0,0 +1,41 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/yaml.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### YAML +### +### Validates YAML files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. +### +### #### Symfony YAML +### +### Symfony's YAML config files use 4 spaces for indentation and single quotes. +### Therefore we use a [Prettier configuration +### file](https://prettier.io/docs/configuration), `.prettierrc.yaml`, to make +### Prettier format YAML files in the `config/` folder like Symfony expects. + +name: YAML + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + yaml-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier '**/*.{yml,yaml}' --check diff --git a/.gitignore b/.gitignore index b294082..8cbcf0d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ yarn.lock .php-cs-fixer.cache .phpunit.cache/* .phpunit.cache +coverage diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index 85d45c0..0000000 --- a/.markdownlint.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "default": true, - "MD013": { - "line_length": 120, - "ignore_code_blocks": true, - "tables": false - }, - "no-duplicate-heading": { - "siblings_only": true - } -} diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..0253096 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,22 @@ +// This file is copied from config/markdown/.markdownlint.jsonc in https://github.com/itk-dev/devops_itkdev-docker. +// Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +// markdownlint-cli configuration file (cf. https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration) +{ + "default": true, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md + "line-length": { + "line_length": 120, + "code_blocks": false, + "tables": false + }, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md + "no-duplicate-heading": { + "siblings_only": true + }, + // https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections#creating-a-collapsed-section + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md + "no-inline-html": { + "allowed_elements": ["details", "summary"] + } +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..d143ace --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,12 @@ +# This file is copied from config/markdown/.markdownlintignore in https://github.com/itk-dev/devops_itkdev-docker. +# Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +# https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#ignoring-files +vendor/ +node_modules/ +LICENSE.md +# Drupal +web/*.md +web/core/ +web/libraries/ +web/*/contrib/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 50e228b..6b1ccc9 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,14 +1,22 @@ in(__DIR__) - ->exclude('var') -; - -return (new PhpCsFixer\Config()) - ->setRules([ - '@Symfony' => true, - 'phpdoc_align' => false, - ]) - ->setFinder($finder) -; +// https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/config.rst + +$finder = new PhpCsFixer\Finder(); +// Check all files … +$finder->in(__DIR__); +// … that are not ignored by VCS +$finder->ignoreVCSIgnored(true); + +$config = new PhpCsFixer\Config(); +$config->setFinder($finder); + +$config->setRules([ + '@Symfony' => true, + 'phpdoc_align' => false, + 'phpdoc_to_comment' => ['ignored_tags' => ['var']], +]); + +return $config; diff --git a/CHANGELOG.md b/CHANGELOG.md index f83ea6a..b3d129c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] * Updated GitHub workflow images. +* Modernized dev environment with Taskfile and multi-PHP docker setup (8.3, 8.4, 8.5). +* Replaced monolithic PR workflow with dedicated CI workflows. +* Upgraded to PHPUnit 12 and bumped minimum PHP to 8.3. +* Fixed PHPStan errors at max level. +* Added PHPStan and markdownlint configuration. +* Added tests for error paths and Token model (94% line coverage). ## [0.1.0] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9d9f703 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 ITK Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 84bd818..35f7698 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,22 @@ # Vault library +[![Github](https://img.shields.io/badge/source-itk--dev/vault--library-blue?style=flat-square)](https://github.com/itk-dev/vault-library) +[![Release](https://img.shields.io/packagist/v/itk-dev/vault.svg?style=flat-square&label=release)](https://packagist.org/packages/itk-dev/vault) +[![PHP Version](https://img.shields.io/packagist/php-v/itk-dev/vault.svg?style=flat-square&colorB=%238892BF)](https://www.php.net/downloads) +[![Build Status](https://img.shields.io/github/actions/workflow/status/itk-dev/vault-library/php.yaml?label=CI&logo=github&style=flat-square)](https://github.com/itk-dev/vault-library/actions?query=workflow%3APHP) +[![Codecov Code Coverage](https://img.shields.io/codecov/c/gh/itk-dev/vault-library?label=codecov&logo=codecov&style=flat-square)](https://codecov.io/gh/itk-dev/vault-library) +[![Read License](https://img.shields.io/packagist/l/itk-dev/vault-library.svg?style=flat-square&colorB=darkcyan)](https://github.com/itk-dev/vault-library/blob/master/LICENSE.md) +[![Package downloads on Packagist](https://img.shields.io/packagist/dt/itk-dev/vault.svg?style=flat-square&colorB=darkmagenta)](https://packagist.org/packages/itk-dev/vault/stats) + A PHP library for authenticating and fetching secrets with HashiCorp Vault using the `approle` method. This library implements the PSR-18 and PSR-17 interfaces, so you will need to provide your own HTTP client. -## Install +## Usage + +See [itk-dev/vault-bundle](https://github.com/itk-dev/vault-bundle) for usage in a Symfony application. + +## Direct Install You can install this library by utilizing PHP Composer, which is the recommended dependency management tool for PHP. @@ -13,10 +25,47 @@ dependency management tool for PHP. composer require itk-dev/vault ``` -## Usage +## Developing -See [itk-dev/vault-bundle](https://github.com/itk-dev/vault-bundle) +### Prerequisites -## Developing +- [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) +- [Task](https://taskfile.dev/) (task runner) + +### Getting started + +```shell +task setup +``` + +This starts the Docker containers and installs Composer dependencies. -See details on contributing in the [contributing docs](/docs/CONTRIBUTING.md). +### Available tasks + +Run `task` to list all available tasks. Key tasks: + +| Task | Description | +|----------------------|-------------------------------------------------| +| `task test` | Run unit tests | +| `task test:coverage` | Run tests with coverage report | +| `task test:matrix` | Run tests across PHP 8.3, 8.4, 8.5 (mirrors CI) | +| `task lint` | Run all linters (PHP, Composer, Markdown, YAML) | +| `task lint:php:fix` | Auto-fix PHP coding standards | +| `task analyze:php` | Run PHPStan static analysis | +| `task pr:actions` | Run all CI checks locally | + +### Test matrix + +The test matrix runs against PHP 8.3, 8.4, and 8.5 with both `prefer-lowest` +and `prefer-stable` dependency sets: + +```shell +task test:matrix +``` + +To force a fresh dependency resolve (clearing cached vendor volumes): + +```shell +task test:matrix:reset +task test:matrix +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..0eb711d --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,252 @@ +# yaml-language-server: $schema=https://taskfile.dev/schema.json +--- +version: "3" + +vars: + DOCKER_COMPOSE: "docker compose" + PHP: "{{.DOCKER_COMPOSE}} exec phpfpm" + COMPOSER: "{{.PHP}} composer" + +tasks: + default: + silent: true + cmds: + - task --list + + install: + desc: Install dependencies + cmds: + - task: composer:install + + setup: + desc: Set up the project + cmds: + - task: up + - task: install + + # Container management + + up: + desc: Start docker containers + cmds: + - task: network:frontend + - "{{.DOCKER_COMPOSE}} up -d" + + down: + desc: Stop docker containers + cmds: + - "{{.DOCKER_COMPOSE}} down" + + restart: + desc: Restart docker containers + cmds: + - task: down + - task: up + + network:frontend: + internal: true + desc: Create docker frontend network + cmds: + - docker network create frontend 2>/dev/null || true + + # Composer + + composer: + desc: Run arbitrary composer command + cmds: + - "{{.COMPOSER}} {{.CLI_ARGS}}" + + composer:install: + desc: Install composer dependencies + cmds: + - "{{.COMPOSER}} install" + + composer:update: + desc: Update composer dependencies + cmds: + - "{{.COMPOSER}} update" + + composer:normalize: + desc: Normalize composer.json + cmds: + - "{{.COMPOSER}} normalize" + + composer:check: + desc: Validate and audit composer + cmds: + - "{{.PHP}} composer validate --strict" + - "{{.COMPOSER}} normalize --dry-run" + - "{{.COMPOSER}} audit" + + # Code quality + + lint: + desc: Run all linters + cmds: + - task: lint:php + - task: lint:composer + - task: lint:markdown + - task: lint:yaml + + lint:php: + desc: Check PHP coding standards + cmds: + - "{{.PHP}} vendor/bin/php-cs-fixer fix --dry-run --diff" + + lint:php:fix: + desc: Fix PHP coding standards + cmds: + - "{{.PHP}} vendor/bin/php-cs-fixer fix" + + lint:composer: + desc: Validate and audit composer + cmds: + - "{{.PHP}} composer validate --strict" + - "{{.COMPOSER}} normalize --dry-run" + - "{{.COMPOSER}} audit" + + lint:markdown: + desc: Lint markdown files + cmds: + - "{{.DOCKER_COMPOSE}} run --rm markdownlint markdownlint '**/*.md'" + + lint:markdown:fix: + desc: Fix markdown files + cmds: + - "{{.DOCKER_COMPOSE}} run --rm markdownlint markdownlint '**/*.md' --fix" + + lint:yaml: + desc: Lint YAML files + cmds: + - "{{.DOCKER_COMPOSE}} run --rm prettier '**/*.{yml,yaml}' --check" + + lint:yaml:fix: + desc: Fix YAML files + cmds: + - "{{.DOCKER_COMPOSE}} run --rm prettier '**/*.{yml,yaml}' --write" + + # Analysis + + analyze:php: + desc: Run PHPStan static analysis + cmds: + - "{{.PHP}} vendor/bin/phpstan" + + # Testing + + test: + desc: Run tests + cmds: + - "{{.PHP}} vendor/bin/phpunit" + + test:coverage: + desc: Run tests with coverage + cmds: + - "{{.DOCKER_COMPOSE}} exec -e XDEBUG_MODE=coverage phpfpm vendor/bin/phpunit --coverage-text --coverage-clover=coverage/unit.xml" + + test:run: + desc: "Run tests for a PHP version and dependency set (e.g. task test:run PHP=8.4 DEPS=lowest)" + vars: + PHP: '{{.PHP | default "8.3"}}' + DEPS: '{{.DEPS | default "stable"}}' + PREFER: "prefer-{{.DEPS}}" + SERVICE: 'phpfpm{{.PHP | replace "." ""}}-{{.DEPS}}' + cmds: + - cmd: | + trap 'stty sane 2>/dev/null || true' EXIT + set -e + echo "Testing PHP {{.PHP}} ({{.PREFER}})..." + {{.DOCKER_COMPOSE}} run --rm -T --user root {{.SERVICE}} chown -R deploy:deploy /app/vendor /home/deploy/.composer + {{.DOCKER_COMPOSE}} run --rm -T -e XDEBUG_MODE=coverage {{.SERVICE}} sh -c ' + if [ -f /app/vendor/.composer.lock ]; then + cp /app/vendor/.composer.lock /app/composer.lock + composer install -q + else + rm -f /app/composer.lock + composer update -q --{{.PREFER}} + cp /app/composer.lock /app/vendor/.composer.lock + fi' + {{.DOCKER_COMPOSE}} run --rm -T -e XDEBUG_MODE=coverage {{.SERVICE}} vendor/bin/phpunit --coverage-clover=coverage/unit.xml + + test:matrix:reset: + desc: Remove cached vendor volumes to force a fresh dependency resolve + cmds: + - "{{.DOCKER_COMPOSE}} --profile ci down --volumes" + + test:matrix: + desc: Run tests across all PHP versions (mirrors CI matrix) + vars: + RESULTS_FILE: + sh: mktemp + cmds: + - task: test:matrix:run + vars: { PHP: "8.3", DEPS: lowest, RESULTS_FILE: "{{.RESULTS_FILE}}" } + - task: test:matrix:run + vars: { PHP: "8.3", DEPS: stable, RESULTS_FILE: "{{.RESULTS_FILE}}" } + - task: test:matrix:run + vars: { PHP: "8.4", DEPS: lowest, RESULTS_FILE: "{{.RESULTS_FILE}}" } + - task: test:matrix:run + vars: { PHP: "8.4", DEPS: stable, RESULTS_FILE: "{{.RESULTS_FILE}}" } + - task: test:matrix:run + vars: { PHP: "8.5", DEPS: lowest, RESULTS_FILE: "{{.RESULTS_FILE}}" } + - task: test:matrix:run + vars: { PHP: "8.5", DEPS: stable, RESULTS_FILE: "{{.RESULTS_FILE}}" } + - task: test:summary + vars: { RESULTS_FILE: "{{.RESULTS_FILE}}" } + + test:matrix:run: + internal: true + desc: Run a single matrix combination and record the result + vars: + PREFER: "prefer-{{.DEPS}}" + cmds: + - cmd: | + if task test:run PHP={{.PHP}} DEPS={{.DEPS}}; then + echo "PASS PHP {{.PHP}} ({{.PREFER}})" >> "{{.RESULTS_FILE}}" + else + echo "FAIL PHP {{.PHP}} ({{.PREFER}})" >> "{{.RESULTS_FILE}}" + fi + ignore_error: true + + test:summary: + internal: true + silent: true + desc: Print test matrix summary + cmds: + - cmd: | + echo "" + echo "==============================" + echo " Test Matrix Summary" + echo "==============================" + while IFS= read -r line; do + if [[ "$line" == PASS* ]]; then + echo " OK ${line#PASS }" + elif [[ "$line" == FAIL* ]]; then + echo " FAIL ${line#FAIL }" + fi + done < "{{.RESULTS_FILE}}" + echo "==============================" + if grep -q "^FAIL" "{{.RESULTS_FILE}}"; then + echo "" + echo " Failed combinations:" + grep "^FAIL" "{{.RESULTS_FILE}}" | while IFS= read -r line; do + echo " - ${line#FAIL }" + done + echo "" + rm -f "{{.RESULTS_FILE}}" + exit 1 + else + echo " All tests PASSED" + echo "==============================" + rm -f "{{.RESULTS_FILE}}" + fi + + # CI + + pr:actions: + desc: Run all CI checks locally + cmds: + - task: composer:check + - task: lint + - task: analyze:php + - task: test:matrix diff --git a/composer.json b/composer.json index e05613a..deb1728 100644 --- a/composer.json +++ b/composer.json @@ -1,10 +1,14 @@ { "name": "itk-dev/vault", - "type": "library", "description": "Library to communicate with Hashicorp Vault", - "keywords": ["Hashicorp", "vault", "approle", "e"], - "homepage": "https://github.com/itk-dev", "license": "MIT", + "type": "library", + "keywords": [ + "Hashicorp", + "vault", + "approle", + "e" + ], "authors": [ { "name": "Jesper Kristensen", @@ -13,27 +17,28 @@ "role": "Developer" } ], - "autoload": { - "psr-4": { - "ItkDev\\Vault\\": "src/Vault" - } - }, + "homepage": "https://github.com/itk-dev", "require": { - "php": ">=8.2", + "php": ">=8.3", "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/simple-cache": "^3.0" }, "require-dev": { - "phpunit/phpunit": "^11.3", - "friendsofphp/php-cs-fixer": "^3.64" + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "^3.64", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "rector/rector": "^2.0" + }, + "autoload": { + "psr-4": { + "ItkDev\\Vault\\": "src/Vault" + } }, - "scripts": { - "coding-standards-apply": [ - "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix" - ], - "coding-standards-check": [ - "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run" - ] + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true + } } } diff --git a/docker-compose.yml b/docker-compose.yml index 28878d6..f130e8f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# itk-version: 3.2.1 +# itk-version: 3.2.4 networks: frontend: external: true @@ -8,7 +8,8 @@ networks: services: phpfpm: - image: itkdev/php8.2-fpm:latest + image: itkdev/php8.3-fpm:latest + user: ${COMPOSE_USER:-deploy} networks: - app extra_hosts: @@ -19,18 +20,111 @@ services: - PHP_MEMORY_LIMIT=256M # Depending on the setup, you may have to remove --read-envelope-from from msmtp (cf. https://marlam.de/msmtp/msmtp.html) or use SMTP to send mail - PHP_SENDMAIL_PATH=/usr/bin/msmtp --host=mail --port=1025 --read-recipients --read-envelope-from - - DOCKER_HOST_DOMAIN=${COMPOSE_DOMAIN} - - COMPOSER_VERSION=2 + - DOCKER_HOST_DOMAIN=${COMPOSE_DOMAIN:?} - PHP_IDE_CONFIG=serverName=localhost volumes: - .:/app + - composer-cache:/home/deploy/.composer/cache - node: - image: node:20 - networks: - - app - extra_hosts: - - "host.docker.internal:host-gateway" - working_dir: /app + phpfpm84: + extends: + service: phpfpm + image: itkdev/php8.4-fpm:latest + profiles: + - ci + + phpfpm85: + extends: + service: phpfpm + image: itkdev/php8.5-fpm:latest + profiles: + - ci + + # Test matrix services (PHP version × dependency set) + phpfpm83-stable: + extends: + service: phpfpm + profiles: + - ci + volumes: + - .:/app + - phpfpm83-stable-vendor:/app/vendor + - composer-cache:/home/deploy/.composer/cache + + phpfpm83-lowest: + extends: + service: phpfpm + profiles: + - ci + volumes: + - .:/app + - phpfpm83-lowest-vendor:/app/vendor + - composer-cache:/home/deploy/.composer/cache + + phpfpm84-stable: + extends: + service: phpfpm84 + profiles: + - ci + volumes: + - .:/app + - phpfpm84-stable-vendor:/app/vendor + - composer-cache:/home/deploy/.composer/cache + + phpfpm84-lowest: + extends: + service: phpfpm84 + profiles: + - ci volumes: - .:/app + - phpfpm84-lowest-vendor:/app/vendor + - composer-cache:/home/deploy/.composer/cache + + phpfpm85-stable: + extends: + service: phpfpm85 + profiles: + - ci + volumes: + - .:/app + - phpfpm85-stable-vendor:/app/vendor + - composer-cache:/home/deploy/.composer/cache + + phpfpm85-lowest: + extends: + service: phpfpm85 + profiles: + - ci + volumes: + - .:/app + - phpfpm85-lowest-vendor:/app/vendor + - composer-cache:/home/deploy/.composer/cache + + # Code checks tools + markdownlint: + image: itkdev/markdownlint + profiles: + - dev + volumes: + - ./:/md + + prettier: + # Prettier does not (yet, fcf. + # https://github.com/prettier/prettier/issues/15206) have an official + # docker image. + # https://hub.docker.com/r/jauderho/prettier is good candidate (cf. https://hub.docker.com/search?q=prettier&sort=updated_at&order=desc) + image: jauderho/prettier + profiles: + - dev + volumes: + - ./:/work + +volumes: + phpfpm83-stable-vendor: + phpfpm83-lowest-vendor: + phpfpm84-stable-vendor: + phpfpm84-lowest-vendor: + phpfpm85-stable-vendor: + phpfpm85-lowest-vendor: + composer-cache: diff --git a/package.json b/package.json deleted file mode 100644 index 2a82596..0000000 --- a/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "license": "UNLICENSED", - "private": true, - "description": "Tooling setup for linting", - "devDependencies": { - "markdownlint-cli": "^0.35.0" - }, - "scripts": { - "coding-standards-check/markdownlint": "markdownlint --ignore 'node_modules' --ignore 'vendor' README.md CHANGELOG.md 'docs/**/*.md'", - "coding-standards-check": "yarn coding-standards-check/markdownlint", - "coding-standards-apply/markdownlint": "markdownlint --fix README.md CHANGELOG.md docs/*.md docs/**/*.md", - "coding-standards-apply": "yarn coding-standards-apply/markdownlint" - } -} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..309bd4f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: max + paths: + - src diff --git a/phpunit.xml b/phpunit.xml index f6025b6..868aa24 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,15 +1,10 @@ @@ -18,7 +13,7 @@ - + src diff --git a/src/Vault/Model/Token.php b/src/Vault/Model/Token.php index faaab02..f2e9038 100644 --- a/src/Vault/Model/Token.php +++ b/src/Vault/Model/Token.php @@ -43,7 +43,6 @@ public function usesLeft(): int * * @throws \DateInvalidOperationException * @throws \DateMalformedIntervalStringException - * @throws \DateMalformedStringException */ public function isExpired(int $tokenGracePeriod = 60): bool { diff --git a/src/Vault/Vault.php b/src/Vault/Vault.php index e692f2d..596831b 100644 --- a/src/Vault/Vault.php +++ b/src/Vault/Vault.php @@ -36,13 +36,13 @@ public function login(string $roleId, string $secretId, string $enginePath = 'ap $cacheKey = 'itkdev_vault_token'.$roleId; $token = $this->cache->get($cacheKey); - if ($refreshCache || is_null($token) || $token->isExpired()) { + if ($refreshCache || !$token instanceof Token || $token->isExpired()) { $loginUrl = sprintf('%s/v1/auth/%s/login', $this->vaultUrl, $enginePath); $body = $this->streamFactory->createStream(json_encode([ 'role_id' => $roleId, 'secret_id' => $secretId, - ])); + ], JSON_THROW_ON_ERROR)); $request = $this->requestFactory->createRequest('POST', $loginUrl) ->withHeader('Content-Type', 'application/json') @@ -50,7 +50,8 @@ public function login(string $roleId, string $secretId, string $enginePath = 'ap try { $response = $this->httpClient->sendRequest($request); - $data = json_decode($response->getBody(), associative: true, flags: JSON_THROW_ON_ERROR); + /** @var array $data */ + $data = json_decode((string) $response->getBody(), associative: true, flags: JSON_THROW_ON_ERROR); } catch (ClientExceptionInterface $e) { throw new VaultException(sprintf('Vault login failed: %s', $e->getMessage()), previous: $e); } catch (\JsonException $e) { @@ -58,17 +59,20 @@ public function login(string $roleId, string $secretId, string $enginePath = 'ap } if (isset($data['errors'])) { - throw new VaultException(sprintf('Vault login failed: %s', reset($data['errors']))); + /** @var array $errors */ + $errors = $data['errors']; + throw new VaultException(sprintf('Vault login failed: %s', reset($errors))); } + /** @var array{auth: array{lease_duration: int, client_token: string, renewable: bool, metadata: array{role_name: string}, num_uses: int}} $data */ $ttl = (int) $data['auth']['lease_duration']; $now = new \DateTimeImmutable(timezone: new \DateTimeZone('UTC')); $token = new Token( token: $data['auth']['client_token'], expiresAt: $now->add(new \DateInterval('PT'.$ttl.'S')), - renewable: (bool) $data['auth']['renewable'], + renewable: $data['auth']['renewable'], roleName: $data['auth']['metadata']['role_name'], - numUsesLeft: (int) $data['auth']['num_uses'], + numUsesLeft: $data['auth']['num_uses'], ); $this->cache->set($cacheKey, $token, $ttl); @@ -85,7 +89,7 @@ public function login(string $roleId, string $secretId, string $enginePath = 'ap */ public function getSecret(Token $token, string $path, string $secret, string $key, ?int $version = null, bool $useCache = false, bool $refreshCache = false, int $expire = 0): Secret { - $secret = $this->getSecrets( + $secrets = $this->getSecrets( token: $token, path: $path, secret: $secret, @@ -96,10 +100,14 @@ public function getSecret(Token $token, string $path, string $secret, string $ke expire: $expire ); - return reset($secret); + return $secrets[$key]; } /** + * @param array $keys + * + * @return array + * * @throws VaultException * @throws UnknownErrorException * @throws \DateMalformedStringException @@ -110,7 +118,7 @@ public function getSecrets(Token $token, string $path, string $secret, array $ke $cacheKey = 'itkdev_vault_secret_'.$path.'_'.$secret.'_'.implode('_', $keys).($version ?? 0); $data = $this->cache->get($cacheKey); - if (!$useCache || is_null($data) || $refreshCache) { + if (!$useCache || !is_array($data) || $refreshCache) { $url = sprintf('%s/v1/%s/data/%s', $this->vaultUrl, $path, $secret); if (!is_null($version)) { $url .= '?version='.$version; @@ -122,7 +130,8 @@ public function getSecrets(Token $token, string $path, string $secret, array $ke try { $response = $this->httpClient->sendRequest($request); - $res = json_decode($response->getBody(), associative: true, flags: JSON_THROW_ON_ERROR); + /** @var array $res */ + $res = json_decode((string) $response->getBody(), associative: true, flags: JSON_THROW_ON_ERROR); } catch (ClientExceptionInterface $e) { throw new VaultException(sprintf('Vault fetch failed: %s', $e->getMessage()), previous: $e); } catch (\JsonException $e) { @@ -130,16 +139,19 @@ public function getSecrets(Token $token, string $path, string $secret, array $ke } if (isset($res['errors'])) { + /** @var array $errors */ + $errors = $res['errors']; // If secret is not found an empty error array is returned. - if (empty($res['errors'])) { + if (empty($errors)) { throw new UnknownErrorException('Unknown error.'); } - preg_match('/.*:\n\t\* (.+)\n\n$/', reset($res['errors']), $matches); + preg_match('/.*:\n\t\* (.+)\n\n$/', (string) reset($errors), $matches); throw new VaultException(sprintf('Vault failed: %s', $matches[1] ?? '')); } + /** @var array{data: array{data: array, metadata: array{created_time: string, version: string}}} $res */ $created = new \DateTimeImmutable($res['data']['metadata']['created_time'], new \DateTimeZone('UTC')); - $version = $res['data']['metadata']['version']; + $secretVersion = (string) $res['data']['metadata']['version']; $data = []; if (!empty($keys)) { $secrets = $res['data']['data']; @@ -148,7 +160,7 @@ public function getSecrets(Token $token, string $path, string $secret, array $ke $data[$key] = new Secret( key: $key, value: $secrets[$key], - version: $version, + version: $secretVersion, createdAt: $created ); } else { @@ -160,6 +172,7 @@ public function getSecrets(Token $token, string $path, string $secret, array $ke $this->cache->set($cacheKey, $data, $expire); } + /** @var array $data */ return $data; } } diff --git a/src/Vault/VaultInterface.php b/src/Vault/VaultInterface.php index db0086a..5587ada 100644 --- a/src/Vault/VaultInterface.php +++ b/src/Vault/VaultInterface.php @@ -74,7 +74,7 @@ public function getSecret(Token $token, string $path, string $secret, string $ke * @param int $expire * Optional parameter specifying cache expiration time in seconds. Defaults to 0. * - * @return array + * @return array * An array containing the requested secrets * * @throws VaultException diff --git a/tests/TokenTest.php b/tests/TokenTest.php new file mode 100644 index 0000000..ab50b04 --- /dev/null +++ b/tests/TokenTest.php @@ -0,0 +1,71 @@ +assertTrue($token->isExpired()); + } + + public function testIsExpiredReturnsFalseForValidToken(): void + { + $token = new Token( + token: 'test-token', + expiresAt: new \DateTimeImmutable('+1 hour', new \DateTimeZone('UTC')), + renewable: false, + roleName: 'test', + numUsesLeft: 0, + ); + + $this->assertFalse($token->isExpired()); + } + + public function testIsExpiredRespectsGracePeriod(): void + { + // Token expires in 30 seconds — not expired with 0 grace, but expired with 60s grace (default) + $token = new Token( + token: 'test-token', + expiresAt: (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT30S')), + renewable: false, + roleName: 'test', + numUsesLeft: 0, + ); + + $this->assertTrue($token->isExpired(60)); + $this->assertFalse($token->isExpired(0)); + } + + public function testUsedDecrementsCounter(): void + { + $token = new Token( + token: 'test-token', + expiresAt: new \DateTimeImmutable('+1 hour', new \DateTimeZone('UTC')), + renewable: false, + roleName: 'test', + numUsesLeft: 3, + ); + + $this->assertSame(3, $token->usesLeft()); + + $token->used(); + $this->assertSame(2, $token->usesLeft()); + + $token->used(); + $this->assertSame(1, $token->usesLeft()); + } +} diff --git a/tests/VaultTest.php b/tests/VaultTest.php index 63be0ce..613eda9 100644 --- a/tests/VaultTest.php +++ b/tests/VaultTest.php @@ -2,12 +2,16 @@ namespace ItkDev\Vault\Tests; +use ItkDev\Vault\Exception\NotFoundException; +use ItkDev\Vault\Exception\UnknownErrorException; +use ItkDev\Vault\Exception\VaultException; use ItkDev\Vault\Model\Secret; use ItkDev\Vault\Model\Token; use ItkDev\Vault\Vault; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; @@ -203,6 +207,315 @@ public function testGetSecret(): void $this->assertEquals($expectedSecret, $secret); } + public function testLoginThrowsOnHttpClientError(): void + { + $mockRequest = $this->createMock(RequestInterface::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockStreamFactory = $this->createMock(StreamFactoryInterface::class); + $mockStream = $this->createMock(StreamInterface::class); + + $mockStreamFactory->method('createStream')->willReturn($mockStream); + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockRequest->method('withBody')->willReturnSelf(); + + $mockClient->method('sendRequest') + ->willThrowException($this->createMock(ClientExceptionInterface::class)); + + $vault = new Vault( + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $mockStreamFactory, + cache: $this->cacheMock, + vaultUrl: $this->vaultUrl, + ); + + $this->expectException(VaultException::class); + $this->expectExceptionMessageMatches('/Vault login failed/'); + $vault->login('role-id', 'secret-id'); + } + + public function testLoginThrowsOnVaultErrorResponse(): void + { + $mockRequest = $this->createMock(RequestInterface::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockStreamFactory = $this->createMock(StreamFactoryInterface::class); + $mockStream = $this->createMock(StreamInterface::class); + $mockResponseBodyStream = $this->createMock(StreamInterface::class); + $mockResponse = $this->createMock(ResponseInterface::class); + + $mockStreamFactory->method('createStream')->willReturn($mockStream); + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockRequest->method('withBody')->willReturnSelf(); + + $mockResponseBodyStream->method('__toString') + ->willReturn(json_encode(['errors' => ['invalid credentials']])); + $mockResponse->method('getBody')->willReturn($mockResponseBodyStream); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + $vault = new Vault( + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $mockStreamFactory, + cache: $this->cacheMock, + vaultUrl: $this->vaultUrl, + ); + + $this->expectException(VaultException::class); + $this->expectExceptionMessageMatches('/invalid credentials/'); + $vault->login('role-id', 'secret-id'); + } + + public function testLoginWithRefreshCacheBypassesCache(): void + { + $ttl = 3600; + $expectedBody = [ + 'auth' => [ + 'client_token' => 'new-token', + 'metadata' => ['role_name' => 'test'], + 'lease_duration' => $ttl, + 'renewable' => false, + 'num_uses' => 0, + ], + ]; + + $mockRequest = $this->createMock(RequestInterface::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockStreamFactory = $this->createMock(StreamFactoryInterface::class); + $mockStream = $this->createMock(StreamInterface::class); + $mockResponseBodyStream = $this->createMock(StreamInterface::class); + $mockResponse = $this->createMock(ResponseInterface::class); + + $mockStreamFactory->method('createStream')->willReturn($mockStream); + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockRequest->method('withBody')->willReturnSelf(); + + $mockResponseBodyStream->method('__toString') + ->willReturn(json_encode($expectedBody)); + $mockResponse->method('getBody')->willReturn($mockResponseBodyStream); + + // Expect sendRequest called twice (once per login with refreshCache=true) + $mockClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturn($mockResponse); + + $vault = new Vault( + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $mockStreamFactory, + cache: $this->cacheMock, + vaultUrl: $this->vaultUrl, + ); + + $token1 = $vault->login('role-id', 'secret-id'); + $token2 = $vault->login('role-id', 'secret-id', refreshCache: true); + + $this->assertSame('new-token', $token1->token); + $this->assertSame('new-token', $token2->token); + } + + public function testGetSecretThrowsNotFoundForMissingKey(): void + { + $token = new Token( + token: 'test-token', + expiresAt: (new \DateTimeImmutable())->add(new \DateInterval('PT300S')), + renewable: false, + roleName: 'test', + numUsesLeft: 0, + ); + + $responseBody = [ + 'data' => [ + 'data' => ['otherKey' => 'value'], + 'metadata' => [ + 'created_time' => '2022-02-16T20:46:22.151178411Z', + 'version' => 1, + ], + ], + ]; + + $mockRequest = $this->createMock(RequestInterface::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockResponseBodyStream = $this->createMock(StreamInterface::class); + $mockResponse = $this->createMock(ResponseInterface::class); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockResponseBodyStream->method('__toString')->willReturn(json_encode($responseBody)); + $mockResponse->method('getBody')->willReturn($mockResponseBodyStream); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + $vault = new Vault( + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $this->createMock(StreamFactoryInterface::class), + cache: $this->cacheMock, + vaultUrl: $this->vaultUrl, + ); + + $this->expectException(NotFoundException::class); + $vault->getSecret($token, 'path', 'secret', 'missingKey'); + } + + public function testGetSecretsThrowsOnHttpClientError(): void + { + $token = new Token( + token: 'test-token', + expiresAt: (new \DateTimeImmutable())->add(new \DateInterval('PT300S')), + renewable: false, + roleName: 'test', + numUsesLeft: 0, + ); + + $mockRequest = $this->createMock(RequestInterface::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockClient->method('sendRequest') + ->willThrowException($this->createMock(ClientExceptionInterface::class)); + + $vault = new Vault( + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $this->createMock(StreamFactoryInterface::class), + cache: $this->cacheMock, + vaultUrl: $this->vaultUrl, + ); + + $this->expectException(VaultException::class); + $this->expectExceptionMessageMatches('/Vault fetch failed/'); + $vault->getSecrets($token, 'path', 'secret', ['key']); + } + + public function testGetSecretsThrowsUnknownErrorOnEmptyErrors(): void + { + $token = new Token( + token: 'test-token', + expiresAt: (new \DateTimeImmutable())->add(new \DateInterval('PT300S')), + renewable: false, + roleName: 'test', + numUsesLeft: 0, + ); + + $mockRequest = $this->createMock(RequestInterface::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockResponseBodyStream = $this->createMock(StreamInterface::class); + $mockResponse = $this->createMock(ResponseInterface::class); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockResponseBodyStream->method('__toString') + ->willReturn(json_encode(['errors' => []])); + $mockResponse->method('getBody')->willReturn($mockResponseBodyStream); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + $vault = new Vault( + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $this->createMock(StreamFactoryInterface::class), + cache: $this->cacheMock, + vaultUrl: $this->vaultUrl, + ); + + $this->expectException(UnknownErrorException::class); + $vault->getSecrets($token, 'path', 'secret', ['key']); + } + + public function testGetSecretsThrowsOnVaultErrorResponse(): void + { + $token = new Token( + token: 'test-token', + expiresAt: (new \DateTimeImmutable())->add(new \DateInterval('PT300S')), + renewable: false, + roleName: 'test', + numUsesLeft: 0, + ); + + $mockRequest = $this->createMock(RequestInterface::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockResponseBodyStream = $this->createMock(StreamInterface::class); + $mockResponse = $this->createMock(ResponseInterface::class); + + $mockRequestFactory->method('createRequest')->willReturn($mockRequest); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockResponseBodyStream->method('__toString') + ->willReturn(json_encode(['errors' => ['permission denied']])); + $mockResponse->method('getBody')->willReturn($mockResponseBodyStream); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + $vault = new Vault( + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $this->createMock(StreamFactoryInterface::class), + cache: $this->cacheMock, + vaultUrl: $this->vaultUrl, + ); + + $this->expectException(VaultException::class); + $this->expectExceptionMessageMatches('/Vault failed/'); + $vault->getSecrets($token, 'path', 'secret', ['key']); + } + + public function testGetSecretsWithVersionParameter(): void + { + $token = new Token( + token: 'test-token', + expiresAt: (new \DateTimeImmutable())->add(new \DateInterval('PT300S')), + renewable: false, + roleName: 'test', + numUsesLeft: 0, + ); + + $responseBody = [ + 'data' => [ + 'data' => ['myKey' => 'myValue'], + 'metadata' => [ + 'created_time' => '2022-02-16T20:46:22.151178411Z', + 'version' => 3, + ], + ], + ]; + + $mockRequest = $this->createMock(RequestInterface::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockRequestFactory = $this->createMock(RequestFactoryInterface::class); + $mockResponseBodyStream = $this->createMock(StreamInterface::class); + $mockResponse = $this->createMock(ResponseInterface::class); + + $mockRequestFactory->expects($this->once()) + ->method('createRequest') + ->with('GET', $this->vaultUrl.'/v1/path/data/secret?version=3') + ->willReturn($mockRequest); + $mockRequest->method('withHeader')->willReturnSelf(); + $mockResponseBodyStream->method('__toString')->willReturn(json_encode($responseBody)); + $mockResponse->method('getBody')->willReturn($mockResponseBodyStream); + $mockClient->method('sendRequest')->willReturn($mockResponse); + + $vault = new Vault( + httpClient: $mockClient, + requestFactory: $mockRequestFactory, + streamFactory: $this->createMock(StreamFactoryInterface::class), + cache: $this->cacheMock, + vaultUrl: $this->vaultUrl, + ); + + $secrets = $vault->getSecrets($token, 'path', 'secret', ['myKey'], version: 3); + + $this->assertArrayHasKey('myKey', $secrets); + $this->assertSame('myValue', $secrets['myKey']->value); + $this->assertSame('3', $secrets['myKey']->version); + } + /** * Sets up an in-memory cache. *