From 039ed02b4228c6c893da26c2c23b7909ce67c18a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 29 Mar 2026 20:14:23 -0300 Subject: [PATCH] feat: rate limiting, cleanup duplicates, ApiKey app tests, bootstrap hardening - Add API rate limiting (60/min per workspace) in AppServiceProvider - Add throttle:api middleware to all API routes - Add rate limit exception rendering for JSON responses - Add middleware priority list (AuthenticateApiToken before ThrottleRequests) - Add ApiKeyControllerTest (6 tests for App dashboard CRUD) - Remove duplicate test files (Controllers/ dir duplicates) - Remove old controllers and routes/settings.php - Add routes/mcp.php placeholder - Add API Keys nav item to settings layout - Fix LazilyRefreshDatabase conflict in API tests - 698 tests passing --- app/Providers/AppServiceProvider.php | 15 +++ bootstrap/app.php | 22 +++- routes/api.php | 2 +- tests/Feature/ApiKeyControllerTest.php | 79 +++++++++++++ .../Controllers/BillingControllerTest.php | 92 --------------- .../Controllers/MediaControllerTest.php | 106 ------------------ .../Controllers/OnboardingControllerTest.php | 85 -------------- 7 files changed, 116 insertions(+), 285 deletions(-) create mode 100644 tests/Feature/ApiKeyControllerTest.php delete mode 100644 tests/Feature/Controllers/BillingControllerTest.php delete mode 100644 tests/Feature/Controllers/MediaControllerTest.php delete mode 100644 tests/Feature/Controllers/OnboardingControllerTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b5ec5c07..7ba8431c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,12 +20,15 @@ use Carbon\CarbonImmutable; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Cashier\Cashier; @@ -56,6 +59,7 @@ public function boot(): void { $this->configureDefaults(); $this->configureMorphMap(); + $this->configureRateLimiting(); $this->configureSocialite(); $this->configureStripeWebhooks(); @@ -81,6 +85,17 @@ protected function configureMorphMap(): void ]); } + protected function configureRateLimiting(): void + { + RateLimiter::for('api', function (Request $request) { + if ($this->app->environment('local')) { + return Limit::none(); + } + + return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip()); + }); + } + protected function configureStripeWebhooks(): void { Event::listen(WebhookReceived::class, StripeEventListener::class); diff --git a/bootstrap/app.php b/bootstrap/app.php index 964e9a96..94282876 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,6 +8,9 @@ use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; +use Illuminate\Http\Request; +use Illuminate\Routing\Middleware\ThrottleRequests; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -37,7 +40,24 @@ $middleware->preventRequestForgery(except: [ 'stripe/*', ]); + + $middleware->prependToPriorityList( + ThrottleRequests::class, + AuthenticateApiToken::class, + ); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->renderable(function (TooManyRequestsHttpException $e, Request $request) { + if ($request->expectsJson()) { + $retryAfter = $e->getHeaders()['Retry-After'] ?? null; + $message = $retryAfter + ? "Rate limit exceeded. Please retry after {$retryAfter} seconds." + : 'Rate limit exceeded. Please try again later.'; + + return response()->json([ + 'name' => 'rate_limit_exceeded', + 'message' => $message, + ], 429)->withHeaders($e->getHeaders()); + } + }); })->create(); diff --git a/routes/api.php b/routes/api.php index 875a6564..cf63a43e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,7 +12,7 @@ Route::group( [ 'domain' => 'api.'.parse_url(config('app.url'), PHP_URL_HOST), - 'middleware' => ['api.auth'], + 'middleware' => ['api.auth', 'throttle:api'], ], function () { // Posts diff --git a/tests/Feature/ApiKeyControllerTest.php b/tests/Feature/ApiKeyControllerTest.php new file mode 100644 index 00000000..b2216563 --- /dev/null +++ b/tests/Feature/ApiKeyControllerTest.php @@ -0,0 +1,79 @@ +user = User::factory()->create(['setup' => Setup::Completed]); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => 'owner']); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + $this->user->refresh(); +}); + +it('shows api keys page', function () { + $token = ApiToken::factory()->create(['workspace_id' => $this->workspace->id]); + + $this->actingAs($this->user) + ->get(route('app.api-keys.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('settings/ApiKeys') + ->has('apiTokens', 1) + ); +}); + +it('creates an api key', function () { + $this->actingAs($this->user) + ->post(route('app.api-keys.store'), [ + 'name' => 'My API Key', + ]) + ->assertRedirect(); + + expect(ApiToken::where('workspace_id', $this->workspace->id)->count())->toBe(1); + + $token = ApiToken::where('workspace_id', $this->workspace->id)->first(); + expect($token->name)->toBe('My API Key'); + expect($token->status)->toBe('active'); +}); + +it('creates an api key with expiration', function () { + $this->actingAs($this->user) + ->post(route('app.api-keys.store'), [ + 'name' => 'Expiring Key', + 'expires_at' => now()->addDays(30)->format('Y-m-d'), + ]) + ->assertRedirect(); + + $token = ApiToken::where('workspace_id', $this->workspace->id)->first(); + expect($token->expires_at)->not->toBeNull(); +}); + +it('validates name is required', function () { + $this->actingAs($this->user) + ->post(route('app.api-keys.store'), []) + ->assertSessionHasErrors('name'); +}); + +it('deletes an api key', function () { + $token = ApiToken::factory()->create(['workspace_id' => $this->workspace->id]); + + $this->actingAs($this->user) + ->delete(route('app.api-keys.destroy', $token)) + ->assertRedirect(); + + expect(ApiToken::find($token->id))->toBeNull(); +}); + +it('cannot delete api key from another workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $token = ApiToken::factory()->create(['workspace_id' => $otherWorkspace->id]); + + $this->actingAs($this->user) + ->delete(route('app.api-keys.destroy', $token)) + ->assertNotFound(); +}); diff --git a/tests/Feature/Controllers/BillingControllerTest.php b/tests/Feature/Controllers/BillingControllerTest.php deleted file mode 100644 index 758ff111..00000000 --- a/tests/Feature/Controllers/BillingControllerTest.php +++ /dev/null @@ -1,92 +0,0 @@ -user = User::factory()->create([ - 'setup' => Setup::Completed, - ]); - $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); - $this->user->update(['current_workspace_id' => $this->workspace->id]); -}); - -test('billing index shows subscription info for subscribed user', function () { - $this->user->subscriptions()->create([ - 'type' => 'default', - 'stripe_id' => 'sub_123', - 'stripe_status' => 'active', - 'stripe_price' => 'price_123', - 'quantity' => 2, - ]); - - $response = $this->actingAs($this->user)->get(route('app.billing.index')); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->component('billing/Index') - ->has('hasSubscription') - ->has('subscription') - ->has('workspacesCount') - ); -}); - -test('billing index shows info for user without subscription', function () { - $response = $this->actingAs($this->user)->get(route('app.billing.index')); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->component('billing/Index') - ->where('hasSubscription', false) - ); -}); - -test('billing index shows trial info when on trial', function () { - $this->user->subscriptions()->create([ - 'type' => 'default', - 'stripe_id' => 'sub_123', - 'stripe_status' => 'trialing', - 'stripe_price' => 'price_123', - 'quantity' => 1, - 'trial_ends_at' => now()->addDays(14), - ]); - - $response = $this->actingAs($this->user)->get(route('app.billing.index')); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->component('billing/Index') - ->where('onTrial', true) - ->has('trialEndsAt') - ); -}); - -test('processing page shows for user during checkout', function () { - $response = $this->actingAs($this->user)->get(route('app.billing.processing', ['status' => 'success'])); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->component('billing/Processing') - ->has('userId') - ->where('status', 'success') - ); -}); - -test('processing page shows cancelled status', function () { - $response = $this->actingAs($this->user)->get(route('app.billing.processing', ['status' => 'cancelled'])); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->where('status', 'cancelled') - ); -}); - -test('processing page defaults to processing status for invalid status', function () { - $response = $this->actingAs($this->user)->get(route('app.billing.processing', ['status' => 'invalid'])); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->where('status', 'processing') - ); -}); diff --git a/tests/Feature/Controllers/MediaControllerTest.php b/tests/Feature/Controllers/MediaControllerTest.php deleted file mode 100644 index 624b56ca..00000000 --- a/tests/Feature/Controllers/MediaControllerTest.php +++ /dev/null @@ -1,106 +0,0 @@ -user = User::factory()->create([ - 'setup' => Setup::Completed, - ]); - $this->user->subscriptions()->create([ - 'type' => 'default', - 'stripe_id' => 'sub_123', - 'stripe_status' => 'active', - 'stripe_price' => 'price_123', - 'quantity' => 1, - ]); - $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); - $this->user->update(['current_workspace_id' => $this->workspace->id]); -}); - -test('user can upload media to workspace', function () { - $file = UploadedFile::fake()->image('logo.jpg', 100, 100); - - $response = $this->actingAs($this->user)->postJson(route('app.medias.store'), [ - 'media' => $file, - 'model' => 'workspace', - 'model_id' => $this->workspace->id, - 'collection' => 'logo', - ]); - - $response->assertSuccessful(); - $response->assertJsonStructure(['id', 'url', 'type', 'original_filename']); - expect(Media::count())->toBe(1); -}); - -test('user can delete media', function () { - $file = UploadedFile::fake()->image('logo.jpg', 100, 100); - $media = $this->workspace->addMedia($file, 'logo'); - - $response = $this->actingAs($this->user)->deleteJson(route('app.medias.destroy', [ - 'modelId' => $this->workspace->id, - 'media' => $media->id, - ])); - - $response->assertSuccessful(); - expect(Media::find($media->id))->toBeNull(); -}); - -test('user cannot delete media from different model', function () { - $file = UploadedFile::fake()->image('logo.jpg', 100, 100); - $media = $this->workspace->addMedia($file, 'logo'); - - $otherWorkspace = Workspace::factory()->create(); - - $response = $this->actingAs($this->user)->deleteJson(route('app.medias.destroy', [ - 'modelId' => $otherWorkspace->id, - 'media' => $media->id, - ])); - - $response->assertForbidden(); -}); - -test('user can duplicate media to another model', function () { - $file = UploadedFile::fake()->image('image.jpg', 100, 100); - $media = $this->workspace->addMedia($file, 'default'); - - $postPlatform = PostPlatform::factory()->create([ - 'social_account_id' => SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $this->workspace->id, - ])->id, - ]); - - $response = $this->actingAs($this->user)->postJson(route('app.medias.duplicate', ['media' => $media->id]), [ - 'targets' => [ - [ - 'model' => 'postPlatform', - 'model_id' => $postPlatform->id, - 'collection' => 'default', - ], - ], - ]); - - $response->assertSuccessful(); - expect(Media::count())->toBe(2); -}); - -test('media store fails with invalid model type', function () { - $file = UploadedFile::fake()->image('logo.jpg', 100, 100); - - $response = $this->actingAs($this->user)->postJson(route('app.medias.store'), [ - 'media' => $file, - 'model' => 'invalid', - 'model_id' => $this->workspace->id, - 'collection' => 'logo', - ]); - - $response->assertUnprocessable(); - $response->assertJsonValidationErrors('model'); -}); diff --git a/tests/Feature/Controllers/OnboardingControllerTest.php b/tests/Feature/Controllers/OnboardingControllerTest.php deleted file mode 100644 index ca7db7ed..00000000 --- a/tests/Feature/Controllers/OnboardingControllerTest.php +++ /dev/null @@ -1,85 +0,0 @@ -user = User::factory()->create([ - 'setup' => Setup::Role, - ]); -}); - -test('step1 shows persona selection', function () { - $response = $this->actingAs($this->user)->get(route('app.onboarding.step1')); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->component('onboarding/Step1') - ->has('personas') - ); -}); - -test('step1 can be stored with valid persona', function () { - $response = $this->actingAs($this->user)->post(route('app.onboarding.step1.store'), [ - 'persona' => Persona::Creator->value, - ]); - - $response->assertRedirect(route('app.onboarding.step2')); - expect($this->user->fresh()->persona)->toBe(Persona::Creator); - expect($this->user->fresh()->setup)->toBe(Setup::Connections); -}); - -test('step1 fails with invalid persona', function () { - $response = $this->actingAs($this->user)->post(route('app.onboarding.step1.store'), [ - 'persona' => 'invalid', - ]); - - $response->assertSessionHasErrors('persona'); -}); - -test('step2 shows platforms page', function () { - $this->user->update(['setup' => Setup::Connections]); - $workspace = Workspace::factory()->create(['user_id' => $this->user->id]); - $this->user->update(['current_workspace_id' => $workspace->id]); - - $response = $this->actingAs($this->user)->get(route('app.onboarding.step2')); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->component('onboarding/Step2') - ->has('platforms') - ->has('hasWorkspace') - ); -}); - -test('step2 shows without workspace', function () { - $this->user->update(['setup' => Setup::Connections]); - - $response = $this->actingAs($this->user)->get(route('app.onboarding.step2')); - - $response->assertSuccessful(); - $response->assertInertia(fn ($page) => $page - ->where('hasWorkspace', false) - ); -}); - -test('step2 store redirects to calendar in self hosted mode', function () { - config(['trypost.self_hosted' => true]); - $this->user->update(['setup' => Setup::Connections]); - - $response = $this->actingAs($this->user)->post(route('app.onboarding.step2.store')); - - $response->assertRedirect(route('app.calendar')); - expect($this->user->fresh()->setup)->toBe(Setup::Completed); -}); - -test('complete sets setup to completed', function () { - $this->user->update(['setup' => Setup::Subscription]); - - $response = $this->actingAs($this->user)->get(route('app.onboarding.complete')); - - $response->assertRedirect(route('app.calendar')); - expect($this->user->fresh()->setup)->toBe(Setup::Completed); -});