From da1324adda976ea4657cd954183764a4d417c936 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 17 Jan 2026 21:18:55 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=93=EF=B8=8F=20Add=20`MeetupEventFacto?= =?UTF-8?q?ry`,=20implement=20rate=20limiting=20for=20calendar=20downloads?= =?UTF-8?q?,=20and=20enhance=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Added:** `MeetupEventFactory` for generating test data. - **Implemented:** Rate limiting (`throttle:calendar`) for `stream-calendar` routes to prevent abuse. - **Enhanced:** `DownloadMeetupCalendar` controller with validation and cleaner query structure. - **Added:** Feature tests for calendar downloading, invalid input handling, and rate limiting. --- .../Controllers/DownloadMeetupCalendar.php | 48 +++++++------- app/Providers/AppServiceProvider.php | 15 +++++ database/factories/MeetupEventFactory.php | 30 +++++++++ routes/web.php | 6 +- tests/Feature/DownloadMeetupCalendarTest.php | 65 +++++++++++++++++++ 5 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 database/factories/MeetupEventFactory.php create mode 100644 tests/Feature/DownloadMeetupCalendarTest.php diff --git a/app/Http/Controllers/DownloadMeetupCalendar.php b/app/Http/Controllers/DownloadMeetupCalendar.php index 66aba9c..fb12aaa 100644 --- a/app/Http/Controllers/DownloadMeetupCalendar.php +++ b/app/Http/Controllers/DownloadMeetupCalendar.php @@ -17,46 +17,50 @@ class DownloadMeetupCalendar extends Controller public function __invoke(Request $request): Response { if ($request->has('meetup')) { + $validated = $request->validate([ + 'meetup' => ['required', 'integer'], + ]); + $meetup = Meetup::query() - ->with([ - 'meetupEvents.meetup', - ]) - ->findOrFail($request->input('meetup')); + ->with([ + 'meetupEvents.meetup', + ]) + ->findOrFail($validated['meetup']); $events = $meetup->meetupEvents()->where('start', '>=', now())->get(); $image = $meetup->getFirstMediaUrl('logo'); } elseif ($request->has('my')) { $ids = $request->input('my'); $events = MeetupEvent::query() - ->with([ - 'meetup', - ]) - ->where('start', '>=', now()) - ->whereHas('meetup', fn($query) => $query->whereIn('meetups.id', $ids)) - ->get(); + ->with([ + 'meetup', + ]) + ->where('start', '>=', now()) + ->whereHas('meetup', fn ($query) => $query->whereIn('meetups.id', $ids)) + ->get(); $image = asset('img/einundzwanzig-horizontal.png'); } else { $events = MeetupEvent::query() - ->with([ - 'meetup', - ]) - ->where('start', '>=', now()) - ->get(); + ->with([ + 'meetup', + ]) + ->where('start', '>=', now()) + ->get(); $image = asset('img/einundzwanzig-horizontal.png'); } $entries = []; foreach ($events as $event) { $entries[] = Event::create($event->meetup->name) - ->uniqueIdentifier(str($event->meetup->name)->slug().$event->id) - ->address($event->location ?? __('no location set')) - ->description(str_replace(["\r", "\n"], '', $event->description).' Link: '.$event->link) - ->image($event->meetup->getFirstMedia('logo') ? $event->meetup->getFirstMediaUrl('logo') : $image) - ->startsAt($event->start); + ->uniqueIdentifier(str($event->meetup->name)->slug().$event->id) + ->address($event->location ?? __('no location set')) + ->description(str_replace(["\r", "\n"], '', $event->description).' Link: '.$event->link) + ->image($event->meetup->getFirstMedia('logo') ? $event->meetup->getFirstMediaUrl('logo') : $image) + ->startsAt($event->start); } $calendar = Calendar::create() - ->refreshInterval(5) - ->event($entries); + ->refreshInterval(5) + ->event($entries); return response($calendar->get()) ->header('Content-Type', 'text/calendar; charset=utf-8'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 01c7bcc..7fbef57 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,11 +3,14 @@ namespace App\Providers; use App\Support\Carbon; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Events\DiagnosingHealth; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Laravel\Nightwatch\Facades\Nightwatch; @@ -31,6 +34,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + $this->configureRateLimiting(); + Livewire::setUpdateRoute(function ($handle) { return Route::post('/livewire/update', $handle) ->middleware(['web', Sample::rate(0)]); @@ -46,4 +51,14 @@ class AppServiceProvider extends ServiceProvider Model::preventLazyLoading(app()->environment('local')); } + + /** + * Configure the rate limiters for the application. + */ + protected function configureRateLimiting(): void + { + RateLimiter::for('calendar', function (Request $request) { + return Limit::perMinute(60)->by($request->ip()); + }); + } } diff --git a/database/factories/MeetupEventFactory.php b/database/factories/MeetupEventFactory.php new file mode 100644 index 0000000..85401ee --- /dev/null +++ b/database/factories/MeetupEventFactory.php @@ -0,0 +1,30 @@ + + */ +class MeetupEventFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'meetup_id' => \App\Models\Meetup::factory(), + 'start' => now()->addWeek(), + 'location' => fake()->address(), + 'description' => fake()->paragraph(), + 'link' => fake()->url(), + 'attendees' => [], + 'might_attendees' => [], + 'created_by' => \App\Models\User::factory(), + ]; + } +} diff --git a/routes/web.php b/routes/web.php index d76b643..3444ea9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -56,7 +56,8 @@ Route::livewire('/welcome', 'welcome')->name('welcome'); // Stream calendar route to download meetup calendar as ICS file Route::get('stream-calendar', \App\Http\Controllers\DownloadMeetupCalendar::class) - ->name('ics'); + ->name('ics') + ->middleware('throttle:calendar'); // Dashboard redirect route for authenticated users, redirects to German dashboard Route::middleware(['auth']) @@ -78,7 +79,8 @@ Route::middleware([]) /* OLD URLS - redirects for legacy URLs */ // Redirect old meetup calendar route to new one Route::get('meetup/stream-calendar', \App\Http\Controllers\DownloadMeetupCalendar::class) - ->name('ics'); + ->name('ics') + ->middleware('throttle:calendar'); // Redirect old meetup overview URL to new meetups page Route::get('/meetup/overview', function ($country) { return redirect("/{$country}/meetups"); diff --git a/tests/Feature/DownloadMeetupCalendarTest.php b/tests/Feature/DownloadMeetupCalendarTest.php new file mode 100644 index 0000000..eea5a6f --- /dev/null +++ b/tests/Feature/DownloadMeetupCalendarTest.php @@ -0,0 +1,65 @@ +assertRedirect(); +}); + +it('redirects when meetup parameter is not an integer', function () { + $response = get('/stream-calendar?meetup=abc'); + + $response->assertRedirect(); +}); + +it('returns 404 when meetup ID does not exist', function () { + $response = get('/stream-calendar?meetup=999999'); + + $response->assertStatus(404); +}); + +it('returns calendar for valid meetup ID', function () { + $country = Country::factory()->create(); + $city = \App\Models\City::factory()->create([ + 'country_id' => $country->id, + ]); + $meetup = Meetup::factory()->create([ + 'city_id' => $city->id, + ]); + MeetupEvent::factory()->create([ + 'meetup_id' => $meetup->id, + 'start' => now()->addDay(), + ]); + + $response = get("/stream-calendar?meetup={$meetup->id}"); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'text/calendar; charset=utf-8'); +}); + +it('returns 429 when rate limit is exceeded', function () { + $country = Country::factory()->create(); + $city = \App\Models\City::factory()->create([ + 'country_id' => $country->id, + ]); + $meetup = Meetup::factory()->create([ + 'city_id' => $city->id, + ]); + MeetupEvent::factory()->create([ + 'meetup_id' => $meetup->id, + 'start' => now()->addDay(), + ]); + + // Make 61 requests to exceed the 60 per minute limit + for ($i = 0; $i < 61; $i++) { + $response = get("/stream-calendar?meetup={$meetup->id}"); + } + + // The last request should be rate limited + $response->assertStatus(429); +});