1
0

AuditRollbackTest.php 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Audit;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * SEC_REVIEW F4: every admin write must be transactional with its
  9. * audit emit. If the audit insert fails for any reason (DB error,
  10. * lock timeout, JSON encoding failure), the originating mutation
  11. * MUST roll back so we can never end up with state changes that have
  12. * no audit row attributing them.
  13. *
  14. * The forcing function here is dropping the `audit_log` table so that
  15. * any subsequent `INSERT INTO audit_log` raises a SQL error. The
  16. * emitter's `emitOrThrow()` propagates, the enclosing
  17. * `Connection::transactional()` rolls back, and the target table
  18. * sees zero new rows.
  19. */
  20. final class AuditRollbackTest extends AppTestCase
  21. {
  22. public function testManualBlockCreateRollsBackWhenAuditInsertFails(): void
  23. {
  24. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  25. // Force every emitOrThrow() to fail with a SQL error.
  26. $this->db->executeStatement('DROP TABLE audit_log');
  27. $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM manual_blocks');
  28. $resp = $this->request(
  29. 'POST',
  30. '/api/v1/admin/manual-blocks',
  31. [
  32. 'Authorization' => 'Bearer ' . $token,
  33. 'Content-Type' => 'application/json',
  34. ],
  35. (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.99', 'reason' => 'rollback-test']),
  36. );
  37. self::assertGreaterThanOrEqual(500, $resp->getStatusCode(), 'request should fail loudly');
  38. $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM manual_blocks');
  39. self::assertSame($before, $after, 'mutation must roll back when audit emit fails');
  40. }
  41. public function testReporterCreateRollsBackWhenAuditInsertFails(): void
  42. {
  43. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  44. $this->db->executeStatement('DROP TABLE audit_log');
  45. $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters');
  46. $resp = $this->request(
  47. 'POST',
  48. '/api/v1/admin/reporters',
  49. [
  50. 'Authorization' => 'Bearer ' . $token,
  51. 'Content-Type' => 'application/json',
  52. ],
  53. (string) json_encode(['name' => 'rollback-test-rep']),
  54. );
  55. self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
  56. $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters');
  57. self::assertSame($before, $after, 'reporter row must not exist if audit emit fails');
  58. }
  59. public function testAllowlistCreateRollsBackWhenAuditInsertFails(): void
  60. {
  61. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  62. $this->db->executeStatement('DROP TABLE audit_log');
  63. $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM allowlist');
  64. $resp = $this->request(
  65. 'POST',
  66. '/api/v1/admin/allowlist',
  67. [
  68. 'Authorization' => 'Bearer ' . $token,
  69. 'Content-Type' => 'application/json',
  70. ],
  71. (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.77', 'reason' => 'rollback-test']),
  72. );
  73. self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
  74. $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM allowlist');
  75. self::assertSame($before, $after);
  76. }
  77. public function testCategoryCreateRollsBackWhenAuditInsertFails(): void
  78. {
  79. $token = $this->createToken(TokenKind::Admin, Role::Admin);
  80. $this->db->executeStatement('DROP TABLE audit_log');
  81. $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories');
  82. $resp = $this->request(
  83. 'POST',
  84. '/api/v1/admin/categories',
  85. [
  86. 'Authorization' => 'Bearer ' . $token,
  87. 'Content-Type' => 'application/json',
  88. ],
  89. (string) json_encode([
  90. 'slug' => 'rollback_test',
  91. 'name' => 'Rollback Test',
  92. 'decay_function' => 'linear',
  93. 'decay_param' => 30,
  94. ]),
  95. );
  96. self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
  97. $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories');
  98. self::assertSame($before, $after, 'category row must not exist if audit emit fails');
  99. }
  100. public function testUpsertLocalRollsBackWhenAuditInsertFails(): void
  101. {
  102. // SEC_REVIEW F5: user.created emit is transactional with the
  103. // user row insert. Drop audit_log on a fresh DB (no local user
  104. // exists yet) → first upsert-local fails → users table stays
  105. // empty.
  106. $token = $this->createToken(TokenKind::Service);
  107. $this->db->executeStatement('DROP TABLE audit_log');
  108. $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM users');
  109. $resp = $this->request(
  110. 'POST',
  111. '/api/v1/auth/users/upsert-local',
  112. [
  113. 'Authorization' => 'Bearer ' . $token,
  114. 'Content-Type' => 'application/json',
  115. ],
  116. (string) json_encode(['username' => 'admin']),
  117. );
  118. self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
  119. $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM users');
  120. self::assertSame($before, $after, 'user.created must be transactional with the user insert');
  121. }
  122. }