From a149db655c3bf119167b527682f64dd51e54dd45 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Thu, 18 Apr 2024 21:42:52 +0200 Subject: [PATCH] Added invitation delete endpoint --- .../Api/V1/InvitationController.php | 25 ++++++++++ app/Models/OrganizationInvitation.php | 5 ++ app/Providers/AppServiceProvider.php | 1 + app/Providers/JetstreamServiceProvider.php | 3 ++ .../OrganizationInvitationFactory.php | 37 ++++++++++++++ routes/api.php | 1 + .../Api/V1/InvitationEndpointTest.php | 48 +++++++++++++++++++ 7 files changed, 120 insertions(+) create mode 100644 database/factories/OrganizationInvitationFactory.php diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index 20516877..397decf0 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -9,12 +9,21 @@ use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Resources\V1\Invitation\InvitationCollection; use App\Http\Resources\V1\Invitation\InvitationResource; use App\Models\Organization; +use App\Models\OrganizationInvitation; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Laravel\Jetstream\Contracts\InvitesTeamMembers; class InvitationController extends Controller { + protected function checkPermission(Organization $organization, string $permission, ?OrganizationInvitation $organizationInvitation = null): void + { + parent::checkPermission($organization, $permission); + if ($organizationInvitation !== null && $organizationInvitation->organization_id !== $organization->id) { + throw new AuthorizationException('Invitation does not belong to organization'); + } + } + /** * List all invitations of an organization * @@ -54,4 +63,20 @@ class InvitationController extends Controller return response()->json(null, 204); } + + /** + * Remove a pending invitation + * + * @throws AuthorizationException + * + * @operationId removeInvitation + */ + public function destroy(Organization $organization, OrganizationInvitation $invitation): JsonResponse + { + $this->checkPermission($organization, 'invitations:remove', $invitation); + + $invitation->delete(); + + return response()->json(null, 204); + } } diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index 4f7570a4..f512bbdd 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Models; +use Database\Factories\OrganizationInvitationFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation; @@ -15,9 +17,12 @@ use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation; * @property string $role * @property string $organization_id * @property-read Organization $organization + * + * @method static OrganizationInvitationFactory factory() */ class OrganizationInvitation extends JetstreamTeamInvitation { + use HasFactory; use HasUuids; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d4ae8bae..33610d74 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -86,5 +86,6 @@ class AppServiceProvider extends ServiceProvider }); Route::model('member', Membership::class); + Route::model('invitation', OrganizationInvitation::class); } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 17447a5f..bc787977 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -93,6 +93,9 @@ class JetstreamServiceProvider extends ServiceProvider 'organizations:view', 'organizations:update', 'import', + 'invitations:view', + 'invitations:create', + 'invitations:remove', 'members:view', 'members:invite-placeholder', 'members:change-role', diff --git a/database/factories/OrganizationInvitationFactory.php b/database/factories/OrganizationInvitationFactory.php new file mode 100644 index 00000000..a4b2377f --- /dev/null +++ b/database/factories/OrganizationInvitationFactory.php @@ -0,0 +1,37 @@ + + */ +class OrganizationInvitationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'email' => $this->faker->unique()->safeEmail(), + 'role' => Role::Employee->value, + 'organization_id' => Organization::factory(), + ]; + } + + public function forOrganization(Organization $organization): self + { + return $this->state(fn (array $attributes) => [ + 'organization_id' => $organization->getKey(), + ]); + } +} diff --git a/routes/api.php b/routes/api.php index 6fbc3fef..1db37f7a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -49,6 +49,7 @@ Route::middleware([ Route::name('invitations.')->group(static function () { Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index'); Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store'); + Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy'); }); // Project routes diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php index 625b53c9..bd8fe78f 100644 --- a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -76,4 +76,52 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract $this->assertEquals('test@asdf.at', $invitation->email); $this->assertEquals('employee', $invitation->role); } + + public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create(); + + // Act + $response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()])); + + // Assert + $response->assertStatus(403); + } + + public function test_delete_fails_if_invitation_belongs_to_different_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:remove', + ]); + Passport::actingAs($data->user); + $invitation = OrganizationInvitation::factory()->create(); + + // Act + $response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()])); + + // Assert + $response->assertStatus(403); + } + + public function test_delete_removes_invitation(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:remove', + ]); + Passport::actingAs($data->user); + $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create(); + + // Act + $response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()])); + + // Assert + $response->assertStatus(204); + $this->assertNull(OrganizationInvitation::find($invitation->getKey())); + } }