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 @@
[](https://github.com/itk-dev/vault-bundle)
[](https://packagist.org/packages/itk-dev/vault-bundle)
[](https://www.php.net/downloads)
-[](https://github.com/itk-dev/vault-bundle/actions?query=workflow%3A%22Test+%26+Code+Style+Review%22)
+[](https://github.com/itk-dev/vault-bundle/actions)
[](https://github.com/itk-dev/vault-bundle/blob/master/LICENSE.md)
[](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,
+ ],
+ ];
+ }
+}