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>
81 lines
2.7 KiB
PHP
81 lines
2.7 KiB
PHP
<?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);
|
|
});
|
|
});
|