* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Tests\Services; use App\Services\AuditLogger; use App\Tests\TestCase; final class AuditLoggerTest extends TestCase { public function testRealUpdateProducesExactlyOneRow(): void { $pdo = $this->makeDb(); $logger = new AuditLogger($pdo); $id = $logger->record( action: 'UPDATE', entityType: 'worker', entityId: 1, before: ['name' => 'Alice', 'is_active' => 1], after: ['name' => 'Alice Cooper', 'is_active' => 1], ); $this->assertIsInt($id); $this->assertSame(1, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn()); } public function testNoopUpdateWritesNoRow(): void { $pdo = $this->makeDb(); $logger = new AuditLogger($pdo); $snap = ['name' => 'Alice', 'is_active' => 1, 'default_rtb' => 0.1]; $result = $logger->record( action: 'UPDATE', entityType: 'worker', entityId: 1, before: $snap, after: $snap, ); $this->assertNull($result); $this->assertSame(0, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn()); } public function testNoopUpdateIsKeyOrderInsensitive(): void { $pdo = $this->makeDb(); $logger = new AuditLogger($pdo); $before = ['name' => 'Alice', 'is_active' => 1]; $after = ['is_active' => 1, 'name' => 'Alice']; // reordered keys $result = $logger->record('UPDATE', 'worker', 1, $before, $after); $this->assertNull($result); $this->assertSame(0, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn()); } public function testCreateActionAlwaysWritesEvenWhenBeforeEqualsAfter(): void { // The no-op rule applies to UPDATE only. CREATE/DELETE always write. $pdo = $this->makeDb(); $logger = new AuditLogger($pdo); $logger->record( action: 'CREATE', entityType: 'worker', entityId: 1, before: null, after: ['name' => 'Alice'], ); $this->assertSame(1, (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn()); } public function testDeleteCarriesBeforeJsonAndNullAfterJson(): void { $pdo = $this->makeDb(); $logger = new AuditLogger($pdo); $logger->record( action: 'DELETE', entityType: 'worker', entityId: 42, before: ['id' => 42, 'name' => 'Bob'], after: null, userId: 7, userEmail: 'admin@x', ipAddress: '1.2.3.4', userAgent: 'test-ua', ); $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch(); $this->assertSame('DELETE', $row['action']); $this->assertSame('worker', $row['entity_type']); $this->assertSame(42, (int) $row['entity_id']); $this->assertSame(7, (int) $row['user_id']); $this->assertSame('admin@x', $row['user_email']); $this->assertSame('1.2.3.4', $row['ip_address']); $this->assertSame('test-ua', $row['user_agent']); $this->assertIsString($row['before_json']); $this->assertSame('{"id":42,"name":"Bob"}', $row['before_json']); $this->assertNull($row['after_json']); } public function testLoginEventWritesWithNullBeforeAndAfter(): void { // LOGIN events are not mutations and carry no before/after. $pdo = $this->makeDb(); $logger = new AuditLogger($pdo); $logger->record( action: 'LOGIN', entityType: 'user', entityId: 1, before: null, after: null, ); $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch(); $this->assertSame('LOGIN', $row['action']); $this->assertNull($row['before_json']); $this->assertNull($row['after_json']); } public function testLoginFailedSupportsNullUser(): void { $pdo = $this->makeDb(); $logger = new AuditLogger($pdo); $logger->record( action: 'LOGIN_FAILED', entityType: 'user', entityId: null, before: null, after: ['reason' => 'bad_signature'], ); $row = $pdo->query('SELECT * FROM audit_log LIMIT 1')->fetch(); $this->assertSame('LOGIN_FAILED', $row['action']); $this->assertNull($row['entity_id']); $this->assertNull($row['user_id']); $this->assertSame('{"reason":"bad_signature"}', $row['after_json']); } public function testOccurredAtIsIsoUtc(): void { $pdo = $this->makeDb(); $logger = new AuditLogger($pdo); $logger->record('CREATE', 'worker', 1, null, ['name' => 'Alice']); $ts = (string) $pdo->query('SELECT occurred_at FROM audit_log LIMIT 1')->fetchColumn(); $this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $ts, ); } }