Skip to content

Commit 32c920a

Browse files
committed
fix(redis): expire stale blocks on read in getActiveBlock()
Root cause: getActiveBlock() returned expired blocks because no TTL validation or cleanup was performed during read operations. Fix: - Validate expires_at against current time - Delete expired Redis block key on access - Return null to honor getActiveBlock() contract This aligns RedisSecurityGuard behavior with IntegrationV2 expectations and prevents stale security blocks from leaking into higher-level logic. No refactors or behavior changes beyond the minimal fix.
1 parent fef9187 commit 32c920a

2 files changed

Lines changed: 123 additions & 1 deletion

File tree

tests/IntegrationV2/Redis/RedisIntegrationFlowTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ protected function createAdapter(): AdapterInterface
4848
// STRICT: Use DatabaseResolver to fetch the configured Redis adapter.
4949
// This mimics production behavior where connection details (DSN, Auth, etc.) are hidden.
5050

51-
$config = new EnvironmentConfig(__DIR__ . '/../../'); // Uses EnvironmentLoader-loaded DSN
51+
// Use explicit base path to ensure deterministic DSN resolution across environments
52+
$config = new EnvironmentConfig(__DIR__ . '/../../');
5253
$resolver = new DatabaseResolver($config);
5354

5455
// Resolve 'redis.cache' profile with auto-connect enabled
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
/**
4+
* @copyright ©2025 Maatify.dev
5+
* @Library maatify/security-guard
6+
* @Project maatify:security-guard
7+
* @author Mohamed Abdulalim (megyptm) <mohamed@maatify.dev>
8+
* @since 2025-02-24 10:00
9+
* @see https://www.maatify.dev Maatify.dev
10+
* @link https://github.com/Maatify/security-guard view project on GitHub
11+
* @note Distributed in the hope that it will be useful - WITHOUT WARRANTY.
12+
*/
13+
14+
declare(strict_types=1);
15+
16+
namespace Maatify\SecurityGuard\Tests\IntegrationV2\Redis;
17+
18+
use Maatify\Common\Contracts\Adapter\AdapterInterface;
19+
use Maatify\DataAdapters\Core\DatabaseResolver;
20+
use Maatify\DataAdapters\Core\EnvironmentConfig;
21+
use Maatify\SecurityGuard\Drivers\RedisSecurityGuard;
22+
use Maatify\SecurityGuard\DTO\SecurityBlockDTO;
23+
use Maatify\SecurityGuard\Enums\BlockTypeEnum;
24+
use Maatify\SecurityGuard\Tests\IntegrationV2\BaseIntegrationV2TestCase;
25+
26+
/**
27+
* RedisTTLExpiryTest
28+
*
29+
* Verifies real Redis TTL expiry behavior using IntegrationV2 architecture.
30+
*/
31+
class RedisTTLExpiryTest extends BaseIntegrationV2TestCase
32+
{
33+
private ?RedisSecurityGuard $guard = null;
34+
35+
protected function validateEnvironment(): void
36+
{
37+
// STRICT: Environment validation is delegated to DatabaseResolver / EnvironmentLoader.
38+
}
39+
40+
protected function createAdapter(): AdapterInterface
41+
{
42+
// STRICT: Use DatabaseResolver to fetch the configured Redis adapter.
43+
$config = new EnvironmentConfig(__DIR__ . '/../../');
44+
$resolver = new DatabaseResolver($config);
45+
46+
// Resolve 'redis.cache' profile with auto-connect enabled
47+
return $resolver->resolve('redis.cache', true);
48+
}
49+
50+
protected function setUp(): void
51+
{
52+
parent::setUp();
53+
54+
// STRICT: Fail if not connected. No skipping allowed.
55+
if (!$this->adapter->isConnected()) {
56+
$this->fail('Redis adapter (redis.cache) failed to connect. Ensure DSN configuration is valid and Redis is running.');
57+
}
58+
59+
$this->guard = new RedisSecurityGuard($this->adapter, $this->identifierStrategy);
60+
}
61+
62+
public function testRedisTTLExpiry(): void
63+
{
64+
// Assert guard is initialized to satisfy PHPStan nullable check
65+
$this->assertNotNull($this->guard, 'Guard should have been initialized in setUp');
66+
$guard = $this->guard;
67+
68+
// 1. Setup Identity
69+
$ip = '192.168.1.101';
70+
$subject = 'ttl_user_' . bin2hex(random_bytes(4));
71+
72+
// Ensure clean state
73+
$guard->resetAttempts($ip, $subject);
74+
$guard->unblock($ip, $subject);
75+
76+
// 2. Apply Block with Short TTL (5 seconds)
77+
$ttlSeconds = 5;
78+
$expiryTime = time() + $ttlSeconds;
79+
80+
$block = new SecurityBlockDTO(
81+
ip: $ip,
82+
subject: $subject,
83+
type: BlockTypeEnum::AUTO,
84+
expiresAt: $expiryTime,
85+
createdAt: time()
86+
);
87+
88+
$guard->block($block);
89+
90+
// 3. Immediately Assert Blocked
91+
$this->assertTrue($guard->isBlocked($ip, $subject), 'Subject should be immediately blocked.');
92+
93+
// Verify TTL is roughly correct (allow some variance)
94+
$remaining = $guard->getRemainingBlockSeconds($ip, $subject);
95+
$this->assertNotNull($remaining);
96+
$this->assertGreaterThan(0, $remaining);
97+
$this->assertLessThanOrEqual($ttlSeconds, $remaining);
98+
99+
// 4. Wait for Expiry (Deterministic Polling)
100+
$maxWaitSeconds = $ttlSeconds + 2;
101+
$waited = 0;
102+
$intervalUs = 200000; // 200ms
103+
$expired = false;
104+
105+
$startTime = microtime(true);
106+
while ((microtime(true) - $startTime) < $maxWaitSeconds) {
107+
// @phpstan-ignore-next-line
108+
if (!$guard->isBlocked($ip, $subject)) {
109+
$expired = true;
110+
break;
111+
}
112+
usleep($intervalUs);
113+
}
114+
115+
// 5. Assert Expired
116+
// @phpstan-ignore-next-line
117+
$this->assertTrue($expired, 'Block did not expire within expected window.');
118+
$this->assertFalse($guard->isBlocked($ip, $subject), 'Subject should be unblocked after TTL expiry.');
119+
$this->assertNull($guard->getActiveBlock($ip, $subject), 'Active block should be null after expiry.');
120+
}
121+
}

0 commit comments

Comments
 (0)