Files
oauth/tests/Feature/ClientSecretRotationTest.php
2026-07-27 17:20:02 +02:00

124 lines
4.4 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Laravel\Passport\Client;
use Tests\TestCase;
class ClientSecretRotationTest extends TestCase
{
use RefreshDatabase;
private User $owner;
private Client $client;
private string $originalSecret;
protected function setUp(): void
{
parent::setUp();
$this->owner = User::factory()->approved()->create();
// Se crea vía el endpoint real para tener un secret en texto plano
// válido y un cliente client_credentials real, igual que en producción.
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Rotation Test Client',
'grant_type' => 'client_credentials',
]);
$response->assertCreated();
$this->originalSecret = $response->json('plain_secret');
$this->client = Client::find($response->json('client.id'));
}
public function test_original_secret_works_before_rotation(): void
{
$this->requestToken($this->originalSecret)->assertOk()->assertJsonStructure(['access_token']);
}
public function test_rotating_returns_a_new_secret_and_grace_period(): void
{
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$response->assertOk()->assertJsonStructure(['plain_secret', 'previous_secret_expires_at']);
$this->assertNotEmpty($response->json('plain_secret'));
$this->assertNotSame($this->originalSecret, $response->json('plain_secret'));
$this->assertDatabaseHas('audit_logs', [
'action' => 'client.secret_rotated',
'subject_id' => $this->client->id,
]);
}
public function test_both_old_and_new_secret_work_during_the_grace_period(): void
{
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$newSecret = $response->json('plain_secret');
$this->requestToken($newSecret)->assertOk();
$this->requestToken($this->originalSecret)->assertOk();
}
public function test_old_secret_stops_working_after_the_grace_period_expires(): void
{
$this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$this->client->forceFill(['previous_secret_expires_at' => now()->subMinute()])->save();
$this->requestToken($this->originalSecret)->assertStatus(401);
}
public function test_rotating_again_invalidates_the_first_old_secret_immediately(): void
{
// Solo se guarda UN secret anterior — rotar de nuevo pisa el que
// había, no acumula un historial de secrets viejos válidos.
$firstResponse = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$secondSecret = $firstResponse->json('plain_secret');
$this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$this->requestToken($this->originalSecret)->assertStatus(401);
$this->requestToken($secondSecret)->assertOk();
}
public function test_non_owner_cannot_rotate_another_users_client_secret(): void
{
$stranger = User::factory()->approved()->create();
$this->actingAs($stranger)
->postJson("/web-api/clients/{$this->client->id}/rotate-secret")
->assertNotFound();
$this->requestToken($this->originalSecret)->assertOk();
}
public function test_cannot_rotate_a_public_clients_secret(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Public Client',
'grant_type' => 'authorization_code',
'redirect_uris' => ['https://example.test/callback'],
'confidential' => false,
]);
$response->assertCreated();
$publicClient = Client::find($response->json('client.id'));
$this->actingAs($this->owner)
->postJson("/web-api/clients/{$publicClient->id}/rotate-secret")
->assertStatus(422);
}
private function requestToken(string $secret): TestResponse
{
return $this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $this->client->id,
'client_secret' => $secret,
'scope' => 'users:read',
]);
}
}