## 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
141 lines
4.8 KiB
PHP
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);
|
|
}
|
|
});
|