| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Audit;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenKind;
- use App\Tests\Integration\Support\AppTestCase;
- /**
- * SEC_REVIEW F4: every admin write must be transactional with its
- * audit emit. If the audit insert fails for any reason (DB error,
- * lock timeout, JSON encoding failure), the originating mutation
- * MUST roll back so we can never end up with state changes that have
- * no audit row attributing them.
- *
- * The forcing function here is dropping the `audit_log` table so that
- * any subsequent `INSERT INTO audit_log` raises a SQL error. The
- * emitter's `emitOrThrow()` propagates, the enclosing
- * `Connection::transactional()` rolls back, and the target table
- * sees zero new rows.
- */
- final class AuditRollbackTest extends AppTestCase
- {
- public function testManualBlockCreateRollsBackWhenAuditInsertFails(): void
- {
- $token = $this->createToken(TokenKind::Admin, Role::Admin);
- // Force every emitOrThrow() to fail with a SQL error.
- $this->db->executeStatement('DROP TABLE audit_log');
- $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM manual_blocks');
- $resp = $this->request(
- 'POST',
- '/api/v1/admin/manual-blocks',
- [
- 'Authorization' => 'Bearer ' . $token,
- 'Content-Type' => 'application/json',
- ],
- (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.99', 'reason' => 'rollback-test']),
- );
- self::assertGreaterThanOrEqual(500, $resp->getStatusCode(), 'request should fail loudly');
- $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM manual_blocks');
- self::assertSame($before, $after, 'mutation must roll back when audit emit fails');
- }
- public function testReporterCreateRollsBackWhenAuditInsertFails(): void
- {
- $token = $this->createToken(TokenKind::Admin, Role::Admin);
- $this->db->executeStatement('DROP TABLE audit_log');
- $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters');
- $resp = $this->request(
- 'POST',
- '/api/v1/admin/reporters',
- [
- 'Authorization' => 'Bearer ' . $token,
- 'Content-Type' => 'application/json',
- ],
- (string) json_encode(['name' => 'rollback-test-rep']),
- );
- self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
- $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters');
- self::assertSame($before, $after, 'reporter row must not exist if audit emit fails');
- }
- public function testAllowlistCreateRollsBackWhenAuditInsertFails(): void
- {
- $token = $this->createToken(TokenKind::Admin, Role::Admin);
- $this->db->executeStatement('DROP TABLE audit_log');
- $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM allowlist');
- $resp = $this->request(
- 'POST',
- '/api/v1/admin/allowlist',
- [
- 'Authorization' => 'Bearer ' . $token,
- 'Content-Type' => 'application/json',
- ],
- (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.77', 'reason' => 'rollback-test']),
- );
- self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
- $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM allowlist');
- self::assertSame($before, $after);
- }
- public function testCategoryCreateRollsBackWhenAuditInsertFails(): void
- {
- $token = $this->createToken(TokenKind::Admin, Role::Admin);
- $this->db->executeStatement('DROP TABLE audit_log');
- $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories');
- $resp = $this->request(
- 'POST',
- '/api/v1/admin/categories',
- [
- 'Authorization' => 'Bearer ' . $token,
- 'Content-Type' => 'application/json',
- ],
- (string) json_encode([
- 'slug' => 'rollback_test',
- 'name' => 'Rollback Test',
- 'decay_function' => 'linear',
- 'decay_param' => 30,
- ]),
- );
- self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
- $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories');
- self::assertSame($before, $after, 'category row must not exist if audit emit fails');
- }
- public function testUpsertLocalRollsBackWhenAuditInsertFails(): void
- {
- // SEC_REVIEW F5: user.created emit is transactional with the
- // user row insert. Drop audit_log on a fresh DB (no local user
- // exists yet) → first upsert-local fails → users table stays
- // empty.
- $token = $this->createToken(TokenKind::Service);
- $this->db->executeStatement('DROP TABLE audit_log');
- $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM users');
- $resp = $this->request(
- 'POST',
- '/api/v1/auth/users/upsert-local',
- [
- 'Authorization' => 'Bearer ' . $token,
- 'Content-Type' => 'application/json',
- ],
- (string) json_encode(['username' => 'admin']),
- );
- self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
- $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM users');
- self::assertSame($before, $after, 'user.created must be transactional with the user insert');
- }
- }
|