1
0

AuditLoggerTest.php 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Services;
  4. use App\Services\AuditLogger;
  5. use App\Tests\TestCase;
  6. final class AuditLoggerTest extends TestCase
  7. {
  8. public function testRealUpdateProducesExactlyOneRow(): void
  9. {
  10. $pdo = $this->makeDb();
  11. $logger = new AuditLogger($pdo);
  12. $id = $logger->record(
  13. action: 'UPDATE',
  14. entityType: 'worker',
  15. entityId: 1,
  16. before: ['name' => 'Alice', 'is_active' => 1],
  17. after: ['name' => 'Alice Cooper', 'is_active' => 1],
  18. );
  19. $this->assertIsInt($id);
  20. $this->assertSame(1, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());
  21. }
  22. public function testNoopUpdateWritesNoRow(): void
  23. {
  24. $pdo = $this->makeDb();
  25. $logger = new AuditLogger($pdo);
  26. $snap = ['name' => 'Alice', 'is_active' => 1, 'default_rtb' => 0.1];
  27. $result = $logger->record(
  28. action: 'UPDATE',
  29. entityType: 'worker',
  30. entityId: 1,
  31. before: $snap,
  32. after: $snap,
  33. );
  34. $this->assertNull($result);
  35. $this->assertSame(0, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());
  36. }
  37. public function testNoopUpdateIsKeyOrderInsensitive(): void
  38. {
  39. $pdo = $this->makeDb();
  40. $logger = new AuditLogger($pdo);
  41. $before = ['name' => 'Alice', 'is_active' => 1];
  42. $after = ['is_active' => 1, 'name' => 'Alice']; // reordered keys
  43. $result = $logger->record('UPDATE', 'worker', 1, $before, $after);
  44. $this->assertNull($result);
  45. $this->assertSame(0, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());
  46. }
  47. public function testCreateActionAlwaysWritesEvenWhenBeforeEqualsAfter(): void
  48. {
  49. // The no-op rule applies to UPDATE only. CREATE/DELETE always write.
  50. $pdo = $this->makeDb();
  51. $logger = new AuditLogger($pdo);
  52. $logger->record(
  53. action: 'CREATE',
  54. entityType: 'worker',
  55. entityId: 1,
  56. before: null,
  57. after: ['name' => 'Alice'],
  58. );
  59. $this->assertSame(1, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn());
  60. }
  61. public function testDeleteCarriesBeforeJsonAndNullAfterJson(): void
  62. {
  63. $pdo = $this->makeDb();
  64. $logger = new AuditLogger($pdo);
  65. $logger->record(
  66. action: 'DELETE',
  67. entityType: 'worker',
  68. entityId: 42,
  69. before: ['id' => 42, 'name' => 'Bob'],
  70. after: null,
  71. userId: 7,
  72. userEmail: 'admin@x',
  73. ipAddress: '1.2.3.4',
  74. userAgent: 'test-ua',
  75. );
  76. $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch();
  77. $this->assertSame('DELETE', $row['action']);
  78. $this->assertSame('worker', $row['entity_type']);
  79. $this->assertSame(42, (int) $row['entity_id']);
  80. $this->assertSame(7, (int) $row['user_id']);
  81. $this->assertSame('admin@x', $row['user_email']);
  82. $this->assertSame('1.2.3.4', $row['ip_address']);
  83. $this->assertSame('test-ua', $row['user_agent']);
  84. $this->assertIsString($row['before_json']);
  85. $this->assertSame('{"id":42,"name":"Bob"}', $row['before_json']);
  86. $this->assertNull($row['after_json']);
  87. }
  88. public function testLoginEventWritesWithNullBeforeAndAfter(): void
  89. {
  90. // LOGIN events are not mutations and carry no before/after.
  91. $pdo = $this->makeDb();
  92. $logger = new AuditLogger($pdo);
  93. $logger->record(
  94. action: 'LOGIN',
  95. entityType: 'user',
  96. entityId: 1,
  97. before: null,
  98. after: null,
  99. );
  100. $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch();
  101. $this->assertSame('LOGIN', $row['action']);
  102. $this->assertNull($row['before_json']);
  103. $this->assertNull($row['after_json']);
  104. }
  105. public function testLoginFailedSupportsNullUser(): void
  106. {
  107. $pdo = $this->makeDb();
  108. $logger = new AuditLogger($pdo);
  109. $logger->record(
  110. action: 'LOGIN_FAILED',
  111. entityType: 'user',
  112. entityId: null,
  113. before: null,
  114. after: ['reason' => 'bad_signature'],
  115. );
  116. $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch();
  117. $this->assertSame('LOGIN_FAILED', $row['action']);
  118. $this->assertNull($row['entity_id']);
  119. $this->assertNull($row['user_id']);
  120. $this->assertSame('{"reason":"bad_signature"}', $row['after_json']);
  121. }
  122. public function testOccurredAtIsIsoUtc(): void
  123. {
  124. $pdo = $this->makeDb();
  125. $logger = new AuditLogger($pdo);
  126. $logger->record('CREATE', 'worker', 1, null, ['name' => 'Alice']);
  127. $ts = (string) $pdo->query('SELECT occurred_at FROM audit_log LIMIT 1')->fetchColumn();
  128. $this->assertMatchesRegularExpression(
  129. '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/',
  130. $ts,
  131. );
  132. }
  133. }