🗓️ Add MeetupEventFactory, implement rate limiting for calendar downloads, and enhance test coverage

- **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.
This commit is contained in:
HolgerHatGarKeineNode
2026-01-17 21:18:55 +01:00
parent d3acc365fd
commit da1324adda
5 changed files with 140 additions and 24 deletions

View File

@@ -17,11 +17,15 @@ class DownloadMeetupCalendar extends Controller
public function __invoke(Request $request): Response public function __invoke(Request $request): Response
{ {
if ($request->has('meetup')) { if ($request->has('meetup')) {
$validated = $request->validate([
'meetup' => ['required', 'integer'],
]);
$meetup = Meetup::query() $meetup = Meetup::query()
->with([ ->with([
'meetupEvents.meetup', 'meetupEvents.meetup',
]) ])
->findOrFail($request->input('meetup')); ->findOrFail($validated['meetup']);
$events = $meetup->meetupEvents()->where('start', '>=', now())->get(); $events = $meetup->meetupEvents()->where('start', '>=', now())->get();
$image = $meetup->getFirstMediaUrl('logo'); $image = $meetup->getFirstMediaUrl('logo');
} elseif ($request->has('my')) { } elseif ($request->has('my')) {
@@ -31,7 +35,7 @@ class DownloadMeetupCalendar extends Controller
'meetup', 'meetup',
]) ])
->where('start', '>=', now()) ->where('start', '>=', now())
->whereHas('meetup', fn($query) => $query->whereIn('meetups.id', $ids)) ->whereHas('meetup', fn ($query) => $query->whereIn('meetups.id', $ids))
->get(); ->get();
$image = asset('img/einundzwanzig-horizontal.png'); $image = asset('img/einundzwanzig-horizontal.png');
} else { } else {

View File

@@ -3,11 +3,14 @@
namespace App\Providers; namespace App\Providers;
use App\Support\Carbon; use App\Support\Carbon;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\DiagnosingHealth; use Illuminate\Foundation\Events\DiagnosingHealth;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Nightwatch\Facades\Nightwatch; use Laravel\Nightwatch\Facades\Nightwatch;
@@ -31,6 +34,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
$this->configureRateLimiting();
Livewire::setUpdateRoute(function ($handle) { Livewire::setUpdateRoute(function ($handle) {
return Route::post('/livewire/update', $handle) return Route::post('/livewire/update', $handle)
->middleware(['web', Sample::rate(0)]); ->middleware(['web', Sample::rate(0)]);
@@ -46,4 +51,14 @@ class AppServiceProvider extends ServiceProvider
Model::preventLazyLoading(app()->environment('local')); 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());
});
}
} }

View File

@@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\MeetupEvent>
*/
class MeetupEventFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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(),
];
}
}

View File

@@ -56,7 +56,8 @@ Route::livewire('/welcome', 'welcome')->name('welcome');
// Stream calendar route to download meetup calendar as ICS file // Stream calendar route to download meetup calendar as ICS file
Route::get('stream-calendar', \App\Http\Controllers\DownloadMeetupCalendar::class) 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 // Dashboard redirect route for authenticated users, redirects to German dashboard
Route::middleware(['auth']) Route::middleware(['auth'])
@@ -78,7 +79,8 @@ Route::middleware([])
/* OLD URLS - redirects for legacy URLs */ /* OLD URLS - redirects for legacy URLs */
// Redirect old meetup calendar route to new one // Redirect old meetup calendar route to new one
Route::get('meetup/stream-calendar', \App\Http\Controllers\DownloadMeetupCalendar::class) 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 // Redirect old meetup overview URL to new meetups page
Route::get('/meetup/overview', function ($country) { Route::get('/meetup/overview', function ($country) {
return redirect("/{$country}/meetups"); return redirect("/{$country}/meetups");

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\Country;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use function Pest\Laravel\get;
it('redirects when meetup parameter contains invalid characters', function () {
$response = get('/stream-calendar?meetup=49)');
$response->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);
});