owner = User::factory()->approved()->create(); } public function test_owner_can_list_only_their_own_non_revoked_clients(): void { $this->actingAs($this->owner)->postJson('/web-api/clients', [ 'name' => 'My Client', 'grant_type' => 'client_credentials', ])->assertCreated(); $stranger = User::factory()->approved()->create(); $this->actingAs($stranger)->postJson('/web-api/clients', [ 'name' => 'Someone Elses Client', 'grant_type' => 'client_credentials', ])->assertCreated(); $response = $this->actingAs($this->owner)->getJson('/web-api/clients'); $response->assertOk(); $names = collect($response->json())->pluck('name'); $this->assertTrue($names->contains('My Client')); $this->assertFalse($names->contains('Someone Elses Client')); } public function test_creating_a_client_credentials_client_is_always_confidential(): void { $response = $this->actingAs($this->owner)->postJson('/web-api/clients', [ 'name' => 'CC Client', 'grant_type' => 'client_credentials', 'confidential' => false, ]); $response->assertCreated(); $this->assertNotNull($response->json('plain_secret')); $this->assertDatabaseHas('audit_logs', ['action' => 'client.created']); } public function test_creating_a_public_authorization_code_client_has_no_secret(): void { $response = $this->actingAs($this->owner)->postJson('/web-api/clients', [ 'name' => 'Public SPA', 'grant_type' => 'authorization_code', 'redirect_uris' => ['https://example.test/callback'], 'confidential' => false, ]); $response->assertCreated(); $this->assertNull($response->json('plain_secret')); } public function test_authorization_code_requires_redirect_uris(): void { $this->actingAs($this->owner)->postJson('/web-api/clients', [ 'name' => 'No Redirect', 'grant_type' => 'authorization_code', ])->assertUnprocessable()->assertJsonValidationErrors('redirect_uris'); } public function test_invalid_grant_type_is_rejected(): void { $this->actingAs($this->owner)->postJson('/web-api/clients', [ 'name' => 'Bad Grant', 'grant_type' => 'not-a-real-grant', ])->assertUnprocessable()->assertJsonValidationErrors('grant_type'); } public function test_owner_can_update_name_and_redirect_uris(): void { $created = $this->actingAs($this->owner)->postJson('/web-api/clients', [ 'name' => 'Original Name', 'grant_type' => 'authorization_code', 'redirect_uris' => ['https://example.test/callback'], ])->json('client'); $response = $this->actingAs($this->owner)->putJson("/web-api/clients/{$created['id']}", [ 'name' => 'Updated Name', 'redirect_uris' => ['https://example.test/new-callback'], ]); $response->assertOk(); $response->assertJsonPath('name', 'Updated Name'); $response->assertJsonPath('redirect_uris.0', 'https://example.test/new-callback'); } public function test_non_owner_cannot_update_or_revoke_another_users_client(): void { $created = $this->actingAs($this->owner)->postJson('/web-api/clients', [ 'name' => 'Owned Client', 'grant_type' => 'client_credentials', ])->json('client'); $stranger = User::factory()->approved()->create(); $this->actingAs($stranger) ->putJson("/web-api/clients/{$created['id']}", ['name' => 'Hijacked']) ->assertNotFound(); $this->actingAs($stranger) ->deleteJson("/web-api/clients/{$created['id']}") ->assertNotFound(); } public function test_revoking_a_client_also_revokes_its_active_tokens(): void { $response = $this->actingAs($this->owner)->postJson('/web-api/clients', [ 'name' => 'To Revoke', 'grant_type' => 'client_credentials', ]); $clientId = $response->json('client.id'); $token = Token::create([ 'id' => \Illuminate\Support\Str::random(80), 'user_id' => $this->owner->id, 'client_id' => $clientId, 'name' => null, 'scopes' => ['users:read'], 'revoked' => false, 'created_at' => now(), 'updated_at' => now(), 'expires_at' => now()->addDays(15), ]); $this->actingAs($this->owner) ->deleteJson("/web-api/clients/{$clientId}") ->assertNoContent(); $this->assertDatabaseHas('oauth_clients', ['id' => $clientId, 'revoked' => true]); $this->assertDatabaseHas('oauth_access_tokens', ['id' => $token->id, 'revoked' => true]); $this->assertDatabaseHas('audit_logs', ['action' => 'client.revoked', 'subject_id' => $clientId]); } }