5 - Add UrlSubmissionForm Livewire component with rate limiting
This commit is contained in:
parent
b0a4102637
commit
43837a99db
6 changed files with 232 additions and 0 deletions
48
app/Livewire/UrlSubmissionForm.php
Normal file
48
app/Livewire/UrlSubmissionForm.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Enums\PageStatusEnum;
|
||||
use App\Models\Page;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Component;
|
||||
|
||||
class UrlSubmissionForm extends Component
|
||||
{
|
||||
public string $url = '';
|
||||
|
||||
public ?string $confirmedUrl = null;
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$key = 'submit-url:' . request()->ip();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$this->addError('rate_limit', 'Too many submissions, try again shortly.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
$validated = $this->validate([
|
||||
'url' => ['required', 'url:http,https'],
|
||||
]);
|
||||
|
||||
Page::firstOrCreate(
|
||||
['url' => $validated['url']],
|
||||
['status' => PageStatusEnum::Discovered],
|
||||
);
|
||||
|
||||
$this->confirmedUrl = $validated['url'];
|
||||
$this->reset('url');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.url-submission-form');
|
||||
}
|
||||
}
|
||||
18
resources/views/components/layout.blade.php
Normal file
18
resources/views/components/layout.blade.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>{{ $title ?? config('app.name') }}</title>
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body>
|
||||
{{ $slot }}
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
14
resources/views/livewire/url-submission-form.blade.php
Normal file
14
resources/views/livewire/url-submission-form.blade.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<div>
|
||||
@error('rate_limit') <p>{{ $message }}</p> @enderror
|
||||
|
||||
@if ($confirmedUrl !== null)
|
||||
<p>Thanks, we've received <strong>{{ $confirmedUrl }}</strong></p>
|
||||
@else
|
||||
<form wire:submit="submit">
|
||||
<label for="url">URL</label>
|
||||
<input id="url" type="url" wire:model="url" required>
|
||||
@error('url') <p>{{ $message }}</p> @enderror
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
3
resources/views/urls/submit.blade.php
Normal file
3
resources/views/urls/submit.blade.php
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<x-layout>
|
||||
<livewire:url-submission-form />
|
||||
</x-layout>
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
|
||||
Route::view('/submit', 'urls.submit');
|
||||
|
|
|
|||
145
tests/Feature/UrlSubmissionTest.php
Normal file
145
tests/Feature/UrlSubmissionTest.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\PageStatusEnum;
|
||||
use App\Livewire\UrlSubmissionForm;
|
||||
use App\Models\Page;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UrlSubmissionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 1 — route renders the submission form
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_submission_form_renders_at_public_route(): void
|
||||
{
|
||||
$response = $this->get('/submit');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSeeLivewire('url-submission-form');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 2 — valid submission creates a page row as Discovered
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_valid_url_submission_creates_page_as_discovered(): void
|
||||
{
|
||||
Livewire::test(UrlSubmissionForm::class)
|
||||
->set('url', 'https://example.com/interesting-post')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('pages', [
|
||||
'url' => 'https://example.com/interesting-post',
|
||||
'status' => PageStatusEnum::Discovered,
|
||||
'instance_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 3 — duplicate submission is idempotent (no second row created)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_duplicate_url_submission_does_not_create_second_page(): void
|
||||
{
|
||||
$url = 'https://example.com/seen-before';
|
||||
|
||||
Page::factory()->create([
|
||||
'url' => $url,
|
||||
'status' => PageStatusEnum::Discovered,
|
||||
]);
|
||||
|
||||
Livewire::test(UrlSubmissionForm::class)
|
||||
->set('url', $url)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseCount('pages', 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 4 — confirmation state echoes submitted URL
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_confirmation_state_echoes_submitted_url(): void
|
||||
{
|
||||
$url = 'https://example.com/great-article';
|
||||
|
||||
Livewire::test(UrlSubmissionForm::class)
|
||||
->set('url', $url)
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('confirmedUrl', $url)
|
||||
->assertSet('url', '')
|
||||
->assertSee($url);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 5 — empty URL fails validation (regression lock)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_missing_url_fails_validation(): void
|
||||
{
|
||||
Livewire::test(UrlSubmissionForm::class)
|
||||
->set('url', '')
|
||||
->call('submit')
|
||||
->assertHasErrors(['url' => 'required']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 6 — invalid URL formats fail validation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[DataProvider('invalidUrls')]
|
||||
public function test_invalid_url_formats_fail_validation(string $url): void
|
||||
{
|
||||
Livewire::test(UrlSubmissionForm::class)
|
||||
->set('url', $url)
|
||||
->call('submit')
|
||||
->assertHasErrors('url');
|
||||
}
|
||||
|
||||
public static function invalidUrls(): array
|
||||
{
|
||||
return [
|
||||
'no scheme' => ['not-a-url'],
|
||||
'disallowed scheme' => ['ftp://example.com'],
|
||||
'javascript scheme' => ['javascript:alert(1)'],
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 7 — rate limit blocks the 11th submission within a minute
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function test_rate_limit_blocks_eleventh_submission_within_a_minute(): void
|
||||
{
|
||||
// 10 submissions within the limit — each must succeed
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
Livewire::test(UrlSubmissionForm::class)
|
||||
->set('url', "https://example.com/post-{$i}")
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
}
|
||||
|
||||
// 11th submission from the same IP must be blocked, with the message visible
|
||||
Livewire::test(UrlSubmissionForm::class)
|
||||
->set('url', 'https://example.com/post-11')
|
||||
->call('submit')
|
||||
->assertHasErrors('rate_limit')
|
||||
->assertSee('Too many submissions');
|
||||
|
||||
// The 11th URL must NOT have been persisted
|
||||
$this->assertDatabaseCount('pages', 10);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue