CategoriesControllerTest.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  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\Domain\Ip\IpAddress;
  7. use App\Tests\Integration\Support\AppTestCase;
  8. use Doctrine\DBAL\ParameterType;
  9. /**
  10. * Covers SPEC §M10.1: categories CRUD + in-use refusal on delete.
  11. */
  12. final class CategoriesControllerTest extends AppTestCase
  13. {
  14. public function testListReturnsSeededCategories(): void
  15. {
  16. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  17. $response = $this->request('GET', '/api/v1/admin/categories', [
  18. 'Authorization' => 'Bearer ' . $token,
  19. ]);
  20. self::assertSame(200, $response->getStatusCode());
  21. $body = $this->decode($response);
  22. self::assertGreaterThanOrEqual(5, $body['total']);
  23. $slugs = array_map(static fn (array $r): string => $r['slug'], $body['items']);
  24. self::assertContains('brute_force', $slugs);
  25. }
  26. public function testCreateRejectsBadSlug(): void
  27. {
  28. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  29. $response = $this->request(
  30. 'POST',
  31. '/api/v1/admin/categories',
  32. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  33. (string) json_encode([
  34. 'slug' => 'BAD-Slug',
  35. 'name' => 'X',
  36. 'decay_function' => 'exponential',
  37. 'decay_param' => 14,
  38. ]),
  39. );
  40. self::assertSame(400, $response->getStatusCode());
  41. $details = $this->decode($response)['details'];
  42. self::assertArrayHasKey('slug', $details);
  43. }
  44. public function testCreateAndShow(): void
  45. {
  46. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  47. $response = $this->request(
  48. 'POST',
  49. '/api/v1/admin/categories',
  50. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  51. (string) json_encode([
  52. 'slug' => 'phishing',
  53. 'name' => 'Phishing',
  54. 'description' => 'fake-domain reports',
  55. 'decay_function' => 'exponential',
  56. 'decay_param' => 14,
  57. 'is_active' => true,
  58. ]),
  59. );
  60. self::assertSame(201, $response->getStatusCode());
  61. $created = $this->decode($response);
  62. self::assertSame('phishing', $created['slug']);
  63. $get = $this->request('GET', '/api/v1/admin/categories/' . $created['id'], [
  64. 'Authorization' => 'Bearer ' . $token,
  65. ]);
  66. self::assertSame(200, $get->getStatusCode());
  67. self::assertSame('phishing', $this->decode($get)['slug']);
  68. }
  69. public function testCreateRejectsDuplicateSlug(): void
  70. {
  71. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  72. $response = $this->request(
  73. 'POST',
  74. '/api/v1/admin/categories',
  75. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  76. (string) json_encode([
  77. 'slug' => 'brute_force', // already seeded
  78. 'name' => 'Dup',
  79. 'decay_function' => 'linear',
  80. 'decay_param' => 30,
  81. ]),
  82. );
  83. self::assertSame(400, $response->getStatusCode());
  84. self::assertArrayHasKey('slug', $this->decode($response)['details']);
  85. }
  86. public function testPatchUpdatesFields(): void
  87. {
  88. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  89. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'spam']);
  90. $response = $this->request(
  91. 'PATCH',
  92. '/api/v1/admin/categories/' . $catId,
  93. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  94. (string) json_encode(['name' => 'Spam (renamed)', 'is_active' => false]),
  95. );
  96. self::assertSame(200, $response->getStatusCode());
  97. $body = $this->decode($response);
  98. self::assertSame('Spam (renamed)', $body['name']);
  99. self::assertFalse($body['is_active']);
  100. }
  101. public function testDeleteRefusedWhenReferencedByPolicy(): void
  102. {
  103. // Seeded policies reference all five seeded categories, so any
  104. // seeded category will trip the in-use guard.
  105. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  106. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
  107. $response = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [
  108. 'Authorization' => 'Bearer ' . $token,
  109. ]);
  110. self::assertSame(409, $response->getStatusCode());
  111. $body = $this->decode($response);
  112. self::assertSame('category_in_use', $body['error']);
  113. self::assertGreaterThan(0, $body['usage']['policies']);
  114. }
  115. public function testDeleteRefusedWhenReferencedByReports(): void
  116. {
  117. // Create a fresh category, attach a single report, then try to
  118. // delete it. Policy refs are zero (we didn't add it to any
  119. // policy); only the report ref blocks the delete.
  120. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  121. $createResp = $this->request(
  122. 'POST',
  123. '/api/v1/admin/categories',
  124. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  125. (string) json_encode([
  126. 'slug' => 'tmp_only', 'name' => 'Tmp',
  127. 'decay_function' => 'linear', 'decay_param' => 5,
  128. ]),
  129. );
  130. $catId = (int) $this->decode($createResp)['id'];
  131. $reporterId = $this->createReporter('rep-cat-test');
  132. $stmt = $this->db->prepare(
  133. 'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at) '
  134. . 'VALUES (:b, :t, :c, :r, :w, :now)'
  135. );
  136. $stmt->bindValue('b', IpAddress::fromString('203.0.113.5')->binary(), ParameterType::LARGE_OBJECT);
  137. $stmt->bindValue('t', '203.0.113.5');
  138. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  139. $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
  140. $stmt->bindValue('w', '1.00');
  141. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  142. $stmt->executeStatement();
  143. $delete = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [
  144. 'Authorization' => 'Bearer ' . $token,
  145. ]);
  146. self::assertSame(409, $delete->getStatusCode());
  147. $body = $this->decode($delete);
  148. self::assertSame('category_in_use', $body['error']);
  149. self::assertSame(0, $body['usage']['policies']);
  150. self::assertGreaterThan(0, $body['usage']['reports']);
  151. }
  152. public function testDeleteSucceedsWhenNotInUse(): void
  153. {
  154. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  155. $createResp = $this->request(
  156. 'POST',
  157. '/api/v1/admin/categories',
  158. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  159. (string) json_encode([
  160. 'slug' => 'orphan', 'name' => 'Orphan',
  161. 'decay_function' => 'linear', 'decay_param' => 5,
  162. ]),
  163. );
  164. $catId = (int) $this->decode($createResp)['id'];
  165. $delete = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [
  166. 'Authorization' => 'Bearer ' . $token,
  167. ]);
  168. self::assertSame(204, $delete->getStatusCode());
  169. }
  170. public function testNonAdminCannotCreate(): void
  171. {
  172. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  173. $response = $this->request(
  174. 'POST',
  175. '/api/v1/admin/categories',
  176. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  177. (string) json_encode([
  178. 'slug' => 'x', 'name' => 'X',
  179. 'decay_function' => 'linear', 'decay_param' => 5,
  180. ]),
  181. );
  182. self::assertSame(403, $response->getStatusCode());
  183. }
  184. }