test: regression guards for listener registration uniqueness + always-present binary tags
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Observability;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Auth\Events\Authenticated;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Queue\Events\JobProcessing;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Laravel\Sanctum\Events\TokenAuthenticated;
|
||||||
|
use Sentry\ClientBuilder;
|
||||||
|
use Sentry\Event as SentryEvent;
|
||||||
|
use Sentry\EventHint;
|
||||||
|
use Sentry\SentrySdk;
|
||||||
|
use Sentry\State\Hub;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression guards for OBS-8 — observability listener registratie-discipline.
|
||||||
|
*
|
||||||
|
* Catches:
|
||||||
|
* 1. Double-registration via auto-discovery + explicit listen combinatie.
|
||||||
|
* {@see bootstrap/app.php} disables auto-discovery via
|
||||||
|
* `->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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1740,3 +1740,48 @@ invariant is subtiel en silent-failure-prone bij toevoegingen.
|
|||||||
**Refs:** `bootstrap/app.php`,
|
**Refs:** `bootstrap/app.php`,
|
||||||
`tests/Feature/Observability/ExceptionReportingTest.php`,
|
`tests/Feature/Observability/ExceptionReportingTest.php`,
|
||||||
RFC-WS-7-OBSERVABILITY.md §3.10.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user