Files
SkinbaseNova/tests/Unit/ForumRateLimitRouteTest.php

482 lines
18 KiB
PHP

<?php
use App\Models\User;
use cPad\Plugins\Forum\Models\ForumBoard;
use cPad\Plugins\Forum\Models\ForumCategory;
use cPad\Plugins\Forum\Models\ForumPost;
use cPad\Plugins\Forum\Models\ForumTopic;
use cPad\Plugins\Forum\Services\ForumModerationService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
uses(Tests\TestCase::class);
it('enforces forum write rate limits for thread creation and replies', function () {
ensureForumRateLimitModelClassesLoaded();
Queue::fake();
config()->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();
}