createToken() and passes Authorization: Bearer in the * request — Sanctum::actingAs() short-circuits the Guard layer and would * NOT detect the regression. */ final class AuthScopeBindingHttpFlowTest extends TestCase { use RefreshDatabase; /** * Captured events from the recording before_send hook. * * @var list */ private static array $captured = []; protected function setUp(): void { parent::setUp(); $this->seed(RoleSeeder::class); self::$captured = []; $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): ?SentryEvent { self::$captured[] = ['event' => $event, 'hint' => $hint]; return null; }, ]); SentrySdk::setCurrentHub(new Hub($clientBuilder->getClient())); } /** * @return array */ private function authHeader(User $user): array { $token = $user->createToken('regression-test')->plainTextToken; return ['Authorization' => 'Bearer '.$token]; } public function test_authenticated_http_request_captures_auth_scope_tags_on_thrown_exception(): void { $user = User::factory()->create(); $user->assignRole('super_admin'); Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void { Route::get('_obs_authflow_throw', static fn () => throw new RuntimeException('regression-test')) ->name('test.obs.authflow_throw'); }); $response = $this->withHeaders($this->authHeader($user))->getJson('/_obs_authflow_throw'); $response->assertStatus(500); $this->assertCount(1, self::$captured, 'Sentry event must be captured for thrown RuntimeException'); $tags = self::$captured[0]['event']->getTags(); $this->assertSame($user->id, $tags['user_id'] ?? null, 'user_id tag missing on live HTTP flow'); $this->assertSame('super_admin', $tags['actor_type'] ?? null, 'actor_type tag missing on live HTTP flow'); $this->assertArrayHasKey('actor_scope', $tags, 'actor_scope tag missing on live HTTP flow'); } public function test_authenticated_http_request_to_admin_route_tags_actor_scope_platform(): void { $user = User::factory()->create(); $user->assignRole('super_admin'); Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class]) ->name('admin.') ->group(function (): void { Route::get('_obs_admin_throw', static fn () => throw new RuntimeException('regression-test')) ->name('platform_throw'); }); $this->withHeaders($this->authHeader($user))->getJson('/_obs_admin_throw'); $tags = self::$captured[0]['event']->getTags(); $this->assertSame('platform', $tags['actor_scope'] ?? null, 'super_admin on admin.* route must tag actor_scope=platform'); $this->assertArrayNotHasKey('organisation_id', $tags, 'organisation_id MUST be absent on platform-scoped events'); } public function test_authenticated_http_request_to_organisation_route_tags_organisation_scope(): void { $org = Organisation::factory()->create(); $user = User::factory()->create(); $org->users()->attach($user, ['role' => 'org_admin']); $user->assignRole('org_admin'); Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void { Route::get('_obs_org_throw/{organisation}', static fn () => throw new RuntimeException('regression-test')) ->name('test.obs.org_throw'); }); $this->withHeaders($this->authHeader($user))->getJson('/_obs_org_throw/'.$org->id); $tags = self::$captured[0]['event']->getTags(); $this->assertSame('organisation', $tags['actor_scope'] ?? null); $this->assertSame($org->id, $tags['organisation_id'] ?? null); $this->assertTrue(Ulid::isValid($tags['organisation_id'])); } }