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'); } }