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; } }