Files
filament-short-url/tests/Feature/ShortUrlApiKeyScopingTest.php
Bartłomiej Janczak 2107a3e6c8 feat: v5.0.0 — Live Feed page, Folders & Tags, Link Archiving, API scopes
## Analytics — Live Activity Feed
- Dedicated /stats/live standalone page (mirrors /stats/logs pattern)
- Consistent 3-tab navigation across Statistics / Live Feed / Visit Logs
- checkForUpdates() with skipRender(): O(1) MAX(id) poll — ~100-byte response on no-change
- latestVisitId sentinel (-1) prevents unnecessary first-poll re-render
- Cache key versioned by latestVisitId — fixes bug where concurrent users
  could permanently miss a visit due to stale thundering-herd cache
- 3-second thundering-herd cache (key: url_id + date + filters + latestVisitId)
- Country flags via flagcdn.com (precomputed in PHP, Alpine.js CSP-safe onerror)
- time_ago precomputed in PHP — no Carbon::parse() double-instantiation in Blade
- SELECT only 12 needed columns instead of SELECT *; limit 25 rows
- wire:poll.5s.visible — polling stops when widget is off-screen

## Link Organization
- Folders: one folder per link, clickable → filtered link list, link count badge
- Tags: up to 5 tags per link, clickable → filtered link list, link count badge
- Archiving: soft-archive links instead of permanent deletion; restorable

## REST API
- Per-key API scopes: links:read-only (GET) / links:read-write (full CRUD)
- Per-key rate limiting: individual requests/minute per API key

## Translations
- Added stats_live_feed_poll_interval (EN: 'Updates every 5s', PL: 'Aktualizacja co 5s')

## Docs
- README.md: corrected API scopes/rate-limiting docs (features were already implemented)
- README.md: added Folders, Tags, Archiving to features list
- README.md: added v5.0.0 changelog entry
2026-06-06 02:16:07 +02:00

141 lines
4.8 KiB
PHP

<?php
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Illuminate\Support\Facades\RateLimiter;
beforeEach(function () {
// Enable REST API and inject mock keys with different scopes and rate limits
app(ShortUrlSettingsManager::class)->set([
'api_enabled' => true,
'api_keys' => [
[
'name' => 'Read Only Token',
'key' => 'sh_key_read_only',
'scope' => 'links:read-only',
'rate_limit' => '60',
'is_active' => true,
],
[
'name' => 'Read Write Token',
'key' => 'sh_key_read_write',
'scope' => 'links:read-write',
'rate_limit' => '0', // Unlimited
'is_active' => true,
],
[
'name' => 'Rate Limited Token',
'key' => 'sh_key_rate_limited',
'scope' => 'links:read-write',
'rate_limit' => '2', // 2 per minute
'is_active' => true,
],
[
'name' => 'Legacy Token',
'key' => 'sh_key_legacy',
// No scope, no rate_limit (should default to links:read-write and 60 rpm)
'is_active' => true,
],
],
]);
// Clear rate limiter cache before each test
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_rate_limited'));
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_read_only'));
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_read_write'));
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_legacy'));
});
it('allows GET requests but blocks modifying requests with read-only scope', function () {
$shortUrl = ShortUrl::create([
'destination_url' => 'https://example.com/scope-test',
'url_key' => 'scopetest',
]);
// GET should work
$this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_read_only',
])->assertStatus(200);
// POST should be blocked
$this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'googleapi',
], [
'X-Api-Key' => 'sh_key_read_only',
])->assertStatus(403)
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
// PUT should be blocked
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
'destination_url' => 'https://updated-destination.com',
], [
'X-Api-Key' => 'sh_key_read_only',
])->assertStatus(403)
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
// DELETE should be blocked
$this->deleteJson("/api/short-url/links/{$shortUrl->id}", [], [
'X-Api-Key' => 'sh_key_read_only',
])->assertStatus(403)
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
});
it('allows modifying requests with read-write scope', function () {
$shortUrl = ShortUrl::create([
'destination_url' => 'https://example.com/scope-test',
'url_key' => 'scopetest',
]);
// PUT should work
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
'destination_url' => 'https://updated-destination.com',
], [
'X-Api-Key' => 'sh_key_read_write',
])->assertStatus(200);
});
it('defaults legacy tokens to read-write and 60 rpm', function () {
$shortUrl = ShortUrl::create([
'destination_url' => 'https://example.com/scope-test',
'url_key' => 'scopetest',
]);
// Legacy token has no scope, so should default to read-write and succeed
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
'destination_url' => 'https://updated-destination.com',
], [
'X-Api-Key' => 'sh_key_legacy',
])->assertStatus(200);
});
it('enforces dynamic rate limiting per API key', function () {
// Request 1: Allowed
$this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_rate_limited',
])->assertStatus(200);
// Request 2: Allowed
$this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_rate_limited',
])->assertStatus(200);
// Request 3: Blocked
$response = $this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_rate_limited',
]);
$response->assertStatus(429)
->assertJsonFragment(['error' => 'Too many requests. API key rate limit exceeded.'])
->assertHeader('Retry-After');
});
it('does not limit unlimited keys (rate_limit = 0)', function () {
// Run multiple requests to exceed standard limit
for ($i = 0; $i < 5; $i++) {
$this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_read_write',
])->assertStatus(200);
}
});