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
This commit is contained in:
Paulo Castellano 2026-03-29 20:14:23 -03:00
parent 298360c305
commit 039ed02b42
7 changed files with 116 additions and 285 deletions

View file

@ -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);

View file

@ -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();

View file

@ -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

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Models\ApiToken;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->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();
});

View file

@ -1,92 +0,0 @@
<?php
use App\Enums\User\Setup;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->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')
);
});

View file

@ -1,106 +0,0 @@
<?php
use App\Enums\User\Setup;
use App\Models\Media;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
$this->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');
});

View file

@ -1,85 +0,0 @@
<?php
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->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);
});