From d200b571fe5b80746181ba86e5eff112ec4686c5 Mon Sep 17 00:00:00 2001 From: Bryce Meyer Date: Mon, 24 Nov 2025 14:56:56 +0100 Subject: [PATCH 1/2] setup invitation token --- .../InvitationTokenRepositoryContract.php | 30 +++ .../Events/User/InvitationAcceptedEvent.php | 51 ++++++ .../AuthenticationControllerAbstract.php | 19 +- .../Requests/Authentication/SignUpRequest.php | 14 +- .../User/InvitationAcceptedListener.php | 51 ++++++ .../Athenia/Models/User/InvitationToken.php | 100 ++++++++++ .../Providers/BaseEventServiceProvider.php | 5 + .../Providers/BaseRepositoryProvider.php | 11 ++ .../Providers/BaseValidatorProvider.php | 2 + .../User/InvitationTokenRepository.php | 73 ++++++++ .../InvitationTokenIsValidValidator.php | 61 +++++++ code/app/Models/User/InvitationToken.php | 54 ++++++ code/config/athenia.php | 4 +- .../factories/User/InvitationTokenFactory.php | 32 ++++ ..._000000_create_invitation_tokens_table.php | 32 ++++ .../Http/Authentication/SignUpTest.php | 171 ++++++++++++++++++ .../User/InvitationTokenRepositoryTest.php | 139 ++++++++++++++ .../User/InvitationAcceptedListenerTest.php | 97 ++++++++++ .../Unit/Models/User/InvitationTokenTest.php | 43 +++++ .../InvitationTokenIsValidValidatorTest.php | 90 +++++++++ 20 files changed, 1075 insertions(+), 4 deletions(-) create mode 100644 code/app/Athenia/Contracts/Repositories/User/InvitationTokenRepositoryContract.php create mode 100644 code/app/Athenia/Events/User/InvitationAcceptedEvent.php create mode 100644 code/app/Athenia/Listeners/User/InvitationAcceptedListener.php create mode 100644 code/app/Athenia/Models/User/InvitationToken.php create mode 100644 code/app/Athenia/Repositories/User/InvitationTokenRepository.php create mode 100644 code/app/Athenia/Validators/InvitationTokenIsValidValidator.php create mode 100644 code/app/Models/User/InvitationToken.php create mode 100644 code/database/factories/User/InvitationTokenFactory.php create mode 100644 code/database/migrations/2025_11_24_000000_create_invitation_tokens_table.php create mode 100644 code/tests/Athenia/Integration/Repositories/User/InvitationTokenRepositoryTest.php create mode 100644 code/tests/Athenia/Unit/Listeners/User/InvitationAcceptedListenerTest.php create mode 100644 code/tests/Athenia/Unit/Models/User/InvitationTokenTest.php create mode 100644 code/tests/Athenia/Unit/Validators/InvitationTokenIsValidValidatorTest.php diff --git a/code/app/Athenia/Contracts/Repositories/User/InvitationTokenRepositoryContract.php b/code/app/Athenia/Contracts/Repositories/User/InvitationTokenRepositoryContract.php new file mode 100644 index 00000000..f073b008 --- /dev/null +++ b/code/app/Athenia/Contracts/Repositories/User/InvitationTokenRepositoryContract.php @@ -0,0 +1,30 @@ +user = $user; + $this->invitationToken = $invitationToken; + } + + /** + * @return User + */ + public function getUser(): User + { + return $this->user; + } + + /** + * @return InvitationToken + */ + public function getInvitationToken(): InvitationToken + { + return $this->invitationToken; + } +} diff --git a/code/app/Athenia/Http/Core/Controllers/AuthenticationControllerAbstract.php b/code/app/Athenia/Http/Core/Controllers/AuthenticationControllerAbstract.php index f43e283b..455c61b7 100644 --- a/code/app/Athenia/Http/Core/Controllers/AuthenticationControllerAbstract.php +++ b/code/app/Athenia/Http/Core/Controllers/AuthenticationControllerAbstract.php @@ -3,7 +3,9 @@ namespace App\Athenia\Http\Core\Controllers; +use App\Athenia\Contracts\Repositories\User\InvitationTokenRepositoryContract; use App\Athenia\Contracts\Repositories\User\UserRepositoryContract; +use App\Athenia\Events\User\InvitationAcceptedEvent; use App\Athenia\Events\User\SignUpEvent; use App\Http\Core\Requests; use App\Models\User\User; @@ -51,19 +53,26 @@ abstract class AuthenticationControllerAbstract extends BaseControllerAbstract */ protected $dispatcher; + /** + * @var InvitationTokenRepositoryContract + */ + protected $invitationTokenRepository; + /** * AuthenticationController constructor. * @param UserRepositoryContract $userRepository * @param Hasher $hasher * @param JWTAuth $auth * @param Dispatcher $dispatcher + * @param InvitationTokenRepositoryContract $invitationTokenRepository */ - public function __construct(UserRepositoryContract $userRepository, Hasher $hasher, JWTAuth $auth, Dispatcher $dispatcher) + public function __construct(UserRepositoryContract $userRepository, Hasher $hasher, JWTAuth $auth, Dispatcher $dispatcher, InvitationTokenRepositoryContract $invitationTokenRepository) { $this->userRepository = $userRepository; $this->hasher = $hasher; $this->auth = $auth; $this->dispatcher = $dispatcher; + $this->invitationTokenRepository = $invitationTokenRepository; } /** @@ -252,6 +261,14 @@ public function signUp(Requests\Authentication\SignUpRequest $request) $this->dispatcher->dispatch(new SignUpEvent($model)); + // If an invitation token was provided, dispatch the InvitationAcceptedEvent + if (isset($data['invitation_token'])) { + $invitationToken = $this->invitationTokenRepository->findByToken($data['invitation_token']); + if ($invitationToken) { + $this->dispatcher->dispatch(new InvitationAcceptedEvent($model, $invitationToken)); + } + } + $token = $this->auth->fromUser($model); return new JsonResponse([ 'token' => $token, diff --git a/code/app/Athenia/Http/Core/Requests/Authentication/SignUpRequest.php b/code/app/Athenia/Http/Core/Requests/Authentication/SignUpRequest.php index b9aa773c..e1faed60 100644 --- a/code/app/Athenia/Http/Core/Requests/Authentication/SignUpRequest.php +++ b/code/app/Athenia/Http/Core/Requests/Authentication/SignUpRequest.php @@ -5,7 +5,9 @@ use App\Athenia\Http\Core\Requests\BaseUnauthenticatedRequest; use App\Athenia\Http\Core\Requests\Traits\HasNoExpands; +use App\Athenia\Validators\InvitationTokenIsValidValidator; use App\Models\User\User; +use Illuminate\Contracts\Config\Repository; /** * Class SignUpRequest @@ -19,15 +21,23 @@ class SignUpRequest extends BaseUnauthenticatedRequest * Gets the rules for the verification * * @param User $user + * @param Repository $config * @return array */ - public function rules(User $user) + public function rules(User $user, Repository $config) { - return [ + $rules = [ 'email' => 'required|string|max:120|email|unique:users,email', 'first_name' => 'required|string|max:120', 'last_name' => 'string|max:120', 'password' => 'required|string|min:6|max:256', ]; + + // Add invitation token validation if invitations are required + if ($config->get('athenia.invitation_required', false)) { + $rules['invitation_token'] = 'required|string|' . InvitationTokenIsValidValidator::KEY; + } + + return $rules; } } \ No newline at end of file diff --git a/code/app/Athenia/Listeners/User/InvitationAcceptedListener.php b/code/app/Athenia/Listeners/User/InvitationAcceptedListener.php new file mode 100644 index 00000000..cddf2efe --- /dev/null +++ b/code/app/Athenia/Listeners/User/InvitationAcceptedListener.php @@ -0,0 +1,51 @@ +invitationTokenRepository = $invitationTokenRepository; + } + + /** + * Handles the invitation accepted event by marking the token as used + * and adding the associated role to the user if present + * + * @param InvitationAcceptedEvent $event + */ + public function handle(InvitationAcceptedEvent $event): void + { + $user = $event->getUser(); + $invitationToken = $event->getInvitationToken(); + + // Mark the invitation token as used + $this->invitationTokenRepository->update($invitationToken, [ + 'used_at' => Carbon::now(), + ]); + + // If the invitation token has a role, add it to the user + if ($invitationToken->role_id) { + $user->roles()->attach($invitationToken->role_id); + } + } +} diff --git a/code/app/Athenia/Models/User/InvitationToken.php b/code/app/Athenia/Models/User/InvitationToken.php new file mode 100644 index 00000000..f81cc8d1 --- /dev/null +++ b/code/app/Athenia/Models/User/InvitationToken.php @@ -0,0 +1,100 @@ +|InvitationToken getAggregateMethod() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken isAppendRelationsCount() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken isLeftJoin() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken isUseTableAlias() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken joinRelations($relations, $leftJoin = null) + * @method static \Illuminate\Database\Eloquent\Builder|InvitationToken onlyTrashed() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken orWhereInJoin($column, $values) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken orWhereJoin($column, $operator, $value) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken orWhereNotInJoin($column, $values) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken orderByJoin($column, $direction = 'asc', $aggregateMethod = null) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken setAggregateMethod(string $aggregateMethod) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken setAppendRelationsCount(bool $appendRelationsCount) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken setLeftJoin(bool $leftJoin) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken setUseTableAlias(bool $useTableAlias) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereInJoin($column, $values, $boolean = 'and', $not = false) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereJoin($column, $operator, $value, $boolean = 'and') + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereNotInJoin($column, $values, $boolean = 'and') + * @method static \Illuminate\Database\Eloquent\Builder|InvitationToken withTrashed(bool $withTrashed = true) + * @method static \Illuminate\Database\Eloquent\Builder|InvitationToken withoutTrashed() + * @mixin \Eloquent + */ +class InvitationToken extends BaseModelAbstract +{ + /** + * The role relation for this invitation token + * + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + + /** + * Check if this invitation token has been used + * + * @return bool + */ + public function isUsed(): bool + { + return $this->used_at !== null; + } + + /** + * Swagger definition below for an invitation token... + * + * @SWG\Definition( + * type="object", + * definition="InvitationToken", + * @SWG\Property( + * property="token", + * type="string", + * maxLength=40, + * description="The invitation token that was generated." + * ), + * @SWG\Property( + * property="role_id", + * type="integer", + * format="int32", + * description="The role ID that will be assigned when this invitation is accepted." + * ), + * @SWG\Property( + * property="used_at", + * type="string", + * format="date-time", + * description="UTC date of when this invitation was used." + * ), + * ) + */ +} diff --git a/code/app/Athenia/Providers/BaseEventServiceProvider.php b/code/app/Athenia/Providers/BaseEventServiceProvider.php index 1924ce3f..26b962d2 100644 --- a/code/app/Athenia/Providers/BaseEventServiceProvider.php +++ b/code/app/Athenia/Providers/BaseEventServiceProvider.php @@ -11,6 +11,7 @@ use App\Athenia\Events\Payment\PaymentReversedEvent; use App\Athenia\Events\User\Contact\ContactCreatedEvent; use App\Athenia\Events\User\ForgotPasswordEvent; +use App\Athenia\Events\User\InvitationAcceptedEvent; use App\Athenia\Events\User\SignUpEvent; use App\Athenia\Events\User\UserMergeEvent; use App\Athenia\Events\Vote\VoteCreatedEvent; @@ -22,6 +23,7 @@ use App\Athenia\Listeners\Messaging\MessageSentListener; use App\Athenia\Listeners\Payment\DefaultPaymentMethodSetListener; use App\Athenia\Listeners\User\ForgotPasswordListener; +use App\Athenia\Listeners\User\InvitationAcceptedListener; use App\Athenia\Listeners\User\UserMerge\UserBallotCompletionsMergeListener; use App\Athenia\Listeners\User\UserMerge\UserCreatedArticlesMergeListener; use App\Athenia\Listeners\User\UserMerge\UserCreatedIterationsMergeListener; @@ -71,6 +73,9 @@ public function listens(): array ForgotPasswordEvent::class => [ ForgotPasswordListener::class, ], + InvitationAcceptedEvent::class => [ + InvitationAcceptedListener::class, + ], MessageCreatedEvent::class => [ MessageCreatedListener::class, ], diff --git a/code/app/Athenia/Providers/BaseRepositoryProvider.php b/code/app/Athenia/Providers/BaseRepositoryProvider.php index 356e8beb..101c7ddf 100644 --- a/code/app/Athenia/Providers/BaseRepositoryProvider.php +++ b/code/app/Athenia/Providers/BaseRepositoryProvider.php @@ -12,6 +12,7 @@ use App\Athenia\Contracts\Repositories\Messaging\ThreadRepositoryContract; use App\Athenia\Contracts\Repositories\Organization\OrganizationManagerRepositoryContract; use App\Athenia\Contracts\Repositories\Organization\OrganizationRepositoryContract; +use App\Athenia\Contracts\Repositories\User\InvitationTokenRepositoryContract; use App\Athenia\Contracts\Repositories\Payment\LineItemRepositoryContract; use App\Athenia\Contracts\Repositories\Payment\PaymentMethodRepositoryContract; use App\Athenia\Contracts\Repositories\Payment\PaymentRepositoryContract; @@ -62,6 +63,7 @@ use App\Athenia\Repositories\Subscription\SubscriptionRepository; use App\Athenia\Repositories\User\ArticleNoteRepository; use App\Athenia\Repositories\User\ContactRepository; +use App\Athenia\Repositories\User\InvitationTokenRepository; use App\Athenia\Repositories\User\PasswordTokenRepository; use App\Athenia\Repositories\User\ProfileImageRepository; use App\Athenia\Repositories\User\UserRepository; @@ -97,6 +99,7 @@ use App\Models\Subscription\Subscription; use App\Models\User\ArticleNote; use App\Models\User\Contact; +use App\Models\User\InvitationToken; use App\Models\User\PasswordToken; use App\Models\User\ProfileImage; use App\Models\User\User; @@ -147,6 +150,7 @@ public final function provides(): array CollectionItemRepositoryContract::class, ContactRepositoryContract::class, FeatureRepositoryContract::class, + InvitationTokenRepositoryContract::class, LineItemRepositoryContract::class, MembershipPlanRepositoryContract::class, MembershipPlanRateRepositoryContract::class, @@ -332,6 +336,13 @@ public final function register(): void $this->app->make('log') ); }); + $this->app->bind(InvitationTokenRepositoryContract::class, function() { + return new InvitationTokenRepository( + new InvitationToken(), + $this->app->make('log'), + $this->app->make(TokenGenerationServiceContract::class) + ); + }); $this->app->bind(PasswordTokenRepositoryContract::class, function() { return new PasswordTokenRepository( new PasswordToken(), diff --git a/code/app/Athenia/Providers/BaseValidatorProvider.php b/code/app/Athenia/Providers/BaseValidatorProvider.php index 87d60cdb..1dfdd80e 100644 --- a/code/app/Athenia/Providers/BaseValidatorProvider.php +++ b/code/app/Athenia/Providers/BaseValidatorProvider.php @@ -6,6 +6,7 @@ use App\Athenia\Validators\ArticleVersion\SelectedIterationBelongsToArticleValidator; use App\Athenia\Validators\ForgotPassword\TokenIsNotExpiredValidator; use App\Athenia\Validators\ForgotPassword\UserOwnsTokenValidator; +use App\Athenia\Validators\InvitationTokenIsValidValidator; use App\Athenia\Validators\NotPresentValidator; use App\Athenia\Validators\OwnedByValidator; use App\Athenia\Validators\Subscription\MembershipPlanRateIsActiveValidator; @@ -30,6 +31,7 @@ public function boot(): void $validator->extend('token_is_not_expired', TokenIsNotExpiredValidator::class); $validator->extend('user_owns_token', UserOwnsTokenValidator::class); $validator->extend('not_present', NotPresentValidator::class); + $validator->extend(InvitationTokenIsValidValidator::KEY, InvitationTokenIsValidValidator::class); $validator->extend(MembershipPlanRateIsActiveValidator::KEY, MembershipPlanRateIsActiveValidator::class); $validator->extend(OwnedByValidator::KEY, OwnedByValidator::class); $validator->extend(PaymentMethodIsOwnedByEntityValidator::KEY, PaymentMethodIsOwnedByEntityValidator::class); diff --git a/code/app/Athenia/Repositories/User/InvitationTokenRepository.php b/code/app/Athenia/Repositories/User/InvitationTokenRepository.php new file mode 100644 index 00000000..14190046 --- /dev/null +++ b/code/app/Athenia/Repositories/User/InvitationTokenRepository.php @@ -0,0 +1,73 @@ +tokenGenerationService = $tokenGenerationService; + } + + /** + * Finds an invitation token by its token string + * + * @param string $token + * @return Model|InvitationToken|null + */ + public function findByToken(string $token): ?InvitationToken + { + return $this->model->newQuery() + ->where('token', '=', $token) + ->first(); + } + + /** + * Generates a unique token, or throws an exception if it cannot do so. + * + * @throws \OverflowException + * @return string + */ + public function generateUniqueToken(): string + { + $attempts = 0; + do { + $token = $this->tokenGenerationService->generateToken(); + $existingModel = $this->findByToken($token); + $attempts++; + } while ($existingModel != null && $attempts < 5); + + if ($existingModel) { + throw new \OverflowException('Unable to generate unique invitation token.'); + } + + return $token; + } +} diff --git a/code/app/Athenia/Validators/InvitationTokenIsValidValidator.php b/code/app/Athenia/Validators/InvitationTokenIsValidValidator.php new file mode 100644 index 00000000..c08cb3a4 --- /dev/null +++ b/code/app/Athenia/Validators/InvitationTokenIsValidValidator.php @@ -0,0 +1,61 @@ +invitationTokenRepository = $invitationTokenRepository; + } + + /** + * This is invoked by the validator rule 'invitation_token_is_valid' + * + * @param $attribute + * @param $value + * @param array $parameters + * @param Validator|null $validator + * @return bool + */ + public function validate($attribute, $value, $parameters = [], Validator $validator = null): bool + { + if (!is_string($value)) { + return false; + } + + $invitationToken = $this->invitationTokenRepository->findByToken($value); + + if (!$invitationToken) { + return false; + } + + if ($invitationToken->isUsed()) { + return false; + } + + return true; + } +} diff --git a/code/app/Models/User/InvitationToken.php b/code/app/Models/User/InvitationToken.php new file mode 100644 index 00000000..2bbb6198 --- /dev/null +++ b/code/app/Models/User/InvitationToken.php @@ -0,0 +1,54 @@ +|InvitationToken getAggregateMethod() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken isAppendRelationsCount() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken isLeftJoin() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken isUseTableAlias() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken joinRelations($relations, $leftJoin = null) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken newModelQuery() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|InvitationToken onlyTrashed() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken orWhereInJoin($column, $values) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken orWhereJoin($column, $operator, $value) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken orWhereNotInJoin($column, $values) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken orderByJoin($column, $direction = 'asc', $aggregateMethod = null) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken query() + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken setAggregateMethod(string $aggregateMethod) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken setAppendRelationsCount(bool $appendRelationsCount) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken setLeftJoin(bool $leftJoin) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken setUseTableAlias(bool $useTableAlias) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereCreatedAt($value) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereDeletedAt($value) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereId($value) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereInJoin($column, $values, $boolean = 'and', $not = false) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereJoin($column, $operator, $value, $boolean = 'and') + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereNotInJoin($column, $values, $boolean = 'and') + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereToken($value) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereRoleId($value) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereUsedAt($value) + * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|InvitationToken whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|InvitationToken withTrashed(bool $withTrashed = true) + * @method static \Illuminate\Database\Eloquent\Builder|InvitationToken withoutTrashed() + * @mixin \Eloquent + */ +class InvitationToken extends AtheniaInvitationToken +{ +} diff --git a/code/config/athenia.php b/code/config/athenia.php index fc078075..009b4be4 100644 --- a/code/config/athenia.php +++ b/code/config/athenia.php @@ -6,5 +6,7 @@ 'slack_enabled' => env('ATHENIA_SLACK_ENABLED', false), 'sms_enabled' => env('ATHENIA_SMS_ENABLED', false), 'push_enabled' => env('ATHENIA_PUSH_ENABLED', false), - ] + ], + + 'invitation_required' => env('INVITATION_REQUIRED', false), ]; \ No newline at end of file diff --git a/code/database/factories/User/InvitationTokenFactory.php b/code/database/factories/User/InvitationTokenFactory.php new file mode 100644 index 00000000..8b040943 --- /dev/null +++ b/code/database/factories/User/InvitationTokenFactory.php @@ -0,0 +1,32 @@ + Str::random(40), + 'role_id' => null, + 'used_at' => null, + ]; + } +} diff --git a/code/database/migrations/2025_11_24_000000_create_invitation_tokens_table.php b/code/database/migrations/2025_11_24_000000_create_invitation_tokens_table.php new file mode 100644 index 00000000..54b9a12e --- /dev/null +++ b/code/database/migrations/2025_11_24_000000_create_invitation_tokens_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->string('token', 40)->unique(); + $table->unsignedInteger('role_id')->nullable(); + $table->foreign('role_id')->references('id')->on('roles'); + $table->timestamp('used_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invitation_tokens'); + } +}; diff --git a/code/tests/Athenia/Feature/Http/Authentication/SignUpTest.php b/code/tests/Athenia/Feature/Http/Authentication/SignUpTest.php index 94fe7f1f..ced4be02 100644 --- a/code/tests/Athenia/Feature/Http/Authentication/SignUpTest.php +++ b/code/tests/Athenia/Feature/Http/Authentication/SignUpTest.php @@ -3,9 +3,13 @@ namespace Tests\Athenia\Feature\Http\Authentication; +use App\Athenia\Events\User\InvitationAcceptedEvent; use App\Athenia\Events\User\SignUpEvent; +use App\Models\Role; +use App\Models\User\InvitationToken; use App\Models\User\User; use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Hash; use Tests\DatabaseSetupTrait; use Tests\TestCase; @@ -164,4 +168,171 @@ public function testWebsiteSignUpFailsEmailInUse(): void $response->assertStatus(400); } + + public function testSignUpSuccessWithValidInvitationToken(): void + { + Config::set('athenia.invitation_required', true); + + $role = Role::find(Role::ARTICLE_EDITOR); + $invitationToken = InvitationToken::factory()->create([ + 'token' => 'test-token-123', + 'role_id' => $role->id, + 'used_at' => null, + ]); + + $dispatcher = mock(Dispatcher::class); + + $signUpEventHit = false; + $invitationAcceptedEventHit = false; + + $dispatcher->shouldReceive('dispatch')->with(\Mockery::on(function ($event) use (&$signUpEventHit, &$invitationAcceptedEventHit) { + if ($event instanceof SignUpEvent) { + $signUpEventHit = true; + } + if ($event instanceof InvitationAcceptedEvent) { + $invitationAcceptedEventHit = true; + } + return true; + })); + + $this->app->bind(Dispatcher::class, function () use ($dispatcher) { + return $dispatcher; + }); + + $properties = [ + 'email' => 'guy@smiley.com', + 'first_name' => 'Steve', + 'password' => 'complex!', + 'invitation_token' => 'test-token-123', + ]; + + $response = $this->json('POST', '/v1/auth/sign-up', $properties); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'token' + ]); + + $this->assertTrue($signUpEventHit); + $this->assertTrue($invitationAcceptedEventHit); + + // Verify the token was marked as used + $invitationToken->refresh(); + $this->assertNotNull($invitationToken->used_at); + + // Verify the user has the role + $user = User::where('email', 'guy@smiley.com')->first(); + $this->assertTrue($user->roles->contains($role)); + } + + public function testSignUpFailsWhenInvitationRequiredButNotProvided(): void + { + Config::set('athenia.invitation_required', true); + + $properties = [ + 'email' => 'guy@smiley.com', + 'first_name' => 'Steve', + 'password' => 'complex!', + ]; + + $response = $this->json('POST', '/v1/auth/sign-up', $properties); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'invitation_token' => ['The invitation token field is required.'], + ] + ]); + } + + public function testSignUpFailsWhenInvitationTokenIsInvalid(): void + { + Config::set('athenia.invitation_required', true); + + $properties = [ + 'email' => 'guy@smiley.com', + 'first_name' => 'Steve', + 'password' => 'complex!', + 'invitation_token' => 'invalid-token', + ]; + + $response = $this->json('POST', '/v1/auth/sign-up', $properties); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'invitation_token' => ['The invitation token is invalid.'], + ] + ]); + } + + public function testSignUpFailsWhenInvitationTokenAlreadyUsed(): void + { + Config::set('athenia.invitation_required', true); + + InvitationToken::factory()->create([ + 'token' => 'used-token', + 'used_at' => now(), + ]); + + $properties = [ + 'email' => 'guy@smiley.com', + 'first_name' => 'Steve', + 'password' => 'complex!', + 'invitation_token' => 'used-token', + ]; + + $response = $this->json('POST', '/v1/auth/sign-up', $properties); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'invitation_token' => ['The invitation token has already been used.'], + ] + ]); + } + + public function testSignUpSuccessWithInvitationTokenWithoutRole(): void + { + Config::set('athenia.invitation_required', true); + + InvitationToken::factory()->create([ + 'token' => 'token-without-role', + 'role_id' => null, + 'used_at' => null, + ]); + + $properties = [ + 'email' => 'guy@smiley.com', + 'first_name' => 'Steve', + 'password' => 'complex!', + 'invitation_token' => 'token-without-role', + ]; + + $response = $this->json('POST', '/v1/auth/sign-up', $properties); + + $response->assertStatus(201); + + // Verify the user was created but has no additional roles + $user = User::where('email', 'guy@smiley.com')->first(); + $this->assertNotNull($user); + } + + public function testSignUpSuccessWhenInvitationNotRequired(): void + { + Config::set('athenia.invitation_required', false); + + $properties = [ + 'email' => 'guy@smiley.com', + 'first_name' => 'Steve', + 'password' => 'complex!', + ]; + + $response = $this->json('POST', '/v1/auth/sign-up', $properties); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'token' + ]); + } } diff --git a/code/tests/Athenia/Integration/Repositories/User/InvitationTokenRepositoryTest.php b/code/tests/Athenia/Integration/Repositories/User/InvitationTokenRepositoryTest.php new file mode 100644 index 00000000..b65440fd --- /dev/null +++ b/code/tests/Athenia/Integration/Repositories/User/InvitationTokenRepositoryTest.php @@ -0,0 +1,139 @@ +setupDatabase(); + + $this->tokenGenerationService = mock(TokenGenerationServiceContract::class); + $this->repository = new InvitationTokenRepository( + new InvitationToken(), + $this->getGenericLogMock(), + $this->tokenGenerationService + ); + } + + public function testFindAllThrowsException(): void + { + $this->expectException(NotImplementedException::class); + + $this->repository->findAll(); + } + + public function testFindOrFailThrowsException(): void + { + $this->expectException(NotImplementedException::class); + + $this->repository->findOrFail(1); + } + + public function testDeleteThrowsException(): void + { + $this->expectException(NotImplementedException::class); + + $this->repository->delete(new InvitationToken()); + } + + public function testCreateSuccess(): void + { + $role = Role::find(Role::ARTICLE_EDITOR); + + /** @var InvitationToken $invitationToken */ + $invitationToken = $this->repository->create([ + 'token' => 'hello-world', + 'role_id' => $role->id, + ]); + + $this->assertEquals('hello-world', $invitationToken->token); + $this->assertEquals($role->id, $invitationToken->role_id); + $this->assertNull($invitationToken->used_at); + } + + public function testCreateSuccessWithoutRole(): void + { + /** @var InvitationToken $invitationToken */ + $invitationToken = $this->repository->create([ + 'token' => 'hello-world', + ]); + + $this->assertEquals('hello-world', $invitationToken->token); + $this->assertNull($invitationToken->role_id); + $this->assertNull($invitationToken->used_at); + } + + public function testUpdateSuccess(): void + { + $invitationToken = InvitationToken::factory()->create([ + 'token' => 'test-token', + 'used_at' => null, + ]); + + $this->assertNull($invitationToken->used_at); + + $this->repository->update($invitationToken, [ + 'used_at' => now(), + ]); + + $invitationToken->refresh(); + $this->assertNotNull($invitationToken->used_at); + } + + public function testFindByToken(): void + { + $invitationToken = InvitationToken::factory()->create([ + 'token' => '1234', + ]); + + $this->assertEquals($invitationToken->id, $this->repository->findByToken('1234')->id); + $this->assertNull($this->repository->findByToken('12345')); + } + + public function testGenerateUniqueTokenSuccess(): void + { + $this->tokenGenerationService->shouldReceive('generateToken')->once()->andReturn('unique-token-12345'); + + $this->assertEquals('unique-token-12345', $this->repository->generateUniqueToken()); + } + + public function testGenerateUniqueTokenThrowsException(): void + { + InvitationToken::factory()->create([ + 'token' => '12345', + ]); + + $this->tokenGenerationService->shouldReceive('generateToken')->times(5)->andReturn('12345'); + $this->expectException(\OverflowException::class); + + $this->repository->generateUniqueToken(); + } +} diff --git a/code/tests/Athenia/Unit/Listeners/User/InvitationAcceptedListenerTest.php b/code/tests/Athenia/Unit/Listeners/User/InvitationAcceptedListenerTest.php new file mode 100644 index 00000000..6ee5c77f --- /dev/null +++ b/code/tests/Athenia/Unit/Listeners/User/InvitationAcceptedListenerTest.php @@ -0,0 +1,97 @@ +id = 1; + + $invitationToken = new InvitationToken(); + $invitationToken->id = 1; + $invitationToken->token = 'test-token'; + $invitationToken->role_id = Role::ARTICLE_EDITOR; + + $event = new InvitationAcceptedEvent($user, $invitationToken); + + // Mock the repository update call + $repository->shouldReceive('update')->once()->with($invitationToken, \Mockery::on(function ($data) { + $this->assertArrayHasKey('used_at', $data); + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $data['used_at']); + return true; + })); + + // Mock the roles relationship + $rolesRelation = mock(BelongsToMany::class); + $rolesRelation->shouldReceive('attach')->once()->with(Role::ARTICLE_EDITOR); + $user->setRelation('roles', $rolesRelation); + + // We need to mock the roles() method + $user = \Mockery::mock($user)->makePartial(); + $user->shouldReceive('roles')->once()->andReturn($rolesRelation); + + // Update the event with the mocked user + $event = new InvitationAcceptedEvent($user, $invitationToken); + + $listener->handle($event); + } + + public function testHandleWithoutRole(): void + { + /** @var InvitationTokenRepositoryContract|CustomMockInterface $repository */ + $repository = mock(InvitationTokenRepositoryContract::class); + + $listener = new InvitationAcceptedListener($repository); + + $user = new User(); + $user->id = 1; + + $invitationToken = new InvitationToken(); + $invitationToken->id = 1; + $invitationToken->token = 'test-token'; + $invitationToken->role_id = null; + + $event = new InvitationAcceptedEvent($user, $invitationToken); + + // Mock the repository update call + $repository->shouldReceive('update')->once()->with($invitationToken, \Mockery::on(function ($data) { + $this->assertArrayHasKey('used_at', $data); + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $data['used_at']); + return true; + })); + + // Mock the roles relationship - it should NOT be called + $rolesRelation = mock(BelongsToMany::class); + $rolesRelation->shouldReceive('attach')->never(); + + $user = \Mockery::mock($user)->makePartial(); + $user->shouldReceive('roles')->never(); + + // Update the event with the mocked user + $event = new InvitationAcceptedEvent($user, $invitationToken); + + $listener->handle($event); + } +} diff --git a/code/tests/Athenia/Unit/Models/User/InvitationTokenTest.php b/code/tests/Athenia/Unit/Models/User/InvitationTokenTest.php new file mode 100644 index 00000000..62acb395 --- /dev/null +++ b/code/tests/Athenia/Unit/Models/User/InvitationTokenTest.php @@ -0,0 +1,43 @@ +role(); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals('invitation_tokens.role_id', $relation->getQualifiedForeignKeyName()); + $this->assertEquals('roles.id', $relation->getQualifiedOwnerKeyName()); + } + + public function testIsUsedReturnsTrueWhenUsedAtIsSet(): void + { + $model = new InvitationToken(); + $model->used_at = now(); + + $this->assertTrue($model->isUsed()); + } + + public function testIsUsedReturnsFalseWhenUsedAtIsNull(): void + { + $model = new InvitationToken(); + $model->used_at = null; + + $this->assertFalse($model->isUsed()); + } +} diff --git a/code/tests/Athenia/Unit/Validators/InvitationTokenIsValidValidatorTest.php b/code/tests/Athenia/Unit/Validators/InvitationTokenIsValidValidatorTest.php new file mode 100644 index 00000000..5957bad8 --- /dev/null +++ b/code/tests/Athenia/Unit/Validators/InvitationTokenIsValidValidatorTest.php @@ -0,0 +1,90 @@ +token = 'valid-token'; + $invitationToken->used_at = null; + + $repository->shouldReceive('findByToken') + ->once() + ->with('valid-token') + ->andReturn($invitationToken); + + $validator = new InvitationTokenIsValidValidator($repository); + + $this->assertTrue($validator->validate('invitation_token', 'valid-token')); + } + + public function testValidateReturnsFalseWhenTokenNotFound(): void + { + /** @var InvitationTokenRepositoryContract|CustomMockInterface $repository */ + $repository = mock(InvitationTokenRepositoryContract::class); + + $repository->shouldReceive('findByToken') + ->once() + ->with('invalid-token') + ->andReturn(null); + + $validator = new InvitationTokenIsValidValidator($repository); + + $this->assertFalse($validator->validate('invitation_token', 'invalid-token')); + } + + public function testValidateReturnsFalseWhenTokenAlreadyUsed(): void + { + /** @var InvitationTokenRepositoryContract|CustomMockInterface $repository */ + $repository = mock(InvitationTokenRepositoryContract::class); + + $invitationToken = new InvitationToken(); + $invitationToken->token = 'used-token'; + $invitationToken->used_at = now(); + + $repository->shouldReceive('findByToken') + ->once() + ->with('used-token') + ->andReturn($invitationToken); + + $validator = new InvitationTokenIsValidValidator($repository); + + $this->assertFalse($validator->validate('invitation_token', 'used-token')); + } + + public function testValidateReturnsFalseWhenValueIsNotString(): void + { + /** @var InvitationTokenRepositoryContract|CustomMockInterface $repository */ + $repository = mock(InvitationTokenRepositoryContract::class); + + $validator = new InvitationTokenIsValidValidator($repository); + + $this->assertFalse($validator->validate('invitation_token', 123)); + } + + public function testValidateReturnsFalseWhenValueIsArray(): void + { + /** @var InvitationTokenRepositoryContract|CustomMockInterface $repository */ + $repository = mock(InvitationTokenRepositoryContract::class); + + $validator = new InvitationTokenIsValidValidator($repository); + + $this->assertFalse($validator->validate('invitation_token', ['invalid'])); + } +} From 49dee2ad31ce5d14d4e72cf464912d9aea39ed87 Mon Sep 17 00:00:00 2001 From: Bryce Meyer Date: Mon, 24 Nov 2025 15:39:31 +0100 Subject: [PATCH 2/2] fixed test --- .../AuthenticationControllerAbstract.php | 8 +++-- code/lang/en/validation.php | 2 ++ .../Http/Authentication/SignUpTest.php | 29 +++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/code/app/Athenia/Http/Core/Controllers/AuthenticationControllerAbstract.php b/code/app/Athenia/Http/Core/Controllers/AuthenticationControllerAbstract.php index 455c61b7..2a50977c 100644 --- a/code/app/Athenia/Http/Core/Controllers/AuthenticationControllerAbstract.php +++ b/code/app/Athenia/Http/Core/Controllers/AuthenticationControllerAbstract.php @@ -252,6 +252,10 @@ public function signUp(Requests\Authentication\SignUpRequest $request) { $data = $request->json()->all(); + // Store invitation token separately and remove it from user data + $invitationTokenValue = $data['invitation_token'] ?? null; + unset($data['invitation_token']); + $forcedData = [ 'password' => $this->hasher->make($data['password']), ]; @@ -262,8 +266,8 @@ public function signUp(Requests\Authentication\SignUpRequest $request) $this->dispatcher->dispatch(new SignUpEvent($model)); // If an invitation token was provided, dispatch the InvitationAcceptedEvent - if (isset($data['invitation_token'])) { - $invitationToken = $this->invitationTokenRepository->findByToken($data['invitation_token']); + if ($invitationTokenValue) { + $invitationToken = $this->invitationTokenRepository->findByToken($invitationTokenValue); if ($invitationToken) { $this->dispatcher->dispatch(new InvitationAcceptedEvent($model, $invitationToken)); } diff --git a/code/lang/en/validation.php b/code/lang/en/validation.php index 3ccf4b3a..bb1cddb0 100644 --- a/code/lang/en/validation.php +++ b/code/lang/en/validation.php @@ -130,6 +130,8 @@ 'token_is_not_expired' => 'The reset password token has expired. You are going to have to request a new one.', 'user_owns_token' => 'The reset password token does not seem to be for the entered email address.', 'not_present' => 'The :attribute field is not allowed or can not be set for this request.', + \App\Athenia\Validators\InvitationTokenIsValidValidator::KEY => + 'The :attribute is invalid.', \App\Athenia\Validators\Subscription\MembershipPlanRateIsActiveValidator::KEY => 'The membership plan rate must be active for you to purchase it.', \App\Athenia\Validators\Subscription\PaymentMethodIsOwnedByEntityValidator::KEY => diff --git a/code/tests/Athenia/Feature/Http/Authentication/SignUpTest.php b/code/tests/Athenia/Feature/Http/Authentication/SignUpTest.php index ced4be02..edf97693 100644 --- a/code/tests/Athenia/Feature/Http/Authentication/SignUpTest.php +++ b/code/tests/Athenia/Feature/Http/Authentication/SignUpTest.php @@ -5,6 +5,7 @@ use App\Athenia\Events\User\InvitationAcceptedEvent; use App\Athenia\Events\User\SignUpEvent; +use App\Athenia\Listeners\User\InvitationAcceptedListener; use App\Models\Role; use App\Models\User\InvitationToken; use App\Models\User\User; @@ -216,12 +217,16 @@ public function testSignUpSuccessWithValidInvitationToken(): void $this->assertTrue($signUpEventHit); $this->assertTrue($invitationAcceptedEventHit); + // Since we mocked the dispatcher, manually call the listener to verify its behavior + $user = User::where('email', 'guy@smiley.com')->first(); + $invitationAcceptedListener = app(InvitationAcceptedListener::class); + $invitationAcceptedListener->handle(new InvitationAcceptedEvent($user, $invitationToken)); + // Verify the token was marked as used $invitationToken->refresh(); $this->assertNotNull($invitationToken->used_at); // Verify the user has the role - $user = User::where('email', 'guy@smiley.com')->first(); $this->assertTrue($user->roles->contains($role)); } @@ -287,7 +292,7 @@ public function testSignUpFailsWhenInvitationTokenAlreadyUsed(): void $response->assertStatus(400); $response->assertJson([ 'errors' => [ - 'invitation_token' => ['The invitation token has already been used.'], + 'invitation_token' => ['The invitation token is invalid.'], ] ]); } @@ -302,6 +307,16 @@ public function testSignUpSuccessWithInvitationTokenWithoutRole(): void 'used_at' => null, ]); + $dispatcher = mock(Dispatcher::class); + + $dispatcher->shouldReceive('dispatch')->with(\Mockery::on(function ($event) { + return true; + })); + + $this->app->bind(Dispatcher::class, function () use ($dispatcher) { + return $dispatcher; + }); + $properties = [ 'email' => 'guy@smiley.com', 'first_name' => 'Steve', @@ -322,6 +337,16 @@ public function testSignUpSuccessWhenInvitationNotRequired(): void { Config::set('athenia.invitation_required', false); + $dispatcher = mock(Dispatcher::class); + + $dispatcher->shouldReceive('dispatch')->with(\Mockery::on(function ($event) { + return true; + })); + + $this->app->bind(Dispatcher::class, function () use ($dispatcher) { + return $dispatcher; + }); + $properties = [ 'email' => 'guy@smiley.com', 'first_name' => 'Steve',