@anonympins/fingerprint 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,300 +1,331 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint\Tests;
6
-
7
- use Anonympins\Fingerprint\Config\SecurityProfiles;
8
- use Anonympins\Fingerprint\FingerprintBuilder;
9
- use Anonympins\Fingerprint\FingerprintEngine;
10
- use Anonympins\Fingerprint\RequestContext;
11
- use Anonympins\Fingerprint\Store\InMemoryStore;
12
- use Anonympins\Fingerprint\Store\StoreManager;
13
- use PHPUnit\Framework\TestCase;
14
-
15
- class FingerprintEngineTest extends TestCase
16
- {
17
- private FingerprintEngine $engine;
18
- private array $securityConfig;
19
-
20
- /**
21
- * Cette méthode est appelée avant chaque test.
22
- * Elle garantit que chaque test s'exécute dans un environnement propre.
23
- */
24
- protected function setUp(): void
25
- {
26
- // 1. Utiliser un store en mémoire propre pour chaque test
27
- $store = new InMemoryStore();
28
- StoreManager::configureStore($store);
29
-
30
- // 2. Charger une configuration de sécurité de base pour les tests
31
- $this->securityConfig = SecurityProfiles::createSecurityProfile('balanced', [
32
- 'verbose' => false, // Désactiver les logs pour ne pas polluer la sortie des tests
33
- 'challengeNewDevices' => false, // Simplifie les tests de base
34
- ]);
35
-
36
- // 3. Créer une nouvelle instance du moteur pour chaque test
37
- $this->engine = new FingerprintEngine($this->securityConfig);
38
- }
39
-
40
- /**
41
- * Crée un contexte de requête de base pour les tests.
42
- *
43
- * @param array<string, mixed> $overrides
44
- * @return RequestContext
45
- */
46
- private function createRequestContext(array $overrides = []): RequestContext
47
- {
48
- $defaults = [
49
- 'clientIp' => '192.168.1.10',
50
- 'path' => '/',
51
- 'headers' => [
52
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
53
- 'accept-language' => 'en-US,en;q=0.9',
54
- ],
55
- 'query' => [],
56
- 'body' => null,
57
- 'cookies' => [],
58
- 'httpVersion' => '1.1',
59
- ];
60
-
61
- $params = array_merge($defaults, $overrides);
62
-
63
- return new RequestContext(
64
- $params['clientIp'],
65
- $params['path'],
66
- $params['headers'],
67
- $params['query'],
68
- $params['body'],
69
- $params['cookies'],
70
- $params['httpVersion']
71
- );
72
- }
73
-
74
- public function testAllowsLegitimateRequest(): void
75
- {
76
- $context = $this->createRequestContext();
77
-
78
- // Simule une première visite
79
- $firstDecision = $this->engine->processRequest($context);
80
- $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "Un nouveau cookie aurait dû être généré.");
81
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
82
-
83
- // Simule une deuxième visite avec le cookie reçu
84
- $nextContext = $this->createRequestContext([
85
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']]
86
- ]);
87
- $secondDecision = $this->engine->processRequest($nextContext);
88
-
89
- $this->assertEquals('next', $secondDecision['action']);
90
- $this->assertLessThan($this->securityConfig['thresholds']['low'], $secondDecision['score']);
91
- }
92
-
93
- public function testIssuesChallengeForSuspiciousRequest(): void
94
- {
95
- // 1. First, establish a device identity to avoid the cookie-dropping penalty
96
- $firstContext = $this->createRequestContext();
97
- $firstDecision = $this->engine->processRequest($firstContext);
98
- $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "A device ID cookie should have been generated.");
99
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
100
-
101
- // 2. Now, simulate a suspicious request from that same device (e.g., missing user-agent)
102
- $suspiciousContext = $this->createRequestContext([
103
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
104
- 'headers' => [
105
- // No user-agent, which is anomalous
106
- 'accept-language' => 'en-US,en;q=0.9',
107
- ]
108
- ]);
109
-
110
- $decision = $this->engine->processRequest($suspiciousContext);
111
-
112
- $this->assertEquals('challenge', $decision['action'], "The action should be to issue a challenge.");
113
- $this->assertGreaterThanOrEqual($this->securityConfig['thresholds']['low'], $decision['score'], "The score should be above the 'low' threshold.");
114
- $this->assertLessThan($this->securityConfig['thresholds']['block'], $decision['score']);
115
- }
116
-
117
- public function testBlocksHighlySuspiciousRequest(): void
118
- {
119
- // Configure the engine to recognize the 'email_confirm' honeypot field.
120
- // We need to create a new engine instance for this test with a custom config.
121
- $config = SecurityProfiles::createSecurityProfile('balanced', [
122
- 'honeypot' => ['fields' => ['email_confirm']],
123
- 'verbose' => true,
124
- 'weights'=> ['honeypotScore' => 1]
125
- ]);
126
- $engine = new FingerprintEngine($config);
127
-
128
- // 1. First, establish a device identity to avoid other scores interfering.
129
- $firstContext = $this->createRequestContext();
130
- $firstDecision = $engine->processRequest($firstContext);
131
- $this->assertArrayHasKey('newCookieForResponse', $firstDecision);
132
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
133
-
134
- // 2. Now, simulate a request from that device that falls into a honeypot trap.
135
- $honeypotContext = $this->createRequestContext([
136
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
137
- // The bot filled in a hidden field, triggering the honeypot.
138
- 'body' => ['email_confirm' => 'bot@example.com']
139
- ]);
140
-
141
- $decision = $engine->processRequest($honeypotContext);
142
-
143
- $this->assertEquals('block', $decision['action']);
144
- $this->assertGreaterThanOrEqual($config['thresholds']['block'], $decision['score']);
145
- }
146
-
147
- public function testDetectsCookieDropping(): void
148
- {
149
- $context = $this->createRequestContext(['clientIp' => '1.2.3.4']);
150
-
151
- // 1. Première requête, un "pending_cookie" est défini pour cette IP
152
- $this->engine->processRequest($context);
153
-
154
- // 2. Deuxième requête de la même IP, mais sans le cookie attendu
155
- $contextWithoutCookie = $this->createRequestContext(['clientIp' => '1.2.3.4']);
156
- $decision = $this->engine->processRequest($contextWithoutCookie);
157
-
158
- // Le score de "cookieDropping" devrait être de 100
159
- $this->assertEquals(100, $decision['vector']['cookieDroppingScore']);
160
- // L'action devrait être un challenge car le score final sera élevé
161
- $this->assertEquals('challenge', $decision['action']);
162
- }
163
-
164
- public function testDetectsFingerprintInconsistency(): void
165
- {
166
- $context = $this->createRequestContext();
167
-
168
- // 1. Première visite, le fingerprint initial est stocké
169
- $firstDecision = $this->engine->processRequest($context);
170
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
171
-
172
- // 2. Deuxième visite avec le même cookie, mais un User-Agent complètement différent (incohérence)
173
- $nextContext = $this->createRequestContext([
174
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
175
- 'headers' => [
176
- 'user-agent' => 'DefinitelyNotTheSameBrowser/1.0',
177
- 'accept-language' => 'fr-FR',
178
- ]
179
- ]);
180
-
181
- $decision = $this->engine->processRequest($nextContext);
182
-
183
- // Le score d'incohérence devrait être de 100
184
- $this->assertEquals(100, $decision['vector']['inconsistencyScore']);
185
- // L'action devrait être un challenge ou un blocage
186
- $this->assertContains($decision['action'], ['challenge', 'block']);
187
- }
188
-
189
- public function testAllowsWhitelistedIp(): void
190
- {
191
- $config = SecurityProfiles::createSecurityProfile('strict', [
192
- 'whitelist' => [['type' => 'allowlist', 'entries' => ['10.0.0.1']]]
193
- ]);
194
- $engine = new FingerprintEngine($config);
195
-
196
- $context = $this->createRequestContext(['clientIp' => '10.0.0.1']);
197
- $decision = $engine->processRequest($context);
198
-
199
- $this->assertEquals('next', $decision['action']);
200
- $this->assertEquals(0, $decision['score']);
201
- }
202
- public function testFingerprintBuilderCompareLogic(): void
203
- {
204
- $fp1 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
205
- $fp2 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
206
- $fp3 = (new FingerprintBuilder())->add('hw', '4_8')->add('gpu', 'amd')->__toString(); // hw:1039882088834313|gpu:14339535343484648
207
- $fp4 = (new FingerprintBuilder())->add('hw', '8_16')->add('os', 'win32')->__toString(); // hw:1039882088834313|os:14339535343484648
208
-
209
- $this->assertEquals(1.0, FingerprintBuilder::compare($fp1, $fp2), "Identical FPs should return 1.0");
210
- $this->assertLessThan(0.5, FingerprintBuilder::compare($fp1, $fp3), "Different FPs should have low similarity");
211
-
212
- // The partial match score calculation reflects the current weights in FingerprintBuilder.
213
- // Matching keys: 'hw' (weight 1.5).
214
- // All relevant keys considered from both fingerprints: 'hw' (1.5), 'gpu' (4.0), 'os' (0.8).
215
- // Total weight = 1.5 + 4.0 + 0.8 = 6.3.
216
- // Score = 1.5 / 6.3 = ~0.238095... (This logic is correct, the test is fine)
217
- $this->assertEqualsWithDelta(0.238, FingerprintBuilder::compare($fp1, $fp4), 0.001, "Partial match score should reflect current weights"); // This test is correct, no change needed.
218
- }
219
-
220
- /**
221
- * @dataProvider clientHintsInconsistencyProvider
222
- * @param array<string, string> $headers Les en-têtes à tester.
223
- * @param float $expectedScore Le score attendu.
224
- * @param string $message Le message de test.
225
- */
226
- public function testDetectsClientHintsInconsistency(array $headers, float $expectedScore, string $message): void
227
- {
228
- // Le test nécessite un deviceId, donc on simule une première visite.
229
- // Les en-têtes de la première visite n'ont pas d'importance ici.
230
- $firstDecision = $this->engine->processRequest($this->createRequestContext());
231
- $deviceIdCookie = $firstDecision['newCookieForResponse'] ?? null;
232
- $this->assertNotNull($deviceIdCookie, "Un cookie deviceId aurait dû être créé.");
233
-
234
- // On simule la visite suivante avec les en-têtes à tester.
235
- $testContext = $this->createRequestContext([
236
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
237
- 'headers' => $headers,
238
- ]);
239
-
240
- $decision = $this->engine->processRequest($testContext);
241
- $this->assertEquals($expectedScore, $decision['vector']['clientHintsInconsistencyScore'], $message);
242
- }
243
-
244
- /**
245
- * Fournisseur de données pour le test d'incohérence des Client Hints.
246
- * @return array<string, array{0: array<string, string>, 1: float, 2: string}>
247
- */
248
- public function clientHintsInconsistencyProvider(): array
249
- {
250
- $baseHeaders = ['accept-language' => 'en-US,en;q=0.9'];
251
-
252
- return [
253
- 'no inconsistency' => [
254
- array_merge($baseHeaders, [
255
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
256
- 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
257
- ]),
258
- 0.0,
259
- 'Should return 0 for consistent headers.'
260
- ],
261
- 'large version mismatch' => [
262
- array_merge($baseHeaders, [
263
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
264
- 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
265
- ]),
266
- 80.0,
267
- 'Should return 80 for a large version mismatch (>5).'
268
- ],
269
- 'small version mismatch' => [
270
- array_merge($baseHeaders, [
271
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
272
- 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
273
- ]),
274
- 40.0,
275
- 'Should return 40 for a small version mismatch (>1).'
276
- ],
277
- 'browser mismatch' => [
278
- array_merge($baseHeaders, [
279
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
280
- 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
281
- ]),
282
- 90.0,
283
- 'Should return 90 for a browser mismatch (Firefox vs Chrome).'
284
- ],
285
- 'missing client hints header' => [
286
- ['user-agent' => 'Mozilla/5.0 Chrome/120.0.0.0'],
287
- 0.0,
288
- 'Should return 0 if sec-ch-ua header is missing.'
289
- ],
290
- 'malformed client hints' => [
291
- array_merge($baseHeaders, [
292
- 'user-agent' => 'Mozilla/5.0 Chrome/120.0.0.0',
293
- 'sec-ch-ua' => '"SomeOtherBrowser";v="abc"',
294
- ]),
295
- 0.0,
296
- 'Should return 0 if headers are malformed or unparsable.'
297
- ],
298
- ];
299
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
8
+ use Anonympins\Fingerprint\FingerprintBuilder;
9
+ use Anonympins\Fingerprint\FingerprintEngine;
10
+ use Anonympins\Fingerprint\RequestContext;
11
+ use Anonympins\Fingerprint\Store\InMemoryStore;
12
+ use Anonympins\Fingerprint\Store\StoreManager;
13
+ use PHPUnit\Framework\TestCase;
14
+
15
+ class FingerprintEngineTest extends TestCase
16
+ {
17
+ private FingerprintEngine $engine;
18
+ private array $securityConfig;
19
+
20
+ /**
21
+ * Cette méthode est appelée avant chaque test.
22
+ * Elle garantit que chaque test s'exécute dans un environnement propre.
23
+ */
24
+ protected function setUp(): void
25
+ {
26
+ // 1. Utiliser un store en mémoire propre pour chaque test
27
+ $store = new InMemoryStore();
28
+ StoreManager::configureStore($store);
29
+
30
+ // 2. Charger une configuration de sécurité de base pour les tests
31
+ $this->securityConfig = SecurityProfiles::createSecurityProfile('balanced', [
32
+ 'verbose' => false, // Désactiver les logs pour ne pas polluer la sortie des tests
33
+ 'challengeNewDevices' => false, // Simplifie les tests de base
34
+ ]);
35
+
36
+ // 3. Créer une nouvelle instance du moteur pour chaque test
37
+ $this->engine = new FingerprintEngine($this->securityConfig);
38
+ }
39
+
40
+ /**
41
+ * Crée un contexte de requête de base pour les tests.
42
+ *
43
+ * @param array<string, mixed> $overrides
44
+ * @return RequestContext
45
+ */
46
+ private function createRequestContext(array $overrides = []): RequestContext
47
+ {
48
+ $defaults = [
49
+ 'clientIp' => '192.168.1.10',
50
+ 'path' => '/',
51
+ 'headers' => [
52
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
53
+ 'accept-language' => 'en-US,en;q=0.9',
54
+ ],
55
+ 'query' => [],
56
+ 'body' => null,
57
+ 'cookies' => [],
58
+ 'httpVersion' => '1.1',
59
+ ];
60
+
61
+ $params = array_merge($defaults, $overrides);
62
+
63
+ return new RequestContext(
64
+ $params['clientIp'],
65
+ $params['path'],
66
+ $params['headers'],
67
+ $params['query'],
68
+ $params['body'],
69
+ $params['cookies'],
70
+ $params['httpVersion']
71
+ );
72
+ }
73
+
74
+ public function testAllowsLegitimateRequest(): void
75
+ {
76
+ $context = $this->createRequestContext();
77
+
78
+ // Simule une première visite
79
+ $firstDecision = $this->engine->processRequest($context);
80
+ $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "Un nouveau cookie aurait dû être généré.");
81
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
82
+
83
+ // Simule une deuxième visite avec le cookie reçu
84
+ $nextContext = $this->createRequestContext([
85
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']]
86
+ ]);
87
+ $secondDecision = $this->engine->processRequest($nextContext);
88
+
89
+ $this->assertEquals('next', $secondDecision['action']);
90
+ $this->assertLessThan($this->securityConfig['thresholds']['low'], $secondDecision['score']);
91
+ }
92
+
93
+ public function testIssuesChallengeForSuspiciousRequest(): void
94
+ {
95
+ // 1. First, establish a device identity to avoid the cookie-dropping penalty
96
+ $firstContext = $this->createRequestContext();
97
+ $firstDecision = $this->engine->processRequest($firstContext);
98
+ $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "A device ID cookie should have been generated.");
99
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
100
+
101
+ // 2. Now, simulate a suspicious request from that same device (e.g., missing user-agent)
102
+ $suspiciousContext = $this->createRequestContext([
103
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
104
+ 'headers' => [
105
+ // No user-agent, which is anomalous
106
+ 'accept-language' => 'en-US,en;q=0.9',
107
+ ]
108
+ ]);
109
+
110
+ $decision = $this->engine->processRequest($suspiciousContext);
111
+
112
+ $this->assertEquals('challenge', $decision['action'], "The action should be to issue a challenge.");
113
+ $this->assertGreaterThanOrEqual($this->securityConfig['thresholds']['low'], $decision['score'], "The score should be above the 'low' threshold.");
114
+ $this->assertLessThan($this->securityConfig['thresholds']['block'], $decision['score']);
115
+ }
116
+
117
+ public function testBlocksHighlySuspiciousRequest(): void
118
+ {
119
+ // Configure the engine to recognize the 'email_confirm' honeypot field.
120
+ // We need to create a new engine instance for this test with a custom config.
121
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
122
+ 'honeypot' => ['fields' => ['email_confirm']],
123
+ 'verbose' => true,
124
+ 'weights'=> ['honeypotScore' => 1]
125
+ ]);
126
+ $engine = new FingerprintEngine($config);
127
+
128
+ // 1. First, establish a device identity to avoid other scores interfering.
129
+ $firstContext = $this->createRequestContext();
130
+ $firstDecision = $engine->processRequest($firstContext);
131
+ $this->assertArrayHasKey('newCookieForResponse', $firstDecision);
132
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
133
+
134
+ // 2. Now, simulate a request from that device that falls into a honeypot trap.
135
+ $honeypotContext = $this->createRequestContext([
136
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
137
+ // The bot filled in a hidden field, triggering the honeypot.
138
+ 'body' => ['email_confirm' => 'bot@example.com']
139
+ ]);
140
+
141
+ $decision = $engine->processRequest($honeypotContext);
142
+
143
+ $this->assertEquals('block', $decision['action']);
144
+ $this->assertGreaterThanOrEqual($config['thresholds']['block'], $decision['score']);
145
+ }
146
+
147
+ public function testDetectsCookieDropping(): void
148
+ {
149
+ $context = $this->createRequestContext(['clientIp' => '1.2.3.4']);
150
+
151
+ // 1. Première requête, un "pending_cookie" est défini pour cette IP
152
+ $this->engine->processRequest($context);
153
+
154
+ // 2. Deuxième requête de la même IP, mais sans le cookie attendu
155
+ $contextWithoutCookie = $this->createRequestContext(['clientIp' => '1.2.3.4']);
156
+ $decision = $this->engine->processRequest($contextWithoutCookie);
157
+
158
+ // Le score de "cookieDropping" devrait être de 100
159
+ $this->assertEquals(100, $decision['vector']['cookieDroppingScore']);
160
+ // L'action devrait être un challenge car le score final sera élevé
161
+ $this->assertEquals('challenge', $decision['action']);
162
+ }
163
+
164
+ public function testDetectsFingerprintInconsistency(): void
165
+ {
166
+ $context = $this->createRequestContext();
167
+
168
+ // 1. Première visite, le fingerprint initial est stocké
169
+ $firstDecision = $this->engine->processRequest($context);
170
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
171
+
172
+ // 2. Deuxième visite avec le même cookie, mais un User-Agent complètement différent (incohérence)
173
+ $nextContext = $this->createRequestContext([
174
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
175
+ 'headers' => [
176
+ 'user-agent' => 'DefinitelyNotTheSameBrowser/1.0',
177
+ 'accept-language' => 'fr-FR',
178
+ ]
179
+ ]);
180
+
181
+ $decision = $this->engine->processRequest($nextContext);
182
+
183
+ // Le score d'incohérence devrait être de 100
184
+ $this->assertEquals(100, $decision['vector']['inconsistencyScore']);
185
+ // L'action devrait être un challenge ou un blocage
186
+ $this->assertContains($decision['action'], ['challenge', 'block']);
187
+ }
188
+
189
+ public function testAllowsWhitelistedIp(): void
190
+ {
191
+ $config = SecurityProfiles::createSecurityProfile('strict', [
192
+ 'whitelist' => [['type' => 'allowlist', 'entries' => ['10.0.0.1']]]
193
+ ]);
194
+ $engine = new FingerprintEngine($config);
195
+
196
+ $context = $this->createRequestContext(['clientIp' => '10.0.0.1']);
197
+ $decision = $engine->processRequest($context);
198
+
199
+ $this->assertEquals('next', $decision['action']);
200
+ $this->assertEquals(0, $decision['score']);
201
+ }
202
+
203
+ public function testReChallengeWhenScoreIsHighEvenWithValidTicket(): void
204
+ {
205
+ // 1. Établir l'identité d'origine du terminal
206
+ $context = $this->createRequestContext();
207
+ $decision = $this->engine->processRequest($context);
208
+ $deviceIdCookie = $decision['newCookieForResponse'];
209
+
210
+ // Générer un ticket de clearance cryptographiquement valide
211
+ $expiry = (time() + 3600) * 1000;
212
+ $secret = getenv('POW_SECRET') ?: "fallback-dev-secret-32-chars-minimum";
213
+ $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", $secret);
214
+ $validTicket = "{$expiry}:{$signature}";
215
+
216
+ // Créer une requête avec un ticket valide, mais avec un UA et des attributs incohérents provoquant un score >= 75
217
+ $suspiciousContext = $this->createRequestContext([
218
+ 'cookies' => [
219
+ $deviceIdCookie['name'] => $deviceIdCookie['value'],
220
+ 'pow_clearance' => $validTicket
221
+ ],
222
+ 'headers' => [
223
+ 'user-agent' => 'DefinitelyNotTheSameBrowser/1.0', // Incohérence matérielle forte
224
+ 'accept-language' => 'fr-FR',
225
+ ]
226
+ ]);
227
+
228
+ $redecision = $this->engine->processRequest($suspiciousContext);
229
+
230
+ // Malgré le ticket valide, le score est élevé, une nouvelle vérification PoW doit être imposée
231
+ $this->assertEquals('challenge', $redecision['action']);
232
+ }
233
+ public function testFingerprintBuilderCompareLogic(): void
234
+ {
235
+ $fp1 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
236
+ $fp2 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
237
+ $fp3 = (new FingerprintBuilder())->add('hw', '4_8')->add('gpu', 'amd')->__toString(); // hw:1039882088834313|gpu:14339535343484648
238
+ $fp4 = (new FingerprintBuilder())->add('hw', '8_16')->add('os', 'win32')->__toString(); // hw:1039882088834313|os:14339535343484648
239
+
240
+ $this->assertEquals(1.0, FingerprintBuilder::compare($fp1, $fp2), "Identical FPs should return 1.0");
241
+ $this->assertLessThan(0.5, FingerprintBuilder::compare($fp1, $fp3), "Different FPs should have low similarity");
242
+
243
+ // The partial match score calculation reflects the current weights in FingerprintBuilder.
244
+ // Matching keys: 'hw' (weight 1.5).
245
+ // All relevant keys considered from both fingerprints: 'hw' (1.5), 'gpu' (4.0), 'os' (0.8).
246
+ // Total weight = 1.5 + 4.0 + 0.8 = 6.3.
247
+ // Score = 1.5 / 6.3 = ~0.238095... (This logic is correct, the test is fine)
248
+ $this->assertEqualsWithDelta(0.238, FingerprintBuilder::compare($fp1, $fp4), 0.001, "Partial match score should reflect current weights"); // This test is correct, no change needed.
249
+ }
250
+
251
+ /**
252
+ * @dataProvider clientHintsInconsistencyProvider
253
+ * @param array<string, string> $headers Les en-têtes à tester.
254
+ * @param float $expectedScore Le score attendu.
255
+ * @param string $message Le message de test.
256
+ */
257
+ public function testDetectsClientHintsInconsistency(array $headers, float $expectedScore, string $message): void
258
+ {
259
+ // Le test nécessite un deviceId, donc on simule une première visite.
260
+ // Les en-têtes de la première visite n'ont pas d'importance ici.
261
+ $firstDecision = $this->engine->processRequest($this->createRequestContext());
262
+ $deviceIdCookie = $firstDecision['newCookieForResponse'] ?? null;
263
+ $this->assertNotNull($deviceIdCookie, "Un cookie deviceId aurait être créé.");
264
+
265
+ // On simule la visite suivante avec les en-têtes à tester.
266
+ $testContext = $this->createRequestContext([
267
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
268
+ 'headers' => $headers,
269
+ ]);
270
+
271
+ $decision = $this->engine->processRequest($testContext);
272
+ $this->assertEquals($expectedScore, $decision['vector']['clientHintsInconsistencyScore'], $message);
273
+ }
274
+
275
+ /**
276
+ * Fournisseur de données pour le test d'incohérence des Client Hints.
277
+ * @return array<string, array{0: array<string, string>, 1: float, 2: string}>
278
+ */
279
+ public function clientHintsInconsistencyProvider(): array
280
+ {
281
+ $baseHeaders = ['accept-language' => 'en-US,en;q=0.9'];
282
+
283
+ return [
284
+ 'no inconsistency' => [
285
+ array_merge($baseHeaders, [
286
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
287
+ 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
288
+ ]),
289
+ 0.0,
290
+ 'Should return 0 for consistent headers.'
291
+ ],
292
+ 'large version mismatch' => [
293
+ array_merge($baseHeaders, [
294
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
295
+ 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
296
+ ]),
297
+ 80.0,
298
+ 'Should return 80 for a large version mismatch (>5).'
299
+ ],
300
+ 'small version mismatch' => [
301
+ array_merge($baseHeaders, [
302
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
303
+ 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
304
+ ]),
305
+ 40.0,
306
+ 'Should return 40 for a small version mismatch (>1).'
307
+ ],
308
+ 'browser mismatch' => [
309
+ array_merge($baseHeaders, [
310
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
311
+ 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
312
+ ]),
313
+ 90.0,
314
+ 'Should return 90 for a browser mismatch (Firefox vs Chrome).'
315
+ ],
316
+ 'missing client hints header' => [
317
+ ['user-agent' => 'Mozilla/5.0 Chrome/120.0.0.0'],
318
+ 0.0,
319
+ 'Should return 0 if sec-ch-ua header is missing.'
320
+ ],
321
+ 'malformed client hints' => [
322
+ array_merge($baseHeaders, [
323
+ 'user-agent' => 'Mozilla/5.0 Chrome/120.0.0.0',
324
+ 'sec-ch-ua' => '"SomeOtherBrowser";v="abc"',
325
+ ]),
326
+ 0.0,
327
+ 'Should return 0 if headers are malformed or unparsable.'
328
+ ],
329
+ ];
330
+ }
300
331
  }