1
0

MaintenanceControllerTest.php 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  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. * `/api/v1/admin/maintenance/{purge,seed-demo}` — admin-only one-shot
  9. * data tools used by the Settings page for demos and resets.
  10. */
  11. final class MaintenanceControllerTest extends AppTestCase
  12. {
  13. public function testPurgeRequiresAdmin(): void
  14. {
  15. $token = $this->createToken(TokenKind::Admin, Role::Operator);
  16. $resp = $this->request(
  17. 'POST',
  18. '/api/v1/admin/maintenance/purge',
  19. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  20. '{"confirm": "PURGE"}',
  21. );
  22. self::assertSame(403, $resp->getStatusCode());
  23. }
  24. public function testPurgeRequiresLiteralConfirmString(): void
  25. {
  26. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  27. $resp = $this->request(
  28. 'POST',
  29. '/api/v1/admin/maintenance/purge',
  30. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  31. '{"confirm": "yes"}',
  32. );
  33. self::assertSame(400, $resp->getStatusCode());
  34. self::assertSame('validation_failed', $this->decode($resp)['error']);
  35. }
  36. public function testPurgeWipesOperationalDataButPreservesServiceToken(): void
  37. {
  38. // Seed a service token (bypassing the controller; the API protects it).
  39. $this->db->insert('api_tokens', [
  40. 'kind' => TokenKind::Service->value,
  41. 'token_hash' => str_repeat('a', 64),
  42. 'token_prefix' => 'irdb_svc',
  43. ]);
  44. // And one operational token, plus a reporter and a stray report.
  45. $reporterId = $this->createReporter('purge-victim');
  46. $catRow = $this->db->fetchAssociative('SELECT id FROM categories LIMIT 1');
  47. self::assertNotFalse($catRow);
  48. $this->db->insert('reports', [
  49. 'ip_bin' => str_repeat("\x00", 12) . "\x00\x00\x00\x01",
  50. 'ip_text' => '0.0.0.1',
  51. 'category_id' => (int) $catRow['id'],
  52. 'reporter_id' => $reporterId,
  53. 'weight_at_report' => '1.00',
  54. ]);
  55. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  56. $resp = $this->request(
  57. 'POST',
  58. '/api/v1/admin/maintenance/purge',
  59. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  60. '{"confirm": "PURGE"}',
  61. );
  62. self::assertSame(200, $resp->getStatusCode());
  63. $body = $this->decode($resp);
  64. self::assertSame('purged', $body['status']);
  65. self::assertGreaterThanOrEqual(1, (int) $body['deleted']['reports']);
  66. // Service token survives, the admin token does not.
  67. $svcCount = (int) $this->db->fetchOne(
  68. 'SELECT COUNT(*) FROM api_tokens WHERE kind = :k',
  69. ['k' => TokenKind::Service->value],
  70. );
  71. self::assertSame(1, $svcCount);
  72. $allTokens = (int) $this->db->fetchOne('SELECT COUNT(*) FROM api_tokens');
  73. self::assertSame(1, $allTokens, 'only the service token should remain');
  74. // Reporters / reports gone.
  75. self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters'));
  76. self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports'));
  77. // Categories preserved.
  78. self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories'));
  79. }
  80. public function testSeedDemoPopulatesDataAndIsIdempotent(): void
  81. {
  82. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  83. $first = $this->request(
  84. 'POST',
  85. '/api/v1/admin/maintenance/seed-demo',
  86. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  87. '{"confirm": "SEED"}',
  88. );
  89. self::assertSame(200, $first->getStatusCode());
  90. $body = $this->decode($first);
  91. self::assertSame('seeded', $body['status']);
  92. self::assertGreaterThan(0, (int) $body['summary']['reporters']);
  93. self::assertGreaterThan(0, (int) $body['summary']['ips']);
  94. self::assertGreaterThan(0, (int) $body['summary']['reports']);
  95. // Real rows landed.
  96. self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters'));
  97. self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports'));
  98. // Second call short-circuits with 409.
  99. $second = $this->request(
  100. 'POST',
  101. '/api/v1/admin/maintenance/seed-demo',
  102. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  103. '{"confirm": "SEED"}',
  104. );
  105. self::assertSame(409, $second->getStatusCode());
  106. self::assertSame('already_seeded', $this->decode($second)['error']);
  107. }
  108. public function testSeedDemoForbiddenForViewer(): void
  109. {
  110. $token = $this->createToken(TokenKind::Admin, Role::Viewer);
  111. $resp = $this->request(
  112. 'POST',
  113. '/api/v1/admin/maintenance/seed-demo',
  114. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  115. '{"confirm": "SEED"}',
  116. );
  117. self::assertSame(403, $resp->getStatusCode());
  118. }
  119. /**
  120. * SEC_REVIEW F15: a single drive-by POST with no body must not load the
  121. * demo dataset. The API requires `confirm: "SEED"` symmetric with
  122. * `purge`'s `"PURGE"` gate.
  123. */
  124. public function testSeedDemoRequiresLiteralConfirmString(): void
  125. {
  126. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  127. // No body at all.
  128. $bare = $this->request(
  129. 'POST',
  130. '/api/v1/admin/maintenance/seed-demo',
  131. ['Authorization' => 'Bearer ' . $token],
  132. );
  133. self::assertSame(400, $bare->getStatusCode());
  134. self::assertSame('validation_failed', $this->decode($bare)['error']);
  135. // Wrong literal.
  136. $wrong = $this->request(
  137. 'POST',
  138. '/api/v1/admin/maintenance/seed-demo',
  139. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  140. '{"confirm": "yes"}',
  141. );
  142. self::assertSame(400, $wrong->getStatusCode());
  143. self::assertSame('validation_failed', $this->decode($wrong)['error']);
  144. // Nothing landed.
  145. self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters'));
  146. self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports'));
  147. }
  148. }