mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
Add email notifications for expiring api tokens
This commit is contained in:
committed by
Constantin Graf
parent
3d58f570bd
commit
f9c0d64f82
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Auth;
|
||||
|
||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
||||
use App\Mail\AuthApiTokenExpiredMail;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class AuthSendReminderForExpiringApiTokensCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'auth:send-mails-expiring-api-tokens '.
|
||||
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->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<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->isApiToken(true)
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, Token> $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<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->isApiToken(true)
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, Token> $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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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}'),
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->whereJsonContains('grant_types', 'personal_access');
|
||||
});
|
||||
return $query->isApiToken();
|
||||
},
|
||||
false: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->whereJsonDoesntContain('grant_types', 'personal_access');
|
||||
});
|
||||
return $query->isApiToken(false);
|
||||
},
|
||||
blank: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AuthApiTokenExpirationReminderMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Token $token;
|
||||
|
||||
public User $user;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Token $token, User $user)
|
||||
{
|
||||
$this->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!'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AuthApiTokenExpiredMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Token $token;
|
||||
|
||||
public User $user;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Token $token, User $user)
|
||||
{
|
||||
$this->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!'));
|
||||
}
|
||||
}
|
||||
@@ -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<string> $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<Token> 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<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scopes' => 'array',
|
||||
'revoked' => 'bool',
|
||||
'expires_at' => 'datetime',
|
||||
'reminder_sent_at' => 'datetime',
|
||||
'expired_info_sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
|
||||
{
|
||||
if ($isApiToken) {
|
||||
return $query->whereHas('client', function (Builder $query): void {
|
||||
/** @var Builder<Client> $query */
|
||||
$query->whereJsonContains('grant_types', 'personal_access');
|
||||
});
|
||||
} else {
|
||||
return $query->whereHas('client', function (Builder $query): void {
|
||||
/** @var Builder<Client> $query */
|
||||
$query->whereJsonDoesntContain('grant_types', 'personal_access');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Console\Commands\Auth;
|
||||
|
||||
use App\Console\Commands\Auth\AuthSendReminderForExpiringApiTokensCommand;
|
||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
||||
use App\Mail\AuthApiTokenExpiredMail;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
#[CoversClass(AuthSendReminderForExpiringApiTokensCommand::class)]
|
||||
class AuthSendReminderForExpiringApiTokensCommandTest extends TestCaseWithDatabase
|
||||
{
|
||||
public function test_sends_mail_for_expired_api_tokens_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();
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Mail;
|
||||
|
||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
#[CoversClass(AuthApiTokenExpirationReminderMail::class)]
|
||||
class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase
|
||||
{
|
||||
public function test_mail_renders_content_correctly(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Mail;
|
||||
|
||||
use App\Mail\AuthApiTokenExpiredMail;
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
#[CoversClass(AuthApiTokenExpiredMail::class)]
|
||||
class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase
|
||||
{
|
||||
public function test_mail_renders_content_correctly(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Model\Passport;
|
||||
|
||||
use App\Models\Passport\Client;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tests\Unit\Model\ModelTestAbstract;
|
||||
|
||||
#[CoversClass(Token::class)]
|
||||
class TokenModelTest extends ModelTestAbstract
|
||||
{
|
||||
public function test_it_belongs_to_a_client(): void
|
||||
{
|
||||
// Arrange
|
||||
$client = Client::factory()->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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user