diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9d71bdc..1adb79b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,19 +13,25 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - php: [8.2, 8.3] + php: ['8.2', '8.3', '8.4', '8.5'] + laravel: ['11.*', '12.*', '13.*'] + exclude: + - php: '8.2' + laravel: '13.*' include: - laravel: 11.* testbench: ^9.0 - laravel: 12.* testbench: ^10.0 + - laravel: 13.* + testbench: ^11.0 stability: [prefer-stable] name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -40,9 +46,17 @@ jobs: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: Install dependencies + shell: bash run: | + composer validate --strict + if [ "${{ matrix.laravel }}" = "11.*" ]; then + composer config --json policy.advisories.ignore-id '{"PKSA-d5tc-s1qs-h781": "Laravel 11 compatibility tests only; production apps should use patched Laravel 12 or 13.", "PKSA-m5cs-t1y6-qpcs": "Laravel 11 compatibility tests only; production apps should use patched Laravel 12 or 13.", "PKSA-3r5d-mb8f-1qw9": "Laravel 11 compatibility tests only; production apps should use patched Laravel 12 or 13.", "PKSA-mdq4-51ck-6kdq": "Laravel 11 compatibility tests only; production apps should use patched Laravel 12 or 13."}' + fi composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction + - name: Audit dependencies + run: composer audit + - name: Execute tests - run: vendor/bin/phpunit + run: vendor/bin/phpunit --fail-on-warning --fail-on-risky --fail-on-phpunit-deprecation diff --git a/CHANGELOG.md b/CHANGELOG.md index f22effe..64fbf56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to `lockout` will be documented in this file ## [Unreleased] +## [6.1.0] - 2026-10-09 + +### Added +- Laravel 13 compatibility while retaining Laravel 11 and 12 support. +- IPv6 CIDR matching using Symfony's existing IP utilities. + +### Fixed +- Load and publish the bundled maintenance view from the package resources directory. +- Match route whitelist patterns literally except for `*` wildcards. +- Resolve named routes and API middleware before global middleware dispatch. +- Reject malformed CIDR masks instead of crashing or widening whitelist access. +- Make enable/disable commands update the active environment file atomically, preserve file permissions, fail on persistence errors, and clear stale configuration and status caches. + +### Changed +- Use patched PHPUnit 11–13 releases and PHPUnit test attributes. +- Test every supported Laravel version with PHP 8.2–8.5 in CI, and audit dependencies. +- Retain Laravel 11 compatibility tests with CI-only exceptions for four known unpatched framework advisories; recommend patched Laravel 12 or 13 for production. + +### Compatibility +- Requires PHP 8.2 or higher with Laravel 11 or 12, or PHP 8.3 or higher with Laravel 13; tested through PHP 8.5. +- Laravel 11 has reached the end of security support. Its compatibility tests allow only `PKSA-d5tc-s1qs-h781`, `PKSA-m5cs-t1y6-qpcs`, `PKSA-3r5d-mb8f-1qw9`, and `PKSA-mdq4-51ck-6kdq` in CI. The package does not change application security policy. +- Enable/disable commands now clear configuration and lockout status caches automatically. Rebuild cached configuration if your deployment uses it, and restart long-running workers or Octane processes. `--clear-cache` remains accepted for existing scripts. + ## [6.0.0] - 2025-01-XX ### Added @@ -61,10 +84,11 @@ All notable changes to `lockout` will be documented in this file - Initial release -[Unreleased]: https://github.com/rappasoft/laravel-boilerplate/compare/v5.0.0...develop -[5.0.0]: https://github.com/rappasoft/laravel-boilerplate/compare/v4.0.0...v5.0.0 -[4.0.0]: https://github.com/rappasoft/laravel-boilerplate/compare/v3.0.1...v4.0.0 -[3.0.1]: https://github.com/rappasoft/laravel-boilerplate/compare/v3.0.0...v3.0.1 -[3.0.0]: https://github.com/rappasoft/laravel-boilerplate/compare/v2.0.0...v3.0.0 -[2.0.0]: https://github.com/rappasoft/laravel-boilerplate/compare/v1.0.1...v2.0.0 -[1.0.1]: https://github.com/rappasoft/laravel-boilerplate/compare/v1.0.0...v1.0.1 +[Unreleased]: https://github.com/rappasoft/lockout/compare/v6.1.0...master +[6.1.0]: https://github.com/rappasoft/lockout/compare/v6.0.0...v6.1.0 +[6.0.0]: https://github.com/rappasoft/lockout/compare/v5.0.0...v6.0.0 +[5.0.0]: https://github.com/rappasoft/lockout/compare/v4.0.0...v5.0.0 +[4.0.0]: https://github.com/rappasoft/lockout/compare/v3.0.1...v4.0.0 +[3.0.1]: https://github.com/rappasoft/lockout/compare/v3.0...v3.0.1 +[3.0.0]: https://github.com/rappasoft/lockout/compare/v2.1...v3.0 +[2.0.0]: https://github.com/rappasoft/lockout/compare/v1.0...v2.0 diff --git a/composer.json b/composer.json index da24172..7546400 100644 --- a/composer.json +++ b/composer.json @@ -19,11 +19,11 @@ ], "require": { "php": "^8.2", - "illuminate/support": "^11.0 || ^12.0" + "illuminate/support": "^11.0 || ^12.0 || ^13.0" }, "require-dev": { - "orchestra/testbench": "^9.0 || ^10.0", - "phpunit/phpunit": "^11.0" + "orchestra/testbench": "^9.0 || ^10.0 || ^11.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0" }, "autoload": { "psr-4": { diff --git a/docs/installation.md b/docs/installation.md index 7a68613..cf83875 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -18,4 +18,11 @@ composer require rappasoft/lockout | 8.x | 3.x | | 9.x | 4.x | | 10.x | 5.x | +| 11.x | 6.x | +| 12.x | 6.x | +| 13.x | 6.1+ | + +Lockout 6 requires PHP 8.2 or higher. Laravel 13 requires PHP 8.3 or higher. + +Laravel 11 remains compatible with Lockout, but has reached the end of its security support. For production applications, use a patched Laravel 12 or 13 release. Laravel 11 compatibility tests allow only its known unpatched framework advisories: `PKSA-d5tc-s1qs-h781`, `PKSA-m5cs-t1y6-qpcs`, `PKSA-3r5d-mb8f-1qw9`, and `PKSA-mdq4-51ck-6kdq`. These exceptions are confined to CI and are not part of the package configuration. diff --git a/docs/introduction.md b/docs/introduction.md index 78f1292..f6fb913 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -52,7 +52,7 @@ APP_READ_ONLY=true ## Requirements - PHP 8.2+ -- Laravel 11.x or 12.x +- Laravel 11.x, 12.x, or 13.x (Laravel 13 requires PHP 8.3+) ## Documentation diff --git a/docs/usage.md b/docs/usage.md index 90a868e..b11eed1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -70,6 +70,8 @@ Allow specific routes to bypass lockout: Allow specific IP addresses to bypass lockout completely: +IPv4 and IPv6 addresses and CIDR ranges are supported. Malformed CIDR masks are ignored. + **Via .env:** ```bash LOCKOUT_IP_WHITELIST=127.0.0.1,192.168.1.100 @@ -166,6 +168,8 @@ Automatically detect and handle API requests differently: Whitelist routes by pattern: +`*` matches any path characters. Other characters, including dots, are matched literally. + ```php 'route_patterns' => [ 'api/*', // All API routes @@ -178,6 +182,8 @@ Whitelist routes by pattern: Whitelist routes by name: +Names are matched against the route for the current request, including when Lockout runs as global middleware. + ```php 'route_names' => [ 'health.check', @@ -234,6 +240,8 @@ php artisan cache:clear Or when enabling/disabling: +The enable and disable commands automatically clear the lockout status cache. `--clear-cache` remains accepted for existing scripts. + ```bash php artisan lockout:enable --clear-cache php artisan lockout:disable --clear-cache @@ -287,6 +295,8 @@ Disable events: ## Artisan Commands +These commands update the application's active environment file, preserve its file permissions, and clear any cached Laravel configuration. They fail if the environment file is missing or cannot be updated. Run `php artisan config:cache` afterwards if your deployment uses cached configuration, and restart long-running workers or Octane processes so they reload the setting. If your deployment supplies `APP_READ_ONLY` through external environment variables, update that setting through your deployment instead. + ### Enable Lockout ```bash @@ -294,7 +304,7 @@ php artisan lockout:enable ``` Options: -- `--clear-cache` - Clear the lockout cache after enabling +- `--clear-cache` - Accepted for existing scripts; the lockout cache is always cleared ### Disable Lockout @@ -303,7 +313,7 @@ php artisan lockout:disable ``` Options: -- `--clear-cache` - Clear the lockout cache after disabling +- `--clear-cache` - Accepted for existing scripts; the lockout cache is always cleared ### Check Status diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0f2131f..93ff387 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,6 @@ diff --git a/src/Console/Commands/LockoutDisable.php b/src/Console/Commands/LockoutDisable.php index 9c1eb8f..67e968c 100644 --- a/src/Console/Commands/LockoutDisable.php +++ b/src/Console/Commands/LockoutDisable.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\File; use Rappasoft\Lockout\Events\LockoutDisabled; class LockoutDisable extends Command @@ -28,10 +29,16 @@ class LockoutDisable extends Command public function handle(): int { // Update .env file - $envFile = base_path('.env'); + $envFile = $this->laravel->environmentFilePath(); - if (file_exists($envFile)) { - $envContent = file_get_contents($envFile); + if (! is_file($envFile)) { + $this->error('The application environment file does not exist.'); + + return Command::FAILURE; + } + + try { + $envContent = File::get($envFile); // Update or add APP_READ_ONLY if (preg_match('/^APP_READ_ONLY=(.*)$/m', $envContent)) { @@ -40,23 +47,32 @@ public function handle(): int $envContent .= "\nAPP_READ_ONLY=false\n"; } - file_put_contents($envFile, $envContent); + File::replace($envFile, $envContent, fileperms($envFile) & 0777); + + if (is_file($this->laravel->getCachedConfigPath())) { + $this->callSilent('config:clear'); + if (is_file($this->laravel->getCachedConfigPath())) { + throw new \RuntimeException('The configuration cache could not be cleared.'); + } + } + } catch (\Throwable $exception) { + $this->error('Unable to disable lockout: '.$exception->getMessage()); + + return Command::FAILURE; } - // Clear cache if requested + Cache::forget(config('lockout.cache_key', 'lockout.status')); if ($this->option('clear-cache')) { - Cache::forget(config('lockout.cache_key', 'lockout.status')); $this->info('Lockout cache cleared.'); } + config(['lockout.enabled' => false]); + // Fire event if (config('lockout.fire_events', true)) { event(new LockoutDisabled()); } - // Reload config - config(['lockout.enabled' => false]); - $this->info('Application lockout has been disabled.'); return Command::SUCCESS; diff --git a/src/Console/Commands/LockoutEnable.php b/src/Console/Commands/LockoutEnable.php index e677c7e..b7f5a6c 100644 --- a/src/Console/Commands/LockoutEnable.php +++ b/src/Console/Commands/LockoutEnable.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\File; use Rappasoft\Lockout\Events\LockoutEnabled; class LockoutEnable extends Command @@ -28,10 +29,16 @@ class LockoutEnable extends Command public function handle(): int { // Update .env file - $envFile = base_path('.env'); + $envFile = $this->laravel->environmentFilePath(); - if (file_exists($envFile)) { - $envContent = file_get_contents($envFile); + if (! is_file($envFile)) { + $this->error('The application environment file does not exist.'); + + return Command::FAILURE; + } + + try { + $envContent = File::get($envFile); // Update or add APP_READ_ONLY if (preg_match('/^APP_READ_ONLY=(.*)$/m', $envContent)) { @@ -40,23 +47,32 @@ public function handle(): int $envContent .= "\nAPP_READ_ONLY=true\n"; } - file_put_contents($envFile, $envContent); + File::replace($envFile, $envContent, fileperms($envFile) & 0777); + + if (is_file($this->laravel->getCachedConfigPath())) { + $this->callSilent('config:clear'); + if (is_file($this->laravel->getCachedConfigPath())) { + throw new \RuntimeException('The configuration cache could not be cleared.'); + } + } + } catch (\Throwable $exception) { + $this->error('Unable to enable lockout: '.$exception->getMessage()); + + return Command::FAILURE; } - // Clear cache if requested + Cache::forget(config('lockout.cache_key', 'lockout.status')); if ($this->option('clear-cache')) { - Cache::forget(config('lockout.cache_key', 'lockout.status')); $this->info('Lockout cache cleared.'); } + config(['lockout.enabled' => true]); + // Fire event if (config('lockout.fire_events', true)) { event(new LockoutEnabled()); } - // Reload config - config(['lockout.enabled' => true]); - $this->info('Application lockout has been enabled.'); return Command::SUCCESS; diff --git a/src/Helpers/IpHelper.php b/src/Helpers/IpHelper.php index 9676bfc..64b0912 100644 --- a/src/Helpers/IpHelper.php +++ b/src/Helpers/IpHelper.php @@ -2,6 +2,8 @@ namespace Rappasoft\Lockout\Helpers; +use Symfony\Component\HttpFoundation\IpUtils; + class IpHelper { /** @@ -45,17 +47,12 @@ public static function isIpBlocked(string $ip, array $blacklist): bool */ protected static function ipMatches(string $ip, string $pattern): bool { - // Exact match - if ($ip === $pattern) { - return true; - } - // CIDR notation support if (str_contains($pattern, '/')) { return self::ipInCidr($ip, $pattern); } - return false; + return IpUtils::checkIp($ip, $pattern); } /** @@ -63,18 +60,12 @@ protected static function ipMatches(string $ip, string $pattern): bool */ protected static function ipInCidr(string $ip, string $cidr): bool { - [$subnet, $mask] = explode('/', $cidr); - - $ipLong = ip2long($ip); - $subnetLong = ip2long($subnet); - - if ($ipLong === false || $subnetLong === false) { + $mask = explode('/', $cidr, 2)[1]; + if (! ctype_digit($mask)) { return false; } - $maskLong = -1 << (32 - (int) $mask); - - return ($ipLong & $maskLong) === ($subnetLong & $maskLong); + return IpUtils::checkIp($ip, $cidr); } /** diff --git a/src/Http/Middleware/CheckForReadOnlyMode.php b/src/Http/Middleware/CheckForReadOnlyMode.php index cb78d1b..7f582d1 100644 --- a/src/Http/Middleware/CheckForReadOnlyMode.php +++ b/src/Http/Middleware/CheckForReadOnlyMode.php @@ -5,11 +5,14 @@ use Closure; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Routing\Route as RoutingRoute; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Route; use Rappasoft\Lockout\Events\RequestBlocked; use Rappasoft\Lockout\Helpers\IpHelper; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Class CheckForReadOnlyMode. @@ -203,25 +206,7 @@ protected function hasAllowedRole(Request $request): bool */ protected function matchesRoutePattern(Request $request): bool { - $patterns = config('lockout.route_patterns', []); - - if (empty($patterns)) { - return false; - } - - $path = $request->path(); - - foreach ($patterns as $pattern) { - // Convert wildcard pattern to regex - $regex = str_replace(['*', '/'], ['.*', '\/'], $pattern); - $regex = '/^'.$regex.'$/'; - - if (preg_match($regex, $path)) { - return true; - } - } - - return false; + return $request->is(config('lockout.route_patterns', [])); } /** @@ -235,7 +220,7 @@ protected function matchesRouteName(Request $request): bool return false; } - $route = Route::current(); + $route = $this->resolveRoute($request); if (! $route) { return false; @@ -246,6 +231,16 @@ protected function matchesRouteName(Request $request): bool return $routeName && in_array($routeName, $routeNames, true); } + protected function resolveRoute(Request $request): ?RoutingRoute + { + try { + // Global middleware runs before Laravel has matched the current route. + return $request->route() ?? Route::getRoutes()->match($request); + } catch (NotFoundHttpException|MethodNotAllowedHttpException) { + return null; + } + } + /** * Check if path is the health check endpoint. */ @@ -306,7 +301,7 @@ protected function isApiRequest(Request $request): bool } // Check if route is in api middleware group - $route = Route::current(); + $route = $this->resolveRoute($request); if ($route && in_array('api', $route->middleware(), true)) { return true; } diff --git a/src/LockoutServiceProvider.php b/src/LockoutServiceProvider.php index d0b2b3c..33db420 100644 --- a/src/LockoutServiceProvider.php +++ b/src/LockoutServiceProvider.php @@ -27,7 +27,7 @@ public function boot(): void ], 'config'); $this->publishes([ - __DIR__.'/../../resources/views' => resource_path('views/vendor/lockout'), + __DIR__.'/../resources/views' => resource_path('views/vendor/lockout'), ], 'lockout-views'); } @@ -44,7 +44,7 @@ public function register(): void $this->mergeConfigFrom(__DIR__.'/../config/lockout.php', 'lockout'); // Register views - $this->loadViewsFrom(__DIR__.'/../../resources/views', 'lockout'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'lockout'); // Register commands if ($this->app->runningInConsole()) { diff --git a/tests/AdvancedFeaturesTest.php b/tests/AdvancedFeaturesTest.php index aeb1a69..e374a47 100644 --- a/tests/AdvancedFeaturesTest.php +++ b/tests/AdvancedFeaturesTest.php @@ -5,7 +5,9 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; +use PHPUnit\Framework\Attributes\Test; use Rappasoft\Lockout\Events\RequestBlocked; +use Rappasoft\Lockout\LockoutServiceProvider; class AdvancedFeaturesTest extends TestCase { @@ -16,7 +18,7 @@ protected function setUp(): void Event::fake(); } - /** @test */ + #[Test] public function ip_whitelist_allows_access() { config(['lockout.enabled' => true]); @@ -28,7 +30,7 @@ public function ip_whitelist_allows_access() ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function ip_whitelist_blocks_other_ips() { config(['lockout.enabled' => true]); @@ -40,7 +42,7 @@ public function ip_whitelist_blocks_other_ips() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function ip_whitelist_supports_cidr_notation() { config(['lockout.enabled' => true]); @@ -56,7 +58,7 @@ public function ip_whitelist_supports_cidr_notation() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function ip_blacklist_blocks_even_when_lockout_disabled() { config(['lockout.enabled' => false]); @@ -67,7 +69,7 @@ public function ip_blacklist_blocks_even_when_lockout_disabled() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function health_check_endpoint_is_always_accessible() { config(['lockout.enabled' => true]); @@ -80,7 +82,7 @@ public function health_check_endpoint_is_always_accessible() ->assertJsonStructure(['status', 'timestamp', 'lockout_enabled']); } - /** @test */ + #[Test] public function health_check_can_be_disabled() { config(['lockout.enabled' => true]); @@ -92,7 +94,7 @@ public function health_check_can_be_disabled() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function route_patterns_whitelist_routes() { config(['lockout.enabled' => true]); @@ -106,7 +108,7 @@ public function route_patterns_whitelist_routes() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function api_requests_return_json_response() { config(['lockout.enabled' => true]); @@ -124,7 +126,7 @@ public function api_requests_return_json_response() } } - /** @test */ + #[Test] public function view_response_type_returns_view() { config(['lockout.enabled' => true]); @@ -132,15 +134,21 @@ public function view_response_type_returns_view() config(['lockout.response_type' => 'view']); config(['lockout.response_view' => 'lockout::maintenance']); - // View might not exist in test environment, so test with json instead - config(['lockout.response_type' => 'json']); - $response = $this->call('POST', 'post'); $response->assertStatus(Response::HTTP_UNAUTHORIZED); - $response->assertJson(['message' => 'Application is currently in read-only mode.']); + $response->assertSee('Application in Read-Only Mode'); + $response->assertSee('Application is currently in read-only mode.'); + } + + public function testBundledViewPublishPathExists() + { + $paths = LockoutServiceProvider::pathsToPublish(LockoutServiceProvider::class, 'lockout-views'); + $this->assertCount(1, $paths); + $this->assertFileExists(array_key_first($paths).'/maintenance.blade.php'); + $this->assertSame(resource_path('views/vendor/lockout'), reset($paths)); } - /** @test */ + #[Test] public function json_response_type_returns_json() { config(['lockout.enabled' => true]); @@ -152,7 +160,7 @@ public function json_response_type_returns_json() ->assertJson(['message' => 'Application is currently in read-only mode.']); } - /** @test */ + #[Test] public function cache_is_used_when_enabled() { config(['lockout.enabled' => true]); @@ -168,7 +176,7 @@ public function cache_is_used_when_enabled() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function cache_is_bypassed_when_disabled() { config(['lockout.enabled' => true]); @@ -180,7 +188,7 @@ public function cache_is_bypassed_when_disabled() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function request_blocked_event_is_fired() { config(['lockout.enabled' => true]); @@ -196,7 +204,7 @@ public function request_blocked_event_is_fired() }); } - /** @test */ + #[Test] public function events_are_not_fired_when_disabled() { config(['lockout.enabled' => true]); diff --git a/tests/BladeTest.php b/tests/BladeTest.php index 5dddd08..854945c 100644 --- a/tests/BladeTest.php +++ b/tests/BladeTest.php @@ -2,12 +2,14 @@ namespace Rappasoft\Lockout\Tests; +use PHPUnit\Framework\Attributes\Test; + /** * Class BladeTest. */ class BladeTest extends TestCase { - /** @test */ + #[Test] public function the_readonly_directive_renders_the_correct_block_based_on_the_package_status() { config(['lockout.enabled' => false]); diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php new file mode 100644 index 0000000..25e6552 --- /dev/null +++ b/tests/CommandsTest.php @@ -0,0 +1,92 @@ +directory = sys_get_temp_dir().'/lockout-'.bin2hex(random_bytes(8)); + mkdir($this->directory); + mkdir($this->directory.'/cache'); + $this->app->useEnvironmentPath($this->directory); + $this->app->useBootstrapPath($this->directory); + Cache::flush(); + Event::fake([LockoutEnabled::class, LockoutDisabled::class]); + } + + protected function tearDown(): void + { + foreach (glob($this->directory.'/cache/*') as $file) { + unlink($file); + } + if (is_file($this->directory.'/.env')) { + unlink($this->directory.'/.env'); + } + rmdir($this->directory.'/cache'); + rmdir($this->directory); + parent::tearDown(); + } + + public function testEnablePersistsStatusAndClearsBothCaches() + { + file_put_contents($this->directory.'/.env', "APP_NAME=Lockout\nAPP_READ_ONLY=false\n"); + chmod($this->directory.'/.env', 0600); + file_put_contents($this->app->getCachedConfigPath(), 'artisan('lockout:enable')->assertSuccessful(); + + $this->assertSame("APP_NAME=Lockout\nAPP_READ_ONLY=true\n", file_get_contents($this->directory.'/.env')); + $this->assertSame(0600, fileperms($this->directory.'/.env') & 0777); + $this->assertTrue(config('lockout.enabled')); + $this->assertFalse(Cache::has('lockout.status')); + $this->assertFileDoesNotExist($this->app->getCachedConfigPath()); + Event::assertDispatched(LockoutEnabled::class); + } + + public function testDisablePersistsStatusAndClearsTheStatusCache() + { + file_put_contents($this->directory.'/.env', "APP_NAME=Lockout\nAPP_READ_ONLY=true\n"); + config(['lockout.enabled' => true, 'lockout.cache_key' => 'custom.lockout']); + Cache::put('custom.lockout', true); + + $this->artisan('lockout:disable')->assertSuccessful(); + + $this->assertSame("APP_NAME=Lockout\nAPP_READ_ONLY=false\n", file_get_contents($this->directory.'/.env')); + $this->assertFalse(config('lockout.enabled')); + $this->assertFalse(Cache::has('custom.lockout')); + Event::assertDispatched(LockoutDisabled::class); + } + + public function testCommandsFailWhenTheEnvironmentFileIsMissing() + { + $this->artisan('lockout:enable')->assertFailed(); + $this->artisan('lockout:disable')->assertFailed(); + Event::assertNotDispatched(LockoutEnabled::class); + Event::assertNotDispatched(LockoutDisabled::class); + } + + public function testFailedPersistenceDoesNotChangeStatusOrFireSuccessEvents() + { + $content = "APP_READ_ONLY=false\n"; + file_put_contents($this->directory.'/.env', $content); + File::partialMock()->shouldReceive('replace')->andThrow(new \RuntimeException('Disk full')); + config(['lockout.enabled' => false]); + + $this->artisan('lockout:enable')->assertFailed(); + + $this->assertSame($content, file_get_contents($this->directory.'/.env')); + $this->assertFalse(config('lockout.enabled')); + Event::assertNotDispatched(LockoutEnabled::class); + } +} diff --git a/tests/EdgeCasesTest.php b/tests/EdgeCasesTest.php index 5913cce..84c96ed 100644 --- a/tests/EdgeCasesTest.php +++ b/tests/EdgeCasesTest.php @@ -4,10 +4,11 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; +use PHPUnit\Framework\Attributes\Test; class EdgeCasesTest extends TestCase { - /** @test */ + #[Test] public function ip_whitelist_from_env_variable_works() { config(['lockout.enabled' => true]); @@ -24,7 +25,7 @@ public function ip_whitelist_from_env_variable_works() ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function ip_whitelist_combines_env_and_array() { config(['lockout.enabled' => true]); @@ -41,7 +42,7 @@ public function ip_whitelist_combines_env_and_array() ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function ip_blacklist_from_env_variable_works() { config(['lockout.enabled' => false]); @@ -53,21 +54,20 @@ public function ip_blacklist_from_env_variable_works() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function route_name_matching_works() { config(['lockout.enabled' => true]); config(['lockout.locked_types' => ['post']]); config(['lockout.route_names' => ['test.route']]); - // Route name matching requires Route::current() to work - // In testbench, this can be tricky, so we test the config is read correctly - // The actual route matching is tested in integration scenarios - $routeNames = config('lockout.route_names', []); - $this->assertContains('test.route', $routeNames); + Route::post('named-route', fn () => 'allowed')->name('test.route'); + + $this->call('POST', 'named-route')->assertOk()->assertSee('allowed'); + $this->call('POST', 'post')->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function route_name_matching_returns_false_when_no_route() { config(['lockout.enabled' => true]); @@ -79,7 +79,7 @@ public function route_name_matching_returns_false_when_no_route() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function route_name_matching_returns_false_when_route_has_no_name() { config(['lockout.enabled' => true]); @@ -94,7 +94,7 @@ public function route_name_matching_returns_false_when_route_has_no_name() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function multiple_route_patterns_work() { config(['lockout.enabled' => true]); @@ -119,7 +119,7 @@ public function multiple_route_patterns_work() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function route_pattern_with_complex_wildcards() { config(['lockout.enabled' => true]); @@ -141,7 +141,17 @@ public function route_pattern_with_complex_wildcards() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + public function testRoutePatternTreatsRegexCharactersLiterally() + { + config(['lockout.enabled' => true, 'lockout.route_patterns' => ['reports/v1.0/*']]); + Route::post('reports/v1.0/export', fn () => 'allowed'); + Route::post('reports/v1x0/export', fn () => 'blocked'); + + $this->call('POST', 'reports/v1.0/export')->assertOk(); + $this->call('POST', 'reports/v1x0/export')->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + #[Test] public function custom_health_check_path_configuration_works() { // Test that custom health check path configuration is respected @@ -157,7 +167,7 @@ public function custom_health_check_path_configuration_works() // This test verifies the configuration is properly stored and retrieved } - /** @test */ + #[Test] public function api_detection_by_path_works() { config(['lockout.enabled' => true]); @@ -170,7 +180,17 @@ public function api_detection_by_path_works() ->assertJsonStructure(['message', 'status']); } - /** @test */ + public function testApiMiddlewareIsDetectedBeforeRouteDispatch() + { + config(['lockout.enabled' => true]); + Route::post('service', fn () => 'blocked')->middleware('api'); + + $this->call('POST', 'service') + ->assertStatus(Response::HTTP_UNAUTHORIZED) + ->assertJsonStructure(['message', 'status']); + } + + #[Test] public function api_detection_when_disabled() { config(['lockout.enabled' => true]); @@ -182,7 +202,7 @@ public function api_detection_when_disabled() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function api_response_type_abort_works() { config(['lockout.enabled' => true]); @@ -195,7 +215,7 @@ public function api_response_type_abort_works() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function custom_response_code_works() { config(['lockout.enabled' => true]); @@ -206,7 +226,7 @@ public function custom_response_code_works() ->assertStatus(503); } - /** @test */ + #[Test] public function custom_response_message_works() { config(['lockout.enabled' => true]); @@ -219,7 +239,7 @@ public function custom_response_message_works() ->assertJson(['message' => 'Custom maintenance message']); } - /** @test */ + #[Test] public function whitelist_method_case_insensitive() { config(['lockout.enabled' => true]); @@ -236,7 +256,7 @@ public function whitelist_method_case_insensitive() ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function locked_types_case_insensitive() { config(['lockout.enabled' => true]); @@ -249,7 +269,7 @@ public function locked_types_case_insensitive() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function cache_key_customization_works() { config(['lockout.enabled' => true]); @@ -262,7 +282,7 @@ public function cache_key_customization_works() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function cache_ttl_customization_works() { config(['lockout.enabled' => true]); diff --git a/tests/IpHelperTest.php b/tests/IpHelperTest.php index d769027..abc99ae 100644 --- a/tests/IpHelperTest.php +++ b/tests/IpHelperTest.php @@ -2,18 +2,19 @@ namespace Rappasoft\Lockout\Tests; +use PHPUnit\Framework\Attributes\Test; use Rappasoft\Lockout\Helpers\IpHelper; class IpHelperTest extends TestCase { - /** @test */ + #[Test] public function exact_ip_match_works() { $this->assertTrue(IpHelper::isIpAllowed('127.0.0.1', ['127.0.0.1'])); $this->assertFalse(IpHelper::isIpAllowed('127.0.0.2', ['127.0.0.1'])); } - /** @test */ + #[Test] public function cidr_notation_works() { $this->assertTrue(IpHelper::isIpAllowed('192.168.1.100', ['192.168.1.0/24'])); @@ -21,7 +22,7 @@ public function cidr_notation_works() $this->assertFalse(IpHelper::isIpAllowed('192.168.2.100', ['192.168.1.0/24'])); } - /** @test */ + #[Test] public function cidr_with_different_masks_works() { $this->assertTrue(IpHelper::isIpAllowed('10.0.0.1', ['10.0.0.0/8'])); @@ -29,7 +30,7 @@ public function cidr_with_different_masks_works() $this->assertFalse(IpHelper::isIpAllowed('11.0.0.1', ['10.0.0.0/8'])); } - /** @test */ + #[Test] public function multiple_ips_in_whitelist_work() { $whitelist = ['127.0.0.1', '192.168.1.0/24', '10.0.0.1']; @@ -40,33 +41,33 @@ public function multiple_ips_in_whitelist_work() $this->assertFalse(IpHelper::isIpAllowed('172.16.0.1', $whitelist)); } - /** @test */ + #[Test] public function empty_whitelist_returns_false() { $this->assertFalse(IpHelper::isIpAllowed('127.0.0.1', [])); } - /** @test */ + #[Test] public function ip_blacklist_works() { $this->assertTrue(IpHelper::isIpBlocked('192.168.1.1', ['192.168.1.1'])); $this->assertFalse(IpHelper::isIpBlocked('192.168.1.2', ['192.168.1.1'])); } - /** @test */ + #[Test] public function ip_blacklist_with_cidr_works() { $this->assertTrue(IpHelper::isIpBlocked('192.168.1.100', ['192.168.1.0/24'])); $this->assertFalse(IpHelper::isIpBlocked('192.168.2.100', ['192.168.1.0/24'])); } - /** @test */ + #[Test] public function empty_blacklist_returns_false() { $this->assertFalse(IpHelper::isIpBlocked('127.0.0.1', [])); } - /** @test */ + #[Test] public function parse_ip_list_from_string() { $result = IpHelper::parseIpList('127.0.0.1,192.168.1.1,10.0.0.0/8'); @@ -74,7 +75,7 @@ public function parse_ip_list_from_string() $this->assertEquals(['127.0.0.1', '192.168.1.1', '10.0.0.0/8'], $result); } - /** @test */ + #[Test] public function parse_ip_list_handles_spaces() { $result = IpHelper::parseIpList('127.0.0.1, 192.168.1.1 , 10.0.0.0/8'); @@ -82,26 +83,43 @@ public function parse_ip_list_handles_spaces() $this->assertEquals(['127.0.0.1', '192.168.1.1', '10.0.0.0/8'], $result); } - /** @test */ + #[Test] public function parse_ip_list_handles_null() { $this->assertEquals([], IpHelper::parseIpList(null)); } - /** @test */ + #[Test] public function parse_ip_list_handles_empty_string() { $this->assertEquals([], IpHelper::parseIpList('')); } - /** @test */ + #[Test] public function invalid_cidr_returns_false() { // Invalid IP in CIDR $this->assertFalse(IpHelper::isIpAllowed('192.168.1.1', ['999.999.999.999/24'])); } - /** @test */ + public function testMalformedCidrNeverWhitelistsAnIp() + { + foreach (['192.168.1.0/33', '192.168.1.0/-1', '192.168.1.0/', '192.168.1.0/foo', '192.168.1.0/24/0'] as $cidr) { + $this->assertFalse(IpHelper::isIpAllowed('192.168.1.1', [$cidr]), $cidr); + $this->assertFalse(IpHelper::isIpBlocked('192.168.1.1', [$cidr]), $cidr); + } + } + + public function testIpv6AddressesAndCidrRanges() + { + $this->assertTrue(IpHelper::isIpAllowed('2001:db8::1', ['2001:db8::/32'])); + $this->assertFalse(IpHelper::isIpAllowed('2001:db9::1', ['2001:db8::/32'])); + $this->assertTrue(IpHelper::isIpBlocked('::1', ['::1/128'])); + $this->assertTrue(IpHelper::isIpAllowed('::1', ['0:0:0:0:0:0:0:1'])); + $this->assertFalse(IpHelper::isIpAllowed('not-an-ip', ['not-an-ip'])); + } + + #[Test] public function cidr_edge_cases() { // /32 should match only exact IP diff --git a/tests/RequestTest.php b/tests/RequestTest.php index db1bd16..1d4ed50 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -3,13 +3,14 @@ namespace Rappasoft\Lockout\Tests; use Illuminate\Http\Response; +use PHPUnit\Framework\Attributes\Test; /** * Class RequestTest. */ class RequestTest extends TestCase { - /** @test */ + #[Test] public function get_requests_can_be_accessed_with_the_plugin_off() { config(['lockout.enabled' => false]); @@ -20,7 +21,7 @@ public function get_requests_can_be_accessed_with_the_plugin_off() $this->assertEquals('got', $crawler->getContent()); } - /** @test */ + #[Test] public function get_requests_cannot_be_accessed_with_the_plugin_on() { config(['lockout.enabled' => true]); @@ -30,7 +31,7 @@ public function get_requests_cannot_be_accessed_with_the_plugin_on() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function post_requests_can_be_accessed_with_the_plugin_off() { config(['lockout.enabled' => false]); @@ -41,7 +42,7 @@ public function post_requests_can_be_accessed_with_the_plugin_off() $this->assertEquals('posted', $crawler->getContent()); } - /** @test */ + #[Test] public function post_requests_cannot_be_accessed_with_the_plugin_on() { config(['lockout.enabled' => true]); @@ -51,7 +52,7 @@ public function post_requests_cannot_be_accessed_with_the_plugin_on() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function put_requests_can_be_accessed_with_the_plugin_off() { config(['lockout.enabled' => false]); @@ -62,7 +63,7 @@ public function put_requests_can_be_accessed_with_the_plugin_off() $this->assertEquals('placed', $crawler->getContent()); } - /** @test */ + #[Test] public function put_requests_cannot_be_accessed_with_the_plugin_on() { config(['lockout.enabled' => true]); @@ -72,7 +73,7 @@ public function put_requests_cannot_be_accessed_with_the_plugin_on() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function patch_requests_can_be_accessed_with_the_plugin_off() { config(['lockout.enabled' => false]); @@ -83,7 +84,7 @@ public function patch_requests_can_be_accessed_with_the_plugin_off() $this->assertEquals('patched', $crawler->getContent()); } - /** @test */ + #[Test] public function patch_requests_cannot_be_accessed_with_the_plugin_on() { config(['lockout.enabled' => true]); @@ -93,7 +94,7 @@ public function patch_requests_cannot_be_accessed_with_the_plugin_on() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function delete_requests_can_be_accessed_with_the_plugin_off() { config(['lockout.enabled' => false]); @@ -104,7 +105,7 @@ public function delete_requests_can_be_accessed_with_the_plugin_off() $this->assertEquals('deleted', $crawler->getContent()); } - /** @test */ + #[Test] public function deleted_requests_cannot_be_accessed_with_the_plugin_on() { config(['lockout.enabled' => true]); @@ -159,7 +160,7 @@ public function test_a_page_that_is_whitelisted_is_allowed() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function multiple_whitelist_entries_work_correctly() { config(['lockout.enabled' => true]); @@ -179,7 +180,7 @@ public function multiple_whitelist_entries_work_correctly() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function post_requests_to_non_login_paths_are_blocked_when_allow_login_is_true() { config(['lockout.enabled' => true]); @@ -196,7 +197,7 @@ public function post_requests_to_non_login_paths_are_blocked_when_allow_login_is ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function post_requests_are_blocked_when_allow_login_is_false() { config(['lockout.enabled' => true]); @@ -210,7 +211,7 @@ public function post_requests_are_blocked_when_allow_login_is_false() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function get_requests_not_in_pages_array_are_allowed() { config(['lockout.enabled' => true]); @@ -224,7 +225,7 @@ public function get_requests_not_in_pages_array_are_allowed() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function multiple_pages_in_pages_array_are_blocked() { config(['lockout.enabled' => true]); @@ -241,7 +242,7 @@ public function multiple_pages_in_pages_array_are_blocked() ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function empty_whitelist_does_not_break_middleware() { config(['lockout.enabled' => true]); @@ -252,7 +253,7 @@ public function empty_whitelist_does_not_break_middleware() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function empty_locked_types_allows_all_methods_except_pages() { config(['lockout.enabled' => true]); @@ -269,7 +270,7 @@ public function empty_locked_types_allows_all_methods_except_pages() ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function whitelist_takes_precedence_over_locked_types() { config(['lockout.enabled' => true]); @@ -292,7 +293,7 @@ public function whitelist_takes_precedence_over_locked_types() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function custom_login_and_logout_paths_work() { config(['lockout.enabled' => true]); @@ -314,7 +315,7 @@ public function custom_login_and_logout_paths_work() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function get_requests_work_when_not_in_pages_array_and_locked_types_empty() { config(['lockout.enabled' => true]); @@ -328,7 +329,7 @@ public function get_requests_work_when_not_in_pages_array_and_locked_types_empty ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function pages_array_with_non_array_value_is_handled_gracefully() { config(['lockout.enabled' => true]); diff --git a/tests/RoleBasedTest.php b/tests/RoleBasedTest.php index b417dab..78557a1 100644 --- a/tests/RoleBasedTest.php +++ b/tests/RoleBasedTest.php @@ -4,10 +4,11 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Http\Response; +use PHPUnit\Framework\Attributes\Test; class RoleBasedTest extends TestCase { - /** @test */ + #[Test] public function user_with_role_attribute_bypasses_lockout() { config(['lockout.enabled' => true]); @@ -24,7 +25,7 @@ public function user_with_role_attribute_bypasses_lockout() ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function user_without_allowed_role_is_blocked() { config(['lockout.enabled' => true]); @@ -41,7 +42,7 @@ public function user_without_allowed_role_is_blocked() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function user_with_hasRole_method_bypasses_lockout() { config(['lockout.enabled' => true]); @@ -61,7 +62,7 @@ public function hasRole($role) ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function unauthenticated_user_is_blocked() { config(['lockout.enabled' => true]); @@ -72,7 +73,7 @@ public function unauthenticated_user_is_blocked() ->assertStatus(Response::HTTP_UNAUTHORIZED); } - /** @test */ + #[Test] public function multiple_allowed_roles_work() { config(['lockout.enabled' => true]); @@ -107,7 +108,7 @@ public function multiple_allowed_roles_work() ->assertStatus(Response::HTTP_OK); } - /** @test */ + #[Test] public function empty_allowed_roles_blocks_everyone() { config(['lockout.enabled' => true]);