diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 288bad15..6049530b 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -31,6 +31,8 @@ jobs: SSL_ENABLED: false SESSION_DRIVER: redis PHP_VERSION: 8.3 + OTEL_SDK_DISABLED: true + OTEL_SERVICE_ENABLED: false services: mysql: image: mysql:8.0 diff --git a/app/Audit/AuditContext.php b/app/Audit/AuditContext.php index ee9fad9b..6ea0d102 100644 --- a/app/Audit/AuditContext.php +++ b/app/Audit/AuditContext.php @@ -12,6 +12,11 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ + +use Auth\Repositories\IUserRepository; +use Illuminate\Support\Facades\Auth; +use OAuth2\IResourceServerContext; + class AuditContext { public function __construct( @@ -28,4 +33,57 @@ public function __construct( public ?string $userAgent = null, ) { } + + /** + * Get the currently authenticated user from either OAuth2 or UI context + * + * @return \Auth\User|null + */ + public static function getCurrentUser() + { + $resourceContext = app(IResourceServerContext::class); + $clientId = $resourceContext->getCurrentClientId(); + $userId = $resourceContext->getCurrentUserId(); + + if (!empty($clientId) && $userId) { + // OAuth2 context: user authenticated via API + return app(IUserRepository::class)->getById($userId); + } + + // UI context: user logged in at IDP + return Auth::user(); + } + + /** + * Create an AuditContext from the current request + * Handles both OAuth2 and UI authentication contexts + */ + public static function fromCurrentRequest(): ?self + { + try { + $user = self::getCurrentUser(); + + if (!$user) { + return null; + } + + $req = request(); + + return new self( + userId: $user->getId(), + userEmail: $user->getEmail(), + userFirstName: $user->getFirstName(), + userLastName: $user->getLastName(), + route: $req?->path(), + httpMethod: $req?->method(), + clientIp: $req?->ip(), + userAgent: $req?->userAgent(), + ); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::warning('Failed to build audit context from request', [ + 'error' => $e->getMessage() + ]); + return null; + } + } } \ No newline at end of file diff --git a/app/Audit/AuditEventListener.php b/app/Audit/AuditEventListener.php index aa41060c..93df00b0 100644 --- a/app/Audit/AuditEventListener.php +++ b/app/Audit/AuditEventListener.php @@ -24,8 +24,6 @@ use Illuminate\Support\Facades\Route; use Illuminate\Http\Request; use OAuth2\IResourceServerContext; -use OAuth2\Models\IClient; -use Services\OAuth2\ResourceServerContext; /** * Class AuditEventListener @@ -99,26 +97,23 @@ private function getAuditStrategy($em): ?IAuditStrategy private function buildAuditContext(): AuditContext { + if (app()->runningInConsole()) { + if (app()->bound('audit.context')) { + $context = app('audit.context'); + if ($context instanceof AuditContext) { + return $context; + } + } + } + /*** * here we have 2 cases - * 1. we are connecting to the IDP using an external APi ( under oauth2 ) so the + * 1. we are connecting to the IDP using an external API ( under oauth2 ) so the * resource context have a client id and have a user id * 2. we are logged at idp and using the UI ( $user = Auth::user() ) ***/ - $resource_server_context = app(IResourceServerContext::class); - $oauth2_current_client_id = $resource_server_context->getCurrentClientId(); - - if(!empty($oauth2_current_client_id)) { - $userId = $resource_server_context->getCurrentUserId(); - // here $userId can be null bc - // $resource_server_context->getApplicationType() == IClient::ApplicationType_Service - $user = $userId ? app(IUserRepository::class)->getById($userId) : null; - } - else{ - // 2. we are at IDP UI - $user = Auth::user(); - } + $user = AuditContext::getCurrentUser(); $defaultUiContext = [ 'app' => null, @@ -157,4 +152,6 @@ private function buildAuditContext(): AuditContext rawRoute: $rawRoute ); } + + } \ No newline at end of file diff --git a/app/Listeners/CleanupJobAuditContextListener.php b/app/Listeners/CleanupJobAuditContextListener.php new file mode 100644 index 00000000..93d9571c --- /dev/null +++ b/app/Listeners/CleanupJobAuditContextListener.php @@ -0,0 +1,44 @@ +bound('audit.context')) { + app()->forgetInstance('audit.context'); + Log::debug('CleanupJobAuditContextListener: audit context cleaned after job', [ + 'job' => get_class($event->job), + ]); + } + } catch (\Exception $e) { + Log::warning('CleanupJobAuditContextListener failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Listeners/RestoreJobAuditContextListener.php b/app/Listeners/RestoreJobAuditContextListener.php new file mode 100644 index 00000000..ea64e96e --- /dev/null +++ b/app/Listeners/RestoreJobAuditContextListener.php @@ -0,0 +1,84 @@ +isOpenTelemetryEnabled()) { + return; + } + + try { + $context = $this->extractContextFromPayload($event->job->payload()); + + if ($context !== null) { + app()->instance(self::CONTAINER_BINDING_KEY, $context); + } + } catch (\Exception $e) { + Log::warning('Failed to restore audit context from queue job', [ + self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE, + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + ]); + } + } + + private function isOpenTelemetryEnabled(): bool + { + return config('opentelemetry.enabled', false); + } + + private function extractContextFromPayload(array $payload): ?AuditContext + { + if (!isset($payload[self::PAYLOAD_DATA_KEY][self::PAYLOAD_CONTEXT_KEY])) { + return null; + } + + try { + $context = unserialize( + $payload[self::PAYLOAD_DATA_KEY][self::PAYLOAD_CONTEXT_KEY], + ['allowed_classes' => [AuditContext::class]] + ); + + if (!$context instanceof AuditContext) { + Log::warning('Invalid audit context type in job payload', [ + self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE, + 'actual_type' => gettype($context), + ]); + return null; + } + + return $context; + } catch (\Exception $e) { + Log::warning('Failed to unserialize audit context from job payload', [ + self::LOG_CONTEXT_KEY => self::LOG_CONTEXT_VALUE, + 'exception_message' => $e->getMessage(), + ]); + return null; + } + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index f89794d9..5d79e090 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -20,6 +20,7 @@ use App\Events\UserPasswordResetRequestCreated; use App\Events\UserPasswordResetSuccessful; use App\Events\UserSpamStateUpdated; +use App\Audit\AuditContext; use App\libs\Auth\Repositories\IUserPasswordResetRequestRepository; use App\Mail\UserLockedEmail; use App\Mail\UserPasswordResetMail; @@ -30,12 +31,16 @@ use Illuminate\Database\Events\MigrationsStarted; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Models\OAuth2\Client; use OAuth2\Repositories\IClientRepository; +use OAuth2\IResourceServerContext; /** * Class EventServiceProvider @@ -57,6 +62,12 @@ final class EventServiceProvider extends ServiceProvider 'Illuminate\Auth\Events\Login' => [ 'App\Listeners\OnUserLogin', ], + \Illuminate\Queue\Events\JobProcessing::class => [ + 'App\Listeners\RestoreJobAuditContextListener', + ], + \Illuminate\Queue\Events\JobProcessed::class => [ + 'App\Listeners\CleanupJobAuditContextListener', + ], \SocialiteProviders\Manager\SocialiteWasCalled::class => [ // ... other providers 'SocialiteProviders\\Facebook\\FacebookExtendSocialite@handle', @@ -77,6 +88,21 @@ public function boot() { parent::boot(); + if (config('opentelemetry.enabled', false)) { + Queue::createPayloadUsing(function ($connection, $queue, $payload) { + try { + $context = AuditContext::fromCurrentRequest(); + + if ($context) { + $payload['data']['auditContext'] = serialize($context); + } + } catch (\Exception $e) { + Log::warning('Failed to attach audit context to job', ['error' => $e->getMessage()]); + } + return $payload; + }); + } + Event::listen(UserEmailVerified::class, function($event) { $service = App::make(IUserService::class); diff --git a/tests/Jobs/TestAuditContextJob.php b/tests/Jobs/TestAuditContextJob.php new file mode 100644 index 00000000..9b784645 --- /dev/null +++ b/tests/Jobs/TestAuditContextJob.php @@ -0,0 +1,74 @@ +testData = $testData; + } + + public function handle(): void + { + if (app()->bound('audit.context')) { + $context = app('audit.context'); + Log::info('TestAuditContextJob processed', [ + 'user_id' => $context->userId, + 'user_email' => $context->userEmail, + ]); + } + } + + public function payload(): array + { + return [ + 'data' => [ + 'testData' => $this->testData, + ], + 'displayName' => self::class, + 'job' => 'Illuminate\\Queue\\CallQueuedHandler@call', + 'maxTries' => $this->tries, + 'timeout' => $this->timeout, + 'timeoutAt' => null, + ]; + } + + public function timeout(int $seconds): self + { + $this->timeout = $seconds; + return $this; + } + + public function tries(int $tries): self + { + $this->tries = $tries; + return $this; + } +} diff --git a/tests/OpenTelemetry/AuditContextPropagationTest.php b/tests/OpenTelemetry/AuditContextPropagationTest.php new file mode 100644 index 00000000..99a56841 --- /dev/null +++ b/tests/OpenTelemetry/AuditContextPropagationTest.php @@ -0,0 +1,152 @@ +app = $this->createApplication(); + if (!config('opentelemetry.enabled')) { + $this->app['config']['opentelemetry.enabled'] = false; + } + } + + protected function tearDown(): void + { + restore_error_handler(); + restore_exception_handler(); + parent::tearDown(); + } + + public function testAuditContextIsAttachedToJobPayload(): void + { + Queue::fake(); + $testData = 'propagation-test-data'; + TestAuditContextJob::dispatch($testData); + + Queue::assertPushed(TestAuditContextJob::class, fn($job) => $job->testData === $testData); + } + + public function testAuditContextIsRestoredFromPayload(): void + { + $context = new AuditContext( + userId: self::TEST_USER_ID_1, + userEmail: self::TEST_USER_EMAIL_1, + userFirstName: 'John', + userLastName: 'Doe', + route: '/api/users', + httpMethod: 'GET', + clientIp: '192.168.1.1', + userAgent: 'Mozilla/5.0', + ); + + $restored = unserialize(serialize($context)); + + $this->assertInstanceOf(AuditContext::class, $restored); + $this->assertEquals($context->userId, $restored->userId); + $this->assertEquals($context->userEmail, $restored->userEmail); + $this->assertEquals($context->userFirstName, $restored->userFirstName); + $this->assertEquals($context->userLastName, $restored->userLastName); + $this->assertEquals($context->route, $restored->route); + $this->assertEquals($context->httpMethod, $restored->httpMethod); + $this->assertEquals($context->clientIp, $restored->clientIp); + $this->assertEquals($context->userAgent, $restored->userAgent); + } + + public function testAuditContextIsAvailableInContainer(): void + { + $context = new AuditContext( + userId: self::TEST_USER_ID_2, + userEmail: self::TEST_USER_EMAIL_2, + userFirstName: 'Container', + userLastName: 'Test', + ); + + App::singleton(self::CONTAINER_BINDING_KEY, fn() => $context); + + $this->assertTrue(App::bound(self::CONTAINER_BINDING_KEY)); + $restored = App::make(self::CONTAINER_BINDING_KEY); + + $this->assertInstanceOf(AuditContext::class, $restored); + $this->assertEquals(self::TEST_USER_ID_2, $restored->userId); + $this->assertEquals(self::TEST_USER_EMAIL_2, $restored->userEmail); + } + + public function testOTLPDisabledSkipsContextAttachment(): void + { + $this->app['config']['opentelemetry.enabled'] = false; + + Queue::fake(); + TestAuditContextJob::dispatch('test-data'); + + Queue::assertPushed(TestAuditContextJob::class); + } + + public function testContextSurvivesSerializationRoundTrip(): void + { + $original = new AuditContext( + userId: self::TEST_USER_ID_3, + userEmail: self::TEST_USER_EMAIL_3, + userFirstName: 'Round', + userLastName: 'Trip', + route: '/api/roundtrip', + httpMethod: 'PUT', + clientIp: '10.0.0.1', + userAgent: 'Test-RoundTrip/2.0', + ); + + $restored = unserialize(serialize($original)); + + $this->assertSame($original->userId, $restored->userId); + $this->assertSame($original->userEmail, $restored->userEmail); + $this->assertSame($original->userFirstName, $restored->userFirstName); + $this->assertSame($original->userLastName, $restored->userLastName); + $this->assertSame($original->route, $restored->route); + $this->assertSame($original->httpMethod, $restored->httpMethod); + $this->assertSame($original->clientIp, $restored->clientIp); + $this->assertSame($original->userAgent, $restored->userAgent); + } + + public function testContextWithNullValuesSerializes(): void + { + $context = new AuditContext(); + $restored = unserialize(serialize($context)); + + $this->assertInstanceOf(AuditContext::class, $restored); + $this->assertNull($restored->userId); + $this->assertNull($restored->userEmail); + } +} diff --git a/tests/OpenTelemetry/JobContextIntegrationTest.php b/tests/OpenTelemetry/JobContextIntegrationTest.php new file mode 100644 index 00000000..f5e96dc4 --- /dev/null +++ b/tests/OpenTelemetry/JobContextIntegrationTest.php @@ -0,0 +1,119 @@ +app = $this->createApplication(); + $this->app['config']['opentelemetry.enabled'] = true; + } + + protected function tearDown(): void + { + restore_error_handler(); + restore_exception_handler(); + parent::tearDown(); + } + + public function testJobDispatchAttachesSerializedContext(): void + { + Queue::fake(); + TestAuditContextJob::dispatch('job-dispatch-test'); + + Queue::assertPushed(TestAuditContextJob::class, fn($job) => is_array($job->payload()['data'])); + } + + public function testMultipleJobsCanQueueWithContext(): void + { + Queue::fake(); + + for ($i = 0; $i < self::EXPECTED_JOB_COUNT; $i++) { + TestAuditContextJob::dispatch("job-queue-test-$i"); + } + + $this->assertCount(self::EXPECTED_JOB_COUNT, Queue::pushed(TestAuditContextJob::class)); + } + + public function testJobPayloadStructure(): void + { + Queue::fake(); + TestAuditContextJob::dispatch('job-payload-test'); + + Queue::assertPushed(TestAuditContextJob::class, function ($job) { + $payload = $job->payload(); + + $this->assertArrayHasKey('data', $payload); + $this->assertArrayHasKey('displayName', $payload); + $this->assertArrayHasKey('job', $payload); + $this->assertArrayHasKey('maxTries', $payload); + $this->assertArrayHasKey('timeout', $payload); + $this->assertArrayHasKey('timeoutAt', $payload); + + return true; + }); + } + + public function testNoContextIsAttachedWhenOTLPDisabled(): void + { + + Queue::fake(); + TestAuditContextJob::dispatch('disabled-test'); + + Queue::assertPushed(TestAuditContextJob::class, + fn($job) => !isset($job->payload()['data']['auditContext']) + ); + } + + public function testQueueNameIsPreserved(): void + { + Queue::fake(); + TestAuditContextJob::dispatch('job-queue-name-test')->onQueue(self::QUEUE_NAME_AUDIT); + + Queue::assertPushed(TestAuditContextJob::class, fn($job) => $job->queue === self::QUEUE_NAME_AUDIT); + } + + public function testJobTimeoutIsPreserved(): void + { + Queue::fake(); + TestAuditContextJob::dispatch('job-timeout-test')->timeout(self::JOB_TIMEOUT_SECONDS); + + Queue::assertPushed(TestAuditContextJob::class, fn($job) => $job->timeout === self::JOB_TIMEOUT_SECONDS); + } + + public function testJobTriesIsPreserved(): void + { + Queue::fake(); + TestAuditContextJob::dispatch('job-tries-test')->tries(self::JOB_MAX_TRIES); + + Queue::assertPushed(TestAuditContextJob::class, fn($job) => $job->tries === self::JOB_MAX_TRIES); + } +} diff --git a/tests/OpenTelemetry/Listeners/RestoreJobAuditContextListenerTest.php b/tests/OpenTelemetry/Listeners/RestoreJobAuditContextListenerTest.php new file mode 100644 index 00000000..e6320faf --- /dev/null +++ b/tests/OpenTelemetry/Listeners/RestoreJobAuditContextListenerTest.php @@ -0,0 +1,198 @@ +app = $this->createApplication(); + $this->listener = new RestoreJobAuditContextListener(); + } + + protected function tearDown(): void + { + restore_error_handler(); + restore_exception_handler(); + parent::tearDown(); + } + + /** + * Creates a valid test AuditContext with meaningful test data. + */ + private function createTestAuditContext(): AuditContext + { + return new AuditContext( + userId: self::TEST_USER_ID, + userEmail: self::TEST_USER_EMAIL, + userFirstName: self::TEST_USER_FIRST_NAME, + userLastName: self::TEST_USER_LAST_NAME, + route: self::TEST_ROUTE, + httpMethod: self::TEST_HTTP_METHOD, + clientIp: self::TEST_CLIENT_IP, + userAgent: self::TEST_USER_AGENT, + ); + } + + /** + * Creates a job payload structure with serialized context. + */ + private function createPayloadWithContext(AuditContext $context): array + { + return [ + 'uuid' => 'test-uuid-' . uniqid(), + 'displayName' => TestAuditContextJob::class, + 'job' => 'Illuminate\\Queue\\CallQueuedHandler@call', + 'maxTries' => 1, + 'timeout' => 60, + 'timeoutAt' => null, + 'data' => [ + 'auditContext' => serialize($context), + ], + ]; + } + + /** + * Creates a minimal job payload without context data. + */ + private function createPayloadWithoutContext(): array + { + return [ + 'uuid' => 'test-uuid-' . uniqid(), + 'displayName' => TestAuditContextJob::class, + 'job' => 'Illuminate\\Queue\\CallQueuedHandler@call', + 'data' => [], + ]; + } + + private function createMockJobWithPayload(array $payload): \Illuminate\Contracts\Queue\Job + { + $mockJob = $this->createMock(\Illuminate\Contracts\Queue\Job::class); + $mockJob->method('payload')->willReturn($payload); + + return $mockJob; + } + + public function testListenerRestoresContextFromPayload(): void + { + // Arrange: Enable OTLP and create a valid context + $this->app['config']['opentelemetry.enabled'] = true; + $context = $this->createTestAuditContext(); + $payload = $this->createPayloadWithContext($context); + + // Act + $mockJob = $this->createMockJobWithPayload($payload); + $event = new JobProcessing(self::DEFAULT_QUEUE_NAME, $mockJob); + $this->listener->handle($event); + + // Assert: Context was bound to container + $this->assertTrue(App::bound(self::CONTAINER_BINDING_KEY)); + $restoredContext = App::make(self::CONTAINER_BINDING_KEY); + + $this->assertInstanceOf(AuditContext::class, $restoredContext); + $this->assertEquals(self::TEST_USER_ID, $restoredContext->userId); + $this->assertEquals(self::TEST_USER_EMAIL, $restoredContext->userEmail); + } + + public function testListenerDoesNotBindMissingContext(): void + { + // Arrange: Enable OTLP but provide payload without context + $this->app['config']['opentelemetry.enabled'] = true; + $payload = $this->createPayloadWithoutContext(); + + // Act + $mockJob = $this->createMockJobWithPayload($payload); + $event = new JobProcessing(self::DEFAULT_QUEUE_NAME, $mockJob); + $this->listener->handle($event); + + // Assert: No context binding occurs + $this->assertFalse(App::bound(self::CONTAINER_BINDING_KEY)); + } + + public function testListenerSkipsWhenOTLPDisabled(): void + { + // Arrange: Disable OTLP + $this->app['config']['opentelemetry.enabled'] = false; + $context = $this->createTestAuditContext(); + $payload = $this->createPayloadWithContext($context); + + // Act + $mockJob = $this->createMockJobWithPayload($payload); + $event = new JobProcessing(self::DEFAULT_QUEUE_NAME, $mockJob); + $this->listener->handle($event); + + // Assert: No context binding even though context exists + $this->assertFalse(App::bound(self::CONTAINER_BINDING_KEY)); + } + + public function testListenerHandlesInvalidSerializedData(): void + { + // Arrange: Enable OTLP with invalid serialized data + $this->app['config']['opentelemetry.enabled'] = true; + $payload = [ + 'uuid' => 'test-uuid-' . uniqid(), + 'data' => ['auditContext' => 'not-serialized-data'], + ]; + + // Act + $mockJob = $this->createMockJobWithPayload($payload); + $event = new JobProcessing(self::DEFAULT_QUEUE_NAME, $mockJob); + $this->listener->handle($event); + + // Assert: Error is handled gracefully, no context bound + $this->assertFalse(App::bound(self::CONTAINER_BINDING_KEY)); + } + + public function testListenerHandlesPayloadRetrievalException(): void + { + // Arrange: Create mock job that throws exception on payload access + $this->app['config']['opentelemetry.enabled'] = true; + $mockJob = $this->createMock(\Illuminate\Contracts\Queue\Job::class); + $mockJob->method('payload') + ->willThrowException(new \RuntimeException('Payload retrieval failed')); + + // Act & Assert: Exception is caught and logged, no context bound + $event = new JobProcessing(self::DEFAULT_QUEUE_NAME, $mockJob); + $this->listener->handle($event); + + $this->assertFalse(App::bound(self::CONTAINER_BINDING_KEY)); + } +} diff --git a/tests/OpenTelemetry/Providers/EventServiceProviderAuditTest.php b/tests/OpenTelemetry/Providers/EventServiceProviderAuditTest.php new file mode 100644 index 00000000..e62c021f --- /dev/null +++ b/tests/OpenTelemetry/Providers/EventServiceProviderAuditTest.php @@ -0,0 +1,112 @@ +app = $this->createApplication(); + $this->app['config']['opentelemetry.enabled'] = true; + } + + protected function tearDown(): void + { + restore_error_handler(); + restore_exception_handler(); + parent::tearDown(); + } + + public function testOTLPEnabledEnqueuesContextWithPayload(): void + { + Queue::fake(); + + TestAuditContextJob::dispatch('provider-otlp-enabled-test'); + + Queue::assertPushed(TestAuditContextJob::class, + fn($job) => isset($job->payload()['data']) && is_array($job->payload()['data']) + ); + } + + public function testOTLPDisabledDoesNotAttemptContextAttachment(): void + { + $this->app['config']['opentelemetry.enabled'] = false; + Queue::fake(); + + TestAuditContextJob::dispatch('disabled-test'); + + Queue::assertPushed(TestAuditContextJob::class, + fn($job) => !isset($job->payload()['data']['auditContext']) + ); + } + + public function testPayloadStructureIsValid(): void + { + Queue::fake(); + + TestAuditContextJob::dispatch('provider-payload-structure-test')->onQueue(self::QUEUE_NAME_TEST); + + Queue::assertPushed(TestAuditContextJob::class, function ($job) { + $payload = $job->payload(); + return isset($payload['data'], $payload['displayName'], $payload['job']); + }); + } + + public function testContextCanBeRetrievedFromPayloadAfterDispatch(): void + { + Queue::fake(); + + TestAuditContextJob::dispatch('provider-context-retrieval-test'); + + Queue::assertPushed(TestAuditContextJob::class, function ($job) { + $payload = $job->payload(); + + if (!isset($payload['data']['auditContext'])) { + return true; + } + + try { + $context = unserialize($payload['data']['auditContext']); + return $context instanceof AuditContext; + } catch (\Exception $e) { + return false; + } + }); + } + + public function testMultipleJobsPreserveIndependentContexts(): void + { + Queue::fake(); + + for ($i = 1; $i <= self::EXPECTED_JOB_COUNT; $i++) { + TestAuditContextJob::dispatch("provider-context-job-$i"); + } + + $this->assertCount(self::EXPECTED_JOB_COUNT, Queue::pushed(TestAuditContextJob::class)); + } +}