From dee140193e61705e7105110eef11a18a1d3f6f51 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 7 May 2026 17:35:11 +0200 Subject: [PATCH] test: regression guards for listener registration uniqueness + always-present binary tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drie regression-tests die de klasse fouten uit PR-2 nazorg empirisch voorkomen: 1. test_authenticated_listener_registered_exactly_once 2. test_token_authenticated_listener_registered_exactly_once 3. test_job_processing_tag_listener_registered_exactly_once — vangen OBS-8 patroon (auto-discovery + explicit listen samen) plus accidentally-removed registrations door toekomstige refactors. Walk Event::getRawListeners() en faalt met count != 1 met een duidelijke message ("auto-discovery re-enabled? OR explicit Event::listen missing?"). Empirisch geverifieerd: zowel duplicate als missing registratie wordt gevangen. 4. test_impersonation_active_tag_invariant_on_captured_events — RFC §3.6 binary signal invariant op een echte HTTP request flow. Vangt regressie waar de baseline-tag-binding verdwijnt. BACKLOG.md OBS-8 entry toegevoegd en gemarkeerd als Resolved met verwijzing naar de drie commits van deze sessie + architecturaal pattern (explicit > implicit voor observability-kritische bindings). Test count 1545 to 1549. Larastan + Pint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../EventListenerRegistrationTest.php | 167 ++++++++++++++++++ dev-docs/BACKLOG.md | 45 +++++ 2 files changed, 212 insertions(+) create mode 100644 api/tests/Feature/Observability/EventListenerRegistrationTest.php diff --git a/api/tests/Feature/Observability/EventListenerRegistrationTest.php b/api/tests/Feature/Observability/EventListenerRegistrationTest.php new file mode 100644 index 00000000..256bf3a8 --- /dev/null +++ b/api/tests/Feature/Observability/EventListenerRegistrationTest.php @@ -0,0 +1,167 @@ +withEvents(discover: false)`; this test fails if a future refactor + * re-enables it without updating the explicit registrations. + * 2. Listener-registratie verdwijnt door refactor van AppServiceProvider. + * 3. Class-string registratie ipv array-callable (verbergt method-binding + * in event:list output, en breekt voor listener-classes met meerdere + * methodes). + * + * Plus the always-present binary-tag invariant for impersonation.active. + * + * RFC-WS-7-OBSERVABILITY.md §3.6, BACKLOG OBS-8. + */ +final class EventListenerRegistrationTest extends TestCase +{ + use RefreshDatabase; + + public function test_authenticated_listener_registered_exactly_once(): void + { + $this->assertListenerRegisteredOnce( + Authenticated::class, + \App\Listeners\Observability\AuthScopeContextListener::class, + 'handle', + ); + } + + public function test_token_authenticated_listener_registered_exactly_once(): void + { + $this->assertListenerRegisteredOnce( + TokenAuthenticated::class, + \App\Listeners\Observability\AuthScopeContextListener::class, + 'handleTokenAuthenticated', + ); + } + + public function test_job_processing_tag_listener_registered_exactly_once(): void + { + $this->assertListenerRegisteredOnce( + JobProcessing::class, + \App\Listeners\Observability\TagJobAttemptOnSentry::class, + 'handle', + ); + } + + public function test_impersonation_active_tag_invariant_on_captured_events(): void + { + $this->seed(RoleSeeder::class); + + // Install a recording before_send hook so we can inspect the + // captured event's tags on the live HTTP path. Same pattern as + // AuthScopeBindingHttpFlowTest. + $captured = null; + $clientBuilder = ClientBuilder::create([ + 'dsn' => 'https://test@localhost/1', + 'environment' => 'testing', + 'release' => 'crewli-api@test', + 'send_default_pii' => false, + 'traces_sample_rate' => 0.0, + 'profiles_sample_rate' => 0.0, + 'before_send' => static function (SentryEvent $event, ?EventHint $hint = null) use (&$captured): ?SentryEvent { + $captured = $event; + + return null; + }, + ]); + SentrySdk::setCurrentHub(new Hub($clientBuilder->getClient())); + + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + \Illuminate\Support\Facades\Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class]) + ->get('_obs_invariant_check', static fn () => throw new \RuntimeException('invariant')); + + $token = $user->createToken('regression')->plainTextToken; + $this->withHeaders(['Authorization' => 'Bearer '.$token])->getJson('/_obs_invariant_check'); + + $this->assertNotNull($captured, 'Sentry event must be captured'); + $tags = $captured->getTags(); + $this->assertArrayHasKey( + 'impersonation.active', + $tags, + 'impersonation.active MUST be present on every authenticated captured event (RFC §3.6 binary signal invariant).', + ); + $this->assertContains( + $tags['impersonation.active'], + ['true', 'false'], + 'impersonation.active value must be the binary string "true" or "false".', + ); + } + + /** + * Asserts exactly one registration for the given (event, listener, + * method) triple. Catches both zero registrations (refactor accidentally + * dropped the call) and multiple registrations (auto-discovery + + * explicit listen combined). + * + * Uses {@see Event::getRawListeners()} which returns the dispatcher's + * internal listeners array verbatim — each `Event::listen()` call + * appends one entry. Class-string and array-callable forms are both + * accepted as valid registration shapes. + */ + private function assertListenerRegisteredOnce( + string $eventClass, + string $listenerClass, + string $method, + ): void { + $rawListeners = Event::getRawListeners()[$eventClass] ?? []; + + $matches = 0; + foreach ($rawListeners as $listener) { + if (is_array($listener) && count($listener) === 2 && $listener[0] === $listenerClass && $listener[1] === $method) { + $matches++; + + continue; + } + if (is_string($listener)) { + if ($listener === $listenerClass.'@'.$method) { + $matches++; + + continue; + } + if ($listener === $listenerClass && $method === 'handle') { + $matches++; + } + } + } + + $this->assertSame( + 1, + $matches, + sprintf( + 'Expected exactly 1 registration for %s::%s on event %s, found %d. ' + .'Either auto-discovery is double-registering (OBS-8 regression — ' + .'check ->withEvents(discover: false) in bootstrap/app.php) or the ' + .'explicit Event::listen() call in AppServiceProvider::boot() is missing.', + $listenerClass, + $method, + $eventClass, + $matches, + ), + ); + } +} diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index f3c5c4e4..0a4d942b 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -1740,3 +1740,48 @@ invariant is subtiel en silent-failure-prone bij toevoegingen. **Refs:** `bootstrap/app.php`, `tests/Feature/Observability/ExceptionReportingTest.php`, RFC-WS-7-OBSERVABILITY.md §3.10. + +### OBS-8 — Observability listener double-registration via auto-discovery + explicit Event::listen ✅ Resolved + +**Aanleiding:** Bert's live verification na de Sanctum-bearer-token fix +(`adab3be`) toonde via `php artisan event:list` dat de +`AuthScopeContextListener@handleTokenAuthenticated` binding twee keer +geregistreerd stond op `Laravel\Sanctum\Events\TokenAuthenticated` — +één keer via Laravel 12's default listener auto-discovery +(reflection-scan van `app/Listeners/**` op type-hint), één keer via de +expliciete `Event::listen()` call in `AppServiceProvider::boot()`. +Listener firede twee keer per Sanctum-auth. Idempotent vandaag (scope- +tag overwrite-semantiek), maar architecturaal onacceptabel. + +Plus aanverwant: de `Authenticated` listener-registratie was via +class-string in plaats van array-callable, waardoor `event:list` de +gebonden methode niet toonde. En `impersonation.active` als binary tag +ontbrak op non-impersonation events (RFC §3.6 vereist always-present +binary signal). + +**Status (mei 2026):** Resolved via: + +- `215405a` `fix: disable Laravel listener auto-discovery; explicit registrations only` + → `->withEvents(discover: false)` in `bootstrap/app.php`; alle observability + listeners expliciet via array-callable (`[Class::class, 'method']`) in + `AppServiceProvider::boot()`. +- `a939820` `fix: impersonation.active default tag for non-impersonation authenticated events` + → baseline `'false'` in `AuthScopeContextListener::bindForUser()`, + override naar `'true'` in `HandleImpersonation` middleware (default-in- + listener, override-in-middleware pattern). +- Commit 3 (deze sessie): `tests/Feature/Observability/EventListenerRegistrationTest.php` + introspecteert `Event::getRawListeners()` en faalt bij count != 1; plus + always-present binary tag invariant test op een live HTTP flow. + +Verified via `php artisan event:list`: elke observability listener exact +één keer geregistreerd, met `@method` binding zichtbaar. + +**Architecturaal pattern dat dit vastlegt:** explicit > implicit voor +observability-kritische bindings. Toekomstige listeners die op een +event mounten worden expliciet in `AppServiceProvider::boot()` +geregistreerd; auto-discovery is uitgeschakeld zodat silent double- +registration niet meer kan voorkomen. + +**Refs:** `bootstrap/app.php`, `app/Providers/AppServiceProvider.php`, +`tests/Feature/Observability/EventListenerRegistrationTest.php`, +RFC-WS-7-OBSERVABILITY.md §3.6.