FileThrottleStoreTest.php 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Auth;
  4. use App\Auth\FileThrottleStore;
  5. use App\Auth\LoginThrottle;
  6. use Monolog\Handler\NullHandler;
  7. use Monolog\Logger;
  8. use PHPUnit\Framework\TestCase;
  9. use Psr\Log\LoggerInterface;
  10. /**
  11. * SEC_REVIEW F6 regression: persisting the throttle counters to a file
  12. * shared by all FrankenPHP workers must survive a worker recycle (a fresh
  13. * `LoginThrottle` instance over the same path) and serialise updates from
  14. * concurrent workers.
  15. */
  16. final class FileThrottleStoreTest extends TestCase
  17. {
  18. private string $path;
  19. protected function setUp(): void
  20. {
  21. $this->path = sys_get_temp_dir() . '/irdb_throttle_test_' . bin2hex(random_bytes(8)) . '.json';
  22. }
  23. protected function tearDown(): void
  24. {
  25. @unlink($this->path);
  26. @unlink($this->path . '.lock');
  27. }
  28. public function testFailureRecordedOnOneInstanceIsVisibleToAnother(): void
  29. {
  30. $a = $this->throttle();
  31. for ($i = 0; $i < 4; ++$i) {
  32. $a->recordFailure('admin', '10.0.0.1');
  33. }
  34. self::assertFalse($a->isLocked('admin', '10.0.0.1'));
  35. // Simulates a FrankenPHP worker recycle: throw away the old
  36. // singleton, build a new LoginThrottle pointing at the same file.
  37. $b = $this->throttle();
  38. self::assertFalse($b->isLocked('admin', '10.0.0.1'));
  39. // The 5th failure recorded by the second instance trips the lockout
  40. // because it accumulates against the persisted count from the first.
  41. $b->recordFailure('admin', '10.0.0.1');
  42. self::assertTrue($b->isLocked('admin', '10.0.0.1'));
  43. // And the first instance, on its next read, sees the lockout too.
  44. self::assertTrue($a->isLocked('admin', '10.0.0.1'));
  45. }
  46. public function testClearOnOneInstanceIsVisibleToAnother(): void
  47. {
  48. $a = $this->throttle();
  49. for ($i = 0; $i < 5; ++$i) {
  50. $a->recordFailure('admin', '10.0.0.1');
  51. }
  52. self::assertTrue($a->isLocked('admin', '10.0.0.1'));
  53. $b = $this->throttle();
  54. $b->clear('admin', '10.0.0.1');
  55. self::assertFalse($a->isLocked('admin', '10.0.0.1'));
  56. }
  57. public function testMissingFileLoadsAsEmpty(): void
  58. {
  59. // No prior writes — load() must return both empty buckets without
  60. // creating the file.
  61. $store = new FileThrottleStore($this->path, $this->logger());
  62. $state = $store->load();
  63. self::assertSame(['ip' => [], 'username' => []], $state);
  64. self::assertFileDoesNotExist($this->path);
  65. }
  66. public function testCorruptFileIsTreatedAsEmpty(): void
  67. {
  68. file_put_contents($this->path, '{not json');
  69. $t = $this->throttle();
  70. self::assertFalse($t->isLocked('admin', '10.0.0.1'));
  71. // And we can still write afterwards (the next mutate replaces the
  72. // corrupt content via temp + rename).
  73. $t->recordFailure('admin', '10.0.0.1');
  74. self::assertGreaterThan(0, filesize($this->path));
  75. }
  76. public function testStaleEntriesGarbageCollected(): void
  77. {
  78. $now = 1_000_000;
  79. $store = new FileThrottleStore($this->path, $this->logger(), function () use (&$now): int {
  80. return $now;
  81. });
  82. $throttle = new LoginThrottle($this->logger(), function () use (&$now): int {
  83. return $now;
  84. }, $store);
  85. // Trip a per-IP lockout (5 failures).
  86. for ($i = 0; $i < 5; ++$i) {
  87. $throttle->recordFailure('admin', '10.0.0.1');
  88. }
  89. self::assertTrue($throttle->isLocked('admin', '10.0.0.1'));
  90. // Advance well past the stale-lock GC window. The next mutation
  91. // (any recordFailure) must drop the dormant per-IP entry from disk.
  92. $now += 86400 + 3600;
  93. $throttle->recordFailure('other', '10.0.0.99');
  94. $raw = (string) file_get_contents($this->path);
  95. $decoded = json_decode($raw, true);
  96. self::assertIsArray($decoded);
  97. self::assertArrayNotHasKey('admin|10.0.0.1', $decoded['ip']);
  98. self::assertArrayHasKey('other|10.0.0.99', $decoded['ip']);
  99. }
  100. public function testWritesGoThroughTempPlusRename(): void
  101. {
  102. // Sanity: after a mutation, no `.tmp.*` siblings should leak in
  103. // the directory. Confirms the rename path completes cleanly.
  104. $t = $this->throttle();
  105. $t->recordFailure('admin', '10.0.0.1');
  106. $dir = dirname($this->path);
  107. $base = basename($this->path);
  108. $leftovers = glob($dir . '/' . $base . '.tmp.*');
  109. self::assertSame([], $leftovers !== false ? $leftovers : []);
  110. }
  111. public function testResetUnlinksFile(): void
  112. {
  113. $t = $this->throttle();
  114. $t->recordFailure('admin', '10.0.0.1');
  115. self::assertFileExists($this->path);
  116. $t->reset();
  117. self::assertFileDoesNotExist($this->path);
  118. self::assertFileDoesNotExist($this->path . '.lock');
  119. }
  120. private function throttle(): LoginThrottle
  121. {
  122. return new LoginThrottle(
  123. $this->logger(),
  124. null,
  125. new FileThrottleStore($this->path, $this->logger()),
  126. );
  127. }
  128. private function logger(): LoggerInterface
  129. {
  130. $l = new Logger('test');
  131. $l->pushHandler(new NullHandler());
  132. return $l;
  133. }
  134. }