OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
330
tests/Unit/app/Domain/Comments/Services/CommentsServiceTest.php
Normal file
330
tests/Unit/app/Domain/Comments/Services/CommentsServiceTest.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Comments\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Comments\Services\Comments;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reactions\Services\Reactions as ReactionsService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Comments service: reaction orchestration plus the project-scoped
|
||||
* authorization fences (comments are read/moderated against the host entity's REAL project).
|
||||
*/
|
||||
class CommentsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/** The session user used across the reaction tests. */
|
||||
private const SESSION_USER = 5;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
session(['userdata.id' => self::SESSION_USER]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the service. By default the comment repository resolves a real (ticket) comment in
|
||||
* project 9 and the permission engine allows everything; pass overrides to exercise denials.
|
||||
*/
|
||||
private function makeService(
|
||||
ReactionsService $reactionsService,
|
||||
?CommentRepository $repo = null,
|
||||
?PermissionService $permissions = null,
|
||||
): Comments {
|
||||
$service = new Comments(
|
||||
$repo ?? $this->defaultRepo(),
|
||||
$this->make(ProjectService::class),
|
||||
$this->make(LanguageCore::class),
|
||||
$reactionsService,
|
||||
);
|
||||
$service->setPermissionService($permissions ?? $this->allowingPermissions());
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function defaultRepo(): CommentRepository
|
||||
{
|
||||
return $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => self::SESSION_USER, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
]);
|
||||
}
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => fn () => null,
|
||||
'currentUserCan' => fn () => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
'currentUserCan' => fn () => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function noopReactions(): ReactionsService
|
||||
{
|
||||
return $this->make(ReactionsService::class, []);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Reaction orchestration (existing behaviour, now session-pinned).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_toggle_rejects_unknown_reaction_type(): void
|
||||
{
|
||||
$added = false;
|
||||
$removed = false;
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => false,
|
||||
'addReaction' => function () use (&$added) {
|
||||
$added = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'removeReaction' => function () use (&$removed) {
|
||||
$removed = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'bogus');
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertFalse($added, 'No reaction should be added for an unknown type');
|
||||
$this->assertFalse($removed, 'No reaction should be removed for an unknown type');
|
||||
}
|
||||
|
||||
public function test_toggle_off_removes_existing_same_reaction(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$added = false;
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => [['reaction' => 'thumbsup']],
|
||||
'removeReaction' => function ($userId, $module, $moduleId, $reaction) use (&$removeCalls) {
|
||||
$removeCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function () use (&$added) {
|
||||
$added = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertFalse($added, 'Toggling off should not add a reaction');
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $removeCalls);
|
||||
}
|
||||
|
||||
public function test_toggle_on_replaces_existing_sentiment(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$addCalls = [];
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => function ($userId, $module, $moduleId, $reaction = '') {
|
||||
if ($reaction !== '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [['reaction' => 'thumbsdown']];
|
||||
},
|
||||
'removeReaction' => function ($userId, $module, $moduleId, $reaction) use (&$removeCalls) {
|
||||
$removeCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsdown']], $removeCalls);
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls);
|
||||
}
|
||||
|
||||
public function test_toggle_on_with_no_existing_reactions_just_adds(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$addCalls = [];
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => false,
|
||||
'removeReaction' => function (...$args) use (&$removeCalls) {
|
||||
$removeCalls[] = $args;
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame([], $removeCalls, 'Nothing to remove when there are no existing reactions');
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_flattens_user_reaction_codes(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getEntityReactionsWithUsers' => fn () => ['thumbsup' => ['count' => 2]],
|
||||
'getUserReactions' => fn () => [
|
||||
['reaction' => 'thumbsup'],
|
||||
['reaction' => 'heart'],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->getCommentReactions(99, 5);
|
||||
|
||||
$this->assertSame(['thumbsup' => ['count' => 2]], $result['reactions']);
|
||||
$this->assertSame(['thumbsup', 'heart'], $result['userReactions']);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_handles_anonymous_user(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getEntityReactionsWithUsers' => fn () => [],
|
||||
'getUserReactions' => fn () => [['reaction' => 'thumbsup']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->getCommentReactions(99, 0);
|
||||
|
||||
$this->assertSame([], $result['reactions']);
|
||||
$this->assertSame([], $result['userReactions']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Project-scoped authorization fences (the IDOR hardening).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_toggle_reaction_uses_session_user_not_caller_supplied_id(): void
|
||||
{
|
||||
// A caller passes someone else's id; the service must react as the SESSION user only.
|
||||
$addCalls = [];
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => false,
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService($reactionsService)->toggleCommentReaction(999, 99, 'thumbsup');
|
||||
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls, 'Reaction must use the session user, not the caller-supplied id');
|
||||
}
|
||||
|
||||
public function test_toggle_reaction_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// Valid reaction type so the method reaches the project fence (not the type guard). A denied
|
||||
// cross-project comment returns false — same as a missing comment, so no existence oracle.
|
||||
$reactions = $this->make(ReactionsService::class, ['getReactionType' => fn () => 'positive']);
|
||||
$service = $this->makeService($reactions, $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup'));
|
||||
}
|
||||
|
||||
public function test_get_comments_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// getComments resolves the host entity's project and authorizes VIEW there; a denying
|
||||
// engine must throw before any comment data is returned.
|
||||
$service = $this->makeService($this->noopReactions(), $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->getComments('ticket', 123);
|
||||
}
|
||||
|
||||
public function test_delete_comment_denies_non_author_moderation_cross_project(): void
|
||||
{
|
||||
// Comment belongs to another user; the session user is NOT a moderator in the comment's
|
||||
// project (denying engine) -> deleteComment must refuse and never reach the repo delete.
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => 7, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
'deleteComment' => function (): bool {
|
||||
throw new \RuntimeException('delete must not run when moderation is denied');
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->deleteComment(99));
|
||||
}
|
||||
|
||||
public function test_delete_comment_allows_the_author_without_moderation(): void
|
||||
{
|
||||
$deleted = null;
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
// Authored by the session user -> author branch, no moderation check needed.
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => self::SESSION_USER, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
'deleteComment' => function ($id) use (&$deleted): bool {
|
||||
$deleted = $id;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
// Denying engine proves the author path does NOT depend on comments.moderate.
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertTrue($service->deleteComment(99));
|
||||
$this->assertSame(99, $deleted);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// A denied cross-project comment returns the SAME empty payload as a missing comment
|
||||
// (soft-deny), so reactor identities/sentiment never leak AND missing vs unauthorized are
|
||||
// indistinguishable — no commentId existence oracle.
|
||||
$service = $this->makeService($this->noopReactions(), $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->assertSame(['reactions' => [], 'userReactions' => []], $service->getCommentReactions(99, self::SESSION_USER));
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_returns_empty_for_missing_comment(): void
|
||||
{
|
||||
// A missing comment yields the same empty payload a DENIED comment does (see above), so the
|
||||
// two are indistinguishable — no commentId existence oracle.
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => false,
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
]);
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertSame(['reactions' => [], 'userReactions' => []], $service->getCommentReactions(404, self::SESSION_USER));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user