Add email notifications for expiring api tokens

This commit is contained in:
Constantin Graf
2025-07-17 15:33:06 +02:00
committed by Constantin Graf
parent 3d58f570bd
commit f9c0d64f82
18 changed files with 618 additions and 16 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}