diff --git a/.env b/.env index a72f5ba..7bd1ea5 100644 --- a/.env +++ b/.env @@ -1 +1,4 @@ +###> aakb/itkdev-docker configuration ### COMPOSE_PROJECT_NAME=itkdev-vault-bundle +COMPOSE_DOMAIN=itkdev-vault-bundle.local.itkdev.dk +###< aakb/itkdev-docker configuration ### 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 index d80d889..b2c083f 100644 --- a/.github/workflows/github_build_release.yml +++ b/.github/workflows/github_build_release.yml @@ -1,7 +1,7 @@ on: push: tags: - - '*.*.*' + - "*.*.*" name: Create Github Release @@ -16,7 +16,7 @@ jobs: APP_ENV: prod steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Create a release in GitHub run: gh release create ${{ github.ref_name }} --verify-tag --generate-notes 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 d9eb028..0000000 --- a/.github/workflows/pr.yaml +++ /dev/null @@ -1,113 +0,0 @@ -on: pull_request -name: Review -jobs: - test-composer-install: - runs-on: ubuntu-24.04 - 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 - - php-cs-fixer: - runs-on: ubuntu-24.04 - 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 05c084f..025ebfc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ composer.lock vendor -yarn.lock -node_modules .php-cs-fixer.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 0e4a7e2..6b73353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] * Added support for Symfony 8 +* Added PHPUnit test suite with 88% line coverage +* Added PHPStan static analysis at max level +* Added Taskfile-based development workflow +* Added Docker test matrix for PHP 8.3, 8.4, and 8.5 +* Added split CI workflows (PHP, Composer, Markdown, YAML, Changelog) +* Updated minimum PHP version to 8.3 +* Updated development tooling (markdownlint, Prettier via Docker) +* Updated README and CONTRIBUTING docs with Taskfile commands +* Removed legacy node/yarn tooling ## [0.1.2] diff --git a/README.md b/README.md index bf32fa0..ec0418b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Github](https://img.shields.io/badge/source-itk--dev/vault--bundle-blue?style=flat-square)](https://github.com/itk-dev/vault-bundle) [![Release](https://img.shields.io/packagist/v/itk-dev/vault-bundle.svg?style=flat-square&label=release)](https://packagist.org/packages/itk-dev/vault-bundle) [![PHP Version](https://img.shields.io/packagist/php-v/itk-dev/vault-bundle.svg?style=flat-square&colorB=%238892BF)](https://www.php.net/downloads) -[![Build Status](https://img.shields.io/github/actions/workflow/status/itk-dev/vault-bundle/pr.yaml?label=CI&logo=github&style=flat-square)](https://github.com/itk-dev/vault-bundle/actions?query=workflow%3A%22Test+%26+Code+Style+Review%22) +[![Build Status](https://img.shields.io/github/actions/workflow/status/itk-dev/vault-bundle/php.yaml?label=CI&logo=github&style=flat-square)](https://github.com/itk-dev/vault-bundle/actions) [![Read License](https://img.shields.io/packagist/l/itk-dev/vault-bundle.svg?style=flat-square&colorB=darkcyan)](https://github.com/itk-dev/vault-bundle/blob/master/LICENSE.md) [![Package downloads on Packagist](https://img.shields.io/packagist/dt/itk-dev/vault-bundle.svg?style=flat-square&colorB=darkmagenta)](https://packagist.org/packages/itk-dev/vault-bundle/stats) @@ -80,4 +80,48 @@ to Symfony console to see the options available for the commands. ## Developing +This project uses [Taskfile](https://taskfile.dev/) for development workflows. +Run `task` to see all available commands. + +### Setup + +```shell +task setup +``` + +This starts the Docker containers and installs dependencies. + +### Tests + +```shell +task test +``` + +Run the full test matrix across PHP versions: + +```shell +task test:matrix +``` + +### Code quality + +```shell +task lint # Run all linters (PHP, Composer, Markdown, YAML) +task analyze:php # Run PHPStan static analysis +``` + +Fix code style issues automatically: + +```shell +task lint:php:fix +task lint:markdown:fix +task lint:yaml:fix +``` + +### All CI checks locally + +```shell +task pr:actions +``` + See details on contributing in the [contributing docs](/docs/CONTRIBUTING.md). 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 b76f8db..5e84a21 100644 --- a/composer.json +++ b/composer.json @@ -3,37 +3,51 @@ "description": "Symfony bundle for HashiCorp Vault", "license": "MIT", "type": "symfony-bundle", + "keywords": [ + "Hashicorp", + "vault", + "approle", + "e" + ], "authors": [ { - "name": "Jesper Kristensen", - "email": "cableman@linuxdev.dk", - "homepage": "https://linuxdev.dk", - "role": "Developer" - } + "name": "Jesper Kristensen", + "email": "cableman@linuxdev.dk", + "homepage": "https://linuxdev.dk", + "role": "Developer" + } ], "require": { - "php": "^8.0", - "symfony/http-client": "^6.4 || ^7.0 || ^8.0", - "nyholm/psr7": "^1.8", + "php": ">=8.3", "itk-dev/vault": "^0.1.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "nyholm/psr7": "^1.8", "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/http-client": "^6.4 || ^7.0 || ^8.0", "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { - "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", + "symfony/runtime": "^6.4.13 || ^7.0 || ^8.0" }, "autoload": { "psr-4": { "ItkDev\\VaultBundle\\": "src/" } }, - "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" - ] + "autoload-dev": { + "psr-4": { + "ItkDev\\VaultBundle\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "symfony/runtime": true + } } } diff --git a/config/services.yaml b/config/services.yaml index 34ecfaf..1747966 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -1,27 +1,27 @@ services: - # default configuration for services in *this* file - _defaults: - autowire: true # Automatically injects dependencies in your services. - autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. - # Register a PSR-6 to PSR-16 cache bridge - cache.simple: - class: Symfony\Component\Cache\Psr16Cache - arguments: - - '@cache.app' # Pass the PSR-6 adapter + # Register a PSR-6 to PSR-16 cache bridge + cache.simple: + class: Symfony\Component\Cache\Psr16Cache + arguments: + - "@cache.app" # Pass the PSR-6 adapter - # Alias the service for autowiring purposes - Psr\SimpleCache\CacheInterface: '@cache.simple' + # Alias the service for autowiring purposes + Psr\SimpleCache\CacheInterface: "@cache.simple" - ItkDev\VaultBundle\Service\Vault: - arguments: - $cache: '@Psr\SimpleCache\CacheInterface' + ItkDev\VaultBundle\Service\Vault: + arguments: + $cache: '@Psr\SimpleCache\CacheInterface' - ItkDev\VaultBundle\Command\VaultLoginCommand: + ItkDev\VaultBundle\Command\VaultLoginCommand: - ItkDev\VaultBundle\Command\VaultSecretCommand: + ItkDev\VaultBundle\Command\VaultSecretCommand: - # Register the VaultEnvResolver - ItkDev\VaultBundle\Processor\VaultEnvResolver: - tags: - - { name: 'container.env_var_processor', priority: 100 } + # Register the VaultEnvResolver + ItkDev\VaultBundle\Processor\VaultEnvResolver: + tags: + - { name: "container.env_var_processor", priority: 100 } diff --git a/docker-compose.yml b/docker-compose.yml index 4ca74c7..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,19 +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 + phpfpm84: + extends: + service: phpfpm + image: itkdev/php8.4-fpm:latest + profiles: + - ci - node: - image: node:20 - networks: - - app - extra_hosts: - - "host.docker.internal:host-gateway" - working_dir: /app + 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/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ea0e962..42184b6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,36 +1,87 @@ # Contributing -This document describes various tools used during development of this bundle. +This document describes various tools used during development of this library. + +## Prerequisites + +- [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) +- [Taskfile](https://taskfile.dev/) (`task`) ## Install -To install the dependencies required for the development and usage of this -library, run `composer install` through the supplied docker compose setup. +Set up the project (starts Docker containers and installs dependencies): + +```shell +task setup +``` + +Or step by step: ```shell -docker compose run --rm phpfpm composer install +task up +task composer:install +``` + +## Tests + +We use the [PHPUnit](https://phpunit.de/) testing framework. + +Run tests: + +```shell +task test +``` + +Run tests with coverage: + +```shell +task test:coverage +``` + +Run the full test matrix across PHP versions and dependency sets (mirrors CI): + +```shell +task test:matrix +``` + +## Static analysis + +Run [PHPStan](https://phpstan.org/) at max level: + +```shell +task analyze:php ``` ## Check coding standards -The following commands let you test that the code follows the coding -standards we decided to adhere to in this project. +Run all linters (PHP, Composer, Markdown, YAML): ```shell -docker compose run --rm phpfpm composer coding-standards-check +task lint ``` -### Check Markdown file +Or individually: ```shell -docker compose run --rm node yarn install -docker compose run --rm node yarn run coding-standards-check +task lint:php # PHP CS Fixer (dry-run) +task lint:composer # Validate, normalize, audit +task lint:markdown # markdownlint +task lint:yaml # Prettier ``` ## Apply coding standards -You can automatically fix some coding styles issues by running: +Fix code style issues automatically: + +```shell +task lint:php:fix +task lint:markdown:fix +task lint:yaml:fix +task composer:normalize +``` + +## Run all CI checks locally ```shell -docker compose run --rm phpfpm composer coding-standards-apply +task pr:actions ``` 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 new file mode 100644 index 0000000..868aa24 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Command/VaultLoginCommand.php b/src/Command/VaultLoginCommand.php index 6411bb9..2a601be 100644 --- a/src/Command/VaultLoginCommand.php +++ b/src/Command/VaultLoginCommand.php @@ -44,7 +44,9 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + /** @var string $enginePath */ $enginePath = $input->getOption('engine-path'); + /** @var bool $refresh */ $refresh = $input->getOption('refresh'); $token = $this->vaultService->login($this->roleId, $this->secretId, $enginePath, $refresh); diff --git a/src/Command/VaultSecretCommand.php b/src/Command/VaultSecretCommand.php index cc5d739..4528243 100644 --- a/src/Command/VaultSecretCommand.php +++ b/src/Command/VaultSecretCommand.php @@ -65,13 +65,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (empty($keys)) { throw new InvalidArgumentException('At least one key must be specified.'); } - $version = $input->getOption('version-id'); + /** @var string|null $versionRaw */ + $versionRaw = $input->getOption('version-id'); + $version = null !== $versionRaw ? (int) $versionRaw : null; + /** @var bool $useCache */ $useCache = $input->getOption('useCache'); - $expire = (int) $input->getOption('expire'); + /** @var string|null $expireRaw */ + $expireRaw = $input->getOption('expire'); + $expire = (int) ($expireRaw ?? 0); + /** @var bool $refresh */ $refresh = $input->getOption('refresh'); $token = $this->vaultService->login($this->roleId, $this->secretId); + /** @var string $path */ + /** @var string $secret */ + /** @var array $keys */ $secrets = $this->vaultService->getSecrets( token: $token, path: $path, diff --git a/src/ItkDevVaultBundle.php b/src/ItkDevVaultBundle.php index 7f16c44..5b8f4d6 100644 --- a/src/ItkDevVaultBundle.php +++ b/src/ItkDevVaultBundle.php @@ -31,6 +31,9 @@ public function configure(DefinitionConfigurator $definition): void ; } + /** + * @param array $config + */ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { // Load an XML, PHP or YAML file diff --git a/src/Processor/VaultEnvResolver.php b/src/Processor/VaultEnvResolver.php index 371e237..c7e32cb 100644 --- a/src/Processor/VaultEnvResolver.php +++ b/src/Processor/VaultEnvResolver.php @@ -27,6 +27,7 @@ public function __construct( */ public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed { + /** @var string $nameValue */ $nameValue = $getEnv($name); $params = explode(':', $nameValue); diff --git a/src/Service/Vault.php b/src/Service/Vault.php index 9cd193b..bb96b83 100644 --- a/src/Service/Vault.php +++ b/src/Service/Vault.php @@ -56,6 +56,10 @@ public function getSecret(Token $token, string $path, string $secret, string $ke } /** + * @param array $keys + * + * @return array + * * @throws VaultException * @throws \DateMalformedStringException * @throws InvalidArgumentException @@ -63,6 +67,7 @@ public function getSecret(Token $token, string $path, string $secret, string $ke */ public function getSecrets(Token $token, string $path, string $secret, array $keys, ?int $version = null, bool $useCache = false, bool $refreshCache = false, int $expire = 0): array { + /** @var array */ return $this->getVault()->getSecrets( token: $token, path: $path, @@ -83,6 +88,7 @@ public function getSecrets(Token $token, string $path, string $secret, array $ke */ private function getVault(): VaultClient { + /** @var VaultClient|null $vaultClient */ static $vaultClient = null; if (is_null($vaultClient)) { diff --git a/tests/Command/VaultLoginCommandTest.php b/tests/Command/VaultLoginCommandTest.php new file mode 100644 index 0000000..c13a15f --- /dev/null +++ b/tests/Command/VaultLoginCommandTest.php @@ -0,0 +1,117 @@ +createToken(); + + $vault = $this->createMock(Vault::class); + $vault->expects($this->once()) + ->method('login') + ->with('role-id', 'secret-id', 'approle', false) + ->willReturn($token); + + $tester = $this->createCommandTester($vault); + $exitCode = $tester->execute([]); + $output = $tester->getDisplay(); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('hvs.test-token', $output); + $this->assertStringContainsString('test-role', $output); + $this->assertStringContainsString('Yes', $output); // renewable + $this->assertStringContainsString('10', $output); // uses left + } + + #[Test] + public function executeWithEnginePath(): void + { + $vault = $this->createMock(Vault::class); + $vault->expects($this->once()) + ->method('login') + ->with('role-id', 'secret-id', 'custom-engine', false) + ->willReturn($this->createToken()); + + $tester = $this->createCommandTester($vault); + $tester->execute(['--engine-path' => 'custom-engine']); + } + + #[Test] + public function executeWithRefresh(): void + { + $vault = $this->createMock(Vault::class); + $vault->expects($this->once()) + ->method('login') + ->with('role-id', 'secret-id', 'approle', true) + ->willReturn($this->createToken()); + + $tester = $this->createCommandTester($vault); + $tester->execute(['--refresh' => true]); + } + + #[Test] + public function executeDisplaysExpiredToken(): void + { + $token = $this->createExpiredToken(); + + $vault = $this->createStub(Vault::class); + $vault->method('login')->willReturn($token); + + $tester = $this->createCommandTester($vault); + $tester->execute([]); + $output = $tester->getDisplay(); + + // Renewable should show "No" for non-renewable token + $this->assertMatchesRegularExpression('/Renewable\s+No/', $output); + // Is Expired should show "Yes" for expired token + $this->assertMatchesRegularExpression('/Is Expired\s+Yes/', $output); + } + + private function createCommandTester(Vault $vault): CommandTester + { + $command = new VaultLoginCommand($vault, 'role-id', 'secret-id'); + $application = new Application(); + // addCommand() is Symfony 7.1+, add() is the legacy name + if (method_exists($application, 'addCommand')) { + $application->addCommand($command); + } else { + $application->add($command); + } + + return new CommandTester($application->find('itkdev:vault:login')); + } + + private function createToken(): Token + { + return new Token( + token: 'hvs.test-token', + expiresAt: new \DateTimeImmutable('+1 hour', new \DateTimeZone('UTC')), + renewable: true, + roleName: 'test-role', + numUsesLeft: 10, + ); + } + + private function createExpiredToken(): Token + { + return new Token( + token: 'hvs.expired', + expiresAt: new \DateTimeImmutable('-1 hour', new \DateTimeZone('UTC')), + renewable: false, + roleName: 'expired-role', + numUsesLeft: 0, + ); + } +} diff --git a/tests/Command/VaultSecretCommandTest.php b/tests/Command/VaultSecretCommandTest.php new file mode 100644 index 0000000..95ad44c --- /dev/null +++ b/tests/Command/VaultSecretCommandTest.php @@ -0,0 +1,176 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('path'); + + $tester = $this->createCommandTester(); + $tester->execute([ + '--secret' => 'db', + '--key' => ['password'], + ]); + } + + #[Test] + public function executeMissingSecret(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('secret'); + + $tester = $this->createCommandTester(); + $tester->execute([ + '--path' => 'prod', + '--key' => ['password'], + ]); + } + + #[Test] + public function executeMissingKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('key'); + + $tester = $this->createCommandTester(); + $tester->execute([ + '--path' => 'prod', + '--secret' => 'db', + ]); + } + + #[Test] + public function executeSuccess(): void + { + $token = $this->createToken(); + $secrets = [ + $this->createSecret('password', 's3cret'), + ]; + + $vault = $this->createMock(Vault::class); + $vault->expects($this->once()) + ->method('login') + ->with('role-id', 'secret-id') + ->willReturn($token); + + $vault->expects($this->once()) + ->method('getSecrets') + ->with( + $token, + 'prod', + 'db', + ['password'], + null, // version + false, // useCache + false, // refreshCache + 0, // expire + ) + ->willReturn($secrets); + + $tester = $this->createCommandTester($vault); + $exitCode = $tester->execute([ + '--path' => 'prod', + '--secret' => 'db', + '--key' => ['password'], + ]); + $output = $tester->getDisplay(); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('password', $output); + $this->assertStringContainsString('s3cret', $output); + } + + #[Test] + public function executeWithAllOptions(): void + { + $token = $this->createToken(); + $secrets = [ + $this->createSecret('user', 'admin'), + $this->createSecret('pass', 'p@ss'), + ]; + + $vault = $this->createMock(Vault::class); + $vault->method('login')->willReturn($token); + + $vault->expects($this->once()) + ->method('getSecrets') + ->with( + $token, + 'prod', + 'db', + ['user', 'pass'], + 3, // version (cast from string) + true, // useCache + true, // refreshCache + 600, // expire + ) + ->willReturn($secrets); + + $tester = $this->createCommandTester($vault); + $exitCode = $tester->execute([ + '--path' => 'prod', + '--secret' => 'db', + '--key' => ['user', 'pass'], + '--version-id' => '3', + '--useCache' => true, + '--expire' => '600', + '--refresh' => true, + ]); + $output = $tester->getDisplay(); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('admin', $output); + $this->assertStringContainsString('p@ss', $output); + } + + private function createCommandTester(?Vault $vault = null): CommandTester + { + $vault ??= $this->createStub(Vault::class); + $command = new VaultSecretCommand($vault, 'role-id', 'secret-id'); + $application = new Application(); + // addCommand() is Symfony 7.1+, add() is the legacy name + if (method_exists($application, 'addCommand')) { + $application->addCommand($command); + } else { + $application->add($command); + } + + return new CommandTester($application->find('itkdev:vault:secret')); + } + + private function createToken(): Token + { + return new Token( + token: 'hvs.test-token', + expiresAt: new \DateTimeImmutable('+1 hour', new \DateTimeZone('UTC')), + renewable: true, + roleName: 'test-role', + numUsesLeft: 10, + ); + } + + private function createSecret(string $key, string $value): Secret + { + return new Secret( + key: $key, + value: $value, + version: '1', + createdAt: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('UTC')), + ); + } +} diff --git a/tests/ItkDevVaultBundleTest.php b/tests/ItkDevVaultBundleTest.php new file mode 100644 index 0000000..34c701e --- /dev/null +++ b/tests/ItkDevVaultBundleTest.php @@ -0,0 +1,52 @@ +getContainerExtension()->getConfiguration([], $this->createStub(ContainerBuilder::class)); + + $processor = new Processor(); + $config = $processor->processConfiguration($configuration, [ + 'itkdev_vault' => [ + 'role_id' => 'test-role', + 'secret_id' => 'test-secret', + 'url' => 'http://vault.test', + ], + ]); + + $this->assertSame('test-role', $config['role_id']); + $this->assertSame('test-secret', $config['secret_id']); + $this->assertSame('http://vault.test', $config['url']); + } + + #[Test] + public function configureRequiresRoleId(): void + { + $bundle = new ItkDevVaultBundle(); + $configuration = $bundle->getContainerExtension()->getConfiguration([], $this->createStub(ContainerBuilder::class)); + + $this->expectException(InvalidConfigurationException::class); + + $processor = new Processor(); + $processor->processConfiguration($configuration, [ + 'itkdev_vault' => [ + 'secret_id' => 'test-secret', + 'url' => 'http://vault.test', + ], + ]); + } +} diff --git a/tests/Processor/VaultEnvResolverTest.php b/tests/Processor/VaultEnvResolverTest.php new file mode 100644 index 0000000..9024277 --- /dev/null +++ b/tests/Processor/VaultEnvResolverTest.php @@ -0,0 +1,146 @@ +vaultService = $this->createMock(Vault::class); + $this->resolver = new VaultEnvResolver( + $this->vaultService, + 'test-role-id', + 'test-secret-id', + ); + } + + #[Test] + public function getEnvBasic(): void + { + $token = $this->createToken(); + $secret = $this->createSecret(); + + $this->vaultService->expects($this->once()) + ->method('login') + ->with('test-role-id', 'test-secret-id') + ->willReturn($token); + + $this->vaultService->expects($this->once()) + ->method('getSecret') + ->with( + $token, + 'prod', + 'my-secret', + 'api-key', + null, // version + false, // useCache + false, // refreshCache + 0, // expire + ) + ->willReturn($secret); + + $getEnv = fn (string $name) => 'prod:my-secret:api-key'; + + $result = $this->resolver->getEnv('vault', 'MY_VAR', $getEnv); + + $this->assertSame('secret-value', $result); + } + + #[Test] + public function getEnvWithVersion(): void + { + $token = $this->createToken(); + $secret = $this->createSecret(); + + $this->vaultService->method('login')->willReturn($token); + + $this->vaultService->expects($this->once()) + ->method('getSecret') + ->with( + $token, + 'prod', + 'my-secret', + 'api-key', + 3, // version (string "3" coerced to int) + false, // useCache + false, // refreshCache + 0, // expire + ) + ->willReturn($secret); + + $getEnv = fn (string $name) => 'prod:my-secret:api-key:3'; + + $result = $this->resolver->getEnv('vault', 'MY_VAR', $getEnv); + + $this->assertSame('secret-value', $result); + } + + #[Test] + public function getEnvWithVersionAndExpire(): void + { + $token = $this->createToken(); + $secret = $this->createSecret(); + + $this->vaultService->method('login')->willReturn($token); + + $this->vaultService->expects($this->once()) + ->method('getSecret') + ->with( + $token, + 'prod', + 'my-secret', + 'api-key', + 5, // version + true, // useCache (expire is not null) + false, // refreshCache + 600, // expire + ) + ->willReturn($secret); + + $getEnv = fn (string $name) => 'prod:my-secret:api-key:5:600'; + + $result = $this->resolver->getEnv('vault', 'MY_VAR', $getEnv); + + $this->assertSame('secret-value', $result); + } + + #[Test] + #[AllowMockObjectsWithoutExpectations] + public function getProvidedTypes(): void + { + $this->assertSame(['vault' => 'string'], VaultEnvResolver::getProvidedTypes()); + } + + private function createToken(): Token + { + return new Token( + token: 'hvs.test-token', + expiresAt: new \DateTimeImmutable('+1 hour', new \DateTimeZone('UTC')), + renewable: true, + roleName: 'test-role', + numUsesLeft: 10, + ); + } + + private function createSecret(string $value = 'secret-value'): Secret + { + return new Secret( + key: 'api-key', + value: $value, + version: '1', + createdAt: new \DateTimeImmutable('2025-01-01', new \DateTimeZone('UTC')), + ); + } +} diff --git a/tests/Service/VaultTest.php b/tests/Service/VaultTest.php new file mode 100644 index 0000000..96d9ded --- /dev/null +++ b/tests/Service/VaultTest.php @@ -0,0 +1,144 @@ +createStub(ClientInterface::class); + $httpClient->method('sendRequest') + ->willReturn(new Response(200, [], json_encode($this->loginResponseData()))); + + $vault = $this->createVaultService($httpClient); + $token = $vault->login('test-role', 'test-secret'); + + $this->assertSame('hvs.test-token-abc', $token->token); + $this->assertTrue($token->renewable); + $this->assertSame('test-role', $token->roleName); + $this->assertSame(10, $token->usesLeft()); + $this->assertFalse($token->isExpired()); + } + + #[Test] + public function loginPassesEnginePath(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->with($this->callback(function (RequestInterface $request): bool { + return str_contains((string) $request->getUri(), '/v1/auth/custom-engine/login'); + })) + ->willReturn(new Response(200, [], json_encode($this->loginResponseData()))); + + $vault = $this->createVaultService($httpClient); + $vault->login('test-role', 'test-secret', 'custom-engine'); + } + + #[Test] + public function getSecretSuccess(): void + { + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest') + ->willReturnOnConsecutiveCalls( + new Response(200, [], json_encode($this->loginResponseData())), + new Response(200, [], json_encode($this->secretResponseData(['api-key' => 'my-secret-value']))), + ); + + $vault = $this->createVaultService($httpClient); + $token = $vault->login('test-role', 'test-secret'); + $secret = $vault->getSecret($token, 'secret', 'my-app', 'api-key'); + + $this->assertSame('api-key', $secret->key); + $this->assertSame('my-secret-value', $secret->value); + $this->assertSame(1, (int) $secret->version); + } + + #[Test] + public function getSecretsMultipleKeys(): void + { + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest') + ->willReturnOnConsecutiveCalls( + new Response(200, [], json_encode($this->loginResponseData())), + new Response(200, [], json_encode($this->secretResponseData([ + 'user' => 'admin', + 'pass' => 'p@ssw0rd', + ]))), + ); + + $vault = $this->createVaultService($httpClient); + $token = $vault->login('test-role', 'test-secret'); + $secrets = $vault->getSecrets($token, 'secret', 'db', ['user', 'pass']); + + $this->assertCount(2, $secrets); + $this->assertSame('admin', $secrets['user']->value); + $this->assertSame('p@ssw0rd', $secrets['pass']->value); + } + + #[Test] + public function loginVaultError(): void + { + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest') + ->willReturn(new Response(200, [], json_encode([ + 'errors' => ['permission denied'], + ]))); + + $vault = $this->createVaultService($httpClient); + + $this->expectException(VaultException::class); + $vault->login('bad-role', 'bad-secret'); + } + + private function createVaultService(ClientInterface $httpClient): Vault + { + $factory = new Psr17Factory(); + $cache = $this->createStub(CacheInterface::class); + $cache->method('get')->willReturn(null); + + return new Vault($httpClient, $factory, $factory, $cache, 'http://vault.test'); + } + + private function loginResponseData(): array + { + return [ + 'auth' => [ + 'client_token' => 'hvs.test-token-abc', + 'lease_duration' => 3600, + 'renewable' => true, + 'metadata' => ['role_name' => 'test-role'], + 'num_uses' => 10, + ], + ]; + } + + /** + * @param array $data + */ + private function secretResponseData(array $data): array + { + return [ + 'data' => [ + 'metadata' => [ + 'created_time' => '2025-01-01T00:00:00.000000Z', + 'version' => 1, + ], + 'data' => $data, + ], + ]; + } +}