From c778582268c5cc32fe4792ec3285436c1a2880c0 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 10:49:35 +0100 Subject: [PATCH 01/14] 7041: Update dev environment setup Add Taskfile, PHPStan config, LICENSE, README badges, and update docker-compose with PHP 8.3-8.5 matrix services. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env | 3 + LICENSE.md | 21 ++++ README.md | 8 ++ Taskfile.yml | 281 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 116 +++++++++++++++++-- phpstan.neon | 4 + 6 files changed, 422 insertions(+), 11 deletions(-) create mode 100644 LICENSE.md create mode 100644 Taskfile.yml create mode 100644 phpstan.neon 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/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..e153644 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # 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/pr.yaml?label=CI&logo=github&style=flat-square)](https://github.com/itk-dev/vault-library/actions?query=workflow%3A%22Test+%26+Code+Style+Review%22) +[![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. diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..a31fd95 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,281 @@ +# 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 + 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}} 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/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/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 From 3a8e4497d514f580df20fae2815983e441a34cee Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 10:49:50 +0100 Subject: [PATCH 02/14] 7041: Replace monolithic PR workflow with individual CI workflows Split pr.yaml into dedicated workflows for changelog, composer, markdown, PHP (coding standards, PHPStan, unit tests), and YAML linting. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/changelog.yaml | 29 ++++ .github/workflows/composer.yaml | 80 +++++++++++ .github/workflows/github_build_release.yml | 25 ++++ .github/workflows/markdown.yaml | 44 ++++++ .github/workflows/php.yaml | 85 ++++++++++++ .github/workflows/pr.yaml | 153 --------------------- .github/workflows/yaml.yaml | 41 ++++++ 7 files changed, 304 insertions(+), 153 deletions(-) create mode 100644 .github/workflows/changelog.yaml create mode 100644 .github/workflows/composer.yaml create mode 100644 .github/workflows/github_build_release.yml create mode 100644 .github/workflows/markdown.yaml create mode 100644 .github/workflows/php.yaml delete mode 100644 .github/workflows/pr.yaml create mode 100644 .github/workflows/yaml.yaml diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml new file mode 100644 index 0000000..e0cd8a0 --- /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..f807f83 --- /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..9246e0a --- /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..4aa1189 --- /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..0220370 --- /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..2e910c0 --- /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 From 6a4cc2f1c9c2a02e12b63732063521ca6c780fef Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 10:50:06 +0100 Subject: [PATCH 03/14] 7041: Upgrade to PHPUnit 12 and normalize composer.json Bump phpunit/phpunit from ^11.3 to ^12.0, add dev dependencies (phpstan, rector, composer-normalize, symfony/runtime), and update phpunit.xml for PHPUnit 12 schema compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- composer.json | 35 +++++++++++++++++++++++++---------- phpunit.xml | 9 ++------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/composer.json b/composer.json index e05613a..0248ee7 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,11 +17,7 @@ "role": "Developer" } ], - "autoload": { - "psr-4": { - "ItkDev\\Vault\\": "src/Vault" - } - }, + "homepage": "https://github.com/itk-dev", "require": { "php": ">=8.2", "psr/http-client": "^1.0", @@ -25,8 +25,23 @@ "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", + "symfony/runtime": "^6.4.13 || ^7.0 || ^8.0" + }, + "autoload": { + "psr-4": { + "ItkDev\\Vault\\": "src/Vault" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "symfony/runtime": true + } }, "scripts": { "coding-standards-apply": [ 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 From 56cabed4908101a3ff3d73f0876e4ed131e1966a Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 10:53:40 +0100 Subject: [PATCH 04/14] 7041: Fix PHPStan errors at max level Add type annotations for json_decode results, narrow cache return types with instanceof/is_array checks, and specify generic return types for getSecrets(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Vault/Model/Token.php | 1 - src/Vault/Vault.php | 41 ++++++++++++++++++++++++------------ src/Vault/VaultInterface.php | 2 +- 3 files changed, 28 insertions(+), 16 deletions(-) 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 From 024f0987ba22a1ab4fbbf0799b0a1332b303c020 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:05:40 +0100 Subject: [PATCH 05/14] 7041: Apply prettier YAML formatting (2-space indent) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/changelog.yaml | 28 +- .github/workflows/composer.yaml | 80 ++-- .github/workflows/github_build_release.yml | 34 +- .github/workflows/markdown.yaml | 34 +- .github/workflows/php.yaml | 138 +++--- .github/workflows/yaml.yaml | 28 +- Taskfile.yml | 518 ++++++++++----------- 7 files changed, 415 insertions(+), 445 deletions(-) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index e0cd8a0..63638c2 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -9,21 +9,21 @@ name: Changelog on: - pull_request: + pull_request: jobs: - changelog: - runs-on: ubuntu-latest - strategy: - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 2 + 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: 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 + - 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 index f807f83..f1b0f6e 100644 --- a/.github/workflows/composer.yaml +++ b/.github/workflows/composer.yaml @@ -26,55 +26,55 @@ name: Composer env: - COMPOSE_USER: runner + COMPOSE_USER: runner on: - pull_request: - push: - branches: - - main - - develop + pull_request: + push: + branches: + - main + - develop jobs: - composer-validate: - runs-on: ubuntu-latest - strategy: - fail-fast: false - steps: - - uses: actions/checkout@v6 + composer-validate: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 - - name: Create docker network - run: | - docker network create frontend + - name: Create docker network + run: | + docker network create frontend - - run: | - docker compose run --rm phpfpm composer validate --strict + - run: | + docker compose run --rm phpfpm composer validate --strict - composer-normalized: - runs-on: ubuntu-latest - strategy: - fail-fast: false - steps: - - uses: actions/checkout@v6 + composer-normalized: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 - - name: Create docker network - run: | - docker network create frontend + - 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 + - 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 + composer-audit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 - - name: Create docker network - run: | - docker network create frontend + - name: Create docker network + run: | + docker network create frontend - - run: | - docker compose run --rm phpfpm composer audit + - 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 9246e0a..b2c083f 100644 --- a/.github/workflows/github_build_release.yml +++ b/.github/workflows/github_build_release.yml @@ -1,25 +1,25 @@ on: - push: - tags: - - "*.*.*" + push: + tags: + - "*.*.*" name: Create Github Release permissions: - contents: write + contents: write jobs: - create-release: - runs-on: ubuntu-latest - env: - COMPOSER_ALLOW_SUPERUSER: 1 - APP_ENV: prod - steps: - - name: Checkout - uses: actions/checkout@v6 + 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 + - 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 index 4aa1189..8f0fc25 100644 --- a/.github/workflows/markdown.yaml +++ b/.github/workflows/markdown.yaml @@ -21,24 +21,24 @@ name: Markdown on: - pull_request: - push: - branches: - - main - - develop + pull_request: + push: + branches: + - main + - develop jobs: - markdown-lint: - runs-on: ubuntu-latest - strategy: - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 + 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 + - name: Create docker network + run: | + docker network create frontend - - run: | - docker compose run --rm markdownlint markdownlint '**/*.md' + - run: | + docker compose run --rm markdownlint markdownlint '**/*.md' diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml index 0220370..ec59121 100644 --- a/.github/workflows/php.yaml +++ b/.github/workflows/php.yaml @@ -1,85 +1,85 @@ name: PHP env: - COMPOSE_USER: runner + COMPOSE_USER: runner on: - pull_request: - push: - branches: - - main - - develop + pull_request: + push: + branches: + - main + - develop jobs: - coding-standards: - name: PHP - Check Coding Standards - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 + 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 + - 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 + - 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 + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 - - name: Create docker network - run: | - docker network create frontend + - 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 + - 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 + 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 + - 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 + - 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 + - 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/yaml.yaml b/.github/workflows/yaml.yaml index 2e910c0..299d4e1 100644 --- a/.github/workflows/yaml.yaml +++ b/.github/workflows/yaml.yaml @@ -21,21 +21,21 @@ name: YAML on: - pull_request: - push: - branches: - - main - - develop + pull_request: + push: + branches: + - main + - develop jobs: - yaml-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 + yaml-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 - - name: Create docker network - run: | - docker network create frontend + - name: Create docker network + run: | + docker network create frontend - - run: | - docker compose run --rm prettier '**/*.{yml,yaml}' --check + - run: | + docker compose run --rm prettier '**/*.{yml,yaml}' --check diff --git a/Taskfile.yml b/Taskfile.yml index a31fd95..255fa59 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,279 +3,249 @@ version: "3" vars: - DOCKER_COMPOSE: "docker compose" - PHP: "{{.DOCKER_COMPOSE}} exec phpfpm" - COMPOSER: "{{.PHP}} composer" + 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 - 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}} 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 + 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 + 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}} 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 From a24002c639a0977a0eccdab58c28c64010ff95be Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:05:47 +0100 Subject: [PATCH 06/14] 7041: Add markdownlint configuration Co-Authored-By: Claude Opus 4.6 (1M context) --- .markdownlint.jsonc | 22 ++++++++++++++++++++++ .markdownlintignore | 12 ++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 .markdownlint.jsonc create mode 100644 .markdownlintignore 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/ From 11f46ff3fd471b8a143dd964ad7dc05623819427 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:05:54 +0100 Subject: [PATCH 07/14] 7041: Update php-cs-fixer config and bump minimum PHP to 8.3 Allow @var PHPDoc annotations via phpdoc_to_comment ignored_tags, and bump minimum PHP version to match PHPUnit 12 requirement. Co-Authored-By: Claude Opus 4.6 (1M context) --- .php-cs-fixer.dist.php | 32 ++++++++++++++++++++------------ composer.json | 2 +- 2 files changed, 21 insertions(+), 13 deletions(-) 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/composer.json b/composer.json index 0248ee7..6da0c17 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ ], "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" From 1d03b459db6ea8bccc1d66ddc8636d256823ef25 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:16:26 +0100 Subject: [PATCH 08/14] 7041: Add tests for error paths and Token model (94% coverage) Add TokenTest covering isExpired, used, and usesLeft methods. Add VaultTest error path tests for HTTP failures, vault error responses, missing keys, cache refresh, and version parameter. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/TokenTest.php | 71 ++++++++++ tests/VaultTest.php | 313 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 tests/TokenTest.php 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. * From 8d36975600ffe238e54845230ecbb98eba7f3634 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:19:31 +0100 Subject: [PATCH 09/14] 7041: Update Chnagelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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] From 8bb0d3c81a36292559801d71fb700535f6c15002 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:37:48 +0100 Subject: [PATCH 10/14] 7041: Update Readme --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e153644..12f5838 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![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/pr.yaml?label=CI&logo=github&style=flat-square)](https://github.com/itk-dev/vault-library/actions?query=workflow%3A%22Test+%26+Code+Style+Review%22) +[![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) @@ -12,7 +12,11 @@ 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. @@ -21,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 +``` From 17b5419578c0a4094178da0db50b91921667b5e4 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:40:24 +0100 Subject: [PATCH 11/14] 7041: Cleanup scripts in composer.json after migration to Taskfile --- composer.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/composer.json b/composer.json index 6da0c17..ab0b923 100644 --- a/composer.json +++ b/composer.json @@ -42,13 +42,5 @@ "ergebnis/composer-normalize": true, "symfony/runtime": true } - }, - "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" - ] } } From 4426d160da1824b3ce246bb0b097c497d749a9e7 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:43:50 +0100 Subject: [PATCH 12/14] 7041: Remove redundant symfony/runtime --- composer.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index ab0b923..deb1728 100644 --- a/composer.json +++ b/composer.json @@ -29,8 +29,7 @@ "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" + "rector/rector": "^2.0" }, "autoload": { "psr-4": { @@ -39,8 +38,7 @@ }, "config": { "allow-plugins": { - "ergebnis/composer-normalize": true, - "symfony/runtime": true + "ergebnis/composer-normalize": true } } } From 8ba9797c40af89b492fde12e74b4e16dc0e00190 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 11:50:44 +0100 Subject: [PATCH 13/14] 7041: Cleanup --- .markdownlint.json | 11 ----------- README.md | 18 +++++++++--------- package.json | 14 -------------- 3 files changed, 9 insertions(+), 34 deletions(-) delete mode 100644 .markdownlint.json delete mode 100644 package.json 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/README.md b/README.md index 12f5838..35f7698 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,15 @@ This starts the Docker containers and installs Composer dependencies. 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 | +| 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 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" - } -} From f1d7d771fc4a20c3704f9eb96a2ed76e9fd89067 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 24 Mar 2026 12:40:18 +0100 Subject: [PATCH 14/14] 7041: Taskfile corrections --- .gitignore | 1 + Taskfile.yml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/Taskfile.yml b/Taskfile.yml index 255fa59..0eb711d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -162,6 +162,7 @@ tasks: 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' @@ -170,7 +171,7 @@ tasks: test:matrix:reset: desc: Remove cached vendor volumes to force a fresh dependency resolve cmds: - - "{{.DOCKER_COMPOSE}} down --volumes" + - "{{.DOCKER_COMPOSE}} --profile ci down --volumes" test:matrix: desc: Run tests across all PHP versions (mirrors CI matrix)