diff --git a/api/app/Http/Middleware/BindSentryContext.php b/api/app/Http/Middleware/BindSentryContext.php deleted file mode 100644 index d414e210..00000000 --- a/api/app/Http/Middleware/BindSentryContext.php +++ /dev/null @@ -1,195 +0,0 @@ -user(); - $actorType = ActorType::resolve($user, $request); - $organisationId = $this->resolveOrganisationId($request); - $eventId = $this->resolveEventId($request); - $impersonatorUserId = $this->resolveImpersonatorUserId($request); - - $this->enforceTenantInvariant($request, $user, $organisationId); - - configureScope(function (Scope $scope) use ($request, $user, $actorType, $organisationId, $eventId, $impersonatorUserId): void { - $scope->setTag('app', 'api'); - $scope->setTag('http.method', $request->method()); - $scope->setTag('actor_type', $actorType->value); - - $routeName = $request->route()?->getName(); - if (is_string($routeName) && $routeName !== '') { - $scope->setTag('route_name', $routeName); - } - - if ($user instanceof User) { - $scope->setTag('user_id', $user->id); - $scope->setUser([ - 'id' => $user->id, - 'username' => $user->id, - ]); - } - - if (is_string($organisationId) && $organisationId !== '') { - $scope->setTag('organisation_id', $organisationId); - } - - if (is_string($eventId) && $eventId !== '') { - $scope->setTag('event_id', $eventId); - } - - $scope->setTag('impersonation.active', $impersonatorUserId !== null ? 'true' : 'false'); - if ($impersonatorUserId !== null) { - $scope->setTag('impersonation.impersonator_user_id', $impersonatorUserId); - } - }); - - return $next($request); - } - - private function resolveOrganisationId(Request $request): ?string - { - $portalEvent = $request->attributes->get('portal_event'); - if ($portalEvent instanceof Event) { - return $portalEvent->organisation_id; - } - - $route = $request->route(); - if ($route === null) { - return null; - } - - $org = $route->parameter('organisation'); - if ($org instanceof Organisation) { - return $org->id; - } - if (is_string($org) && $org !== '') { - return $org; - } - - $event = $route->parameter('event'); - if ($event instanceof Event) { - return $event->organisation_id; - } - - return null; - } - - private function resolveEventId(Request $request): ?string - { - $portalEvent = $request->attributes->get('portal_event'); - if ($portalEvent instanceof Event) { - return $portalEvent->id; - } - - $event = $request->route()?->parameter('event'); - if ($event instanceof Event) { - return $event->id; - } - if (is_string($event) && $event !== '') { - return $event; - } - - return null; - } - - private function resolveImpersonatorUserId(Request $request): ?string - { - $impersonator = $request->attributes->get('impersonator'); - if ($impersonator instanceof User) { - return $impersonator->id; - } - - $session = $request->attributes->get('impersonation_session'); - if ($session instanceof ImpersonationSession) { - return $session->admin_id; - } - - return null; - } - - private function enforceTenantInvariant(Request $request, mixed $user, ?string $organisationId): void - { - if (! $user instanceof User) { - return; - } - - if ($organisationId !== null) { - return; - } - - if ($this->routeRequiresTenantContext($request) === false) { - return; - } - - $env = app()->environment(); - $message = sprintf( - 'BindSentryContext: authenticated request to "%s" lacks resolvable organisation_id', - $request->path(), - ); - - if (in_array($env, ['local', 'testing'], true)) { - throw new RuntimeException($message); - } - - Log::warning($message, [ - 'route' => $request->route()?->getName(), - 'user_id' => $user->id, - ]); - } - - /** - * The Crewli tenant invariant: a route that declares an `{organisation}` - * or `{event}` URI parameter MUST resolve to a real organisation_id by - * the time this middleware runs. Routes that don't declare those params - * are user-scoped (account, portal, lists across user's memberships) and - * legitimately have no tenant context. - */ - private function routeRequiresTenantContext(Request $request): bool - { - if ($request->attributes->get('portal_event') !== null) { - return true; - } - - $route = $request->route(); - if ($route === null) { - // Synthetic test requests without a Laravel route still trigger - // the invariant if the path looks tenant-scoped, so dev-time - // bugs surface in unit/feature tests. - return str_contains($request->path(), 'organisations/') - || str_contains($request->path(), 'events/'); - } - - $names = $route->parameterNames(); - - return in_array('organisation', $names, true) || in_array('event', $names, true); - } -} diff --git a/api/app/Http/Middleware/BindSentryRouteContext.php b/api/app/Http/Middleware/BindSentryRouteContext.php new file mode 100644 index 00000000..87b15327 --- /dev/null +++ b/api/app/Http/Middleware/BindSentryRouteContext.php @@ -0,0 +1,41 @@ +setTag('app', 'api'); + $scope->setTag('http.method', $request->method()); + + $routeName = $request->route()?->getName(); + if (is_string($routeName) && $routeName !== '') { + $scope->setTag('route_name', $routeName); + } + }); + + return $next($request); + } +} diff --git a/api/app/Http/Middleware/HandleImpersonation.php b/api/app/Http/Middleware/HandleImpersonation.php index e44644c9..0e998781 100644 --- a/api/app/Http/Middleware/HandleImpersonation.php +++ b/api/app/Http/Middleware/HandleImpersonation.php @@ -4,13 +4,17 @@ declare(strict_types=1); namespace App\Http\Middleware; -use App\Services\ImpersonationService; +use App\Enums\Observability\ActorType; use App\Models\User; +use App\Services\ImpersonationService; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Sentry\State\Scope; use Symfony\Component\HttpFoundation\Response; +use function Sentry\configureScope; + class HandleImpersonation { /** @@ -88,6 +92,24 @@ class HandleImpersonation 'impersonation_session_id' => $session->id, ]); + // Re-bind Sentry auth-scope tags after the user swap. The + // Authenticated event already fired with the admin; AuthScopeContextListener + // tagged the admin's user_id/actor_type. We now overwrite both with + // the target's data and add the impersonation.* invariants + // (RFC-WS-7 §3.6) so captured events attribute correctly. + $targetActorType = ActorType::resolve($targetUser, $request); + configureScope(static function (Scope $scope) use ($admin, $targetUser, $session, $targetActorType): void { + $scope->setUser([ + 'id' => $targetUser->id, + 'username' => $targetUser->id, + ]); + $scope->setTag('user_id', $targetUser->id); + $scope->setTag('actor_type', $targetActorType->value); + $scope->setTag('impersonation.active', 'true'); + $scope->setTag('impersonation.impersonator_user_id', $admin->id); + $scope->setTag('impersonation.session_id', $session->id); + }); + // Increment actions count $this->impersonationService->incrementActionsCount($session); diff --git a/api/app/Listeners/Observability/AuthScopeContextListener.php b/api/app/Listeners/Observability/AuthScopeContextListener.php new file mode 100644 index 00000000..2f49b627 --- /dev/null +++ b/api/app/Listeners/Observability/AuthScopeContextListener.php @@ -0,0 +1,58 @@ +user; + + if (! $user instanceof User) { + return; + } + + $actorType = ActorType::resolve($user, request()); + + configureScope(static function (Scope $scope) use ($user, $actorType): void { + $scope->setUser([ + 'id' => $user->id, + 'username' => $user->id, // RFC §3.8: ULID, never email. + ]); + $scope->setTag('user_id', $user->id); + $scope->setTag('actor_type', $actorType->value); + }); + + Log::withContext([ + 'user_id' => $user->id, + ]); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 02706692..842eedbf 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -204,6 +204,15 @@ class AppServiceProvider extends ServiceProvider \App\Listeners\Observability\TagJobAttemptOnSentry::class, ); + // RFC-WS-7 §3.6 — auth-scope Sentry tags + Log::withContext on + // every successful authentication. Decoupled from middleware so + // future authenticators (e.g. portal-token) only need to fire + // the Authenticated event for tagging to work. + \Illuminate\Support\Facades\Event::listen( + \Illuminate\Auth\Events\Authenticated::class, + \App\Listeners\Observability\AuthScopeContextListener::class, + ); + ResetPassword::createUrlUsing(function ($user, string $token) { return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email); }); diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 84e3489d..182c051c 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -34,17 +34,18 @@ return Application::configure(basePath: dirname(__DIR__)) // round-trip. Runs early so unauthenticated 4xx responses // still carry a request_id header. \App\Http\Middleware\BindRequestLogContext::class, + // RFC-WS-7 §3.6 — route-scope Sentry tags (app/route_name/ + // http.method). Auth-scope tags (user_id/actor_type/ + // organisation_id/actor_scope/impersonation.*) bind in + // AuthScopeContextListener on the Authenticated event, + // not in middleware. See the listener for rationale. + \App\Http\Middleware\BindSentryRouteContext::class, ]); $middleware->alias([ 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'impersonation' => \App\Http\Middleware\HandleImpersonation::class, - // RFC-WS-7 §3.6 — applied inside auth:sanctum groups so it runs - // after authentication and can read $request->user(). Cannot live - // on the api group because route-level auth middleware runs after - // group middleware in Laravel. - 'sentry.context' => \App\Http\Middleware\BindSentryContext::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/api/routes/api.php b/api/routes/api.php index ae915fd4..412c70d1 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -120,7 +120,7 @@ Route::middleware('throttle:30,1')->group(function (): void { // Platform Admin routes Route::prefix('admin') - ->middleware(['auth:sanctum', 'impersonation', 'sentry.context', 'role:super_admin']) + ->middleware(['auth:sanctum', 'impersonation', 'role:super_admin']) ->name('admin.') ->group(function () { // Organisations @@ -155,7 +155,7 @@ Route::prefix('admin') }); // Protected routes -Route::middleware(['auth:sanctum', 'impersonation', 'sentry.context'])->group(function () { +Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { // Impersonation (stop — accessible by impersonated user, not just super_admin) Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']); diff --git a/api/tests/Feature/Observability/AuthScopeContextListenerTest.php b/api/tests/Feature/Observability/AuthScopeContextListenerTest.php new file mode 100644 index 00000000..b2e13f1b --- /dev/null +++ b/api/tests/Feature/Observability/AuthScopeContextListenerTest.php @@ -0,0 +1,172 @@ +seed(RoleSeeder::class); + SentrySdk::getCurrentHub()->pushScope(); + } + + /** + * @return array + */ + private function captureScopeTags(): array + { + $event = SentryEvent::createEvent(); + + configureScope(static function (Scope $scope) use ($event): void { + $scope->applyToEvent($event); + }); + + return $event->getTags(); + } + + private function captureScopeUserId(): ?string + { + $event = SentryEvent::createEvent(); + + configureScope(static function (Scope $scope) use ($event): void { + $scope->applyToEvent($event); + }); + + return $event->getUser()?->getId(); + } + + public function test_authenticated_event_tags_user_id(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame($user->id, $tags['user_id'] ?? null); + $this->assertSame($user->id, $this->captureScopeUserId()); + } + + public function test_authenticated_event_tags_actor_type_super_admin(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + event(new Authenticated('web', $user)); + + $this->assertSame('super_admin', $this->captureScopeTags()['actor_type'] ?? null); + } + + public function test_authenticated_event_tags_actor_type_organizer_admin(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + event(new Authenticated('web', $user)); + + $this->assertSame('organizer_admin', $this->captureScopeTags()['actor_type'] ?? null); + } + + public function test_authenticated_event_tags_actor_type_org_member(): void + { + $user = User::factory()->create(); + $user->assignRole('org_member'); + + event(new Authenticated('web', $user)); + + $this->assertSame('org_member', $this->captureScopeTags()['actor_type'] ?? null); + } + + public function test_authenticated_event_does_not_set_impersonation_tags(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertArrayNotHasKey('impersonation.active', $tags); + $this->assertArrayNotHasKey('impersonation.impersonator_user_id', $tags); + } + + public function test_handle_impersonation_rebinds_user_id_and_tags_impersonation_after_swap(): void + { + Organisation::factory()->create(); // tenancy fixture + + $admin = User::factory()->create([ + 'mfa_enabled' => true, + 'mfa_method' => \App\Enums\MfaMethod::TOTP->value, + 'mfa_secret' => encrypt('JBSWY3DPEHPK3PXP'), + 'mfa_confirmed_at' => now(), + ]); + $admin->assignRole('super_admin'); + + $target = User::factory()->create(); + $target->assignRole('org_admin'); + + // Authenticated event for the admin (Sanctum's normal flow). + event(new Authenticated('web', $admin)); + $this->assertSame($admin->id, $this->captureScopeTags()['user_id'] ?? null); + $this->assertSame('super_admin', $this->captureScopeTags()['actor_type'] ?? null); + + // Manufacture an impersonation session and run HandleImpersonation + // through to the post-swap re-binding logic. + $session = ImpersonationSession::create([ + 'admin_id' => $admin->id, + 'target_user_id' => $target->id, + 'reason' => 'test', + 'mfa_method' => \App\Enums\MfaMethod::TOTP->value, + 'ip_address' => '127.0.0.1', + 'started_at' => now(), + 'expires_at' => now()->addHour(), + ]); + \Illuminate\Support\Facades\Cache::put( + 'impersonation:'.$admin->id.':'.$target->id, + $session->id, + now()->addHour(), + ); + + $request = Request::create('http://localhost/api/v1/me/profile', 'GET'); + $request->headers->set('X-Impersonate-User', $target->id); + $request->setUserResolver(static fn () => $admin); + $request->server->set('REMOTE_ADDR', '127.0.0.1'); + + $middleware = app(HandleImpersonation::class); + $middleware->handle($request, static fn () => response('ok')); + + $tags = $this->captureScopeTags(); + $this->assertSame($target->id, $tags['user_id'] ?? null); + $this->assertSame('organizer_admin', $tags['actor_type']); + $this->assertSame('true', $tags['impersonation.active'] ?? null); + $this->assertSame($admin->id, $tags['impersonation.impersonator_user_id'] ?? null); + } +} diff --git a/api/tests/Feature/Observability/BindSentryContextTest.php b/api/tests/Feature/Observability/BindSentryContextTest.php deleted file mode 100644 index ecda8981..00000000 --- a/api/tests/Feature/Observability/BindSentryContextTest.php +++ /dev/null @@ -1,328 +0,0 @@ -seed(RoleSeeder::class); - $this->resetSentryScope(); - } - - private function resetSentryScope(): void - { - SentrySdk::getCurrentHub()->pushScope(); - } - - /** - * Read the current Sentry scope by applying it to a fresh Event and - * harvesting the tags / user context. - * - * @return array{tags: array, user: ?array} - */ - private function captureScope(): array - { - $event = SentryEvent::createEvent(); - - configureScope(static function (Scope $scope) use ($event): void { - $scope->applyToEvent($event); - }); - - $userBag = $event->getUser(); - - return [ - 'tags' => $event->getTags(), - 'user' => $userBag === null ? null : array_filter([ - 'id' => $userBag->getId(), - 'username' => $userBag->getUsername(), - ], static fn ($v) => $v !== null), - ]; - } - - private function runMiddleware(Request $request): void - { - (new BindSentryContext)->handle($request, static fn (Request $req) => response('ok')); - } - - private function makeAuthenticatedRequest(User $user, string $path = 'api/v1/me/profile'): Request - { - $request = Request::create('http://localhost/'.$path, 'GET'); - $request->setUserResolver(static fn () => $user); - - return $request; - } - - public function test_authenticated_org_admin_request_tags_organisation_id(): void - { - $org = Organisation::factory()->create(); - $user = User::factory()->create(); - $org->users()->attach($user, ['role' => 'org_admin']); - $user->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($user, 'api/v1/organisations/'.$org->id.'/some-path'); - $request->setRouteResolver(function () use ($org, $request) { - $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/some-path', static fn () => null); - $route->bind($request); - $route->setParameter('organisation', $org); - $route->name('organisations.test'); - - return $route; - }); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame($org->id, $scope['tags']['organisation_id'] ?? null); - } - - public function test_authenticated_org_admin_request_tags_user_id(): void - { - $user = User::factory()->create(); - $user->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame($user->id, $scope['tags']['user_id'] ?? null); - $this->assertSame($user->id, $scope['user']['id'] ?? null); - $this->assertSame($user->id, $scope['user']['username'] ?? null); - } - - public function test_authenticated_org_admin_request_tags_actor_type_organizer_admin(): void - { - $user = User::factory()->create(); - $user->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($user); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('organizer_admin', $scope['tags']['actor_type'] ?? null); - } - - public function test_super_admin_request_tags_actor_type_super_admin(): void - { - $user = User::factory()->create(); - $user->assignRole('super_admin'); - - $request = $this->makeAuthenticatedRequest($user, 'api/v1/admin/organisations'); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('super_admin', $scope['tags']['actor_type'] ?? null); - } - - public function test_org_member_authenticated_user_tags_actor_type_org_member(): void - { - // Volunteer-ness is behaviour in Crewli (shift assignments), not - // identity — non-admin authenticated users resolve to ORG_MEMBER. - $user = User::factory()->create(); - $user->assignRole('org_member'); - - $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('org_member', $scope['tags']['actor_type'] ?? null); - } - - public function test_portal_token_request_tags_actor_type_portal_token(): void - { - $org = Organisation::factory()->create(); - $event = Event::factory()->create(['organisation_id' => $org->id]); - - $request = Request::create('http://localhost/api/v1/portal/me', 'GET'); - $request->attributes->set('portal_context', 'artist'); - $request->attributes->set('portal_event', $event); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('portal_token', $scope['tags']['actor_type'] ?? null); - } - - public function test_portal_token_request_tags_organisation_id_from_token(): void - { - $org = Organisation::factory()->create(); - $event = Event::factory()->create(['organisation_id' => $org->id]); - - $request = Request::create('http://localhost/api/v1/portal/me', 'GET'); - $request->attributes->set('portal_context', 'artist'); - $request->attributes->set('portal_event', $event); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame($org->id, $scope['tags']['organisation_id'] ?? null); - $this->assertSame($event->id, $scope['tags']['event_id'] ?? null); - } - - public function test_unauthenticated_request_tags_actor_type_unauthenticated(): void - { - $request = Request::create('http://localhost/api/v1/auth/login', 'POST'); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('unauthenticated', $scope['tags']['actor_type'] ?? null); - $this->assertArrayNotHasKey('user_id', $scope['tags']); - } - - public function test_authenticated_request_to_tenant_scoped_route_without_org_throws_in_test_environment(): void - { - $user = User::factory()->create(); - $user->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($user, 'api/v1/organisations/missing/events'); - // Synthesise a route that DECLARES {organisation} but has no - // bound parameter — the invariant must fire. - $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/events', static fn () => null); - $route->bind($request); - $request->setRouteResolver(static fn () => $route); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessageMatches('/lacks resolvable organisation_id/'); - - $this->runMiddleware($request); - } - - public function test_authenticated_request_to_user_scoped_route_skips_invariant(): void - { - $user = User::factory()->create(); - $user->assignRole('org_admin'); - - // /me/profile route declares no {organisation} or {event} param — - // user-scoped, invariant skipped. - $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); - $route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null); - $route->bind($request); - $request->setRouteResolver(static fn () => $route); - - $this->runMiddleware($request); - - $scope = $this->captureScope(); - $this->assertSame('organizer_admin', $scope['tags']['actor_type'] ?? null); - } - - public function test_impersonation_active_tag_when_session_active(): void - { - $admin = User::factory()->create(); - $admin->assignRole('super_admin'); - $target = User::factory()->create(); - $target->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($target, 'api/v1/me/profile'); - $request->attributes->set('impersonator', $admin); - $session = new ImpersonationSession; - $session->id = '01J0000000000000000000IMPS'; - $session->admin_id = $admin->id; - $session->target_user_id = $target->id; - $request->attributes->set('impersonation_session', $session); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('true', $scope['tags']['impersonation.active'] ?? null); - } - - public function test_impersonation_impersonator_user_id_tag_when_session_active(): void - { - $admin = User::factory()->create(); - $admin->assignRole('super_admin'); - $target = User::factory()->create(); - $target->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($target, 'api/v1/me/profile'); - $request->attributes->set('impersonator', $admin); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame($admin->id, $scope['tags']['impersonation.impersonator_user_id'] ?? null); - } - - public function test_impersonation_active_false_when_no_session(): void - { - $user = User::factory()->create(); - $user->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('false', $scope['tags']['impersonation.active'] ?? null); - $this->assertArrayNotHasKey('impersonation.impersonator_user_id', $scope['tags']); - } - - public function test_route_name_tag_present(): void - { - $user = User::factory()->create(); - $user->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); - $route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null); - $route->name('me.profile'); - $route->bind($request); - $request->setRouteResolver(static fn () => $route); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('me.profile', $scope['tags']['route_name'] ?? null); - } - - public function test_http_method_tag_present(): void - { - $user = User::factory()->create(); - $user->assignRole('org_admin'); - - $request = Request::create('http://localhost/api/v1/me/profile', 'PATCH'); - $request->setUserResolver(static fn () => $user); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('PATCH', $scope['tags']['http.method'] ?? null); - } - - public function test_app_tag_is_api(): void - { - $user = User::factory()->create(); - $user->assignRole('org_admin'); - - $request = $this->makeAuthenticatedRequest($user); - - $this->runMiddleware($request); - $scope = $this->captureScope(); - - $this->assertSame('api', $scope['tags']['app'] ?? null); - } -} diff --git a/api/tests/Feature/Observability/BindSentryRouteContextTest.php b/api/tests/Feature/Observability/BindSentryRouteContextTest.php new file mode 100644 index 00000000..2ff5112b --- /dev/null +++ b/api/tests/Feature/Observability/BindSentryRouteContextTest.php @@ -0,0 +1,89 @@ +pushScope(); + } + + /** + * @return array + */ + private function captureScopeTags(): array + { + $event = SentryEvent::createEvent(); + + configureScope(static function (Scope $scope) use ($event): void { + $scope->applyToEvent($event); + }); + + return $event->getTags(); + } + + private function runMiddleware(Request $request): void + { + (new BindSentryRouteContext())->handle($request, static fn (Request $req) => response('ok')); + } + + public function test_app_tag_is_api(): void + { + $request = Request::create('http://localhost/api/v1/_anything', 'GET'); + + $this->runMiddleware($request); + + $this->assertSame('api', $this->captureScopeTags()['app'] ?? null); + } + + public function test_http_method_tag_present(): void + { + $request = Request::create('http://localhost/api/v1/me/profile', 'PATCH'); + + $this->runMiddleware($request); + + $this->assertSame('PATCH', $this->captureScopeTags()['http.method'] ?? null); + } + + public function test_route_name_tag_present(): void + { + $request = Request::create('http://localhost/api/v1/me/profile', 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null); + $route->name('me.profile'); + $route->bind($request); + $request->setRouteResolver(static fn () => $route); + + $this->runMiddleware($request); + + $this->assertSame('me.profile', $this->captureScopeTags()['route_name'] ?? null); + } + + public function test_route_name_tag_omitted_when_route_has_no_name(): void + { + $request = Request::create('http://localhost/api/v1/anonymous', 'GET'); + + $this->runMiddleware($request); + + $this->assertArrayNotHasKey('route_name', $this->captureScopeTags()); + } +} diff --git a/api/tests/Feature/Observability/ExceptionReportingTest.php b/api/tests/Feature/Observability/ExceptionReportingTest.php index b2bfd8ef..05865986 100644 --- a/api/tests/Feature/Observability/ExceptionReportingTest.php +++ b/api/tests/Feature/Observability/ExceptionReportingTest.php @@ -76,7 +76,7 @@ final class ExceptionReportingTest extends TestCase // Test-only routes that exercise each branch of the // ignore_exceptions / before_send / capture pipeline. - Route::middleware(['auth:sanctum', 'sentry.context'])->group(function (): void { + Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void { Route::get('_obs_runtime', static fn () => throw new RuntimeException('boom')) ->name('test.obs.runtime'); Route::get('_obs_validation', static function (): never {