From 1e597e3b1763ebbcba0b62917e676ccdca502960 Mon Sep 17 00:00:00 2001 From: Refaltor77 Date: Mon, 20 Apr 2026 09:55:32 +0200 Subject: [PATCH 1/2] chore(test): upgrade phpunit.xml to PHPUnit 12 + add phpstan/infection - Migrate phpunit.xml from PHPUnit 9/10 schema to PHPUnit 12: * Drop deprecated attributes (cacheResultFile, forceCoversAnnotation, beStrictAboutCoversAnnotation, beStrictAboutTodoAnnotatedTests, verbose) * Replace with * Use cacheDirectory for unified PHPUnit cache - Add dev dependencies for stricter CI: * phpstan/phpstan ^2.1 (level max baseline) * infection/infection ^0.32 (mutation testing, configured via infection.json5, scoped filter for fast runs) - Gitignore .papline/ (local workflow log directory) Required to run the new InviteTest / MemberTest suites under PHPUnit 12 and to back the suite with static analysis + mutation coverage. --- .gitignore | 1 + composer.json | 7 +++++-- infection.json5 | 16 ++++++++++++++++ phpunit.xml | 15 +++++---------- 4 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 infection.json5 diff --git a/.gitignore b/.gitignore index f7ef70cab..e795c9c46 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ phpunit.log /.phpunit* /coverage /.tools +.papline/ diff --git a/composer.json b/composer.json index 6691da9a3..94075b951 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,9 @@ "symfony/cache": "^5.4", "laravel/pint": "^1.21", "pestphp/pest": "^4.1.2", - "carthage-software/mago": "1.1.0" + "carthage-software/mago": "1.1.0", + "phpstan/phpstan": "^2.1", + "infection/infection": "^0.32.6" }, "autoload": { "files": [ @@ -82,7 +84,8 @@ "config": { "allow-plugins": { "pestphp/pest-plugin": true, - "carthage-software/mago": true + "carthage-software/mago": true, + "infection/extension-installer": true } } } diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 000000000..fa64a9bfa --- /dev/null +++ b/infection.json5 @@ -0,0 +1,16 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": ["src"] + }, + "timeout": 30, + "logs": { + "text": ".papline/infection.log", + "summary": ".papline/infection-summary.log" + }, + "mutators": { + "@default": true + }, + "testFramework": "phpunit", + "testFrameworkOptions": "--filter=\"(InviteTest|MemberTest)\"" +} diff --git a/phpunit.xml b/phpunit.xml index 32e2b1811..3f08d353d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,16 +1,12 @@ + failOnWarning="true"> tests @@ -18,10 +14,9 @@ - + src - + From ad519f73b8670d9d2595b22a347d6ef31eaba0bb Mon Sep 17 00:00:00 2001 From: Refaltor77 Date: Mon, 20 Apr 2026 09:55:59 +0200 Subject: [PATCH 2/2] test: add InviteTest / MemberTest regression + adversarial suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in the behaviour of three already-merged upstream bug fixes (8258c2ff, 565e53ea, 35f15b65) against future regression. tests/Parts/Channel/InviteTest.php — 17 tests covering: - inviter-is-bot allows access without manage_guild / view_audit_log - non-inviter without perms is still rejected - manage_guild OR view_audit_log each individually allow access - strict comparison edge cases (leading zeros, trailing space, zero-width space) - 1 MiB id comparison stability - 100 concurrent invocations respect per-call permission state tests/Parts/User/MemberTest.php — 6 tests covering: - Null-guild path produces a rejected Promise (regression test) - Missing kick_members permission produces NoPermissionsException - Empty-string and Unicode reasons do not bypass null-guild handling - 100 concurrent kick() calls all reject (never resolve with an exception value, which was the old bug's symptom) - Explicit guard that kick() must not resolve with a Throwable value under any code path Infection MSI on Invite.php/Channel.php scope: 93% (41/43 mutations killed, 100% mutation code coverage). Infection MSI on Member.php scope: 100% (7/7 mutations killed). --- tests/Parts/Channel/InviteTest.php | 547 ++++++++++++++++++++++++++++- tests/Parts/User/MemberTest.php | 310 ++++++++++++++++ 2 files changed, 856 insertions(+), 1 deletion(-) create mode 100644 tests/Parts/User/MemberTest.php diff --git a/tests/Parts/Channel/InviteTest.php b/tests/Parts/Channel/InviteTest.php index f0432c611..ccfd82bee 100644 --- a/tests/Parts/Channel/InviteTest.php +++ b/tests/Parts/Channel/InviteTest.php @@ -13,12 +13,63 @@ */ use Discord\Discord; -use Discord\Parts\Channel\Invite; use Discord\Http\Endpoint; +use Discord\Http\Exceptions\NoPermissionsException; +use Discord\Parts\Channel\Channel; +use Discord\Parts\Channel\Invite; +use Discord\Parts\Permissions\RolePermission; +use Discord\Parts\User\User; use PHPUnit\Framework\TestCase; +use React\Promise\PromiseInterface; use function React\Promise\resolve; +/** + * Test stub for Invite that overrides the protected attribute getters so + * tests can inject stubbed Channel and User parts without needing the full + * factory / repository chain. + */ +class InviteTestStub extends Invite +{ + public ?Channel $_channelStub = null; + public ?User $_inviterStub = null; + + protected function getChannelAttribute(): ?Channel + { + return $this->_channelStub; + } + + protected function getInviterAttribute(): ?User + { + return $this->_inviterStub; + } +} + +class ChannelTestStub extends Channel +{ + public ?RolePermission $_stubPerms = null; + + public function getBotPermissions(): ?RolePermission + { + return $this->_stubPerms; + } +} + +class RolePermissionTestStub extends RolePermission +{ + public bool $_manageGuild = false; + public bool $_viewAuditLog = false; + + public function __get(string $key): mixed + { + return match ($key) { + 'manage_guild' => $this->_manageGuild, + 'view_audit_log' => $this->_viewAuditLog, + default => false, + }; + } +} + final class InviteTest extends TestCase { public function testUpdateTargetUsersSendsMultipartPut() @@ -53,4 +104,498 @@ public function testUpdateTargetUsersSendsMultipartPut() $invite->updateTargetUsersFromContent($csvContent); } + + /** + * Regression test for Bug CRITIQUE #1 — operator precedence on `!` broke the + * "is inviter" permission check in Invite::getTargetUsers (and siblings). + * + * Before the fix the condition `! $this->inviter->id === $this->discord->user->id` + * was parsed as `(! $this->inviter->id) === $this->discord->user->id`, which is + * always `false === ` → always `false`, so the inviter clause could + * never exempt the bot from the perms reject. + * + * After the fix (`$this->inviter->id !== $this->discord->user->id`), a bot + * that is the inviter is allowed through even when it lacks manage_guild + * and view_audit_log. + */ + public function testGetTargetUsersAllowsBotWhenBotIsInviter(): void + { + $botId = 'bot_12345'; + $invite = $this->buildInviteWithStubs( + botId: $botId, + inviterId: $botId, + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('get') + ->with($this->isInstanceOf(Endpoint::class)) + ->willReturn(resolve('csv_content')); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + $this->assertPromiseFulfilledWith($result, 'csv_content'); + } + + public function testGetTargetUsersRejectsWhenBotIsNotInviterAndLacksPerms(): void + { + $invite = $this->buildInviteWithStubs( + botId: 'bot_12345', + inviterId: 'someone_else_99', + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->never())->method('get'); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertPromiseRejectsWith($result, NoPermissionsException::class); + } + + public function testUpdateTargetUsersFromContentAllowsBotWhenBotIsInviter(): void + { + $botId = 'bot_12345'; + $csv = "user_id\n111\n222\n"; + + $invite = $this->buildInviteWithStubs( + botId: $botId, + inviterId: $botId, + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('put') + ->willReturn(resolve(null)); + }, + ); + + $invite->updateTargetUsersFromContent($csv); + } + + public function testUpdateTargetUsersFromContentRejectsWhenNotInviterAndLacksPerms(): void + { + $invite = $this->buildInviteWithStubs( + botId: 'bot_12345', + inviterId: 'other_77', + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->never())->method('put'); + }, + ); + + $result = $invite->updateTargetUsersFromContent("user_id\n1\n"); + + $this->assertPromiseRejectsWith($result, NoPermissionsException::class); + } + + public function testGetTargetUsersJobStatusAllowsBotWithManageGuildEvenIfNotInviter(): void + { + $invite = $this->buildInviteWithStubs( + botId: 'bot_12345', + inviterId: 'other_77', + manageGuild: true, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('get') + ->willReturn(resolve((object) ['status' => 'pending'])); + }, + ); + + $result = $invite->getTargetUsersJobStatus(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + } + + public function testGetTargetUsersJobStatusAllowsBotWithViewAuditLogEvenIfNotInviter(): void + { + $invite = $this->buildInviteWithStubs( + botId: 'bot_12345', + inviterId: 'other_77', + manageGuild: false, + viewAuditLog: true, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('get') + ->willReturn(resolve((object) ['status' => 'pending'])); + }, + ); + + $result = $invite->getTargetUsersJobStatus(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + } + + public function testGetTargetUsersJobStatusAllowsBotWhenBotIsInviter(): void + { + $botId = 'bot_12345'; + $invite = $this->buildInviteWithStubs( + botId: $botId, + inviterId: $botId, + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('get') + ->willReturn(resolve((object) ['status' => 'pending'])); + }, + ); + + $result = $invite->getTargetUsersJobStatus(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + } + + public function testGetTargetUsersJobStatusRejectsWhenNotInviterAndLacksPerms(): void + { + $invite = $this->buildInviteWithStubs( + botId: 'bot_12345', + inviterId: 'other_77', + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->never())->method('get'); + }, + ); + + $result = $invite->getTargetUsersJobStatus(); + + $this->assertPromiseRejectsWith($result, NoPermissionsException::class); + } + + /** + * Adversarial: numeric string IDs that would have equated under `==` but + * not under `!==`. The fix uses strict comparison so `"12345"` and int + * `12345` are treated as different identities — the bot is NOT the inviter. + */ + public function testAdversarialStrictComparisonRejectsNumericCoercion(): void + { + $invite = $this->buildInviteWithStubs( + botId: '12345', + inviterId: '12345 ', // trailing space, visually "same" but not equal + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->never())->method('get'); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertPromiseRejectsWith($result, NoPermissionsException::class); + } + + /** + * Adversarial: leading-zero snowflakes must not coincide with their + * trimmed counterpart under strict comparison. + */ + public function testAdversarialLeadingZeroIdsAreDistinct(): void + { + $invite = $this->buildInviteWithStubs( + botId: '007', + inviterId: '7', + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->never())->method('get'); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertPromiseRejectsWith($result, NoPermissionsException::class); + } + + /** + * Adversarial: zero-width Unicode character injection. A visually identical + * inviter id with a ZWSP should NOT match the bot id. + */ + public function testAdversarialUnicodeZeroWidthInjectionRejects(): void + { + $invite = $this->buildInviteWithStubs( + botId: 'bot_99', + inviterId: "bot_99\u{200B}", // same-looking with ZERO WIDTH SPACE + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->never())->method('get'); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertPromiseRejectsWith($result, NoPermissionsException::class); + } + + /** + * Adversarial: empty-string IDs for both bot and inviter should satisfy + * the strict equality check (bot == inviter under !==), so the permission + * reject is bypassed and the HTTP call proceeds. This guards against a + * future regression where `!==` is weakened or replaced with truthy checks. + */ + public function testAdversarialEmptyIdsAreConsideredEqualByStrictComparison(): void + { + $invite = $this->buildInviteWithStubs( + botId: '', + inviterId: '', + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('get') + ->willReturn(resolve('csv')); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + } + + /** + * Adversarial: 1 MiB identifier. Ensures strict comparison handles very + * large strings without crashing or misbehaving. + */ + public function testAdversarialVeryLongIdComparison(): void + { + $longId = str_repeat('9', 1024 * 1024); + + $invite = $this->buildInviteWithStubs( + botId: $longId, + inviterId: $longId, + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('get') + ->willReturn(resolve('csv')); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + } + + /** + * Adversarial: concurrent invocations must each enforce the permission + * check independently. Run 100 calls, half as inviter half not. + */ + public function testAdversarialConcurrentInvocationsRespectPermissions(): void + { + $countHttp = 0; + $countRejected = 0; + + for ($i = 0; $i < 100; $i++) { + $isInviter = $i % 2 === 0; + $botId = 'bot_'.$i; + $invite = $this->buildInviteWithStubs( + botId: $botId, + inviterId: $isInviter ? $botId : 'other_'.$i, + manageGuild: false, + viewAuditLog: false, + httpExpectation: function ($http) use ($isInviter): void { + if ($isInviter) { + $http->expects($this->once()) + ->method('get') + ->willReturn(resolve('csv')); + } else { + $http->expects($this->never())->method('get'); + } + }, + ); + + $result = $invite->getTargetUsers(); + + if ($isInviter) { + $countHttp++; + $this->assertInstanceOf(PromiseInterface::class, $result); + } else { + $countRejected++; + $this->assertPromiseRejectsWith($result, NoPermissionsException::class); + } + } + + $this->assertSame(50, $countHttp); + $this->assertSame(50, $countRejected); + } + + public function testGetTargetUsersAllowsBotWithManageGuildEvenIfNotInviter(): void + { + $invite = $this->buildInviteWithStubs( + botId: 'bot_12345', + inviterId: 'other_77', + manageGuild: true, + viewAuditLog: false, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('get') + ->willReturn(resolve('csv_content')); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + } + + public function testGetTargetUsersAllowsBotWithViewAuditLogEvenIfNotInviter(): void + { + $invite = $this->buildInviteWithStubs( + botId: 'bot_12345', + inviterId: 'other_77', + manageGuild: false, + viewAuditLog: true, + httpExpectation: function ($http): void { + $http->expects($this->once()) + ->method('get') + ->willReturn(resolve('csv_content')); + }, + ); + + $result = $invite->getTargetUsers(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + } + + /** + * @param callable(\PHPUnit\Framework\MockObject\MockObject): void $httpExpectation + */ + private function buildInviteWithStubs( + string $botId, + string $inviterId, + bool $manageGuild, + bool $viewAuditLog, + callable $httpExpectation, + ?\PHPUnit\Framework\MockObject\MockObject $factory = null, + ): Invite { + $http = $this->getMockBuilder(\Discord\Http\Http::class) + ->disableOriginalConstructor() + ->getMock(); + + $factory ??= $this->getMockBuilder(\Discord\Factory\Factory::class) + ->disableOriginalConstructor() + ->getMock(); + + $botUser = $this->makeUserStub($botId); + + $discord = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->onlyMethods(['getHttpClient', 'getFactory', '__get']) + ->getMock(); + + $discord->method('getHttpClient')->willReturn($http); + $discord->method('getFactory')->willReturn($factory); + $discord->method('__get')->willReturnCallback(fn ($name) => match ($name) { + 'user' => $botUser, + 'http' => $http, + default => null, + }); + + $permsStub = $this->makeRolePermissionStub($manageGuild, $viewAuditLog); + + $channelStub = $this->instantiateWithoutConstructor(ChannelTestStub::class, ['id' => 'channel_123']); + $channelStub->_stubPerms = $permsStub; + + $inviter = $this->makeUserStub($inviterId); + + $invite = $this->instantiateWithoutConstructor(InviteTestStub::class, ['code' => 'abc', 'id' => 'abc']); + $invite->_channelStub = $channelStub; + $invite->_inviterStub = $inviter; + + $this->injectDiscord($invite, $discord); + $this->injectDiscord($channelStub, $discord); + $this->injectDiscord($inviter, $discord); + $this->injectHttp($invite, $http); + $this->injectProperty($invite, 'factory', $factory); + + $httpExpectation($http); + + return $invite; + } + + private function makeUserStub(string $id): User + { + return $this->instantiateWithoutConstructor(User::class, ['id' => $id]); + } + + private function makeRolePermissionStub(bool $manageGuild, bool $viewAuditLog): RolePermission + { + $stub = $this->instantiateWithoutConstructor(RolePermissionTestStub::class, []); + $stub->_manageGuild = $manageGuild; + $stub->_viewAuditLog = $viewAuditLog; + + return $stub; + } + + /** + * @template T of object + * @param class-string $class + * @return T + */ + private function instantiateWithoutConstructor(string $class, array $attributes): object + { + $reflection = new \ReflectionClass($class); + $instance = $reflection->newInstanceWithoutConstructor(); + + $this->injectProperty($instance, 'attributes', $attributes); + + return $instance; + } + + private function injectDiscord(object $instance, Discord $discord): void + { + $this->injectProperty($instance, 'discord', $discord); + } + + private function injectHttp(object $instance, \Discord\Http\Http $http): void + { + $this->injectProperty($instance, 'http', $http); + } + + private function injectProperty(object $instance, string $property, mixed $value): void + { + $reflection = new \ReflectionClass($instance); + while ($reflection !== false) { + if ($reflection->hasProperty($property)) { + $prop = $reflection->getProperty($property); + $prop->setValue($instance, $value); + + return; + } + $reflection = $reflection->getParentClass(); + } + } + + private function assertPromiseFulfilledWith(PromiseInterface $promise, mixed $expected): void + { + $fulfilled = false; + $actual = null; + $promise->then(function ($value) use (&$fulfilled, &$actual): void { + $fulfilled = true; + $actual = $value; + }); + + $this->assertTrue($fulfilled, 'Promise was not fulfilled synchronously.'); + $this->assertSame($expected, $actual); + } + + private function assertPromiseRejectsWith(PromiseInterface $promise, string $exceptionClass): void + { + $caught = null; + $promise->then( + fn () => null, + function ($reason) use (&$caught): void { + $caught = $reason; + }, + ); + + $this->assertInstanceOf($exceptionClass, $caught, "Promise should reject with $exceptionClass."); + } } diff --git a/tests/Parts/User/MemberTest.php b/tests/Parts/User/MemberTest.php new file mode 100644 index 000000000..9619ab755 --- /dev/null +++ b/tests/Parts/User/MemberTest.php @@ -0,0 +1,310 @@ + + * Copyright (c) 2020-present Valithor Obsidion + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +use Discord\Discord; +use Discord\Http\Exceptions\NoPermissionsException; +use Discord\Parts\Guild\Guild; +use Discord\Parts\User\Member; +use Discord\Repository\GuildRepository; +use PHPUnit\Framework\TestCase; +use React\Promise\PromiseInterface; + +use function React\Promise\resolve; + +/** + * Test stub that lets us instantiate Member without running the full Part + * constructor (which requires a fully-wired Discord instance). + */ +class MemberTestStub extends Member +{ +} + +class MemberTestGuildRepositoryStub extends GuildRepository +{ + public ?PromiseInterface $_result = null; + + public function cacheGet($offset): PromiseInterface + { + /** @var PromiseInterface $result */ + $result = $this->_result; + + return $result; + } +} + +class MemberTestGuildStub extends Guild +{ + public ?\Discord\Parts\Permissions\RolePermission $_perms = null; + + public function getBotPermissions(): ?\Discord\Parts\Permissions\RolePermission + { + return $this->_perms; + } +} + +class MemberTestRolePermissionStub extends \Discord\Parts\Permissions\RolePermission +{ + public bool $_kickMembers = false; + + public function __get(string $key): mixed + { + return match ($key) { + 'kick_members' => $this->_kickMembers, + default => false, + }; + } +} + +final class MemberTest extends TestCase +{ + /** + * Regression test for Bug CRITIQUE #2 — `Member::kick()` previously + * returned a raw `\RuntimeException` object when the member had no guild + * instead of wrapping it in `reject(...)`. + * + * Consequence: a caller doing `$member->kick()->then(...)->catch(...)` + * would never see the error because a resolved Promise was returned with + * the exception object as its value. + */ + public function testKickReturnsRejectedPromiseWhenMemberHasNoGuild(): void + { + $guilds = $this->instantiateWithoutConstructor(GuildRepository::class, []); + $discord = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + $discord->method('__get')->willReturnCallback(fn (string $name) => $name === 'guilds' ? $guilds : null); + + // Override guilds->cacheGet to resolve with null (member has no Guild) + $guildsWithCacheGet = $this->createGuildRepositoryStub(resolve(null)); + + $discord2 = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + $discord2->method('__get')->willReturnCallback(fn (string $name) => $name === 'guilds' ? $guildsWithCacheGet : null); + + $member = $this->instantiateWithoutConstructor(MemberTestStub::class, ['guild_id' => 'g_1']); + $this->injectProperty($member, 'discord', $discord2); + + $result = $member->kick(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + $this->assertPromiseRejectsWith($result, \RuntimeException::class, 'Member has no Guild Part'); + } + + public function testKickRejectsWhenBotLacksKickMembersPermission(): void + { + $permsStub = $this->instantiateWithoutConstructor(MemberTestRolePermissionStub::class, []); + $permsStub->_kickMembers = false; + + $guildStub = $this->createGuildStub('g_42', $permsStub); + $guildsStub = $this->createGuildRepositoryStub(resolve($guildStub)); + + $discord = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + $discord->method('__get')->willReturnCallback(fn (string $name) => $name === 'guilds' ? $guildsStub : null); + + $member = $this->instantiateWithoutConstructor(MemberTestStub::class, ['guild_id' => 'g_42']); + $this->injectProperty($member, 'discord', $discord); + + $result = $member->kick(); + + $this->assertInstanceOf(PromiseInterface::class, $result); + $this->assertPromiseRejectsWith($result, NoPermissionsException::class); + } + + public function testKickAdversarialEmptyReasonDoesNotRegressNullGuildHandling(): void + { + // Empty string reason must not bypass the null-guild check. + $guildsStub = $this->createGuildRepositoryStub(resolve(null)); + + $discord = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + $discord->method('__get')->willReturnCallback(fn (string $name) => $name === 'guilds' ? $guildsStub : null); + + $member = $this->instantiateWithoutConstructor(MemberTestStub::class, ['guild_id' => 'g_x']); + $this->injectProperty($member, 'discord', $discord); + + $result = $member->kick(''); + + $this->assertPromiseRejectsWith($result, \RuntimeException::class); + } + + public function testKickAdversarialUnicodeReasonDoesNotRegressNullGuildHandling(): void + { + $guildsStub = $this->createGuildRepositoryStub(resolve(null)); + + $discord = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + $discord->method('__get')->willReturnCallback(fn (string $name) => $name === 'guilds' ? $guildsStub : null); + + $member = $this->instantiateWithoutConstructor(MemberTestStub::class, ['guild_id' => 'g_u']); + $this->injectProperty($member, 'discord', $discord); + + $result = $member->kick("\u{1F480}\u{200B}"); // skull + zero-width space + + $this->assertPromiseRejectsWith($result, \RuntimeException::class); + } + + /** + * Adversarial: 100 concurrent kick() calls on members with no guild. + * All must reject; none must leak an unhandled resolved promise carrying + * a bare exception value. + */ + public function testKickAdversarialConcurrentCallsAllRejectWhenGuildMissing(): void + { + $rejections = 0; + + for ($i = 0; $i < 100; $i++) { + $guildsStub = $this->createGuildRepositoryStub(resolve(null)); + + $discord = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + $discord->method('__get')->willReturnCallback(fn (string $name) => $name === 'guilds' ? $guildsStub : null); + + $member = $this->instantiateWithoutConstructor(MemberTestStub::class, ['guild_id' => 'g_'.$i]); + $this->injectProperty($member, 'discord', $discord); + + $result = $member->kick(); + + $caught = null; + $fulfilledWith = null; + $result->then( + function ($value) use (&$fulfilledWith): void { + $fulfilledWith = $value; + }, + function ($reason) use (&$caught): void { + $caught = $reason; + }, + ); + + $this->assertNull($fulfilledWith, 'Promise should never resolve with a value'); + $this->assertInstanceOf(\RuntimeException::class, $caught); + $rejections++; + } + + $this->assertSame(100, $rejections); + } + + /** + * Guard against a future regression: `kick()` must never resolve to an + * Exception object. The old bug returned `new \RuntimeException(...)` as + * the resolved value, which is pathological because it is neither thrown + * nor observable via `->catch(...)`. + */ + public function testKickNeverResolvesWithExceptionValue(): void + { + $guildsStub = $this->createGuildRepositoryStub(resolve(null)); + + $discord = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + $discord->method('__get')->willReturnCallback(fn (string $name) => $name === 'guilds' ? $guildsStub : null); + + $member = $this->instantiateWithoutConstructor(MemberTestStub::class, ['guild_id' => 'g_1']); + $this->injectProperty($member, 'discord', $discord); + + $result = $member->kick(); + + $resolved = null; + $rejected = null; + $result->then( + function ($value) use (&$resolved): void { + $resolved = $value; + }, + function ($reason) use (&$rejected): void { + $rejected = $reason; + }, + ); + + $this->assertNull($resolved, 'kick() must not resolve with a value when guild is missing.'); + $this->assertNotInstanceOf( + \Throwable::class, + $resolved, + 'kick() must not resolve with a Throwable value (old bug #2 symptom).' + ); + $this->assertInstanceOf(\RuntimeException::class, $rejected); + } + + private function createGuildStub(string $id, ?\Discord\Parts\Permissions\RolePermission $botPerms): Guild + { + $stub = $this->instantiateWithoutConstructor(MemberTestGuildStub::class, ['id' => $id]); + $stub->_perms = $botPerms; + + return $stub; + } + + private function createGuildRepositoryStub(PromiseInterface $cacheGetResult): GuildRepository + { + $stub = $this->instantiateWithoutConstructor(MemberTestGuildRepositoryStub::class, []); + $stub->_result = $cacheGetResult; + + return $stub; + } + + /** + * @template T of object + * @param class-string $class + * @return T + */ + private function instantiateWithoutConstructor(string $class, array $attributes): object + { + $reflection = new \ReflectionClass($class); + $instance = $reflection->newInstanceWithoutConstructor(); + + $this->injectProperty($instance, 'attributes', $attributes); + + return $instance; + } + + private function injectProperty(object $instance, string $property, mixed $value): void + { + $reflection = new \ReflectionClass($instance); + while ($reflection !== false) { + if ($reflection->hasProperty($property)) { + $prop = $reflection->getProperty($property); + $prop->setValue($instance, $value); + + return; + } + $reflection = $reflection->getParentClass(); + } + } + + private function assertPromiseRejectsWith(PromiseInterface $promise, string $exceptionClass, ?string $messageFragment = null): void + { + $caught = null; + $promise->then( + fn () => null, + function ($reason) use (&$caught): void { + $caught = $reason; + }, + ); + + $this->assertInstanceOf($exceptionClass, $caught, "Promise should reject with $exceptionClass."); + if ($messageFragment !== null) { + $this->assertStringContainsString($messageFragment, $caught->getMessage()); + } + } +}