收口聊天室安全边界并优化特效生命周期

This commit is contained in:
2026-04-25 02:52:30 +08:00
parent 4d3f4f7a4b
commit 855d031b04
26 changed files with 1219 additions and 175 deletions
+147 -7
View File
@@ -9,11 +9,13 @@ namespace Tests\Feature;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Models\Room;
use App\Models\ShopItem;
use App\Models\User;
use App\Models\UserPurchase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;
/**
@@ -24,6 +26,15 @@ class ShopControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 每个测试前清空 Redis,避免房间在线状态串扰。
*/
protected function setUp(): void
{
parent::setUp();
Redis::flushall();
}
/**
* 测试商品列表接口只返回上架商品。
*/
@@ -86,6 +97,8 @@ class ShopControllerTest extends TestCase
public function test_can_buy_one_time_item()
{
$user = User::factory()->create(['jjb' => 500]);
$room = Room::create(['room_name' => '购买房']);
$this->joinRoom($user, $room);
$item = ShopItem::firstOrCreate(
['slug' => 'rename_card_test'],
@@ -98,7 +111,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => 1,
'room_id' => $room->id,
]);
$response->assertStatus(200);
@@ -121,6 +134,8 @@ class ShopControllerTest extends TestCase
public function test_cannot_buy_if_insufficient_funds()
{
$user = User::factory()->create(['jjb' => 50]);
$room = Room::create(['room_name' => '余额不足房']);
$this->joinRoom($user, $room);
$item = ShopItem::firstOrCreate(
['slug' => 'rename_card_test'],
@@ -133,6 +148,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => $room->id,
]);
$response->assertStatus(400);
@@ -150,6 +166,8 @@ class ShopControllerTest extends TestCase
public function test_cannot_buy_inactive_item()
{
$user = User::factory()->create(['jjb' => 500]);
$room = Room::create(['room_name' => '下架商品房']);
$this->joinRoom($user, $room);
$item = ShopItem::create([
'name' => 'Old Card',
@@ -161,6 +179,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => $room->id,
]);
$response->assertStatus(400);
@@ -226,6 +245,8 @@ class ShopControllerTest extends TestCase
Event::fake([MessageSent::class]);
$user = User::factory()->create(['jjb' => 500]);
$room = Room::create(['room_name' => '自动钓鱼房']);
$this->joinRoom($user, $room);
$item = ShopItem::create([
'name' => '自动钓鱼卡(2小时)',
@@ -238,14 +259,14 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => 1,
'room_id' => $room->id,
]);
$response->assertOk();
$response->assertJson(['status' => 'success']);
Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($user, $item): bool {
return $event->roomId === 1
Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($user, $item, $room): bool {
return $event->roomId === $room->id
&& ($event->message['from_user'] ?? null) === '钓鱼播报'
&& str_contains((string) ($event->message['content'] ?? ''), $user->username)
&& str_contains((string) ($event->message['content'] ?? ''), $item->name);
@@ -263,6 +284,8 @@ class ShopControllerTest extends TestCase
'username' => 'buyer-user',
'jjb' => 5000,
]);
$room = Room::create(['room_name' => '指定特效房']);
$this->joinRoom($buyer, $room);
$recipient = User::factory()->create([
'username' => 'receiver-user',
]);
@@ -278,7 +301,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => 1,
'room_id' => $room->id,
'recipient' => $recipient->username,
'message' => '送你一场烟花',
]);
@@ -291,8 +314,8 @@ class ShopControllerTest extends TestCase
'gift_message' => '送你一场烟花',
]);
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($buyer, $recipient, $item): bool {
return $event->roomId === 1
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($buyer, $recipient, $item, $room): bool {
return $event->roomId === $room->id
&& $event->type === $item->effectKey()
&& $event->operator === $buyer->username
&& $event->targetUsername === $recipient->username
@@ -301,12 +324,114 @@ class ShopControllerTest extends TestCase
});
}
/**
* 测试定向特效的目标用户名作为标识字段时保持原始值,避免前端比较失败。
*/
public function test_buy_instant_effect_keeps_raw_target_username_identifier(): void
{
Event::fake([EffectBroadcast::class]);
$buyer = User::factory()->create([
'username' => 'buyer-user-raw',
'jjb' => 5000,
]);
$room = Room::create(['room_name' => '原始目标名房']);
$this->joinRoom($buyer, $room);
$recipient = User::factory()->create([
'username' => 'receiver&user',
]);
$item = ShopItem::create([
'name' => '烟花单次卡',
'slug' => 'once_fireworks_raw_target',
'type' => 'instant',
'price' => 888,
'is_active' => true,
]);
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => $room->id,
'recipient' => $recipient->username,
]);
$response->assertOk()
->assertJsonPath('target_username', $recipient->username);
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($recipient): bool {
return $event->targetUsername === $recipient->username;
});
}
/**
* 测试购买特效时缺少房间 ID 会被拒绝,避免广播到 room.0
*/
public function test_buy_instant_effect_requires_room_id(): void
{
$buyer = User::factory()->create([
'jjb' => 5000,
]);
$item = ShopItem::create([
'name' => '烟花单次卡',
'slug' => 'once_fireworks_missing_room',
'type' => 'instant',
'price' => 888,
'is_active' => true,
]);
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id,
]);
$response->assertStatus(422);
}
/**
* 测试购买特效的赠言会转义后再进入广播载荷。
*/
public function test_buy_instant_effect_escapes_gift_message(): void
{
Event::fake([EffectBroadcast::class, MessageSent::class]);
$buyer = User::factory()->create([
'jjb' => 5000,
]);
$room = Room::create(['room_name' => '赠言安全房']);
$this->joinRoom($buyer, $room);
$item = ShopItem::create([
'name' => '烟花单次卡',
'slug' => 'once_fireworks_safe_message',
'type' => 'instant',
'price' => 888,
'is_active' => true,
]);
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => $room->id,
'recipient' => 'all',
'message' => '<img src=x onerror=alert(1)>',
]);
$response->assertOk()
->assertJsonPath('gift_message', '&lt;img src=x onerror=alert(1)&gt;');
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event): bool {
return $event->giftMessage === '&lt;img src=x onerror=alert(1)&gt;';
});
Event::assertDispatched(MessageSent::class, function (MessageSent $event): bool {
return str_contains((string) ($event->message['content'] ?? ''), '&lt;img src=x onerror=alert(1)&gt;')
&& ! str_contains((string) ($event->message['content'] ?? ''), '<img src=x onerror=alert(1)>');
});
}
/**
* 测试购买签到补签卡会扣金币并生成可用背包记录。
*/
public function test_buy_sign_repair_card_creates_active_purchase(): void
{
$user = User::factory()->create(['jjb' => 12000]);
$room = Room::create(['room_name' => '补签卡房']);
$this->joinRoom($user, $room);
$item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
$item->update([
'price' => 10000,
@@ -315,6 +440,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => $room->id,
]);
$response->assertOk()
@@ -335,6 +461,8 @@ class ShopControllerTest extends TestCase
public function test_buy_sign_repair_card_supports_quantity(): void
{
$user = User::factory()->create(['jjb' => 35000]);
$room = Room::create(['room_name' => '多张补签卡房']);
$this->joinRoom($user, $room);
$item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
$item->update([
'price' => 10000,
@@ -343,6 +471,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => $room->id,
'quantity' => 3,
]);
@@ -358,4 +487,15 @@ class ShopControllerTest extends TestCase
->where('status', 'active')
->count());
}
/**
* 将测试用户写入指定房间在线表,模拟已经进入聊天室的状态。
*/
private function joinRoom(User $user, Room $room): void
{
Redis::hset("room:{$room->id}:users", $user->username, json_encode([
'id' => $user->id,
'username' => $user->username,
], JSON_UNESCAPED_UNICODE));
}
}