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:
parent
298360c305
commit
039ed02b42
7 changed files with 116 additions and 285 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
tests/Feature/ApiKeyControllerTest.php
Normal file
79
tests/Feature/ApiKeyControllerTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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')
|
||||
);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in a new issue