From 43837a99db8d3433808f86ccb55cd54f70a3f1e9 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 26 Apr 2026 11:58:51 +0200 Subject: [PATCH] 5 - Add UrlSubmissionForm Livewire component with rate limiting --- app/Livewire/UrlSubmissionForm.php | 48 ++++++ resources/views/components/layout.blade.php | 18 +++ .../livewire/url-submission-form.blade.php | 14 ++ resources/views/urls/submit.blade.php | 3 + routes/web.php | 4 + tests/Feature/UrlSubmissionTest.php | 145 ++++++++++++++++++ 6 files changed, 232 insertions(+) create mode 100644 app/Livewire/UrlSubmissionForm.php create mode 100644 resources/views/components/layout.blade.php create mode 100644 resources/views/livewire/url-submission-form.blade.php create mode 100644 resources/views/urls/submit.blade.php create mode 100644 tests/Feature/UrlSubmissionTest.php diff --git a/app/Livewire/UrlSubmissionForm.php b/app/Livewire/UrlSubmissionForm.php new file mode 100644 index 0000000..bbf2000 --- /dev/null +++ b/app/Livewire/UrlSubmissionForm.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/resources/views/components/layout.blade.php b/resources/views/components/layout.blade.php new file mode 100644 index 0000000..5100c31 --- /dev/null +++ b/resources/views/components/layout.blade.php @@ -0,0 +1,18 @@ + + + + + + + {{ $title ?? config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + @livewireStyles + + + {{ $slot }} + + @livewireScripts + + diff --git a/resources/views/livewire/url-submission-form.blade.php b/resources/views/livewire/url-submission-form.blade.php new file mode 100644 index 0000000..49da751 --- /dev/null +++ b/resources/views/livewire/url-submission-form.blade.php @@ -0,0 +1,14 @@ +
+ @error('rate_limit')

{{ $message }}

@enderror + + @if ($confirmedUrl !== null) +

Thanks, we've received {{ $confirmedUrl }}

+ @else +
+ + + @error('url')

{{ $message }}

@enderror + +
+ @endif +
diff --git a/resources/views/urls/submit.blade.php b/resources/views/urls/submit.blade.php new file mode 100644 index 0000000..266ab36 --- /dev/null +++ b/resources/views/urls/submit.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/routes/web.php b/routes/web.php index 86a06c5..5f96afc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,11 @@ 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); + } +}