This commit is contained in:
mathiaspalmqvist committed 2026-11-11 23:19:20 +01:00
commit 3ed6eb9d19
23 files changed
+1216

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
<?php
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Http\Kernel;
use Mattitja\BadIpBlocker\Middleware\CheckBadIps;
it('registers the middleware globally without any setup', function () {
expect(app(Kernel::class)->hasMiddleware(CheckBadIps::class))->toBeTrue();
});
it('does not register the middleware twice when the app also adds it', function () {
app(Kernel::class)->pushMiddleware(CheckBadIps::class);
$count = collect(app(Kernel::class)->getGlobalMiddleware())
->filter(fn ($middleware) => $middleware === CheckBadIps::class)
->count();
expect($count)->toBe(1);
});
it('schedules a daily prune', function () {
$event = collect(app(Schedule::class)->events())
->first(fn ($event) => str_contains($event->command ?? '', 'bad-ip-blocker:prune'));
expect($event)->not->toBeNull()
->and($event->expression)->toBe('0 0 * * *');
});
+53
View File
@@ -0,0 +1,53 @@
<?php
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
it('adds an ip', function () {
$this->artisan('bad-ip-blocker:add', ['ip' => '203.0.113.10', '--reason' => 'bot', '--ttl' => 60])
->assertSuccessful();
expect(BadIpBlocker::all()['203.0.113.10'])
->reason->toBe('bot')
->expires_at->not->toBeNull();
});
it('rejects an invalid ip or ttl', function () {
$this->artisan('bad-ip-blocker:add', ['ip' => 'nope'])->assertFailed();
$this->artisan('bad-ip-blocker:add', ['ip' => '203.0.113.10', '--ttl' => 'abc'])->assertFailed();
expect(BadIpBlocker::all())->toBe([]);
});
it('removes an ip', function () {
BadIpBlocker::block('203.0.113.10');
$this->artisan('bad-ip-blocker:remove', ['ip' => '203.0.113.10'])->assertSuccessful();
$this->artisan('bad-ip-blocker:remove', ['ip' => '203.0.113.10'])->assertFailed();
});
it('lists ips', function () {
$this->artisan('bad-ip-blocker:list')->expectsOutputToContain('No blocked')->assertSuccessful();
BadIpBlocker::block('203.0.113.10', 'bot');
$blockedAt = BadIpBlocker::all()['203.0.113.10']['blocked_at'];
$this->artisan('bad-ip-blocker:list')
->expectsTable(['IP', 'Reason', 'Blocked at', 'Expires at'], [['203.0.113.10', 'bot', $blockedAt, 'never']])
->assertSuccessful();
});
it('prunes expired blocks', function () {
$this->artisan('bad-ip-blocker:prune')->assertSuccessful();
});
it('clears all blocks after confirmation', function () {
BadIpBlocker::block('203.0.113.10');
$this->artisan('bad-ip-blocker:clear')
->expectsConfirmation('Remove all blocked IP addresses?', 'no')
->assertFailed();
expect(BadIpBlocker::all())->not->toBe([]);
$this->artisan('bad-ip-blocker:clear', ['--force' => true])->assertSuccessful();
expect(BadIpBlocker::all())->toBe([]);
});
+100
View File
@@ -0,0 +1,100 @@
<?php
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Route;
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
it('lets normal visitors through', function () {
$this->getFrom('/', '203.0.113.10')->assertOk()->assertSee('ok');
});
it('blocks ips on the list', function () {
BadIpBlocker::block('203.0.113.10');
$this->getFrom('/', '203.0.113.10')->assertStatus(418)->assertSee('Blocked.');
$this->getFrom('/', '192.0.2.30')->assertOk();
});
it('uses the configured response', function () {
config(['bad-ip-blocker.response' => ['status' => 403, 'message' => 'Nope']]);
BadIpBlocker::block('203.0.113.10');
$this->getFrom('/', '203.0.113.10')->assertForbidden()->assertSee('Nope');
});
it('does nothing when disabled', function () {
config(['bad-ip-blocker.enabled' => false]);
BadIpBlocker::block('203.0.113.10');
$this->getFrom('/', '203.0.113.10')->assertOk();
});
it('never blocks whitelisted ranges, including private proxy addresses', function () {
BadIpBlocker::block('192.168.1.10');
$this->getFrom('/', '192.168.1.10')->assertOk();
$this->getFrom('/.well-known/security.txt', '192.168.1.20')->assertNotFound();
expect(BadIpBlocker::isBlocked('192.168.1.20'))->toBeFalse();
});
it('auto-blocks clients probing for a missing security.txt', function (string $path) {
$this->getFrom($path, '198.51.100.20')->assertStatus(418);
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeTrue()
->and(BadIpBlocker::all()['198.51.100.20']['reason'])->toBe('Auto: '.$path);
$this->getFrom('/', '198.51.100.20')->assertStatus(418);
})->with(['/.well-known/security.txt', '/security.txt', '/.env', '/wp-login.php']);
it('does not auto-block when the path exists in the app', function () {
Route::get('/.well-known/security.txt', fn () => 'Contact: mailto:security@example.com');
$this->getFrom('/.well-known/security.txt', '198.51.100.20')->assertOk();
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeFalse();
});
it('does not auto-block ordinary 404s', function () {
foreach (['/about', '/products', '/missing-page'] as $path) {
$this->getFrom($path, '203.0.113.10')->assertNotFound();
}
expect(BadIpBlocker::isBlocked('203.0.113.10'))->toBeFalse();
});
it('respects max_hits', function () {
config(['bad-ip-blocker.auto_block.max_hits' => 2]);
$this->getFrom('/.well-known/security.txt', '198.51.100.20')->assertNotFound();
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeFalse();
$this->getFrom('/security.txt', '198.51.100.20')->assertStatus(418);
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeTrue();
});
it('expires auto-blocks after 7 days by default', function () {
CarbonImmutable::setTestNow('2026-11-11 20:00:00');
$this->getFrom('/security.txt', '198.51.100.20');
expect(BadIpBlocker::all()['198.51.100.20']['expires_at'])->toBe('2026-11-18T20:00:00+00:00');
CarbonImmutable::setTestNow();
});
it('can make auto-blocks permanent', function () {
config(['bad-ip-blocker.auto_block.ttl' => null]);
$this->getFrom('/security.txt', '198.51.100.20');
expect(BadIpBlocker::all()['198.51.100.20']['expires_at'])->toBeNull();
});
it('can disable auto-blocking', function () {
config(['bad-ip-blocker.auto_block.enabled' => false]);
$this->getFrom('/security.txt', '198.51.100.20')->assertNotFound();
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeFalse();
});
+21
View File
@@ -0,0 +1,21 @@
<?php
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Http\Kernel;
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
use Mattitja\BadIpBlocker\Middleware\CheckBadIps;
it('does not register the middleware when auto_register is disabled', function () {
expect(app(Kernel::class)->hasMiddleware(CheckBadIps::class))->toBeFalse();
BadIpBlocker::block('203.0.113.10');
$this->getFrom('/', '203.0.113.10')->assertOk();
});
it('does not schedule prune when schedule_prune is disabled', function () {
$scheduled = collect(app(Schedule::class)->events())
->contains(fn ($event) => str_contains($event->command ?? '', 'bad-ip-blocker:prune'));
expect($scheduled)->toBeFalse();
});
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Mattitja\BadIpBlocker\Tests;
abstract class OptOutTestCase extends TestCase
{
protected function defineEnvironment($app): void
{
parent::defineEnvironment($app);
$app['config']->set('bad-ip-blocker.auto_register', false);
$app['config']->set('bad-ip-blocker.schedule_prune', false);
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
use Mattitja\BadIpBlocker\Tests\OptOutTestCase;
use Mattitja\BadIpBlocker\Tests\TestCase;
uses(TestCase::class)->in('Feature', 'Unit');
uses(OptOutTestCase::class)->in('OptOut');
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Mattitja\BadIpBlocker\Tests;
use Illuminate\Support\Facades\File;
use Mattitja\BadIpBlocker\BadIpBlockerServiceProvider;
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
use Orchestra\Testbench\TestCase as Orchestra;
abstract class TestCase extends Orchestra
{
protected string $storageDir;
protected function getPackageProviders($app): array
{
return [BadIpBlockerServiceProvider::class];
}
protected function getPackageAliases($app): array
{
return ['BadIpBlocker' => BadIpBlocker::class];
}
protected function defineEnvironment($app): void
{
$this->storageDir = sys_get_temp_dir().'/bad-ip-blocker-tests-'.bin2hex(random_bytes(4));
$app['config']->set('bad-ip-blocker.storage_path', $this->storageDir.'/bad-ips.json');
$app['config']->set('cache.default', 'array');
}
protected function defineRoutes($router): void
{
$router->get('/', fn () => 'ok');
}
protected function tearDown(): void
{
File::deleteDirectory($this->storageDir);
parent::tearDown();
}
protected function getFrom(string $uri, string $ip)
{
return $this->withServerVariables(['REMOTE_ADDR' => $ip])->get($uri);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Log;
use Mattitja\BadIpBlocker\BadIpStore;
beforeEach(fn () => $this->store = app(BadIpStore::class));
afterEach(fn () => CarbonImmutable::setTestNow());
it('starts empty when no file exists', function () {
expect($this->store->all())->toBe([])
->and($this->store->isBlocked('203.0.113.10'))->toBeFalse();
});
it('blocks and unblocks an ip', function () {
$this->store->block('203.0.113.10', 'test');
expect($this->store->isBlocked('203.0.113.10'))->toBeTrue()
->and($this->store->all()['203.0.113.10']['reason'])->toBe('test');
expect($this->store->unblock('203.0.113.10'))->toBeTrue()
->and($this->store->unblock('203.0.113.10'))->toBeFalse()
->and($this->store->isBlocked('203.0.113.10'))->toBeFalse();
});
it('supports ipv6', function () {
$this->store->block('2001:db8::1');
expect($this->store->isBlocked('2001:db8::1'))->toBeTrue();
});
it('rejects invalid ips', function () {
$this->store->block('not-an-ip');
})->throws(InvalidArgumentException::class);
it('persists to the json file and is visible to other instances', function () {
$this->store->block('198.51.100.20');
$other = new BadIpStore($this->store->path());
expect($other->isBlocked('198.51.100.20'))->toBeTrue()
->and(json_decode(file_get_contents($this->store->path()), true)['ips'])->toHaveKey('198.51.100.20');
});
it('picks up changes made by another process', function () {
expect($this->store->isBlocked('192.0.2.30'))->toBeFalse();
(new BadIpStore($this->store->path()))->block('192.0.2.30');
expect($this->store->isBlocked('192.0.2.30'))->toBeTrue();
});
it('expires blocks with a ttl', function () {
CarbonImmutable::setTestNow('2026-11-11 20:00:00');
$this->store->block('203.0.113.10', null, 60);
expect($this->store->isBlocked('203.0.113.10'))->toBeTrue();
CarbonImmutable::setTestNow('2026-11-11 20:01:01');
expect($this->store->isBlocked('203.0.113.10'))->toBeFalse()
->and($this->store->all())->toBe([]);
});
it('prunes expired entries', function () {
CarbonImmutable::setTestNow('2026-11-11 20:00:00');
$this->store->block('203.0.113.10', null, 60);
$this->store->block('198.51.100.20');
CarbonImmutable::setTestNow('2026-11-11 21:00:00');
expect($this->store->prune())->toBe(1)
->and(array_keys($this->store->all()))->toBe(['198.51.100.20']);
});
it('clears all entries', function () {
$this->store->block('203.0.113.10');
$this->store->clear();
expect($this->store->all())->toBe([]);
});
it('treats a corrupt file as empty and logs a warning', function () {
Log::spy();
@mkdir(dirname($this->store->path()), 0755, true);
file_put_contents($this->store->path(), '{not json');
expect($this->store->all())->toBe([]);
Log::shouldHaveReceived('warning')->once();
});