feat(security): support expiration on API tokens with warning notifications
Add optional expiration to personal API tokens. Users pick a duration (1/7/30/60/90 days or Never) at creation time. Expired tokens are rejected by Sanctum, pruned hourly by sanctum:prune-expired, and a team notification fires ~24h before expiry so owners can rotate before API calls start failing. - ApiTokens Livewire component stores expires_at from expiresInDays - Rework issued-tokens UI from card grid to table (matches other views) - New ApiTokenExpirationWarningJob scheduled hourly (idempotent via RateLimiter) - New ApiTokenExpiringNotification (email/discord/telegram/slack/pushover) - api_token_expiring added to alwaysSendEvents so users cannot silence expiry warnings from the per-event notification toggle UI - sanctum:prune-expired cadence moved from daily to hourly Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
bff6d85370
commit
90ddbb3572
9 changed files with 419 additions and 35 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Console;
|
namespace App\Console;
|
||||||
|
|
||||||
|
use App\Jobs\ApiTokenExpirationWarningJob;
|
||||||
use App\Jobs\CheckForUpdatesJob;
|
use App\Jobs\CheckForUpdatesJob;
|
||||||
use App\Jobs\CheckHelperImageJob;
|
use App\Jobs\CheckHelperImageJob;
|
||||||
use App\Jobs\CheckTraefikVersionJob;
|
use App\Jobs\CheckTraefikVersionJob;
|
||||||
|
|
@ -41,6 +42,8 @@ protected function schedule(Schedule $schedule): void
|
||||||
|
|
||||||
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
||||||
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
||||||
|
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
|
||||||
|
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
// Instance Jobs
|
// Instance Jobs
|
||||||
|
|
|
||||||
49
app/Jobs/ApiTokenExpirationWarningJob.php
Normal file
49
app/Jobs/ApiTokenExpirationWarningJob.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\PersonalAccessToken;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\ApiTokenExpiringNotification;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Laravel\Horizon\Contracts\Silenced;
|
||||||
|
|
||||||
|
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $tries = 1;
|
||||||
|
|
||||||
|
public $timeout = 120;
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
PersonalAccessToken::query()
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '>', now())
|
||||||
|
->where('expires_at', '<=', now()->addDay())
|
||||||
|
->where('tokenable_type', User::class)
|
||||||
|
->chunkById(100, function ($tokens) {
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if (! $token->team_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
RateLimiter::attempt(
|
||||||
|
'api-token-expiring:'.$token->id,
|
||||||
|
$maxAttempts = 0,
|
||||||
|
function () use ($token) {
|
||||||
|
Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
|
||||||
|
},
|
||||||
|
$decaySeconds = 7 * 24 * 3600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,10 +13,20 @@ class ApiTokens extends Component
|
||||||
|
|
||||||
public ?string $description = null;
|
public ?string $description = null;
|
||||||
|
|
||||||
|
public ?int $expiresInDays = 30;
|
||||||
|
|
||||||
public $tokens = [];
|
public $tokens = [];
|
||||||
|
|
||||||
public array $permissions = ['read'];
|
public array $permissions = ['read'];
|
||||||
|
|
||||||
|
public array $expirationOptions = [
|
||||||
|
7 => '7 days',
|
||||||
|
30 => '30 days',
|
||||||
|
60 => '60 days',
|
||||||
|
90 => '90 days',
|
||||||
|
365 => '1 year',
|
||||||
|
];
|
||||||
|
|
||||||
public $isApiEnabled;
|
public $isApiEnabled;
|
||||||
|
|
||||||
public bool $canUseRootPermissions = false;
|
public bool $canUseRootPermissions = false;
|
||||||
|
|
@ -90,8 +100,10 @@ public function addNewToken()
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'description' => 'required|min:3|max:255',
|
'description' => 'required|min:3|max:255',
|
||||||
|
'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
|
||||||
]);
|
]);
|
||||||
$token = auth()->user()->createToken($this->description, array_values($this->permissions));
|
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
|
||||||
|
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
|
||||||
$this->getTokens();
|
$this->getTokens();
|
||||||
session()->flash('token', $token->plainTextToken);
|
session()->flash('token', $token->plainTextToken);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
|
||||||
103
app/Notifications/ApiTokenExpiringNotification.php
Normal file
103
app/Notifications/ApiTokenExpiringNotification.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\PersonalAccessToken;
|
||||||
|
use App\Notifications\Dto\DiscordMessage;
|
||||||
|
use App\Notifications\Dto\PushoverMessage;
|
||||||
|
use App\Notifications\Dto\SlackMessage;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
class ApiTokenExpiringNotification extends CustomEmailNotification
|
||||||
|
{
|
||||||
|
protected string $tokenName;
|
||||||
|
|
||||||
|
protected string $expiresAt;
|
||||||
|
|
||||||
|
protected string $manageUrl;
|
||||||
|
|
||||||
|
public function __construct(public PersonalAccessToken $token)
|
||||||
|
{
|
||||||
|
$this->onQueue('high');
|
||||||
|
$this->tokenName = $token->name;
|
||||||
|
$this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? '';
|
||||||
|
$this->manageUrl = route('security.api-tokens');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return $notifiable->getEnabledChannels('api_token_expiring');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(): MailMessage
|
||||||
|
{
|
||||||
|
$mail = new MailMessage;
|
||||||
|
$mail->subject("Coolify: API token '{$this->tokenName}' expires in 24 hours");
|
||||||
|
$mail->view('emails.api-token-expiring', [
|
||||||
|
'tokenName' => $this->tokenName,
|
||||||
|
'expiresAt' => $this->expiresAt,
|
||||||
|
'manageUrl' => $this->manageUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $mail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toDiscord(): DiscordMessage
|
||||||
|
{
|
||||||
|
$message = new DiscordMessage(
|
||||||
|
title: '🔑 API token expiring soon',
|
||||||
|
description: "API token **{$this->tokenName}** expires on {$this->expiresAt}.\n\n**Action Required:** Rotate this token before it expires to avoid API outages.",
|
||||||
|
color: DiscordMessage::warningColor(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$message->addField('Manage tokens', "[Open Security settings]({$this->manageUrl})");
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toTelegram(): array
|
||||||
|
{
|
||||||
|
$message = "Coolify: API token '{$this->tokenName}' expires on {$this->expiresAt}.\n\nAction Required: Rotate this token before it expires to avoid API outages.";
|
||||||
|
|
||||||
|
return [
|
||||||
|
'message' => $message,
|
||||||
|
'buttons' => [
|
||||||
|
[
|
||||||
|
'text' => 'Manage API tokens',
|
||||||
|
'url' => $this->manageUrl,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toPushover(): PushoverMessage
|
||||||
|
{
|
||||||
|
$message = "API token <b>{$this->tokenName}</b> expires on {$this->expiresAt}.<br/><br/>";
|
||||||
|
$message .= '<b>Action Required:</b> Rotate this token before it expires to avoid API outages.';
|
||||||
|
|
||||||
|
return new PushoverMessage(
|
||||||
|
title: 'API token expiring soon',
|
||||||
|
level: 'warning',
|
||||||
|
message: $message,
|
||||||
|
buttons: [
|
||||||
|
[
|
||||||
|
'text' => 'Manage API tokens',
|
||||||
|
'url' => $this->manageUrl,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toSlack(): SlackMessage
|
||||||
|
{
|
||||||
|
$description = "API token *{$this->tokenName}* expires on {$this->expiresAt}.\n\n";
|
||||||
|
$description .= "*Action Required:* Rotate this token before it expires to avoid API outages.\n\n";
|
||||||
|
$description .= "Manage tokens: {$this->manageUrl}";
|
||||||
|
|
||||||
|
return new SlackMessage(
|
||||||
|
title: '🔑 API token expiring soon',
|
||||||
|
description: $description,
|
||||||
|
color: SlackMessage::warningColor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ trait HasNotificationSettings
|
||||||
'test',
|
'test',
|
||||||
'ssl_certificate_renewal',
|
'ssl_certificate_renewal',
|
||||||
'hetzner_deletion_failure',
|
'hetzner_deletion_failure',
|
||||||
|
'api_token_expiring',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
7
resources/views/emails/api-token-expiring.blade.php
Normal file
7
resources/views/emails/api-token-expiring.blade.php
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<x-emails.layout>
|
||||||
|
Your Coolify API token ({{ $tokenName }}) expires on {{ $expiresAt }}.
|
||||||
|
|
||||||
|
Rotate this token before it expires. API calls using this token will start failing once the expiration time is reached.
|
||||||
|
|
||||||
|
Manage your API tokens [here]({{ $manageUrl }}).
|
||||||
|
</x-emails.layout>
|
||||||
|
|
@ -14,13 +14,19 @@
|
||||||
<h3>New Token</h3>
|
<h3>New Token</h3>
|
||||||
@can('create', App\Models\PersonalAccessToken::class)
|
@can('create', App\Models\PersonalAccessToken::class)
|
||||||
<form class="flex flex-col gap-2" wire:submit='addNewToken'>
|
<form class="flex flex-col gap-2" wire:submit='addNewToken'>
|
||||||
<div class="flex gap-2 items-end w-96">
|
<div class="flex gap-2 items-end w-lg">
|
||||||
<x-forms.input required id="description" label="Description" />
|
<x-forms.input class="w-64" required id="description" label="Description" />
|
||||||
|
<x-forms.select id="expiresInDays" label="Expires in" wire:model="expiresInDays">
|
||||||
|
@foreach ($expirationOptions as $days => $label)
|
||||||
|
<option value="{{ $days }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
<option value="">Never</option>
|
||||||
|
</x-forms.select>
|
||||||
<x-forms.button type="submit">Create</x-forms.button>
|
<x-forms.button type="submit">Create</x-forms.button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
Permissions
|
Permissions
|
||||||
<x-helper class="px-1" helper="These permissions will be granted to the token." /><span
|
<span
|
||||||
class="pr-1">:</span>
|
class="pr-1">:</span>
|
||||||
<div class="flex gap-1 font-bold dark:text-white">
|
<div class="flex gap-1 font-bold dark:text-white">
|
||||||
@if ($permissions)
|
@if ($permissions)
|
||||||
|
|
@ -31,7 +37,6 @@ class="pr-1">:</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>Token Permissions</h4>
|
|
||||||
<div class="w-64">
|
<div class="w-64">
|
||||||
@if ($canUseRootPermissions)
|
@if ($canUseRootPermissions)
|
||||||
<x-forms.checkbox label="root" wire:model.live="permissions" domValue="root"
|
<x-forms.checkbox label="root" wire:model.live="permissions" domValue="root"
|
||||||
|
|
@ -71,38 +76,78 @@ class="pr-1">:</span>
|
||||||
<div class="pb-4 font-bold dark:text-white"> {{ session('token') }}</div>
|
<div class="pb-4 font-bold dark:text-white"> {{ session('token') }}</div>
|
||||||
@endif
|
@endif
|
||||||
<h3 class="py-4">Issued Tokens</h3>
|
<h3 class="py-4">Issued Tokens</h3>
|
||||||
<div class="grid gap-2 lg:grid-cols-1">
|
<div class="flex flex-col">
|
||||||
@forelse ($tokens as $token)
|
<div class="flex flex-col">
|
||||||
<div wire:key="token-{{ $token->id }}"
|
<div class="overflow-x-auto">
|
||||||
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
|
<div class="inline-block min-w-full">
|
||||||
<div>Description: {{ $token->name }}</div>
|
<div class="overflow-hidden">
|
||||||
<div>Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}</div>
|
<table class="min-w-full">
|
||||||
<div class="flex gap-1">
|
<thead>
|
||||||
@if ($token->abilities)
|
<tr>
|
||||||
Permissions:
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Description</th>
|
||||||
@foreach ($token->abilities as $ability)
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Permissions</th>
|
||||||
<div class="font-bold dark:text-white">{{ $ability }}</div>
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Last used</th>
|
||||||
@endforeach
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Created</th>
|
||||||
@endif
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Expires</th>
|
||||||
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($tokens as $token)
|
||||||
|
<tr wire:key="token-{{ $token->id }}">
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ $token->name }}</td>
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||||
|
@if ($token->abilities)
|
||||||
|
<div class="flex gap-1">
|
||||||
|
@foreach ($token->abilities as $ability)
|
||||||
|
<div class="font-bold dark:text-white">{{ $ability }}</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||||
|
{{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||||
|
{{ $token->created_at->diffForHumans() }}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||||
|
@if (! $token->expires_at)
|
||||||
|
Never
|
||||||
|
@elseif ($token->expires_at->isPast())
|
||||||
|
<span class="font-bold dark:text-error">Expired
|
||||||
|
{{ $token->expires_at->format('Y-m-d H:i:s') }}</span>
|
||||||
|
@else
|
||||||
|
{{ $token->expires_at->format('Y-m-d H:i:s') }}
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4 text-sm font-medium whitespace-nowrap">
|
||||||
|
@if (auth()->id() === $token->tokenable_id)
|
||||||
|
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton
|
||||||
|
buttonTitle="Revoke token"
|
||||||
|
submitAction="revoke({{ data_get($token, 'id') }})" :actions="[
|
||||||
|
'This API Token will be revoked and permanently deleted.',
|
||||||
|
'Any API call made with this token will fail.',
|
||||||
|
]"
|
||||||
|
confirmationText="{{ $token->name }}"
|
||||||
|
confirmationLabel="Please confirm the execution of the actions by entering the API Token Description below"
|
||||||
|
shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
|
||||||
|
step2ButtonText="Revoke API Token" />
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap" colspan="6">No API tokens found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (auth()->id() === $token->tokenable_id)
|
|
||||||
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton buttonTitle="Revoke token"
|
|
||||||
submitAction="revoke({{ data_get($token, 'id') }})" :actions="[
|
|
||||||
'This API Token will be revoked and permanently deleted.',
|
|
||||||
'Any API call made with this token will fail.',
|
|
||||||
]"
|
|
||||||
confirmationText="{{ $token->name }}"
|
|
||||||
confirmationLabel="Please confirm the execution of the actions by entering the API Token Description below"
|
|
||||||
shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
|
|
||||||
step2ButtonText="Revoke API Token" />
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
@empty
|
</div>
|
||||||
<div>
|
|
||||||
<div>No API tokens found.</div>
|
|
||||||
</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
81
tests/Feature/ApiTokenExpirationTest.php
Normal file
81
tests/Feature/ApiTokenExpirationTest.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Security\ApiTokens;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('token creation with expiration', function () {
|
||||||
|
test('livewire component stores expires_at when expiresInDays set', function () {
|
||||||
|
Livewire::test(ApiTokens::class)
|
||||||
|
->set('description', 'test-token')
|
||||||
|
->set('expiresInDays', 7)
|
||||||
|
->set('permissions', ['read'])
|
||||||
|
->call('addNewToken')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$token = $this->user->tokens()->latest()->first();
|
||||||
|
|
||||||
|
expect($token)->not->toBeNull()
|
||||||
|
->and($token->expires_at)->not->toBeNull()
|
||||||
|
->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
|
||||||
|
->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
|
||||||
|
Livewire::test(ApiTokens::class)
|
||||||
|
->set('description', 'never-token')
|
||||||
|
->set('expiresInDays', null)
|
||||||
|
->set('permissions', ['read'])
|
||||||
|
->call('addNewToken')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$token = $this->user->tokens()->latest()->first();
|
||||||
|
|
||||||
|
expect($token)->not->toBeNull()
|
||||||
|
->and($token->expires_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('livewire component rejects invalid expiresInDays value', function () {
|
||||||
|
Livewire::test(ApiTokens::class)
|
||||||
|
->set('description', 'bad-token')
|
||||||
|
->set('expiresInDays', 42)
|
||||||
|
->set('permissions', ['read'])
|
||||||
|
->call('addNewToken')
|
||||||
|
->assertHasErrors('expiresInDays');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expired token rejected on API', function () {
|
||||||
|
test('request with expired token returns 401', function () {
|
||||||
|
$token = $this->user->createToken('expired', ['read'], now()->subDay());
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||||
|
])->getJson('/api/v1/projects');
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('request with non-expired token works', function () {
|
||||||
|
$token = $this->user->createToken('valid', ['read'], now()->addDay());
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||||
|
])->getJson('/api/v1/projects');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
83
tests/Feature/ApiTokenExpirationWarningTest.php
Normal file
83
tests/Feature/ApiTokenExpirationWarningTest.php
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\ApiTokenExpirationWarningJob;
|
||||||
|
use App\Models\PersonalAccessToken;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\ApiTokenExpiringNotification;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||||
|
$this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]);
|
||||||
|
$this->team->discordNotificationSettings()->update([
|
||||||
|
'discord_enabled' => true,
|
||||||
|
'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
Notification::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
|
||||||
|
{
|
||||||
|
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
|
||||||
|
$token = $plain->accessToken;
|
||||||
|
$token->team_id = $team->id;
|
||||||
|
$token->save();
|
||||||
|
|
||||||
|
return $token->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ApiTokenExpirationWarningJob', function () {
|
||||||
|
test('notifies team when token expires within 24h', function () {
|
||||||
|
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rate limiter prevents duplicate warnings on repeat runs', function () {
|
||||||
|
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips tokens expiring more than 24h out', function () {
|
||||||
|
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips already-expired tokens', function () {
|
||||||
|
createTokenExpiring($this->user, $this->team, now()->subHour());
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips tokens with null expires_at', function () {
|
||||||
|
createTokenExpiring($this->user, $this->team, null);
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue