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>
330 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|