From f9c0d64f82c7dea76b976bf4d5e7d2575b365cd0 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Thu, 17 Jul 2025 15:33:06 +0200 Subject: [PATCH] Add email notifications for expiring api tokens --- ...endReminderForExpiringApiTokensCommand.php | 108 ++++++++++++++++ app/Console/Kernel.php | 4 + app/Filament/Resources/FailedJobResource.php | 11 +- .../Pages/ListFailedJobs.php | 8 +- app/Filament/Resources/TokenResource.php | 11 +- .../AuthApiTokenExpirationReminderMail.php | 44 +++++++ app/Mail/AuthApiTokenExpiredMail.php | 44 +++++++ app/Models/Passport/Token.php | 41 ++++++ config/scheduling.php | 1 + database/factories/Passport/ClientFactory.php | 16 +++ database/factories/Passport/TokenFactory.php | 2 + ...r_sent_at_to_oauth_access_tokens_table.php | 32 +++++ .../auth-api-expiration-reminder.blade.php | 12 ++ .../emails/auth-api-token-expired.blade.php | 13 ++ ...eminderForExpiringApiTokensCommandTest.php | 121 ++++++++++++++++++ ...AuthApiTokenExpirationReminderMailTest.php | 33 +++++ .../Unit/Mail/AuthApiTokenExpiredMailTest.php | 33 +++++ tests/Unit/Model/Passport/TokenModelTest.php | 100 +++++++++++++++ 18 files changed, 618 insertions(+), 16 deletions(-) create mode 100644 app/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommand.php create mode 100644 app/Mail/AuthApiTokenExpirationReminderMail.php create mode 100644 app/Mail/AuthApiTokenExpiredMail.php create mode 100644 database/migrations/2025_07_17_104903_add_reminder_sent_at_to_oauth_access_tokens_table.php create mode 100644 resources/views/emails/auth-api-expiration-reminder.blade.php create mode 100644 resources/views/emails/auth-api-token-expired.blade.php create mode 100644 tests/Unit/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommandTest.php create mode 100644 tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php create mode 100644 tests/Unit/Mail/AuthApiTokenExpiredMailTest.php create mode 100644 tests/Unit/Model/Passport/TokenModelTest.php diff --git a/app/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommand.php b/app/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommand.php new file mode 100644 index 00000000..ae2b679d --- /dev/null +++ b/app/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommand.php @@ -0,0 +1,108 @@ +option('dry-run'); + if ($dryRun) { + $this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.'); + } + + $this->comment('Sending reminder emails about expiring API tokens...'); + $sentMails = 0; + Token::query() + ->where('expires_at', '<=', Carbon::now()->addDays(7)) + ->whereNull('reminder_sent_at') + ->with([ + 'client', + 'user', + ]) + ->whereHas('user', function (Builder $query): void { + /** @var Builder $query */ + $query->where('is_placeholder', '=', false); + }) + ->isApiToken(true) + ->orderBy('created_at', 'asc') + ->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void { + /** @var Collection $tokens */ + foreach ($tokens as $token) { + $user = $token->user; + $this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey()); + $sentMails++; + if (! $dryRun) { + Mail::to($user->email) + ->queue(new AuthApiTokenExpirationReminderMail($token, $user)); + $token->reminder_sent_at = Carbon::now(); + $token->save(); + } + } + }); + $this->comment('Finished sending '.$sentMails.' expiring API token emails...'); + + $this->comment('Sent emails about expired API tokens'); + $sentMails = 0; + Token::query() + ->where('expires_at', '<=', Carbon::now()) + ->whereNull('expired_info_sent_at') + ->with([ + 'client', + 'user', + ]) + ->whereHas('user', function (Builder $query): void { + /** @var Builder $query */ + $query->where('is_placeholder', '=', false); + }) + ->isApiToken(true) + ->orderBy('created_at', 'asc') + ->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void { + /** @var Collection $tokens */ + foreach ($tokens as $token) { + $user = $token->user; + $this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey()); + $sentMails++; + if (! $dryRun) { + Mail::to($user->email) + ->queue(new AuthApiTokenExpiredMail($token, $user)); + $token->expired_info_sent_at = Carbon::now(); + $token->save(); + } + } + }); + $this->comment('Finished sending '.$sentMails.' expired API token emails...'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8aaced22..72c9b148 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -18,6 +18,10 @@ class Kernel extends ConsoleKernel ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails')) ->everyTenMinutes(); + $schedule->command('auth:send-mails-expiring-api-tokens') + ->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens')) + ->everyTenMinutes(); + $schedule->command('self-host:check-for-update') ->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update')) ->twiceDaily(); diff --git a/app/Filament/Resources/FailedJobResource.php b/app/Filament/Resources/FailedJobResource.php index 6188477c..7960f1c3 100644 --- a/app/Filament/Resources/FailedJobResource.php +++ b/app/Filament/Resources/FailedJobResource.php @@ -15,6 +15,7 @@ use Filament\Resources\Resource; use Filament\Tables\Actions\Action; use Filament\Tables\Actions\BulkAction; use Filament\Tables\Actions\DeleteAction; +use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -75,7 +76,8 @@ class FailedJobResource extends Resource ->filters([]) ->bulkActions([ BulkAction::make('retry') - ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->label('Retry selected') ->requiresConfirmation() ->action(function (Collection $records): void { /** @var FailedJob $record */ @@ -87,11 +89,13 @@ class FailedJobResource extends Resource ->success() ->send(); }), + DeleteBulkAction::make(), ]) ->actions([ - DeleteAction::make('Delete'), - ViewAction::make('View'), + DeleteAction::make(), + ViewAction::make(), Action::make('retry') + ->icon('heroicon-o-arrow-path') ->label('Retry') ->requiresConfirmation() ->action(function (FailedJob $record): void { @@ -109,7 +113,6 @@ class FailedJobResource extends Resource return [ 'index' => ListFailedJobs::route('/'), 'view' => ViewFailedJobs::route('/{record}'), - ]; } } diff --git a/app/Filament/Resources/FailedJobResource/Pages/ListFailedJobs.php b/app/Filament/Resources/FailedJobResource/Pages/ListFailedJobs.php index c9cae25c..bd0d037e 100644 --- a/app/Filament/Resources/FailedJobResource/Pages/ListFailedJobs.php +++ b/app/Filament/Resources/FailedJobResource/Pages/ListFailedJobs.php @@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages; use App\Filament\Resources\FailedJobResource; use App\Models\FailedJob; +use Filament\Actions\Action; use Filament\Notifications\Notification; -use Filament\Pages\Actions\Action; use Filament\Resources\Pages\ListRecords; use Illuminate\Support\Facades\Artisan; @@ -19,7 +19,8 @@ class ListFailedJobs extends ListRecords { return [ Action::make('retry_all') - ->label('Retry all failed Jobs') + ->icon('heroicon-o-arrow-path') + ->label('Retry all') ->requiresConfirmation() ->action(function (): void { Artisan::call('queue:retry all'); @@ -30,7 +31,8 @@ class ListFailedJobs extends ListRecords }), Action::make('delete_all') - ->label('Delete all failed Jobs') + ->icon('heroicon-o-trash') + ->label('Delete all') ->requiresConfirmation() ->color('danger') ->action(function (): void { diff --git a/app/Filament/Resources/TokenResource.php b/app/Filament/Resources/TokenResource.php index 0ca00f0c..13e34d39 100644 --- a/app/Filament/Resources/TokenResource.php +++ b/app/Filament/Resources/TokenResource.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Filament\Resources; use App\Filament\Resources\TokenResource\Pages; -use App\Models\Passport\Client; use App\Models\Passport\Token; use Filament\Forms; use Filament\Forms\Form; @@ -106,17 +105,11 @@ class TokenResource extends Resource ->queries( true: function (Builder $query) { /** @var Builder $query */ - return $query->whereHas('client', function (Builder $query) { - /** @var Builder $query */ - return $query->whereJsonContains('grant_types', 'personal_access'); - }); + return $query->isApiToken(); }, false: function (Builder $query) { /** @var Builder $query */ - return $query->whereHas('client', function (Builder $query) { - /** @var Builder $query */ - return $query->whereJsonDoesntContain('grant_types', 'personal_access'); - }); + return $query->isApiToken(false); }, blank: function (Builder $query) { /** @var Builder $query */ diff --git a/app/Mail/AuthApiTokenExpirationReminderMail.php b/app/Mail/AuthApiTokenExpirationReminderMail.php new file mode 100644 index 00000000..39a12165 --- /dev/null +++ b/app/Mail/AuthApiTokenExpirationReminderMail.php @@ -0,0 +1,44 @@ +token = $token; + $this->user = $user; + } + + /** + * Build the message. + */ + public function build(): self + { + return $this->markdown('emails.auth-api-expiration-reminder', [ + 'profileUrl' => URL::to('user/profile'), + 'tokenName' => $this->token->name, + ]) + ->subject(__('Your API token will expire in 7 days!')); + } +} diff --git a/app/Mail/AuthApiTokenExpiredMail.php b/app/Mail/AuthApiTokenExpiredMail.php new file mode 100644 index 00000000..3b3d65d3 --- /dev/null +++ b/app/Mail/AuthApiTokenExpiredMail.php @@ -0,0 +1,44 @@ +token = $token; + $this->user = $user; + } + + /** + * Build the message. + */ + public function build(): self + { + return $this->markdown('emails.auth-api-token-expired', [ + 'profileUrl' => URL::to('user/profile'), + 'tokenName' => $this->token->name, + ]) + ->subject(__('Your API token has expired!')); + } +} diff --git a/app/Models/Passport/Token.php b/app/Models/Passport/Token.php index 5fce7e7c..176b818f 100644 --- a/app/Models/Passport/Token.php +++ b/app/Models/Passport/Token.php @@ -6,6 +6,7 @@ namespace App\Models\Passport; use App\Models\User; use Database\Factories\Passport\TokenFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; @@ -18,11 +19,15 @@ use Laravel\Passport\Token as PassportToken; * @property null|string $name * @property array $scopes * @property bool $revoked + * @property Carbon|null $reminder_sent_at + * @property Carbon|null $expired_info_sent_at * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property Carbon|null $expires_at * @property-read Client|null $client * @property-read User|null $user + * + * @method Builder isApiToken(bool $isApiToken = true) */ class Token extends PassportToken { @@ -52,4 +57,40 @@ class Token extends PassportToken { return $this->belongsTo(User::class, 'user_id'); } + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'scopes' => 'array', + 'revoked' => 'bool', + 'expires_at' => 'datetime', + 'reminder_sent_at' => 'datetime', + 'expired_info_sent_at' => 'datetime', + ]; + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder + { + if ($isApiToken) { + return $query->whereHas('client', function (Builder $query): void { + /** @var Builder $query */ + $query->whereJsonContains('grant_types', 'personal_access'); + }); + } else { + return $query->whereHas('client', function (Builder $query): void { + /** @var Builder $query */ + $query->whereJsonDoesntContain('grant_types', 'personal_access'); + }); + } + + } } diff --git a/config/scheduling.php b/config/scheduling.php index ae24cf13..57b6d775 100644 --- a/config/scheduling.php +++ b/config/scheduling.php @@ -6,6 +6,7 @@ return [ 'tasks' => [ 'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true), + 'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true), 'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true), 'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true), 'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false), diff --git a/database/factories/Passport/ClientFactory.php b/database/factories/Passport/ClientFactory.php index 41c39a50..41a2b6bb 100644 --- a/database/factories/Passport/ClientFactory.php +++ b/database/factories/Passport/ClientFactory.php @@ -36,6 +36,22 @@ class ClientFactory extends BaseClientFactory ]; } + public function desktopClient(): self + { + return $this->state(fn (array $attributes) => [ + 'name' => 'Desktop', + 'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'], + ]); + } + + public function apiClient(): self + { + return $this->state(fn (array $attributes) => [ + 'name' => 'API', + 'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'], + ]); + } + public function personalAccessClient(): self { return $this->state(function (array $attributes) { diff --git a/database/factories/Passport/TokenFactory.php b/database/factories/Passport/TokenFactory.php index 16b86422..e1a30734 100644 --- a/database/factories/Passport/TokenFactory.php +++ b/database/factories/Passport/TokenFactory.php @@ -31,6 +31,8 @@ class TokenFactory extends Factory 'created_at' => $this->faker->dateTime, 'updated_at' => $this->faker->dateTime, 'expires_at' => $this->faker->dateTime, + 'reminder_sent_at' => null, + 'expired_info_sent_at' => null, ]; } diff --git a/database/migrations/2025_07_17_104903_add_reminder_sent_at_to_oauth_access_tokens_table.php b/database/migrations/2025_07_17_104903_add_reminder_sent_at_to_oauth_access_tokens_table.php new file mode 100644 index 00000000..8ea205ee --- /dev/null +++ b/database/migrations/2025_07_17_104903_add_reminder_sent_at_to_oauth_access_tokens_table.php @@ -0,0 +1,32 @@ +dateTime('reminder_sent_at')->nullable(); + $table->dateTime('expired_info_sent_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_access_tokens', function (Blueprint $table): void { + $table->dropColumn('reminder_sent_at'); + $table->dropColumn('expired_info_sent_at'); + }); + } +}; diff --git a/resources/views/emails/auth-api-expiration-reminder.blade.php b/resources/views/emails/auth-api-expiration-reminder.blade.php new file mode 100644 index 00000000..4dcc6ce6 --- /dev/null +++ b/resources/views/emails/auth-api-expiration-reminder.blade.php @@ -0,0 +1,12 @@ +@component('mail::message') + +{{ __('The API token ":token" expired.', ['token' => $tokenName]) }} + + +{{ __('You can create a new API token in your profile:') }} + +@component('mail::button', ['url' => $profileUrl]) + {{ __('Go to your profile') }} +@endcomponent + +@endcomponent diff --git a/resources/views/emails/auth-api-token-expired.blade.php b/resources/views/emails/auth-api-token-expired.blade.php new file mode 100644 index 00000000..124007ba --- /dev/null +++ b/resources/views/emails/auth-api-token-expired.blade.php @@ -0,0 +1,13 @@ +@component('mail::message') + +{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }} + +{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }} + +{{ __('You can create a new API token in your profile:') }} + +@component('mail::button', ['url' => $profileUrl]) + {{ __('Go to your profile') }} +@endcomponent + +@endcomponent diff --git a/tests/Unit/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommandTest.php b/tests/Unit/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommandTest.php new file mode 100644 index 00000000..a2e2d6fd --- /dev/null +++ b/tests/Unit/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommandTest.php @@ -0,0 +1,121 @@ +createUserWithPermission(); + $apiClient = Client::factory()->apiClient()->create(); + $otherClient = Client::factory()->desktopClient()->create(); + $expiredToken = Token::factory()->forUser($user->user)->forClient($apiClient)->create([ + 'reminder_sent_at' => Carbon::now()->subDays(8), + 'expired_info_sent_at' => null, + 'expires_at' => Carbon::now()->subDay(), + ]); + $expiredTokenWithMailSent = Token::factory()->forUser($user->user)->forClient($apiClient)->create([ + 'reminder_sent_at' => Carbon::now()->subDays(8), + 'expired_info_sent_at' => Carbon::now(), + 'expires_at' => Carbon::now()->subDay(), + ]); + $nonApiToken = Token::factory()->forUser($user->user)->forClient($otherClient)->create([ + 'reminder_sent_at' => null, + 'expired_info_sent_at' => null, + 'expires_at' => Carbon::now()->subDay(), + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('auth:send-mails-expiring-api-tokens'); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $expiredToken->refresh(); + $expiredTokenWithMailSent->refresh(); + $nonApiToken->refresh(); + $this->assertNotNull($expiredToken->expired_info_sent_at); + $this->assertNotNull($expiredTokenWithMailSent->expired_info_sent_at); + $this->assertNull($nonApiToken->reminder_sent_at); + $this->assertNull($nonApiToken->expired_info_sent_at); + Mail::assertNotQueued(AuthApiTokenExpirationReminderMail::class); + Mail::assertQueued(AuthApiTokenExpiredMail::class, function (AuthApiTokenExpiredMail $mail) use ($user, $expiredToken): bool { + return $mail->hasTo($user->user->email) && + $mail->token->is($expiredToken) && + $mail->user->is($user->user); + }); + + $output = Artisan::output(); + $this->assertStringContainsString('Finished sending 0 expiring API token emails...', $output); + $this->assertStringContainsString('Finished sending 1 expired API token emails...', $output); + $this->assertStringContainsString( + 'Start sending email to user "'.$user->user->email.'" ('. + $user->user->id.') about expired API token '.$expiredToken->getKey(), $output); + } + + public function test_sends_mail_for_api_tokens_that_expire_soon_but_ignores_the_one_where_the_mail_was_already_sent_and_ignores_non_api_tokens(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $apiClient = Client::factory()->apiClient()->create(); + $otherClient = Client::factory()->desktopClient()->create(); + $expiringToken = Token::factory()->forUser($user->user)->forClient($apiClient)->create([ + 'reminder_sent_at' => null, + 'expired_info_sent_at' => null, + 'expires_at' => Carbon::now()->addDays(6), + ]); + $expiringTokenWithMailSent = Token::factory()->forUser($user->user)->forClient($apiClient)->create([ + 'reminder_sent_at' => Carbon::now(), + 'expired_info_sent_at' => null, + 'expires_at' => Carbon::now()->addDays(6), + ]); + $nonApiToken = Token::factory()->forUser($user->user)->forClient($otherClient)->create([ + 'reminder_sent_at' => null, + 'expired_info_sent_at' => null, + 'expires_at' => Carbon::now()->addDays(6), + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('auth:send-mails-expiring-api-tokens'); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $expiringToken->refresh(); + $expiringTokenWithMailSent->refresh(); + $nonApiToken->refresh(); + $this->assertNotNull($expiringToken->reminder_sent_at); + $this->assertNull($expiringToken->expired_info_sent_at); + $this->assertNotNull($expiringTokenWithMailSent->reminder_sent_at); + $this->assertNull($expiringTokenWithMailSent->expired_info_sent_at); + $this->assertNull($nonApiToken->reminder_sent_at); + $this->assertNull($nonApiToken->expired_info_sent_at); + Mail::assertNotQueued(AuthApiTokenExpiredMail::class); + Mail::assertQueued(AuthApiTokenExpirationReminderMail::class, function (AuthApiTokenExpirationReminderMail $mail) use ($user, $expiringToken): bool { + return $mail->hasTo($user->user->email) && + $mail->token->is($expiringToken) && + $mail->user->is($user->user); + }); + + $output = Artisan::output(); + $this->assertStringContainsString('Finished sending 1 expiring API token emails...', $output); + $this->assertStringContainsString('Finished sending 0 expired API token emails...', $output); + $this->assertStringContainsString( + 'Start sending email to user "'.$user->user->email.'" ('. + $user->user->id.') reminding about API token '.$expiringToken->getKey(), $output); + } +} diff --git a/tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php b/tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php new file mode 100644 index 00000000..91bc4eb1 --- /dev/null +++ b/tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php @@ -0,0 +1,33 @@ +create(); + $client = Client::factory()->apiClient()->create(); + $token = Token::factory()->forClient($client)->forUser($user)->create([ + 'name' => 'TEST', + ]); + $mail = new AuthApiTokenExpirationReminderMail($token, $user); + + // Act + $rendered = $mail->render(); + + // Assert + $this->assertStringContainsString('The API token "TEST" expired.', $rendered); + } +} diff --git a/tests/Unit/Mail/AuthApiTokenExpiredMailTest.php b/tests/Unit/Mail/AuthApiTokenExpiredMailTest.php new file mode 100644 index 00000000..68c328a1 --- /dev/null +++ b/tests/Unit/Mail/AuthApiTokenExpiredMailTest.php @@ -0,0 +1,33 @@ +create(); + $client = Client::factory()->apiClient()->create(); + $token = Token::factory()->forClient($client)->forUser($user)->create([ + 'name' => 'TEST', + ]); + $mail = new AuthApiTokenExpiredMail($token, $user); + + // Act + $rendered = $mail->render(); + + // Assert + $this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered); + } +} diff --git a/tests/Unit/Model/Passport/TokenModelTest.php b/tests/Unit/Model/Passport/TokenModelTest.php new file mode 100644 index 00000000..42378f12 --- /dev/null +++ b/tests/Unit/Model/Passport/TokenModelTest.php @@ -0,0 +1,100 @@ +create(); + $token = Token::factory()->forClient($client)->create(); + + // Act + $token->refresh(); + $clientRel = $token->client; + + // Assert + $this->assertNotNull($clientRel); + $this->assertTrue($clientRel->is($client)); + } + + public function test_it_belongs_to_a_user(): void + { + // Arrange + $user = User::factory()->create(); + $client = Client::factory()->create(); + $token = Token::factory()->forUser($user)->forClient($client)->create(); + + // Act + $token->refresh(); + $userRel = $token->user; + + // Assert + $this->assertNotNull($userRel); + $this->assertTrue($userRel->is($user)); + } + + public function test_scope_is_api_tokens_only_returns_api_tokens_with_no_parameters(): void + { + // Arrange + $clientApi = Client::factory()->apiClient()->create(); + $clientDesktop = Client::factory()->desktopClient()->create(); + $token1 = Token::factory()->forClient($clientApi)->create(); + $token2 = Token::factory()->forClient($clientDesktop)->create(); + + // Act + $apiTokens = Token::query() + ->isApiToken() + ->get(); + + // Assert + $this->assertCount(1, $apiTokens); + $this->assertTrue($apiTokens->first()->is($token1)); + } + + public function test_scope_is_api_tokens_only_returns_api_tokens_with_true(): void + { + // Arrange + $clientApi = Client::factory()->apiClient()->create(); + $clientDesktop = Client::factory()->desktopClient()->create(); + $token1 = Token::factory()->forClient($clientApi)->create(); + $token2 = Token::factory()->forClient($clientDesktop)->create(); + + // Act + $apiTokens = Token::query() + ->isApiToken(true) + ->get(); + + // Assert + $this->assertCount(1, $apiTokens); + $this->assertTrue($apiTokens->first()->is($token1)); + } + + public function test_scope_is_api_tokens_only_returns_api_tokens_with_false(): void + { + // Arrange + $clientApi = Client::factory()->apiClient()->create(); + $clientDesktop = Client::factory()->desktopClient()->create(); + $token1 = Token::factory()->forClient($clientApi)->create(); + $token2 = Token::factory()->forClient($clientDesktop)->create(); + + // Act + $apiTokens = Token::query() + ->isApiToken(false) + ->get(); + + // Assert + $this->assertCount(1, $apiTokens); + $this->assertTrue($apiTokens->first()->is($token2)); + } +}