set('forum_bot_protection.enabled', false); config()->set('forum_bot_protection.behavior.new_account_days', 0); config()->set('skinbase_ai_moderation.enabled', false); $moderationService = \Mockery::mock(ForumModerationService::class); $moderationService->shouldReceive('preflight')->andReturnUsing(static function ($user, $content, $sourceIp): array { return [ 'spam_score' => 0, 'quality_score' => 0, 'ai_spam_score' => 0, 'ai_toxicity_score' => 0, 'behavior_score' => 0, 'link_score' => 0, 'learning_score' => 0, 'risk_score' => 0, 'trust_modifier' => 0, 'decision' => 'allowed', 'captcha_required' => false, 'blocked' => false, 'requires_review' => false, 'flagged' => false, 'reason' => null, 'content_hash' => hash('sha256', (string) $content), 'pattern_signature' => null, 'source_ip_hash' => $sourceIp ? hash('sha256', $sourceIp) : null, 'moderation_labels' => ['preflight', 'allowed'], 'provider' => 'none', 'provider_available' => false, 'language' => null, ]; }); $moderationService->shouldReceive('applyPreflightAssessment')->andReturnUsing(static function (ForumPost $post, array $assessment): void { $post->forceFill([ 'source_ip_hash' => $assessment['source_ip_hash'] ?? $post->source_ip_hash, 'content_hash' => $assessment['content_hash'] ?? $post->content_hash, 'spam_score' => (int) ($assessment['spam_score'] ?? 0), 'quality_score' => (int) ($assessment['quality_score'] ?? 0), 'ai_spam_score' => (int) ($assessment['ai_spam_score'] ?? 0), 'ai_toxicity_score' => (int) ($assessment['ai_toxicity_score'] ?? 0), 'behavior_score' => (int) ($assessment['behavior_score'] ?? 0), 'link_score' => (int) ($assessment['link_score'] ?? 0), 'learning_score' => (int) ($assessment['learning_score'] ?? 0), 'risk_score' => (int) ($assessment['risk_score'] ?? 0), 'trust_modifier' => (int) ($assessment['trust_modifier'] ?? 0), 'flagged' => (bool) ($assessment['flagged'] ?? false), 'flagged_reason' => $assessment['reason'] ?? null, 'moderation_checked' => false, 'moderation_status' => 'pending_ai_scan', 'moderation_labels' => (array) ($assessment['moderation_labels'] ?? []), 'moderation_meta' => [ 'provider' => $assessment['provider'] ?? 'none', 'provider_available' => (bool) ($assessment['provider_available'] ?? false), 'language' => $assessment['language'] ?? null, ], ])->save(); }); $moderationService->shouldReceive('logRequestSecurity')->andReturnNull(); $moderationService->shouldReceive('dispatchAsyncScan')->andReturnNull(); $this->app->instance(ForumModerationService::class, $moderationService); createForumRateLimitTestSchema(); $user = User::query()->create([ 'username' => 'ratelimit-user', 'username_changed_at' => now()->subDays(120), 'last_username_change_at' => now()->subDays(120), 'onboarding_step' => 'complete', 'name' => 'Rate Limit User', 'email' => 'ratelimit@example.com', 'email_verified_at' => now(), 'password' => 'password', 'is_active' => true, ]); markForumRateLimitUserAsEstablished($user); $board = makeForumBoard('thread-limit'); clearForumRateLimiters((string) $user->id); for ($attempt = 1; $attempt <= 3; $attempt++) { $response = $this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [ 'title' => 'Rate limit topic ' . $attempt, 'content' => 'Thread body ' . $attempt, ]); $response->assertRedirect(); } $this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [ 'title' => 'Rate limit topic 4', 'content' => 'Thread body 4', ])->assertStatus(429); expect(ForumTopic::query()->count())->toBe(3) ->and(ForumPost::query()->count())->toBe(3); clearForumRateLimiters((string) $user->id); $replyUser = User::query()->create([ 'username' => 'reply-limit-user', 'username_changed_at' => now()->subDays(120), 'last_username_change_at' => now()->subDays(120), 'onboarding_step' => 'complete', 'name' => 'Reply Limit User', 'email' => 'replylimit@example.com', 'email_verified_at' => now(), 'password' => 'password', 'is_active' => true, ]); markForumRateLimitUserAsEstablished($replyUser); clearForumRateLimiters((string) $replyUser->id); $topic = makeForumTopic($replyUser); for ($attempt = 1; $attempt <= 3; $attempt++) { $response = $this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [ 'content' => 'Reply burst ' . $attempt, ]); $response->assertRedirect(); } $this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [ 'content' => 'Reply burst 4', ])->assertStatus(429); expect(ForumPost::query()->where('topic_id', $topic->id)->count())->toBe(4); clearForumRateLimiters((string) $user->id); clearForumRateLimiters((string) $replyUser->id); }); function createForumRateLimitTestSchema(): void { foreach ([ 'forum_security_logs', 'forum_firewall_logs', 'forum_bot_ip_blacklist', 'forum_spam_signatures', 'forum_spam_learning', 'forum_spam_domains', 'forum_spam_keywords', 'forum_topic_tags', 'forum_tags', 'forum_posts', 'forum_topics', 'forum_threads', 'forum_boards', 'forum_categories', 'users', ] as $table) { Schema::dropIfExists($table); } Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void { $table->id(); $table->string('ip_address', 45)->unique(); $table->string('reason', 255)->nullable(); $table->unsignedTinyInteger('risk_score')->default(100); $table->timestamp('expires_at')->nullable(); $table->timestamp('created_at')->nullable(); }); Schema::create('forum_firewall_logs', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('user_id')->nullable(); $table->string('ip_address', 45)->nullable(); $table->string('action', 80); $table->unsignedTinyInteger('risk_score')->default(0); $table->string('decision', 20)->default('allow'); $table->string('threat_type', 80)->nullable(); $table->string('reason', 255)->nullable(); $table->json('metadata')->nullable(); $table->timestamps(); }); Schema::create('forum_security_logs', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('user_id')->nullable(); $table->unsignedBigInteger('post_id')->nullable(); $table->string('ip_address', 45)->nullable(); $table->string('action', 80); $table->unsignedTinyInteger('risk_score')->default(0); $table->string('decision', 20)->default('allow'); $table->string('reason', 255)->nullable(); $table->json('layer_scores')->nullable(); $table->json('metadata')->nullable(); $table->timestamps(); }); Schema::create('forum_spam_signatures', function (Blueprint $table): void { $table->id(); $table->string('content_hash', 64)->nullable()->index(); $table->string('pattern_signature', 191)->nullable()->index(); $table->string('source', 32)->nullable(); $table->string('reason', 255)->nullable(); $table->unsignedInteger('confidence')->default(0); $table->unsignedBigInteger('reviewed_by')->nullable(); $table->json('metadata')->nullable(); $table->timestamp('created_at')->nullable(); }); Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('username')->nullable(); $table->timestamp('username_changed_at')->nullable(); $table->timestamp('last_username_change_at')->nullable(); $table->string('onboarding_step')->nullable(); $table->string('name')->nullable(); $table->string('email')->nullable(); $table->timestamp('email_verified_at')->nullable(); $table->string('password')->nullable(); $table->boolean('is_active')->default(true); $table->unsignedInteger('trust_score')->default(0); $table->unsignedInteger('approved_posts')->default(0); $table->unsignedInteger('flagged_posts')->default(0); $table->string('role')->nullable(); $table->rememberToken(); $table->timestamps(); $table->softDeletes(); }); Schema::create('forum_categories', function (Blueprint $table): void { $table->id(); $table->string('name')->nullable(); $table->string('title')->nullable(); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->unsignedBigInteger('parent_id')->nullable(); $table->integer('position')->default(0); $table->boolean('is_active')->default(true); $table->timestamps(); }); Schema::create('forum_boards', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('category_id'); $table->unsignedBigInteger('legacy_category_id')->nullable(); $table->string('title'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->string('icon')->nullable(); $table->string('image')->nullable(); $table->integer('position')->default(0); $table->boolean('is_active')->default(true); $table->boolean('is_read_only')->default(false); $table->timestamps(); }); Schema::create('forum_threads', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('category_id'); $table->unsignedBigInteger('user_id'); $table->string('title'); $table->string('slug')->unique(); $table->longText('content'); $table->unsignedInteger('views')->default(0); $table->boolean('is_locked')->default(false); $table->boolean('is_pinned')->default(false); $table->string('visibility')->default('public'); $table->timestamp('last_post_at')->nullable(); $table->timestamps(); $table->softDeletes(); }); Schema::create('forum_topics', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('board_id'); $table->unsignedBigInteger('user_id'); $table->unsignedBigInteger('artwork_id')->nullable(); $table->unsignedBigInteger('legacy_thread_id')->nullable(); $table->string('title'); $table->string('slug')->unique(); $table->unsignedInteger('views')->default(0); $table->unsignedInteger('replies_count')->default(0); $table->boolean('is_pinned')->default(false); $table->boolean('is_locked')->default(false); $table->boolean('is_deleted')->default(false); $table->unsignedBigInteger('last_post_id')->nullable(); $table->timestamp('last_post_at')->nullable(); $table->timestamps(); }); Schema::create('forum_posts', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('thread_id'); $table->unsignedBigInteger('topic_id')->nullable(); $table->string('source_ip_hash', 64)->nullable(); $table->unsignedBigInteger('user_id'); $table->longText('content'); $table->string('content_hash', 64)->nullable(); $table->boolean('is_edited')->default(false); $table->timestamp('edited_at')->nullable(); $table->unsignedInteger('spam_score')->default(0); $table->unsignedInteger('quality_score')->default(0); $table->unsignedInteger('ai_spam_score')->default(0); $table->unsignedInteger('ai_toxicity_score')->default(0); $table->unsignedInteger('behavior_score')->default(0); $table->unsignedInteger('link_score')->default(0); $table->integer('learning_score')->default(0); $table->unsignedInteger('risk_score')->default(0); $table->integer('trust_modifier')->default(0); $table->boolean('flagged')->default(false); $table->string('flagged_reason')->nullable(); $table->boolean('moderation_checked')->default(false); $table->string('moderation_status')->nullable(); $table->json('moderation_labels')->nullable(); $table->json('moderation_meta')->nullable(); $table->timestamp('last_ai_scan_at')->nullable(); $table->timestamps(); $table->softDeletes(); }); Schema::create('forum_tags', function (Blueprint $table): void { $table->id(); $table->string('name')->unique(); $table->string('slug')->unique(); $table->timestamps(); }); Schema::create('forum_topic_tags', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('topic_id'); $table->unsignedBigInteger('tag_id'); $table->timestamps(); $table->unique(['topic_id', 'tag_id']); }); Schema::create('forum_spam_domains', function (Blueprint $table): void { $table->id(); $table->string('domain')->unique(); $table->timestamps(); }); Schema::create('forum_spam_keywords', function (Blueprint $table): void { $table->id(); $table->string('keyword', 120)->unique(); $table->timestamp('created_at')->nullable(); }); Schema::create('forum_spam_learning', function (Blueprint $table): void { $table->id(); $table->string('content_hash', 64)->index(); $table->string('decision', 32); $table->string('pattern_signature', 191)->nullable(); $table->unsignedBigInteger('reviewed_by')->nullable(); $table->json('metadata')->nullable(); $table->timestamps(); }); } function ensureForumRateLimitModelClassesLoaded(): void { foreach ([ 'packages/klevze/Plugins/Forum/Models/ForumPost.php', 'packages/klevze/Plugins/Forum/Models/ForumSpamLearning.php', 'packages/klevze/Plugins/Forum/Models/ForumAiLog.php', 'packages/klevze/Plugins/Forum/Models/ForumModerationQueue.php', ] as $relativePath) { require_once base_path($relativePath); } } function makeForumBoard(string $suffix): ForumBoard { $category = ForumCategory::query()->create([ 'name' => 'Rate Limit Category ' . $suffix, 'title' => 'Rate Limit Category ' . $suffix, 'slug' => 'rate-limit-category-' . $suffix, 'description' => 'Test category', 'position' => 1, 'is_active' => true, 'parent_id' => null, ]); return ForumBoard::query()->create([ 'category_id' => $category->id, 'title' => 'Rate Limit Board ' . $suffix, 'slug' => 'rate-limit-board-' . $suffix, 'description' => 'Test board', 'position' => 1, 'is_active' => true, 'is_read_only' => false, ]); } function makeForumTopic(User $user): ForumTopic { $board = makeForumBoard('reply-limit'); $legacyThreadId = DB::table('forum_threads')->insertGetId([ 'category_id' => $board->category_id, 'user_id' => $user->id, 'title' => 'Existing topic', 'slug' => 'existing-topic-reply-limit', 'content' => 'Opening post', 'views' => 0, 'is_locked' => false, 'is_pinned' => false, 'visibility' => 'public', 'last_post_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); $topic = ForumTopic::query()->create([ 'board_id' => $board->id, 'user_id' => $user->id, 'legacy_thread_id' => $legacyThreadId, 'title' => 'Existing topic', 'slug' => 'existing-topic-reply-limit', 'views' => 0, 'replies_count' => 0, 'is_pinned' => false, 'is_locked' => false, 'is_deleted' => false, 'last_post_at' => now(), ]); $post = ForumPost::query()->create([ 'thread_id' => $legacyThreadId, 'topic_id' => $topic->id, 'user_id' => $user->id, 'content' => 'Opening post', 'is_edited' => false, ]); $topic->forceFill([ 'last_post_id' => $post->id, 'last_post_at' => $post->created_at, ])->save(); return $topic; } function clearForumRateLimiters(string $key): void { foreach ([ 'forum-thread-minute:' . $key, 'forum-thread-hour:' . $key, 'forum-post-minute:' . $key, 'forum-post-hour:' . $key, ] as $limiterKey) { RateLimiter::clear($limiterKey); } } function markForumRateLimitUserAsEstablished(User $user): void { $timestamp = now()->subDays(30); $user->forceFill([ 'created_at' => $timestamp, 'updated_at' => $timestamp, ])->save(); }