| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\Auth;
- use App\Auth\FileThrottleStore;
- use App\Auth\LoginThrottle;
- use Monolog\Handler\NullHandler;
- use Monolog\Logger;
- use PHPUnit\Framework\TestCase;
- use Psr\Log\LoggerInterface;
- /**
- * SEC_REVIEW F6 regression: persisting the throttle counters to a file
- * shared by all FrankenPHP workers must survive a worker recycle (a fresh
- * `LoginThrottle` instance over the same path) and serialise updates from
- * concurrent workers.
- */
- final class FileThrottleStoreTest extends TestCase
- {
- private string $path;
- protected function setUp(): void
- {
- $this->path = sys_get_temp_dir() . '/irdb_throttle_test_' . bin2hex(random_bytes(8)) . '.json';
- }
- protected function tearDown(): void
- {
- @unlink($this->path);
- @unlink($this->path . '.lock');
- }
- public function testFailureRecordedOnOneInstanceIsVisibleToAnother(): void
- {
- $a = $this->throttle();
- for ($i = 0; $i < 4; ++$i) {
- $a->recordFailure('admin', '10.0.0.1');
- }
- self::assertFalse($a->isLocked('admin', '10.0.0.1'));
- // Simulates a FrankenPHP worker recycle: throw away the old
- // singleton, build a new LoginThrottle pointing at the same file.
- $b = $this->throttle();
- self::assertFalse($b->isLocked('admin', '10.0.0.1'));
- // The 5th failure recorded by the second instance trips the lockout
- // because it accumulates against the persisted count from the first.
- $b->recordFailure('admin', '10.0.0.1');
- self::assertTrue($b->isLocked('admin', '10.0.0.1'));
- // And the first instance, on its next read, sees the lockout too.
- self::assertTrue($a->isLocked('admin', '10.0.0.1'));
- }
- public function testClearOnOneInstanceIsVisibleToAnother(): void
- {
- $a = $this->throttle();
- for ($i = 0; $i < 5; ++$i) {
- $a->recordFailure('admin', '10.0.0.1');
- }
- self::assertTrue($a->isLocked('admin', '10.0.0.1'));
- $b = $this->throttle();
- $b->clear('admin', '10.0.0.1');
- self::assertFalse($a->isLocked('admin', '10.0.0.1'));
- }
- public function testMissingFileLoadsAsEmpty(): void
- {
- // No prior writes — load() must return both empty buckets without
- // creating the file.
- $store = new FileThrottleStore($this->path, $this->logger());
- $state = $store->load();
- self::assertSame(['ip' => [], 'username' => []], $state);
- self::assertFileDoesNotExist($this->path);
- }
- public function testCorruptFileIsTreatedAsEmpty(): void
- {
- file_put_contents($this->path, '{not json');
- $t = $this->throttle();
- self::assertFalse($t->isLocked('admin', '10.0.0.1'));
- // And we can still write afterwards (the next mutate replaces the
- // corrupt content via temp + rename).
- $t->recordFailure('admin', '10.0.0.1');
- self::assertGreaterThan(0, filesize($this->path));
- }
- public function testStaleEntriesGarbageCollected(): void
- {
- $now = 1_000_000;
- $store = new FileThrottleStore($this->path, $this->logger(), function () use (&$now): int {
- return $now;
- });
- $throttle = new LoginThrottle($this->logger(), function () use (&$now): int {
- return $now;
- }, $store);
- // Trip a per-IP lockout (5 failures).
- for ($i = 0; $i < 5; ++$i) {
- $throttle->recordFailure('admin', '10.0.0.1');
- }
- self::assertTrue($throttle->isLocked('admin', '10.0.0.1'));
- // Advance well past the stale-lock GC window. The next mutation
- // (any recordFailure) must drop the dormant per-IP entry from disk.
- $now += 86400 + 3600;
- $throttle->recordFailure('other', '10.0.0.99');
- $raw = (string) file_get_contents($this->path);
- $decoded = json_decode($raw, true);
- self::assertIsArray($decoded);
- self::assertArrayNotHasKey('admin|10.0.0.1', $decoded['ip']);
- self::assertArrayHasKey('other|10.0.0.99', $decoded['ip']);
- }
- public function testWritesGoThroughTempPlusRename(): void
- {
- // Sanity: after a mutation, no `.tmp.*` siblings should leak in
- // the directory. Confirms the rename path completes cleanly.
- $t = $this->throttle();
- $t->recordFailure('admin', '10.0.0.1');
- $dir = dirname($this->path);
- $base = basename($this->path);
- $leftovers = glob($dir . '/' . $base . '.tmp.*');
- self::assertSame([], $leftovers !== false ? $leftovers : []);
- }
- public function testResetUnlinksFile(): void
- {
- $t = $this->throttle();
- $t->recordFailure('admin', '10.0.0.1');
- self::assertFileExists($this->path);
- $t->reset();
- self::assertFileDoesNotExist($this->path);
- self::assertFileDoesNotExist($this->path . '.lock');
- }
- private function throttle(): LoginThrottle
- {
- return new LoginThrottle(
- $this->logger(),
- null,
- new FileThrottleStore($this->path, $this->logger()),
- );
- }
- private function logger(): LoggerInterface
- {
- $l = new Logger('test');
- $l->pushHandler(new NullHandler());
- return $l;
- }
- }
|