WS-7 Observability — closure #8
@@ -15,9 +15,13 @@ use Illuminate\Http\Request;
|
|||||||
* 1. Portal-token request → PORTAL_TOKEN
|
* 1. Portal-token request → PORTAL_TOKEN
|
||||||
* 2. Authenticated super_admin → SUPER_ADMIN
|
* 2. Authenticated super_admin → SUPER_ADMIN
|
||||||
* 3. Authenticated org_admin → ORGANIZER_ADMIN
|
* 3. Authenticated org_admin → ORGANIZER_ADMIN
|
||||||
* 4. Authenticated volunteer (role match) → VOLUNTEER
|
* 4. Other authenticated user → ORG_MEMBER (covers volunteers — Crewli has
|
||||||
* 5. Other authenticated user → ORG_MEMBER
|
* no `volunteer` Spatie role today; volunteers join an organisation as
|
||||||
* 6. None of the above → UNAUTHENTICATED
|
* org_member with org-pivot semantics. Promote to a dedicated case once
|
||||||
|
* the role model differentiates them at the user level.)
|
||||||
|
* 5. None of the above → UNAUTHENTICATED
|
||||||
|
*
|
||||||
|
* The VOLUNTEER case is reserved for that future split.
|
||||||
*/
|
*/
|
||||||
enum ActorType: string
|
enum ActorType: string
|
||||||
{
|
{
|
||||||
@@ -46,10 +50,6 @@ enum ActorType: string
|
|||||||
return self::ORGANIZER_ADMIN;
|
return self::ORGANIZER_ADMIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->hasRole('volunteer')) {
|
|
||||||
return self::VOLUNTEER;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::ORG_MEMBER;
|
return self::ORG_MEMBER;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
195
api/app/Http/Middleware/BindSentryContext.php
Normal file
195
api/app/Http/Middleware/BindSentryContext.php
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Enums\Observability\ActorType;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\ImpersonationSession;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
use Sentry\State\Scope;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
use function Sentry\configureScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the Crewli context tags listed in RFC-WS-7 §3.6 to every Sentry
|
||||||
|
* event captured during this request.
|
||||||
|
*
|
||||||
|
* Multi-tenant invariant (§3.6): if the request is authenticated AND a
|
||||||
|
* controller is about to act on tenant data, organisation_id MUST be
|
||||||
|
* resolvable. In `local` and `testing` we throw so missing-tag bugs surface
|
||||||
|
* during development; in other environments we log a warning and fall
|
||||||
|
* through (don't break user flows over a missing tag).
|
||||||
|
*/
|
||||||
|
final class BindSentryContext
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
api/app/Listeners/Observability/TagJobAttemptOnSentry.php
Normal file
29
api/app/Listeners/Observability/TagJobAttemptOnSentry.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listeners\Observability;
|
||||||
|
|
||||||
|
use Illuminate\Queue\Events\JobProcessing;
|
||||||
|
use Sentry\State\Scope;
|
||||||
|
|
||||||
|
use function Sentry\configureScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener for {@see JobProcessing} that attaches the `queue.attempt` tag
|
||||||
|
* (RFC-WS-7 §3.6) to the active Sentry scope before the job runs. Default
|
||||||
|
* stack-trace grouping is preserved — RFC §3.11 explicitly forbids
|
||||||
|
* per-attempt fingerprinting so retries that eventually succeed remain
|
||||||
|
* grouped with retries that always fail.
|
||||||
|
*/
|
||||||
|
final class TagJobAttemptOnSentry
|
||||||
|
{
|
||||||
|
public function handle(JobProcessing $event): void
|
||||||
|
{
|
||||||
|
$attempt = (string) $event->job->attempts();
|
||||||
|
|
||||||
|
configureScope(static function (Scope $scope) use ($attempt): void {
|
||||||
|
$scope->setTag('queue.attempt', $attempt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,6 +196,14 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
ApplyBindingsOnFormSectionSubmitted::class,
|
ApplyBindingsOnFormSectionSubmitted::class,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// RFC-WS-7 §3.6 / §3.11 — tag captured Sentry events with the queue
|
||||||
|
// attempt count. Default stack-trace grouping is preserved (no
|
||||||
|
// per-attempt fingerprinting).
|
||||||
|
\Illuminate\Support\Facades\Event::listen(
|
||||||
|
\Illuminate\Queue\Events\JobProcessing::class,
|
||||||
|
\App\Listeners\Observability\TagJobAttemptOnSentry::class,
|
||||||
|
);
|
||||||
|
|
||||||
ResetPassword::createUrlUsing(function ($user, string $token) {
|
ResetPassword::createUrlUsing(function ($user, string $token) {
|
||||||
return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email);
|
return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class,
|
'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class,
|
||||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
'impersonation' => \App\Http\Middleware\HandleImpersonation::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 {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ Route::middleware('throttle:30,1')->group(function (): void {
|
|||||||
|
|
||||||
// Platform Admin routes
|
// Platform Admin routes
|
||||||
Route::prefix('admin')
|
Route::prefix('admin')
|
||||||
->middleware(['auth:sanctum', 'impersonation', 'role:super_admin'])
|
->middleware(['auth:sanctum', 'impersonation', 'sentry.context', 'role:super_admin'])
|
||||||
->name('admin.')
|
->name('admin.')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
// Organisations
|
// Organisations
|
||||||
@@ -155,7 +155,7 @@ Route::prefix('admin')
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
Route::middleware(['auth:sanctum', 'impersonation'])->group(function () {
|
Route::middleware(['auth:sanctum', 'impersonation', 'sentry.context'])->group(function () {
|
||||||
// Impersonation (stop — accessible by impersonated user, not just super_admin)
|
// Impersonation (stop — accessible by impersonated user, not just super_admin)
|
||||||
Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']);
|
Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']);
|
||||||
|
|
||||||
|
|||||||
329
api/tests/Feature/Observability/BindSentryContextTest.php
Normal file
329
api/tests/Feature/Observability/BindSentryContextTest.php
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user