87 lines
3.3 KiB
PHP
87 lines
3.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\V1\FullSurface;
|
|
|
|
use Tests\Feature\Api\V1\FullSurface\Support\FullSurfaceE2EContractCase;
|
|
|
|
class ApiRateLimitTokenAndSessionHardeningContractTest extends FullSurfaceE2EContractCase
|
|
{
|
|
public function test_repeated_bad_login_attempts_stay_controlled_and_never_return_a_token(): void
|
|
{
|
|
$paths = array_values(array_unique(array_filter([
|
|
'/api/v1/auth/login',
|
|
'/api/login',
|
|
'/api/v1/login',
|
|
], fn (string $path): bool => $this->routeExistsFor('POST', $path))));
|
|
|
|
$this->assertNotEmpty($paths, 'At least one API login route should exist for brute-force hardening coverage.');
|
|
|
|
foreach ($paths as $path) {
|
|
for ($attempt = 1; $attempt <= 8; $attempt++) {
|
|
$response = $this->postJson($path, [
|
|
'email' => $this->admin->email,
|
|
'password' => 'definitely-not-the-password-' . $attempt,
|
|
]);
|
|
|
|
$this->assertStatusIn($response, [400, 401, 403, 419, 422, 429], "Bad login attempt {$attempt} for {$path}");
|
|
$response->assertJsonMissingPath('token');
|
|
$response->assertJsonMissingPath('access_token');
|
|
$response->assertJsonMissingPath('user.remember_token');
|
|
}
|
|
}
|
|
}
|
|
|
|
public function test_expired_or_garbage_bearer_tokens_are_rejected_without_fallback_identity(): void
|
|
{
|
|
foreach ($this->apiRoutesContainingAny(['auth/me', 'dashboard', 'profile', 'settings', 'finance', 'inventory']) as $route) {
|
|
$method = $this->primaryMethod($route);
|
|
$uri = $route->uri();
|
|
|
|
if ($method !== 'GET') {
|
|
continue;
|
|
}
|
|
|
|
$response = $this->withHeader('Authorization', 'Bearer garbage-token-that-must-not-authenticate')
|
|
->json('GET', $this->materializePath($uri));
|
|
|
|
$this->assertStatusIn($response, [401, 403, 404, 419], "Garbage bearer token should not authenticate {$uri}");
|
|
}
|
|
}
|
|
|
|
public function test_logout_is_safe_to_repeat_and_does_not_leak_token_state(): void
|
|
{
|
|
$logoutRoutes = $this->apiRoutesContainingAny(['logout']);
|
|
|
|
$this->assertNotEmpty($logoutRoutes, 'Logout routes should be covered by repeat/logout contract tests.');
|
|
|
|
foreach ($logoutRoutes as $route) {
|
|
if ($this->primaryMethod($route) !== 'POST') {
|
|
continue;
|
|
}
|
|
|
|
$path = $this->materializePath($route->uri());
|
|
|
|
$first = $this->requestAs($this->admin, 'POST', $route->uri());
|
|
$this->assertControlled($first, 'POST', $route->uri());
|
|
$first->assertJsonMissingPath('token');
|
|
$first->assertJsonMissingPath('access_token');
|
|
|
|
$second = $this->postJson($path);
|
|
$this->assertStatusIn($second, [200, 204, 401, 403, 419], 'Repeated logout should stay controlled.');
|
|
$second->assertJsonMissingPath('token');
|
|
$second->assertJsonMissingPath('access_token');
|
|
}
|
|
}
|
|
|
|
private function routeExistsFor(string $method, string $path): bool
|
|
{
|
|
foreach ($this->apiRoutes() as $route) {
|
|
if ($this->primaryMethod($route) === $method && '/' . ltrim($route->uri(), '/') === $path) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|