diff --git a/.gitattributes b/.gitattributes index 449e70a..0a044ac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,17 @@ * text=auto eol=lf +/.editorconfig export-ignore +/.env export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore /.github export-ignore -/docs export-ignore +/docker export-ignore /tests export-ignore +/CODE_OF_CONDUCT.md export-ignore +/CONTRIBUTING.md export-ignore +/Makefile export-ignore +/SECURITY.md export-ignore /phpunit.xml.dist export-ignore +/phpunit.postgresql.xml.dist export-ignore /phpstan.neon.dist export-ignore /.php-cs-fixer.dist.php export-ignore diff --git a/.github/ci/assert-versions.php b/.github/ci/assert-versions.php new file mode 100644 index 0000000..4ee805e --- /dev/null +++ b/.github/ci/assert-versions.php @@ -0,0 +1,139 @@ + \n"); + + exit(2); +} + +[, $expectedPhpMinor, $expectedSymfonyConstraint, $expectedDoctrineBundleMajor, $expectedDoctrineOrmMajor, $expectedDoctrineDbalMajor, $expectedDoctrinePersistenceMajor] = $argv; +$expectedSymfonyMinor = preg_replace('/\.\*$/', '', $expectedSymfonyConstraint); + +if (null === $expectedSymfonyMinor) { + fwrite(STDERR, "Invalid Symfony version constraint.\n"); + + exit(2); +} + +$packages = [ + 'symfony/framework-bundle', + 'doctrine/doctrine-bundle', + 'doctrine/orm', + 'doctrine/dbal', + 'doctrine/persistence', + 'symfony/polyfill-mbstring', + 'phpunit/phpunit', + 'phpstan/phpstan', + 'friendsofphp/php-cs-fixer', +]; + +printf("PHP: %s\n", PHP_VERSION); + +foreach ($packages as $package) { + printf("%s: %s\n", $package, InstalledVersions::getPrettyVersion($package) ?? 'unknown'); +} + +$expectedPhpStanMajor = getenv('EXPECT_PHPSTAN_MAJOR'); +if (false !== $expectedPhpStanMajor && '' !== $expectedPhpStanMajor) { + if (!ctype_digit($expectedPhpStanMajor)) { + fwrite(STDERR, "EXPECT_PHPSTAN_MAJOR must be a positive integer.\n"); + + exit(2); + } + + $assertedPhpStanVersion = InstalledVersions::getVersion('phpstan/phpstan'); + if (null === $assertedPhpStanVersion || !str_starts_with(ltrim($assertedPhpStanVersion, 'v'), $expectedPhpStanMajor.'.')) { + fwrite(STDERR, sprintf( + "Expected phpstan/phpstan %s.x, resolved %s.\n", + $expectedPhpStanMajor, + InstalledVersions::getPrettyVersion('phpstan/phpstan') ?? 'unknown', + )); + + exit(1); + } +} + +if (!InstalledVersions::isInstalled('symfony/polyfill-mbstring')) { + fwrite(STDERR, "symfony/polyfill-mbstring is not installed.\n"); + + exit(1); +} + +$nativeMbstring = extension_loaded('mbstring'); +$iconv = extension_loaded('iconv'); +$mbStrlen = function_exists('mb_strlen'); +$mbSubstr = function_exists('mb_substr'); + +printf("Native mbstring: %s\n", $nativeMbstring ? 'loaded' : 'not loaded'); +printf("Native iconv: %s\n", $iconv ? 'loaded' : 'not loaded'); +printf("mb_strlen: %s\n", $mbStrlen ? 'available' : 'unavailable'); +printf("mb_substr: %s\n", $mbSubstr ? 'available' : 'unavailable'); + +$assertVersion = static function (string $package, string $expectedPrefix): void { + $version = InstalledVersions::getVersion($package); + + if (null === $version || !str_starts_with(ltrim($version, 'v'), $expectedPrefix.'.')) { + fwrite(STDERR, sprintf( + "Expected %s %s.x, resolved %s.\n", + $package, + $expectedPrefix, + InstalledVersions::getPrettyVersion($package) ?? 'unknown', + )); + + exit(1); + } +}; + +$actualPhpMinor = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION; +if ($actualPhpMinor !== $expectedPhpMinor) { + fwrite(STDERR, sprintf("Expected PHP %s.x, running %s.\n", $expectedPhpMinor, PHP_VERSION)); + + exit(1); +} + +$assertVersion('symfony/framework-bundle', $expectedSymfonyMinor); +$assertVersion('doctrine/doctrine-bundle', $expectedDoctrineBundleMajor); +$assertVersion('doctrine/orm', $expectedDoctrineOrmMajor); +$assertVersion('doctrine/dbal', $expectedDoctrineDbalMajor); +$assertVersion('doctrine/persistence', $expectedDoctrinePersistenceMajor); + +$rootComposer = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/composer.json'), true, flags: JSON_THROW_ON_ERROR); +$rootRequire = is_array($rootComposer) && isset($rootComposer['require']) && is_array($rootComposer['require']) ? $rootComposer['require'] : []; +if (!array_key_exists('symfony/polyfill-mbstring', $rootRequire)) { + fwrite(STDERR, "symfony/polyfill-mbstring must be a direct runtime dependency.\n"); + + exit(1); +} + +$expectedNativeMbstring = getenv('EXPECT_NATIVE_MBSTRING'); +if (!in_array($expectedNativeMbstring, ['true', 'false'], true)) { + fwrite(STDERR, "EXPECT_NATIVE_MBSTRING must be true or false.\n"); + + exit(2); +} + +if (('true' === $expectedNativeMbstring) !== $nativeMbstring) { + fwrite(STDERR, sprintf("Expected native mbstring %s, but it is %s.\n", $expectedNativeMbstring, $nativeMbstring ? 'loaded' : 'not loaded')); + + exit(1); +} + +if (!$mbStrlen || !$mbSubstr) { + fwrite(STDERR, "Multibyte string functions are unavailable.\n"); + + exit(1); +} + +if ('false' === $expectedNativeMbstring) { + if (!$iconv || 3 !== mb_strlen('Été') || 'É' !== mb_substr('Été', 0, 1)) { + fwrite(STDERR, "The mbstring polyfill path is not functional.\n"); + + exit(1); + } +} diff --git a/.github/ci/check-package-archive.php b/.github/ci/check-package-archive.php new file mode 100644 index 0000000..d78a3b0 --- /dev/null +++ b/.github/ci/check-package-archive.php @@ -0,0 +1,209 @@ +\n"); + + exit(2); +} + +$archivePath = $argv[1]; +if (!is_file($archivePath)) { + fwrite(\STDERR, sprintf("Archive not found: %s\n", $archivePath)); + + exit(1); +} + +$archive = new ZipArchive(); +if (true !== $archive->open($archivePath)) { + fwrite(\STDERR, sprintf("Unable to open ZIP archive: %s\n", $archivePath)); + + exit(1); +} + +try { + $entries = []; + for ($index = 0; $index < $archive->numFiles; ++$index) { + $name = $archive->getNameIndex($index); + if (false === $name) { + fwrite(\STDERR, sprintf("Unable to read ZIP entry at index %d.\n", $index)); + + exit(1); + } + + $entries[] = ltrim(str_replace('\\', '/', $name), '/'); + } + + $composerEntries = array_values(array_filter( + $entries, + static fn (string $entry): bool => 'composer.json' === $entry || str_ends_with($entry, '/composer.json'), + )); + if (1 !== count($composerEntries)) { + fwrite(\STDERR, sprintf("Expected exactly one composer.json, found %d.\n", count($composerEntries))); + + exit(1); + } + + $composerEntry = $composerEntries[0]; + $rootPrefix = substr($composerEntry, 0, -strlen('composer.json')); + $relativeEntries = []; + foreach ($entries as $entry) { + if (!str_starts_with($entry, $rootPrefix)) { + fwrite(\STDERR, sprintf("ZIP entry is outside the package root: %s\n", $entry)); + + exit(1); + } + + $relativeEntries[] = substr($entry, strlen($rootPrefix)); + } + + $contains = static fn (string $path): bool => in_array($path, $relativeEntries, true); + $containsDirectory = static function (string $directory) use ($relativeEntries): bool { + foreach ($relativeEntries as $entry) { + if (str_starts_with($entry, $directory.'/')) { + return true; + } + } + + return false; + }; + + $requiredFiles = [ + 'composer.json', + 'README.md', + 'CHANGELOG.md', + 'LICENSE', + 'UPGRADE-2.0.md', + 'docs/compatibility.md', + 'docs/index.md', + 'docs/legacy-mode.md', + 'docs/release-process.md', + 'docs/security-privacy.md', + 'docs/transactional-doctrine.md', + ]; + foreach ($requiredFiles as $requiredFile) { + if (!$contains($requiredFile)) { + fwrite(\STDERR, sprintf("Required runtime file is missing: %s\n", $requiredFile)); + + exit(1); + } + } + + foreach (['src', 'config'] as $requiredDirectory) { + if (!$containsDirectory($requiredDirectory)) { + fwrite(\STDERR, sprintf("Required runtime directory is missing or empty: %s/\n", $requiredDirectory)); + + exit(1); + } + } + + $excludedFiles = [ + '.editorconfig', + '.env', + '.gitattributes', + '.gitignore', + 'CODE_OF_CONDUCT.md', + 'CONTRIBUTING.md', + 'Makefile', + 'SECURITY.md', + 'phpunit.xml.dist', + 'phpunit.postgresql.xml.dist', + 'phpstan.neon.dist', + '.php-cs-fixer.dist.php', + 'composer.lock', + ]; + foreach ($excludedFiles as $excludedFile) { + if ($contains($excludedFile)) { + fwrite(\STDERR, sprintf("Development file must not be distributed: %s\n", $excludedFile)); + + exit(1); + } + } + + foreach (['.github', 'tests', 'docker', 'vendor'] as $excludedDirectory) { + if ($containsDirectory($excludedDirectory)) { + fwrite(\STDERR, sprintf("Development directory must not be distributed: %s/\n", $excludedDirectory)); + + exit(1); + } + } + + $normalizeRelativePath = static function (string $source, string $target): ?string { + $sourceDirectory = str_contains($source, '/') ? dirname($source) : ''; + $candidate = '' === $sourceDirectory ? $target : $sourceDirectory.'/'.$target; + $segments = []; + + foreach (explode('/', str_replace('\\', '/', $candidate)) as $segment) { + if ('' === $segment || '.' === $segment) { + continue; + } + if ('..' === $segment) { + if ([] === $segments) { + return null; + } + array_pop($segments); + + continue; + } + $segments[] = $segment; + } + + return implode('/', $segments); + }; + + $markdownFiles = array_values(array_filter( + $relativeEntries, + static fn (string $entry): bool => str_ends_with(strtolower($entry), '.md'), + )); + sort($markdownFiles); + $validatedLinks = 0; + foreach ($markdownFiles as $markdownFile) { + $contents = $archive->getFromName($rootPrefix.$markdownFile); + if (false === $contents) { + fwrite(\STDERR, sprintf("Unable to read documentation from archive: %s\n", $markdownFile)); + + exit(1); + } + + preg_match_all('/(?')) { + $target = substr($target, 1, -1); + } + if ( + str_starts_with($target, 'http://') + || str_starts_with($target, 'https://') + || str_starts_with($target, 'mailto:') + || str_starts_with($target, '#') + ) { + continue; + } + + $target = preg_split('/[?#]/', $target, 2)[0]; + $resolvedTarget = $normalizeRelativePath($markdownFile, $target); + if (null === $resolvedTarget) { + fwrite(\STDERR, sprintf("Markdown link escapes package root: %s -> %s\n", $markdownFile, $rawTarget)); + + exit(1); + } + if (!$contains($resolvedTarget)) { + fwrite(\STDERR, sprintf("Broken relative Markdown link: %s -> %s\n", $markdownFile, $rawTarget)); + + exit(1); + } + + ++$validatedLinks; + } + } + + printf( + "Package archive OK: %d entries, root prefix %s, runtime files and documentation present, %d relative Markdown links valid, development files excluded.\n", + count($relativeEntries), + '' === $rootPrefix ? '' : $rootPrefix, + $validatedLinks, + ); +} finally { + $archive->close(); +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7d3c168..4c974e5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,24 @@ version: 2 + updates: - - package-ecosystem: "composer" - directory: "/" + - package-ecosystem: composer + directory: / + target-branch: develop schedule: - interval: "weekly" + interval: monthly + groups: + compatible-non-breaking-updates: + update-types: + - minor + - patch - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: github-actions + directory: / + target-branch: develop schedule: - interval: "weekly" + interval: monthly + groups: + compatible-non-breaking-updates: + update-types: + - minor + - patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 983022b..681b8cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,63 +2,342 @@ name: CI on: push: + branches: + - main + - develop + tags: + - '*' pull_request: + branches: + - main + - develop +permissions: + contents: read + +# DoctrineBundle 2 excludes Symfony 8. ORM 2 is outside the next release baseline. jobs: - tests: + compatibility: + name: Compatibility / ${{ matrix.name }} runs-on: ubuntu-latest + timeout-minutes: 20 strategy: fail-fast: false matrix: include: - - php: "8.3" + - name: PHP 8.3 / Symfony 7.4 / DBundle 2.19 / ORM 3 / lowest + php: "8.3" symfony: "7.4.*" - composer_flags: "" - deprecations: "max[direct]=0" - - - php: "8.4" + doctrine_bundle: "2.19" + doctrine_bundle_major: "2" + doctrine_orm: "3" + doctrine_dbal: "3" + doctrine_persistence: "3" + prefer_lowest: true + dependency_flags: "--prefer-lowest --prefer-stable" + native_mbstring: false + extensions: "pdo_sqlite, :mbstring, iconv, dom, xml, zip" + - name: PHP 8.3 / Symfony 7.4 / DBundle 2 / ORM 3 / latest + php: "8.3" symfony: "7.4.*" - composer_flags: "" - deprecations: "max[direct]=0" - - # Symfony 8 requires PHP 8.4+ - - php: "8.4" + doctrine_bundle: "2" + doctrine_bundle_major: "2" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + prefer_lowest: false + dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" + - name: PHP 8.4 / Symfony 7.4 / DBundle 3 / ORM 3 / latest + php: "8.4" + symfony: "7.4.*" + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" + - name: PHP 8.4 / Symfony 8.0 / DBundle 3 / ORM 3 / latest + php: "8.4" symfony: "8.0.*" - composer_flags: "" - deprecations: "max[direct]=0" - + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" + - name: PHP 8.5 / Symfony 7.4 / DBundle 3 / ORM 3 / latest + php: "8.5" + symfony: "7.4.*" + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" + - name: PHP 8.5 / Symfony 8.x latest / DBundle 3 / ORM 3 / latest + php: "8.5" + symfony: "8.*" + symfony_resolved: "8.*" + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: "${{ matrix.php }}" + extensions: "${{ matrix.extensions }}" tools: composer:v2 coverage: none + - name: Install Symfony Flex + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies + env: + SYMFONY_REQUIRE: "${{ matrix.symfony }}" + run: composer update --prefer-dist --no-interaction --no-progress --with "doctrine/doctrine-bundle:^${{ matrix.doctrine_bundle }}.0" --with "doctrine/orm:^${{ matrix.doctrine_orm }}.0" ${{ matrix.dependency_flags }} + - run: composer validate --strict + - name: Assert resolved versions + env: + EXPECT_NATIVE_MBSTRING: "${{ matrix.native_mbstring }}" + run: php .github/ci/assert-versions.php "${{ matrix.php }}" "${{ matrix.symfony_resolved || matrix.symfony }}" "${{ matrix.doctrine_bundle_major }}" "${{ matrix.doctrine_orm }}" "${{ matrix.doctrine_dbal }}" "${{ matrix.doctrine_persistence }}" + - run: composer check-platform-reqs + - name: PHPUnit + env: + SYMFONY_DEPRECATIONS_HELPER: "max[direct]=0" + run: | + if [[ "${{ matrix.native_mbstring }}" == "false" ]]; then + # PHPUnit 11's launcher requires native mbstring. Calling its + # application directly runs the same 67 tests with the polyfill. + php -r 'require "vendor/autoload.php"; exit((new PHPUnit\TextUI\Application())->run(["phpunit"]));' + else + composer test + fi - - name: Validate composer.json - run: composer validate --strict - - - name: Install Symfony Flex (for SYMFONY_REQUIRE) + static-analysis: + name: Static analysis / ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: Symfony 7.4 / ORM 3 latest + symfony: "7.4.*" + symfony_resolved: "7.4.*" + doctrine_bundle: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + - name: Symfony 8.x / ORM 3 latest + symfony: "8.*" + symfony_resolved: "8.*" + doctrine_bundle: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: "8.4" + extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip + tools: composer:v2 + coverage: none + - name: Install Symfony Flex run: | composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --no-progress --no-scripts --no-plugins symfony/flex + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies + env: + SYMFONY_REQUIRE: "${{ matrix.symfony }}" + run: composer update --prefer-dist --no-interaction --no-progress --with "doctrine/doctrine-bundle:^${{ matrix.doctrine_bundle }}.0" --with "doctrine/orm:^${{ matrix.doctrine_orm }}.0" + - run: composer validate --strict + - name: Assert PHPStan and resolved versions + env: + EXPECT_NATIVE_MBSTRING: "true" + EXPECT_PHPSTAN_MAJOR: "2" + run: php .github/ci/assert-versions.php "8.4" "${{ matrix.symfony_resolved }}" "${{ matrix.doctrine_bundle }}" "${{ matrix.doctrine_orm }}" "${{ matrix.doctrine_dbal }}" "${{ matrix.doctrine_persistence }}" + - run: composer phpstan -- --memory-limit=1G - - name: Install dependencies + transactional-postgresql: + name: Transactional PostgreSQL / ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: PHP 8.3 / Symfony 7.4 / DBundle 2.19 / ORM 3 / lowest + php: "8.3" + symfony: "7.4.*" + doctrine_bundle: "2.19" + doctrine_bundle_major: "2" + doctrine_orm: "3" + doctrine_dbal: "3" + doctrine_persistence: "3" + dependency_flags: "--prefer-lowest --prefer-stable" + - name: PHP 8.5 / Symfony 8.x / DBundle 3 / ORM 3 / latest + php: "8.5" + symfony: "8.*" + symfony_resolved: "8.*" + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: auditable_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd "pg_isready -U postgres -d auditable_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + env: + TEST_DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:5432/auditable_test?serverVersion=16&charset=utf8" + SYMFONY_DEPRECATIONS_HELPER: "max[direct]=0" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: "${{ matrix.php }}" + extensions: pdo_pgsql, mbstring, dom, xml, zip + tools: composer:v2 + coverage: none + - name: Install Symfony Flex + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies env: SYMFONY_REQUIRE: "${{ matrix.symfony }}" + run: composer update --prefer-dist --no-interaction --no-progress --with "doctrine/doctrine-bundle:^${{ matrix.doctrine_bundle }}.0" --with "doctrine/orm:^${{ matrix.doctrine_orm }}.0" --with "doctrine/dbal:^${{ matrix.doctrine_dbal }}.0" --with "doctrine/persistence:^${{ matrix.doctrine_persistence }}.0" ${{ matrix.dependency_flags }} + - run: composer validate --strict + - name: Assert resolved versions + env: + EXPECT_NATIVE_MBSTRING: "true" + run: php .github/ci/assert-versions.php "${{ matrix.php }}" "${{ matrix.symfony_resolved || matrix.symfony }}" "${{ matrix.doctrine_bundle_major }}" "${{ matrix.doctrine_orm }}" "${{ matrix.doctrine_dbal }}" "${{ matrix.doctrine_persistence }}" + - run: composer check-platform-reqs + - run: composer show --direct symfony/uid + - run: composer why symfony/uid + - run: composer test:postgresql + + package-distribution: + name: Package distribution + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: "8.3" + extensions: mbstring, iconv, dom, xml, zip + tools: composer:v2 + coverage: none + - run: composer validate --strict + - name: Build and inspect Composer archive + run: | + mkdir -p /tmp/auditable-package + composer archive --format=zip --dir=/tmp/auditable-package --file=auditable-bundle + php .github/ci/check-package-archive.php /tmp/auditable-package/auditable-bundle.zip + - name: Install the distributed package without development dependencies run: | - composer update --prefer-dist --no-progress ${{ matrix.composer_flags }} + mkdir -p /tmp/auditable-package/extracted + unzip -q /tmp/auditable-package/auditable-bundle.zip -d /tmp/auditable-package/extracted + package_root=/tmp/auditable-package/extracted + if [[ ! -f "${package_root}/composer.json" ]]; then + package_root="$(find "${package_root}" -mindepth 1 -maxdepth 1 -type d -print -quit)" + fi + cd "${package_root}" + composer install --no-dev --prefer-dist --no-interaction --no-progress + composer validate --strict + composer check-platform-reqs + # The single-quoted program is evaluated by PHP. + # shellcheck disable=SC2016 + php -r ' + require "vendor/autoload.php"; + foreach ([ + Zhortein\AuditableBundle\ZhorteinAuditableBundle::class, + Zhortein\AuditableBundle\Transactional\Model\AuditEvent::class, + Zhortein\AuditableBundle\Transactional\Contract\AuditRecorderInterface::class, + Zhortein\AuditableBundle\Transactional\Service\StrictAuditRecorder::class, + Zhortein\AuditableBundle\Transactional\Service\DoctrineIdentifierExtractor::class, + Zhortein\AuditableBundle\Transactional\Service\SymfonySecurityActorResolver::class, + ] as $type) { + if (!class_exists($type) && !interface_exists($type)) { + fwrite(STDERR, "Unable to autoload ".$type.PHP_EOL); + exit(1); + } + echo "Autoloaded ".$type.PHP_EOL; + } + ' - - name: PHPUnit + coding-style: + name: Coding style + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: "8.4" + extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip + tools: composer:v2 + coverage: none + - name: Install Symfony Flex + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies env: - SYMFONY_DEPRECATIONS_HELPER: "${{ matrix.deprecations }}" - run: vendor/bin/phpunit - - - name: PHPStan - run: vendor/bin/phpstan analyse -c phpstan.neon.dist --no-progress + SYMFONY_REQUIRE: "7.4.*" + run: composer update --prefer-dist --no-interaction --no-progress --with doctrine/doctrine-bundle:^3.0 --with doctrine/orm:^3.0 + - run: composer validate --strict + - run: composer cs:check - - name: CS Fixer (dry-run) - run: vendor/bin/php-cs-fixer fix --dry-run --diff + security-audit: + name: Security audit + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: "8.5" + extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip + tools: composer:v2 + coverage: none + - name: Install Symfony Flex + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies + env: + SYMFONY_REQUIRE: "8.*" + run: composer update --prefer-dist --no-interaction --no-progress --with doctrine/doctrine-bundle:^3.0 --with doctrine/orm:^3.0 + - run: composer validate --strict + - run: composer audit --locked --no-interaction diff --git a/CHANGELOG.md b/CHANGELOG.md index 622b459..cb66fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,5 +2,33 @@ ## [Unreleased] -## 0.1.0 - TBD -- Initial repository setup (CI, QA tooling, bundle skeleton). +### Added + +- Add immutable transactional contracts and models for events, records, subjects and actors. +- Add a deterministic Doctrine identifier extractor for scalar, stringable and primitive composite identifiers using the stable `doctrine-composite-v1` format. +- Add a Symfony Security actor resolver supporting authenticated users and immediate impersonation context. +- Add a strict recorder that delegates entity creation and persistence to application-provided factory and storage implementations and uses an application-provided PSR-20 clock. +- Add opt-in Symfony container wiring for the strict recorder. The bundle provides no default transactional factory, storage or clock. +- Add PostgreSQL-backed executable tests proving shared commit, shared rollback, absence of hidden flush and fail-closed rollback with application-owned UUID v7 entities. +- Add a guarded opt-out of the legacy Doctrine mapping for transactional-only applications. The opt-out is disabled by default and never removes a table. +- Add immutable 1.0 and 2.0 public API snapshots. + +### Changed + +- Upgrade static analysis to PHPStan 2 at maximum level without a baseline or global ignored errors. +- Include the versioned documentation in the Composer archive and validate its relative links, no-development installation and runtime autoload. + +### Compatibility + +- Require Doctrine ORM 3.x and DoctrineBundle 2.19 or 3.x for the future 2.x series. +- Support PHP 8.3 through 8.5 and Symfony 7.4, 8.0 and 8.1 on the executed CI boundaries. +- Add a direct runtime dependency on `symfony/polyfill-mbstring`, allowing operation without the native extension. +- Preserve the default legacy runtime, `AuditEntry` mapping, table schema, service aliases and fail-open behavior. Existing applications need no mandatory SQL migration when retaining the default mapping. + +### Documentation + +- Add guides for upgrading from 1.0, legacy behavior, transactional Doctrine integration, security and privacy, and the 2.x compatibility and deprecation policy. + +## [1.0.0] - 2025-12-24 + +- Initial stable release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2bdfff..8c90c92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,36 @@ Thanks for contributing! ## Development -- PHP >= 8.2 + +- PHP >= 8.3 +- Symfony 7.4 or 8.x +- Doctrine ORM 3 - Composer ## Commands + - Tests: `composer test` - Static analysis: `composer phpstan` - Code style: `composer cs:check` / `composer cs:fix` + +## Branch workflow + +### Ordinary development + +Create `feature/*`, `fix/*` or `chore/*` branches from `develop`, then open a draft pull request targeting `develop`. Ordinary changes must never target `main` directly. + +Keep commits atomic when their history is useful. Squashing is appropriate for maintenance or Dependabot pull requests with little internal structure. A merge commit is appropriate for larger features whose commits have been deliberately organized. + +### Release + +Create `release/x.y.z` from `develop`. Finalize the changelog and release-candidate checks on that branch, then open a `release/x.y.z` pull request targeting `main`. Nothing is published before explicit maintainer approval. After publication, synchronize `main` back into `develop`. + +### Security fix + +Dependabot security updates may target `main`. Apply an urgent security fix to `main` through a pull request, then immediately open a `main` to `develop` synchronization pull request. Also integrate the fix into an active release branch when necessary. + +### Prohibited operations + +- Do not force-push shared branches. +- Do not move or delete a published tag. +- Do not publish implicitly from a pull request. diff --git a/README.md b/README.md index 4540470..736c3e3 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,20 @@ # Zhortein Auditable Bundle -A lightweight Symfony bundle to automatically **audit and historize Doctrine ORM entity changes** (create/update/delete) with optional **async persistence** via Symfony Messenger. +Zhortein Auditable Bundle provides two audit paths for Symfony applications using Doctrine ORM: -> Designed for Symfony 7.4+ / 8.x, PHP 8.3+. +- the compatibility-preserved legacy listener, which records selected entity changes automatically; +- an opt-in strict recorder that lets the application own its audit model, persistence and transaction boundary. -## Features +## Requirements -- ✅ Opt-in auditing with PHP Attributes: - - `#[Audited]` on a Doctrine entity class to enable auditing - - `#[AuditLabel('…')]` on an entity class to override the displayed label - - `#[AuditIgnore]` on an entity property to exclude it from audits (PII/secrets/noise) -- ✅ Captures create / update / delete actions -- ✅ Stores audit records in a `History` entity (Doctrine ORM) -- ✅ Supports async writing using Symfony Messenger (recommended) -- ✅ Extensible: actor resolver, label strategy, change detector, writer… +- PHP 8.3 or later +- Symfony 7.4 or 8.x +- Doctrine ORM 3.x +- DoctrineBundle 2.19 or 3.x -## Requirements +Symfony Messenger is a required package dependency and supports the legacy asynchronous writer. The bundle includes `symfony/polyfill-mbstring`; the native `mbstring` extension remains recommended for performance. -- PHP 8.3+ -- Symfony 7.4+ (Symfony 8.x supported) -- Doctrine ORM + DoctrineBundle -- Symfony Messenger (optional but recommended for async) +See the [compatibility and deprecation policy](docs/compatibility.md) and the [2.0 upgrade guide](UPGRADE-2.0.md) before upgrading an existing application. ## Installation @@ -28,42 +22,38 @@ A lightweight Symfony bundle to automatically **audit and historize Doctrine ORM composer require zhortein/auditable-bundle ``` -If you **don’t** use Symfony Flex recipes, enable the bundle: +Without Symfony Flex recipes, enable the bundle in `config/bundles.php`: ```php -// config/bundles.php return [ // ... Zhortein\AuditableBundle\ZhorteinAuditableBundle::class => ['all' => true], ]; ``` -### Doctrine mapping & migrations +With no bundle configuration, the legacy runtime and its `AuditEntry` mapping remain enabled and the transactional recorder remains disabled. The bundle does not apply migrations automatically. Review any Doctrine migration generated for your application before running it. -The bundle ships a Doctrine entity (`AuditEntry`). Its mapping is registered automatically by a compiler pass, so you **don't need** to declare a `doctrine.orm.mappings` entry manually. +## Choose an audit path -**Generate and run migrations** after installation: +The [legacy mode](docs/legacy-mode.md) preserves the 1.0 behavior for existing applications. It is convenient for automatic create, update and delete histories, but is fail-open, flushes through its persister and does not guarantee atomicity with the business write. -```bash -php bin/console doctrine:migrations:diff -php bin/console doctrine:migrations:migrate -``` +The [transactional Doctrine integration](docs/transactional-doctrine.md) is opt-in. It is intended for operations where an audit failure must prevent the business commit. The application supplies its own audit entity, factory, storage and clock; the bundle supplies no default persistence model for this path. -This creates the `audit_entry` table with the necessary schema for storing audit trail entries. +Both paths can coexist during a migration. The [2.0 upgrade guide](UPGRADE-2.0.md) covers coexistence, transactional-only applications and rollback planning. -## Quick start +## Legacy quick start -### 1) Mark entities as audited +Mark an entity with the actual legacy attributes: ```php -use Zhortein\AuditableBundle\Attribute\Audited; -use Zhortein\AuditableBundle\Attribute\AuditLabel; +use Zhortein\AuditableBundle\Attribute\Auditable; +use Zhortein\AuditableBundle\Attribute\AuditField; use Zhortein\AuditableBundle\Attribute\AuditIgnore; -#[Audited] -#[AuditLabel('Customer')] -class Customer +#[Auditable(label: 'Customer')] +final class Customer { + #[AuditField(label: 'Email address')] private string $email; #[AuditIgnore] @@ -71,106 +61,106 @@ class Customer } ``` -### 2) (Recommended) Configure async message handling - -By default, the bundle is configured for **async persistence** via Symfony Messenger. - -#### Step A: Define the Messenger transport - -If your project doesn't already have a Messenger `async` transport, add one: +The defaults are: ```yaml -# config/packages/messenger.yaml -framework: - messenger: - transports: - async: '%env(MESSENGER_TRANSPORT_DSN)%' - routing: - 'Zhortein\AuditableBundle\Message\PersistAuditEntryMessage': async +zhortein_auditable: + enabled: true + legacy_mapping: + enabled: true + transactional: + enabled: false + async: + enabled: true + transport: async ``` -See `config/packages/messenger.yaml.example` in the bundle for a complete configuration example. +When `async.enabled` is `true`, route `Zhortein\AuditableBundle\Message\PersistAuditEntryMessage` through Symfony Messenger. The historical `async.transport` key is preserved for compatibility; Messenger routing determines the effective transport. Set `async.enabled: false` to use the synchronous legacy writer. + +The complete options and historical limitations are documented in [Legacy mode](docs/legacy-mode.md). A commented example is available at `config/packages/zhortein_auditable.yaml.example`. -#### Step B: Configure the bundle +## Transactional quick start -Create or update the bundle configuration: +Enable the recorder explicitly: ```yaml -# config/packages/zhortein_auditable.yaml zhortein_auditable: - enabled: true - async: + transactional: enabled: true - transport: 'async' ``` -See `config/packages/zhortein_auditable.yaml.example` in the bundle for all available options. +Provide application services through standard Symfony aliases: -#### Synchronous mode (optional) +```yaml +services: + App\Audit\AuditEntryFactory: ~ + App\Audit\AuditStorage: ~ + App\Audit\AuditClock: ~ -If you prefer **synchronous persistence** (audit records written immediately without Messenger): + Zhortein\AuditableBundle\Transactional\Contract\AuditEntryFactoryInterface: + alias: App\Audit\AuditEntryFactory -```yaml -zhortein_auditable: - enabled: true - async: - enabled: false + Zhortein\AuditableBundle\Transactional\Contract\AuditStorageInterface: + alias: App\Audit\AuditStorage + + Psr\Clock\ClockInterface: + alias: App\Audit\AuditClock ``` -> **Note**: Async mode is recommended for production to avoid blocking request handling with database writes. +The bundle provides default aliases for `IdentifierExtractorInterface` and `AuditActorResolverInterface`; the application may replace either alias. It intentionally provides no entry factory, storage or clock. -## Configuration reference +Keep the mutation and audit entry in the same application-owned Unit of Work: -The bundle's configuration options are documented with comments in `config/packages/zhortein_auditable.yaml.example`. +```php +$entityManager->wrapInTransaction(function (EntityManagerInterface $entityManager) use ($operation, $auditRecorder): void { + $operation->complete(); + $entityManager->persist($operation); + + $auditRecorder->record(new AuditEvent( + action: 'complete', + title: 'Operation completed', + entity: $operation, + )); +}); +``` -**Key settings:** +The strict recorder performs no flush, commit or rollback and does not catch factory or storage exceptions. Fail-closed behavior therefore requires the audit storage to use the same entity manager and connection and not to flush independently. The application controls the transaction boundary. -- **`enabled`**: Master switch to enable/disable auditing globally (default: `true`) -- **`async.enabled`**: Use Messenger for async persistence (default: `true`) -- **`async.transport`**: Messenger transport name for audit messages (default: `'async'`) -- **`listener.track_insert/update/delete`**: Control which operations are tracked (all default to `true`) -- **`fields.max_string_length`**: Maximum length for serialized field values (default: `180`) -- **`fields.global_ignored`**: List of properties to always exclude from all entities (default: `[]`) +No Doctrine audit entity, storage or migration is imposed by the bundle. The executable PostgreSQL proof and full boundary rules are in the [transactional Doctrine guide](docs/transactional-doctrine.md). -**Actor resolution:** +## Transactional-only applications -By default, the bundle uses Symfony Security to resolve the current user via `SecurityActorResolver`. No additional configuration is needed. +After all legacy producers, pending Messenger messages and legacy workers have been dealt with, an application may omit the legacy mapping: -The resolver automatically handles: -- Regular authenticated users → stores user ID or user identifier -- Null users (not authenticated) → stores `null` -- Impersonation → stores both original user and impersonator IDs +```yaml +zhortein_auditable: + enabled: false + legacy_mapping: + enabled: false + transactional: + enabled: true +``` -## What gets stored +`enabled` controls the legacy runtime, `legacy_mapping.enabled` controls only Doctrine registration of the legacy `AuditEntry`, and `transactional.enabled` controls the strict recorder. Their defaults are `true`, `true` and `false`. Disabling the mapping while legacy auditing remains enabled is rejected. -Each `AuditEntry` record in the audit trail contains: +This option never drops an existing `audit_entry` table and supplies no migration. See the [upgrade guide](UPGRADE-2.0.md#transactional-only-mode) before opting out. -- **Entity metadata**: Fully qualified class name and entity ID -- **Action**: One of `create`, `update`, `delete`, or `log` -- **Level**: Severity level (`debug`, `info`, `warning`, `error`, `critical`) -- **Title & Description**: Human-readable summary of the change -- **Context**: Optional context tag for grouping related entities -- **Actor**: User ID or identifier of who made the change (null if unauthenticated) -- **Impersonator**: Original user ID if the change was made during impersonation -- **Timestamp**: When the change occurred (as `DateTimeImmutable`) -- **Data**: JSON-encoded field changes (old value → new value), excluding `#[AuditIgnore]` properties +## Security and non-guarantees -**Example audit entry for an update:** -``` -Title: "Update [Customer] - 2 field(s) changed" -Description: - - "Email: john@example.com → john.doe@example.com" - - "Phone: +1234567890 → +1987654321" -Data: { "email": { "old": "john@example.com", "new": "john.doe@example.com" }, ... } -``` +Never audit passwords, tokens, private keys or other secrets. Use `#[AuditIgnore]`, `fields.global_ignored` and application-level factory/storage validation to minimize recorded data. Actor identifiers and audit payloads may be personal data. -## Security / PII +The bundle does not provide encryption at rest, cryptographic signatures, hash chaining, append-only storage, retention, purge, anonymization or legal compliance. Strict transactional recording provides a fail-closed transaction boundary when integrated correctly; it does not provide tamper evidence. See [Security and privacy](docs/security-privacy.md). -This bundle is meant to help you build **auditable applications**—but you are responsible for what you store. +## Documentation -- Use `#[AuditIgnore]` for secrets (password hashes, tokens) and sensitive data that should not be persisted in audit logs. -- Consider encrypting audit payloads or restricting access to the History table depending on your domain constraints. +- [Documentation index](docs/index.md) +- [Legacy mode](docs/legacy-mode.md) +- [Transactional Doctrine integration](docs/transactional-doctrine.md) +- [Upgrade from 1.0 to 2.0](UPGRADE-2.0.md) +- [Security and privacy](docs/security-privacy.md) +- [Compatibility and deprecation](docs/compatibility.md) +- [Changelog](CHANGELOG.md) ## License -MIT (see [LICENSE](LICENSE)]). +MIT. See [LICENSE](LICENSE). diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 0000000..ab11cb0 --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,117 @@ +# Upgrade from 1.0.0 to 2.0.0 + +This guide describes how to prepare an application for the future 2.x series. It does not announce a release and it does not replace application-specific migration review. + +## Breaking platform changes + +The principal breaking change is the supported platform: + +- PHP 8.3 or later; +- Symfony 7.4 or 8.x; +- Doctrine ORM 3.x; +- DoctrineBundle 2.19 or 3.x. + +Doctrine ORM 2 is no longer supported. Raising this platform baseline is why these changes belong to a new major series. + +## What remains unchanged by default + +With no new configuration, the historical behavior remains selected: + +- `enabled` remains `true`; +- `legacy_mapping.enabled` remains `true`; +- `transactional.enabled` remains `false`; +- the `audit_entry` table keeps its historical integer identifier, columns and indexes; +- `#[Auditable]`, `#[AuditField]` and `#[AuditIgnore]` keep their contracts; +- the automatic Doctrine listener remains enabled; +- legacy errors remain fail-open: `Historizer` catches and logs them; +- legacy entity identifiers are still read only through `getId()`; +- no SQL change or migration is required merely to preserve this default mode. + +## Checks before updating + +- Confirm PHP, Symfony, Doctrine ORM 3 and DoctrineBundle 2.19 or 3 compatibility. +- Review and preserve the application's `composer.lock` as appropriate for its deployment process. +- Run the complete application test suite. +- Generate and review Doctrine migration diffs; do not execute an unexpected schema change blindly. +- Check Messenger routing, consumers and pending `PersistAuditEntryMessage` messages. +- Review custom implementations of legacy interfaces. +- Search for direct access to concrete bundle services or classes. +- Review the [compatibility policy](docs/compatibility.md) and [security and privacy responsibilities](docs/security-privacy.md). + +## Simple legacy update + +An application can update its Composer constraints and keep its existing configuration. No transactional configuration is required: + +```yaml +zhortein_auditable: + enabled: true +``` + +The historical mapping, listener and selected sync or async writer continue to operate. See [Legacy mode](docs/legacy-mode.md) for its preserved semantics and limitations. + +## Progressive transactional activation + +Enable the strict recorder explicitly: + +```yaml +zhortein_auditable: + transactional: + enabled: true +``` + +The application must provide services or aliases for `AuditEntryFactoryInterface`, `AuditStorageInterface` and `Psr\Clock\ClockInterface`. The bundle supplies default aliases for `IdentifierExtractorInterface` and `AuditActorResolverInterface`; applications may replace those aliases through standard Symfony service configuration. + +The factory creates the application-owned representation. The storage attaches it to the current persistence context without flushing. See the [transactional Doctrine guide](docs/transactional-doctrine.md) for wiring and transaction boundaries. + +## Coexistence + +Legacy storage and the strict recorder can coexist during a progressive migration: + +```yaml +zhortein_auditable: + legacy_mapping: + enabled: true + transactional: + enabled: true +``` + +The historical `AuditEntry` mapping remains known to Doctrine while selected application paths adopt the strict recorder. + +## Transactional-only mode + +After every legacy path has been retired: + +```yaml +zhortein_auditable: + enabled: false + legacy_mapping: + enabled: false + transactional: + enabled: true +``` + +This opt-out does not remove the physical `audit_entry` table and the bundle supplies no migration. Review generated migrations manually and decide whether historical data must be retained, archived or migrated. + +Before disabling the mapping, stop legacy producers, drain pending `PersistAuditEntryMessage` messages and ensure that no legacy worker can write `AuditEntry` records. + +## Transaction boundary and fail-closed behavior + +The strict recorder does not flush, commit, roll back or catch factory and storage exceptions. An application storage must not flush. Doctrine `persist()` does not mean commit. + +Shared atomicity requires the business mutation and application audit entry to use the same `EntityManagerInterface` and underlying connection. The application owns `flush()`, `commit()` and `rollBack()`, and must allow audit exceptions to propagate so the transaction can be rolled back. After a flush or transaction failure, abandon the affected entity manager according to Doctrine practices. Never keep a transaction open across HTTP requests or user interaction. + +## Identifiers + +The default Doctrine extractor accepts integer, string, `BackedEnum` and `Stringable` identifier values. Primitive composite identifiers are encoded deterministically using the stable `doctrine-composite-v1` format. + +Association identifier fields are not supported by the default extractor. Use a custom `IdentifierExtractorInterface` implementation or provide an explicit `AuditSubject`. The same alternatives apply when an application needs a logical subject identifier unrelated to Doctrine metadata. + +## Actors + +The default transactional Symfony Security resolver uses `UserInterface::getUserIdentifier()`. It resolves immediate `SwitchUserToken` impersonation and may return `null` when no user is available. Supply an explicit `AuditActor` for system or technical operations, or replace `AuditActorResolverInterface` when another identifier policy is required. + +## Rollback plan + +An application that retains the legacy mapping can return its own code paths to the historical services without an automatic schema migration. Application-owned audit entities, tables and migrations remain the application's responsibility; the bundle cannot undo them automatically. + +No automatic downgrade of Doctrine ORM is promised. A rollback involving platform dependencies must be planned and tested by the application using its dependency lock and deployment process. diff --git a/composer.json b/composer.json index ffb8da1..c49bdce 100644 --- a/composer.json +++ b/composer.json @@ -23,23 +23,29 @@ ], "require": { "php": ">=8.3", + "psr/clock": "^1.0", "psr/log": "^3.0", - "doctrine/doctrine-bundle": "^2.5 || ^3.0", - "doctrine/orm": "^2.12 || ^3.0", + "doctrine/doctrine-bundle": "^2.19 || ^3.0", + "doctrine/orm": "^3.0", "symfony/config": "^7.4 || ^8.0", "symfony/dependency-injection": "^7.4 || ^8.0", - "symfony/http-kernel": "^7.4 || ^8.0", - "symfony/options-resolver": "^7.4 || ^8.0", "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", "symfony/messenger": "^7.4 || ^8.0", + "symfony/options-resolver": "^7.4 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", "symfony/security-bundle": "^7.4 || ^8.0" }, "require-dev": { "phpunit/phpunit": "^11.0", "symfony/phpunit-bridge": "^7.4 || ^8.0", - "phpstan/phpstan": "^1.11", + "symfony/uid": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.60" }, + "suggest": { + "ext-mbstring": "Recommended for the best multibyte string performance." + }, "autoload": { "psr-4": { "Zhortein\\AuditableBundle\\": "src/" @@ -57,11 +63,12 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-main": "0.1-dev" + "dev-develop": "2.0.x-dev" } }, "scripts": { "test": "vendor/bin/phpunit", + "test:postgresql": "vendor/bin/phpunit -c phpunit.postgresql.xml.dist", "phpstan": "vendor/bin/phpstan analyse --no-progress", "cs:check": "vendor/bin/php-cs-fixer fix --dry-run --diff", "cs:fix": "vendor/bin/php-cs-fixer fix" diff --git a/config/packages/zhortein_auditable.yaml.example b/config/packages/zhortein_auditable.yaml.example index b6a51d3..b1630ee 100644 --- a/config/packages/zhortein_auditable.yaml.example +++ b/config/packages/zhortein_auditable.yaml.example @@ -11,14 +11,26 @@ zhortein_auditable: # Master enable/disable switch for the entire auditing system enabled: true - # Async message persistence configuration + # Keep the bundle's historical AuditEntry Doctrine mapping by default. + # Disabling it requires enabled: false and is intended for applications that + # use only their own transactional audit storage. No table is removed automatically. + legacy_mapping: + enabled: true + + # Strict transactional recorder wiring is opt-in and disabled by default. + # Enabling it requires application services or aliases for + # AuditEntryFactoryInterface, AuditStorageInterface and Psr\Clock\ClockInterface. + transactional: + enabled: false + + # Legacy asynchronous message persistence configuration async: - # Set to true to dispatch audit messages to Messenger (recommended) + # Set to true to dispatch legacy audit messages to Messenger # Set to false for synchronous persistence (audit records written immediately) enabled: true - # Messenger transport name where PersistAuditEntryMessage will be dispatched - # Must match a transport configured in config/packages/messenger.yaml + # Historical compatibility key. The effective transport for + # PersistAuditEntryMessage is selected by Messenger routing, not this value. transport: 'async' # Doctrine listener configuration - what operations to track diff --git a/config/services.php b/config/services.php index d560c21..3a3950a 100644 --- a/config/services.php +++ b/config/services.php @@ -13,6 +13,10 @@ use Zhortein\AuditableBundle\Service\Historizer; use Zhortein\AuditableBundle\Service\SecurityActorResolver; use Zhortein\AuditableBundle\Service\SyncAuditEntryWriter; +use Zhortein\AuditableBundle\Transactional\Contract\AuditActorResolverInterface; +use Zhortein\AuditableBundle\Transactional\Contract\IdentifierExtractorInterface; +use Zhortein\AuditableBundle\Transactional\Service\DoctrineIdentifierExtractor; +use Zhortein\AuditableBundle\Transactional\Service\SymfonySecurityActorResolver; use function Symfony\Component\DependencyInjection\Loader\Configurator\param; @@ -32,8 +36,16 @@ __DIR__ . '/../src/Attribute/', __DIR__ . '/../src/Enum/', __DIR__ . '/../src/Message/', + __DIR__ . '/../src/Transactional/Contract/', + __DIR__ . '/../src/Transactional/Exception/', + __DIR__ . '/../src/Transactional/Model/', + // Manually composable until factory, storage and opt-in wiring are introduced. + __DIR__ . '/../src/Transactional/Service/StrictAuditRecorder.php', ]); + $services->alias(IdentifierExtractorInterface::class, DoctrineIdentifierExtractor::class); + $services->alias(AuditActorResolverInterface::class, SymfonySecurityActorResolver::class); + $services->set(AuditableMetadataProvider::class); $services->set(ChangeDetector::class) diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..cfdb1e9 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,39 @@ +# Compatibility and deprecation policy + +## Supported platform + +The future 2.x series targets: + +- PHP 8.3 and later within the tested range; +- Symfony 7.4 and 8.x; +- Doctrine ORM 3.x; +- DoctrineBundle 2.19 and 3.x. + +Doctrine ORM 2 is not supported. This platform break is the reason for the 2.0 major line. + +## Executed compatibility boundaries + +The general CI executes six runtime boundaries covering PHP 8.3, 8.4 and 8.5, Symfony 7.4, 8.0 and 8.1, DoctrineBundle 2.19/2.x/3.x, DBAL 3/4 and the lowest dependency boundary without native mbstring. + +PostgreSQL 16 is used for the executable shared commit, rollback and fail-closed transactional proof. SQLite is used for most container, mapping and general integration tests. Compatibility is not claimed outside executed matrices and declared Composer constraints. + +## Semantic versioning and public API + +The project follows Semantic Versioning. The historical 1.0 API is preserved by `tests/Contract/public-api-1.0.0.json`. The complete 2.0 surface is frozen by `tests/Contract/public-api-2.0.0.json`. + +Additive public types may be introduced during 2.x. Existing 2.0 types, concrete public classes, interface methods, constructors, enum cases, public properties, finality and readonly semantics are protected for the 2.x series. An incompatible removal or signature change requires a future major version. + +The deterministic `doctrine-composite-v1` identifier representation is considered stable during 2.x. + +## Deprecation policy + +Before removing a supported public surface, the project intends to: + +1. document the deprecation; +2. provide and document an alternative where applicable; +3. retain the deprecated path for a reasonable migration period; +4. remove it only in a future major version. + +Concrete classes currently represented in the 2.0 public snapshot are protected even when normally obtained through Symfony services. Consumers should still prefer documented interfaces and dependency injection. + +See [Upgrade 2.0](../UPGRADE-2.0.md), [Legacy mode](legacy-mode.md) and the [Changelog](../CHANGELOG.md). diff --git a/docs/index.md b/docs/index.md index a74dcfc..5e408a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,96 +1,19 @@ -# Zhortein Auditable Bundle Documentation +# Zhortein Auditable Bundle documentation -A lightweight Symfony bundle for automatic audit trail tracking on Doctrine ORM entities. +Zhortein Auditable Bundle supports the compatibility-preserved legacy listener and an opt-in strict transactional recorder. Start with the [README overview](../README.md) to choose the appropriate path. -## Overview +## Guides -This bundle provides a declarative, attribute-based system to automatically track changes to your Doctrine entities. When you mark an entity with `#[Auditable]`, the bundle's Doctrine listener automatically captures: +- [Overview and quick starts](../README.md) +- [Legacy mode](legacy-mode.md) — automatic Doctrine lifecycle auditing and its historical fail-open semantics +- [Transactional Doctrine integration](transactional-doctrine.md) — application-owned persistence and transaction boundaries +- [Upgrade from 1.0 to 2.0](../UPGRADE-2.0.md) — platform changes, migration paths and rollback planning +- [Security and privacy](security-privacy.md) — data minimization, access, retention and guarantees not provided +- [Compatibility and deprecation](compatibility.md) — supported matrices, API freezes and 2.x policy +- [Changelog](../CHANGELOG.md) -- **Entity creations** (INSERT) -- **Entity modifications** (UPDATE) with field-level change detection -- **Entity deletions** (DELETE) +## Maintainer lifecycle -All audit entries are persisted to a dedicated `audit_entry` table, with optional async processing via Symfony Messenger. +- [Release process](release-process.md) — branch progression, publication controls and recovery -## Core Features - -### ✅ Attribute-based configuration -- `#[Auditable]` on entity classes to enable auditing -- `#[AuditField(label: '...')]` on properties for custom field labels -- `#[AuditIgnore]` on sensitive fields to exclude from audit logs - -### ✅ Change detection -- Automatic diff of old → new values for UPDATE operations -- Smart stringification of complex types (Enums, Collections, Objects, etc.) -- Configurable truncation of long values - -### ✅ Actor tracking -- Integration with Symfony Security to track "who did it" -- Automatic impersonation detection (original user vs impersonator) -- Fallback to null for unauthenticated actions - -### ✅ Flexible persistence -- **Async mode** (recommended): Messages dispatched to Messenger, processed in background -- **Sync mode**: Audit entries written immediately within the request lifecycle -- Easy toggle between modes via configuration - -### ✅ Production-ready -- Full type checking (PHPStan level 8) -- Comprehensive test coverage -- Follows Symfony conventions and best practices -- Stable API suitable for package distribution - -## Quick links - -- [Installation & Quick Start](../README.md#installation) -- [Configuration Reference](../README.md#configuration-reference) -- [Security & PII Handling](../README.md#security--pii) -- [GitHub Repository](https://github.com/zhortein/auditable-bundle) - -## Architecture - -The bundle consists of: - -- **Attributes**: Configuration metadata for entities and fields -- **Metadata Provider**: Reflection-based extraction of audit configuration -- **Change Detector**: Computes diffs between old and new entity states -- **Historizer**: Main service for recording audit entries (entry point for custom logging) -- **Doctrine Listener**: Intercepts entity lifecycle events to trigger auditing -- **Writers**: Sync/Async implementations for persisting audit data -- **Message Handler**: Processes queued audit messages from Messenger - -## Example usage - -```php -use Zhortein\AuditableBundle\Attribute\Auditable; -use Zhortein\AuditableBundle\Attribute\AuditField; -use Zhortein\AuditableBundle\Attribute\AuditIgnore; - -#[Auditable(label: 'Customer')] -class Customer -{ - #[AuditField(label: 'Email Address')] - private string $email; - - #[AuditField(label: 'Full Name')] - private string $name; - - #[AuditIgnore] - private string $passwordHash; -} - -// In a controller/service: -$customer->setEmail('new@example.com'); -$em->persist($customer); -$em->flush(); - -// Automatically generates an audit entry: -// Title: "Update [Customer] - 2 field(s) changed" -// Description: "Email Address: old@example.com → new@example.com" -``` - -## Further reading - -- See [README.md](../README.md) for installation and configuration -- Review source code documentation in `/src` for detailed API information -- Check [tests](../tests) for integration examples +The transactional PostgreSQL suite proves shared commit, shared rollback and fail-closed rollback. SQLite covers most container and mapping integration tests. Neither database choice imposes an audit entity or storage on consuming applications. diff --git a/docs/legacy-mode.md b/docs/legacy-mode.md new file mode 100644 index 0000000..058b892 --- /dev/null +++ b/docs/legacy-mode.md @@ -0,0 +1,51 @@ +# Legacy mode + +Legacy mode is the behavior preserved from 1.0.0 for backward compatibility. It is enabled by default and is deliberately distinct from the opt-in strict transactional recorder. + +## Attributes and automatic operations + +The supported attributes are: + +- `#[Auditable]` on an entity class; +- `#[AuditField]` on a property to provide a display label; +- `#[AuditIgnore]` on a property to exclude it from automatic change data. + +The Doctrine listener observes create, update and delete operations. Automatic entries use the historical `isAuto=false` value. This behavior is preserved rather than silently reinterpreted. + +## Historical persistence model + +The bundle-owned `AuditEntry` entity maps to `audit_entry` with an auto-generated integer identifier. Its historical indexes cover `occurred_at` and the pair `entity_class, entity_id`. Principal columns include occurrence time, action, level, title, description, context, entity class and identifier, actor and impersonator identifiers, `is_auto`, and JSON data. + +Actor and target identifiers are stored as strings. Legacy entity extraction calls `getId()` only and does not support composite identifiers. The legacy security resolver prefers `getId()` when available and otherwise uses `getUserIdentifier()`. + +## Fail-open behavior and flush + +`Historizer` catches every `Throwable`, logs the error through PSR-3 and does not rethrow it. This is fail-open behavior: an audit failure does not interrupt the business flow. + +`AuditEntryPersister` calls `persist()` and then `flush()`. Consequently, legacy mode provides no guarantee that a business mutation and its audit entry share one application-controlled atomic transaction. Applications requiring fail-closed shared commit and rollback should use the [strict transactional mode](transactional-doctrine.md). + +## Synchronous and asynchronous writers + +With `async.enabled=false`, `SyncAuditEntryWriter` invokes the legacy persister immediately. With `async.enabled=true`, `AsyncAuditEntryWriter` dispatches `PersistAuditEntryMessage` to Messenger and persistence occurs when its handler runs. + +Messenger routing determines the effective transport. The historical `async.transport` configuration key and parameter remain available for backward compatibility, but the writer does not use them to select a transport. Route `PersistAuditEntryMessage` explicitly in Messenger configuration. + +Asynchronous delivery separates business and audit processing and therefore does not provide a shared database transaction. Before disabling the legacy mapping, drain pending messages and stop legacy workers. + +## Configuration + +- `enabled` defaults to `true` and controls the legacy runtime. +- `legacy_mapping.enabled` defaults to `true` and controls Doctrine registration of `AuditEntry`; it cannot be disabled while `enabled=true`. +- `async.enabled` defaults to `true` and selects the legacy writer. +- `async.transport` defaults to `async` but does not replace Messenger routing. +- `listener.track_insert`, `track_update` and `track_delete` default to `true`. +- `fields.max_string_length` defaults to `180`. +- `fields.global_ignored` defaults to an empty list. + +Disabling the mapping never deletes an existing table and no migration is supplied. + +## When to use transactional mode + +Prefer transactional mode when an audit failure must prevent the business commit, when business and audit rows must share commit or rollback, when composite or non-`getId()` subjects are required, or when the application must own its audit entity, factory, storage and retention model. + +See also [Upgrade 2.0](../UPGRADE-2.0.md) and [Security and privacy](security-privacy.md). diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..28104d8 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,62 @@ +# Release process + +This process is for project maintainers. Creating release branches, merging to `main`, tagging, creating GitHub Releases and publishing to Packagist remain manual operations that require explicit maintainer authorization. + +## Branch model + +The release progression is: + +```text +feature/* -> develop +develop -> release/x.y.z +release/x.y.z -> main +tag x.y.z +GitHub Release +Packagist +main -> develop +``` + +Tags use the exact `x.y.z` form without a `v` prefix. + +## Before creating a release branch + +- Confirm that the integration pull request is green. +- Confirm that no unreviewed change is included. +- Confirm that the public API snapshots pass. +- Confirm that the general compatibility matrix passes. +- Confirm that the PostgreSQL checks pass. +- Confirm that PHPStan passes at maximum level. +- Confirm that Composer Audit passes. +- Confirm that the package archive check passes. +- Confirm that migration documentation is complete. + +## Creating release/x.y.z + +Create `release/x.y.z` from `develop`. Do not force-push it. Change the version, changelog and release date only as part of the release work. Explicitly review the Composer branch alias for the branch being prepared; the subsequent synchronization of `main` and `develop` may require a different alias decision for the continuing development cycle. Do not publish anything at this stage. + +## Pull request to main + +Open a pull request with `main` as its base and `release/x.y.z` as its head. A merge commit is recommended to preserve the release topology. All required checks must pass. Do not enable automatic merging, and do not create the tag before the pull request is merged with explicit maintainer approval. + +## Tag and publication + +Only after explicit maintainer authorization: + +1. Create the exact `x.y.z` tag on the validated `main` commit. +2. Never move that tag. +3. Create the GitHub Release for the tag. +4. Verify the release on Packagist. + +Never rebuild or replace a release using the same version number. + +## Synchronize main back to develop + +Open a pull request from `main` to `develop` and use a merge commit. Resolve every divergence explicitly. Do not cherry-pick a partial collection of release commits. Verify that `develop` contains the expected tag and release state. + +## Dependabot + +GitHub reads `.github/dependabot.yml` from the default branch, `main`. Version updates target `develop` through `target-branch`. Security updates target the default branch, `main`. After applying a security fix on `main`, immediately synchronize `main` back to `develop`. + +## Rollback and recovery + +Never delete or rewrite a published tag. Revert a release with a new commit or prepare a new corrective version. A ruleset may be temporarily disabled only during documented administrator recovery. Never force-push to rewrite a public release. diff --git a/docs/security-privacy.md b/docs/security-privacy.md new file mode 100644 index 0000000..cbbbdbe --- /dev/null +++ b/docs/security-privacy.md @@ -0,0 +1,51 @@ +# Security and privacy responsibilities + +Audit records can concentrate sensitive information. This bundle supplies recording mechanisms; it does not make an application legally compliant or determine which data it may retain. Establish retention and deletion obligations with the application's legal, privacy or compliance advisers. + +## Sensitive data + +Do not audit passwords, authentication or refresh tokens, API secrets, private keys, temporary codes, complete payment data, or medical and other sensitive data without a documented need and appropriate protection. + +## Data-minimization mechanisms + +- Use `#[AuditIgnore]` for fields excluded from automatic legacy change detection. +- Use `fields.global_ignored` for property names excluded across audited entities. +- Control every value placed in transactional `data` arrays. +- Control actor metadata produced by custom resolvers or explicit `AuditActor` values. +- Use application-owned factories and storage to validate and minimize transactional records. + +These mechanisms depend on application configuration and review; they are not automatic classification of sensitive data. + +## Actor identifiers + +The default transactional resolver uses `getUserIdentifier()`, which may return an email address or another personal identifier. Replace `AuditActorResolverInterface` when an opaque identifier is more appropriate. + +The supplied resolver does not implicitly collect roles, IP addresses, user agents, tenants or organizations. Applications adding such metadata remain responsible for necessity, transparency and retention. + +## Storage, access and retention + +Restrict access to audit tables and administrative views. Define retention, archival and deletion policies. Apply backup controls and encryption appropriate to the application and its infrastructure. An audit trail may be more sensitive than an individual business record because it aggregates actions, identities and historical values. + +Avoid exposing audit payloads or sensitive identifiers in exception messages and logs. Review custom factories, storage implementations and log handlers accordingly. + +## Guarantees not supplied + +The bundle does not automatically provide: + +- encryption at rest; +- cryptographic signatures or hash chaining; +- append-only storage or database immutability; +- tamper evidence; +- archival, purge or anonymization workflows; +- GDPR or sector-specific compliance; +- authorization for an administration interface. + +## Transactional mode + +Fail-closed recording protects the application-controlled transaction boundary; it is not a cryptographic integrity guarantee. Factory and storage implementations remain responsible for validation and minimization. Using another entity manager or connection removes the demonstrated shared-atomicity guarantee. + +## Legacy mode + +Legacy mode is fail-open: `Historizer` logs and absorbs failures. Automatic field change detection can capture unexpected values unless `#[AuditIgnore]` and `fields.global_ignored` are applied carefully. Its persister flushes and does not provide the strict recorder's application-controlled shared transaction. + +For behavioral details, see [Legacy mode](legacy-mode.md) and [Transactional Doctrine integration](transactional-doctrine.md). diff --git a/docs/transactional-doctrine.md b/docs/transactional-doctrine.md new file mode 100644 index 0000000..d231f06 --- /dev/null +++ b/docs/transactional-doctrine.md @@ -0,0 +1,102 @@ +# Transactional Doctrine integration + +The strict recorder is deliberately independent from an application's persistence model. An application that stores audit entries with Doctrine supplies its own audit entity, `AuditEntryFactoryInterface` implementation, `AuditStorageInterface` implementation and PSR-20 clock. The bundle provides none of these persistence choices. + +The 2.0 design provides only strict, fail-closed recording: there is no `best_effort` mode. The recorder does not catch exceptions from subject extraction, actor resolution, the factory or storage. Applications that need the compatibility-preserved fail-open behavior should use the [legacy mode](legacy-mode.md) and account for its different transaction semantics. + +## Application-owned storage + +A minimal Doctrine storage attaches the application-owned audit entity to the same Unit of Work as the business mutation: + +```php +use Doctrine\ORM\EntityManagerInterface; +use Zhortein\AuditableBundle\Transactional\Contract\AuditStorageInterface; + +final readonly class DoctrineAuditStorage implements AuditStorageInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + ) { + } + + public function persist(object $entry): void + { + // Optional application-specific type validation belongs here. + $this->entityManager->persist($entry); + } +} +``` + +Calling `persist()` does not commit or even issue the insert. The storage alone does not guarantee durability. Atomicity requires the business mutation and audit storage to use the same entity manager and underlying connection; a factory or storage using another connection cannot provide the same guarantee. + +## Application transaction boundary + +The application owns the transaction boundary. `wrapInTransaction()` is the concise choice when one Doctrine Unit of Work covers the operation: + +```php +$entityManager->wrapInTransaction(function (EntityManagerInterface $entityManager) use ($operation, $auditRecorder): void { + $operation->complete(); + $entityManager->persist($operation); + + $auditRecorder->record(new AuditEvent( + action: 'complete', + title: 'Operation completed', + entity: $operation, + )); +}); +``` + +The recorder does not catch factory or storage errors. Doctrine controls flush and commit at the boundary, so a strict audit failure aborts the business transaction. Applications needing finer control can instead call `beginTransaction()`, perform the mutation and audit, call `flush()` and `commit()`, and call `rollBack()` immediately on failure. + +After a flush or transaction error, abandon the entity manager according to Doctrine's transaction practices rather than attempting to reuse a potentially inconsistent Unit of Work. Never keep a database transaction open during user interaction or across HTTP requests. + +The executable PostgreSQL fixtures in [`tests/Fixtures/Transactional/PostgreSql`](https://github.com/Zhortein/auditable-bundle/tree/main/tests/Fixtures/Transactional/PostgreSql) and [`TransactionalAtomicityIntegrationTest`](https://github.com/Zhortein/auditable-bundle/blob/main/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php) demonstrate shared commit, shared rollback and fail-closed behavior with application-owned UUID v7 entities. + +## Explicit values and default strategies + +When an `AuditEvent` contains an entity and no explicit subject, the default Doctrine extractor accepts scalar `int` and `string` identifiers, backed enums, stringable identifiers and primitive composite identifiers. Composite identifiers are encoded deterministically with the stable `doctrine-composite-v1` format. Associations that form part of a Doctrine identifier are not supported by the default extractor; use a custom `IdentifierExtractorInterface` or provide an explicit `AuditSubject`. + +An explicit `AuditSubject` bypasses identifier extraction. An explicit `AuditActor` bypasses the Symfony Security resolver. An explicit `occurredAt` bypasses the PSR-20 clock. These values let application code express system actors, external subjects or domain timestamps without changing the default aliases. + +## Transactional-only mapping + +During a transition, an application can keep the historical mapping while enabling the strict recorder: + +```yaml +zhortein_auditable: + legacy_mapping: + enabled: true + transactional: + enabled: true +``` + +The legacy runtime may be enabled or disabled in this coexistence configuration, and Doctrine continues to know the bundle's `AuditEntry` entity. + +Once the application no longer uses any legacy audit path, it can retain only its application-owned transactional mapping: + +```yaml +zhortein_auditable: + enabled: false + legacy_mapping: + enabled: false + transactional: + enabled: true +``` + +The opt-out is rejected unless `enabled` is also `false`. It only prevents registration of the bundle's legacy Doctrine mapping: it does not delete `audit_entry`, alter an existing schema or provide a migration. Historical data remains physically present until an application migration deliberately preserves, archives or changes it. Review any migration generated after removing the mapping before execution; the bundle does not recommend automatic deletion of audit history. + +Before disabling the mapping in an application that used asynchronous legacy auditing: + +1. Stop or disable production of legacy audits. +2. Drain or process every pending `PersistAuditEntryMessage`. +3. Verify that no legacy worker still writes `AuditEntry` records. +4. Only then disable the mapping. + +A queued legacy message still depends on the legacy message handler, persister and `AuditEntry` mapping. The bundle does not drain queues automatically. The executable SQLite and PostgreSQL opt-out tests demonstrate that the strict recorder and application-owned audit schema continue to work without registering the legacy metadata. + +## Related guidance + +- [Upgrade from 1.0 to 2.0](../UPGRADE-2.0.md) covers migration sequencing and rollback planning. +- [Legacy mode](legacy-mode.md) documents the compatibility-preserved listener and persistence behavior. +- [Security and privacy](security-privacy.md) describes data-minimization and storage responsibilities. +- [Compatibility and deprecation](compatibility.md) defines the supported platform and stability guarantees. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 59ae674..1c2e504 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,8 @@ parameters: - level: 8 + level: max + phpVersion: + min: 80300 + max: 80599 paths: - src - tests diff --git a/phpunit.postgresql.xml.dist b/phpunit.postgresql.xml.dist new file mode 100644 index 0000000..883759c --- /dev/null +++ b/phpunit.postgresql.xml.dist @@ -0,0 +1,17 @@ + + + + + tests/Integration/PostgreSql + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b165b93..293974d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,6 +8,7 @@ tests + tests/Integration/PostgreSql diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1158cd1..eea3896 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -15,9 +15,32 @@ public function getConfigTreeBuilder(): TreeBuilder $root = $treeBuilder->getRootNode(); $root + ->validate() + ->ifTrue(static function (array $config): bool { + /** @var array{enabled: bool, legacy_mapping: array{enabled: bool}} $typedConfig */ + $typedConfig = $config; + + return $typedConfig['enabled'] && !$typedConfig['legacy_mapping']['enabled']; + }) + ->thenInvalid('The legacy Doctrine mapping cannot be disabled while legacy auditing is enabled. Set "enabled" to false first.') + ->end() ->children() ->booleanNode('enabled')->defaultTrue()->end() + ->arrayNode('legacy_mapping') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultTrue()->end() + ->end() + ->end() + + ->arrayNode('transactional') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->end() + ->end() + ->arrayNode('async') ->addDefaultsIfNotSet() ->children() diff --git a/src/DependencyInjection/ZhorteinAuditableExtension.php b/src/DependencyInjection/ZhorteinAuditableExtension.php index 3a2d573..4f512dd 100644 --- a/src/DependencyInjection/ZhorteinAuditableExtension.php +++ b/src/DependencyInjection/ZhorteinAuditableExtension.php @@ -11,15 +11,27 @@ use Zhortein\AuditableBundle\Service\AsyncAuditEntryWriter; use Zhortein\AuditableBundle\Service\AuditEntryWriterInterface; use Zhortein\AuditableBundle\Service\SyncAuditEntryWriter; +use Zhortein\AuditableBundle\Transactional\Contract\AuditRecorderInterface; +use Zhortein\AuditableBundle\Transactional\Service\StrictAuditRecorder; final class ZhorteinAuditableExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); + /** @var array{ + * enabled: bool, + * legacy_mapping: array{enabled: bool}, + * transactional: array{enabled: bool}, + * async: array{enabled: bool, transport: string}, + * listener: array{track_insert: bool, track_update: bool, track_delete: bool}, + * fields: array{max_string_length: int, global_ignored: list} + * } $config + */ $config = $this->processConfiguration($configuration, $configs); $container->setParameter('zhortein_auditable.enabled', (bool) $config['enabled']); + $container->setParameter('zhortein_auditable.legacy_mapping.enabled', (bool) $config['legacy_mapping']['enabled']); $container->setParameter('zhortein_auditable.async.enabled', (bool) $config['async']['enabled']); $container->setParameter('zhortein_auditable.async.transport', (string) $config['async']['transport']); @@ -33,6 +45,18 @@ public function load(array $configs, ContainerBuilder $container): void $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); $loader->load('services.php'); + $container->removeDefinition(StrictAuditRecorder::class); + + if ($config['transactional']['enabled']) { + $container + ->register(StrictAuditRecorder::class, StrictAuditRecorder::class) + ->setAutowired(true) + ->setAutoconfigured(false) + ->setPublic(false); + $container + ->setAlias(AuditRecorderInterface::class, StrictAuditRecorder::class) + ->setPublic(false); + } // Alias Writer (sync vs async) $asyncEnabled = (bool) $config['async']['enabled']; diff --git a/src/Entity/AuditEntry.php b/src/Entity/AuditEntry.php index 425b080..95d4957 100644 --- a/src/Entity/AuditEntry.php +++ b/src/Entity/AuditEntry.php @@ -17,6 +17,7 @@ final class AuditEntry #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: Types::INTEGER)] + /** @phpstan-ignore property.unusedType (assigned by Doctrine) */ private ?int $id = null; #[ORM\Column(name: 'occurred_at', type: Types::DATETIME_IMMUTABLE)] diff --git a/src/Service/ChangeDetector.php b/src/Service/ChangeDetector.php index 0b479db..cac45d4 100644 --- a/src/Service/ChangeDetector.php +++ b/src/Service/ChangeDetector.php @@ -25,6 +25,7 @@ public function getChanges(EntityManagerInterface $em, object $entity): array $changes = $uow->getEntityChangeSet($entity); $formatted = []; + /** @var array $changes */ foreach ($changes as $field => [$old, $new]) { $formatted[$field] = [ 'old' => $this->stringify($old), @@ -58,17 +59,21 @@ private function stringify(mixed $value): string $sample = []; foreach ($value as $item) { + /** @var object $item */ if (method_exists($item, '__toString')) { $sample[] = (string) $item; } elseif (method_exists($item, 'getName')) { - /* @phpstan-ignore-next-line method.undefined */ - $sample[] = (string) $item->getName(); + /** @var scalar|\Stringable|null $name */ + $name = $item->getName(); + $sample[] = (string) $name; } elseif (method_exists($item, 'getTitle')) { - /* @phpstan-ignore-next-line method.undefined */ - $sample[] = (string) $item->getTitle(); + /** @var scalar|\Stringable|null $title */ + $title = $item->getTitle(); + $sample[] = (string) $title; } elseif (method_exists($item, 'getId')) { - /* @phpstan-ignore-next-line method.undefined */ - $sample[] = \sprintf('%s#%s', (new \ReflectionClass($item))->getShortName(), (string) $item->getId()); + /** @var scalar|\Stringable|null $id */ + $id = $item->getId(); + $sample[] = \sprintf('%s#%s', (new \ReflectionClass($item))->getShortName(), (string) $id); } else { $sample[] = (new \ReflectionClass($item))->getShortName(); } @@ -91,13 +96,22 @@ private function stringify(mixed $value): string return $this->truncate((string) $value); } if (method_exists($value, 'getName')) { - return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $value->getName())); + /** @var scalar|\Stringable|null $name */ + $name = $value->getName(); + + return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $name)); } if (method_exists($value, 'getTitle')) { - return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $value->getTitle())); + /** @var scalar|\Stringable|null $title */ + $title = $value->getTitle(); + + return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $title)); } if (method_exists($value, 'getId')) { - return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $value->getId())); + /** @var scalar|\Stringable|null $id */ + $id = $value->getId(); + + return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $id)); } return $this->truncate(\sprintf('[object %s]', (new \ReflectionClass($value))->getShortName())); @@ -111,7 +125,10 @@ private function stringify(mixed $value): string } } - return $this->truncate((string) $value); + /** @var scalar|\Stringable|null $stringableValue */ + $stringableValue = $value; + + return $this->truncate((string) $stringableValue); } private function truncate(string $value): string diff --git a/src/Service/Historizer.php b/src/Service/Historizer.php index fb77203..5748c52 100644 --- a/src/Service/Historizer.php +++ b/src/Service/Historizer.php @@ -73,6 +73,7 @@ public function historize( private function extractEntityId(object $entity): ?string { if (method_exists($entity, 'getId')) { + /** @var scalar|\Stringable|null $id */ $id = $entity->getId(); return null !== $id ? (string) $id : null; diff --git a/src/Service/SecurityActorResolver.php b/src/Service/SecurityActorResolver.php index c121c1e..9ec9b00 100644 --- a/src/Service/SecurityActorResolver.php +++ b/src/Service/SecurityActorResolver.php @@ -37,6 +37,7 @@ private function extractId(mixed $user): ?string } if (method_exists($user, 'getId')) { + /** @var scalar|\Stringable|null $id */ $id = $user->getId(); return null !== $id ? (string) $id : null; diff --git a/src/Transactional/Contract/AuditActorResolverInterface.php b/src/Transactional/Contract/AuditActorResolverInterface.php new file mode 100644 index 0000000..e2205d7 --- /dev/null +++ b/src/Transactional/Contract/AuditActorResolverInterface.php @@ -0,0 +1,18 @@ + $metadata + */ + public function __construct( + public string $type, + public ?string $identifier = null, + public ?string $impersonatorIdentifier = null, + public array $metadata = [], + ) { + if ('' === trim($type)) { + throw new \InvalidArgumentException('The audit actor type must not be empty.'); + } + if (null !== $identifier && '' === trim($identifier)) { + throw new \InvalidArgumentException('The audit actor identifier must not be empty when provided.'); + } + if (null !== $impersonatorIdentifier && '' === trim($impersonatorIdentifier)) { + throw new \InvalidArgumentException('The audit actor impersonator identifier must not be empty when provided.'); + } + } +} diff --git a/src/Transactional/Model/AuditEvent.php b/src/Transactional/Model/AuditEvent.php new file mode 100644 index 0000000..0d91e3c --- /dev/null +++ b/src/Transactional/Model/AuditEvent.php @@ -0,0 +1,55 @@ + $data + */ + public function __construct( + AuditAction|string $action, + public string $title, + public ?string $description = null, + public ?string $context = null, + AuditLevel|string $level = AuditLevel::INFO, + public ?object $entity = null, + public ?AuditSubject $subject = null, + public ?AuditActor $actor = null, + public bool $isAuto = false, + public array $data = [], + public ?\DateTimeImmutable $occurredAt = null, + ) { + $this->action = $action instanceof AuditAction ? $action->value : $action; + $this->level = $level instanceof AuditLevel ? $level->value : $level; + + if ('' === trim($this->action)) { + throw new \InvalidArgumentException('The audit event action must not be empty.'); + } + if ('' === trim($this->level)) { + throw new \InvalidArgumentException('The audit event level must not be empty.'); + } + if ('' === trim($title)) { + throw new \InvalidArgumentException('The audit event title must not be empty.'); + } + if (null !== $entity && null !== $subject) { + throw new \InvalidArgumentException('An audit event cannot contain both an entity and a resolved subject.'); + } + } +} diff --git a/src/Transactional/Model/AuditRecord.php b/src/Transactional/Model/AuditRecord.php new file mode 100644 index 0000000..9f8fdf4 --- /dev/null +++ b/src/Transactional/Model/AuditRecord.php @@ -0,0 +1,35 @@ + $data + */ + public function __construct( + public \DateTimeImmutable $occurredAt, + public string $action, + public string $level, + public string $title, + public ?string $description = null, + public ?string $context = null, + public ?AuditSubject $subject = null, + public ?AuditActor $actor = null, + public bool $isAuto = false, + public array $data = [], + ) { + if ('' === trim($action)) { + throw new \InvalidArgumentException('The audit record action must not be empty.'); + } + if ('' === trim($level)) { + throw new \InvalidArgumentException('The audit record level must not be empty.'); + } + if ('' === trim($title)) { + throw new \InvalidArgumentException('The audit record title must not be empty.'); + } + } +} diff --git a/src/Transactional/Model/AuditSubject.php b/src/Transactional/Model/AuditSubject.php new file mode 100644 index 0000000..aa90b96 --- /dev/null +++ b/src/Transactional/Model/AuditSubject.php @@ -0,0 +1,27 @@ +resolveMetadata($entity); + $fields = $metadata->getIdentifierFieldNames(); + if ([] === $fields) { + throw new IdentifierExtractionException(\sprintf('Doctrine metadata for "%s" declares no identifier field.', $metadata->getName())); + } + + $values = $metadata->getIdentifierValues($entity); + $canonical = []; + foreach ($fields as $field) { + if (!\array_key_exists($field, $values) || null === $values[$field]) { + throw new IdentifierExtractionException(\sprintf('Identifier field "%s" is not available for mapped class "%s".', $field, $metadata->getName())); + } + if ($metadata->hasAssociation($field)) { + throw new IdentifierExtractionException(\sprintf('Association identifier field "%s" on mapped class "%s" requires a custom extractor or an explicit AuditSubject.', $field, $metadata->getName())); + } + $canonical[$field] = $this->canonicalize($values[$field], $field, $metadata->getName()); + } + + if (1 === \count($fields)) { + return new AuditSubject($metadata->getName(), $canonical[$fields[0]]['value']); + } + + ksort($canonical, \SORT_STRING); + try { + $identifier = json_encode( + ['format' => 'doctrine-composite-v1', 'fields' => $canonical], + \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES, + ); + } catch (\JsonException $exception) { + throw new IdentifierExtractionException(\sprintf('Composite identifier for mapped class "%s" cannot be encoded.', $metadata->getName()), previous: $exception); + } + + return new AuditSubject($metadata->getName(), $identifier); + } + + /** @return ClassMetadata */ + private function resolveMetadata(object $entity): ClassMetadata + { + $class = $entity::class; + try { + $manager = $this->registry->getManagerForClass($class); + if (null === $manager && $entity instanceof Proxy) { + $parent = get_parent_class($entity); + if (false !== $parent) { + $manager = $this->registry->getManagerForClass($parent); + $class = $parent; + } + } + } catch (\Throwable $exception) { + throw new IdentifierExtractionException(\sprintf('Unable to resolve a persistence manager for class "%s".', $class), previous: $exception); + } + + if (null === $manager) { + throw new IdentifierExtractionException(\sprintf('No persistence manager found for class "%s".', $class)); + } + if (!$manager instanceof EntityManagerInterface) { + throw new IdentifierExtractionException(\sprintf('Persistence manager for class "%s" is not a Doctrine ORM entity manager.', $class)); + } + + try { + return $this->loadMetadata($manager, $class); + } catch (\Throwable $exception) { + throw new IdentifierExtractionException(\sprintf('Unable to load Doctrine ORM metadata for class "%s".', $class), previous: $exception); + } + } + + /** + * @return ClassMetadata + * + * @throws \Throwable + */ + private function loadMetadata(EntityManagerInterface $manager, string $class): ClassMetadata + { + return $manager->getClassMetadata($class); + } + + /** @return array{type: 'integer'|'string', value: string} */ + private function canonicalize(mixed $value, string $field, string $class): array + { + if ($value instanceof \BackedEnum) { + $value = $value->value; + } + if (\is_int($value)) { + return ['type' => 'integer', 'value' => (string) $value]; + } + if ($value instanceof \Stringable) { + try { + $stringifier = \Closure::fromCallable([$value, '__toString']); + $value = $stringifier(); + } catch (\Throwable $exception) { + throw new IdentifierExtractionException(\sprintf('Stringable identifier field "%s" on mapped class "%s" could not be converted.', $field, $class), previous: $exception); + } + } + if (\is_string($value)) { + if (!mb_check_encoding($value, 'UTF-8')) { + throw new IdentifierExtractionException(\sprintf('Identifier field "%s" on mapped class "%s" contains invalid UTF-8.', $field, $class)); + } + if ('' === $value || 1 === preg_match('/^\s+$/u', $value)) { + throw new IdentifierExtractionException(\sprintf('Identifier field "%s" on mapped class "%s" must not be empty or blank.', $field, $class)); + } + + return ['type' => 'string', 'value' => $value]; + } + + throw new IdentifierExtractionException(\sprintf('Identifier field "%s" on mapped class "%s" has unsupported PHP type "%s"; use a custom extractor or an explicit AuditSubject.', $field, $class, get_debug_type($value))); + } +} diff --git a/src/Transactional/Service/StrictAuditRecorder.php b/src/Transactional/Service/StrictAuditRecorder.php new file mode 100644 index 0000000..5f3c51f --- /dev/null +++ b/src/Transactional/Service/StrictAuditRecorder.php @@ -0,0 +1,56 @@ + $entryFactory + * @param AuditStorageInterface $storage + */ + public function __construct( + private IdentifierExtractorInterface $identifierExtractor, + private AuditActorResolverInterface $actorResolver, + private ClockInterface $clock, + private AuditEntryFactoryInterface $entryFactory, + private AuditStorageInterface $storage, + ) { + } + + public function record(AuditEvent $event): void + { + $subject = $event->subject ?? (null !== $event->entity ? $this->identifierExtractor->extract($event->entity) : null); + $actor = $event->actor ?? $this->actorResolver->resolveActor(); + $occurredAt = $event->occurredAt ?? $this->clock->now(); + + $record = new AuditRecord( + occurredAt: $occurredAt, + action: $event->action, + level: $event->level, + title: $event->title, + description: $event->description, + context: $event->context, + subject: $subject, + actor: $actor, + isAuto: $event->isAuto, + data: $event->data, + ); + + $entry = $this->entryFactory->create($record); + $this->storage->persist($entry); + } +} diff --git a/src/Transactional/Service/SymfonySecurityActorResolver.php b/src/Transactional/Service/SymfonySecurityActorResolver.php new file mode 100644 index 0000000..74926ee --- /dev/null +++ b/src/Transactional/Service/SymfonySecurityActorResolver.php @@ -0,0 +1,90 @@ +tokenStorage->getToken(); + } catch (\Throwable $exception) { + throw new ActorResolutionException('The current security token could not be read.', previous: $exception); + } + + if (null === $token) { + return null; + } + + try { + $user = $token->getUser(); + } catch (\Throwable $exception) { + throw new ActorResolutionException('The current user could not be read from the security token.', previous: $exception); + } + + if (!$user instanceof UserInterface) { + if ($token instanceof SwitchUserToken) { + throw new ActorResolutionException('The current user in the impersonation token is inconsistent.'); + } + + return null; + } + + $identifier = $this->resolveIdentifier($user, false); + $impersonatorIdentifier = null; + + if ($token instanceof SwitchUserToken) { + try { + $originalUser = $token->getOriginalToken()->getUser(); + } catch (\Throwable $exception) { + throw new ActorResolutionException('The original user in the impersonation token could not be read.', previous: $exception); + } + if (!$originalUser instanceof UserInterface) { + throw new ActorResolutionException('The original user in the impersonation token is inconsistent.'); + } + $impersonatorIdentifier = $this->resolveIdentifier($originalUser, true); + } + + try { + return new AuditActor( + type: self::ACTOR_TYPE, + identifier: $identifier, + impersonatorIdentifier: $impersonatorIdentifier, + metadata: [], + ); + } catch (\Throwable $exception) { + throw new ActorResolutionException('The resolved security user cannot produce a valid audit actor.', previous: $exception); + } + } + + private function resolveIdentifier(UserInterface $user, bool $impersonator): string + { + try { + $identifier = $user->getUserIdentifier(); + } catch (\Throwable $exception) { + throw new ActorResolutionException($impersonator ? 'The impersonator identifier could not be read.' : 'The current user identifier could not be read.', previous: $exception); + } + + if (!mb_check_encoding($identifier, 'UTF-8') || 1 === preg_match('/^\s*$/u', $identifier)) { + throw new ActorResolutionException($impersonator ? 'The impersonator identifier is invalid.' : 'The current user identifier is invalid.'); + } + + return $identifier; + } +} diff --git a/src/ZhorteinAuditableBundle.php b/src/ZhorteinAuditableBundle.php index e61d17a..cf2b0fd 100644 --- a/src/ZhorteinAuditableBundle.php +++ b/src/ZhorteinAuditableBundle.php @@ -5,6 +5,7 @@ namespace Zhortein\AuditableBundle; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DoctrineOrmMappingsPass; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -25,17 +26,33 @@ public function build(ContainerBuilder $container): void return; } + $mappingPass = DoctrineOrmMappingsPass::createAttributeMappingDriver( + // Namespaces / mapping prefixes + ['Zhortein\\AuditableBundle\\Entity'], + // Directories that contain the entities + [$entityDir], + // Manager parameters: empty = all entity managers + [], + // Enabled parameter: preserve the mapping by default, with an explicit opt-out. + 'zhortein_auditable.legacy_mapping.enabled', + ); + $container->addCompilerPass( - DoctrineOrmMappingsPass::createAttributeMappingDriver( - // Namespaces / mapping prefixes - ['Zhortein\\AuditableBundle\\Entity'], - // Directories that contain the entities - [$entityDir], - // Manager parameters: empty = all entity managers - [], - // Enabled parameter: false = always enabled - false, - ) + new readonly class($mappingPass) implements CompilerPassInterface { + public function __construct( + private DoctrineOrmMappingsPass $mappingPass, + ) { + } + + public function process(ContainerBuilder $container): void + { + if (true !== $container->getParameter('zhortein_auditable.legacy_mapping.enabled')) { + return; + } + + $this->mappingPass->process($container); + } + } ); } } diff --git a/tests/Contract/DoctrineIdentifierExtractorContractTest.php b/tests/Contract/DoctrineIdentifierExtractorContractTest.php new file mode 100644 index 0000000..2952f1a --- /dev/null +++ b/tests/Contract/DoctrineIdentifierExtractorContractTest.php @@ -0,0 +1,57 @@ +getNamespaceName()); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + self::assertTrue($reflection->implementsInterface(IdentifierExtractorInterface::class)); + + $constructor = $reflection->getConstructor(); + self::assertNotNull($constructor); + self::assertTrue($constructor->isPublic()); + $parameters = $constructor->getParameters(); + self::assertCount(1, $parameters); + self::assertSame('registry', $parameters[0]->getName()); + self::assertSame(ManagerRegistry::class, (string) $parameters[0]->getType()); + self::assertTrue($parameters[0]->isPromoted()); + self::assertTrue($reflection->getProperty('registry')->isPrivate()); + + $publicMethods = array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => DoctrineIdentifierExtractor::class === $method->getDeclaringClass()->getName(), + )); + self::assertSame(['__construct', 'extract'], array_map(static fn (\ReflectionMethod $method): string => $method->getName(), $publicMethods)); + $extract = $reflection->getMethod('extract'); + self::assertSame('object', (string) $extract->getParameters()[0]->getType()); + self::assertSame(AuditSubject::class, (string) $extract->getReturnType()); + } + + public function testExceptionContractIsExact(): void + { + $reflection = new \ReflectionClass(IdentifierExtractionException::class); + self::assertSame('Zhortein\\AuditableBundle\\Transactional\\Exception', $reflection->getNamespaceName()); + self::assertTrue($reflection->isFinal()); + $parent = $reflection->getParentClass(); + self::assertInstanceOf(\ReflectionClass::class, $parent); + self::assertSame(\RuntimeException::class, $parent->getName()); + self::assertSame([], array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => IdentifierExtractionException::class === $method->getDeclaringClass()->getName(), + ))); + } +} diff --git a/tests/Contract/PublicApi20ContractTest.php b/tests/Contract/PublicApi20ContractTest.php new file mode 100644 index 0000000..8a0e287 --- /dev/null +++ b/tests/Contract/PublicApi20ContractTest.php @@ -0,0 +1,174 @@ + $contract) { + self::assertArrayHasKey($type, $versionTwo, \sprintf('The 1.0 public type %s is missing from the 2.0 snapshot.', $type)); + self::assertSame($contract, $versionTwo[$type], \sprintf('The historical contract of %s changed in the 2.0 snapshot.', $type)); + } + } + + public function testFuturePublicTypesRemainAllowed(): void + { + $snapshot = ['Existing\\PublicType' => self::contractFixture()]; + $current = $snapshot; + $current['Future\\PublicType'] = self::contractFixture(); + + self::assertVersionTwoContractIsPreserved($snapshot, $current); + } + + /** + * @param PublicApi $snapshot + * @param PublicApi $current + */ + #[DataProvider('incompatibleVersionTwoContracts')] + public function testVersionTwoContractChangesAreRejected(array $snapshot, array $current): void + { + $this->expectException(\PHPUnit\Framework\ExpectationFailedException::class); + self::assertVersionTwoContractIsPreserved($snapshot, $current); + } + + /** @return iterable */ + public static function incompatibleVersionTwoContracts(): iterable + { + $snapshot = ['Frozen\\PublicType' => self::contractFixture()]; + + yield 'removed type' => [$snapshot, []]; + + $changed = self::contractFixture(); + $changed['final'] = false; + yield 'changed final state' => [$snapshot, ['Frozen\\PublicType' => $changed]]; + + $changed = self::contractFixture(); + $changed['readonly'] = false; + yield 'changed readonly state' => [$snapshot, ['Frozen\\PublicType' => $changed]]; + + $changed = self::contractFixture(); + $changed['enum_cases'] = ['Changed' => 'changed']; + yield 'changed enum cases' => [$snapshot, ['Frozen\\PublicType' => $changed]]; + + $changed = self::contractFixture(); + $changed['public_properties']['value']['type'] = 'int'; + /** @var PublicApi $changedApi */ + $changedApi = ['Frozen\\PublicType' => $changed]; + yield 'changed public property' => [$snapshot, $changedApi]; + + $changed = self::contractFixture(); + $changed['public_methods']['create']['return'] = 'int'; + /** @var PublicApi $changedApi */ + $changedApi = ['Frozen\\PublicType' => $changed]; + yield 'changed public method' => [$snapshot, $changedApi]; + + $changed = self::contractFixture(); + $changed['public_methods']['__construct']['parameters'][0]['name'] = 'renamed'; + /** @var PublicApi $changedApi */ + $changedApi = ['Frozen\\PublicType' => $changed]; + yield 'changed constructor' => [$snapshot, $changedApi]; + + $interface = self::contractFixture(); + $interface['kind'] = 'interface'; + $current = $interface; + $current['public_methods']['additionalMethod'] = [ + 'static' => false, + 'return' => 'void', + 'parameters' => [], + ]; + yield 'added interface method' => [ + ['Frozen\\PublicType' => $interface], + ['Frozen\\PublicType' => $current], + ]; + } + + /** + * @param PublicApi $snapshot + * @param PublicApi $current + */ + private static function assertVersionTwoContractIsPreserved(array $snapshot, array $current): void + { + PublicApiContractTest::assertHistoricalContractIsPreserved($snapshot, $current); + + foreach ($snapshot as $type => $contract) { + if ('interface' === $contract['kind']) { + self::assertSame( + $contract['public_methods'], + $current[$type]['public_methods'], + \sprintf('The 2.0 interface %s gained a method.', $type), + ); + } + } + } + + /** @return PublicApi */ + private static function snapshot(string $file): array + { + self::assertFileExists($file); + $snapshot = json_decode((string) file_get_contents($file), true, flags: \JSON_THROW_ON_ERROR); + self::assertIsArray($snapshot); + /** @var PublicApi $typedSnapshot */ + $typedSnapshot = $snapshot; + + return $typedSnapshot; + } + + /** @return PublicContract */ + private static function contractFixture(): array + { + return [ + 'kind' => 'class', + 'final' => true, + 'readonly' => true, + 'attribute_targets' => null, + 'enum_cases' => ['Stable' => 'stable'], + 'public_properties' => [ + 'value' => [ + 'type' => 'string', + 'readonly' => true, + 'static' => false, + ], + ], + 'public_methods' => [ + '__construct' => [ + 'static' => false, + 'return' => null, + 'parameters' => [[ + 'name' => 'value', + 'type' => 'string', + 'by_reference' => false, + 'variadic' => false, + 'has_default' => false, + 'default' => null, + ]], + ], + 'create' => [ + 'static' => true, + 'return' => 'self', + 'parameters' => [], + ], + ], + ]; + } +} diff --git a/tests/Contract/PublicApiContractTest.php b/tests/Contract/PublicApiContractTest.php new file mode 100644 index 0000000..e190b7c --- /dev/null +++ b/tests/Contract/PublicApiContractTest.php @@ -0,0 +1,314 @@ +} + * @phpstan-type PublicProperty array{type: ?string, readonly: bool, static: bool} + * @phpstan-type PublicContract array{ + * kind: 'class'|'interface'|'enum', + * final: bool, + * readonly: bool, + * attribute_targets: ?int, + * enum_cases: array, + * public_properties: array, + * public_methods: array + * } + * @phpstan-type PublicApi array + */ +final class PublicApiContractTest extends TestCase +{ + private const SNAPSHOT = __DIR__.'/public-api-1.0.0.json'; + + public function testPublicApiMatchesVersionOneSnapshot(): void + { + $actual = self::publicApi(); + self::assertFileExists(self::SNAPSHOT); + $expected = json_decode((string) file_get_contents(self::SNAPSHOT), true, flags: \JSON_THROW_ON_ERROR); + self::assertIsArray($expected); + /** @var PublicApi $typedExpected */ + $typedExpected = $expected; + + self::assertHistoricalContractIsPreserved($typedExpected, $actual); + } + + public function testAdditionalPublicTypesAndMembersAreAllowed(): void + { + $historical = [ + 'Legacy\\PublicType' => self::contractFixture(), + ]; + $current = $historical; + $current['Future\\TransactionalType'] = self::contractFixture(); + $current['Legacy\\PublicType']['public_methods']['futureMethod'] = [ + 'static' => false, + 'return' => 'void', + 'parameters' => [], + ]; + $current['Legacy\\PublicType']['public_properties']['futureProperty'] = [ + 'type' => 'string', + 'readonly' => true, + 'static' => false, + ]; + + self::assertHistoricalContractIsPreserved($historical, $current); + $reflection = new \ReflectionClass(CanonicalTypeChild::class); + + self::assertSame('self', self::type($reflection->getMethod('selfType')->getReturnType(), $reflection)); + self::assertSame('parent', self::type($reflection->getMethod('parentType')->getReturnType(), $reflection)); + self::assertSame('static', self::type($reflection->getMethod('staticType')->getReturnType(), $reflection)); + self::assertSame('?self', self::type($reflection->getMethod('nullableSelfType')->getReturnType(), $reflection)); + self::assertSame('self|string', self::type($reflection->getMethod('unionType')->getReturnType(), $reflection)); + self::assertSame( + CanonicalTypeLeft::class.'&'.CanonicalTypeRight::class, + self::type($reflection->getMethod('intersectionType')->getReturnType(), $reflection), + ); + } + + /** @param PublicApi $current */ + #[DataProvider('brokenHistoricalContracts')] + public function testMissingOrModifiedHistoricalContractIsRejected(array $current): void + { + $historical = ['Legacy\\PublicType' => self::contractFixture()]; + + $this->expectException(\PHPUnit\Framework\ExpectationFailedException::class); + self::assertHistoricalContractIsPreserved($historical, $current); + } + + /** @return iterable */ + public static function brokenHistoricalContracts(): iterable + { + yield 'missing historical type' => [[]]; + + $modified = self::contractFixture(); + $modified['final'] = false; + yield 'modified historical type' => [['Legacy\\PublicType' => $modified]]; + } + + /** @return PublicApi */ + public static function publicApi(): array + { + $classes = []; + foreach (self::sourceClasses() as $class) { + $reflection = new \ReflectionClass($class); + $methods = []; + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() !== $class) { + continue; + } + $parameters = []; + foreach ($method->getParameters() as $parameter) { + $parameters[] = [ + 'name' => $parameter->getName(), + 'type' => self::type($parameter->getType(), $reflection), + 'by_reference' => $parameter->isPassedByReference(), + 'variadic' => $parameter->isVariadic(), + 'has_default' => $parameter->isDefaultValueAvailable(), + 'default' => $parameter->isDefaultValueAvailable() ? self::value($parameter->getDefaultValue()) : null, + ]; + } + $methods[$method->getName()] = [ + 'static' => $method->isStatic(), + 'return' => self::type($method->getReturnType(), $reflection), + 'parameters' => $parameters, + ]; + } + ksort($methods); + + $properties = []; + foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->getDeclaringClass()->getName() !== $class) { + continue; + } + $properties[$property->getName()] = [ + 'type' => self::type($property->getType(), $reflection), + 'readonly' => $property->isReadOnly(), + 'static' => $property->isStatic(), + ]; + } + ksort($properties); + + $enumCases = []; + if ($reflection->isEnum()) { + foreach ($reflection->getReflectionConstants() as $case) { + if ($case->isEnumCase()) { + $enumCases[$case->getName()] = self::value($case->getValue()); + } + } + } + $classes[$class] = [ + 'kind' => $reflection->isInterface() ? 'interface' : ($reflection->isEnum() ? 'enum' : 'class'), + 'final' => $reflection->isFinal(), + 'readonly' => $reflection->isReadOnly(), + 'attribute_targets' => self::attributeTargets($reflection), + 'enum_cases' => $enumCases, + 'public_properties' => $properties, + 'public_methods' => $methods, + ]; + } + ksort($classes); + + return $classes; + } + + /** + * @param PublicApi $historical + * @param PublicApi $current + */ + public static function assertHistoricalContractIsPreserved(array $historical, array $current): void + { + foreach ($historical as $class => $expectedContract) { + self::assertArrayHasKey($class, $current, \sprintf('Historical public type %s no longer exists.', $class)); + $actualContract = $current[$class]; + + foreach (['kind', 'final', 'readonly', 'attribute_targets', 'enum_cases'] as $key) { + self::assertSame($expectedContract[$key], $actualContract[$key], \sprintf('%s changed for %s.', $key, $class)); + } + foreach (['public_methods', 'public_properties'] as $membersKey) { + foreach ($expectedContract[$membersKey] as $member => $expectedMember) { + self::assertArrayHasKey($member, $actualContract[$membersKey], \sprintf('%s::%s no longer has its historical public visibility.', $class, $member)); + self::assertSame($expectedMember, $actualContract[$membersKey][$member], \sprintf('Public contract of %s::%s changed.', $class, $member)); + } + } + } + } + + /** @return PublicContract */ + private static function contractFixture(): array + { + return [ + 'kind' => 'class', + 'final' => true, + 'readonly' => false, + 'attribute_targets' => null, + 'enum_cases' => [], + 'public_properties' => [], + 'public_methods' => [], + ]; + } + + /** @return list */ + private static function sourceClasses(): array + { + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(\dirname(__DIR__, 2).'/src')) as $file) { + if ($file instanceof \SplFileInfo && 'php' === $file->getExtension()) { + require_once $file->getPathname(); + } + } + + $sourceDirectory = realpath(\dirname(__DIR__, 2).'/src'); + self::assertIsString($sourceDirectory); + $symbols = array_merge(get_declared_classes(), get_declared_interfaces(), get_declared_traits()); + $symbols = array_values(array_filter( + $symbols, + static function (string $symbol) use ($sourceDirectory): bool { + if (!str_starts_with($symbol, 'Zhortein\\AuditableBundle\\')) { + return false; + } + $filename = (new \ReflectionClass($symbol))->getFileName(); + + return \is_string($filename) && str_starts_with(realpath($filename) ?: '', $sourceDirectory.'/'); + }, + )); + sort($symbols); + + /* @var list $symbols */ + return $symbols; + } + + /** @param \ReflectionClass $declaringClass */ + private static function type(?\ReflectionType $type, \ReflectionClass $declaringClass): ?string + { + if (null === $type) { + return null; + } + if ($type instanceof \ReflectionNamedType) { + $name = $type->getName(); + if ($name === $declaringClass->getName()) { + $name = 'self'; + } elseif (false !== $declaringClass->getParentClass() && $name === $declaringClass->getParentClass()->getName()) { + $name = 'parent'; + } + + return ($type->allowsNull() && 'mixed' !== $name ? '?' : '').$name; + } + if ($type instanceof \ReflectionUnionType) { + return implode('|', array_map(static fn (\ReflectionType $member): ?string => self::type($member, $declaringClass), $type->getTypes())); + } + if (!$type instanceof \ReflectionIntersectionType) { + throw new \LogicException('Unsupported reflection type.'); + } + + return implode('&', array_map(static fn (\ReflectionType $member): ?string => self::type($member, $declaringClass), $type->getTypes())); + } + + private static function value(mixed $value): mixed + { + if ($value instanceof \BackedEnum) { + return $value->value; + } + if ($value instanceof \UnitEnum) { + return $value->name; + } + + return $value; + } + + /** @param \ReflectionClass $reflection */ + private static function attributeTargets(\ReflectionClass $reflection): ?int + { + $attributes = $reflection->getAttributes(\Attribute::class); + + return [] === $attributes ? null : $attributes[0]->newInstance()->flags; + } +} + +interface CanonicalTypeLeft +{ +} + +interface CanonicalTypeRight +{ +} + +class CanonicalTypeParent +{ +} + +final class CanonicalTypeChild extends CanonicalTypeParent +{ + public function selfType(): self + { + throw new \LogicException(); + } + + public function parentType(): parent + { + throw new \LogicException(); + } + + public function staticType(): static + { + throw new \LogicException(); + } + + public function nullableSelfType(): ?self + { + throw new \LogicException(); + } + + public function unionType(): self|string + { + throw new \LogicException(); + } + + public function intersectionType(): CanonicalTypeLeft&CanonicalTypeRight + { + throw new \LogicException(); + } +} diff --git a/tests/Contract/StrictAuditRecorderContractTest.php b/tests/Contract/StrictAuditRecorderContractTest.php new file mode 100644 index 0000000..0eb08e6 --- /dev/null +++ b/tests/Contract/StrictAuditRecorderContractTest.php @@ -0,0 +1,72 @@ +getNamespaceName()); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + self::assertTrue($reflection->implementsInterface(AuditRecorderInterface::class)); + self::assertStringContainsString('@template TEntry of object', (string) $reflection->getDocComment()); + self::assertSame([], $reflection->getConstants(\ReflectionClassConstant::IS_PUBLIC)); + + $constructor = $reflection->getConstructor(); + self::assertNotNull($constructor); + self::assertTrue($constructor->isPublic()); + $parameters = $constructor->getParameters(); + self::assertSame( + ['identifierExtractor', 'actorResolver', 'clock', 'entryFactory', 'storage'], + array_map(static fn (\ReflectionParameter $parameter): string => $parameter->getName(), $parameters), + ); + self::assertSame( + [IdentifierExtractorInterface::class, AuditActorResolverInterface::class, ClockInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class], + array_map(static fn (\ReflectionParameter $parameter): string => (string) $parameter->getType(), $parameters), + ); + foreach ($parameters as $parameter) { + self::assertTrue($parameter->isPromoted()); + self::assertTrue($reflection->getProperty($parameter->getName())->isPrivate()); + } + $constructorDoc = (string) $constructor->getDocComment(); + self::assertMatchesRegularExpression('/@param AuditEntryFactoryInterface\s+\$entryFactory/', $constructorDoc); + self::assertMatchesRegularExpression('/@param AuditStorageInterface\s+\$storage/', $constructorDoc); + + $publicMethods = array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => StrictAuditRecorder::class === $method->getDeclaringClass()->getName(), + )); + self::assertSame(['__construct', 'record'], array_map(static fn (\ReflectionMethod $method): string => $method->getName(), $publicMethods)); + $record = $reflection->getMethod('record'); + self::assertSame(AuditEvent::class, (string) $record->getParameters()[0]->getType()); + self::assertSame('void', (string) $record->getReturnType()); + } + + public function testPsrClockIsADirectRuntimeDependency(): void + { + $composer = json_decode((string) file_get_contents(\dirname(__DIR__, 2).'/composer.json'), true, flags: \JSON_THROW_ON_ERROR); + self::assertIsArray($composer); + $require = $composer['require'] ?? null; + $requireDev = $composer['require-dev'] ?? null; + self::assertIsArray($require); + self::assertIsArray($requireDev); + self::assertSame('^1.0', $require['psr/clock'] ?? null); + self::assertArrayNotHasKey('symfony/clock', $require); + self::assertArrayNotHasKey('symfony/clock', $requireDev); + } +} diff --git a/tests/Contract/SymfonySecurityActorResolverContractTest.php b/tests/Contract/SymfonySecurityActorResolverContractTest.php new file mode 100644 index 0000000..3f6a01b --- /dev/null +++ b/tests/Contract/SymfonySecurityActorResolverContractTest.php @@ -0,0 +1,62 @@ +getNamespaceName()); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + self::assertTrue($reflection->implementsInterface(AuditActorResolverInterface::class)); + + $constructor = $reflection->getConstructor(); + self::assertNotNull($constructor); + self::assertTrue($constructor->isPublic()); + $parameters = $constructor->getParameters(); + self::assertCount(1, $parameters); + self::assertSame('tokenStorage', $parameters[0]->getName()); + self::assertSame(TokenStorageInterface::class, (string) $parameters[0]->getType()); + self::assertTrue($parameters[0]->isPromoted()); + $properties = $reflection->getProperties(); + self::assertCount(1, $properties); + self::assertSame('tokenStorage', $properties[0]->getName()); + self::assertTrue($properties[0]->isPrivate()); + + $publicMethods = array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => SymfonySecurityActorResolver::class === $method->getDeclaringClass()->getName(), + )); + self::assertSame(['__construct', 'resolveActor'], array_map(static fn (\ReflectionMethod $method): string => $method->getName(), $publicMethods)); + $resolve = $reflection->getMethod('resolveActor'); + self::assertCount(0, $resolve->getParameters()); + self::assertSame('?'.AuditActor::class, (string) $resolve->getReturnType()); + } + + public function testExceptionContractIsExact(): void + { + self::assertTrue(class_exists(ActorResolutionException::class)); + $reflection = new \ReflectionClass(ActorResolutionException::class); + self::assertSame('Zhortein\\AuditableBundle\\Transactional\\Exception', $reflection->getNamespaceName()); + self::assertTrue($reflection->isFinal()); + $parent = $reflection->getParentClass(); + self::assertInstanceOf(\ReflectionClass::class, $parent); + self::assertSame(\RuntimeException::class, $parent->getName()); + self::assertSame([], array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => ActorResolutionException::class === $method->getDeclaringClass()->getName(), + ))); + } +} diff --git a/tests/Contract/TransactionalContractsTest.php b/tests/Contract/TransactionalContractsTest.php new file mode 100644 index 0000000..12e2a78 --- /dev/null +++ b/tests/Contract/TransactionalContractsTest.php @@ -0,0 +1,176 @@ +> */ + private const CONSTRUCTORS = [ + AuditSubject::class => [ + ['name' => 'type', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'identifier', 'type' => 'string', 'optional' => false, 'default' => null], + ], + AuditActor::class => [ + ['name' => 'type', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'identifier', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'impersonatorIdentifier', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'metadata', 'type' => 'array', 'optional' => true, 'default' => []], + ], + AuditEvent::class => [ + ['name' => 'action', 'type' => 'Zhortein\\AuditableBundle\\Enum\\AuditAction|string', 'optional' => false, 'default' => null], + ['name' => 'title', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'description', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'context', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'level', 'type' => 'Zhortein\\AuditableBundle\\Enum\\AuditLevel|string', 'optional' => true, 'default' => 'info'], + ['name' => 'entity', 'type' => '?object', 'optional' => true, 'default' => null], + ['name' => 'subject', 'type' => '?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject', 'optional' => true, 'default' => null], + ['name' => 'actor', 'type' => '?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor', 'optional' => true, 'default' => null], + ['name' => 'isAuto', 'type' => 'bool', 'optional' => true, 'default' => false], + ['name' => 'data', 'type' => 'array', 'optional' => true, 'default' => []], + ['name' => 'occurredAt', 'type' => '?DateTimeImmutable', 'optional' => true, 'default' => null], + ], + AuditRecord::class => [ + ['name' => 'occurredAt', 'type' => 'DateTimeImmutable', 'optional' => false, 'default' => null], + ['name' => 'action', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'level', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'title', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'description', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'context', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'subject', 'type' => '?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject', 'optional' => true, 'default' => null], + ['name' => 'actor', 'type' => '?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor', 'optional' => true, 'default' => null], + ['name' => 'isAuto', 'type' => 'bool', 'optional' => true, 'default' => false], + ['name' => 'data', 'type' => 'array', 'optional' => true, 'default' => []], + ], + ]; + + /** @var array> */ + private const INTERFACES = [ + AuditRecorderInterface::class => ['record' => ['parameter' => 'event', 'type' => AuditEvent::class, 'return' => 'void']], + IdentifierExtractorInterface::class => ['extract' => ['parameter' => 'entity', 'type' => 'object', 'return' => AuditSubject::class]], + AuditActorResolverInterface::class => ['resolveActor' => ['parameter' => '', 'type' => '', 'return' => '?'.AuditActor::class]], + AuditEntryFactoryInterface::class => ['create' => ['parameter' => 'record', 'type' => AuditRecord::class, 'return' => 'object']], + AuditStorageInterface::class => ['persist' => ['parameter' => 'entry', 'type' => 'object', 'return' => 'void']], + ]; + + public function testExactModelContracts(): void + { + foreach (self::CONSTRUCTORS as $class => $expectedParameters) { + $reflection = new \ReflectionClass($class); + self::assertSame('Zhortein\\AuditableBundle\\Transactional\\Model', $reflection->getNamespaceName()); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + $constructor = $reflection->getConstructor(); + self::assertNotNull($constructor); + self::assertTrue($constructor->isPublic()); + self::assertFalse($constructor->isStatic()); + self::assertSame($expectedParameters, self::parameters($constructor)); + foreach ($constructor->getParameters() as $parameter) { + self::assertFalse($parameter->isPassedByReference()); + self::assertFalse($parameter->isVariadic()); + } + + $declaredMethods = array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $class, + ); + self::assertSame(['__construct'], array_values(array_map(static fn (\ReflectionMethod $method): string => $method->getName(), $declaredMethods))); + + $expectedProperties = array_column($expectedParameters, 'type', 'name'); + if (AuditEvent::class === $class) { + $expectedProperties['action'] = 'string'; + $expectedProperties['level'] = 'string'; + } + $actualProperties = []; + foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->getDeclaringClass()->getName() === $class) { + $actualProperties[$property->getName()] = self::type($property->getType()); + } + } + ksort($expectedProperties); + ksort($actualProperties); + self::assertSame($expectedProperties, $actualProperties); + } + } + + public function testExactInterfaceContracts(): void + { + foreach (self::INTERFACES as $interface => $expectedMethods) { + $reflection = new \ReflectionClass($interface); + self::assertSame('Zhortein\\AuditableBundle\\Transactional\\Contract', $reflection->getNamespaceName()); + self::assertTrue($reflection->isInterface()); + self::assertFalse($reflection->isFinal()); + $actualMethods = []; + foreach ($reflection->getMethods() as $method) { + self::assertTrue($method->isPublic()); + self::assertFalse($method->isStatic()); + $parameters = $method->getParameters(); + self::assertCount('' === $expectedMethods[$method->getName()]['parameter'] ? 0 : 1, $parameters); + foreach ($parameters as $parameter) { + self::assertFalse($parameter->isPassedByReference()); + self::assertFalse($parameter->isVariadic()); + } + $actualMethods[$method->getName()] = [ + 'parameter' => isset($parameters[0]) ? $parameters[0]->getName() : '', + 'type' => isset($parameters[0]) ? self::type($parameters[0]->getType()) : '', + 'return' => (string) self::type($method->getReturnType()), + ]; + } + self::assertSame($expectedMethods, $actualMethods); + } + self::assertSame(['record'], get_class_methods(AuditRecorderInterface::class)); + self::assertSame(['create'], get_class_methods(AuditEntryFactoryInterface::class)); + self::assertSame(['persist'], get_class_methods(AuditStorageInterface::class)); + foreach (['flush', 'commit', 'rollback', 'transaction'] as $forbidden) { + self::assertFalse(method_exists(AuditStorageInterface::class, $forbidden)); + } + } + + /** @return list */ + private static function parameters(\ReflectionMethod $method): array + { + return array_map(static fn (\ReflectionParameter $parameter): array => [ + 'name' => $parameter->getName(), + 'type' => (string) self::type($parameter->getType()), + 'optional' => $parameter->isOptional(), + 'default' => $parameter->isDefaultValueAvailable() ? self::defaultValue($parameter) : null, + ], $method->getParameters()); + } + + private static function defaultValue(\ReflectionParameter $parameter): mixed + { + $value = $parameter->getDefaultValue(); + + return $value instanceof \BackedEnum ? $value->value : $value; + } + + private static function type(?\ReflectionType $type): ?string + { + if (null === $type) { + return null; + } + if ($type instanceof \ReflectionNamedType) { + return ($type->allowsNull() && 'mixed' !== $type->getName() ? '?' : '').$type->getName(); + } + if ($type instanceof \ReflectionUnionType) { + return implode('|', array_map(self::type(...), $type->getTypes())); + } + if ($type instanceof \ReflectionIntersectionType) { + return implode('&', array_map(self::type(...), $type->getTypes())); + } + + throw new \LogicException('Unsupported reflection type.'); + } +} diff --git a/tests/Contract/public-api-1.0.0.json b/tests/Contract/public-api-1.0.0.json new file mode 100644 index 0000000..20a374f --- /dev/null +++ b/tests/Contract/public-api-1.0.0.json @@ -0,0 +1,1328 @@ +{ + "Zhortein\\AuditableBundle\\Attribute\\AuditField": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 8, + "enum_cases": [], + "public_properties": { + "label": { + "type": "?string", + "readonly": false, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Attribute\\AuditIgnore": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 8, + "enum_cases": [], + "public_properties": [], + "public_methods": [] + }, + "Zhortein\\AuditableBundle\\Attribute\\Auditable": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 1, + "enum_cases": [], + "public_properties": { + "context": { + "type": "?string", + "readonly": false, + "static": false + }, + "label": { + "type": "?string", + "readonly": false, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\DependencyInjection\\Configuration": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "getConfigTreeBuilder": { + "static": false, + "return": "Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\DependencyInjection\\ZhorteinAuditableExtension": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "load": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "configs", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "container", + "type": "Symfony\\Component\\DependencyInjection\\ContainerBuilder", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Doctrine\\AuditableDoctrineListener": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "historizer", + "type": "Zhortein\\AuditableBundle\\Service\\Historizer", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "metadataProvider", + "type": "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadataProvider", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "changeDetector", + "type": "Zhortein\\AuditableBundle\\Service\\ChangeDetector", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "enabled", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackInsert", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackUpdate", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackDelete", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "globalIgnoredFields", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + }, + "onFlush": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\OnFlushEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "postPersist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\PostPersistEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "preRemove": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\PreRemoveEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Entity\\AuditEntry": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [] + }, + "getAction": { + "static": false, + "return": "string", + "parameters": [] + }, + "getActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getContext": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getData": { + "static": false, + "return": "array", + "parameters": [] + }, + "getDescription": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getEntityClass": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getEntityId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getId": { + "static": false, + "return": "?int", + "parameters": [] + }, + "getImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getLevel": { + "static": false, + "return": "string", + "parameters": [] + }, + "getOccurredAt": { + "static": false, + "return": "DateTimeImmutable", + "parameters": [] + }, + "getTitle": { + "static": false, + "return": "string", + "parameters": [] + }, + "isAuto": { + "static": false, + "return": "bool", + "parameters": [] + }, + "setAction": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setActorId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "actorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setContext": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setData": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setDescription": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setEntityClass": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "entityClass", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setEntityId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "entityId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setImpersonatorId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "impersonatorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setIsAuto": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setLevel": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setOccurredAt": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "occurredAt", + "type": "DateTimeImmutable", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setTitle": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Enum\\AuditAction": { + "kind": "enum", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": { + "CREATE": "create", + "UPDATE": "update", + "DELETE": "delete", + "LOG": "log" + }, + "public_properties": { + "name": { + "type": "string", + "readonly": true, + "static": false + }, + "value": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "cases": { + "static": true, + "return": "array", + "parameters": [] + }, + "from": { + "static": true, + "return": "static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "tryFrom": { + "static": true, + "return": "?static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Enum\\AuditLevel": { + "kind": "enum", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": { + "DEBUG": "debug", + "INFO": "info", + "WARNING": "warning", + "ERROR": "error", + "CRITICAL": "critical" + }, + "public_properties": { + "name": { + "type": "string", + "readonly": true, + "static": false + }, + "value": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "cases": { + "static": true, + "return": "array", + "parameters": [] + }, + "from": { + "static": true, + "return": "static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "tryFrom": { + "static": true, + "return": "?static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\MessageHandler\\PersistAuditEntryMessageHandler": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "persister", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "__invoke": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "action": { + "type": "string", + "readonly": true, + "static": false + }, + "actorId": { + "type": "?string", + "readonly": true, + "static": false + }, + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "data": { + "type": "array", + "readonly": true, + "static": false + }, + "description": { + "type": "?string", + "readonly": true, + "static": false + }, + "entityClass": { + "type": "?string", + "readonly": true, + "static": false + }, + "entityId": { + "type": "?string", + "readonly": true, + "static": false + }, + "impersonatorId": { + "type": "?string", + "readonly": true, + "static": false + }, + "isAuto": { + "type": "bool", + "readonly": true, + "static": false + }, + "level": { + "type": "string", + "readonly": true, + "static": false + }, + "occurredAt": { + "type": "string", + "readonly": true, + "static": false + }, + "title": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "occurredAt", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entityClass", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entityId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "actorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "impersonatorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadata": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "fieldLabels": { + "type": "array", + "readonly": true, + "static": false + }, + "ignoredFields": { + "type": "array", + "readonly": true, + "static": false + }, + "label": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "fieldLabels", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "ignoredFields", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadataProvider": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "getFor": { + "static": false, + "return": "?Zhortein\\AuditableBundle\\Metadata\\AuditableMetadata", + "parameters": [ + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Repository\\AuditEntryRepository": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "registry", + "type": "Doctrine\\Persistence\\ManagerRegistry", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\ActorResolverInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "resolveActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "resolveImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AsyncAuditEntryWriter": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "bus", + "type": "Symfony\\Component\\Messenger\\MessageBusInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "entityManager", + "type": "Doctrine\\ORM\\EntityManagerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "persist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AuditEntryWriterInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\ChangeDetector": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "maxStringLength", + "type": "int", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": 180 + } + ] + }, + "getChanges": { + "static": false, + "return": "array", + "parameters": [ + { + "name": "em", + "type": "Doctrine\\ORM\\EntityManagerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\Historizer": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "writer", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryWriterInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "actorResolver", + "type": "Zhortein\\AuditableBundle\\Service\\ActorResolverInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "enabled", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + } + ] + }, + "historize": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "action", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditAction|string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entity", + "type": "?object", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "level", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditLevel|string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": "info" + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\SecurityActorResolver": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "security", + "type": "Symfony\\Bundle\\SecurityBundle\\Security", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "resolveActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "resolveImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\SyncAuditEntryWriter": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "persister", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\ZhorteinAuditableBundle": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "build": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "container", + "type": "Symfony\\Component\\DependencyInjection\\ContainerBuilder", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + } +} diff --git a/tests/Contract/public-api-2.0.0.json b/tests/Contract/public-api-2.0.0.json new file mode 100644 index 0000000..d9e55e0 --- /dev/null +++ b/tests/Contract/public-api-2.0.0.json @@ -0,0 +1,2013 @@ +{ + "Zhortein\\AuditableBundle\\Attribute\\AuditField": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 8, + "enum_cases": [], + "public_properties": { + "label": { + "type": "?string", + "readonly": false, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Attribute\\AuditIgnore": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 8, + "enum_cases": [], + "public_properties": [], + "public_methods": [] + }, + "Zhortein\\AuditableBundle\\Attribute\\Auditable": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 1, + "enum_cases": [], + "public_properties": { + "context": { + "type": "?string", + "readonly": false, + "static": false + }, + "label": { + "type": "?string", + "readonly": false, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\DependencyInjection\\Configuration": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "getConfigTreeBuilder": { + "static": false, + "return": "Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\DependencyInjection\\ZhorteinAuditableExtension": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "load": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "configs", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "container", + "type": "Symfony\\Component\\DependencyInjection\\ContainerBuilder", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Doctrine\\AuditableDoctrineListener": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "historizer", + "type": "Zhortein\\AuditableBundle\\Service\\Historizer", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "metadataProvider", + "type": "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadataProvider", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "changeDetector", + "type": "Zhortein\\AuditableBundle\\Service\\ChangeDetector", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "enabled", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackInsert", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackUpdate", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackDelete", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "globalIgnoredFields", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + }, + "onFlush": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\OnFlushEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "postPersist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\PostPersistEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "preRemove": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\PreRemoveEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Entity\\AuditEntry": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [] + }, + "getAction": { + "static": false, + "return": "string", + "parameters": [] + }, + "getActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getContext": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getData": { + "static": false, + "return": "array", + "parameters": [] + }, + "getDescription": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getEntityClass": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getEntityId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getId": { + "static": false, + "return": "?int", + "parameters": [] + }, + "getImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getLevel": { + "static": false, + "return": "string", + "parameters": [] + }, + "getOccurredAt": { + "static": false, + "return": "DateTimeImmutable", + "parameters": [] + }, + "getTitle": { + "static": false, + "return": "string", + "parameters": [] + }, + "isAuto": { + "static": false, + "return": "bool", + "parameters": [] + }, + "setAction": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setActorId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "actorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setContext": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setData": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setDescription": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setEntityClass": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "entityClass", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setEntityId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "entityId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setImpersonatorId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "impersonatorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setIsAuto": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setLevel": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setOccurredAt": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "occurredAt", + "type": "DateTimeImmutable", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setTitle": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Enum\\AuditAction": { + "kind": "enum", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": { + "CREATE": "create", + "UPDATE": "update", + "DELETE": "delete", + "LOG": "log" + }, + "public_properties": { + "name": { + "type": "string", + "readonly": true, + "static": false + }, + "value": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "cases": { + "static": true, + "return": "array", + "parameters": [] + }, + "from": { + "static": true, + "return": "static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "tryFrom": { + "static": true, + "return": "?static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Enum\\AuditLevel": { + "kind": "enum", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": { + "DEBUG": "debug", + "INFO": "info", + "WARNING": "warning", + "ERROR": "error", + "CRITICAL": "critical" + }, + "public_properties": { + "name": { + "type": "string", + "readonly": true, + "static": false + }, + "value": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "cases": { + "static": true, + "return": "array", + "parameters": [] + }, + "from": { + "static": true, + "return": "static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "tryFrom": { + "static": true, + "return": "?static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\MessageHandler\\PersistAuditEntryMessageHandler": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "persister", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "__invoke": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "action": { + "type": "string", + "readonly": true, + "static": false + }, + "actorId": { + "type": "?string", + "readonly": true, + "static": false + }, + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "data": { + "type": "array", + "readonly": true, + "static": false + }, + "description": { + "type": "?string", + "readonly": true, + "static": false + }, + "entityClass": { + "type": "?string", + "readonly": true, + "static": false + }, + "entityId": { + "type": "?string", + "readonly": true, + "static": false + }, + "impersonatorId": { + "type": "?string", + "readonly": true, + "static": false + }, + "isAuto": { + "type": "bool", + "readonly": true, + "static": false + }, + "level": { + "type": "string", + "readonly": true, + "static": false + }, + "occurredAt": { + "type": "string", + "readonly": true, + "static": false + }, + "title": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "occurredAt", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entityClass", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entityId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "actorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "impersonatorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadata": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "fieldLabels": { + "type": "array", + "readonly": true, + "static": false + }, + "ignoredFields": { + "type": "array", + "readonly": true, + "static": false + }, + "label": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "fieldLabels", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "ignoredFields", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadataProvider": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "getFor": { + "static": false, + "return": "?Zhortein\\AuditableBundle\\Metadata\\AuditableMetadata", + "parameters": [ + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Repository\\AuditEntryRepository": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "registry", + "type": "Doctrine\\Persistence\\ManagerRegistry", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\ActorResolverInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "resolveActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "resolveImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AsyncAuditEntryWriter": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "bus", + "type": "Symfony\\Component\\Messenger\\MessageBusInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "entityManager", + "type": "Doctrine\\ORM\\EntityManagerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "persist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AuditEntryWriterInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\ChangeDetector": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "maxStringLength", + "type": "int", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": 180 + } + ] + }, + "getChanges": { + "static": false, + "return": "array", + "parameters": [ + { + "name": "em", + "type": "Doctrine\\ORM\\EntityManagerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\Historizer": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "writer", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryWriterInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "actorResolver", + "type": "Zhortein\\AuditableBundle\\Service\\ActorResolverInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "enabled", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + } + ] + }, + "historize": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "action", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditAction|string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entity", + "type": "?object", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "level", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditLevel|string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": "info" + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\SecurityActorResolver": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "security", + "type": "Symfony\\Bundle\\SecurityBundle\\Security", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "resolveActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "resolveImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\SyncAuditEntryWriter": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "persister", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditActorResolverInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "resolveActor": { + "static": false, + "return": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditEntryFactoryInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "create": { + "static": false, + "return": "object", + "parameters": [ + { + "name": "record", + "type": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditRecord", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditRecorderInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "record": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "event", + "type": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditEvent", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditStorageInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "persist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "entry", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\IdentifierExtractorInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "extract": { + "static": false, + "return": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "parameters": [ + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Exception\\ActorResolutionException": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": [] + }, + "Zhortein\\AuditableBundle\\Transactional\\Exception\\IdentifierExtractionException": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": [] + }, + "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "identifier": { + "type": "?string", + "readonly": true, + "static": false + }, + "impersonatorIdentifier": { + "type": "?string", + "readonly": true, + "static": false + }, + "metadata": { + "type": "array", + "readonly": true, + "static": false + }, + "type": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "type", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "identifier", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "impersonatorIdentifier", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "metadata", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditEvent": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "action": { + "type": "string", + "readonly": true, + "static": false + }, + "actor": { + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "readonly": true, + "static": false + }, + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "data": { + "type": "array", + "readonly": true, + "static": false + }, + "description": { + "type": "?string", + "readonly": true, + "static": false + }, + "entity": { + "type": "?object", + "readonly": true, + "static": false + }, + "isAuto": { + "type": "bool", + "readonly": true, + "static": false + }, + "level": { + "type": "string", + "readonly": true, + "static": false + }, + "occurredAt": { + "type": "?DateTimeImmutable", + "readonly": true, + "static": false + }, + "subject": { + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "readonly": true, + "static": false + }, + "title": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "action", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditAction|string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "level", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditLevel|string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": "info" + }, + { + "name": "entity", + "type": "?object", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "subject", + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "actor", + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + }, + { + "name": "occurredAt", + "type": "?DateTimeImmutable", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditRecord": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "action": { + "type": "string", + "readonly": true, + "static": false + }, + "actor": { + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "readonly": true, + "static": false + }, + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "data": { + "type": "array", + "readonly": true, + "static": false + }, + "description": { + "type": "?string", + "readonly": true, + "static": false + }, + "isAuto": { + "type": "bool", + "readonly": true, + "static": false + }, + "level": { + "type": "string", + "readonly": true, + "static": false + }, + "occurredAt": { + "type": "DateTimeImmutable", + "readonly": true, + "static": false + }, + "subject": { + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "readonly": true, + "static": false + }, + "title": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "occurredAt", + "type": "DateTimeImmutable", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "subject", + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "actor", + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "identifier": { + "type": "string", + "readonly": true, + "static": false + }, + "type": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "type", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "identifier", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Service\\DoctrineIdentifierExtractor": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "registry", + "type": "Doctrine\\Persistence\\ManagerRegistry", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "extract": { + "static": false, + "return": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "parameters": [ + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Service\\StrictAuditRecorder": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "identifierExtractor", + "type": "Zhortein\\AuditableBundle\\Transactional\\Contract\\IdentifierExtractorInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "actorResolver", + "type": "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditActorResolverInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "clock", + "type": "Psr\\Clock\\ClockInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "entryFactory", + "type": "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditEntryFactoryInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "storage", + "type": "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditStorageInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "record": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "event", + "type": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditEvent", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Service\\SymfonySecurityActorResolver": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "tokenStorage", + "type": "Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "resolveActor": { + "static": false, + "return": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\ZhorteinAuditableBundle": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "build": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "container", + "type": "Symfony\\Component\\DependencyInjection\\ContainerBuilder", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + } +} diff --git a/tests/Fixtures/App/DoctrineTestFactory.php b/tests/Fixtures/App/DoctrineTestFactory.php new file mode 100644 index 0000000..ae614c0 --- /dev/null +++ b/tests/Fixtures/App/DoctrineTestFactory.php @@ -0,0 +1,43 @@ + $paths */ + private static function createForPaths(array $paths): EntityManagerInterface + { + $eventManager = new EventManager(); + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $configuration = ORMSetup::createAttributeMetadataConfiguration($paths, true); + + if (\PHP_VERSION_ID >= 80400) { + $configuration->enableNativeLazyObjects(true); + } + + return new EntityManager($connection, $configuration, $eventManager); + } +} diff --git a/tests/Fixtures/App/TestKernel.php b/tests/Fixtures/App/TestKernel.php new file mode 100644 index 0000000..148e514 --- /dev/null +++ b/tests/Fixtures/App/TestKernel.php @@ -0,0 +1,78 @@ + $bundleConfig */ + public function __construct( + private readonly array $bundleConfig = [], + ) { + parent::__construct('test', true); + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new SecurityBundle(); + yield new DoctrineBundle(); + yield new ZhorteinAuditableBundle(); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(function (ContainerBuilder $container): void { + $ormConfig = [ + 'mappings' => [ + 'TestFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity', + 'is_bundle' => false, + ], + ], + ]; + + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/doctrine-bundle', '^2.0')) { + $ormConfig['auto_generate_proxy_classes'] = true; + } + + $container->loadFromExtension('framework', [ + 'secret' => 'auditable-characterization-tests', + 'test' => true, + 'messenger' => ['default_bus' => 'messenger.bus.default'], + ]); + $container->loadFromExtension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['test' => ['security' => false]], + ]); + $container->loadFromExtension('doctrine', [ + 'dbal' => ['url' => 'sqlite:///:memory:'], + 'orm' => $ormConfig, + ]); + $container->loadFromExtension('zhortein_auditable', $this->bundleConfig); + }); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/auditable-bundle-tests/'.getmypid().'-'.sha1(serialize($this->bundleConfig)); + } + + public function getLogDir(): string + { + return $this->getCacheDir().'/log'; + } +} diff --git a/tests/Fixtures/App/TransactionalNoLegacyMappingTestKernel.php b/tests/Fixtures/App/TransactionalNoLegacyMappingTestKernel.php new file mode 100644 index 0000000..b08a599 --- /dev/null +++ b/tests/Fixtures/App/TransactionalNoLegacyMappingTestKernel.php @@ -0,0 +1,102 @@ +addCompilerPass(new TransactionalWiringPass(true, true, true)); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container): void { + $ormConfig = [ + 'mappings' => [ + 'TestFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity', + 'is_bundle' => false, + ], + ], + ]; + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/doctrine-bundle', '^2.0')) { + $ormConfig['auto_generate_proxy_classes'] = true; + } + + $container->loadFromExtension('framework', [ + 'secret' => 'transactional-no-legacy-mapping-tests', + 'test' => true, + 'messenger' => ['default_bus' => 'messenger.bus.default'], + ]); + $container->loadFromExtension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['test' => ['security' => false]], + ]); + $container->loadFromExtension('doctrine', [ + 'dbal' => ['url' => 'sqlite:///:memory:'], + 'orm' => $ormConfig, + ]); + $container->loadFromExtension('zhortein_auditable', [ + 'enabled' => false, + 'legacy_mapping' => ['enabled' => false], + 'transactional' => ['enabled' => true], + ]); + + $container->register(CapturingAuditEntryFactory::class); + $container->register(CapturingAuditStorage::class); + $container->register(FrozenClock::class) + ->setArguments([new Definition(\DateTimeImmutable::class, [self::NOW])]); + $container->register(TransactionalRecorderConsumer::class) + ->setAutowired(true) + ->setPublic(true); + }); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/auditable-bundle-no-legacy-mapping-tests/'.getmypid(); + } + + public function getLogDir(): string + { + return $this->getCacheDir().'/log'; + } +} diff --git a/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php b/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php new file mode 100644 index 0000000..2e848ff --- /dev/null +++ b/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php @@ -0,0 +1,115 @@ +databaseUrl = $databaseUrl; + + parent::__construct($environment, true); + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new SecurityBundle(); + yield new DoctrineBundle(); + yield new ZhorteinAuditableBundle(); + } + + public function build(ContainerBuilder $container): void + { + parent::build($container); + + $container->addCompilerPass(new PostgreSqlWiringPass(self::FAILING_STORAGE === $this->environment)); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $databaseUrl = $this->databaseUrl; + $withoutLegacyMapping = self::WITHOUT_LEGACY_MAPPING === $this->environment; + $loader->load(static function (ContainerBuilder $container) use ($databaseUrl, $withoutLegacyMapping): void { + $ormConfig = [ + 'mappings' => [ + 'TransactionalPostgreSqlFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Transactional/PostgreSql/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Transactional\\PostgreSql\\Entity', + 'is_bundle' => false, + ], + ], + ]; + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/doctrine-bundle', '^2.0')) { + $ormConfig['auto_generate_proxy_classes'] = true; + } + + $container->loadFromExtension('framework', [ + 'secret' => 'transactional-postgresql-tests', + 'test' => true, + 'messenger' => ['default_bus' => 'messenger.bus.default'], + ]); + $container->loadFromExtension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['test' => ['security' => false]], + ]); + $container->loadFromExtension('doctrine', [ + 'dbal' => ['url' => $databaseUrl], + 'orm' => $ormConfig, + ]); + $container->loadFromExtension('zhortein_auditable', [ + 'enabled' => !$withoutLegacyMapping, + 'legacy_mapping' => ['enabled' => !$withoutLegacyMapping], + 'transactional' => ['enabled' => true], + ]); + + $container->register(ApplicationAuditEntryFactory::class); + $container->register(DoctrineAuditStorage::class)->setAutowired(true); + $container->register(FailingAuditStorage::class); + $container->register(PostgreSqlFrozenClock::class); + $container + ->register(TransactionalRecorderConsumer::class) + ->setAutowired(true) + ->setPublic(true); + }); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/auditable-bundle-postgresql-tests/'.getmypid().'-'.$this->environment; + } + + public function getLogDir(): string + { + return $this->getCacheDir().'/log'; + } +} diff --git a/tests/Fixtures/App/TransactionalWiringTestKernel.php b/tests/Fixtures/App/TransactionalWiringTestKernel.php new file mode 100644 index 0000000..f368635 --- /dev/null +++ b/tests/Fixtures/App/TransactionalWiringTestKernel.php @@ -0,0 +1,120 @@ +addCompilerPass(new TransactionalWiringPass( + provideFactory: self::MISSING_FACTORY !== $this->environment, + provideStorage: self::MISSING_STORAGE !== $this->environment, + provideClock: self::MISSING_CLOCK !== $this->environment, + )); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container): void { + $ormConfig = [ + 'mappings' => [ + 'TestFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity', + 'is_bundle' => false, + ], + ], + ]; + + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/doctrine-bundle', '^2.0')) { + $ormConfig['auto_generate_proxy_classes'] = true; + } + + $container->loadFromExtension('framework', [ + 'secret' => 'transactional-wiring-tests', + 'test' => true, + 'messenger' => ['default_bus' => 'messenger.bus.default'], + ]); + $container->loadFromExtension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['test' => ['security' => false]], + ]); + $container->loadFromExtension('doctrine', [ + 'dbal' => ['url' => 'sqlite:///:memory:'], + 'orm' => $ormConfig, + ]); + $container->loadFromExtension('zhortein_auditable', [ + 'transactional' => ['enabled' => true], + ]); + + $container->register(CallSequence::class)->setPublic(true); + $container + ->register(CapturingAuditEntryFactory::class) + ->setAutowired(true); + $container + ->register(CapturingAuditStorage::class) + ->setAutowired(true) + ->setPublic(true); + $container + ->register(FrozenClock::class) + ->setArguments([new Definition(\DateTimeImmutable::class, [self::NOW])]) + ->setAutowired(true); + $container + ->register(TransactionalRecorderConsumer::class) + ->setAutowired(true) + ->setPublic(true); + }); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/auditable-bundle-transactional-tests/'.getmypid().'-'.$this->environment; + } + + public function getLogDir(): string + { + return $this->getCacheDir().'/log'; + } +} diff --git a/tests/Fixtures/Entity/AuditableEntity.php b/tests/Fixtures/Entity/AuditableEntity.php new file mode 100644 index 0000000..13e77f8 --- /dev/null +++ b/tests/Fixtures/Entity/AuditableEntity.php @@ -0,0 +1,73 @@ +name = $name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function setSecret(string $secret): void + { + $this->secret = $secret; + } + + public function getSecret(): string + { + return $this->secret; + } + + public function setGloballyIgnored(string $value): void + { + $this->globallyIgnored = $value; + } + + public function getGloballyIgnored(): string + { + return $this->globallyIgnored; + } +} diff --git a/tests/Fixtures/Entity/NonAuditableEntity.php b/tests/Fixtures/Entity/NonAuditableEntity.php new file mode 100644 index 0000000..f121595 --- /dev/null +++ b/tests/Fixtures/Entity/NonAuditableEntity.php @@ -0,0 +1,41 @@ +name = $name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Fixtures/Service/CollectingAuditEntryWriter.php b/tests/Fixtures/Service/CollectingAuditEntryWriter.php new file mode 100644 index 0000000..27b87e5 --- /dev/null +++ b/tests/Fixtures/Service/CollectingAuditEntryWriter.php @@ -0,0 +1,19 @@ + */ + public array $messages = []; + + public function write(PersistAuditEntryMessage $message): void + { + $this->messages[] = $message; + } +} diff --git a/tests/Fixtures/Transactional/Entity/CompositeIdentifier.php b/tests/Fixtures/Transactional/Entity/CompositeIdentifier.php new file mode 100644 index 0000000..c363953 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/CompositeIdentifier.php @@ -0,0 +1,24 @@ +country || 0 === $this->number) { + throw new \InvalidArgumentException('Fixture identifier parts must not be empty.'); + } + } +} diff --git a/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php b/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php new file mode 100644 index 0000000..b7eca99 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php @@ -0,0 +1,22 @@ +id; + } +} diff --git a/tests/Fixtures/Transactional/Entity/IdMethodEntity.php b/tests/Fixtures/Transactional/Entity/IdMethodEntity.php new file mode 100644 index 0000000..ba4b41b --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/IdMethodEntity.php @@ -0,0 +1,26 @@ +identifier) { + throw new \InvalidArgumentException('Fixture identifier must not be empty.'); + } + } + + public function id(): never + { + throw new \LogicException('The id() method must not be called.'); + } +} diff --git a/tests/Fixtures/Transactional/Entity/PrivateStringIdentifier.php b/tests/Fixtures/Transactional/Entity/PrivateStringIdentifier.php new file mode 100644 index 0000000..053a158 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/PrivateStringIdentifier.php @@ -0,0 +1,21 @@ +identifier) { + throw new \InvalidArgumentException('Fixture identifier must not be empty.'); + } + } +} diff --git a/tests/Fixtures/Transactional/Entity/ProxyEntity.php b/tests/Fixtures/Transactional/Entity/ProxyEntity.php new file mode 100644 index 0000000..1435450 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/ProxyEntity.php @@ -0,0 +1,21 @@ +identifier) { + throw new \InvalidArgumentException('Fixture identifier must not be empty.'); + } + } +} diff --git a/tests/Fixtures/Transactional/Entity/ReverseCompositeIdentifier.php b/tests/Fixtures/Transactional/Entity/ReverseCompositeIdentifier.php new file mode 100644 index 0000000..b0329e0 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/ReverseCompositeIdentifier.php @@ -0,0 +1,24 @@ +country || 0 === $this->number) { + throw new \InvalidArgumentException('Fixture identifier parts must not be empty.'); + } + } +} diff --git a/tests/Fixtures/Transactional/Entity/ThrowingGetterEntity.php b/tests/Fixtures/Transactional/Entity/ThrowingGetterEntity.php new file mode 100644 index 0000000..ff17efb --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/ThrowingGetterEntity.php @@ -0,0 +1,26 @@ +identifier) { + throw new \InvalidArgumentException('Fixture identifier must not be empty.'); + } + } + + public function getId(): never + { + throw new \LogicException('The getId() method must not be called.'); + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php b/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php new file mode 100644 index 0000000..dbd9ee4 --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php @@ -0,0 +1,37 @@ + */ +final readonly class ApplicationAuditEntryFactory implements AuditEntryFactoryInterface +{ + public function create(AuditRecord $record): ApplicationAuditEntry + { + $actor = $record->actor; + + return new ApplicationAuditEntry( + id: Uuid::v7()->toRfc4122(), + occurredAt: $record->occurredAt, + action: $record->action, + level: $record->level, + title: $record->title, + description: $record->description, + context: $record->context, + subjectType: $record->subject?->type, + subjectIdentifier: $record->subject?->identifier, + actorType: $actor?->type, + actorIdentifier: $actor?->identifier, + impersonatorIdentifier: $actor?->impersonatorIdentifier, + actorMetadata: null === $actor ? [] : $actor->metadata, + isAuto: $record->isAuto, + data: $record->data, + ); + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php b/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php new file mode 100644 index 0000000..86b8689 --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php @@ -0,0 +1,27 @@ + */ +final readonly class DoctrineAuditStorage implements AuditStorageInterface +{ + public function __construct(private EntityManagerInterface $entityManager) + { + } + + public function persist(object $entry): void + { + /* @phpstan-ignore-next-line instanceof.alwaysTrue (runtime generic validation) */ + if (!$entry instanceof ApplicationAuditEntry) { + throw new \InvalidArgumentException('DoctrineAuditStorage accepts only ApplicationAuditEntry instances.'); + } + + $this->entityManager->persist($entry); + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/Entity/ApplicationAuditEntry.php b/tests/Fixtures/Transactional/PostgreSql/Entity/ApplicationAuditEntry.php new file mode 100644 index 0000000..62dd80e --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/Entity/ApplicationAuditEntry.php @@ -0,0 +1,132 @@ + $actorMetadata + * @param array $data + */ + public function __construct( + #[ORM\Id] + #[ORM\Column(type: Types::GUID)] + private string $id, + \DateTimeImmutable $occurredAt, + #[ORM\Column(length: 64)] + private string $action, + #[ORM\Column(length: 32)] + private string $level, + #[ORM\Column(length: 255)] + private string $title, + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $description, + #[ORM\Column(length: 255, nullable: true)] + private ?string $context, + #[ORM\Column(length: 255, nullable: true)] + private ?string $subjectType, + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $subjectIdentifier, + #[ORM\Column(length: 128, nullable: true)] + private ?string $actorType, + #[ORM\Column(length: 255, nullable: true)] + private ?string $actorIdentifier, + #[ORM\Column(length: 255, nullable: true)] + private ?string $impersonatorIdentifier, + #[ORM\Column(type: Types::JSON)] + private array $actorMetadata, + #[ORM\Column] + private bool $isAuto, + #[ORM\Column(type: Types::JSON)] + private array $data, + ) { + $this->occurredAt = $occurredAt->format('Y-m-d\\TH:i:s.uP'); + } + + public function getId(): string + { + return $this->id; + } + + public function getOccurredAt(): \DateTimeImmutable + { + return new \DateTimeImmutable($this->occurredAt); + } + + public function getAction(): string + { + return $this->action; + } + + public function getLevel(): string + { + return $this->level; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getContext(): ?string + { + return $this->context; + } + + public function getSubjectType(): ?string + { + return $this->subjectType; + } + + public function getSubjectIdentifier(): ?string + { + return $this->subjectIdentifier; + } + + public function getActorType(): ?string + { + return $this->actorType; + } + + public function getActorIdentifier(): ?string + { + return $this->actorIdentifier; + } + + public function getImpersonatorIdentifier(): ?string + { + return $this->impersonatorIdentifier; + } + + /** @return array */ + public function getActorMetadata(): array + { + return $this->actorMetadata; + } + + public function isAuto(): bool + { + return $this->isAuto; + } + + /** @return array */ + public function getData(): array + { + return $this->data; + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/Entity/BusinessOperation.php b/tests/Fixtures/Transactional/PostgreSql/Entity/BusinessOperation.php new file mode 100644 index 0000000..8329396 --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/Entity/BusinessOperation.php @@ -0,0 +1,40 @@ +id = Uuid::v7()->toRfc4122(); + } + + public function getId(): string + { + return $this->id; + } + + public function getStatus(): string + { + return $this->status; + } + + public function changeStatus(string $status): void + { + $this->status = $status; + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/FailingAuditStorage.php b/tests/Fixtures/Transactional/PostgreSql/FailingAuditStorage.php new file mode 100644 index 0000000..f3a946a --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/FailingAuditStorage.php @@ -0,0 +1,17 @@ + */ +final readonly class FailingAuditStorage implements AuditStorageInterface +{ + public function persist(object $entry): void + { + throw new \RuntimeException('Intentional transactional audit storage failure.'); + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/PostgreSqlFrozenClock.php b/tests/Fixtures/Transactional/PostgreSql/PostgreSqlFrozenClock.php new file mode 100644 index 0000000..e18061d --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/PostgreSqlFrozenClock.php @@ -0,0 +1,24 @@ +time = new \DateTimeImmutable(self::INSTANT); + } + + public function now(): \DateTimeImmutable + { + return $this->time; + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/PostgreSqlWiringPass.php b/tests/Fixtures/Transactional/PostgreSql/PostgreSqlWiringPass.php new file mode 100644 index 0000000..ef65610 --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/PostgreSqlWiringPass.php @@ -0,0 +1,28 @@ +setAlias(AuditEntryFactoryInterface::class, ApplicationAuditEntryFactory::class); + $container->setAlias( + AuditStorageInterface::class, + $this->failingStorage ? FailingAuditStorage::class : DoctrineAuditStorage::class, + ); + $container->setAlias(ClockInterface::class, PostgreSqlFrozenClock::class); + } +} diff --git a/tests/Fixtures/Transactional/Recorder/CallSequence.php b/tests/Fixtures/Transactional/Recorder/CallSequence.php new file mode 100644 index 0000000..04fd518 --- /dev/null +++ b/tests/Fixtures/Transactional/Recorder/CallSequence.php @@ -0,0 +1,16 @@ + */ + public array $calls = []; + + public function add(string $call): void + { + $this->calls[] = $call; + } +} diff --git a/tests/Fixtures/Transactional/Recorder/CapturedAuditEntry.php b/tests/Fixtures/Transactional/Recorder/CapturedAuditEntry.php new file mode 100644 index 0000000..ec00a4f --- /dev/null +++ b/tests/Fixtures/Transactional/Recorder/CapturedAuditEntry.php @@ -0,0 +1,14 @@ + */ +final class CapturingAuditEntryFactory implements AuditEntryFactoryInterface +{ + /** @var list */ + public array $entries = []; + + public function __construct(private readonly ?CallSequence $sequence = null) + { + } + + public function create(AuditRecord $record): CapturedAuditEntry + { + $this->sequence?->add('factory'); + $entry = new CapturedAuditEntry($record); + $this->entries[] = $entry; + + return $entry; + } +} diff --git a/tests/Fixtures/Transactional/Recorder/CapturingAuditStorage.php b/tests/Fixtures/Transactional/Recorder/CapturingAuditStorage.php new file mode 100644 index 0000000..aa9107f --- /dev/null +++ b/tests/Fixtures/Transactional/Recorder/CapturingAuditStorage.php @@ -0,0 +1,24 @@ + */ +final class CapturingAuditStorage implements AuditStorageInterface +{ + /** @var list */ + public array $entries = []; + + public function __construct(private readonly ?CallSequence $sequence = null) + { + } + + public function persist(object $entry): void + { + $this->sequence?->add('storage'); + $this->entries[] = $entry; + } +} diff --git a/tests/Fixtures/Transactional/Recorder/FrozenClock.php b/tests/Fixtures/Transactional/Recorder/FrozenClock.php new file mode 100644 index 0000000..f0011e1 --- /dev/null +++ b/tests/Fixtures/Transactional/Recorder/FrozenClock.php @@ -0,0 +1,23 @@ +sequence?->add('clock'); + + return $this->time; + } +} diff --git a/tests/Fixtures/Transactional/Security/TestUser.php b/tests/Fixtures/Transactional/Security/TestUser.php new file mode 100644 index 0000000..438b047 --- /dev/null +++ b/tests/Fixtures/Transactional/Security/TestUser.php @@ -0,0 +1,40 @@ + */ + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void + { + } + + public function getUserIdentifier(): string + { + return $this->identifier; + } + + public function getId(): never + { + throw new \LogicException('getId() must not be called.'); + } + + public function id(): never + { + throw new \LogicException('id() must not be called.'); + } +} diff --git a/tests/Fixtures/Transactional/Wiring/TransactionalRecorderConsumer.php b/tests/Fixtures/Transactional/Wiring/TransactionalRecorderConsumer.php new file mode 100644 index 0000000..c980da8 --- /dev/null +++ b/tests/Fixtures/Transactional/Wiring/TransactionalRecorderConsumer.php @@ -0,0 +1,25 @@ +recorder->record($event); + } + + public function recorder(): AuditRecorderInterface + { + return $this->recorder; + } +} diff --git a/tests/Fixtures/Transactional/Wiring/TransactionalWiringPass.php b/tests/Fixtures/Transactional/Wiring/TransactionalWiringPass.php new file mode 100644 index 0000000..384b049 --- /dev/null +++ b/tests/Fixtures/Transactional/Wiring/TransactionalWiringPass.php @@ -0,0 +1,39 @@ +provideFactory) { + $container->setAlias(AuditEntryFactoryInterface::class, CapturingAuditEntryFactory::class); + } + if ($this->provideStorage) { + $container->setAlias(AuditStorageInterface::class, CapturingAuditStorage::class); + } + if ($this->provideClock) { + $container->setAlias(ClockInterface::class, FrozenClock::class); + } else { + $container->removeAlias(ClockInterface::class); + } + } +} diff --git a/tests/Integration/AuditableDoctrineListenerTest.php b/tests/Integration/AuditableDoctrineListenerTest.php index 6cecae2..bc036a8 100644 --- a/tests/Integration/AuditableDoctrineListenerTest.php +++ b/tests/Integration/AuditableDoctrineListenerTest.php @@ -12,11 +12,6 @@ final class AuditableDoctrineListenerTest extends TestCase { - public function testListenerIsInstantiable(): void - { - $this->markTestSkipped('Requires Doctrine integration setup'); - } - public function testAuditableEntityMetadata(): void { $metadataProvider = new AuditableMetadataProvider(); @@ -41,6 +36,35 @@ public function testNonAuditableEntityMetadata(): void self::assertNull($metadata); } + + public function testDefaultLabelEmptyFieldLabelAndCache(): void + { + $metadataProvider = new AuditableMetadataProvider(); + $entity = new DefaultMetadataEntity(); + + $first = $metadataProvider->getFor($entity); + $second = $metadataProvider->getFor($entity); + + self::assertNotNull($first); + self::assertSame('DefaultMetadataEntity', $first->label); + self::assertNull($first->context); + self::assertSame([], $first->fieldLabels); + self::assertSame([], $first->ignoredFields); + self::assertSame($first, $second); + } + + public function testNonAuditableResultIsCached(): void + { + $provider = new AuditableMetadataProvider(); + $entity = new NonAuditableTestEntity(); + + self::assertNull($provider->getFor($entity)); + self::assertNull($provider->getFor($entity)); + $cache = new \ReflectionProperty($provider, 'cache'); + $cacheValue = $cache->getValue($provider); + self::assertIsArray($cacheValue); + self::assertArrayHasKey(NonAuditableTestEntity::class, $cacheValue); + } } #[Auditable(label: 'AuditableTest', context: 'test-context')] @@ -87,3 +111,15 @@ public function setName(string $name): void $this->name = $name; } } + +#[Auditable] +final class DefaultMetadataEntity +{ + #[AuditField(label: ' ')] + private string $emptyLabel = ''; + + public function getEmptyLabel(): string + { + return $this->emptyLabel; + } +} diff --git a/tests/Integration/ContainerTest.php b/tests/Integration/ContainerTest.php new file mode 100644 index 0000000..2722213 --- /dev/null +++ b/tests/Integration/ContainerTest.php @@ -0,0 +1,71 @@ +registerBundles()); + + self::assertContainsOnlyInstancesOf(FrameworkBundle::class, [$bundles[0]]); + self::assertContainsOnlyInstancesOf(DoctrineBundle::class, [$bundles[2]]); + self::assertContainsOnlyInstancesOf(ZhorteinAuditableBundle::class, [$bundles[3]]); + } + + /** @param array $config */ + #[DataProvider('writerAliases')] + public function testLegacyDefinitionsAliasesAndParameters(array $config, string $writer): void + { + $container = new ContainerBuilder(); + (new ZhorteinAuditableExtension())->load([$config], $container); + + self::assertTrue($container->hasDefinition(Historizer::class)); + self::assertTrue($container->hasDefinition(AuditableDoctrineListener::class)); + self::assertSame(SecurityActorResolver::class, (string) $container->getAlias(ActorResolverInterface::class)); + // Symfony 7.4's PHP configurator public() has no boolean argument: the historical + // public(false) call therefore makes this alias public instead of private. + self::assertTrue($container->getAlias(ActorResolverInterface::class)->isPublic()); + self::assertSame($writer, (string) $container->getAlias(AuditEntryWriterInterface::class)); + self::assertFalse($container->getAlias(AuditEntryWriterInterface::class)->isPublic()); + self::assertSame([], $container->getDefinition(AsyncAuditEntryWriter::class)->getArguments()); + self::assertFalse($container->getDefinition(Historizer::class)->isPublic()); + self::assertFalse($container->getDefinition(AuditableDoctrineListener::class)->isPublic()); + + self::assertTrue($container->getParameter('zhortein_auditable.enabled')); + $async = $config['async'] ?? []; + self::assertIsArray($async); + self::assertSame($async['enabled'] ?? true, $container->getParameter('zhortein_auditable.async.enabled')); + self::assertSame('async', $container->getParameter('zhortein_auditable.async.transport')); + self::assertTrue($container->getParameter('zhortein_auditable.listener.track_insert')); + self::assertTrue($container->getParameter('zhortein_auditable.listener.track_update')); + self::assertTrue($container->getParameter('zhortein_auditable.listener.track_delete')); + self::assertSame(180, $container->getParameter('zhortein_auditable.fields.max_string_length')); + self::assertSame([], $container->getParameter('zhortein_auditable.fields.global_ignored')); + } + + /** @return iterable, class-string}> */ + public static function writerAliases(): iterable + { + yield 'async by default' => [[], AsyncAuditEntryWriter::class]; + yield 'sync when disabled' => [['async' => ['enabled' => false]], SyncAuditEntryWriter::class]; + } +} diff --git a/tests/Integration/DoctrineBundleIntegrationTest.php b/tests/Integration/DoctrineBundleIntegrationTest.php new file mode 100644 index 0000000..68f6166 --- /dev/null +++ b/tests/Integration/DoctrineBundleIntegrationTest.php @@ -0,0 +1,116 @@ +kernel = new TestKernel(); + $this->kernel->boot(); + + $testContainer = $this->kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $this->testContainer = $testContainer; + $registry = $testContainer->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + $manager = $registry->getManagerForClass(AuditEntry::class); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + $this->entityManager = $manager; + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->close(); + $cacheDir = $this->kernel->getCacheDir(); + $this->kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + public function testBundleMappingSchemaAndDoctrineListenerUseDoctrineBundleEntityManager(): void + { + self::assertTrue($this->kernel->getContainer()->getParameter('zhortein_auditable.legacy_mapping.enabled')); + $metadata = $this->entityManager->getClassMetadata(AuditEntry::class); + self::assertSame(AuditEntry::class, $metadata->getName()); + self::assertSame('audit_entry', $metadata->getTableName()); + self::assertSame([ + 'id', + 'occurred_at', + 'action', + 'level', + 'title', + 'description', + 'context', + 'entity_class', + 'entity_id', + 'actor_id', + 'impersonator_id', + 'is_auto', + 'data', + ], array_values(array_map(static fn ($mapping): string => $mapping->columnName, $metadata->fieldMappings))); + + $indexes = $metadata->table['indexes'] ?? null; + self::assertIsArray($indexes); + self::assertSame(['columns' => ['occurred_at']], $indexes['idx_audit_entry_occurred_at']); + self::assertSame(['columns' => ['entity_class', 'entity_id']], $indexes['idx_audit_entry_entity']); + + $allMetadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + (new SchemaTool($this->entityManager))->createSchema($allMetadata); + self::assertTrue($this->entityManager->getConnection()->createSchemaManager()->tablesExist(['audit_entry'])); + + // test.service_container can retrieve this private alias; that does not alter + // the visibility asserted directly on the pre-compilation ContainerBuilder. + self::assertInstanceOf(AsyncAuditEntryWriter::class, $this->testContainer->get(AuditEntryWriterInterface::class)); + + foreach ([Events::onFlush, Events::postPersist, Events::preRemove] as $event) { + $listeners = $this->entityManager->getEventManager()->getListeners($event); + $listenerFound = false; + foreach ($listeners as $listener) { + if ($listener instanceof AuditableDoctrineListener) { + $listenerFound = true; + break; + } + } + self::assertTrue($listenerFound, \sprintf('The bundle listener is not registered for %s.', $event)); + } + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/DoctrineMappingTest.php b/tests/Integration/DoctrineMappingTest.php new file mode 100644 index 0000000..c0afcaa --- /dev/null +++ b/tests/Integration/DoctrineMappingTest.php @@ -0,0 +1,91 @@ +entityManager = DoctrineTestFactory::createEntityManager(); + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->close(); + } + + public function testLegacyAuditEntryMappingIsExact(): void + { + $metadata = $this->entityManager->getClassMetadata(AuditEntry::class); + + self::assertSame(AuditEntry::class, $metadata->getName()); + self::assertSame('audit_entry', $metadata->getTableName()); + self::assertSame(['id'], $metadata->getIdentifierFieldNames()); + self::assertTrue($metadata->isIdGeneratorIdentity()); + self::assertSame([], $metadata->associationMappings); + $indexes = $metadata->table['indexes'] ?? null; + self::assertIsArray($indexes); + self::assertSame([ + 'idx_audit_entry_occurred_at' => ['columns' => ['occurred_at']], + 'idx_audit_entry_entity' => ['columns' => ['entity_class', 'entity_id']], + ], $indexes); + + $actual = []; + foreach ($metadata->fieldMappings as $field => $mapping) { + $actual[$mapping->columnName] = [ + 'field' => $field, + 'type' => $mapping->type, + 'length' => $mapping->length, + 'nullable' => $mapping->nullable, + 'id' => $mapping->id ?? false, + 'options' => $mapping->options ?? [], + ]; + } + + self::assertSame(self::expectedColumns(), $actual); + } + + public function testGeneratedSchemaContainsOnlyLegacyAndFixtureTables(): void + { + $tool = new SchemaTool($this->entityManager); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $tool->dropSchema($metadata); + $tool->createSchema($metadata); + + $tables = $this->entityManager->getConnection()->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], $tables); + self::assertCount(3, $tables); + self::assertSame([], $this->entityManager->getClassMetadata(AuditEntry::class)->associationMappings); + } + + /** @return array}> */ + private static function expectedColumns(): array + { + return [ + 'id' => ['field' => 'id', 'type' => 'integer', 'length' => null, 'nullable' => false, 'id' => true, 'options' => []], + 'occurred_at' => ['field' => 'occurredAt', 'type' => 'datetime_immutable', 'length' => null, 'nullable' => false, 'id' => false, 'options' => []], + 'action' => ['field' => 'action', 'type' => 'string', 'length' => 50, 'nullable' => false, 'id' => false, 'options' => []], + 'level' => ['field' => 'level', 'type' => 'string', 'length' => 20, 'nullable' => false, 'id' => false, 'options' => []], + 'title' => ['field' => 'title', 'type' => 'string', 'length' => 255, 'nullable' => false, 'id' => false, 'options' => []], + 'description' => ['field' => 'description', 'type' => 'text', 'length' => null, 'nullable' => true, 'id' => false, 'options' => []], + 'context' => ['field' => 'context', 'type' => 'string', 'length' => 255, 'nullable' => true, 'id' => false, 'options' => []], + 'entity_class' => ['field' => 'entityClass', 'type' => 'string', 'length' => 255, 'nullable' => true, 'id' => false, 'options' => []], + 'entity_id' => ['field' => 'entityId', 'type' => 'string', 'length' => 64, 'nullable' => true, 'id' => false, 'options' => []], + 'actor_id' => ['field' => 'actorId', 'type' => 'string', 'length' => 64, 'nullable' => true, 'id' => false, 'options' => []], + 'impersonator_id' => ['field' => 'impersonatorId', 'type' => 'string', 'length' => 64, 'nullable' => true, 'id' => false, 'options' => []], + 'is_auto' => ['field' => 'isAuto', 'type' => 'boolean', 'length' => null, 'nullable' => false, 'id' => false, 'options' => ['default' => false]], + 'data' => ['field' => 'data', 'type' => 'json', 'length' => null, 'nullable' => true, 'id' => false, 'options' => []], + ]; + } +} diff --git a/tests/Integration/ListenerBehaviorTest.php b/tests/Integration/ListenerBehaviorTest.php new file mode 100644 index 0000000..10726d3 --- /dev/null +++ b/tests/Integration/ListenerBehaviorTest.php @@ -0,0 +1,189 @@ +entityManager = DoctrineTestFactory::createEntityManager(); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + (new SchemaTool($this->entityManager))->dropSchema($metadata); + (new SchemaTool($this->entityManager))->createSchema($metadata); + $this->writer = new CollectingAuditEntryWriter(); + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->close(); + } + + public function testCreateUpdateDeleteAndCurrentMessages(): void + { + $this->registerListener(); + $entity = new AuditableEntity('before'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + + $create = $this->writer->messages[0]; + self::assertSame(AuditAction::CREATE->value, $create->action); + self::assertSame(AuditLevel::INFO->value, $create->level); + self::assertSame('Create [Characterized entity]', $create->title); + self::assertSame('Entity created: '.AuditableEntity::class, $create->description); + self::assertSame('fixture-context', $create->context); + self::assertFalse($create->isAuto); + + $entity->setName('after'); + $entity->setSecret('changed-secret'); + $entity->setGloballyIgnored('changed-global'); + $this->entityManager->flush(); + + $update = $this->writer->messages[1]; + self::assertSame(AuditAction::UPDATE->value, $update->action); + self::assertSame(AuditLevel::INFO->value, $update->level); + self::assertSame('Update [Characterized entity] - 1 field(s) changed', $update->title); + self::assertSame('Public name : before → after', $update->description); + self::assertSame(['Public name' => 'Public name : before → after'], $update->data); + self::assertSame('fixture-context', $update->context); + self::assertFalse($update->isAuto); + + $this->entityManager->remove($entity); + $this->entityManager->flush(); + + $delete = $this->writer->messages[2]; + self::assertSame(AuditAction::DELETE->value, $delete->action); + self::assertSame('Delete [Characterized entity]', $delete->title); + self::assertSame('Entity removed: '.AuditableEntity::class, $delete->description); + self::assertFalse($delete->isAuto); + self::assertCount(3, $this->writer->messages); + } + + public function testNonAuditableEntityProducesNothing(): void + { + $this->registerListener(); + $entity = new NonAuditableEntity('before'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $entity->setName('after'); + $this->entityManager->flush(); + $this->entityManager->remove($entity); + $this->entityManager->flush(); + + self::assertSame([], $this->writer->messages); + } + + public function testUpdateWithOnlyIgnoredChangesProducesNothing(): void + { + $this->registerListener(); + $entity = new AuditableEntity('same'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->writer->messages = []; + $entity->setSecret('changed-secret'); + $entity->setGloballyIgnored('changed-global'); + $this->entityManager->flush(); + + self::assertSame([], $this->writer->messages); + } + + public function testTrackInsertCanBeDisabled(): void + { + $this->registerListener(trackInsert: false); + $this->entityManager->persist(new AuditableEntity('name')); + $this->entityManager->flush(); + self::assertSame([], $this->writer->messages); + } + + public function testTrackUpdateCanBeDisabled(): void + { + $this->registerListener(trackUpdate: false); + $entity = new AuditableEntity('before'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->writer->messages = []; + $entity->setName('after'); + $this->entityManager->flush(); + self::assertSame([], $this->writer->messages); + } + + public function testTrackDeleteCanBeDisabled(): void + { + $this->registerListener(trackDelete: false); + $entity = new AuditableEntity('name'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->writer->messages = []; + $this->entityManager->remove($entity); + $this->entityManager->flush(); + self::assertSame([], $this->writer->messages); + } + + public function testGlobalDisableProducesNothing(): void + { + $this->registerListener(enabled: false); + $entity = new AuditableEntity('before'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $entity->setName('after'); + $this->entityManager->flush(); + $this->entityManager->remove($entity); + $this->entityManager->flush(); + self::assertSame([], $this->writer->messages); + } + + private function registerListener( + bool $enabled = true, + bool $trackInsert = true, + bool $trackUpdate = true, + bool $trackDelete = true, + ): void { + $resolver = new class implements ActorResolverInterface { + /** @phpstan-ignore return.unusedType (interface contract is nullable) */ + public function resolveActorId(): ?string + { + return 'fixture-actor'; + } + + public function resolveImpersonatorId(): ?string + { + return null; + } + }; + $listener = new AuditableDoctrineListener( + new Historizer($this->writer, $resolver, new NullLogger()), + new AuditableMetadataProvider(), + new ChangeDetector(), + $enabled, + $trackInsert, + $trackUpdate, + $trackDelete, + ['globallyIgnored'], + ); + $this->entityManager->getEventManager()->addEventListener( + [Events::onFlush, Events::postPersist, Events::preRemove], + $listener, + ); + } +} diff --git a/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php b/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php new file mode 100644 index 0000000..b774f2b --- /dev/null +++ b/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php @@ -0,0 +1,307 @@ +bootKernel(TransactionalPostgreSqlTestKernel::NORMAL); + $connection = $entityManager->getConnection(); + + try { + $this->assertSchema($connection); + $this->assertStorageHasOnlyOnePersistCall(); + + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + + $connection->beginTransaction(); + $operation = new BusinessOperation('pending'); + self::assertInstanceOf(UuidV7::class, Uuid::fromString($operation->getId())); + $entityManager->persist($operation); + $operation->changeStatus('completed'); + self::assertSame('completed', $operation->getStatus()); + $consumer->record($this->auditEvent($operation)); + + $this->assertCounts($connection, 0, 0); + $this->assertCounts($observer, 0, 0); + + $entityManager->flush(); + + $this->assertCounts($connection, 1, 1); + $this->assertCounts($observer, 0, 0); + + $connection->commit(); + + $this->assertCounts($observer, 1, 1); + self::assertSame('completed', $observer->fetchOne('SELECT status FROM '.self::BUSINESS_TABLE)); + + $entityManager->clear(); + $entry = $entityManager->getRepository(ApplicationAuditEntry::class)->findOneBy([]); + self::assertInstanceOf(ApplicationAuditEntry::class, $entry); + self::assertInstanceOf(UuidV7::class, Uuid::fromString($entry->getId())); + self::assertSame(PostgreSqlFrozenClock::INSTANT, $entry->getOccurredAt()->format('Y-m-d\\TH:i:s.uP')); + self::assertSame('complete', $entry->getAction()); + self::assertSame('notice', $entry->getLevel()); + self::assertSame('Business operation completed', $entry->getTitle()); + self::assertSame('Atomic PostgreSQL operation', $entry->getDescription()); + self::assertSame('transactional-test', $entry->getContext()); + self::assertSame(BusinessOperation::class, $entry->getSubjectType()); + self::assertSame($operation->getId(), $entry->getSubjectIdentifier()); + self::assertSame('test-user', $entry->getActorType()); + self::assertSame('david', $entry->getActorIdentifier()); + self::assertSame('administrator', $entry->getImpersonatorIdentifier()); + self::assertSame(['tenant' => 'test'], $entry->getActorMetadata()); + self::assertTrue($entry->isAuto()); + self::assertSame(['status' => ['from' => 'pending', 'to' => 'completed']], $entry->getData()); + } finally { + $this->closeResources($kernel, $entityManager, $observer); + } + } + + public function testBusinessMutationAndAuditRollBackTogether(): void + { + [$kernel, $entityManager, $observer] = $this->bootKernel(TransactionalPostgreSqlTestKernel::NORMAL); + $connection = $entityManager->getConnection(); + + try { + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + + $connection->beginTransaction(); + $operation = new BusinessOperation('pending'); + $entityManager->persist($operation); + $consumer->record($this->auditEvent($operation)); + $entityManager->flush(); + + $this->assertCounts($connection, 1, 1); + $this->assertCounts($observer, 0, 0); + + $connection->rollBack(); + $entityManager->close(); + + $this->assertCounts($observer, 0, 0); + } finally { + $this->closeResources($kernel, $entityManager, $observer); + } + } + + public function testStrictAuditFailureKeepsAnAlreadyFlushedBusinessMutationRollbackable(): void + { + [$kernel, $entityManager, $observer] = $this->bootKernel(TransactionalPostgreSqlTestKernel::FAILING_STORAGE); + $connection = $entityManager->getConnection(); + + try { + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + + $connection->beginTransaction(); + $operation = new BusinessOperation('pending'); + $entityManager->persist($operation); + $entityManager->flush(); + + $this->assertCounts($connection, 1, 0); + $this->assertCounts($observer, 0, 0); + + try { + $consumer->record($this->auditEvent($operation)); + self::fail('The strict recorder unexpectedly absorbed the storage failure.'); + } catch (\RuntimeException $exception) { + self::assertSame('Intentional transactional audit storage failure.', $exception->getMessage()); + if ($connection->isTransactionActive()) { + $connection->rollBack(); + } + $entityManager->close(); + } + + $this->assertCounts($observer, 0, 0); + } finally { + $this->closeResources($kernel, $entityManager, $observer); + } + } + + public function testTransactionalApplicationCanOwnTheOnlyAuditMapping(): void + { + [$kernel, $entityManager, $observer] = $this->bootKernel(TransactionalPostgreSqlTestKernel::WITHOUT_LEGACY_MAPPING); + $connection = $entityManager->getConnection(); + + try { + $metadataNames = array_map( + static fn ($metadata): string => $metadata->getName(), + $entityManager->getMetadataFactory()->getAllMetadata(), + ); + sort($metadataNames); + self::assertSame([ApplicationAuditEntry::class, BusinessOperation::class], $metadataNames); + self::assertFalse($connection->createSchemaManager()->tablesExist(['audit_entry'])); + self::assertSame([self::AUDIT_TABLE, self::BUSINESS_TABLE], $this->sortedTableNames($connection)); + + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + + $connection->beginTransaction(); + $operation = new BusinessOperation('pending'); + $entityManager->persist($operation); + $operation->changeStatus('completed'); + $consumer->record($this->auditEvent($operation)); + $entityManager->flush(); + $connection->commit(); + + $this->assertCounts($observer, 1, 1); + self::assertFalse($observer->createSchemaManager()->tablesExist(['audit_entry'])); + $testContainer = $kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $registry = $testContainer->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + self::assertNull($registry->getManagerForClass(AuditEntry::class)); + } finally { + $this->closeResources($kernel, $entityManager, $observer); + } + } + + /** @return array{TransactionalPostgreSqlTestKernel, EntityManagerInterface, Connection} */ + private function bootKernel(string $environment): array + { + $kernel = new TransactionalPostgreSqlTestKernel($environment); + self::removeDirectory($kernel->getCacheDir()); + $kernel->boot(); + + $testContainer = $kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($entityManager); + $schemaTool->dropDatabase(); + $schemaTool->createSchema($metadata); + + $observer = DriverManager::getConnection($entityManager->getConnection()->getParams()); + + return [$kernel, $entityManager, $observer]; + } + + private function assertSchema(Connection $connection): void + { + $tables = $connection->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['audit_entry', self::AUDIT_TABLE, self::BUSINESS_TABLE], $tables); + + foreach ([self::BUSINESS_TABLE, self::AUDIT_TABLE] as $table) { + self::assertSame('uuid', $connection->fetchOne( + 'SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?', + [$table, 'id'], + )); + } + } + + private function assertStorageHasOnlyOnePersistCall(): void + { + $filename = (new \ReflectionClass(DoctrineAuditStorage::class))->getFileName(); + self::assertIsString($filename); + $source = file_get_contents($filename); + self::assertIsString($source); + self::assertSame(1, substr_count($source, '$this->entityManager->persist($entry);')); + foreach (['->flush(', 'beginTransaction(', 'commit(', 'rollBack(', 'wrapInTransaction(', 'transactional(', 'getConnection(', 'createQuery(', 'getRepository('] as $forbidden) { + self::assertStringNotContainsString($forbidden, $source); + } + } + + private function auditEvent(BusinessOperation $operation): AuditEvent + { + return new AuditEvent( + action: 'complete', + title: 'Business operation completed', + description: 'Atomic PostgreSQL operation', + context: 'transactional-test', + level: 'notice', + entity: $operation, + actor: new AuditActor( + type: 'test-user', + identifier: 'david', + impersonatorIdentifier: 'administrator', + metadata: ['tenant' => 'test'], + ), + isAuto: true, + data: ['status' => ['from' => 'pending', 'to' => 'completed']], + ); + } + + private function assertCounts(Connection $connection, int $business, int $audit): void + { + /** @var int|numeric-string $businessCount */ + $businessCount = $connection->fetchOne('SELECT COUNT(*) FROM '.self::BUSINESS_TABLE); + /** @var int|numeric-string $auditCount */ + $auditCount = $connection->fetchOne('SELECT COUNT(*) FROM '.self::AUDIT_TABLE); + self::assertSame($business, (int) $businessCount); + self::assertSame($audit, (int) $auditCount); + } + + /** @return list */ + private function sortedTableNames(Connection $connection): array + { + $tables = $connection->createSchemaManager()->listTableNames(); + sort($tables); + + return $tables; + } + + private function closeResources( + TransactionalPostgreSqlTestKernel $kernel, + EntityManagerInterface $entityManager, + Connection $observer, + ): void { + $connection = $entityManager->getConnection(); + if ($connection->isTransactionActive()) { + $connection->rollBack(); + } + if ($entityManager->isOpen()) { + $entityManager->close(); + } + $observer->close(); + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/Transactional/DoctrineIdentifierExtractorIntegrationTest.php b/tests/Integration/Transactional/DoctrineIdentifierExtractorIntegrationTest.php new file mode 100644 index 0000000..cb843e0 --- /dev/null +++ b/tests/Integration/Transactional/DoctrineIdentifierExtractorIntegrationTest.php @@ -0,0 +1,117 @@ +entityManager = DoctrineTestFactory::createTransactionalEntityManager(); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + (new SchemaTool($this->entityManager))->createSchema($metadata); + + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManagerForClass')->willReturnCallback(function (string $class): ?EntityManagerInterface { + if (ProxyEntity::class === $class || is_subclass_of($class, ProxyEntity::class)) { + return ProxyEntity::class === $class ? $this->entityManager : null; + } + + if (!class_exists($class)) { + return null; + } + + /* @var class-string $class */ + return $this->entityManager->getMetadataFactory()->isTransient($class) ? null : $this->entityManager; + }); + $this->extractor = new DoctrineIdentifierExtractor($registry); + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->close(); + } + + public function testExtractsPrivateStringIdentifierWithoutGetter(): void + { + $subject = $this->extractor->extract(new PrivateStringIdentifier('private-42')); + self::assertSame(PrivateStringIdentifier::class, $subject->type); + self::assertSame('private-42', $subject->identifier); + } + + public function testNeverCallsIdMethod(): void + { + $subject = $this->extractor->extract(new IdMethodEntity('method-42')); + self::assertSame(IdMethodEntity::class, $subject->type); + self::assertSame('method-42', $subject->identifier); + } + + public function testNeverCallsUnusableGetId(): void + { + $subject = $this->extractor->extract(new ThrowingGetterEntity('getter-42')); + self::assertSame(ThrowingGetterEntity::class, $subject->type); + self::assertSame('getter-42', $subject->identifier); + } + + public function testExtractsNewUnmanagedManualIdentifier(): void + { + $entity = new PrivateStringIdentifier('unmanaged-42'); + self::assertFalse($this->entityManager->contains($entity)); + self::assertSame('unmanaged-42', $this->extractor->extract($entity)->identifier); + self::assertFalse($this->entityManager->contains($entity)); + } + + public function testRejectsNewGeneratedNullIdentifierWithoutManagingEntity(): void + { + $entity = new GeneratedIdentifier(); + self::assertFalse($this->entityManager->contains($entity)); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage('not available'); + $this->extractor->extract($entity); + } + + public function testCompositeIdentifierIsExactAndIndependentOfDeclarationOrder(): void + { + $first = $this->extractor->extract(new CompositeIdentifier(42, 'FR')); + $second = $this->extractor->extract(new ReverseCompositeIdentifier('FR', 42)); + $expected = '{"format":"doctrine-composite-v1","fields":{"country":{"type":"string","value":"FR"},"number":{"type":"integer","value":"42"}}}'; + self::assertSame(CompositeIdentifier::class, $first->type); + self::assertSame(ReverseCompositeIdentifier::class, $second->type); + self::assertSame($expected, $first->identifier); + self::assertSame($expected, $second->identifier); + } + + public function testExtractsMappedTypeAndIdentifierFromDoctrineReference(): void + { + $entity = new ProxyEntity('proxy-42'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $reference = $this->entityManager->getReference(ProxyEntity::class, 'proxy-42'); + self::assertInstanceOf(ProxyEntity::class, $reference); + $subject = $this->extractor->extract($reference); + self::assertSame(ProxyEntity::class, $subject->type); + self::assertSame('proxy-42', $subject->identifier); + self::assertStringNotContainsString('Proxies', $subject->type); + } +} diff --git a/tests/Integration/Transactional/LegacyMappingOptOutIntegrationTest.php b/tests/Integration/Transactional/LegacyMappingOptOutIntegrationTest.php new file mode 100644 index 0000000..c785cae --- /dev/null +++ b/tests/Integration/Transactional/LegacyMappingOptOutIntegrationTest.php @@ -0,0 +1,105 @@ +getCacheDir()); + $kernel->boot(); + + try { + $container = $kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $container); + $registry = $container->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + $manager = $registry->getManagerForClass(AuditEntry::class); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + self::assertSame('audit_entry', $manager->getClassMetadata(AuditEntry::class)->getTableName()); + + (new SchemaTool($manager))->createSchema($manager->getMetadataFactory()->getAllMetadata()); + self::assertTrue($manager->getConnection()->createSchemaManager()->tablesExist(['audit_entry'])); + } finally { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + } + + public function testTransactionalRecorderWorksWithoutLegacyMapping(): void + { + $kernel = new TransactionalNoLegacyMappingTestKernel(); + self::removeDirectory($kernel->getCacheDir()); + $kernel->boot(); + + try { + self::assertTrue(class_exists(AuditEntry::class)); + $testContainer = $kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $registry = $testContainer->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + self::assertNull($registry->getManagerForClass(AuditEntry::class)); + + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $metadataNames = array_map(static fn ($item): string => $item->getName(), $metadata); + sort($metadataNames); + self::assertSame([AuditableEntity::class, NonAuditableEntity::class], $metadataNames); + + (new SchemaTool($entityManager))->createSchema($metadata); + $tables = $entityManager->getConnection()->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['test_auditable_entity', 'test_non_auditable_entity'], $tables); + + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + $consumer->record(new AuditEvent( + action: 'mapping-opt-out', + title: 'Transactional recorder without legacy mapping', + subject: null, + actor: new AuditActor('test-user', 'david'), + )); + } finally { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/Transactional/StrictAuditRecorderIntegrationTest.php b/tests/Integration/Transactional/StrictAuditRecorderIntegrationTest.php new file mode 100644 index 0000000..93270fc --- /dev/null +++ b/tests/Integration/Transactional/StrictAuditRecorderIntegrationTest.php @@ -0,0 +1,144 @@ +kernel = new TestKernel(); + $this->kernel->boot(); + $container = $this->kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $container); + $tokenStorage = $container->get(TokenStorageInterface::class); + self::assertInstanceOf(TokenStorageInterface::class, $tokenStorage); + $this->tokenStorage = $tokenStorage; + } + + protected function tearDown(): void + { + $cacheDir = $this->kernel->getCacheDir(); + $this->kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + public function testAuthenticatedUserWithExplicitSubjectIsCaptured(): void + { + $time = new \DateTimeImmutable('2026-08-03 09:10:11.123456+02:00'); + $subject = new AuditSubject('order', '42'); + $factory = new CapturingAuditEntryFactory(); + $storage = new CapturingAuditStorage(); + $recorder = new StrictAuditRecorder( + $this->neverExtractor(), + new SymfonySecurityActorResolver($this->tokenStorage), + new FrozenClock($time), + $factory, + $storage, + ); + $this->tokenStorage->setToken(new UsernamePasswordToken(new TestUser('authenticated-user'), 'test')); + try { + $recorder->record(new AuditEvent('update', 'Updated', subject: $subject, data: ['changed' => true])); + self::assertCount(1, $storage->entries); + self::assertSame($factory->entries[0], $storage->entries[0]); + $record = $storage->entries[0]->record; + self::assertSame($subject, $record->subject); + self::assertSame($time, $record->occurredAt); + self::assertSame(['changed' => true], $record->data); + self::assertNotNull($record->actor); + self::assertSame('authenticated_user', $record->actor->type); + self::assertSame('authenticated-user', $record->actor->identifier); + self::assertSame([], $record->actor->metadata); + } finally { + $this->tokenStorage->setToken(null); + } + } + + public function testExplicitActorBypassesResolverWithoutToken(): void + { + $actor = new AuditActor('system', 'worker', metadata: ['source' => 'command']); + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->expects(self::never())->method('resolveActor'); + $factory = new CapturingAuditEntryFactory(); + $storage = new CapturingAuditStorage(); + $this->tokenStorage->setToken(null); + try { + (new StrictAuditRecorder($this->neverExtractor(), $resolver, new FrozenClock(new \DateTimeImmutable()), $factory, $storage)) + ->record(new AuditEvent('run', 'Command', actor: $actor)); + self::assertSame($actor, $storage->entries[0]->record->actor); + self::assertSame(['source' => 'command'], $storage->entries[0]->record->actor->metadata); + } finally { + $this->tokenStorage->setToken(null); + } + } + + public function testAnonymousGlobalEventIsCaptured(): void + { + $time = new \DateTimeImmutable('2026-08-03T00:00:00Z'); + $factory = new CapturingAuditEntryFactory(); + $storage = new CapturingAuditStorage(); + $this->tokenStorage->setToken(null); + try { + (new StrictAuditRecorder( + $this->neverExtractor(), + new SymfonySecurityActorResolver($this->tokenStorage), + new FrozenClock($time), + $factory, + $storage, + ))->record(new AuditEvent('maintenance', 'Global', data: ['scope' => 'all'])); + $record = $storage->entries[0]->record; + self::assertNull($record->subject); + self::assertNull($record->actor); + self::assertSame($time, $record->occurredAt); + self::assertSame(['scope' => 'all'], $record->data); + } finally { + $this->tokenStorage->setToken(null); + } + } + + private function neverExtractor(): IdentifierExtractorInterface + { + $extractor = $this->createMock(IdentifierExtractorInterface::class); + $extractor->expects(self::never())->method('extract'); + + return $extractor; + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/Transactional/SymfonySecurityActorResolverIntegrationTest.php b/tests/Integration/Transactional/SymfonySecurityActorResolverIntegrationTest.php new file mode 100644 index 0000000..8daef2d --- /dev/null +++ b/tests/Integration/Transactional/SymfonySecurityActorResolverIntegrationTest.php @@ -0,0 +1,111 @@ +kernel = new TestKernel(); + $this->kernel->boot(); + $container = $this->kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $container); + $tokenStorage = $container->get(TokenStorageInterface::class); + self::assertInstanceOf(TokenStorageInterface::class, $tokenStorage); + $this->tokenStorage = $tokenStorage; + $this->resolver = new SymfonySecurityActorResolver($tokenStorage); + } + + protected function tearDown(): void + { + $cacheDir = $this->kernel->getCacheDir(); + $this->kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + public function testNoToken(): void + { + $this->assertActorWithToken(null, static function (?object $actor): void { + self::assertNull($actor); + }); + } + + public function testAuthenticatedUser(): void + { + $token = new UsernamePasswordToken(new TestUser('user-42'), 'test'); + $this->assertActorWithToken($token, static function (?object $actor): void { + self::assertNotNull($actor); + self::assertSame('authenticated_user', $actor->type); + self::assertSame('user-42', $actor->identifier); + self::assertNull($actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + }); + } + + public function testImpersonation(): void + { + $original = new UsernamePasswordToken(new TestUser('original-user'), 'test'); + $token = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $original); + $this->assertActorWithToken($token, static function (?object $actor): void { + self::assertNotNull($actor); + self::assertSame('authenticated_user', $actor->type); + self::assertSame('effective-user', $actor->identifier); + self::assertSame('original-user', $actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + }); + } + + public function testUnusableBusinessGettersAreNeverCalled(): void + { + $token = new UsernamePasswordToken(new TestUser('security-identifier'), 'test'); + $this->assertActorWithToken($token, static function (?object $actor): void { + self::assertNotNull($actor); + self::assertSame('security-identifier', $actor->identifier); + }); + } + + /** @param callable(?AuditActor): void $assertion */ + private function assertActorWithToken(?TokenInterface $token, callable $assertion): void + { + $this->tokenStorage->setToken($token); + try { + $assertion($this->resolver->resolveActor()); + } finally { + $this->tokenStorage->setToken(null); + } + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/Transactional/TransactionalRecorderWiringIntegrationTest.php b/tests/Integration/Transactional/TransactionalRecorderWiringIntegrationTest.php new file mode 100644 index 0000000..aa69756 --- /dev/null +++ b/tests/Integration/Transactional/TransactionalRecorderWiringIntegrationTest.php @@ -0,0 +1,207 @@ +getCacheDir()); + $kernel->boot(); + + try { + $container = $kernel->getContainer(); + self::assertFalse($container->has(AuditRecorderInterface::class)); + self::assertFalse($container->has(StrictAuditRecorder::class)); + $testContainer = $container->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + self::assertInstanceOf(AsyncAuditEntryWriter::class, $testContainer->get(AuditEntryWriterInterface::class)); + self::assertInstanceOf(SecurityActorResolver::class, $testContainer->get(ActorResolverInterface::class)); + + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + self::assertSame( + ['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], + self::sortedTableNames($entityManager->getMetadataFactory()->getAllMetadata()), + ); + (new SchemaTool($entityManager))->createSchema($entityManager->getMetadataFactory()->getAllMetadata()); + $tables = $entityManager->getConnection()->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], $tables); + } finally { + self::shutdownAndRemove($kernel); + } + } + + public function testEnabledModeAutowiresApplicationStrategiesWithoutMakingRecorderPublic(): void + { + $kernel = new TransactionalWiringTestKernel(TransactionalWiringTestKernel::ENABLED); + self::removeDirectory($kernel->getCacheDir()); + $kernel->boot(); + + try { + $container = $kernel->getContainer(); + self::assertInstanceOf(Container::class, $container); + $consumer = $container->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + self::assertInstanceOf(StrictAuditRecorder::class, $consumer->recorder()); + self::assertFalse($container->has(StrictAuditRecorder::class)); + self::assertFalse($container->has(AuditRecorderInterface::class)); + + $subject = new AuditSubject('order', 'order-42'); + $actor = new AuditActor('user', 'david'); + $consumer->record(new AuditEvent( + action: 'approve', + title: 'Order approved', + description: 'Explicit wiring test', + context: 'orders', + subject: $subject, + actor: $actor, + data: ['order' => 42], + )); + + $storage = $container->get(CapturingAuditStorage::class); + self::assertInstanceOf(CapturingAuditStorage::class, $storage); + self::assertCount(1, $storage->entries); + $entry = $storage->entries[0]; + self::assertSame('2026-08-03T10:15:30+02:00', $entry->record->occurredAt->format(\DateTimeInterface::ATOM)); + self::assertSame($subject, $entry->record->subject); + self::assertSame($actor, $entry->record->actor); + self::assertSame('approve', $entry->record->action); + self::assertSame(['order' => 42], $entry->record->data); + + $testContainer = $container->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $factory = $testContainer->get(CapturingAuditEntryFactory::class); + self::assertInstanceOf(CapturingAuditEntryFactory::class, $factory); + self::assertSame($factory->entries[0], $storage->entries[0]); + $sequence = $container->get(CallSequence::class); + self::assertInstanceOf(CallSequence::class, $sequence); + self::assertSame(['clock', 'factory', 'storage'], $sequence->calls); + + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + self::assertSame([ + AuditEntry::class, + 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity\\AuditableEntity', + 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity\\NonAuditableEntity', + ], self::sortedClassNames($entityManager->getMetadataFactory()->getAllMetadata())); + self::assertSame( + ['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], + self::sortedTableNames($entityManager->getMetadataFactory()->getAllMetadata()), + ); + } finally { + self::shutdownAndRemove($kernel); + } + } + + #[DataProvider('missingDependencyProvider')] + public function testEnabledModeFailsWhenAnApplicationStrategyIsMissing(string $environment, string $interface): void + { + $kernel = new TransactionalWiringTestKernel($environment); + self::removeDirectory($kernel->getCacheDir()); + + try { + $kernel->boot(); + self::fail('The kernel boot unexpectedly succeeded without '.$interface.'.'); + } catch (\Throwable $exception) { + self::assertStringContainsString($interface, self::exceptionMessages($exception)); + } finally { + self::shutdownAndRemove($kernel); + } + } + + /** @return iterable */ + public static function missingDependencyProvider(): iterable + { + yield 'factory' => [TransactionalWiringTestKernel::MISSING_FACTORY, AuditEntryFactoryInterface::class]; + yield 'storage' => [TransactionalWiringTestKernel::MISSING_STORAGE, AuditStorageInterface::class]; + yield 'clock' => [TransactionalWiringTestKernel::MISSING_CLOCK, \Psr\Clock\ClockInterface::class]; + } + + private static function exceptionMessages(\Throwable $exception): string + { + $messages = []; + do { + $messages[] = $exception->getMessage(); + $exception = $exception->getPrevious(); + } while (null !== $exception); + + return implode("\n", $messages); + } + + /** @param list> $metadata + * @return list + */ + private static function sortedClassNames(array $metadata): array + { + $classes = array_map(static fn (ClassMetadata $classMetadata): string => $classMetadata->getName(), $metadata); + sort($classes); + + return $classes; + } + + /** @param list> $metadata + * @return list + */ + private static function sortedTableNames(array $metadata): array + { + $tables = array_map(static fn (ClassMetadata $classMetadata): string => $classMetadata->getTableName(), $metadata); + sort($tables); + + return $tables; + } + + private static function shutdownAndRemove(\Symfony\Component\HttpKernel\KernelInterface $kernel): void + { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/TransactionalContainerIsolationTest.php b/tests/Integration/TransactionalContainerIsolationTest.php new file mode 100644 index 0000000..22017bf --- /dev/null +++ b/tests/Integration/TransactionalContainerIsolationTest.php @@ -0,0 +1,230 @@ +getCacheDir()); + $kernel->boot(); + try { + $container = $kernel->getContainer(); + self::assertInstanceOf(Container::class, $container); + $testContainer = $container->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + + foreach ([AuditSubject::class, AuditActor::class, AuditEvent::class, AuditRecord::class] as $model) { + self::assertTrue(class_exists($model)); + self::assertFalse($container->has($model)); + } + foreach ([AuditRecorderInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { + self::assertTrue(interface_exists($contract)); + self::assertFalse($container->has($contract)); + } + self::assertTrue(interface_exists(IdentifierExtractorInterface::class)); + self::assertFalse($container->has(IdentifierExtractorInterface::class)); + self::assertFalse($container->has(DoctrineIdentifierExtractor::class)); + self::assertFalse($container->has(SymfonySecurityActorResolver::class)); + self::assertTrue(class_exists(StrictAuditRecorder::class)); + self::assertFalse($container->has(StrictAuditRecorder::class)); + self::assertTrue(class_exists(ActorResolutionException::class)); + self::assertFalse($container->has(ActorResolutionException::class)); + self::assertTrue(class_exists(IdentifierExtractionException::class)); + self::assertFalse($container->has(IdentifierExtractionException::class)); + + self::assertInstanceOf(AsyncAuditEntryWriter::class, $testContainer->get(AuditEntryWriterInterface::class)); + self::assertInstanceOf(SecurityActorResolver::class, $testContainer->get(ActorResolverInterface::class)); + + $transactionalParameters = array_filter( + array_keys($container->getParameterBag()->all()), + static fn (int|string $name): bool => str_contains((string) $name, 'transactional'), + ); + self::assertSame([], $transactionalParameters); + + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $classes = array_map(static fn ($classMetadata): string => $classMetadata->getName(), $metadata); + sort($classes); + self::assertSame([ + AuditEntry::class, + 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity\\AuditableEntity', + 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity\\NonAuditableEntity', + ], $classes); + self::assertSame(['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], self::sortedTableNames($metadata)); + (new SchemaTool($entityManager))->createSchema($metadata); + $tables = $entityManager->getConnection()->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], $tables); + } finally { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + } + + public function testNoTransactionalAliasReplacesLegacyAliases(): void + { + $container = new ContainerBuilder(); + (new ZhorteinAuditableExtension())->load([], $container); + + self::assertSame(AsyncAuditEntryWriter::class, (string) $container->getAlias(AuditEntryWriterInterface::class)); + self::assertFalse($container->getAlias(AuditEntryWriterInterface::class)->isPublic()); + self::assertSame(SecurityActorResolver::class, (string) $container->getAlias(ActorResolverInterface::class)); + self::assertTrue($container->getAlias(ActorResolverInterface::class)->isPublic()); + self::assertSame(DoctrineIdentifierExtractor::class, (string) $container->getAlias(IdentifierExtractorInterface::class)); + self::assertFalse($container->getAlias(IdentifierExtractorInterface::class)->isPublic()); + self::assertTrue($container->hasDefinition(DoctrineIdentifierExtractor::class)); + self::assertFalse($container->getDefinition(DoctrineIdentifierExtractor::class)->isPublic()); + self::assertSame(SymfonySecurityActorResolver::class, (string) $container->getAlias(AuditActorResolverInterface::class)); + self::assertFalse($container->getAlias(AuditActorResolverInterface::class)->isPublic()); + self::assertTrue($container->hasDefinition(SymfonySecurityActorResolver::class)); + self::assertFalse($container->getDefinition(SymfonySecurityActorResolver::class)->isPublic()); + foreach ([AuditRecorderInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { + self::assertFalse($container->hasAlias($contract)); + } + self::assertFalse($container->hasDefinition(StrictAuditRecorder::class)); + self::assertFalse($container->hasAlias(ClockInterface::class)); + self::assertTrue($container->hasParameter('zhortein_auditable.legacy_mapping.enabled')); + self::assertTrue($container->getParameter('zhortein_auditable.legacy_mapping.enabled')); + self::assertNoTransactionalParameters($container); + } + + public function testTransactionalRecorderDefinitionIsConditionalAndPrivateBeforeCompilation(): void + { + $container = new ContainerBuilder(); + (new ZhorteinAuditableExtension())->load([['transactional' => ['enabled' => true]]], $container); + + self::assertTrue($container->hasDefinition(StrictAuditRecorder::class)); + $definition = $container->getDefinition(StrictAuditRecorder::class); + self::assertSame(StrictAuditRecorder::class, $definition->getClass()); + self::assertTrue($definition->isAutowired()); + self::assertFalse($definition->isAutoconfigured()); + self::assertFalse($definition->isPublic()); + self::assertTrue($definition->isShared()); + self::assertSame([], $definition->getArguments()); + self::assertSame([], $definition->getTags()); + + self::assertTrue($container->hasAlias(AuditRecorderInterface::class)); + $alias = $container->getAlias(AuditRecorderInterface::class); + self::assertSame(StrictAuditRecorder::class, (string) $alias); + self::assertFalse($alias->isPublic()); + + foreach ([AuditEntryFactoryInterface::class, AuditStorageInterface::class, ClockInterface::class] as $contract) { + self::assertFalse($container->hasAlias($contract)); + } + self::assertNoTransactionalParameters($container); + + $container->setAlias(IdentifierExtractorInterface::class, 'app.identifier_extractor')->setPublic(false); + $container->setAlias(AuditActorResolverInterface::class, 'app.actor_resolver')->setPublic(false); + self::assertSame('app.identifier_extractor', (string) $container->getAlias(IdentifierExtractorInterface::class)); + self::assertSame('app.actor_resolver', (string) $container->getAlias(AuditActorResolverInterface::class)); + } + + public function testLegacyMappingOptOutParameterIsFalseBeforeCompilation(): void + { + $container = new ContainerBuilder(); + (new ZhorteinAuditableExtension())->load([[ + 'enabled' => false, + 'legacy_mapping' => ['enabled' => false], + ]], $container); + + self::assertTrue($container->hasParameter('zhortein_auditable.legacy_mapping.enabled')); + self::assertFalse($container->getParameter('zhortein_auditable.legacy_mapping.enabled')); + self::assertFalse($container->getParameter('zhortein_auditable.enabled')); + self::assertTrue($container->hasDefinition(AuditEntryPersister::class)); + self::assertTrue($container->hasDefinition(AuditableDoctrineListener::class)); + self::assertSame(AsyncAuditEntryWriter::class, (string) $container->getAlias(AuditEntryWriterInterface::class)); + self::assertSame(SecurityActorResolver::class, (string) $container->getAlias(ActorResolverInterface::class)); + self::assertFalse($container->hasAlias(AuditEntryFactoryInterface::class)); + self::assertFalse($container->hasAlias(AuditStorageInterface::class)); + self::assertFalse($container->hasAlias(ClockInterface::class)); + } + + public function testLegacyMappingCannotBeDisabledForAnActiveLegacyRuntime(): void + { + $container = new ContainerBuilder(); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The legacy Doctrine mapping cannot be disabled while legacy auditing is enabled. Set "enabled" to false first.'); + + (new ZhorteinAuditableExtension())->load([[ + 'enabled' => true, + 'legacy_mapping' => ['enabled' => false], + ]], $container); + } + + private static function assertNoTransactionalParameters(ContainerBuilder $container): void + { + $parameters = array_filter( + array_keys($container->getParameterBag()->all()), + static fn (int|string $name): bool => str_starts_with((string) $name, 'zhortein_auditable.transactional.'), + ); + self::assertSame([], $parameters); + } + + /** @param list> $metadata + * @return list + */ + private static function sortedTableNames(array $metadata): array + { + $tables = array_map(static fn (ClassMetadata $classMetadata): string => $classMetadata->getTableName(), $metadata); + sort($tables); + + return $tables; + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Unit/ChangeDetectorTest.php b/tests/Unit/ChangeDetectorTest.php index 48f563f..72c83db 100644 --- a/tests/Unit/ChangeDetectorTest.php +++ b/tests/Unit/ChangeDetectorTest.php @@ -4,56 +4,143 @@ namespace Zhortein\AuditableBundle\Tests\Unit; +use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Zhortein\AuditableBundle\Service\ChangeDetector; final class ChangeDetectorTest extends TestCase { - private ChangeDetector $detector; + #[DataProvider('scalarAndObjectValues')] + public function testCurrentlyProducedRepresentations(mixed $value, string $expected): void + { + self::assertSame($expected, self::stringify(new ChangeDetector(), $value)); + } + + /** @return iterable */ + public static function scalarAndObjectValues(): iterable + { + yield 'null' => [null, '∅']; + yield 'true' => [true, 'true']; + yield 'false' => [false, 'false']; + yield 'date time' => [new \DateTimeImmutable('2025-01-02 03:04:05+00:00'), '2025-01-02 03:04:05']; + yield 'backed enum' => [DetectorEnum::VALUE, 'VALUE (stored)']; + yield 'integer' => [42, '42']; + yield 'float' => [12.5, '12.5']; + yield 'string' => ['text', 'text']; + yield 'serializable array' => [['url' => '/a/b', 'accent' => 'été'], '{"url":"/a/b","accent":"été"}']; + yield 'stringable object' => [new DetectorStringable(), 'string value']; + yield 'getName object' => [new DetectorNamed(), '[DetectorNamed#name value]']; + yield 'getTitle object' => [new DetectorTitled(), '[DetectorTitled#title value]']; + yield 'getId object' => [new DetectorIdentified(), '[DetectorIdentified#123]']; + yield 'unknown object' => [new DetectorUnknown(), '[object DetectorUnknown]']; + } + + public function testNonSerializableArray(): void + { + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + self::assertSame('[array serialization error]', self::stringify(new ChangeDetector(), [$resource])); + fclose($resource); + } + + public function testEmptyCollection(): void + { + self::assertSame('[Collection 0 items: ]', self::stringify(new ChangeDetector(), new ArrayCollection())); + } + + public function testCollectionSamplesKnownRepresentationsAndStopsAtThree(): void + { + $collection = new ArrayCollection([ + new DetectorStringable(), + new DetectorNamed(), + new DetectorTitled(), + new DetectorIdentified(), + ]); + + self::assertSame( + '[Collection 4 items: string value, name value, title value, …]', + self::stringify(new ChangeDetector(), $collection), + ); + } + + public function testCollectionUsesIdAndUnknownClassName(): void + { + self::assertSame( + '[Collection 2 items: DetectorIdentified#123, DetectorUnknown]', + self::stringify(new ChangeDetector(), new ArrayCollection([new DetectorIdentified(), new DetectorUnknown()])), + ); + } - protected function setUp(): void + public function testTruncationKeepsConfiguredLengthAndAddsEllipsis(): void { - $this->detector = new ChangeDetector(maxStringLength: 180); + self::assertSame('1234…', self::stringify(new ChangeDetector(5), '123456789')); + self::assertSame(5, mb_strlen(self::stringify(new ChangeDetector(5), '123456789'))); } - public function testDetectorCanBeInstantiated(): void + public function testUtf8TruncationCountsCharactersAndPreservesMultibyteCharacters(): void { - self::assertInstanceOf(ChangeDetector::class, $this->detector); + $result = self::stringify(new ChangeDetector(6), 'Éléphant à Tokyo'); + + self::assertSame('Éléph…', $result); + self::assertSame(6, mb_strlen($result)); + self::assertGreaterThan(6, \strlen($result)); + self::assertSame(1, preg_match('//u', $result)); } - public function testDetectorWithCustomMaxStringLength(): void + public function testZeroAndNegativeLengthsDisableTruncation(): void { - $detector = new ChangeDetector(maxStringLength: 50); - self::assertInstanceOf(ChangeDetector::class, $detector); + self::assertSame('123456789', self::stringify(new ChangeDetector(0), '123456789')); + self::assertSame('123456789', self::stringify(new ChangeDetector(-1), '123456789')); } - public function testDetectorWithZeroMaxStringLength(): void + private static function stringify(ChangeDetector $detector, mixed $value): string { - $detector = new ChangeDetector(maxStringLength: 0); - self::assertInstanceOf(ChangeDetector::class, $detector); + $method = new \ReflectionMethod($detector, 'stringify'); + $result = $method->invoke($detector, $value); + self::assertIsString($result); + + return $result; } +} - public function testDetectorWithNegativeMaxStringLength(): void +enum DetectorEnum: string +{ + case VALUE = 'stored'; +} + +final class DetectorStringable +{ + public function __toString(): string { - $detector = new ChangeDetector(maxStringLength: -1); - self::assertInstanceOf(ChangeDetector::class, $detector); + return 'string value'; } +} - public function testStringifyMethodExists(): void +final class DetectorNamed +{ + public function getName(): string { - $reflectionClass = new \ReflectionClass($this->detector); - self::assertTrue($reflectionClass->hasMethod('stringify')); + return 'name value'; } +} - public function testTruncateMethodExists(): void +final class DetectorTitled +{ + public function getTitle(): string { - $reflectionClass = new \ReflectionClass($this->detector); - self::assertTrue($reflectionClass->hasMethod('truncate')); + return 'title value'; } +} - public function testDetectorIsReadonly(): void +final class DetectorIdentified +{ + public function getId(): int { - $reflectionClass = new \ReflectionClass($this->detector); - self::assertTrue($reflectionClass->isReadonly()); + return 123; } } + +final class DetectorUnknown +{ +} diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php new file mode 100644 index 0000000..9b64f5a --- /dev/null +++ b/tests/Unit/ConfigurationTest.php @@ -0,0 +1,106 @@ + true, + 'legacy_mapping' => ['enabled' => true], + 'transactional' => ['enabled' => false], + 'async' => ['enabled' => true, 'transport' => 'async'], + 'listener' => ['track_insert' => true, 'track_update' => true, 'track_delete' => true], + 'fields' => ['max_string_length' => 180, 'global_ignored' => []], + ], $this->process([])); + } + + public function testExplicitHistoricalConfiguration(): void + { + $config = [ + 'enabled' => false, + 'legacy_mapping' => ['enabled' => true], + 'transactional' => ['enabled' => false], + 'async' => ['enabled' => false, 'transport' => 'audit_transport'], + 'listener' => ['track_insert' => false, 'track_update' => false, 'track_delete' => false], + 'fields' => ['max_string_length' => 30, 'global_ignored' => ['updatedAt', 'version']], + ]; + + self::assertSame($config, $this->process($config)); + } + + public function testTransactionalModeCanBeExplicitlyEnabled(): void + { + $config = $this->process(['transactional' => ['enabled' => true]]); + + self::assertSame(['enabled' => true], $config['transactional']); + } + + public function testTransactionalModeCanBeExplicitlyDisabled(): void + { + $config = $this->process(['transactional' => ['enabled' => false]]); + + self::assertSame(['enabled' => false], $config['transactional']); + } + + public function testTransactionalModeRejectsNonBooleanValues(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process(['transactional' => ['enabled' => 'yes']]); + } + + public function testLegacyMappingCanBeExplicitlyEnabled(): void + { + $config = $this->process(['legacy_mapping' => ['enabled' => true]]); + + self::assertSame(['enabled' => true], $config['legacy_mapping']); + } + + public function testLegacyMappingCanBeDisabledWithLegacyRuntime(): void + { + $config = $this->process([ + 'enabled' => false, + 'legacy_mapping' => ['enabled' => false], + 'transactional' => ['enabled' => true], + ]); + + self::assertSame(['enabled' => false], $config['legacy_mapping']); + self::assertSame(['enabled' => true], $config['transactional']); + } + + public function testLegacyMappingCannotBeDisabledWhileLegacyRuntimeIsEnabled(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The legacy Doctrine mapping cannot be disabled while legacy auditing is enabled. Set "enabled" to false first.'); + + $this->process(['legacy_mapping' => ['enabled' => false]]); + } + + public function testLegacyMappingRejectsNonBooleanValues(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process(['legacy_mapping' => ['enabled' => 'no']]); + } + + /** @param array $config + * @return array + */ + private function process(array $config): array + { + $processed = (new Processor())->processConfiguration(new Configuration(), [$config]); + /** @var array $typedProcessed */ + $typedProcessed = $processed; + + return $typedProcessed; + } +} diff --git a/tests/Unit/HistorizerTest.php b/tests/Unit/HistorizerTest.php new file mode 100644 index 0000000..eecda85 --- /dev/null +++ b/tests/Unit/HistorizerTest.php @@ -0,0 +1,175 @@ +createMock(AuditEntryWriterInterface::class); + $resolver = $this->createMock(ActorResolverInterface::class); + $writer->expects(self::never())->method('write'); + $resolver->expects(self::never())->method(self::anything()); + + (new Historizer($writer, $resolver, $this->createMock(LoggerInterface::class), false))->historize('log', 'title'); + } + + #[DataProvider('messageCases')] + public function testMessageValuesAreCharacterized( + AuditAction|string $action, + AuditLevel|string $level, + ?object $entity, + ?string $expectedId, + ): void { + $message = null; + $writer = $this->createMock(AuditEntryWriterInterface::class); + $writer->expects(self::once())->method('write')->with(self::callback( + static function (PersistAuditEntryMessage $actual) use (&$message): bool { + $message = $actual; + + return true; + } + )); + $resolver = $this->createConfiguredMock(ActorResolverInterface::class, [ + 'resolveActorId' => 'actor-1', + 'resolveImpersonatorId' => 'admin-2', + ]); + $before = new \DateTimeImmutable(); + + (new Historizer($writer, $resolver, $this->createMock(LoggerInterface::class)))->historize( + $action, + 'exact title', + 'exact description', + $entity, + 'exact-context', + $level, + data: ['raw' => ['nested' => true]], + ); + $after = new \DateTimeImmutable(); + + self::assertInstanceOf(PersistAuditEntryMessage::class, $message); + self::assertSame($action instanceof AuditAction ? $action->value : $action, $message->action); + self::assertSame($level instanceof AuditLevel ? $level->value : $level, $message->level); + self::assertSame('exact title', $message->title); + self::assertSame('exact description', $message->description); + self::assertSame('exact-context', $message->context); + self::assertSame(null === $entity ? null : $entity::class, $message->entityClass); + self::assertSame($expectedId, $message->entityId); + self::assertSame('actor-1', $message->actorId); + self::assertSame('admin-2', $message->impersonatorId); + self::assertFalse($message->isAuto); + self::assertSame(['raw' => ['nested' => true]], $message->data); + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/', $message->occurredAt); + $occurredAt = new \DateTimeImmutable($message->occurredAt); + self::assertGreaterThanOrEqual($before->modify('-1 second'), $occurredAt); + self::assertLessThanOrEqual($after->modify('+1 second'), $occurredAt); + } + + /** @return iterable */ + public static function messageCases(): iterable + { + yield 'enum values and integer getId' => [AuditAction::CREATE, AuditLevel::WARNING, new EntityWithId(42), '42']; + yield 'strings and string getId' => ['custom-action', 'custom-level', new EntityWithId('uuid'), 'uuid']; + yield 'null getId' => ['log', 'info', new EntityWithId(null), null]; + yield 'no getId' => ['log', 'info', new EntityWithoutId(), null]; + yield 'id method is ignored' => ['log', 'info', new EntityWithIdMethod(), null]; + yield 'Stringable getId' => ['log', 'info', new EntityWithId(new StringableId()), 'stringable-id']; + yield 'no entity' => ['log', 'info', null, null]; + } + + #[DataProvider('failOpenCases')] + public function testFailOpenBehavior(string $failure): void + { + $writer = $this->createMock(AuditEntryWriterInterface::class); + $resolver = $this->createMock(ActorResolverInterface::class); + $entity = null; + + if ('resolver' === $failure) { + $writer->expects(self::never())->method('write'); + $resolver->expects(self::once())->method('resolveActorId')->willThrowException(new \RuntimeException('resolver failed')); + $resolver->expects(self::never())->method('resolveImpersonatorId'); + } else { + $resolver->method('resolveActorId')->willReturn('actor'); + $resolver->method('resolveImpersonatorId')->willReturn(null); + } + if ('identifier' === $failure) { + $writer->expects(self::never())->method('write'); + $resolver->expects(self::never())->method('resolveActorId'); + $resolver->expects(self::never())->method('resolveImpersonatorId'); + $entity = new ThrowingIdEntity(); + } + if ('writer' === $failure) { + $writer->expects(self::once())->method('write')->willThrowException(new \RuntimeException('writer failed')); + $resolver->expects(self::once())->method('resolveActorId'); + $resolver->expects(self::once())->method('resolveImpersonatorId'); + } + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error')->with( + self::stringContains('failed'), + self::callback(static fn (array $context): bool => $context['exception'] instanceof \RuntimeException), + ); + + (new Historizer($writer, $resolver, $logger))->historize('log', 'title', entity: $entity); + } + + /** @return iterable */ + public static function failOpenCases(): iterable + { + yield 'resolver exception' => ['resolver']; + yield 'identifier exception' => ['identifier']; + yield 'writer exception' => ['writer']; + } +} + +final class EntityWithId +{ + public function __construct(private readonly mixed $id) + { + } + + public function getId(): mixed + { + return $this->id; + } +} + +final class EntityWithoutId +{ +} + +final class EntityWithIdMethod +{ + public function id(): int + { + return 7; + } +} + +final class StringableId implements \Stringable +{ + public function __toString(): string + { + return 'stringable-id'; + } +} + +final class ThrowingIdEntity +{ + public function getId(): never + { + throw new \RuntimeException('identifier failed'); + } +} diff --git a/tests/Unit/PersistencePipelineTest.php b/tests/Unit/PersistencePipelineTest.php new file mode 100644 index 0000000..c2c1d5a --- /dev/null +++ b/tests/Unit/PersistencePipelineTest.php @@ -0,0 +1,105 @@ +createMock(MessageBusInterface::class); + $bus->expects(self::once())->method('dispatch')->with( + self::identicalTo($message), + self::identicalTo([]), + )->willReturn(new Envelope($message)); + + (new AsyncAuditEntryWriter($bus))->write($message); + } + + public function testSyncWriterPersistsMessageExactlyOnce(): void + { + $message = self::message(); + $entityManager = $this->entityManagerExpectingPersistThenFlush($message); + + (new SyncAuditEntryWriter(new AuditEntryPersister($entityManager)))->write($message); + } + + public function testPersisterCopiesEveryFieldAndPersistsBeforeFlush(): void + { + $message = self::message(); + (new AuditEntryPersister($this->entityManagerExpectingPersistThenFlush($message)))->persist($message); + } + + public function testHandlerIsInvocableAndDelegatesOnce(): void + { + $message = self::message(); + $handler = new PersistAuditEntryMessageHandler( + new AuditEntryPersister($this->entityManagerExpectingPersistThenFlush($message)), + ); + + $handler($message); + } + + private function entityManagerExpectingPersistThenFlush(PersistAuditEntryMessage $message): EntityManagerInterface + { + $sequence = 0; + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('persist')->with(self::callback( + static function (AuditEntry $entry) use ($message, &$sequence): bool { + self::assertSame(0, $sequence++); + self::assertSame($message->occurredAt, $entry->getOccurredAt()->format(\DateTimeInterface::RFC3339_EXTENDED)); + self::assertSame($message->action, $entry->getAction()); + self::assertSame($message->level, $entry->getLevel()); + self::assertSame($message->title, $entry->getTitle()); + self::assertSame($message->description, $entry->getDescription()); + self::assertSame($message->context, $entry->getContext()); + self::assertSame($message->entityClass, $entry->getEntityClass()); + self::assertSame($message->entityId, $entry->getEntityId()); + self::assertSame($message->actorId, $entry->getActorId()); + self::assertSame($message->impersonatorId, $entry->getImpersonatorId()); + self::assertSame($message->isAuto, $entry->isAuto()); + self::assertSame($message->data, $entry->getData()); + + return true; + } + )); + $entityManager->expects(self::once())->method('flush')->with()->willReturnCallback( + static function () use (&$sequence): void { + self::assertSame(1, $sequence++); + } + ); + + return $entityManager; + } + + private static function message(): PersistAuditEntryMessage + { + return new PersistAuditEntryMessage( + '2025-12-21T10:11:12.123+00:00', + 'update', + 'warning', + 'Title', + 'Description', + 'context', + 'Example\\Entity', + '42', + 'actor', + 'impersonator', + true, + ['field' => ['old' => 'before', 'new' => 'after']], + ); + } +} diff --git a/tests/Unit/Transactional/Model/AuditActorTest.php b/tests/Unit/Transactional/Model/AuditActorTest.php new file mode 100644 index 0000000..3f40ddb --- /dev/null +++ b/tests/Unit/Transactional/Model/AuditActorTest.php @@ -0,0 +1,55 @@ + 7, 'roles' => ['ROLE_USER']]; + $actor = new AuditActor('user', '42', '1', $metadata); + self::assertSame('user', $actor->type); + self::assertSame('42', $actor->identifier); + self::assertSame('1', $actor->impersonatorIdentifier); + self::assertSame($metadata, $actor->metadata); + } + + public function testAllowsSystemActorWithoutIdentifier(): void + { + $actor = new AuditActor('system'); + self::assertNull($actor->identifier); + self::assertNull($actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + } + + #[DataProvider('invalidActors')] + public function testRejectsInvalidActor(string $type, ?string $identifier, ?string $impersonator): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditActor($type, $identifier, $impersonator); + } + + /** @return iterable */ + public static function invalidActors(): iterable + { + yield 'empty type' => ['', null, null]; + yield 'blank type' => [' ', null, null]; + yield 'empty identifier' => ['user', '', null]; + yield 'blank identifier' => ['user', ' ', null]; + yield 'empty impersonator' => ['user', null, '']; + yield 'blank impersonator' => ['user', null, ' ']; + } + + public function testIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(AuditActor::class); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + } +} diff --git a/tests/Unit/Transactional/Model/AuditEventTest.php b/tests/Unit/Transactional/Model/AuditEventTest.php new file mode 100644 index 0000000..9621384 --- /dev/null +++ b/tests/Unit/Transactional/Model/AuditEventTest.php @@ -0,0 +1,93 @@ + ['before', 'after']]; + $event = new AuditEvent(AuditAction::UPDATE, 'Title', 'Description', 'orders', AuditLevel::WARNING, $entity, null, $actor, true, $data, $time); + self::assertSame('update', $event->action); + self::assertSame('warning', $event->level); + self::assertSame('Title', $event->title); + self::assertSame('Description', $event->description); + self::assertSame('orders', $event->context); + self::assertSame($entity, $event->entity); + self::assertNull($event->subject); + self::assertSame($actor, $event->actor); + self::assertTrue($event->isAuto); + self::assertSame($data, $event->data); + self::assertSame($time, $event->occurredAt); + } + + public function testPreservesCustomStringsAndExplicitSubject(): void + { + $subject = new AuditSubject('order', '42'); + $event = new AuditEvent('custom-action', 'Custom', subject: $subject, level: 'notice'); + self::assertSame('custom-action', $event->action); + self::assertSame('notice', $event->level); + self::assertSame($subject, $event->subject); + self::assertNull($event->entity); + } + + public function testGlobalEventDefaults(): void + { + $event = new AuditEvent('0', '0', level: '0'); + self::assertSame('0', $event->action); + self::assertSame('0', $event->title); + self::assertSame('0', $event->level); + self::assertNull($event->description); + self::assertNull($event->context); + self::assertNull($event->entity); + self::assertNull($event->subject); + self::assertNull($event->actor); + self::assertFalse($event->isAuto); + self::assertSame([], $event->data); + self::assertNull($event->occurredAt); + } + + #[DataProvider('invalidText')] + public function testRejectsEmptyActionLevelOrTitle(string $action, string $title, string $level): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditEvent($action, $title, level: $level); + } + + /** @return iterable */ + public static function invalidText(): iterable + { + yield 'empty action' => ['', 'title', 'info']; + yield 'blank action' => [' ', 'title', 'info']; + yield 'empty level' => ['log', 'title', '']; + yield 'blank level' => ['log', 'title', ' ']; + yield 'empty title' => ['log', '', 'info']; + yield 'blank title' => ['log', ' ', 'info']; + } + + public function testRejectsEntityAndSubjectTogether(): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditEvent('log', 'title', entity: new \stdClass(), subject: new AuditSubject('type', 'id')); + } + + public function testIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(AuditEvent::class); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + } +} diff --git a/tests/Unit/Transactional/Model/AuditRecordTest.php b/tests/Unit/Transactional/Model/AuditRecordTest.php new file mode 100644 index 0000000..7ecc57e --- /dev/null +++ b/tests/Unit/Transactional/Model/AuditRecordTest.php @@ -0,0 +1,67 @@ + 'value']; + $record = new AuditRecord($time, 'update', 'warning', 'Title', 'Description', 'orders', $subject, $actor, true, $data); + self::assertSame($time, $record->occurredAt); + self::assertSame('update', $record->action); + self::assertSame('warning', $record->level); + self::assertSame('Title', $record->title); + self::assertSame('Description', $record->description); + self::assertSame('orders', $record->context); + self::assertSame($subject, $record->subject); + self::assertSame($actor, $record->actor); + self::assertTrue($record->isAuto); + self::assertSame($data, $record->data); + } + + public function testAllowsRecordWithoutSubjectOrActorAndAcceptsZero(): void + { + $record = new AuditRecord(new \DateTimeImmutable(), '0', '0', '0'); + self::assertNull($record->subject); + self::assertNull($record->actor); + self::assertSame([], $record->data); + } + + #[DataProvider('invalidText')] + public function testRejectsInvalidText(string $action, string $level, string $title): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditRecord(new \DateTimeImmutable(), $action, $level, $title); + } + + /** @return iterable */ + public static function invalidText(): iterable + { + yield 'empty action' => ['', 'info', 'title']; + yield 'blank action' => [' ', 'info', 'title']; + yield 'empty level' => ['log', '', 'title']; + yield 'blank level' => ['log', ' ', 'title']; + yield 'empty title' => ['log', 'info', '']; + yield 'blank title' => ['log', 'info', ' ']; + } + + public function testIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(AuditRecord::class); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + self::assertFalse($reflection->hasProperty('entity')); + } +} diff --git a/tests/Unit/Transactional/Model/AuditSubjectTest.php b/tests/Unit/Transactional/Model/AuditSubjectTest.php new file mode 100644 index 0000000..fa36926 --- /dev/null +++ b/tests/Unit/Transactional/Model/AuditSubjectTest.php @@ -0,0 +1,54 @@ +type); + self::assertSame($identifier, $subject->identifier); + } + + /** @return iterable */ + public static function validSubjects(): iterable + { + yield 'nominal' => ['App\\Entity\\Order', '42']; + yield 'uuid' => ['order', '4f0af080-3771-46c4-9ec2-7ff09e1f4dd2']; + yield 'integer string' => ['order', '123']; + yield 'canonical composite JSON' => ['order_item', '{"order":42,"line":3}']; + yield 'zero' => ['0', '0']; + yield 'no normalization' => [' order ', ' identifier ']; + } + + #[DataProvider('invalidSubjects')] + public function testRejectsEmptyParts(string $type, string $identifier): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditSubject($type, $identifier); + } + + /** @return iterable */ + public static function invalidSubjects(): iterable + { + yield 'empty type' => ['', 'id']; + yield 'blank type' => [" \t\n", 'id']; + yield 'empty identifier' => ['type', '']; + yield 'blank identifier' => ['type', " \t\n"]; + } + + public function testIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(AuditSubject::class); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + } +} diff --git a/tests/Unit/Transactional/Service/DoctrineIdentifierExtractorTest.php b/tests/Unit/Transactional/Service/DoctrineIdentifierExtractorTest.php new file mode 100644 index 0000000..ddf0221 --- /dev/null +++ b/tests/Unit/Transactional/Service/DoctrineIdentifierExtractorTest.php @@ -0,0 +1,280 @@ +extractor($entity, ['identifier'], ['identifier' => $value], 'Mapped\\Subject'); + + $subject = $extractor->extract($entity); + self::assertSame('Mapped\\Subject', $subject->type); + self::assertSame($expected, $subject->identifier); + } + + /** @return iterable */ + public static function validSimpleIdentifiers(): iterable + { + yield 'positive integer' => [42, '42']; + yield 'zero' => [0, '0']; + yield 'negative integer' => [-1, '-1']; + yield 'string' => ['customer-42', 'customer-42']; + yield 'spaces preserved' => [' customer-42 ', ' customer-42 ']; + yield 'string backed enum' => [StringIdentifier::ACTIVE, 'active']; + yield 'integer backed enum' => [IntegerIdentifier::FORTY_TWO, '42']; + yield 'Stringable UUID' => [new CountingStringable('0195f1dc-47ae-7ad2-b67f-9f5666dbbe37'), '0195f1dc-47ae-7ad2-b67f-9f5666dbbe37']; + } + + #[DataProvider('invalidSimpleIdentifiers')] + public function testRejectsUnsupportedOrInvalidSimpleIdentifier(mixed $value, string $type): void + { + $entity = new \stdClass(); + $extractor = $this->extractor($entity, ['identifier'], ['identifier' => $value]); + + try { + $extractor->extract($entity); + self::fail('Identifier extraction should have failed.'); + } catch (IdentifierExtractionException $exception) { + self::assertStringContainsString($type, $exception->getMessage()); + self::assertStringNotContainsString('sensitive-raw-value', $exception->getMessage()); + } + } + + /** @return iterable */ + public static function invalidSimpleIdentifiers(): iterable + { + yield 'empty string' => ['', 'empty or blank']; + yield 'blank string' => [" \t\n", 'empty or blank']; + yield 'invalid UTF-8' => ["\xC3\x28", 'invalid UTF-8']; + yield 'empty Stringable' => [new CountingStringable(''), 'empty or blank']; + yield 'blank Stringable' => [new CountingStringable(' '), 'empty or blank']; + yield 'boolean' => [true, 'bool']; + yield 'float' => [1.5, 'float']; + yield 'array' => [['sensitive-raw-value'], 'array']; + yield 'object' => [new \stdClass(), 'stdClass']; + } + + public function testStringableIsCalledExactlyOnce(): void + { + $entity = new \stdClass(); + $value = new CountingStringable('uuid'); + $subject = $this->extractor($entity, ['id'], ['id' => $value])->extract($entity); + self::assertSame('uuid', $subject->identifier); + self::assertSame(1, $value->calls); + } + + public function testWrapsStringableFailureWithoutLeakingValue(): void + { + $entity = new \stdClass(); + $previous = new \LogicException('conversion failed'); + $extractor = $this->extractor($entity, ['id'], ['id' => new ThrowingStringable($previous)]); + try { + $extractor->extract($entity); + self::fail('Identifier extraction should have failed.'); + } catch (IdentifierExtractionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + self::assertStringNotContainsString('sensitive-raw-value', $exception->getMessage()); + } + } + + public function testRejectsResource(): void + { + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + try { + $entity = new \stdClass(); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage('resource'); + $this->extractor($entity, ['id'], ['id' => $resource])->extract($entity); + } finally { + fclose($resource); + } + } + + /** + * @param list $fields + * @param array $values + */ + #[DataProvider('unavailableIdentifiers')] + public function testRejectsUnavailableIdentifier(array $fields, array $values, string $message): void + { + $entity = new \stdClass(); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage($message); + $this->extractor($entity, $fields, $values)->extract($entity); + } + + /** @return iterable, array, string}> */ + public static function unavailableIdentifiers(): iterable + { + yield 'no fields' => [[], [], 'declares no identifier field']; + yield 'missing simple value' => [['id'], [], 'not available']; + yield 'null omitted value' => [['id'], ['id' => null], 'not available']; + yield 'missing composite field' => [['country', 'number'], ['country' => 'FR'], '"number" is not available']; + } + + public function testCanonicalizesCompositeIdentifierInLexicalOrderWithTypesAndUnescapedCharacters(): void + { + $entity = new \stdClass(); + $subject = $this->extractor($entity, ['number', 'path', 'country'], [ + 'number' => 42, + 'path' => 'Île/Paris', + 'country' => 'FR', + ])->extract($entity); + + self::assertSame('{"format":"doctrine-composite-v1","fields":{"country":{"type":"string","value":"FR"},"number":{"type":"integer","value":"42"},"path":{"type":"string","value":"Île/Paris"}}}', $subject->identifier); + } + + public function testCompositeIntegerAndStringRepresentationsDiffer(): void + { + $integerEntity = new \stdClass(); + $stringEntity = new \stdClass(); + $integer = $this->extractor($integerEntity, ['country', 'number'], ['country' => 'FR', 'number' => 42])->extract($integerEntity); + $string = $this->extractor($stringEntity, ['country', 'number'], ['country' => 'FR', 'number' => '42'])->extract($stringEntity); + self::assertNotSame($integer->identifier, $string->identifier); + self::assertSame('{"format":"doctrine-composite-v1","fields":{"country":{"type":"string","value":"FR"},"number":{"type":"integer","value":"42"}}}', $integer->identifier); + self::assertSame('{"format":"doctrine-composite-v1","fields":{"country":{"type":"string","value":"FR"},"number":{"type":"string","value":"42"}}}', $string->identifier); + } + + public function testRejectsAssociationIdentifier(): void + { + $entity = new \stdClass(); + $related = new \stdClass(); + $extractor = $this->extractor($entity, ['related'], ['related' => $related], associations: ['related']); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage('custom extractor or an explicit AuditSubject'); + $extractor->extract($entity); + } + + public function testRejectsMissingOrNonOrmManager(): void + { + $entity = new \stdClass(); + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManagerForClass')->with($entity::class)->willReturn(null); + $this->expectException(IdentifierExtractionException::class); + (new DoctrineIdentifierExtractor($registry))->extract($entity); + } + + public function testRejectsNonOrmManager(): void + { + $entity = new \stdClass(); + $manager = $this->createMock(ObjectManager::class); + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManagerForClass')->willReturn($manager); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage('not a Doctrine ORM entity manager'); + (new DoctrineIdentifierExtractor($registry))->extract($entity); + } + + public function testWrapsRegistryFailure(): void + { + $entity = new \stdClass(); + $previous = new \LogicException('registry failure'); + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManagerForClass')->willThrowException($previous); + try { + (new DoctrineIdentifierExtractor($registry))->extract($entity); + self::fail('Identifier extraction should have failed.'); + } catch (IdentifierExtractionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + } + } + + public function testWrapsMetadataFailure(): void + { + $entity = new \stdClass(); + $previous = new \LogicException('metadata failure'); + [$registry, $manager] = $this->manager($entity); + $manager->expects(self::once())->method('getClassMetadata')->willThrowException($previous); + try { + (new DoctrineIdentifierExtractor($registry))->extract($entity); + self::fail('Identifier extraction should have failed.'); + } catch (IdentifierExtractionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + } + } + + /** + * @param list $fields + * @param array $values + * @param list $associations + */ + private function extractor(object $entity, array $fields, array $values, string $mappedClass = 'Mapped\\Entity', array $associations = []): DoctrineIdentifierExtractor + { + [$registry, $manager] = $this->manager($entity); + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::atLeastOnce())->method('getName')->willReturn($mappedClass); + $metadata->expects(self::once())->method('getIdentifierFieldNames')->willReturn($fields); + $metadata->expects([] === $fields ? self::never() : self::once())->method('getIdentifierValues')->with($entity)->willReturn($values); + $metadata->method('hasAssociation')->willReturnCallback(static fn (string $field): bool => \in_array($field, $associations, true)); + $manager->expects(self::once())->method('getClassMetadata')->with($entity::class)->willReturn($metadata); + + return new DoctrineIdentifierExtractor($registry); + } + + /** @return array{ManagerRegistry&MockObject, EntityManagerInterface&MockObject} */ + private function manager(object $entity): array + { + $manager = $this->createMock(EntityManagerInterface::class); + foreach (['persist', 'flush', 'find', 'getRepository', 'getConnection', 'getUnitOfWork'] as $method) { + $manager->expects(self::never())->method($method); + } + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManagerForClass')->with($entity::class)->willReturn($manager); + + return [$registry, $manager]; + } +} + +enum StringIdentifier: string +{ + case ACTIVE = 'active'; +} + +enum IntegerIdentifier: int +{ + case FORTY_TWO = 42; +} + +final class CountingStringable implements \Stringable +{ + public int $calls = 0; + + public function __construct(private readonly string $value) + { + } + + public function __toString(): string + { + ++$this->calls; + + return $this->value; + } +} + +final readonly class ThrowingStringable implements \Stringable +{ + public function __construct(private \Throwable $exception) + { + } + + public function __toString(): string + { + throw $this->exception; + } +} diff --git a/tests/Unit/Transactional/Service/StrictAuditRecorderTest.php b/tests/Unit/Transactional/Service/StrictAuditRecorderTest.php new file mode 100644 index 0000000..0b2345a --- /dev/null +++ b/tests/Unit/Transactional/Service/StrictAuditRecorderTest.php @@ -0,0 +1,269 @@ +capturingRecorder( + $this->neverExtractor(), + $this->neverActorResolver(), + $this->neverClock(), + ); + + $event = new AuditEvent( + action: 'publish', + title: 'Order published', + description: 'Exact description', + context: 'billing', + level: 'warning', + subject: $subject, + actor: $actor, + isAuto: true, + data: ['nested' => ['value' => 42]], + occurredAt: $occurredAt, + ); + $result = (new \ReflectionMethod($recorder, 'record'))->invoke($recorder, $event); + + self::assertNull($result); + self::assertCount(1, $factory->entries); + self::assertCount(1, $storage->entries); + self::assertSame($factory->entries[0], $storage->entries[0]); + $record = $factory->entries[0]->record; + self::assertSame($occurredAt, $record->occurredAt); + self::assertSame('publish', $record->action); + self::assertSame('warning', $record->level); + self::assertSame('Order published', $record->title); + self::assertSame('Exact description', $record->description); + self::assertSame('billing', $record->context); + self::assertSame($subject, $record->subject); + self::assertSame($actor, $record->actor); + self::assertTrue($record->isAuto); + self::assertSame(['nested' => ['value' => 42]], $record->data); + } + + public function testExtractsEntityExactlyOnceAndUsesReturnedSubject(): void + { + $entity = new \stdClass(); + $subject = new AuditSubject('entity', '7'); + $extractor = $this->createMock(IdentifierExtractorInterface::class); + $extractor->expects(self::once())->method('extract')->with(self::identicalTo($entity))->willReturn($subject); + [$recorder, $factory] = $this->capturingRecorder($extractor, $this->neverActorResolver(), $this->neverClock()); + $recorder->record(new AuditEvent('update', 'Updated', entity: $entity, actor: new AuditActor('system'), occurredAt: new \DateTimeImmutable())); + self::assertSame($subject, $factory->entries[0]->record->subject); + } + + public function testGlobalEventHasNoSubjectAndNeverUsesExtractor(): void + { + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $this->neverActorResolver(), $this->neverClock()); + $recorder->record(new AuditEvent('run', 'Global', actor: new AuditActor('system'), occurredAt: new \DateTimeImmutable())); + self::assertNull($factory->entries[0]->record->subject); + } + + public function testResolvesActorExactlyOnceAndPreservesIdentity(): void + { + $actor = new AuditActor('authenticated_user', 'user'); + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->expects(self::once())->method('resolveActor')->willReturn($actor); + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $resolver, $this->neverClock()); + $recorder->record(new AuditEvent('read', 'Read', occurredAt: new \DateTimeImmutable())); + self::assertSame($actor, $factory->entries[0]->record->actor); + } + + public function testNullResolvedActorIsValid(): void + { + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->expects(self::once())->method('resolveActor')->willReturn(null); + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $resolver, $this->neverClock()); + $recorder->record(new AuditEvent('read', 'Anonymous', occurredAt: new \DateTimeImmutable())); + self::assertNull($factory->entries[0]->record->actor); + } + + public function testUsesClockExactlyOnceWithoutChangingTimestamp(): void + { + $time = new PreciseDateTimeImmutable('2026-08-03 12:34:56.654321-07:00'); + $clock = $this->createMock(ClockInterface::class); + $clock->expects(self::once())->method('now')->willReturn($time); + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $this->neverActorResolver(), $clock); + $recorder->record(new AuditEvent('run', 'Timed', actor: new AuditActor('system'))); + self::assertSame($time, $factory->entries[0]->record->occurredAt); + self::assertSame('-07:00', $factory->entries[0]->record->occurredAt->format('P')); + self::assertSame('654321', $factory->entries[0]->record->occurredAt->format('u')); + } + + public function testExecutionOrderIsExact(): void + { + $sequence = new CallSequence(); + $entity = new \stdClass(); + $extractor = $this->createMock(IdentifierExtractorInterface::class); + $extractor->method('extract')->willReturnCallback(static function () use ($sequence): AuditSubject { + $sequence->add('subject'); + + return new AuditSubject('entity', '1'); + }); + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->method('resolveActor')->willReturnCallback(static function () use ($sequence): AuditActor { + $sequence->add('actor'); + + return new AuditActor('system'); + }); + $clock = new FrozenClock(new \DateTimeImmutable(), $sequence); + $factory = new CapturingAuditEntryFactory($sequence); + $storage = new CapturingAuditStorage($sequence); + (new StrictAuditRecorder($extractor, $resolver, $clock, $factory, $storage))->record(new AuditEvent('update', 'Ordered', entity: $entity)); + self::assertSame($factory->entries[0], $storage->entries[0]); + self::assertSame(['subject', 'actor', 'clock', 'factory', 'storage'], $sequence->calls); + } + + public function testExtractorFailureStopsAllLaterStepsAndPropagatesSameException(): void + { + $exception = new \LogicException('extractor'); + $extractor = $this->createMock(IdentifierExtractorInterface::class); + $extractor->expects(self::once())->method('extract')->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($extractor, $this->neverActorResolver(), $this->neverClock(), $this->neverFactory(), $this->neverStorage()), new AuditEvent('x', 'X', entity: new \stdClass())); + } + + public function testActorFailureStopsLaterStepsAndPropagatesSameException(): void + { + $exception = new \LogicException('actor'); + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->expects(self::once())->method('resolveActor')->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($this->neverExtractor(), $resolver, $this->neverClock(), $this->neverFactory(), $this->neverStorage()), new AuditEvent('x', 'X')); + } + + public function testClockFailureStopsLaterStepsAndPropagatesSameException(): void + { + $exception = new \LogicException('clock'); + $clock = $this->createMock(ClockInterface::class); + $clock->expects(self::once())->method('now')->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($this->neverExtractor(), $this->nullActorResolver(), $clock, $this->neverFactory(), $this->neverStorage()), new AuditEvent('x', 'X')); + } + + public function testFactoryFailureStopsStorageAndPropagatesSameException(): void + { + $exception = new \LogicException('factory'); + $factory = $this->createMock(AuditEntryFactoryInterface::class); + $factory->expects(self::once())->method('create')->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($this->neverExtractor(), $this->neverActorResolver(), $this->neverClock(), $factory, $this->neverStorage()), new AuditEvent('x', 'X', actor: new AuditActor('system'), occurredAt: new \DateTimeImmutable())); + } + + public function testStorageFailurePropagatesSameException(): void + { + $exception = new \LogicException('storage'); + $entry = new \stdClass(); + $factory = $this->createMock(AuditEntryFactoryInterface::class); + $factory->expects(self::once())->method('create')->willReturn($entry); + $storage = $this->createMock(AuditStorageInterface::class); + $storage->expects(self::once())->method('persist')->with(self::identicalTo($entry))->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($this->neverExtractor(), $this->neverActorResolver(), $this->neverClock(), $factory, $storage), new AuditEvent('x', 'X', actor: new AuditActor('system'), occurredAt: new \DateTimeImmutable())); + } + + public function testExplicitValuesBypassFailingStrategies(): void + { + $subject = new AuditSubject('explicit', '1'); + $actor = new AuditActor('explicit', '2'); + $time = new \DateTimeImmutable('2026-01-01T00:00:00+02:00'); + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $this->neverActorResolver(), $this->neverClock()); + $recorder->record(new AuditEvent('x', 'X', subject: $subject, actor: $actor, occurredAt: $time)); + self::assertSame($subject, $factory->entries[0]->record->subject); + self::assertSame($actor, $factory->entries[0]->record->actor); + self::assertSame($time, $factory->entries[0]->record->occurredAt); + } + + /** @return array{StrictAuditRecorder, CapturingAuditEntryFactory, CapturingAuditStorage} */ + private function capturingRecorder(IdentifierExtractorInterface $extractor, AuditActorResolverInterface $resolver, ClockInterface $clock): array + { + $factory = new CapturingAuditEntryFactory(); + $storage = new CapturingAuditStorage(); + + return [new StrictAuditRecorder($extractor, $resolver, $clock, $factory, $storage), $factory, $storage]; + } + + private function neverExtractor(): IdentifierExtractorInterface&MockObject + { + $mock = $this->createMock(IdentifierExtractorInterface::class); + $mock->expects(self::never())->method('extract'); + + return $mock; + } + + private function neverActorResolver(): AuditActorResolverInterface&MockObject + { + $mock = $this->createMock(AuditActorResolverInterface::class); + $mock->expects(self::never())->method('resolveActor'); + + return $mock; + } + + private function nullActorResolver(): AuditActorResolverInterface&MockObject + { + $mock = $this->createMock(AuditActorResolverInterface::class); + $mock->expects(self::once())->method('resolveActor')->willReturn(null); + + return $mock; + } + + private function neverClock(): ClockInterface&MockObject + { + $mock = $this->createMock(ClockInterface::class); + $mock->expects(self::never())->method('now'); + + return $mock; + } + + /** @return AuditEntryFactoryInterface&MockObject */ + private function neverFactory(): AuditEntryFactoryInterface&MockObject + { + $mock = $this->createMock(AuditEntryFactoryInterface::class); + $mock->expects(self::never())->method('create'); + + return $mock; + } + + /** @return AuditStorageInterface&MockObject */ + private function neverStorage(): AuditStorageInterface&MockObject + { + $mock = $this->createMock(AuditStorageInterface::class); + $mock->expects(self::never())->method('persist'); + + return $mock; + } + + /** @param StrictAuditRecorder $recorder */ + private function assertSameException(\Throwable $expected, StrictAuditRecorder $recorder, AuditEvent $event): void + { + try { + $recorder->record($event); + self::fail('Recording should have failed.'); + } catch (\Throwable $actual) { + self::assertSame($expected, $actual); + } + } +} + +final class PreciseDateTimeImmutable extends \DateTimeImmutable +{ +} diff --git a/tests/Unit/Transactional/Service/SymfonySecurityActorResolverTest.php b/tests/Unit/Transactional/Service/SymfonySecurityActorResolverTest.php new file mode 100644 index 0000000..0cd5897 --- /dev/null +++ b/tests/Unit/Transactional/Service/SymfonySecurityActorResolverTest.php @@ -0,0 +1,197 @@ +createMock(TokenStorageInterface::class); + $storage->expects(self::once())->method('getToken')->willReturn(null); + self::assertNull((new SymfonySecurityActorResolver($storage))->resolveActor()); + } + + public function testReturnsNullWhenOrdinaryTokenHasNoUser(): void + { + $token = $this->token(null); + self::assertNull($this->resolver($token)->resolveActor()); + } + + #[DataProvider('validIdentifiers')] + public function testResolvesAuthenticatedUserExactly(string $identifier): void + { + $user = $this->user($identifier); + $actor = $this->resolver($this->token($user))->resolveActor(); + self::assertNotNull($actor); + self::assertSame('authenticated_user', $actor->type); + self::assertSame($identifier, $actor->identifier); + self::assertNull($actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + } + + /** @return iterable */ + public static function validIdentifiers(): iterable + { + yield 'zero' => ['0']; + yield 'UTF-8' => ['utilisateur-é']; + yield 'significant spaces' => [' utilisateur ']; + } + + #[DataProvider('invalidIdentifiers')] + public function testRejectsInvalidCurrentIdentifier(string $identifier): void + { + $this->expectException(ActorResolutionException::class); + $this->resolver($this->token($this->user($identifier)))->resolveActor(); + } + + /** @return iterable */ + public static function invalidIdentifiers(): iterable + { + yield 'empty' => ['']; + yield 'blank' => [" \t\n"]; + yield 'invalid UTF-8' => ["\xC3\x28"]; + } + + public function testWrapsTokenStorageFailure(): void + { + $previous = new \LogicException('storage failure'); + $storage = $this->createMock(TokenStorageInterface::class); + $storage->expects(self::once())->method('getToken')->willThrowException($previous); + $this->assertWrappedPrevious(new SymfonySecurityActorResolver($storage), $previous); + } + + public function testWrapsCurrentUserReadFailure(): void + { + $previous = new \LogicException('token failure'); + $token = $this->createMock(TokenInterface::class); + $token->expects(self::once())->method('getUser')->willThrowException($previous); + $this->assertWrappedPrevious($this->resolver($token), $previous); + } + + public function testWrapsIdentifierReadFailureWithoutLeakingIt(): void + { + $previous = new \LogicException('sensitive-raw-value'); + $user = $this->createMock(UserInterface::class); + $user->expects(self::once())->method('getUserIdentifier')->willThrowException($previous); + $user->expects(self::never())->method('getRoles'); + try { + $this->resolver($this->token($user))->resolveActor(); + self::fail('Actor resolution should have failed.'); + } catch (ActorResolutionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + self::assertStringNotContainsString('sensitive-raw-value', $exception->getMessage()); + } + } + + public function testResolvesImmediateSwitchUserContext(): void + { + $current = $this->user('effective-user'); + $original = $this->user('original-user'); + $originalToken = $this->token($original); + $switchToken = new SwitchUserToken($current, 'test', [], $originalToken); + $actor = $this->resolver($switchToken)->resolveActor(); + self::assertNotNull($actor); + self::assertSame('effective-user', $actor->identifier); + self::assertSame('original-user', $actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + } + + public function testUsesOnlyImmediateOriginalTokenWhenSwitchTokensAreNested(): void + { + $rootToken = new UsernamePasswordToken(new TestUser('root-user'), 'test'); + $inner = new SwitchUserToken(new TestUser('immediate-user'), 'test', [], $rootToken); + $outer = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $inner); + $actor = $this->resolver($outer)->resolveActor(); + self::assertNotNull($actor); + self::assertSame('immediate-user', $actor->impersonatorIdentifier); + } + + public function testRejectsMissingOriginalUserWithoutPartialResult(): void + { + $switchToken = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $this->token(null)); + $this->expectException(ActorResolutionException::class); + $this->resolver($switchToken)->resolveActor(); + } + + #[DataProvider('invalidIdentifiers')] + public function testRejectsInvalidOriginalIdentifierWithoutLeakingIt(string $identifier): void + { + $switchToken = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $this->token($this->user($identifier))); + try { + $this->resolver($switchToken)->resolveActor(); + self::fail('Actor resolution should have failed.'); + } catch (ActorResolutionException $exception) { + if ('' !== $identifier) { + self::assertStringNotContainsString($identifier, $exception->getMessage()); + } + self::assertSame('The impersonator identifier is invalid.', $exception->getMessage()); + } + } + + public function testWrapsOriginalUserReadFailure(): void + { + $previous = new \LogicException('original token failure'); + $originalToken = $this->createMock(TokenInterface::class); + $originalToken->expects(self::once())->method('getUser')->willThrowException($previous); + $switchToken = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $originalToken); + $this->assertWrappedPrevious($this->resolver($switchToken), $previous); + } + + public function testNeverCallsBusinessIdentifiersOrRoles(): void + { + $actor = $this->resolver(new UsernamePasswordToken(new TestUser('security-identifier'), 'test'))->resolveActor(); + self::assertNotNull($actor); + self::assertSame('security-identifier', $actor->identifier); + } + + /** @return UserInterface&MockObject */ + private function user(string $identifier): UserInterface + { + $user = $this->createMock(UserInterface::class); + $user->expects(self::once())->method('getUserIdentifier')->willReturn($identifier); + $user->expects(self::never())->method('getRoles'); + + return $user; + } + + /** @return TokenInterface&MockObject */ + private function token(?UserInterface $user): TokenInterface + { + $token = $this->createMock(TokenInterface::class); + $token->expects(self::once())->method('getUser')->willReturn($user); + + return $token; + } + + private function resolver(TokenInterface $token): SymfonySecurityActorResolver + { + $storage = $this->createMock(TokenStorageInterface::class); + $storage->expects(self::once())->method('getToken')->willReturn($token); + + return new SymfonySecurityActorResolver($storage); + } + + private function assertWrappedPrevious(SymfonySecurityActorResolver $resolver, \Throwable $previous): void + { + try { + $resolver->resolveActor(); + self::fail('Actor resolution should have failed.'); + } catch (ActorResolutionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + } + } +}