@anonympins/fingerprint 0.3.2 → 0.3.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +276 -34
  3. package/composer.json +38 -0
  4. package/index.js +5 -0
  5. package/package.json +23 -18
  6. package/phpunit.xml +20 -0
  7. package/public/fp.js +2 -0
  8. package/src/js/build-client.js +69 -0
  9. package/{fingerprint.builder.js → src/js/fingerprint.builder.js} +168 -175
  10. package/{fingerprint.client.js → src/js/fingerprint.client.js} +634 -538
  11. package/src/js/fingerprint.client.obfuscated.js +1 -0
  12. package/{fingerprint.js → src/js/fingerprint.js} +255 -101
  13. package/{library.js → src/js/library.js} +1729 -1729
  14. package/{problem-manager.js → src/js/problem-manager.js} +539 -522
  15. package/src/php/AutoTuner.php +155 -0
  16. package/src/php/Challenge/ChallengeUtils.php +306 -0
  17. package/src/php/Config/SecurityProfiles.php +257 -0
  18. package/src/php/DirectFingerprint.php +81 -0
  19. package/src/php/FingerprintBuilder.php +185 -0
  20. package/src/php/FingerprintClient.php +118 -0
  21. package/src/php/FingerprintEngine.php +850 -0
  22. package/src/php/Optimization/FunctionRegistry.php +63 -0
  23. package/src/php/Optimization/Optimization.php +256 -0
  24. package/src/php/Optimization/OptimizationOperators.php +305 -0
  25. package/src/php/Optimization/ProblemInitializers.php +53 -0
  26. package/src/php/ProblemManager.php +255 -0
  27. package/src/php/RequestContext.php +87 -0
  28. package/src/php/Store/IStore.php +42 -0
  29. package/src/php/Store/InMemoryStore.php +67 -0
  30. package/src/php/Store/StoreManager.php +26 -0
  31. package/src/php/Tests/ChallengeUtilsTest.php +82 -0
  32. package/src/php/Tests/FingerprintBuilderTest.php +58 -0
  33. package/src/php/Tests/FingerprintEngineTest.php +219 -0
  34. package/src/php/Tests/PowTest.php +40 -0
  35. package/src/php/Tests/ProblemManagerTest.php +295 -0
  36. package/src/php/Tests/RequestUtilsTest.php +81 -0
  37. package/src/php/Tests/problems.config.json +9 -0
  38. package/src/php/Utils/BigInt.php +102 -0
  39. package/src/php/Utils/BlockList.php +100 -0
  40. package/src/php/Utils/Logger.php +30 -0
  41. package/src/php/Utils/MaliciousPatterns.php +59 -0
  42. package/src/php/Utils/RequestUtils.php +673 -0
  43. package/fingerprint.client.obfuscated.js +0 -1
  44. /package/{mongodb-store.js → src/js/mongodb-store.js} +0 -0
  45. /package/{optimization.worker.js → src/js/optimization.worker.js} +0 -0
  46. /package/{pow.solver.inline.js → src/js/pow.solver.inline.js} +0 -0
  47. /package/{pow.solver.js → src/js/pow.solver.js} +0 -0
  48. /package/{pow.worker.js → src/js/pow.worker.js} +0 -0
  49. /package/{redis-store.js → src/js/redis-store.js} +0 -0
  50. /package/{sql-store.js → src/js/sql-store.js} +0 -0
@@ -0,0 +1,219 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\FingerprintEngine;
9
+ use Anonympins\Fingerprint\FingerprintBuilder;
10
+ use Anonympins\Fingerprint\RequestContext;
11
+ use Anonympins\Fingerprint\Store\StoreManager;
12
+ use Anonympins\Fingerprint\Store\InMemoryStore;
13
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
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();
205
+ $fp2 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString();
206
+ $fp3 = (new FingerprintBuilder())->add('hw', '4_8')->add('gpu', 'amd')->__toString();
207
+ $fp4 = (new FingerprintBuilder())->add('hw', '8_16')->add('os', 'win32')->__toString(); // Partial match
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...
217
+ $this->assertEqualsWithDelta(0.238, FingerprintBuilder::compare($fp1, $fp4), 0.001, "Partial match score should reflect current weights");
218
+ }
219
+ }
@@ -0,0 +1,40 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\FingerprintEngine;
9
+ use Anonympins\Fingerprint\ProblemManager;
10
+ use Anonympins\Fingerprint\Store\InMemoryStore;
11
+ use Anonympins\Fingerprint\Store\StoreManager;
12
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
13
+
14
+ class PowTest extends TestCase
15
+ {
16
+ private FingerprintEngine $engine;
17
+
18
+ protected function setUp(): void
19
+ {
20
+ // 1. Configurer un store en mémoire pour l'isolation des tests.
21
+ $store = new InMemoryStore();
22
+ StoreManager::configureStore($store);
23
+
24
+ // 2. Initialiser le ProblemManager avec une configuration et un store valides.
25
+ // C'est l'étape cruciale qui manquait.
26
+ $configPath = dirname(__FILE__) . '/problems.config.json';
27
+ ProblemManager::getInstance($configPath, $store);
28
+
29
+ // 3. Créer l'instance du moteur.
30
+ $this->engine = new FingerprintEngine(SecurityProfiles::createSecurityProfile('balanced'));
31
+ }
32
+
33
+ public function testGetProblemsIsExposedForTesting(): void
34
+ {
35
+ // Cette méthode appelle ProblemManager::getInstance() en interne.
36
+ // Grâce au setUp(), l'instance est déjà initialisée et le test passe.
37
+ $problems = $this->engine->getProblems();
38
+ $this->assertIsArray($problems);
39
+ }
40
+ }
@@ -0,0 +1,295 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\ProblemManager;
9
+ use Anonympins\Fingerprint\Store\IStore;
10
+ use Anonympins\Fingerprint\Store\InMemoryStore;
11
+ use Anonympins\Fingerprint\Optimization\FunctionRegistry; // Assurez-vous que cette classe est autoloadable
12
+
13
+ class ProblemManagerTest extends TestCase
14
+ {
15
+ private ?string $configPath = null;
16
+
17
+ protected function setUp(): void
18
+ {
19
+ // Réinitialise le singleton avant chaque test pour garantir l'isolation
20
+ ProblemManager::__internal_resetInstance();
21
+ FunctionRegistry::__internal_resetRegistry(); // Assure un registre de fonctions propre
22
+ $this->configPath = dirname(__FILE__).'/problems.config.json';
23
+ }
24
+
25
+ protected function tearDown(): void
26
+ {
27
+ ProblemManager::__internal_resetInstance();
28
+ FunctionRegistry::__internal_resetRegistry();
29
+ }
30
+
31
+ private function createConfigFile(array $content): void
32
+ {
33
+ // S'assurer que le répertoire existe
34
+ if (!is_dir(dirname($this->configPath))) {
35
+ mkdir(dirname($this->configPath), 0777, true);
36
+ }
37
+ file_put_contents($this->configPath, json_encode($content, JSON_PRETTY_PRINT));
38
+ }
39
+
40
+ public function testGetInstanceThrowsExceptionIfNotInitialized(): void
41
+ {
42
+ $this->expectException(\RuntimeException::class);
43
+ $this->expectExceptionMessage('ProblemManager must be initialized with configPath and store.');
44
+ ProblemManager::getInstance();
45
+ }
46
+
47
+ public function testGetInstanceCreatesAndReturnsInstance(): void
48
+ {
49
+ $storeMock = $this->createMock(IStore::class);
50
+
51
+ $instance1 = ProblemManager::getInstance($this->configPath, $storeMock);
52
+ $this->assertInstanceOf(ProblemManager::class, $instance1);
53
+
54
+ // Les appels suivants doivent retourner la même instance
55
+ $instance2 = ProblemManager::getInstance();
56
+ $this->assertSame($instance1, $instance2);
57
+ $this->assertTrue(ProblemManager::isInitialized());
58
+ }
59
+
60
+ public function testLoadProblemsHandlesMissingFile(): void
61
+ {
62
+ $storeMock = $this->createMock(IStore::class);
63
+ $missingConfigPath = dirname(__FILE__).'/non_existent_config.json';
64
+ if (file_exists($missingConfigPath)) {
65
+ unlink($missingConfigPath); // S'assurer que le fichier n'existe pas
66
+ }
67
+
68
+ // Ne doit pas lever d'exception, mais simplement ne pas initialiser de problèmes
69
+ $manager = ProblemManager::getInstance($missingConfigPath, $storeMock);
70
+ $this->assertNull($manager->dispatchWork(0.5));
71
+ }
72
+
73
+ public function testDispatchWorkCyclesThroughProblems(): void
74
+ {
75
+ $storeMock = $this->createMock(IStore::class);
76
+ $storeMock->method('get')->willReturn(null);
77
+
78
+ // Créer un fichier de configuration avec plusieurs problèmes pour tester le cycle
79
+ $this->createConfigFile([
80
+ [
81
+ "id" => "problem-1",
82
+ "workUnit" => ["type" => "simulated_annealing_iterations", "baseIterations" => 10000, "scalingFactor" => 2.0]
83
+ ],
84
+ [
85
+ "id" => "problem-2",
86
+ "workUnit" => ["type" => "simulated_annealing_iterations", "baseIterations" => 20000]
87
+ ],
88
+ [
89
+ "id" => "scaling-problem",
90
+ "workUnit" => ["type" => "multi_objective_genetic_algorithm", "baseGenerations" => 100, "scalingFactor" => 2.0, "solverName" => "test.solver"]
91
+ ],
92
+ [
93
+ "id" => "pareto-challenge",
94
+ "workUnit" => ["type" => "multi_objective_genetic_algorithm", "solverName" => "test.solver"]
95
+ ],
96
+ [
97
+ "id" => "problem-1",
98
+ "workUnit" => ["type" => "simulated_annealing_iterations", "baseIterations" => 10000, "scalingFactor" => 2.0]
99
+ ]
100
+ ]);
101
+
102
+ $manager = ProblemManager::getInstance($this->configPath, $storeMock);
103
+
104
+ $work1 = $manager->dispatchWork(0.5);
105
+ $this->assertEquals('problem-1', $work1['problemId']);
106
+
107
+ $work2 = $manager->dispatchWork(0.5);
108
+ $this->assertEquals('problem-2', $work2['problemId']);
109
+
110
+ // Cycle through a few more times to ensure it loops
111
+ $work3 = $manager->dispatchWork(0.5);
112
+ $this->assertEquals('scaling-problem', $work3['problemId']);
113
+
114
+ $work4 = $manager->dispatchWork(0.5);
115
+ $this->assertEquals('pareto-challenge', $work4['problemId']);
116
+
117
+ $work5 = $manager->dispatchWork(0.5);
118
+ $this->assertEquals('problem-1', $work5['problemId']); // Back to the start
119
+ }
120
+
121
+ public function testDispatchWorkScalesDifficulty(): void
122
+ {
123
+ $storeMock = $this->createMock(IStore::class);
124
+ $storeMock->method('get')->willReturn(null);
125
+
126
+ $this->createConfigFile([
127
+ [
128
+ "id" => "problem-1",
129
+ "workUnit" => ["type" => "simulated_annealing_iterations", "baseIterations" => 10000, "scalingFactor" => 2.0]
130
+ ],
131
+ [
132
+ "id" => "problem-1",
133
+ "workUnit" => ["type" => "simulated_annealing_iterations", "baseIterations" => 10000, "scalingFactor" => 2.0]
134
+ ],
135
+ [
136
+ "id" => "scaling-problem",
137
+ "workUnit" => ["type" => "multi_objective_genetic_algorithm", "baseGenerations" => 100, "scalingFactor" => 2.0, "solverName" => "test.solver"]
138
+ ]
139
+ ]);
140
+
141
+ $manager = ProblemManager::getInstance($this->configPath, $storeMock);
142
+
143
+ // Facteur de suspicion faible
144
+ $workLow = $manager->dispatchWork(0.1);
145
+ $this->assertLessThan(12000, $workLow['task']['iterations']); // 10000 * 2^0.1 ≈ 10717
146
+
147
+ // Facteur de suspicion moyen - dispatch 'problem-1'
148
+ $workMedium = $manager->dispatchWork(0.5);
149
+ $this->assertEquals(14142, $workMedium['task']['iterations']); // 10000 * 2^0.5
150
+
151
+ // Facteur de suspicion élevé - dispatch 'scaling-problem'
152
+ $workHigh = $manager->dispatchWork(1.0);
153
+ $this->assertEquals(200, $workHigh['task']['generations']); // 100 * 2^1.0
154
+ }
155
+
156
+ public function testIntegrateSolutionUpdatesStateForBetterSolution(): void
157
+ {
158
+ $problemId = 'problem-1';
159
+
160
+ $storeMock = $this->createMock(IStore::class);
161
+
162
+ $this->createConfigFile([
163
+ [
164
+ "id" => $problemId,
165
+ "workUnit" => [
166
+ "type" => "simulated_annealing_iterations", // @phpstan-ignore-line
167
+ "baseIterations" => 10000,
168
+ // **LA CORRECTION** : La fonction de score est requise pour la vérification.
169
+ "scoreFunction" => "test.calculateEnergy"
170
+ ]
171
+ ]
172
+ ]);
173
+
174
+ // Enregistrer une fonction de score factice pour le test.
175
+ FunctionRegistry::register('test.calculateEnergy', function ($solution, $payload) { // @phpstan-ignore-line
176
+ return 800.0; // Retourne la nouvelle "meilleure" énergie attendue.
177
+ });
178
+
179
+ $storeMock->method('get')
180
+ ->with("problem-state:{$problemId}")
181
+ ->willReturn(['bestEnergy' => 1000.0]);
182
+
183
+ // On s'attend à ce que le store soit mis à jour avec la nouvelle meilleure solution
184
+ $storeMock->expects($this->once())
185
+ ->method('set')
186
+ ->with(
187
+ "problem-state:{$problemId}",
188
+ $this->callback(function ($state) {
189
+ return isset($state['bestEnergy']) && $state['bestEnergy'] === 800.0 && isset($state['bestSolution']);
190
+ })
191
+ );
192
+
193
+ $manager = ProblemManager::getInstance($this->configPath, $storeMock);
194
+
195
+ $newSolution = [
196
+ 'solution' => [1, 2, 3],
197
+ 'energy' => 800.0 // C'est une meilleure solution (score plus bas)
198
+ ];
199
+ $manager->integrateSolution($problemId, $newSolution);
200
+ }
201
+
202
+ public function testIntegrateSolutionDoesNotUpdateStateForWorseSolution(): void
203
+ {
204
+ $problemId = 'problem-1';
205
+
206
+ $storeMock = $this->createMock(IStore::class);
207
+
208
+ $this->createConfigFile([
209
+ [
210
+ "id" => $problemId,
211
+ "workUnit" => [
212
+ "type" => "simulated_annealing_iterations", // @phpstan-ignore-line
213
+ "baseIterations" => 10000,
214
+ "scoreFunction" => "test.calculateEnergy"
215
+ ]
216
+ ]
217
+ ]);
218
+
219
+ // La fonction de score factice retourne une énergie *pire* que celle existante. // @phpstan-ignore-line
220
+ FunctionRegistry::register('test.calculateEnergy', function ($solution, $payload) {
221
+ return 1200.0;
222
+ });
223
+
224
+ $storeMock->method('get') // @phpstan-ignore-line
225
+ ->with("problem-state:{$problemId}")
226
+ ->willReturn(['bestEnergy' => 1000.0]);
227
+
228
+ // On s'attend à ce que `set` ne soit JAMAIS appelé car la solution n'est pas meilleure
229
+ $storeMock->expects($this->never())->method('set');
230
+
231
+ $manager = ProblemManager::getInstance($this->configPath, $storeMock);
232
+
233
+ $worseSolution = [
234
+ 'solution' => [3, 2, 1], // Le contenu de la solution n'a pas d'importance pour ce test
235
+ 'energy' => 1200.0 // C'est une moins bonne solution
236
+ ];
237
+ $manager->integrateSolution($problemId, $worseSolution);
238
+ }
239
+
240
+ public function testIntegrateParetoFront(): void
241
+ {
242
+ $problemId = 'pareto-challenge';
243
+
244
+ $storeMock = $this->createMock(IStore::class);
245
+
246
+ $this->createConfigFile([
247
+ [
248
+ "id" => "pareto-challenge",
249
+ "workUnit" => ["type" => "multi_objective_genetic_algorithm", "solverName" => "test.solver"]
250
+ ]
251
+ ]);
252
+ // Simule le comportement réel : le store contient déjà l'état initial.
253
+ $initialState = ['paretoFront' => [['solution' => 'A', 'objectives' => [10, 20]]]];
254
+ $storeMock->method('get')
255
+ ->with("problem-state:{$problemId}")
256
+ ->willReturn($initialState);
257
+
258
+ // A new solution that dominates the existing one.
259
+ $newFront = [['solution' => 'B', 'objectives' => [5, 15]]];
260
+
261
+ $storeMock->expects($this->once())
262
+ ->method('set')
263
+ ->with(
264
+ "problem-state:{$problemId}",
265
+ $this->callback(function ($state) {
266
+ // The new front should contain only the new, dominant solution 'B'.
267
+ return isset($state['paretoFront']) && count($state['paretoFront']) === 1 && $state['paretoFront'][0]['solution'] === 'B';
268
+ })
269
+ );
270
+
271
+ $manager = ProblemManager::getInstance($this->configPath, $storeMock);
272
+ $manager->integrateSolution($problemId, ['paretoFront' => $newFront]);
273
+ }
274
+
275
+ public function testLoadProblemsFromStore(): void
276
+ {
277
+ $store = new InMemoryStore();
278
+ $problemId = 'problem-1';
279
+
280
+ $this->createConfigFile([
281
+ [
282
+ "id" => "problem-1",
283
+ "workUnit" => ["type" => "simulated_annealing_iterations", "baseIterations" => 10000]
284
+ ]
285
+ ]);
286
+ $initialState = ['bestEnergy' => 5000.0, 'bestSolution' => [1, 2, 3]];
287
+ $store->set("problem-state:{$problemId}", $initialState);
288
+
289
+ $manager = ProblemManager::getInstance($this->configPath, $store);
290
+ $work = $manager->dispatchWork(0.1); // Dispatch 'problem-1'
291
+
292
+ $this->assertEquals($problemId, $work['problemId']);
293
+ $this->assertEquals($initialState['bestSolution'], $work['task']['initialSolution']);
294
+ }
295
+ }
@@ -0,0 +1,81 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\RequestContext;
9
+ use Anonympins\Fingerprint\Utils\RequestUtils;
10
+
11
+ class RequestUtilsTest extends TestCase
12
+ {
13
+ private function createRequestContext(array $overrides = []): RequestContext
14
+ {
15
+ $defaults = [
16
+ 'clientIp' => '127.0.0.1',
17
+ 'path' => '/',
18
+ 'headers' => ['user-agent' => 'Test UA'],
19
+ 'query' => [],
20
+ 'body' => null,
21
+ 'cookies' => [],
22
+ 'httpVersion' => '1.1',
23
+ ];
24
+ $params = array_merge($defaults, $overrides);
25
+ return new RequestContext(
26
+ $params['clientIp'], $params['path'], $params['headers'],
27
+ $params['query'], $params['body'], $params['cookies'], $params['httpVersion']
28
+ );
29
+ }
30
+
31
+ public function testGetClickVarianceScoreReturnsZeroForNoHistory(): void
32
+ {
33
+ $context = $this->createRequestContext([
34
+ 'headers' => ['x-behavior-metrics' => json_encode([])]
35
+ ]);
36
+ $result = RequestUtils::getClickVarianceScore($context);
37
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
38
+ }
39
+
40
+ public function testGetClickVarianceScoreReturnsZeroForInsufficientClicks(): void
41
+ {
42
+ $metrics = [
43
+ 'clicksHistory' => [
44
+ ['x' => 10, 'y' => 10, 'targetId' => 'hash1'],
45
+ ['x' => 11, 'y' => 11, 'targetId' => 'hash1'],
46
+ ['x' => 100, 'y' => 100, 'targetId' => 'hash2']
47
+ ]
48
+ ];
49
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
50
+ $result = RequestUtils::getClickVarianceScore($context);
51
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
52
+ }
53
+
54
+ public function testGetClickVarianceScoreReturnsHighScoreForLowVariance(): void
55
+ {
56
+ $metrics = [
57
+ 'clicksHistory' => [
58
+ ['x' => 100, 'y' => 100, 'targetId' => 'hash1'],
59
+ ['x' => 100.1, 'y' => 100.2, 'targetId' => 'hash1'],
60
+ ['x' => 99.9, 'y' => 99.8, 'targetId' => 'hash1']
61
+ ]
62
+ ];
63
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
64
+ $result = RequestUtils::getClickVarianceScore($context);
65
+ $this->assertGreaterThan(90, $result['clickVarianceScore']);
66
+ }
67
+
68
+ public function testGetClickVarianceScoreReturnsLowScoreForHighVariance(): void
69
+ {
70
+ $metrics = [
71
+ 'clicksHistory' => [
72
+ ['x' => 105, 'y' => 110, 'targetId' => 'hash1'],
73
+ ['x' => 98, 'y' => 102, 'targetId' => 'hash1'],
74
+ ['x' => 112, 'y' => 95, 'targetId' => 'hash1']
75
+ ]
76
+ ];
77
+ $context = $this->createRequestContext(['headers' => ['x-behavior-metrics' => json_encode($metrics)]]);
78
+ $result = RequestUtils::getClickVarianceScore($context);
79
+ $this->assertEquals(0.0, $result['clickVarianceScore']);
80
+ }
81
+ }
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "id": "problem-1",
4
+ "workUnit": {
5
+ "type": "simulated_annealing_iterations",
6
+ "baseIterations": 10000
7
+ }
8
+ }
9
+ ]