Files
crewli/api/tests/Feature/Observability/BindSentryContextTest.php
bert.hausmans b1d5bcda76 feat: BindSentryContext middleware + queue job attempt tagging
WS-7 PR-2 commit 2.

- app/Http/Middleware/BindSentryContext.php: sets RFC §3.6 tags on the
  active Sentry scope (app, http.method, route_name, actor_type,
  user_id, organisation_id, event_id, impersonation). Multi-tenant
  invariant: throws RuntimeException in local/testing when an auth
  request to a tenant-scoped route lacks organisation_id; logs a
  warning in production so the user flow still completes.
- app/Listeners/Observability/TagJobAttemptOnSentry.php: tags
  queue.attempt on the scope from the JobProcessing event. Default
  stack-trace grouping preserved per §3.11.
- ActorType: VOLUNTEER case reserved for a future role split. Current
  resolver maps non-admin authenticated users to ORG_MEMBER.
- bootstrap/app.php: registers sentry.context alias. Applied inside
  auth:sanctum groups in routes/api.php so it runs after auth.
- AppServiceProvider::boot registers the queue listener.

Test count: 1507 to 1523. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:55 +02:00

330 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Http\Middleware\BindSentryContext;
use App\Models\Event;
use App\Models\ImpersonationSession;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use RuntimeException;
use Sentry\Event as SentryEvent;
use Sentry\SentrySdk;
use Sentry\State\Scope;
use Tests\TestCase;
use function Sentry\configureScope;
final class BindSentryContextTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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<string, string>, user: ?array<string, mixed>}
*/
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
{
// Crewli has no `volunteer` Spatie role today; volunteers fall into
// org_member. The VOLUNTEER ActorType case is reserved for a future
// split — see ActorType::resolve() docblock.
$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);
}
}