Files
filament-short-url/tests/Feature/WorldClassHardeningTest.php

254 lines
7.7 KiB
PHP

<?php
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Bjanczak\FilamentShortUrl\Models\ShortUrlTag;
use Bjanczak\FilamentShortUrl\Services\BotDetector;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function worldClassCreateLink(array $overrides = []): ShortUrl
{
return ShortUrl::create(array_merge([
'destination_url' => 'https://example.com',
'url_key' => 'wc-'.bin2hex(random_bytes(4)),
'is_enabled' => true,
'track_visits' => true,
], $overrides));
}
it('escapes pixel interstitial destination with json encoding', function () {
$pixel = ShortUrlPixel::create([
'name' => 'Meta',
'type' => 'meta',
'pixel_id' => '123',
'is_active' => true,
]);
$link = worldClassCreateLink([
'url_key' => 'xss-pixel',
'destination_url' => 'https://example.com/?x=";alert(1);//',
]);
$link->pixels()->attach($pixel->id);
$response = $this->get('/s/xss-pixel');
expect($response->getContent())
->toContain('window.location.replace(')
->toContain('https://example.com/?x=')
->not->toContain('addslashes');
});
it('rejects api keys without owner_user_id when link scoping is enabled', function () {
config(['filament-short-url.scope_links_to_user' => true]);
app(ShortUrlSettingsManager::class)->set([
'api_enabled' => true,
'api_keys' => [[
'name' => 'Unscoped',
'key' => 'sh_key_unscoped',
'is_active' => true,
]],
]);
$this->getJson('/api/short-url/links', ['X-Api-Key' => 'sh_key_unscoped'])
->assertForbidden();
});
it('scopes api list results to the key owner when link scoping is enabled', function () {
config(['filament-short-url.scope_links_to_user' => true]);
app(ShortUrlSettingsManager::class)->set([
'api_enabled' => true,
'api_keys' => [[
'name' => 'Owner',
'key' => 'sh_key_owner',
'is_active' => true,
'owner_user_id' => 42,
]],
]);
worldClassCreateLink(['url_key' => 'mine', 'user_id' => 42]);
worldClassCreateLink(['url_key' => 'theirs', 'user_id' => 99]);
$this->getJson('/api/short-url/links', ['X-Api-Key' => 'sh_key_owner'])
->assertOk()
->assertJsonPath('meta.total', 1)
->assertJsonPath('data.0.url_key', 'mine');
});
it('rejects foreign folder assignment via api when scoped to owner', function () {
config(['filament-short-url.scope_links_to_user' => true]);
app(ShortUrlSettingsManager::class)->set([
'api_enabled' => true,
'api_keys' => [[
'name' => 'Owner',
'key' => 'sh_key_folder',
'is_active' => true,
'owner_user_id' => 7,
]],
]);
$foreignFolder = ShortUrlFolder::create([
'name' => 'Foreign',
'slug' => 'foreign',
'user_id' => 99,
]);
$this->postJson('/api/short-url/links', [
'destination_url' => 'https://example.com',
'url_key' => 'folder-idor',
'folder_id' => $foreignFolder->id,
], ['X-Api-Key' => 'sh_key_folder'])
->assertStatus(422)
->assertJsonValidationErrors(['folder_id']);
});
it('rejects foreign tag assignment via api when scoped to owner', function () {
config(['filament-short-url.scope_links_to_user' => true]);
app(ShortUrlSettingsManager::class)->set([
'api_enabled' => true,
'api_keys' => [[
'name' => 'Owner',
'key' => 'sh_key_tags',
'is_active' => true,
'owner_user_id' => 8,
]],
]);
$foreignTag = ShortUrlTag::create([
'name' => 'Foreign',
'slug' => 'foreign',
'user_id' => 99,
]);
$this->postJson('/api/short-url/links', [
'destination_url' => 'https://example.com',
'url_key' => 'tag-idor',
'tag_ids' => [$foreignTag->id],
], ['X-Api-Key' => 'sh_key_tags'])
->assertStatus(422)
->assertJsonValidationErrors(['tag_ids.0']);
});
it('does not consume single_use links for social preview bots', function () {
$link = worldClassCreateLink([
'url_key' => 'su-bot',
'single_use' => true,
'track_visits' => false,
]);
$this->mock(BotDetector::class, function ($mock) {
$mock->shouldReceive('isBot')->andReturn(true);
});
$this->get('/s/su-bot', ['User-Agent' => 'facebookexternalhit/1.1'])
->assertRedirect('https://example.com');
expect($link->fresh()->is_enabled)->toBeTrue();
});
it('enforces max_visits when track_visits is disabled', function () {
config(['filament-short-url.queue_connection' => 'sync']);
$link = worldClassCreateLink([
'url_key' => 'max-no-track',
'max_visits' => 1,
'track_visits' => false,
]);
$this->get('/s/max-no-track')->assertRedirect('https://example.com');
$this->get('/s/max-no-track')->assertStatus(410);
expect($link->fresh()->total_visits)->toBe(1);
});
it('does not double count buffered visits when dirty id registration falls back to db', function () {
config([
'filament-short-url.counter_buffering.enabled' => true,
'filament-short-url.queue_connection' => 'sync',
]);
$link = worldClassCreateLink(['url_key' => 'buffer-fallback']);
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
cache()->put("{$prefix}dirty_ids", array_fill(0, 50000, 999999), 3600);
$this->get('/s/buffer-fallback')->assertRedirect('https://example.com');
expect($link->fresh()->total_visits)->toBe(1)
->and((int) cache()->get("{$prefix}total:{$link->id}", 0))->toBe(0);
});
it('uses atomic max_visits reservation under concurrent redirects', function () {
config([
'filament-short-url.queue_connection' => 'sync',
'filament-short-url.cache_ttl' => 0,
]);
worldClassCreateLink([
'url_key' => 'wc-burst',
'max_visits' => 2,
'track_visits' => true,
]);
$redirects = 0;
$blocked = 0;
for ($i = 0; $i < 6; $i++) {
$response = $this->get('/s/wc-burst');
if ($response->isRedirect()) {
$redirects++;
} elseif ($response->status() === 410) {
$blocked++;
}
}
expect($redirects)->toBeLessThanOrEqual(2)
->and($redirects + $blocked)->toBe(6)
->and((int) ShortUrl::where('url_key', 'wc-burst')->value('total_visits'))->toBeLessThanOrEqual(2);
});
it('returns the same public stats 404 for unknown keys and disabled stats', function () {
worldClassCreateLink([
'url_key' => 'hidden-stats',
'public_stats_enabled' => false,
]);
$unknown = $this->getJson('/s/public-stats/missing-key');
$disabled = $this->getJson('/s/public-stats/hidden-stats');
expect($unknown->status())->toBe(404)
->and($disabled->status())->toBe(404);
});
it('limits public stats payload to a safe subset', function () {
$link = worldClassCreateLink([
'url_key' => 'public-stats',
'public_stats_enabled' => true,
]);
$response = $this->getJson('/s/public-stats/'.$link->url_key);
$response->assertOk()
->assertJsonStructure([
'data' => [
'totalVisits',
'uniqueVisits',
'visitsToday',
'visitsThisWeek',
'visitsThisMonth',
'visitsByDay',
'qrScans',
],
])
->assertJsonMissingPath('data.visitsByReferer')
->assertJsonMissingPath('data.utmSources');
});