diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3c48516..1f2f817 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -6,7 +6,32 @@ on: - 'v*' jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, xml, zip, intl, gettext, redis, apcu + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict + + - name: Install Composer dependencies + run: composer install --optimize-autoloader --no-interaction + + - name: Run tests + run: composer test + build-release: + needs: test runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index b141b7b..d2ff6b9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ releases/* # Marketing texts (not for distribution) MARKETING.md + +# PHPUnit cache +.phpunit.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 9746644..8a51dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-02-26 + +### Added + +- Comprehensive PHPUnit test suite with 189 tests and 329 assertions: + - CustomMetricBuilderTest (35 tests) - validation, CRUD, import/export + - AuthenticationTest (13 tests) - Bearer token, query param, header extraction + - StorageFactoryTest (25 tests) - adapter config, env vars, connection testing + - RuntimeCollectorTest (22 tests) - endpoint normalization, histograms, singleton + - DashboardProviderTest (27 tests) - registration validation, path traversal security + - InstallerTest (11 tests) - activation, deactivation, uninstall cleanup + - CollectorTest (10 tests) - registry, metric registration, render output + - MetricsEndpointTest (5 tests) - rewrite rules, request routing +- Test bootstrap with WordPress function stubs and GlobalFunctionState helper +- CI/CD test job in Gitea release workflow that gates release builds +- php-mock/php-mock-phpunit dependency for mocking WordPress functions in namespaced code + +### Changed + +- Release pipeline now requires passing tests before building release packages + ## [0.4.9] - 2026-02-26 ### Security diff --git a/CLAUDE.md b/CLAUDE.md index 0798212..16cecf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,7 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint ```txt wp-prometheus/ ├── .gitea/workflows/ -│ └── release.yml # CI/CD pipeline +│ └── release.yml # CI/CD pipeline (test + build) ├── assets/ │ ├── css/ # Admin/Frontend styles │ ├── dashboards/ # Grafana dashboard templates @@ -238,10 +238,28 @@ wp-prometheus/ │ ├── Installer.php # Activation/Deactivation │ ├── Plugin.php # Main plugin class │ └── index.php +├── tests/ +│ ├── bootstrap.php # WP constants + function stubs +│ ├── Helpers/ +│ │ └── GlobalFunctionState.php # Controllable stub state +│ └── Unit/ +│ ├── TestCase.php # Base class with PHPMock +│ ├── AuthenticationTest.php +│ ├── InstallerTest.php +│ ├── Admin/ +│ │ └── DashboardProviderTest.php +│ ├── Endpoint/ +│ │ └── MetricsEndpointTest.php +│ └── Metrics/ +│ ├── CollectorTest.php +│ ├── CustomMetricBuilderTest.php +│ ├── RuntimeCollectorTest.php +│ └── StorageFactoryTest.php ├── CHANGELOG.md ├── CLAUDE.md ├── composer.json ├── index.php +├── phpunit.xml # PHPUnit 10 configuration ├── PLAN.md ├── README.md ├── uninstall.php @@ -290,6 +308,46 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) { ## Session History +### 2026-02-26 - PHPUnit Test Suite & CI/CD Integration (v0.5.0) + +- Created comprehensive PHPUnit test suite with 189 tests and 329 assertions +- Added `php-mock/php-mock-phpunit:^2.10` to composer.json require-dev +- Created test infrastructure: + - `tests/bootstrap.php`: ~45 WordPress function stubs with `if (!function_exists())` guards + - `tests/Helpers/GlobalFunctionState.php`: Static class for controlling stub behavior + - `tests/Unit/TestCase.php`: Abstract base class with PHPMock trait +- 8 test classes covering all core plugin classes: + - `CustomMetricBuilderTest` (35 tests) - validation, CRUD, import/export + - `StorageFactoryTest` (25 tests) - adapter config, env vars, connection tests + - `AuthenticationTest` (13 tests) - Bearer/query auth, header extraction + - `DashboardProviderTest` (27 tests) - registration, path traversal security + - `RuntimeCollectorTest` (22 tests) - endpoint normalization, histograms + - `InstallerTest` (11 tests) - activate, deactivate, uninstall cleanup + - `CollectorTest` (10 tests) - registry, register_gauge/counter/histogram, render + - `MetricsEndpointTest` (5 tests) - rewrite rules, request routing +- Added `test` job to `.gitea/workflows/release.yml` that gates `build-release` +- **Key Learning**: php-mock/php-mock-phpunit for WordPress testing without WP environment + - Intercepts unqualified function calls in namespaced code via PHP namespace fallback + - `$this->getFunctionMock('Namespace', 'function_name')` creates expectations + - Does NOT work for functions called from global namespace (bootstrap stubs) or in PHP 8.4 for some edge cases + - Solution for global-scope stubs: Make them controllable via `GlobalFunctionState::$options` +- **Key Learning**: Testing singletons and static state + - Use `ReflectionClass::newInstanceWithoutConstructor()` to bypass private constructors + - Reset static `$instance` properties via `ReflectionProperty::setValue(null, null)` in tearDown + - Always reset StorageFactory and RuntimeCollector singletons between tests +- **Key Learning**: CI/CD pipeline structure for test gating + - `test` job uses `composer install` (WITH dev deps) to run tests + - `build-release` job uses `--no-dev` (unchanged) for production builds + - `needs: test` dependency ensures failing tests block releases + +### 2026-02-26 - Security Audit & Refactoring (v0.4.9) + +- Fixed XSS vulnerabilities in admin.js (jQuery `.html()` → safe DOM construction) +- Fixed insecure token generation (`Math.random()` → Web Crypto API) +- Added 1 MB import size limit, import mode validation, removed `site_url` from exports +- Extracted shared authentication logic and helper methods +- Optimized WooCommerce product counting with COUNT query + ### 2026-02-07 - Fix Early Textdomain Loading (v0.4.8) - Fixed `_load_textdomain_just_in_time` warning on admin pages (WordPress 6.7+ compatibility) diff --git a/README.md b/README.md index ca3d66e..5b7c57e 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,46 @@ add_action( 'wp_prometheus_register_dashboards', function( $provider ) { ## Development +### Testing + +The plugin includes a comprehensive PHPUnit test suite with 189 tests covering all core classes. + +```bash +# Install dependencies (including dev) +composer install + +# Run the full test suite +composer test +``` + +#### Test Architecture + +Tests use [php-mock/php-mock-phpunit](https://github.com/php-mock/php-mock-phpunit) to mock WordPress functions called from namespaced plugin code, and a `GlobalFunctionState` helper for controlling global-scope function stubs (authentication, options, conditionals). + +``` +tests/ +├── bootstrap.php # WP constants + function stubs +├── Helpers/ +│ └── GlobalFunctionState.php # Controllable state for stubs +└── Unit/ + ├── TestCase.php # Base class with PHPMock trait + ├── AuthenticationTest.php # Bearer/query auth, header extraction + ├── InstallerTest.php # Activate, deactivate, uninstall + ├── Metrics/ + │ ├── CustomMetricBuilderTest.php # Validation, CRUD, import/export + │ ├── RuntimeCollectorTest.php # Endpoint normalization, histograms + │ ├── StorageFactoryTest.php # Adapter config, env vars, connections + │ └── CollectorTest.php # Registry, register_*, render + ├── Admin/ + │ └── DashboardProviderTest.php # Registration, path security + └── Endpoint/ + └── MetricsEndpointTest.php # Rewrite rules, request routing +``` + +#### CI/CD Integration + +Tests run automatically in the Gitea CI/CD pipeline before release builds. A failing test suite blocks the release. + ### Build for Release ```bash diff --git a/composer.json b/composer.json index d5eed32..8eb47b0 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "promphp/prometheus_client_php": "^2.10" }, "require-dev": { + "php-mock/php-mock-phpunit": "^2.10", "phpunit/phpunit": "^10.0", "squizlabs/php_codesniffer": "^3.7", "wp-coding-standards/wpcs": "^3.0", diff --git a/composer.lock b/composer.lock index 567848b..a6f0f89 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3cacd9c609c3c49fb4600d09a38c04be", + "content-hash": "cfd3853b3cf76d82f972f3326e4f94d3", "packages": [ { "name": "magdev/wc-licensed-product-client", @@ -1128,6 +1128,213 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-mock/php-mock", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/php-mock/php-mock.git", + "reference": "b59734f19765296bb0311942850d02288a224890" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mock/php-mock/zipball/b59734f19765296bb0311942850d02288a224890", + "reference": "b59734f19765296bb0311942850d02288a224890", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0 || ^8.0", + "phpunit/php-text-template": "^1 || ^2 || ^3 || ^4 || ^5 || ^6" + }, + "replace": { + "malkusch/php-mock": "*" + }, + "require-dev": { + "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "php-mock/php-mock-phpunit": "Allows integration into PHPUnit testcase with the trait PHPMock." + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "phpmock\\": [ + "classes/", + "tests/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "WTFPL" + ], + "authors": [ + { + "name": "Markus Malkusch", + "email": "markus@malkusch.de", + "homepage": "http://markus.malkusch.de", + "role": "Developer" + } + ], + "description": "PHP-Mock can mock built-in PHP functions (e.g. time()). PHP-Mock relies on PHP's namespace fallback policy. No further extension is needed.", + "homepage": "https://github.com/php-mock/php-mock", + "keywords": [ + "BDD", + "TDD", + "function", + "mock", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "issues": "https://github.com/php-mock/php-mock/issues", + "source": "https://github.com/php-mock/php-mock/tree/2.7.0" + }, + "funding": [ + { + "url": "https://github.com/michalbundyra", + "type": "github" + } + ], + "time": "2026-02-06T07:39:37+00:00" + }, + { + "name": "php-mock/php-mock-integration", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-mock/php-mock-integration.git", + "reference": "cbbf39705ec13dece5b04133cef4e2fd3137a345" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mock/php-mock-integration/zipball/cbbf39705ec13dece5b04133cef4e2fd3137a345", + "reference": "cbbf39705ec13dece5b04133cef4e2fd3137a345", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "php-mock/php-mock": "^2.5", + "phpunit/php-text-template": "^1 || ^2 || ^3 || ^4 || ^5 || ^6" + }, + "require-dev": { + "phpunit/phpunit": "^5.7.27 || ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || ^13" + }, + "type": "library", + "autoload": { + "psr-4": { + "phpmock\\integration\\": "classes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "WTFPL" + ], + "authors": [ + { + "name": "Markus Malkusch", + "email": "markus@malkusch.de", + "homepage": "http://markus.malkusch.de", + "role": "Developer" + } + ], + "description": "Integration package for PHP-Mock", + "homepage": "https://github.com/php-mock/php-mock-integration", + "keywords": [ + "BDD", + "TDD", + "function", + "mock", + "stub", + "test", + "test double" + ], + "support": { + "issues": "https://github.com/php-mock/php-mock-integration/issues", + "source": "https://github.com/php-mock/php-mock-integration/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/michalbundyra", + "type": "github" + } + ], + "time": "2026-02-06T07:44:43+00:00" + }, + { + "name": "php-mock/php-mock-phpunit", + "version": "2.15.0", + "source": { + "type": "git", + "url": "https://github.com/php-mock/php-mock-phpunit.git", + "reference": "701df15b183f25af663af134eb71353cd838b955" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mock/php-mock-phpunit/zipball/701df15b183f25af663af134eb71353cd838b955", + "reference": "701df15b183f25af663af134eb71353cd838b955", + "shasum": "" + }, + "require": { + "php": ">=7", + "php-mock/php-mock-integration": "^3.0", + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10.0.17 || ^11 || ^12.0.9 || ^13" + }, + "require-dev": { + "mockery/mockery": "^1.3.6" + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "phpmock\\phpunit\\": "classes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "WTFPL" + ], + "authors": [ + { + "name": "Markus Malkusch", + "email": "markus@malkusch.de", + "homepage": "http://markus.malkusch.de", + "role": "Developer" + } + ], + "description": "Mock built-in PHP functions (e.g. time()) with PHPUnit. This package relies on PHP's namespace fallback policy. No further extension is needed.", + "homepage": "https://github.com/php-mock/php-mock-phpunit", + "keywords": [ + "BDD", + "TDD", + "function", + "mock", + "phpunit", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "issues": "https://github.com/php-mock/php-mock-phpunit/issues", + "source": "https://github.com/php-mock/php-mock-phpunit/tree/2.15.0" + }, + "funding": [ + { + "url": "https://github.com/michalbundyra", + "type": "github" + } + ], + "time": "2026-02-06T09:12:10+00:00" + }, { "name": "phpcompatibility/php-compatibility", "version": "9.3.5", diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5ea8fcd --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,25 @@ + + + + + tests/Unit + + + + + + src + + + src/index.php + + + diff --git a/tests/Helpers/GlobalFunctionState.php b/tests/Helpers/GlobalFunctionState.php new file mode 100644 index 0000000..fdac5fb --- /dev/null +++ b/tests/Helpers/GlobalFunctionState.php @@ -0,0 +1,51 @@ + Simulated WordPress options. */ + public static array $options = []; + + /** @var array Track function call counts. */ + public static array $callCounts = []; + + /** @var array> Track arguments passed to functions. */ + public static array $callArgs = []; + + /** + * Reset all state. Call in setUp()/tearDown(). + */ + public static function reset(): void + { + self::$options = []; + self::$callCounts = []; + self::$callArgs = []; + } + + /** + * Record a function call for later assertions. + */ + public static function recordCall(string $function, mixed ...$args): void + { + self::$callCounts[$function] = (self::$callCounts[$function] ?? 0) + 1; + self::$callArgs[$function][] = $args; + } + + /** + * Get call count for a function. + */ + public static function getCallCount(string $function): int + { + return self::$callCounts[$function] ?? 0; + } +} diff --git a/tests/Unit/Admin/DashboardProviderTest.php b/tests/Unit/Admin/DashboardProviderTest.php new file mode 100644 index 0000000..251a0fc --- /dev/null +++ b/tests/Unit/Admin/DashboardProviderTest.php @@ -0,0 +1,376 @@ +provider = new DashboardProvider(); + } + + // ── register_dashboard() - Validation ──────────────────────────── + + #[Test] + public function register_with_inline_json_succeeds(): void + { + $result = $this->provider->register_dashboard('my-dashboard', [ + 'title' => 'My Dashboard', + 'json' => '{"panels":[]}', + ]); + + $this->assertTrue($result); + } + + #[Test] + public function register_rejects_empty_slug(): void + { + $result = $this->provider->register_dashboard('', [ + 'title' => 'Test', + 'json' => '{}', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_rejects_invalid_slug_characters(): void + { + // sanitize_key removes all non [a-z0-9_-] characters + // A slug like '!@#' becomes '' after sanitize_key + $result = $this->provider->register_dashboard('!@#', [ + 'title' => 'Test', + 'json' => '{}', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_rejects_duplicate_builtin_slug(): void + { + $result = $this->provider->register_dashboard('wordpress-overview', [ + 'title' => 'Override Built-in', + 'json' => '{}', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_rejects_duplicate_registered_slug(): void + { + $this->provider->register_dashboard('my-dashboard', [ + 'title' => 'First', + 'json' => '{}', + ]); + + $result = $this->provider->register_dashboard('my-dashboard', [ + 'title' => 'Second', + 'json' => '{}', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_requires_title(): void + { + $result = $this->provider->register_dashboard('no-title', [ + 'json' => '{}', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_requires_file_or_json(): void + { + $result = $this->provider->register_dashboard('no-content', [ + 'title' => 'No Content', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_rejects_both_file_and_json(): void + { + $fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists'); + $fileExists->expects($this->any())->willReturn(true); + + $isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable'); + $isReadable->expects($this->any())->willReturn(true); + + $realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath'); + $realpath->expects($this->any())->willReturnArgument(0); + + $result = $this->provider->register_dashboard('both', [ + 'title' => 'Both', + 'file' => '/tmp/wordpress/wp-content/plugins/test/dashboard.json', + 'json' => '{}', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_file_requires_absolute_path(): void + { + $result = $this->provider->register_dashboard('relative', [ + 'title' => 'Relative Path', + 'file' => 'relative/path/dashboard.json', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_file_must_exist(): void + { + $fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists'); + $fileExists->expects($this->any())->willReturn(false); + + $result = $this->provider->register_dashboard('missing-file', [ + 'title' => 'Missing File', + 'file' => '/tmp/wordpress/wp-content/plugins/test/nonexistent.json', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_file_rejects_path_traversal(): void + { + $fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists'); + $fileExists->expects($this->any())->willReturn(true); + + $isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable'); + $isReadable->expects($this->any())->willReturn(true); + + $realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath'); + $realpath->expects($this->any())->willReturnArgument(0); + + $result = $this->provider->register_dashboard('evil', [ + 'title' => 'Evil Dashboard', + 'file' => '/etc/passwd', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_file_accepts_path_under_wp_content(): void + { + $fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists'); + $fileExists->expects($this->any())->willReturn(true); + + $isReadable = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'is_readable'); + $isReadable->expects($this->any())->willReturn(true); + + $realpath = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'realpath'); + $realpath->expects($this->any())->willReturnArgument(0); + + $result = $this->provider->register_dashboard('valid-file', [ + 'title' => 'Valid File Dashboard', + 'file' => '/tmp/wordpress/wp-content/plugins/my-plugin/dashboard.json', + ]); + + $this->assertTrue($result); + } + + #[Test] + public function register_rejects_invalid_inline_json(): void + { + $result = $this->provider->register_dashboard('bad-json', [ + 'title' => 'Bad JSON', + 'json' => '{invalid json', + ]); + + $this->assertFalse($result); + } + + #[Test] + public function register_sets_source_to_third_party(): void + { + $this->provider->register_dashboard('ext-dashboard', [ + 'title' => 'Extension', + 'json' => '{}', + 'plugin' => 'My Plugin', + ]); + + $this->assertTrue($this->provider->is_third_party('ext-dashboard')); + } + + // ── get_available() ────────────────────────────────────────────── + + #[Test] + public function get_available_includes_builtin_dashboards(): void + { + $fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists'); + $fileExists->expects($this->any())->willReturn(true); + + $available = $this->provider->get_available(); + + $this->assertArrayHasKey('wordpress-overview', $available); + $this->assertArrayHasKey('wordpress-runtime', $available); + $this->assertArrayHasKey('wordpress-woocommerce', $available); + } + + #[Test] + public function get_available_includes_registered_dashboards(): void + { + $fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists'); + $fileExists->expects($this->any())->willReturn(true); + + $this->provider->register_dashboard('custom-dash', [ + 'title' => 'Custom', + 'json' => '{"panels":[]}', + ]); + + $available = $this->provider->get_available(); + $this->assertArrayHasKey('custom-dash', $available); + } + + #[Test] + public function get_available_excludes_builtin_with_missing_file(): void + { + $fileExists = $this->getFunctionMock('Magdev\\WpPrometheus\\Admin', 'file_exists'); + $fileExists->expects($this->any())->willReturn(false); + + $available = $this->provider->get_available(); + + // Built-in dashboards should be excluded (files don't exist). + $this->assertArrayNotHasKey('wordpress-overview', $available); + } + + // ── is_third_party() ───────────────────────────────────────────── + + #[Test] + public function builtin_dashboard_is_not_third_party(): void + { + $this->assertFalse($this->provider->is_third_party('wordpress-overview')); + } + + #[Test] + public function registered_dashboard_is_third_party(): void + { + $this->provider->register_dashboard('ext-dash', [ + 'title' => 'Extension Dashboard', + 'json' => '{}', + ]); + + $this->assertTrue($this->provider->is_third_party('ext-dash')); + } + + #[Test] + public function unknown_slug_is_not_third_party(): void + { + $this->assertFalse($this->provider->is_third_party('nonexistent')); + } + + // ── get_plugin_name() ──────────────────────────────────────────── + + #[Test] + public function get_plugin_name_returns_name_for_registered(): void + { + $this->provider->register_dashboard('ext-dash', [ + 'title' => 'Extension', + 'json' => '{}', + 'plugin' => 'My Awesome Plugin', + ]); + + $this->assertSame('My Awesome Plugin', $this->provider->get_plugin_name('ext-dash')); + } + + #[Test] + public function get_plugin_name_returns_null_for_builtin(): void + { + $this->assertNull($this->provider->get_plugin_name('wordpress-overview')); + } + + #[Test] + public function get_plugin_name_returns_null_when_empty(): void + { + $this->provider->register_dashboard('no-plugin', [ + 'title' => 'No Plugin', + 'json' => '{}', + ]); + + $this->assertNull($this->provider->get_plugin_name('no-plugin')); + } + + // ── get_filename() ─────────────────────────────────────────────── + + #[Test] + public function get_filename_returns_file_for_builtin(): void + { + $filename = $this->provider->get_filename('wordpress-overview'); + $this->assertSame('wordpress-overview.json', $filename); + } + + #[Test] + public function get_filename_returns_slug_json_for_inline(): void + { + $this->provider->register_dashboard('my-dash', [ + 'title' => 'My Dashboard', + 'json' => '{}', + ]); + + $this->assertSame('my-dash.json', $this->provider->get_filename('my-dash')); + } + + #[Test] + public function get_filename_returns_null_for_unknown(): void + { + $this->assertNull($this->provider->get_filename('nonexistent')); + } + + // ── get_metadata() ─────────────────────────────────────────────── + + #[Test] + public function get_metadata_returns_builtin_data(): void + { + $metadata = $this->provider->get_metadata('wordpress-overview'); + + $this->assertIsArray($metadata); + $this->assertSame('WordPress Overview', $metadata['title']); + $this->assertSame('builtin', $metadata['source']); + } + + #[Test] + public function get_metadata_returns_registered_data(): void + { + $this->provider->register_dashboard('ext-dash', [ + 'title' => 'Extension', + 'description' => 'A test dashboard', + 'json' => '{}', + 'plugin' => 'TestPlugin', + ]); + + $metadata = $this->provider->get_metadata('ext-dash'); + + $this->assertIsArray($metadata); + $this->assertSame('Extension', $metadata['title']); + $this->assertSame('third-party', $metadata['source']); + $this->assertSame('TestPlugin', $metadata['plugin']); + } + + #[Test] + public function get_metadata_returns_null_for_unknown(): void + { + $this->assertNull($this->provider->get_metadata('nonexistent')); + } +} diff --git a/tests/Unit/AuthenticationTest.php b/tests/Unit/AuthenticationTest.php new file mode 100644 index 0000000..3f2e465 --- /dev/null +++ b/tests/Unit/AuthenticationTest.php @@ -0,0 +1,148 @@ +originalServer = $_SERVER; + $this->originalGet = $_GET; + } + + protected function tearDown(): void + { + $_SERVER = $this->originalServer; + $_GET = $this->originalGet; + parent::tearDown(); + } + + // ── wp_prometheus_authenticate_request() ───────────────────────── + + #[Test] + public function returns_false_when_no_token_configured(): void + { + // No auth token in options → deny all. + $this->assertFalse(wp_prometheus_authenticate_request()); + } + + #[Test] + public function returns_false_when_token_is_empty_string(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = ''; + $this->assertFalse(wp_prometheus_authenticate_request()); + } + + #[Test] + public function bearer_token_authenticates_successfully(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer secret-token-123'; + + $this->assertTrue(wp_prometheus_authenticate_request()); + } + + #[Test] + public function bearer_token_fails_with_wrong_token(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer wrong-token'; + + $this->assertFalse(wp_prometheus_authenticate_request()); + } + + #[Test] + public function bearer_prefix_is_case_insensitive(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123'; + $_SERVER['HTTP_AUTHORIZATION'] = 'BEARER secret-token-123'; + + $this->assertTrue(wp_prometheus_authenticate_request()); + } + + #[Test] + public function query_parameter_authenticates_successfully(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123'; + $_GET['token'] = 'secret-token-123'; + + $this->assertTrue(wp_prometheus_authenticate_request()); + } + + #[Test] + public function query_parameter_fails_with_wrong_token(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123'; + $_GET['token'] = 'wrong-token'; + + $this->assertFalse(wp_prometheus_authenticate_request()); + } + + #[Test] + public function returns_false_when_no_auth_provided(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'secret-token-123'; + unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + unset($_GET['token']); + + $this->assertFalse(wp_prometheus_authenticate_request()); + } + + #[Test] + public function bearer_takes_precedence_over_query_parameter(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'correct-token'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer correct-token'; + $_GET['token'] = 'wrong-token'; + + $this->assertTrue(wp_prometheus_authenticate_request()); + } + + // ── wp_prometheus_get_authorization_header() ───────────────────── + + #[Test] + public function get_authorization_header_from_http_authorization(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer my-token'; + + $this->assertSame('Bearer my-token', wp_prometheus_get_authorization_header()); + } + + #[Test] + public function get_authorization_header_from_redirect(): void + { + unset($_SERVER['HTTP_AUTHORIZATION']); + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer redirect-token'; + + $this->assertSame('Bearer redirect-token', wp_prometheus_get_authorization_header()); + } + + #[Test] + public function get_authorization_header_returns_empty_when_absent(): void + { + unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + + $this->assertSame('', wp_prometheus_get_authorization_header()); + } + + #[Test] + public function http_authorization_takes_precedence_over_redirect(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer primary'; + $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] = 'Bearer redirect'; + + $this->assertSame('Bearer primary', wp_prometheus_get_authorization_header()); + } +} diff --git a/tests/Unit/Endpoint/MetricsEndpointTest.php b/tests/Unit/Endpoint/MetricsEndpointTest.php new file mode 100644 index 0000000..75f50f3 --- /dev/null +++ b/tests/Unit/Endpoint/MetricsEndpointTest.php @@ -0,0 +1,102 @@ +resetStorageFactory(); + $this->collector = new Collector(); + $this->endpoint = new MetricsEndpoint($this->collector); + } + + protected function tearDown(): void + { + $this->resetStorageFactory(); + parent::tearDown(); + } + + // ── Constructor ─────────────────────────────────────────────── + + #[Test] + public function constructor_accepts_collector(): void + { + $this->assertInstanceOf(MetricsEndpoint::class, $this->endpoint); + } + + // ── register_endpoint() ─────────────────────────────────────── + + #[Test] + public function register_endpoint_adds_rewrite_rule(): void + { + $this->endpoint->register_endpoint(); + + $this->assertSame(1, GlobalFunctionState::getCallCount('add_rewrite_rule')); + $args = GlobalFunctionState::$callArgs['add_rewrite_rule'][0]; + $this->assertSame('^metrics/?$', $args[0]); + $this->assertSame('index.php?wp_prometheus_metrics=1', $args[1]); + $this->assertSame('top', $args[2]); + } + + #[Test] + public function register_endpoint_adds_rewrite_tag(): void + { + $this->endpoint->register_endpoint(); + + $this->assertSame(1, GlobalFunctionState::getCallCount('add_rewrite_tag')); + $args = GlobalFunctionState::$callArgs['add_rewrite_tag'][0]; + $this->assertSame('%wp_prometheus_metrics%', $args[0]); + $this->assertSame('([^&]+)', $args[1]); + } + + // ── handle_request() ────────────────────────────────────────── + + #[Test] + public function handle_request_returns_early_when_no_query_var(): void + { + $wp = new \WP(); + $wp->query_vars = []; + + // Should return without calling exit or outputting anything. + $this->endpoint->handle_request($wp); + + // If we reach this assertion, handle_request returned early (no exit). + $this->assertTrue(true); + } + + #[Test] + public function handle_request_returns_early_when_query_var_empty(): void + { + $wp = new \WP(); + $wp->query_vars = ['wp_prometheus_metrics' => '']; + + $this->endpoint->handle_request($wp); + + $this->assertTrue(true); + } + + // ── Helpers ────────────────────────────────────────────────── + + private function resetStorageFactory(): void + { + $reflection = new \ReflectionClass(StorageFactory::class); + $property = $reflection->getProperty('instance'); + $property->setValue(null, null); + } +} diff --git a/tests/Unit/InstallerTest.php b/tests/Unit/InstallerTest.php new file mode 100644 index 0000000..461be1d --- /dev/null +++ b/tests/Unit/InstallerTest.php @@ -0,0 +1,189 @@ +assertArrayHasKey( + 'wp_prometheus_activated', + GlobalFunctionState::$options + ); + $this->assertIsInt(GlobalFunctionState::$options['wp_prometheus_activated']); + } + + #[Test] + public function activate_flushes_rewrite_rules(): void + { + Installer::activate(); + + $this->assertGreaterThanOrEqual( + 1, + GlobalFunctionState::getCallCount('flush_rewrite_rules') + ); + } + + #[Test] + public function activate_generates_auth_token_when_not_set(): void + { + Installer::activate(); + + $this->assertArrayHasKey( + 'wp_prometheus_auth_token', + GlobalFunctionState::$options + ); + $this->assertNotEmpty(GlobalFunctionState::$options['wp_prometheus_auth_token']); + } + + #[Test] + public function activate_preserves_existing_auth_token(): void + { + GlobalFunctionState::$options['wp_prometheus_auth_token'] = 'existing-token'; + + Installer::activate(); + + $this->assertSame( + 'existing-token', + GlobalFunctionState::$options['wp_prometheus_auth_token'] + ); + } + + #[Test] + public function activate_enables_default_metrics(): void + { + Installer::activate(); + + $this->assertSame( + 1, + GlobalFunctionState::$options['wp_prometheus_enable_default_metrics'] + ); + } + + #[Test] + public function activate_sets_default_enabled_metrics_list(): void + { + Installer::activate(); + + $enabled = GlobalFunctionState::$options['wp_prometheus_enabled_metrics']; + $this->assertIsArray($enabled); + $this->assertContains('wordpress_info', $enabled); + $this->assertContains('wordpress_users_total', $enabled); + $this->assertContains('wordpress_posts_total', $enabled); + $this->assertContains('wordpress_comments_total', $enabled); + $this->assertContains('wordpress_plugins_total', $enabled); + } + + #[Test] + public function activate_preserves_existing_enabled_metrics(): void + { + $custom = ['wordpress_info', 'wordpress_users_total']; + GlobalFunctionState::$options['wp_prometheus_enabled_metrics'] = $custom; + + Installer::activate(); + + $this->assertSame( + $custom, + GlobalFunctionState::$options['wp_prometheus_enabled_metrics'] + ); + } + + // ── deactivate() ───────────────────────────────────────────────── + + #[Test] + public function deactivate_flushes_rewrite_rules(): void + { + Installer::deactivate(); + + $this->assertGreaterThanOrEqual( + 1, + GlobalFunctionState::getCallCount('flush_rewrite_rules') + ); + } + + // ── uninstall() ────────────────────────────────────────────────── + + #[Test] + public function uninstall_removes_all_plugin_options(): void + { + // Set up options that should be cleaned. + $expected_options = [ + 'wp_prometheus_activated', + 'wp_prometheus_license_key', + 'wp_prometheus_license_server_url', + 'wp_prometheus_license_server_secret', + 'wp_prometheus_license_status', + 'wp_prometheus_license_data', + 'wp_prometheus_license_last_check', + 'wp_prometheus_auth_token', + 'wp_prometheus_enable_default_metrics', + 'wp_prometheus_enabled_metrics', + 'wp_prometheus_runtime_metrics', + 'wp_prometheus_custom_metrics', + 'wp_prometheus_isolated_mode', + 'wp_prometheus_storage_adapter', + 'wp_prometheus_redis_host', + 'wp_prometheus_redis_port', + 'wp_prometheus_redis_password', + 'wp_prometheus_redis_database', + 'wp_prometheus_redis_prefix', + 'wp_prometheus_apcu_prefix', + ]; + + foreach ($expected_options as $option) { + GlobalFunctionState::$options[$option] = 'test_value'; + } + + Installer::uninstall(); + + // Verify all options were deleted. + foreach ($expected_options as $option) { + $this->assertArrayNotHasKey( + $option, + GlobalFunctionState::$options, + "Option '$option' was not deleted during uninstall" + ); + } + } + + #[Test] + public function uninstall_removes_license_transient(): void + { + Installer::uninstall(); + + $this->assertGreaterThanOrEqual( + 1, + GlobalFunctionState::getCallCount('delete_transient') + ); + + // Verify the specific transient was targeted. + $args = GlobalFunctionState::$callArgs['delete_transient'] ?? []; + $transientNames = array_column($args, 0); + $this->assertContains('wp_prometheus_license_check', $transientNames); + } + + #[Test] + public function uninstall_delete_option_call_count_matches(): void + { + Installer::uninstall(); + + // Should call delete_option for each option in the list (20 options). + $this->assertSame( + 20, + GlobalFunctionState::getCallCount('delete_option') + ); + } +} diff --git a/tests/Unit/Metrics/CollectorTest.php b/tests/Unit/Metrics/CollectorTest.php new file mode 100644 index 0000000..1c82ce3 --- /dev/null +++ b/tests/Unit/Metrics/CollectorTest.php @@ -0,0 +1,155 @@ +resetStorageFactory(); + $this->collector = new Collector(); + } + + protected function tearDown(): void + { + $this->resetRuntimeCollectorSingleton(); + $this->resetStorageFactory(); + parent::tearDown(); + } + + // ── Constructor & Basic Properties ───────────────────────────── + + #[Test] + public function constructor_creates_registry(): void + { + $this->assertInstanceOf(CollectorRegistry::class, $this->collector->get_registry()); + } + + #[Test] + public function get_namespace_returns_wordpress(): void + { + $this->assertSame('wordpress', $this->collector->get_namespace()); + } + + // ── register_gauge() ────────────────────────────────────────── + + #[Test] + public function register_gauge_returns_gauge_instance(): void + { + $gauge = $this->collector->register_gauge('test_metric', 'A test gauge'); + $this->assertInstanceOf(Gauge::class, $gauge); + } + + #[Test] + public function register_gauge_with_labels(): void + { + $gauge = $this->collector->register_gauge( + 'labeled_metric', + 'A labeled gauge', + ['label1', 'label2'] + ); + $this->assertInstanceOf(Gauge::class, $gauge); + } + + // ── register_counter() ──────────────────────────────────────── + + #[Test] + public function register_counter_returns_counter_instance(): void + { + $counter = $this->collector->register_counter('test_counter', 'A test counter'); + $this->assertInstanceOf(Counter::class, $counter); + } + + #[Test] + public function register_counter_with_labels(): void + { + $counter = $this->collector->register_counter( + 'labeled_counter', + 'A labeled counter', + ['method', 'status'] + ); + $this->assertInstanceOf(Counter::class, $counter); + } + + // ── register_histogram() ────────────────────────────────────── + + #[Test] + public function register_histogram_returns_histogram_instance(): void + { + $histogram = $this->collector->register_histogram('test_histogram', 'A test histogram'); + $this->assertInstanceOf(Histogram::class, $histogram); + } + + #[Test] + public function register_histogram_with_custom_buckets(): void + { + $buckets = [0.1, 0.5, 1.0, 5.0]; + $histogram = $this->collector->register_histogram( + 'custom_buckets_hist', + 'A histogram with custom buckets', + ['label1'], + $buckets + ); + $this->assertInstanceOf(Histogram::class, $histogram); + } + + // ── render() ────────────────────────────────────────────────── + + #[Test] + public function render_returns_string(): void + { + $getOption = $this->getFunctionMock('Magdev\\WpPrometheus\\Metrics', 'get_option'); + $getOption->expects($this->any())->willReturn([]); + + $output = $this->collector->render(); + $this->assertIsString($output); + } + + #[Test] + public function render_includes_registered_gauge_value(): void + { + $getOption = $this->getFunctionMock('Magdev\\WpPrometheus\\Metrics', 'get_option'); + $getOption->expects($this->any())->willReturn([]); + + $gauge = $this->collector->register_gauge('test_render_metric', 'Test metric for render'); + $gauge->set(42, []); + + $output = $this->collector->render(); + + $this->assertStringContainsString('wordpress_test_render_metric', $output); + $this->assertStringContainsString('42', $output); + } + + // ── Helpers ────────────────────────────────────────────────── + + private function resetRuntimeCollectorSingleton(): void + { + $reflection = new \ReflectionClass(RuntimeCollector::class); + $property = $reflection->getProperty('instance'); + $property->setValue(null, null); + } + + private function resetStorageFactory(): void + { + $reflection = new \ReflectionClass(StorageFactory::class); + $property = $reflection->getProperty('instance'); + $property->setValue(null, null); + } +} diff --git a/tests/Unit/Metrics/CustomMetricBuilderTest.php b/tests/Unit/Metrics/CustomMetricBuilderTest.php new file mode 100644 index 0000000..b822a9f --- /dev/null +++ b/tests/Unit/Metrics/CustomMetricBuilderTest.php @@ -0,0 +1,655 @@ +builder = new CustomMetricBuilder(); + } + + // ── validate_name() ────────────────────────────────────────────── + + #[Test] + #[DataProvider('validMetricNamesProvider')] + public function validate_name_accepts_valid_names(string $name): void + { + $this->assertTrue($this->builder->validate_name($name)); + } + + public static function validMetricNamesProvider(): array + { + return [ + 'simple' => ['my_metric'], + 'with_colon' => ['my:metric'], + 'starts_with_underscore' => ['_private_metric'], + 'starts_with_colon' => [':special_metric'], + 'uppercase' => ['MY_METRIC'], + 'mixed_case' => ['myMetric123'], + 'single_letter' => ['m'], + ]; + } + + #[Test] + #[DataProvider('invalidMetricNamesProvider')] + public function validate_name_rejects_invalid_names(string $name): void + { + $this->assertFalse($this->builder->validate_name($name)); + } + + public static function invalidMetricNamesProvider(): array + { + return [ + 'starts_with_digit' => ['0metric'], + 'contains_dash' => ['my-metric'], + 'contains_space' => ['my metric'], + 'contains_dot' => ['my.metric'], + 'empty_string' => [''], + 'special_chars' => ['metric@name'], + ]; + } + + // ── validate_label_name() ──────────────────────────────────────── + + #[Test] + #[DataProvider('validLabelNamesProvider')] + public function validate_label_name_accepts_valid_names(string $name): void + { + $this->assertTrue($this->builder->validate_label_name($name)); + } + + public static function validLabelNamesProvider(): array + { + return [ + 'simple' => ['status'], + 'with_underscore' => ['http_method'], + 'starts_with_underscore' => ['_internal'], + 'uppercase' => ['METHOD'], + 'alphanumeric' => ['label1'], + ]; + } + + #[Test] + #[DataProvider('invalidLabelNamesProvider')] + public function validate_label_name_rejects_invalid_names(string $name): void + { + $this->assertFalse($this->builder->validate_label_name($name)); + } + + public static function invalidLabelNamesProvider(): array + { + return [ + 'double_underscore_prefix' => ['__reserved'], + 'starts_with_digit' => ['1label'], + 'contains_colon' => ['label:name'], + 'contains_dash' => ['label-name'], + 'empty_string' => [''], + 'contains_space' => ['label name'], + ]; + } + + // ── validate() ─────────────────────────────────────────────────── + + #[Test] + public function validate_returns_empty_for_valid_metric(): void + { + $errors = $this->builder->validate($this->validMetric()); + $this->assertEmpty($errors); + } + + #[Test] + public function validate_requires_name(): void + { + $metric = $this->validMetric(); + $metric['name'] = ''; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('name is required', $errors[0]); + } + + #[Test] + public function validate_rejects_invalid_metric_name(): void + { + $metric = $this->validMetric(); + $metric['name'] = '0invalid'; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('must start with', $errors[0]); + } + + #[Test] + #[DataProvider('reservedPrefixProvider')] + public function validate_rejects_reserved_prefix(string $prefix): void + { + $metric = $this->validMetric(); + $metric['name'] = $prefix . 'test'; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('reserved prefix', implode(' ', $errors)); + } + + public static function reservedPrefixProvider(): array + { + return [ + 'wordpress_' => ['wordpress_'], + 'go_' => ['go_'], + 'process_' => ['process_'], + 'promhttp_' => ['promhttp_'], + ]; + } + + #[Test] + public function validate_detects_duplicate_name(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [ + 'existing-id' => [ + 'id' => 'existing-id', + 'name' => 'custom_existing', + 'help' => 'test', + ], + ]; + + $metric = $this->validMetric(); + $metric['name'] = 'custom_existing'; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('already exists', implode(' ', $errors)); + } + + #[Test] + public function validate_allows_same_name_when_editing(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [ + 'my-id' => [ + 'id' => 'my-id', + 'name' => 'custom_existing', + 'help' => 'test', + ], + ]; + + $metric = $this->validMetric(); + $metric['id'] = 'my-id'; + $metric['name'] = 'custom_existing'; + + $errors = $this->builder->validate($metric); + $this->assertEmpty($errors); + } + + #[Test] + public function validate_requires_help_text(): void + { + $metric = $this->validMetric(); + $metric['help'] = ''; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Help text is required', $errors[0]); + } + + #[Test] + public function validate_requires_valid_type(): void + { + $metric = $this->validMetric(); + $metric['type'] = 'counter'; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Invalid metric type', implode(' ', $errors)); + } + + #[Test] + public function validate_rejects_too_many_labels(): void + { + $metric = $this->validMetric(); + $metric['labels'] = ['a', 'b', 'c', 'd', 'e', 'f']; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Maximum', implode(' ', $errors)); + } + + #[Test] + public function validate_rejects_invalid_label_names_in_array(): void + { + $metric = $this->validMetric(); + $metric['labels'] = ['valid', '__reserved']; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Invalid label name', implode(' ', $errors)); + } + + #[Test] + public function validate_rejects_non_array_labels(): void + { + $metric = $this->validMetric(); + $metric['labels'] = 'not_an_array'; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Labels must be an array', implode(' ', $errors)); + } + + #[Test] + public function validate_requires_valid_value_type(): void + { + $metric = $this->validMetric(); + $metric['value_type'] = 'invalid'; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Invalid value type', implode(' ', $errors)); + } + + #[Test] + public function validate_requires_option_name_for_option_type(): void + { + $metric = $this->validMetric(); + $metric['value_type'] = 'option'; + $metric['value_config'] = []; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Option name is required', implode(' ', $errors)); + } + + #[Test] + public function validate_accepts_option_type_with_option_name(): void + { + $metric = $this->validMetric(); + $metric['value_type'] = 'option'; + $metric['value_config'] = ['option_name' => 'my_wp_option']; + + $errors = $this->builder->validate($metric); + $this->assertEmpty($errors); + } + + #[Test] + public function validate_rejects_too_many_label_values(): void + { + $metric = $this->validMetric(); + $metric['labels'] = ['status']; + $metric['label_values'] = array_fill(0, 51, ['active', 1.0]); + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Maximum', implode(' ', $errors)); + } + + #[Test] + public function validate_checks_label_value_row_count(): void + { + $metric = $this->validMetric(); + $metric['labels'] = ['status', 'type']; + // Row has 2 items but needs 3 (2 labels + 1 value). + $metric['label_values'] = [['active', 1.0]]; + + $errors = $this->builder->validate($metric); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('values for all labels', implode(' ', $errors)); + } + + // ── get_all() / get() ──────────────────────────────────────────── + + #[Test] + public function get_all_returns_empty_array_by_default(): void + { + $this->assertSame([], $this->builder->get_all()); + } + + #[Test] + public function get_all_returns_stored_metrics(): void + { + $metrics = ['id1' => ['name' => 'test_metric']]; + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = $metrics; + + $this->assertSame($metrics, $this->builder->get_all()); + } + + #[Test] + public function get_all_returns_empty_when_option_is_not_array(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = 'not_an_array'; + $this->assertSame([], $this->builder->get_all()); + } + + #[Test] + public function get_returns_metric_by_id(): void + { + $metric = ['id' => 'my-id', 'name' => 'test_metric', 'help' => 'Test']; + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = ['my-id' => $metric]; + + $this->assertSame($metric, $this->builder->get('my-id')); + } + + #[Test] + public function get_returns_null_for_nonexistent_id(): void + { + $this->assertNull($this->builder->get('nonexistent')); + } + + // ── save() ─────────────────────────────────────────────────────── + + #[Test] + public function save_creates_new_metric_and_returns_id(): void + { + $metric = $this->validMetric(); + $id = $this->builder->save($metric); + + $this->assertNotEmpty($id); + $this->assertGreaterThanOrEqual(1, GlobalFunctionState::getCallCount('update_option')); + + $saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME]; + $this->assertArrayHasKey($id, $saved); + $this->assertSame('custom_test_metric', $saved[$id]['name']); + } + + #[Test] + public function save_throws_on_validation_failure(): void + { + $metric = [ + 'name' => '', + 'help' => '', + 'type' => 'gauge', + 'value_type' => 'static', + ]; + + $this->expectException(\InvalidArgumentException::class); + $this->builder->save($metric); + } + + #[Test] + public function save_updates_existing_metric(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [ + 'existing-id' => [ + 'id' => 'existing-id', + 'name' => 'custom_test_metric', + 'help' => 'Original help', + 'type' => 'gauge', + 'value_type' => 'static', + 'labels' => [], + 'created_at' => 1000000, + ], + ]; + + $metric = $this->validMetric(); + $metric['id'] = 'existing-id'; + $metric['help'] = 'Updated help'; + + $id = $this->builder->save($metric); + + $this->assertSame('existing-id', $id); + $saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME]; + $this->assertSame('Updated help', $saved['existing-id']['help']); + } + + #[Test] + public function save_sanitizes_metric_data(): void + { + $metric = $this->validMetric(); + $metric['labels'] = ['valid_label']; + $metric['label_values'] = [['active', 42.5]]; + + $id = $this->builder->save($metric); + $saved = GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME][$id]; + + $this->assertSame(['valid_label'], $saved['labels']); + $this->assertSame([['active', 42.5]], $saved['label_values']); + $this->assertIsBool($saved['enabled']); + } + + // ── delete() ───────────────────────────────────────────────────── + + #[Test] + public function delete_removes_metric(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [ + 'my-id' => ['id' => 'my-id', 'name' => 'custom_metric'], + ]; + + $this->assertTrue($this->builder->delete('my-id')); + $this->assertArrayNotHasKey( + 'my-id', + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] + ); + } + + #[Test] + public function delete_returns_false_for_nonexistent(): void + { + $this->assertFalse($this->builder->delete('nonexistent')); + } + + // ── export() ───────────────────────────────────────────────────── + + #[Test] + public function export_returns_valid_json_with_metadata(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [ + 'id1' => ['name' => 'metric1', 'help' => 'Test 1'], + ]; + + $json = $this->builder->export(); + $data = json_decode($json, true); + + $this->assertIsArray($data); + $this->assertSame(CustomMetricBuilder::EXPORT_VERSION, $data['version']); + $this->assertSame(WP_PROMETHEUS_VERSION, $data['plugin_version']); + $this->assertArrayHasKey('exported_at', $data); + $this->assertCount(1, $data['metrics']); + } + + #[Test] + public function export_does_not_include_site_url(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = []; + + $json = $this->builder->export(); + $data = json_decode($json, true); + + $this->assertArrayNotHasKey('site_url', $data); + } + + // ── import() ───────────────────────────────────────────────────── + + #[Test] + public function import_rejects_oversized_json(): void + { + $json = str_repeat('x', CustomMetricBuilder::MAX_IMPORT_SIZE + 1); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('exceeds maximum size'); + $this->builder->import($json); + } + + #[Test] + public function import_rejects_invalid_json(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JSON'); + $this->builder->import('{invalid json'); + } + + #[Test] + public function import_rejects_missing_metrics_key(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No metrics found'); + $this->builder->import('{"version":"1.0.0"}'); + } + + #[Test] + public function import_skip_mode_skips_duplicates(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [ + 'existing' => [ + 'id' => 'existing', + 'name' => 'custom_existing_metric', + 'help' => 'Test', + ], + ]; + + $json = json_encode([ + 'version' => '1.0.0', + 'metrics' => [ + [ + 'name' => 'custom_existing_metric', + 'help' => 'Test', + 'type' => 'gauge', + 'value_type' => 'static', + ], + ], + ]); + + $result = $this->builder->import($json, 'skip'); + $this->assertSame(1, $result['skipped']); + $this->assertSame(0, $result['imported']); + } + + #[Test] + public function import_rename_mode_renames_duplicates(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [ + 'existing' => [ + 'id' => 'existing', + 'name' => 'custom_existing_metric', + 'help' => 'Existing', + 'type' => 'gauge', + 'value_type' => 'static', + 'labels' => [], + ], + ]; + + $json = json_encode([ + 'version' => '1.0.0', + 'metrics' => [ + [ + 'name' => 'custom_existing_metric', + 'help' => 'Imported', + 'type' => 'gauge', + 'value_type' => 'static', + 'labels' => [], + ], + ], + ]); + + $result = $this->builder->import($json, 'rename'); + $this->assertSame(1, $result['imported']); + + $all = $this->builder->get_all(); + $names = array_column($all, 'name'); + $this->assertContains('custom_existing_metric', $names); + $this->assertContains('custom_existing_metric_imported_1', $names); + } + + #[Test] + public function import_invalid_mode_defaults_to_skip(): void + { + GlobalFunctionState::$options[CustomMetricBuilder::OPTION_NAME] = [ + 'existing' => [ + 'id' => 'existing', + 'name' => 'custom_existing_metric', + 'help' => 'Test', + ], + ]; + + $json = json_encode([ + 'version' => '1.0.0', + 'metrics' => [ + [ + 'name' => 'custom_existing_metric', + 'help' => 'Test', + 'type' => 'gauge', + 'value_type' => 'static', + ], + ], + ]); + + $result = $this->builder->import($json, 'invalid_mode'); + $this->assertSame(1, $result['skipped']); + } + + #[Test] + public function import_counts_metrics_without_name_as_errors(): void + { + $json = json_encode([ + 'version' => '1.0.0', + 'metrics' => [['help' => 'No name']], + ]); + + $result = $this->builder->import($json); + $this->assertSame(1, $result['errors']); + $this->assertSame(0, $result['imported']); + } + + #[Test] + public function import_successfully_imports_new_metric(): void + { + $json = json_encode([ + 'version' => '1.0.0', + 'metrics' => [ + [ + 'name' => 'custom_new_metric', + 'help' => 'A new metric', + 'type' => 'gauge', + 'value_type' => 'static', + 'labels' => [], + ], + ], + ]); + + $result = $this->builder->import($json); + $this->assertSame(1, $result['imported']); + $this->assertSame(0, $result['skipped']); + $this->assertSame(0, $result['errors']); + + $all = $this->builder->get_all(); + $names = array_column($all, 'name'); + $this->assertContains('custom_new_metric', $names); + } + + // ── Constants ──────────────────────────────────────────────────── + + #[Test] + public function constants_are_defined(): void + { + $this->assertSame('wp_prometheus_custom_metrics', CustomMetricBuilder::OPTION_NAME); + $this->assertSame(5, CustomMetricBuilder::MAX_LABELS); + $this->assertSame(50, CustomMetricBuilder::MAX_LABEL_VALUES); + $this->assertSame(1048576, CustomMetricBuilder::MAX_IMPORT_SIZE); + } + + // ── Helpers ────────────────────────────────────────────────────── + + private function validMetric(): array + { + return [ + 'name' => 'custom_test_metric', + 'help' => 'A test metric', + 'type' => 'gauge', + 'value_type' => 'static', + 'labels' => [], + 'label_values' => [], + 'enabled' => true, + ]; + } +} diff --git a/tests/Unit/Metrics/RuntimeCollectorTest.php b/tests/Unit/Metrics/RuntimeCollectorTest.php new file mode 100644 index 0000000..f6d21ae --- /dev/null +++ b/tests/Unit/Metrics/RuntimeCollectorTest.php @@ -0,0 +1,234 @@ +originalServer = $_SERVER; + $this->collector = $this->createInstance(); + } + + protected function tearDown(): void + { + $_SERVER = $this->originalServer; + $this->resetSingleton(); + parent::tearDown(); + } + + // ── Singleton ──────────────────────────────────────────────────── + + #[Test] + public function get_instance_returns_singleton(): void + { + $instance1 = RuntimeCollector::get_instance(); + $instance2 = RuntimeCollector::get_instance(); + + $this->assertSame($instance1, $instance2); + } + + #[Test] + public function get_instance_returns_runtime_collector(): void + { + $instance = RuntimeCollector::get_instance(); + $this->assertInstanceOf(RuntimeCollector::class, $instance); + } + + // ── get_stored_metrics() ───────────────────────────────────────── + + #[Test] + public function get_stored_metrics_returns_default_when_empty(): void + { + $metrics = $this->collector->get_stored_metrics(); + $this->assertSame([], $metrics); + } + + #[Test] + public function get_stored_metrics_returns_stored_data(): void + { + $stored = [ + 'counters' => ['key' => ['name' => 'test', 'value' => 5]], + 'histograms' => [], + 'last_reset' => 1000000, + ]; + GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = $stored; + + $metrics = $this->collector->get_stored_metrics(); + $this->assertSame($stored, $metrics); + } + + #[Test] + public function get_stored_metrics_returns_default_for_non_array(): void + { + GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = 'invalid'; + + $metrics = $this->collector->get_stored_metrics(); + $this->assertArrayHasKey('counters', $metrics); + $this->assertArrayHasKey('histograms', $metrics); + $this->assertArrayHasKey('last_reset', $metrics); + } + + // ── reset_metrics() ────────────────────────────────────────────── + + #[Test] + public function reset_metrics_deletes_option(): void + { + GlobalFunctionState::$options['wp_prometheus_runtime_metrics'] = ['data']; + + RuntimeCollector::reset_metrics(); + + $this->assertSame(1, GlobalFunctionState::getCallCount('delete_option')); + $this->assertArrayNotHasKey( + 'wp_prometheus_runtime_metrics', + GlobalFunctionState::$options + ); + } + + // ── get_duration_buckets() ─────────────────────────────────────── + + #[Test] + public function get_duration_buckets_returns_expected_values(): void + { + $buckets = RuntimeCollector::get_duration_buckets(); + + $this->assertIsArray($buckets); + $this->assertCount(11, $buckets); + $this->assertSame(0.005, $buckets[0]); + $this->assertEquals(10, end($buckets)); + } + + #[Test] + public function duration_buckets_are_in_ascending_order(): void + { + $buckets = RuntimeCollector::get_duration_buckets(); + $sorted = $buckets; + sort($sorted); + + $this->assertSame($sorted, $buckets); + } + + // ── get_normalized_endpoint() (private, via reflection) ────────── + + #[Test] + public function normalized_endpoint_returns_admin_when_is_admin(): void + { + $_SERVER['REQUEST_URI'] = '/wp-admin/options-general.php'; + GlobalFunctionState::$options['__is_admin'] = true; + + $result = $this->callPrivateMethod('get_normalized_endpoint'); + $this->assertSame('admin', $result); + } + + #[Test] + public function normalized_endpoint_returns_ajax_when_doing_ajax(): void + { + $_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php'; + GlobalFunctionState::$options['__wp_doing_ajax'] = true; + + $result = $this->callPrivateMethod('get_normalized_endpoint'); + $this->assertSame('ajax', $result); + } + + #[Test] + public function normalized_endpoint_returns_cron_when_doing_cron(): void + { + // Use a generic URI (not /wp-cron.php) to ensure function is checked, not URL pattern. + $_SERVER['REQUEST_URI'] = '/some-page'; + GlobalFunctionState::$options['__wp_doing_cron'] = true; + + $result = $this->callPrivateMethod('get_normalized_endpoint'); + $this->assertSame('cron', $result); + } + + #[Test] + #[DataProvider('urlEndpointProvider')] + public function normalized_endpoint_from_url_pattern(string $uri, string $expected): void + { + $_SERVER['REQUEST_URI'] = $uri; + + $result = $this->callPrivateMethod('get_normalized_endpoint'); + $this->assertSame($expected, $result); + } + + public static function urlEndpointProvider(): array + { + return [ + 'rest api' => ['/wp-json/wp/v2/posts', 'rest-api'], + 'login page' => ['/wp-login.php', 'login'], + 'login with query' => ['/wp-login.php?action=login', 'login'], + 'wp-cron' => ['/wp-cron.php', 'cron'], + 'feed root' => ['/feed/', 'feed'], + 'feed trailing' => ['/category/news/feed', 'feed'], + 'feed with slash' => ['/feed', 'feed'], + 'homepage' => ['/', 'frontend'], + 'page' => ['/about-us', 'frontend'], + 'post' => ['/2024/01/hello-world', 'frontend'], + ]; + } + + // ── is_metrics_request() (private, via reflection) ─────────────── + + #[Test] + public function is_metrics_request_true_for_metrics_uri(): void + { + $_SERVER['REQUEST_URI'] = '/metrics'; + $this->assertTrue($this->callPrivateMethod('is_metrics_request')); + } + + #[Test] + public function is_metrics_request_true_with_trailing_slash(): void + { + $_SERVER['REQUEST_URI'] = '/metrics/'; + $this->assertTrue($this->callPrivateMethod('is_metrics_request')); + } + + #[Test] + public function is_metrics_request_false_for_other_uri(): void + { + $_SERVER['REQUEST_URI'] = '/some-page'; + $this->assertFalse($this->callPrivateMethod('is_metrics_request')); + } + + #[Test] + public function is_metrics_request_false_when_no_uri(): void + { + unset($_SERVER['REQUEST_URI']); + $this->assertFalse($this->callPrivateMethod('is_metrics_request')); + } + + // ── Helpers ────────────────────────────────────────────────────── + + private function createInstance(): RuntimeCollector + { + $reflection = new \ReflectionClass(RuntimeCollector::class); + return $reflection->newInstanceWithoutConstructor(); + } + + private function resetSingleton(): void + { + $reflection = new \ReflectionClass(RuntimeCollector::class); + $property = $reflection->getProperty('instance'); + $property->setValue(null, null); + } + + private function callPrivateMethod(string $method, array $args = []): mixed + { + $reflection = new \ReflectionMethod($this->collector, $method); + return $reflection->invoke($this->collector, ...$args); + } +} diff --git a/tests/Unit/Metrics/StorageFactoryTest.php b/tests/Unit/Metrics/StorageFactoryTest.php new file mode 100644 index 0000000..4907c61 --- /dev/null +++ b/tests/Unit/Metrics/StorageFactoryTest.php @@ -0,0 +1,411 @@ + Environment variables to clean up after each test. */ + private array $envVarsToClean = []; + + protected function setUp(): void + { + parent::setUp(); + StorageFactory::reset(); + } + + protected function tearDown(): void + { + StorageFactory::reset(); + foreach ($this->envVarsToClean as $var) { + putenv($var); + } + $this->envVarsToClean = []; + parent::tearDown(); + } + + // ── Adapter Constants ──────────────────────────────────────────── + + #[Test] + public function adapter_constants_are_defined(): void + { + $this->assertSame('inmemory', StorageFactory::ADAPTER_INMEMORY); + $this->assertSame('redis', StorageFactory::ADAPTER_REDIS); + $this->assertSame('apcu', StorageFactory::ADAPTER_APCU); + } + + // ── get_available_adapters() ───────────────────────────────────── + + #[Test] + public function get_available_adapters_returns_all_three(): void + { + $adapters = StorageFactory::get_available_adapters(); + + $this->assertArrayHasKey(StorageFactory::ADAPTER_INMEMORY, $adapters); + $this->assertArrayHasKey(StorageFactory::ADAPTER_REDIS, $adapters); + $this->assertArrayHasKey(StorageFactory::ADAPTER_APCU, $adapters); + $this->assertCount(3, $adapters); + } + + // ── is_adapter_available() ─────────────────────────────────────── + + #[Test] + public function inmemory_adapter_is_always_available(): void + { + $this->assertTrue(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_INMEMORY)); + } + + #[Test] + public function unknown_adapter_is_not_available(): void + { + $this->assertFalse(StorageFactory::is_adapter_available('unknown')); + } + + #[Test] + public function redis_availability_depends_on_extension(): void + { + $extensionLoaded = $this->getFunctionMock( + 'Magdev\\WpPrometheus\\Metrics', + 'extension_loaded' + ); + $extensionLoaded->expects($this->any())->willReturn(false); + + $this->assertFalse(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_REDIS)); + } + + #[Test] + public function apcu_availability_requires_extension_and_enabled(): void + { + $extensionLoaded = $this->getFunctionMock( + 'Magdev\\WpPrometheus\\Metrics', + 'extension_loaded' + ); + $extensionLoaded->expects($this->any())->willReturn(true); + + $apcuEnabled = $this->getFunctionMock( + 'Magdev\\WpPrometheus\\Metrics', + 'apcu_enabled' + ); + $apcuEnabled->expects($this->any())->willReturn(false); + + $this->assertFalse(StorageFactory::is_adapter_available(StorageFactory::ADAPTER_APCU)); + } + + // ── get_configured_adapter() ───────────────────────────────────── + + #[Test] + public function default_configured_adapter_is_inmemory(): void + { + $this->assertSame('inmemory', StorageFactory::get_configured_adapter()); + } + + #[Test] + public function configured_adapter_reads_from_env_var(): void + { + $this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis'); + + $this->assertSame('redis', StorageFactory::get_configured_adapter()); + } + + #[Test] + public function configured_adapter_reads_from_option(): void + { + GlobalFunctionState::$options['wp_prometheus_storage_adapter'] = 'apcu'; + + $this->assertSame('apcu', StorageFactory::get_configured_adapter()); + } + + #[Test] + public function env_var_takes_precedence_over_option(): void + { + $this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis'); + GlobalFunctionState::$options['wp_prometheus_storage_adapter'] = 'apcu'; + + $this->assertSame('redis', StorageFactory::get_configured_adapter()); + } + + #[Test] + public function configured_adapter_lowercases_env_value(): void + { + $this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'REDIS'); + + $this->assertSame('redis', StorageFactory::get_configured_adapter()); + } + + // ── get_redis_config() ─────────────────────────────────────────── + + #[Test] + public function get_redis_config_returns_defaults(): void + { + $config = StorageFactory::get_redis_config(); + + $this->assertSame('127.0.0.1', $config['host']); + $this->assertSame(6379, $config['port']); + $this->assertSame('', $config['password']); + $this->assertSame(0, $config['database']); + $this->assertSame('WORDPRESS_PROMETHEUS_', $config['prefix']); + } + + #[Test] + public function get_redis_config_reads_from_env_vars(): void + { + $this->setEnv('WP_PROMETHEUS_REDIS_HOST', '10.0.0.1'); + $this->setEnv('WP_PROMETHEUS_REDIS_PORT', '6380'); + $this->setEnv('WP_PROMETHEUS_REDIS_PASSWORD', 's3cret'); + $this->setEnv('WP_PROMETHEUS_REDIS_DATABASE', '2'); + $this->setEnv('WP_PROMETHEUS_REDIS_PREFIX', 'MY_PREFIX_'); + + $config = StorageFactory::get_redis_config(); + + $this->assertSame('10.0.0.1', $config['host']); + $this->assertSame(6380, $config['port']); + $this->assertSame('s3cret', $config['password']); + $this->assertSame(2, $config['database']); + $this->assertSame('MY_PREFIX_', $config['prefix']); + } + + #[Test] + public function get_redis_config_reads_from_option(): void + { + GlobalFunctionState::$options['wp_prometheus_redis_config'] = [ + 'host' => '192.168.1.1', + 'port' => 6381, + 'password' => 'optpass', + 'database' => 3, + 'prefix' => 'OPT_', + ]; + + $config = StorageFactory::get_redis_config(); + + $this->assertSame('192.168.1.1', $config['host']); + $this->assertSame(6381, $config['port']); + $this->assertSame('optpass', $config['password']); + $this->assertSame(3, $config['database']); + $this->assertSame('OPT_', $config['prefix']); + } + + #[Test] + public function get_redis_config_env_takes_precedence(): void + { + $this->setEnv('WP_PROMETHEUS_REDIS_HOST', 'env-host'); + GlobalFunctionState::$options['wp_prometheus_redis_config'] = [ + 'host' => 'option-host', + ]; + + $config = StorageFactory::get_redis_config(); + $this->assertSame('env-host', $config['host']); + } + + // ── get_apcu_prefix() ──────────────────────────────────────────── + + #[Test] + public function get_apcu_prefix_returns_default(): void + { + $this->assertSame('wp_prom', StorageFactory::get_apcu_prefix()); + } + + #[Test] + public function get_apcu_prefix_reads_from_env(): void + { + $this->setEnv('WP_PROMETHEUS_APCU_PREFIX', 'custom_prefix'); + + $this->assertSame('custom_prefix', StorageFactory::get_apcu_prefix()); + } + + #[Test] + public function get_apcu_prefix_reads_from_option(): void + { + GlobalFunctionState::$options['wp_prometheus_apcu_prefix'] = 'opt_prefix'; + + $this->assertSame('opt_prefix', StorageFactory::get_apcu_prefix()); + } + + // ── save_config() ──────────────────────────────────────────────── + + #[Test] + public function save_config_stores_adapter(): void + { + StorageFactory::save_config(['adapter' => 'redis']); + + $this->assertSame( + 'redis', + GlobalFunctionState::$options['wp_prometheus_storage_adapter'] + ); + } + + #[Test] + public function save_config_stores_redis_config(): void + { + StorageFactory::save_config([ + 'redis' => [ + 'host' => '10.0.0.5', + 'port' => 6390, + 'password' => 'pass123', + 'database' => 1, + 'prefix' => 'TEST_', + ], + ]); + + $saved = GlobalFunctionState::$options['wp_prometheus_redis_config']; + $this->assertSame('10.0.0.5', $saved['host']); + $this->assertSame(6390, $saved['port']); + $this->assertSame('pass123', $saved['password']); + $this->assertSame(1, $saved['database']); + // sanitize_key lowercases the prefix + $this->assertSame('test_', $saved['prefix']); + } + + #[Test] + public function save_config_stores_apcu_prefix(): void + { + StorageFactory::save_config(['apcu_prefix' => 'my_apcu']); + + $this->assertSame( + 'my_apcu', + GlobalFunctionState::$options['wp_prometheus_apcu_prefix'] + ); + } + + #[Test] + public function save_config_resets_singleton(): void + { + // Get an adapter (creates singleton). + $adapter1 = StorageFactory::get_adapter(); + + // Save new config (should reset singleton). + StorageFactory::save_config(['adapter' => 'inmemory']); + + // Get adapter again — should be a new instance. + $adapter2 = StorageFactory::get_adapter(); + $this->assertNotSame($adapter1, $adapter2); + } + + // ── test_connection() ──────────────────────────────────────────── + + #[Test] + public function test_connection_inmemory_always_succeeds(): void + { + $result = StorageFactory::test_connection(StorageFactory::ADAPTER_INMEMORY); + + $this->assertTrue($result['success']); + $this->assertNotEmpty($result['message']); + } + + #[Test] + public function test_connection_unknown_adapter_fails(): void + { + $result = StorageFactory::test_connection('unknown'); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Unknown', $result['message']); + } + + #[Test] + public function test_connection_redis_fails_without_extension(): void + { + $extensionLoaded = $this->getFunctionMock( + 'Magdev\\WpPrometheus\\Metrics', + 'extension_loaded' + ); + $extensionLoaded->expects($this->any())->willReturn(false); + + $result = StorageFactory::test_connection(StorageFactory::ADAPTER_REDIS); + $this->assertFalse($result['success']); + $this->assertStringContainsString('not installed', $result['message']); + } + + #[Test] + public function test_connection_apcu_fails_without_extension(): void + { + $extensionLoaded = $this->getFunctionMock( + 'Magdev\\WpPrometheus\\Metrics', + 'extension_loaded' + ); + $extensionLoaded->expects($this->any())->willReturn(false); + + $result = StorageFactory::test_connection(StorageFactory::ADAPTER_APCU); + $this->assertFalse($result['success']); + $this->assertStringContainsString('not installed', $result['message']); + } + + // ── get_adapter() / reset() / singleton ────────────────────────── + + #[Test] + public function get_adapter_returns_inmemory_by_default(): void + { + $adapter = StorageFactory::get_adapter(); + $this->assertInstanceOf(InMemory::class, $adapter); + } + + #[Test] + public function get_adapter_returns_singleton(): void + { + $adapter1 = StorageFactory::get_adapter(); + $adapter2 = StorageFactory::get_adapter(); + + $this->assertSame($adapter1, $adapter2); + } + + #[Test] + public function reset_clears_singleton_and_error(): void + { + StorageFactory::get_adapter(); + StorageFactory::reset(); + + // After reset, get_last_error should be empty. + $this->assertEmpty(StorageFactory::get_last_error()); + + // Getting adapter again creates a new instance. + $adapter = StorageFactory::get_adapter(); + $this->assertInstanceOf(InMemory::class, $adapter); + } + + #[Test] + public function get_adapter_falls_back_to_inmemory_when_redis_unavailable(): void + { + $this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'redis'); + + $extensionLoaded = $this->getFunctionMock( + 'Magdev\\WpPrometheus\\Metrics', + 'extension_loaded' + ); + $extensionLoaded->expects($this->any())->willReturn(false); + + $adapter = StorageFactory::get_adapter(); + $this->assertInstanceOf(InMemory::class, $adapter); + $this->assertNotEmpty(StorageFactory::get_last_error()); + } + + #[Test] + public function get_adapter_falls_back_to_inmemory_when_apcu_unavailable(): void + { + $this->setEnv('WP_PROMETHEUS_STORAGE_ADAPTER', 'apcu'); + + $extensionLoaded = $this->getFunctionMock( + 'Magdev\\WpPrometheus\\Metrics', + 'extension_loaded' + ); + $extensionLoaded->expects($this->any())->willReturn(false); + + $adapter = StorageFactory::get_adapter(); + $this->assertInstanceOf(InMemory::class, $adapter); + $this->assertNotEmpty(StorageFactory::get_last_error()); + } + + // ── Helpers ────────────────────────────────────────────────────── + + private function setEnv(string $name, string $value): void + { + putenv("$name=$value"); + $this->envVarsToClean[] = $name; + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php new file mode 100644 index 0000000..ff38cb6 --- /dev/null +++ b/tests/Unit/TestCase.php @@ -0,0 +1,32 @@ + 0 ? floor(log($bytes, 1024)) : 0; + return number_format($bytes / pow(1024, $power), $decimals) . ' ' . $units[$power]; + } +} + +if (!function_exists('path_is_absolute')) { + function path_is_absolute(string $path): bool + { + return str_starts_with($path, '/') || (bool) preg_match('#^[a-zA-Z]:\\\\#', $path); + } +} + +// -- Hook functions (no-ops) -- +if (!function_exists('add_action')) { + function add_action(string $hook, $callback, int $priority = 10, int $accepted_args = 1): bool + { + return true; + } +} + +if (!function_exists('add_filter')) { + function add_filter(string $hook, $callback, int $priority = 10, int $accepted_args = 1): bool + { + return true; + } +} + +if (!function_exists('remove_all_filters')) { + function remove_all_filters(string $hook, $priority = false): bool + { + return true; + } +} + +if (!function_exists('do_action')) { + function do_action(string $hook, ...$args): void + { + } +} + +if (!function_exists('apply_filters')) { + function apply_filters(string $hook, $value, ...$args) + { + return $value; + } +} + +// -- Option functions (controllable via GlobalFunctionState) -- +if (!function_exists('get_option')) { + function get_option(string $option, $default = false) + { + if (array_key_exists($option, GlobalFunctionState::$options)) { + return GlobalFunctionState::$options[$option]; + } + return $default; + } +} + +if (!function_exists('update_option')) { + function update_option(string $option, $value, $autoload = null): bool + { + GlobalFunctionState::recordCall('update_option', $option, $value); + GlobalFunctionState::$options[$option] = $value; + return true; + } +} + +if (!function_exists('delete_option')) { + function delete_option(string $option): bool + { + GlobalFunctionState::recordCall('delete_option', $option); + unset(GlobalFunctionState::$options[$option]); + return true; + } +} + +if (!function_exists('delete_transient')) { + function delete_transient(string $transient): bool + { + GlobalFunctionState::recordCall('delete_transient', $transient); + return true; + } +} + +if (!function_exists('flush_rewrite_rules')) { + function flush_rewrite_rules(bool $hard = true): void + { + GlobalFunctionState::recordCall('flush_rewrite_rules'); + } +} + +// -- URL functions -- +if (!function_exists('home_url')) { + function home_url(string $path = ''): string + { + return 'https://example.com' . $path; + } +} + +if (!function_exists('admin_url')) { + function admin_url(string $path = ''): string + { + return 'https://example.com/wp-admin/' . $path; + } +} + +// -- Conditional functions (controllable via GlobalFunctionState) -- +if (!function_exists('is_admin')) { + function is_admin(): bool + { + return GlobalFunctionState::$options['__is_admin'] ?? false; + } +} + +if (!function_exists('wp_doing_ajax')) { + function wp_doing_ajax(): bool + { + return GlobalFunctionState::$options['__wp_doing_ajax'] ?? false; + } +} + +if (!function_exists('wp_doing_cron')) { + function wp_doing_cron(): bool + { + return GlobalFunctionState::$options['__wp_doing_cron'] ?? false; + } +} + +if (!function_exists('is_multisite')) { + function is_multisite(): bool + { + return false; + } +} + +// -- Plugin functions -- +if (!function_exists('load_plugin_textdomain')) { + function load_plugin_textdomain(string $domain, $deprecated = false, string $path = ''): bool + { + return true; + } +} + +if (!function_exists('register_activation_hook')) { + function register_activation_hook(string $file, $callback): void + { + } +} + +if (!function_exists('register_deactivation_hook')) { + function register_deactivation_hook(string $file, $callback): void + { + } +} + +// -- Rewrite functions -- +if (!function_exists('add_rewrite_rule')) { + function add_rewrite_rule(string $regex, string $redirect, string $after = 'bottom'): void + { + GlobalFunctionState::recordCall('add_rewrite_rule', $regex, $redirect, $after); + } +} + +if (!function_exists('add_rewrite_tag')) { + function add_rewrite_tag(string $tag, string $regex, string $query = ''): void + { + GlobalFunctionState::recordCall('add_rewrite_tag', $tag, $regex, $query); + } +} + +// -- HTTP functions -- +if (!function_exists('status_header')) { + function status_header(int $code, string $description = ''): void + { + } +} + +if (!function_exists('hash_equals')) { + // hash_equals is a PHP built-in, but define stub just in case. +} + +if (!function_exists('wp_rand')) { + function wp_rand(int $min = 0, int $max = 0): int + { + return random_int($min, max($min, $max ?: PHP_INT_MAX >> 1)); + } +} + +if (!function_exists('get_bloginfo')) { + function get_bloginfo(string $show = '', bool $filter = true): string + { + return match ($show) { + 'version' => '6.7', + 'language' => 'en-US', + 'name' => 'Test Site', + default => '', + }; + } +} + +if (!function_exists('current_user_can')) { + function current_user_can(string $capability, ...$args): bool + { + return true; + } +} + +if (!function_exists('deactivate_plugins')) { + function deactivate_plugins($plugins, bool $silent = false, $network_wide = null): void + { + } +} + +if (!function_exists('wp_die')) { + function wp_die($message = '', $title = '', $args = []): void + { + throw new \RuntimeException((string) $message); + } +} + +// -- Plugin global authentication functions (from wp-prometheus.php) -- +// Cannot include wp-prometheus.php directly due to constant definitions +// and side effects. These mirror the production code for testing. + +if (!function_exists('wp_prometheus_get_authorization_header')) { + function wp_prometheus_get_authorization_header(): string + { + if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + return sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])); + } + + if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + return sanitize_text_field(wp_unslash($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])); + } + + if (function_exists('apache_request_headers')) { + $headers = apache_request_headers(); + if (isset($headers['Authorization'])) { + return sanitize_text_field($headers['Authorization']); + } + } + + return ''; + } +} + +// -- WordPress core class stubs -- +if (!class_exists('WP')) { + class WP + { + public array $query_vars = []; + } +} + +if (!function_exists('wp_prometheus_authenticate_request')) { + function wp_prometheus_authenticate_request(): bool + { + $auth_token = get_option('wp_prometheus_auth_token', ''); + + // If no token is set, deny access. + if (empty($auth_token)) { + return false; + } + + // Check for Bearer token in Authorization header. + $auth_header = wp_prometheus_get_authorization_header(); + if (!empty($auth_header) && preg_match('/Bearer\s+(.*)$/i', $auth_header, $matches)) { + return hash_equals($auth_token, $matches[1]); + } + + // Check for token in query parameter. + if (isset($_GET['token']) && hash_equals($auth_token, sanitize_text_field(wp_unslash($_GET['token'])))) { + return true; + } + + return false; + } +} diff --git a/wp-prometheus.php b/wp-prometheus.php index d5af869..20acf6c 100644 --- a/wp-prometheus.php +++ b/wp-prometheus.php @@ -3,7 +3,7 @@ * Plugin Name: WP Prometheus * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus * Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics. - * Version: 0.4.8 + * Version: 0.5.0 * Requires at least: 6.4 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -199,7 +199,7 @@ wp_prometheus_early_metrics_check(); * * @var string */ -define( 'WP_PROMETHEUS_VERSION', '0.4.8' ); +define( 'WP_PROMETHEUS_VERSION', '0.5.0' ); /** * Plugin file path.