WS-7 PR-2 commit 3. RFC §3.13. - app/Http/Middleware/BindRequestLogContext.php: tags every Laravel log line written during the request with request_id, organisation_id, user_id, and route name. Sets X-Request-Id on the response so the SPA can correlate to backend log lines via one click. - Client-supplied X-Request-Id is honoured only if it parses as a ULID via Str::isUlid. Junk input (empty, non-ULID) is rejected and a fresh ULID is generated server-side. - Registered as a global api-group middleware via the prepend list so it runs before authentication. Unauthenticated 4xx responses still carry the X-Request-Id header. - Test count: 1523 to 1532. Larastan clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
5.2 KiB
PHP
162 lines
5.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Observability;
|
|
|
|
use App\Models\Organisation;
|
|
use App\Models\User;
|
|
use Database\Seeders\RoleSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Tests\TestCase;
|
|
|
|
final class RequestIdRoundTripTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private const VALID_ULID_PATTERN = '/^[0-9A-HJKMNP-TV-Z]{26}$/';
|
|
|
|
/**
|
|
* Captured Log::withContext payload from BindRequestLogContext.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
private static ?array $capturedLogContext = null;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->seed(RoleSeeder::class);
|
|
self::$capturedLogContext = null;
|
|
|
|
// Spy on Log::withContext so we can assert the structured payload.
|
|
Log::swap(new class extends \Illuminate\Log\LogManager
|
|
{
|
|
public function __construct() {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public function withContext(array $context = []): \Illuminate\Log\Logger
|
|
{
|
|
RequestIdRoundTripTest::recordContext($context);
|
|
|
|
return $this->driver();
|
|
}
|
|
|
|
public function driver($driver = null): \Illuminate\Log\Logger
|
|
{
|
|
return new \Illuminate\Log\Logger(new \Psr\Log\NullLogger);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $parameters
|
|
*/
|
|
public function __call($method, $parameters)
|
|
{
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public static function recordContext(array $context): void
|
|
{
|
|
self::$capturedLogContext = array_merge(self::$capturedLogContext ?? [], $context);
|
|
}
|
|
|
|
public function test_response_has_x_request_id_header_when_none_supplied(): void
|
|
{
|
|
$response = $this->getJson('/api/v1/');
|
|
|
|
$response->assertOk();
|
|
$requestId = $response->headers->get('X-Request-Id');
|
|
$this->assertNotNull($requestId);
|
|
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $requestId);
|
|
}
|
|
|
|
public function test_response_has_x_request_id_header_when_client_supplied_valid_ulid(): void
|
|
{
|
|
$supplied = (string) Str::ulid();
|
|
|
|
$response = $this->getJson('/api/v1/', ['X-Request-Id' => $supplied]);
|
|
|
|
$this->assertSame($supplied, $response->headers->get('X-Request-Id'));
|
|
}
|
|
|
|
public function test_server_generates_when_client_supplies_invalid_ulid(): void
|
|
{
|
|
$response = $this->getJson('/api/v1/', ['X-Request-Id' => 'not-a-ulid-at-all']);
|
|
|
|
$emitted = $response->headers->get('X-Request-Id');
|
|
$this->assertNotSame('not-a-ulid-at-all', $emitted);
|
|
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $emitted);
|
|
}
|
|
|
|
public function test_server_generates_when_client_supplies_empty_string(): void
|
|
{
|
|
$response = $this->getJson('/api/v1/', ['X-Request-Id' => '']);
|
|
|
|
$emitted = $response->headers->get('X-Request-Id');
|
|
$this->assertNotNull($emitted);
|
|
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $emitted);
|
|
}
|
|
|
|
public function test_log_context_has_request_id(): void
|
|
{
|
|
$supplied = (string) Str::ulid();
|
|
$this->getJson('/api/v1/', ['X-Request-Id' => $supplied]);
|
|
|
|
$this->assertSame($supplied, self::$capturedLogContext['request_id'] ?? null);
|
|
}
|
|
|
|
public function test_log_context_has_user_id_and_org_when_authenticated_organisation_route(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$user = User::factory()->create();
|
|
$org->users()->attach($user, ['role' => 'org_admin']);
|
|
$user->assignRole('org_admin');
|
|
|
|
Sanctum::actingAs($user);
|
|
|
|
$this->getJson('/api/v1/organisations/'.$org->id.'/dashboard-stats');
|
|
|
|
$this->assertSame($org->id, self::$capturedLogContext['organisation_id'] ?? null);
|
|
$this->assertSame($user->id, self::$capturedLogContext['user_id'] ?? null);
|
|
}
|
|
|
|
public function test_log_context_route_matches_named_route(): void
|
|
{
|
|
$this->getJson('/api/v1/');
|
|
|
|
// The health-check route at /api/v1/ has no name; expectation is
|
|
// simply that the key is absent (filtered out for null) rather
|
|
// than carrying a misleading default.
|
|
$this->assertArrayNotHasKey('route', self::$capturedLogContext ?? []);
|
|
}
|
|
|
|
public function test_unauthenticated_request_still_gets_request_id(): void
|
|
{
|
|
// Hitting an authenticated route unauthenticated yields 401 — but
|
|
// the request_id middleware still runs.
|
|
$response = $this->getJson('/api/v1/auth/me');
|
|
|
|
$response->assertStatus(401);
|
|
$this->assertNotNull($response->headers->get('X-Request-Id'));
|
|
}
|
|
|
|
public function test_request_id_is_valid_ulid_format(): void
|
|
{
|
|
$response = $this->getJson('/api/v1/');
|
|
|
|
$emitted = $response->headers->get('X-Request-Id');
|
|
$this->assertSame(26, strlen((string) $emitted));
|
|
$this->assertTrue(Str::isUlid((string) $emitted));
|
|
}
|
|
}
|