1<?php
2
3namespace dokuwiki\plugin\captcha\test;
4
5use dokuwiki\plugin\captcha\IpCounter;
6use DokuWikiTest;
7
8/**
9 * Tests for the IpCounter class
10 *
11 * @group plugin_captcha
12 * @group plugins
13 */
14class IpCounterTest extends DokuWikiTest
15{
16    protected $pluginsEnabled = ['captcha'];
17
18    /** @var IpCounter */
19    protected $counter;
20
21    public function setUp(): void
22    {
23        parent::setUp();
24        global $conf;
25        $conf['plugin']['captcha']['logindenial'] = 5;
26        $conf['plugin']['captcha']['logindenial_max'] = 3600;
27        $this->counter = new IpCounter();
28        $this->counter->reset();
29    }
30
31    public function tearDown(): void
32    {
33        $this->counter->reset();
34        parent::tearDown();
35    }
36
37    public function testInitialState()
38    {
39        $this->assertEquals(0, $this->counter->get());
40        $this->assertEquals(0, $this->counter->getLastAttempt());
41    }
42
43    public function testIncrement()
44    {
45        $this->assertEquals(0, $this->counter->get());
46
47        $this->counter->increment();
48        $this->assertEquals(1, $this->counter->get());
49
50        $this->counter->increment();
51        $this->assertEquals(2, $this->counter->get());
52
53        $this->counter->increment();
54        $this->assertEquals(3, $this->counter->get());
55    }
56
57    public function testReset()
58    {
59        $this->counter->increment();
60        $this->counter->increment();
61        $this->assertEquals(2, $this->counter->get());
62
63        $this->counter->reset();
64        $this->assertEquals(0, $this->counter->get());
65    }
66
67    public function testGetLastAttempt()
68    {
69        $this->assertEquals(0, $this->counter->getLastAttempt());
70
71        $before = time();
72        $this->counter->increment();
73        $after = time();
74
75        $lastAttempt = $this->counter->getLastAttempt();
76        $this->assertGreaterThanOrEqual($before, $lastAttempt);
77        $this->assertLessThanOrEqual($after, $lastAttempt);
78    }
79
80    public function testCalculateTimeoutNoFailures()
81    {
82        $this->assertEquals(0, $this->counter->calculateTimeout());
83    }
84
85    public function testCalculateTimeoutDisabled()
86    {
87        global $conf;
88        $conf['plugin']['captcha']['logindenial'] = 0;
89        $counter = new IpCounter();
90
91        $counter->increment();
92        $this->assertEquals(0, $counter->calculateTimeout());
93
94        $counter->reset();
95    }
96
97    public function testCalculateTimeoutExponentialGrowth()
98    {
99        // First failure: base * 2^0 = 5
100        $this->counter->increment();
101        $this->assertEquals(5, $this->counter->calculateTimeout());
102
103        // Second failure: base * 2^1 = 10
104        $this->counter->increment();
105        $this->assertEquals(10, $this->counter->calculateTimeout());
106
107        // Third failure: base * 2^2 = 20
108        $this->counter->increment();
109        $this->assertEquals(20, $this->counter->calculateTimeout());
110
111        // Fourth failure: base * 2^3 = 40
112        $this->counter->increment();
113        $this->assertEquals(40, $this->counter->calculateTimeout());
114
115        // Fifth failure: base * 2^4 = 80
116        $this->counter->increment();
117        $this->assertEquals(80, $this->counter->calculateTimeout());
118    }
119
120    public function testCalculateTimeoutMaxCap()
121    {
122        // Add many failures to exceed the max
123        for ($i = 0; $i < 20; $i++) {
124            $this->counter->increment();
125        }
126
127        // Should be capped at max (3600)
128        $this->assertEquals(3600, $this->counter->calculateTimeout());
129    }
130
131    public function testCalculateTimeoutMaxCapLower()
132    {
133        global $conf;
134        $conf['plugin']['captcha']['logindenial_max'] = 100;
135        $counter = new IpCounter();
136
137        // Add many failures to exceed the max
138        for ($i = 0; $i < 20; $i++) {
139            $counter->increment();
140        }
141
142        // Should be capped at max (100)
143        $this->assertEquals(100, $counter->calculateTimeout());
144
145        $counter->reset();
146    }
147
148    public function testCalculateTimeoutDifferentBase()
149    {
150        global $conf;
151        $conf['plugin']['captcha']['logindenial'] = 10;
152        $counter = new IpCounter();
153
154        $counter->increment();
155        $this->assertEquals(10, $counter->calculateTimeout());
156
157        $counter->increment();
158        $this->assertEquals(20, $counter->calculateTimeout());
159
160        $counter->increment();
161        $this->assertEquals(40, $counter->calculateTimeout());
162
163        $counter->reset();
164    }
165
166    public function testGetRemainingTimeNoFailures()
167    {
168        $this->assertEquals(0, $this->counter->getRemainingTime());
169    }
170
171    public function testGetRemainingTimeImmediatelyAfterFailure()
172    {
173        $this->counter->increment();
174
175        $remaining = $this->counter->getRemainingTime();
176
177        // Immediately after increment, remaining should be close to full timeout (5s)
178        // Allow 1 second tolerance for test execution time
179        $this->assertGreaterThanOrEqual(4, $remaining);
180        $this->assertLessThanOrEqual(5, $remaining);
181    }
182
183    public function testGetRemainingTimeAfterTimeoutExpires()
184    {
185        $this->counter->increment();
186
187        // Manipulate the file's mtime to simulate time passing
188        $store = $this->getInaccessibleProperty($this->counter, 'store');
189        touch($store, time() - 10); // 10 seconds ago
190
191        // Timeout is 5 seconds, 10 seconds have passed, so remaining should be 0
192        $this->assertEquals(0, $this->counter->getRemainingTime());
193    }
194
195    public function testGetRemainingTimePartiallyElapsed()
196    {
197        $this->counter->increment();
198        $this->counter->increment(); // timeout = 10 seconds
199
200        // Manipulate the file's mtime to simulate 3 seconds passing
201        $store = $this->getInaccessibleProperty($this->counter, 'store');
202        touch($store, time() - 3);
203
204        $remaining = $this->counter->getRemainingTime();
205
206        // 10 second timeout, 3 seconds elapsed, ~7 seconds remaining
207        $this->assertGreaterThanOrEqual(6, $remaining);
208        $this->assertLessThanOrEqual(7, $remaining);
209    }
210}
211