1
0

PoliciesControllerTest.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Admin;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * Covers SPEC §M07.2: policy CRUD + preview, RBAC split (Viewer reads,
  9. * Admin writes), threshold replacement on PATCH, 409 on delete with
  10. * referencing consumers.
  11. */
  12. final class PoliciesControllerTest extends AppTestCase
  13. {
  14. public function testViewerCanListSeededPolicies(): void
  15. {
  16. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  17. $response = $this->request('GET', '/api/v1/admin/policies', [
  18. 'Authorization' => 'Bearer ' . $token,
  19. ]);
  20. self::assertSame(200, $response->getStatusCode());
  21. $body = $this->decode($response);
  22. self::assertSame(3, $body['total']); // strict, moderate, paranoid
  23. $names = array_map(static fn (array $p): string => $p['name'], $body['items']);
  24. self::assertContains('strict', $names);
  25. self::assertContains('moderate', $names);
  26. self::assertContains('paranoid', $names);
  27. }
  28. public function testShowIncludesThresholdsKeyedBySlug(): void
  29. {
  30. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  31. $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']);
  32. $response = $this->request('GET', "/api/v1/admin/policies/{$policyId}", [
  33. 'Authorization' => 'Bearer ' . $token,
  34. ]);
  35. self::assertSame(200, $response->getStatusCode());
  36. $body = $this->decode($response);
  37. self::assertSame('moderate', $body['name']);
  38. self::assertNotEmpty($body['thresholds']);
  39. $slugs = array_map(static fn (array $t): string => $t['category_slug'], $body['thresholds']);
  40. self::assertContains('brute_force', $slugs);
  41. }
  42. public function testViewerCannotCreate(): void
  43. {
  44. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  45. $response = $this->request(
  46. 'POST',
  47. '/api/v1/admin/policies',
  48. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  49. json_encode([
  50. 'name' => 'aggressive',
  51. 'thresholds' => ['brute_force' => 0.5],
  52. ]) ?: null,
  53. );
  54. self::assertSame(403, $response->getStatusCode());
  55. }
  56. public function testAdminCanCreatePolicyWithThresholds(): void
  57. {
  58. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  59. $response = $this->request(
  60. 'POST',
  61. '/api/v1/admin/policies',
  62. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  63. json_encode([
  64. 'name' => 'aggressive',
  65. 'description' => 'block everything',
  66. 'include_manual_blocks' => true,
  67. 'thresholds' => [
  68. 'brute_force' => 0.1,
  69. 'spam' => 0.2,
  70. ],
  71. ]) ?: null,
  72. );
  73. self::assertSame(201, $response->getStatusCode());
  74. $body = $this->decode($response);
  75. self::assertSame('aggressive', $body['name']);
  76. self::assertCount(2, $body['thresholds']);
  77. }
  78. public function testAdminCanCreatePolicyWithoutThresholds(): void
  79. {
  80. // The UI's create form deliberately omits the threshold matrix
  81. // ("configure on edit page after creation"). A policy with zero
  82. // thresholds is valid per SPEC §4 (absent row = category not
  83. // considered) and may still emit manual blocks.
  84. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  85. $response = $this->request(
  86. 'POST',
  87. '/api/v1/admin/policies',
  88. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  89. json_encode(['name' => 'empty', 'include_manual_blocks' => true]) ?: null,
  90. );
  91. self::assertSame(201, $response->getStatusCode());
  92. $body = $this->decode($response);
  93. self::assertSame('empty', $body['name']);
  94. self::assertSame([], $body['thresholds']);
  95. }
  96. public function testCreateRejectsDuplicateName(): void
  97. {
  98. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  99. $response = $this->request(
  100. 'POST',
  101. '/api/v1/admin/policies',
  102. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  103. json_encode(['name' => 'moderate', 'thresholds' => ['brute_force' => 1.0]]) ?: null,
  104. );
  105. self::assertSame(400, $response->getStatusCode());
  106. $details = $this->decode($response)['details'];
  107. self::assertArrayHasKey('name', $details);
  108. }
  109. public function testCreateRejectsUnknownCategorySlug(): void
  110. {
  111. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  112. $response = $this->request(
  113. 'POST',
  114. '/api/v1/admin/policies',
  115. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  116. json_encode(['name' => 'bogus', 'thresholds' => ['unknown_slug' => 1.0]]) ?: null,
  117. );
  118. self::assertSame(400, $response->getStatusCode());
  119. $details = $this->decode($response)['details'];
  120. self::assertStringContainsString('unknown_slug', (string) $details['thresholds']);
  121. }
  122. public function testPatchReplacesThresholdsWholesale(): void
  123. {
  124. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  125. $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']);
  126. $response = $this->request(
  127. 'PATCH',
  128. "/api/v1/admin/policies/{$policyId}",
  129. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  130. json_encode(['thresholds' => ['brute_force' => 5.0]]) ?: null,
  131. );
  132. self::assertSame(200, $response->getStatusCode());
  133. $body = $this->decode($response);
  134. self::assertCount(1, $body['thresholds']);
  135. self::assertSame('brute_force', $body['thresholds'][0]['category_slug']);
  136. self::assertEqualsWithDelta(5.0, $body['thresholds'][0]['threshold'], 0.0001);
  137. }
  138. public function testDeleteWithReferencingConsumerReturns409(): void
  139. {
  140. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  141. $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']);
  142. $this->db->insert('consumers', [
  143. 'name' => 'fw-edge',
  144. 'policy_id' => $policyId,
  145. 'is_active' => 1,
  146. ]);
  147. $response = $this->request('DELETE', "/api/v1/admin/policies/{$policyId}", [
  148. 'Authorization' => 'Bearer ' . $token,
  149. ]);
  150. self::assertSame(409, $response->getStatusCode());
  151. $body = $this->decode($response);
  152. self::assertSame('policy_in_use', $body['error']);
  153. self::assertNotEmpty($body['consumers']);
  154. }
  155. public function testDeleteSucceedsWithoutReferences(): void
  156. {
  157. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  158. $createResp = $this->request(
  159. 'POST',
  160. '/api/v1/admin/policies',
  161. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  162. json_encode(['name' => 'tmp', 'thresholds' => ['brute_force' => 1.0]]) ?: null,
  163. );
  164. $newId = (int) $this->decode($createResp)['id'];
  165. $deleteResp = $this->request('DELETE', "/api/v1/admin/policies/{$newId}", [
  166. 'Authorization' => 'Bearer ' . $token,
  167. ]);
  168. self::assertSame(204, $deleteResp->getStatusCode());
  169. }
  170. public function testPreviewReturnsCountSampleAndPolicyName(): void
  171. {
  172. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  173. $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'paranoid']);
  174. $response = $this->request('GET', "/api/v1/admin/policies/{$policyId}/preview", [
  175. 'Authorization' => 'Bearer ' . $token,
  176. ]);
  177. self::assertSame(200, $response->getStatusCode());
  178. $body = $this->decode($response);
  179. self::assertArrayHasKey('count', $body);
  180. self::assertArrayHasKey('sample', $body);
  181. self::assertSame('paranoid', $body['policy']);
  182. self::assertIsArray($body['sample']);
  183. }
  184. public function testScoreDistributionReturnsBucketsAndThresholds(): void
  185. {
  186. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  187. $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']);
  188. $response = $this->request('GET', "/api/v1/admin/policies/{$policyId}/score-distribution", [
  189. 'Authorization' => 'Bearer ' . $token,
  190. ]);
  191. self::assertSame(200, $response->getStatusCode());
  192. $body = $this->decode($response);
  193. self::assertArrayHasKey('buckets', $body);
  194. self::assertIsArray($body['buckets']);
  195. self::assertArrayHasKey('thresholds', $body);
  196. self::assertIsArray($body['thresholds']);
  197. self::assertArrayHasKey('bucket_size', $body);
  198. self::assertEqualsWithDelta(5.0, $body['bucket_size'], 0.0001);
  199. self::assertSame('moderate', $body['policy']);
  200. foreach ($body['buckets'] as $bucket) {
  201. self::assertArrayHasKey('start', $bucket);
  202. self::assertArrayHasKey('count', $bucket);
  203. }
  204. }
  205. }