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', ]); } }