Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email

This commit is contained in:
Constantin Graf
2026-02-25 17:28:34 +01:00
parent f582adab0d
commit 99aa7ed450
35 changed files with 773 additions and 208 deletions

View File

@@ -88,7 +88,7 @@ class InviteTeamMemberTest extends TestCase
Mail::fake();
$placeholder = User::factory()->placeholder()->create();
$owner = User::factory()->withPersonalOrganization()->create();
$placeholderMember = Member::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
$placeholderMember = Member::factory()->role(Role::Placeholder)->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
$timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);

View File

@@ -8,21 +8,19 @@ use App\Enums\Role;
use App\Enums\Weekday;
use App\Events\NewsletterRegistered;
use App\Models\Member;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use App\Service\IpLookup\IpLookupResponseDto;
use App\Service\IpLookup\IpLookupServiceContract;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
use Tests\TestCase;
use Tests\TestCaseWithDatabase;
class RegistrationTest extends TestCase
class RegistrationTest extends TestCaseWithDatabase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered(): void
{
if (! Features::enabled(Features::registration())) {
@@ -346,4 +344,37 @@ class RegistrationTest extends TestCase
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}
public function test_registration_does_not_create_private_organization_if_invite_was_accepted_for_the_email_with_the_registration_email(): void
{
// Arrange
$user = $this->createUserWithPermission();
$organizationInvitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'test@example.com',
]);
// Act
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
]);
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
$newUser = User::where('email', 'test@example.com')->first();
$this->assertNotNull($newUser);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'email' => 'test@example.com',
]);
$organizations = $newUser->organizations;
$this->assertCount(1, $organizations);
$this->assertSame($user->organization->id, $organizations->first()->id);
}
}

View File

@@ -260,4 +260,52 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
'billable_rate' => $organizationFake->billable_rate,
]);
}
public function test_delete_endpoint_if_user_does_not_have_permission(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_delete_endpoint_fails_with_not_found_if_id_is_not_uuid(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', ['not-uuid']));
// Assert
$response->assertNotFound();
}
public function test_delete_endpoint_can_delete_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
// Assert
$response->assertNoContent();
$this->assertDatabaseMissing(Organization::class, [
'id' => $data->organization->getKey(),
]);
}
// LAST state: organization store, remove update update
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\User;
use Laravel\Passport\Passport;
class UserEndpointTest extends ApiEndpointTestAbstract
@@ -40,4 +41,57 @@ class UserEndpointTest extends ApiEndpointTestAbstract
],
]);
}
public function test_delete_fails_if_given_user_is_not_the_authenticated_user(): void
{
// Arrange
$data = $this->createUserWithPermission();
$otherData = $this->createUserWithPermission();
Passport::actingAs($otherData->user);
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
// Assert
$response->assertForbidden();
}
public function test_delete_fails_if_not_authenticated(): void
{
// Arrange
$data = $this->createUserWithPermission();
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
// Assert
$response->assertUnauthorized();
}
public function test_delete_fails_if_user_does_not_exist(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', 'not-valid'));
// Assert
$response->assertNotFound();
}
public function test_delete_removes_user(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
// Assert
$response->assertNoContent();
$this->assertDatabaseMissing(User::class, ['id' => $data->user->getKey()]);
}
}

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web;
use App\Enums\Role;
use App\Http\Controllers\Web\OrganizationInvitationController;
use App\Models\Member;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Support\Facades\URL;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(OrganizationInvitationController::class)]
#[CoversClass(MemberService::class)]
class OrganizationInvitationEndpointTest extends EndpointTestAbstract
{
public function test_legacy_url_still_works(): void
{
// Arrange
$user = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create();
// Act
$acceptUrl = URL::temporarySignedRoute(
'team-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()]
);
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('register', [
'bannerStyle' => 'info',
'bannerText' => 'Please create an account to finish joining the '.$user->organization->name.' organization.',
]));
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}
public function test_can_accept_invitation_without_an_account_with_the_email_address_and_redirects_to_registration(): void
{
// Arrange
$user = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create();
// Act
$acceptUrl = URl::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('register', [
'bannerStyle' => 'info',
'bannerText' => 'Please create an account to finish joining the '.$user->organization->name.' organization.',
]));
$invitation->refresh();
$this->assertNotNull($invitation->accepted_at);
}
public function test_can_accept_invitation_with_an_account_with_the_email_address_and_redirects_to_dashboard(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user2 = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create([
'role' => Role::Employee->value,
'email' => $user2->user->email,
]);
$this->actingAs($user2->user);
// Act
$acceptUrl = URl::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('dashboard', [
'bannerStyle' => 'success',
'bannerText' => 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.',
]));
$this->assertDatabaseHas(Member::class, [
'user_id' => $user2->user->getKey(),
'organization_id' => $user->organization->getKey(),
'role' => Role::Employee->value,
]);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'id' => $invitation->getKey(),
]);
}
public function test_fails_if_user_is_already_member_of_the_organization(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user2 = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create([
'role' => Role::Employee->value,
'email' => $user2->user->email,
]);
Member::factory()->forOrganization($user->organization)->forUser($user2->user)->create();
$this->actingAs($user2->user);
// Act
$acceptUrl = URl::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('dashboard', [
'bannerStyle' => 'danger',
'bannerText' => 'You are already a member of the '.$user->organization->name.' organization.',
]));
}
public function test_accepting_invitation_with_existing_account_migrates_data_of_placeholder_users_with_same_email_to_new_member(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user2 = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create([
'role' => Role::Employee->value,
'email' => $user2->user->email,
]);
$placeholder1 = User::factory()->placeholder()->create([
'email' => $user2->user->email,
]);
$placeholder1Member = Member::factory()->forOrganization($user->organization)->forUser($placeholder1)->role(Role::Placeholder)->create();
$placeholder2 = User::factory()->placeholder()->create([
'email' => $user2->user->email,
]);
$placeholder2Member = Member::factory()->forOrganization($user->organization)->forUser($placeholder2)->role(Role::Placeholder)->create();
$this->actingAs($user2->user);
// Act
$acceptUrl = URl::to(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()],
false
));
$response = $this->get($acceptUrl);
// Assert
$response->assertValid();
$response->assertRedirect(route('dashboard', [
'bannerStyle' => 'success',
'bannerText' => 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.',
]));
$this->assertDatabaseHas(Member::class, [
'user_id' => $user2->user->getKey(),
'organization_id' => $user->organization->getKey(),
'role' => Role::Employee->value,
]);
$this->assertDatabaseMissing(User::class, [
'id' => $placeholder1->getKey(),
]);
$this->assertDatabaseMissing(User::class, [
'id' => $placeholder2->getKey(),
]);
$this->assertDatabaseMissing(Member::class, [
'id' => $placeholder1Member->getKey(),
]);
$this->assertDatabaseMissing(Member::class, [
'id' => $placeholder2Member->getKey(),
]);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'id' => $invitation->getKey(),
]);
}
public function test_fails_with_invalid_signature(): void
{
// Arrange
$user = $this->createUserWithPermission();
$invitation = OrganizationInvitation::factory()
->forOrganization($user->organization)
->create();
// Act
$response = $this->get(URL::temporarySignedRoute(
'organization-invitations.accept',
now()->addMinutes(60),
[$invitation->getKey()]).
'?invalid'
);
// Assert
$response->assertForbidden();
}
}

View File

@@ -28,6 +28,6 @@ class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase
$rendered = $mail->render();
// Assert
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
}
}

View File

@@ -28,6 +28,6 @@ class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase
$rendered = $mail->render();
// Assert
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
}
}