OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
264
tests/Unit/app/Domain/Api/Controllers/JsonrpcTest.php
Normal file
264
tests/Unit/app/Domain/Api/Controllers/JsonrpcTest.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Application;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Bootstrap\LoadConfig;
|
||||
use Leantime\Core\Bootstrap\SetRequestForConsole;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Api\Controllers\Jsonrpc;
|
||||
|
||||
/**
|
||||
* The controller now builds its envelopes through the JsonRpcResponse / JsonRpcErrorResponse
|
||||
* response types, so these tests assert on the actual JSON body of the returned Response
|
||||
* (behavior) rather than on the Template::displayJson() call that used to construct it.
|
||||
*/
|
||||
class JsonrpcTest extends \Unit\TestCase
|
||||
{
|
||||
private Jsonrpc $controller;
|
||||
|
||||
private Template $template;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->app = new Application(APP_ROOT);
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
|
||||
$this->app->boot();
|
||||
$this->app['view'] = $this->createMock(\Illuminate\View\Factory::class);
|
||||
$this->app['session'] = $this->createMock(\Illuminate\Session\SessionManager::class);
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
|
||||
// Jsonrpc::init() now type-hints PermissionEnforcer (resolved via app()->call in the
|
||||
// base Controller constructor). Bind a no-op mock so the controller builds without
|
||||
// pulling in the full permission engine (PermissionService -> Repository -> Db), which
|
||||
// this minimal test container can't resolve. These tests don't exercise authorization.
|
||||
$this->app->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class));
|
||||
|
||||
$this->template = $this->createMock(Template::class);
|
||||
$language = $this->createMock(Language::class);
|
||||
$this->controller = new Jsonrpc($this->app['request'], $this->template, $language);
|
||||
$_SERVER['REQUEST_METHOD'] = 'post';
|
||||
}
|
||||
|
||||
private function bodyOf($response): array
|
||||
{
|
||||
return json_decode($response->getContent(), true);
|
||||
}
|
||||
|
||||
public function test_method_string_parsing()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertIsArray($body);
|
||||
$this->assertArrayHasKey('jsonrpc', $body);
|
||||
$this->assertEquals('2.0', $body['jsonrpc']);
|
||||
}
|
||||
|
||||
public function test_invalid_method_string()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'invalid.method.string',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertArrayHasKey('error', $body);
|
||||
$this->assertEquals(-32602, $body['error']['code']);
|
||||
}
|
||||
|
||||
public function test_missing_json_rpc_version()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertArrayHasKey('error', $body);
|
||||
$this->assertEquals(-32600, $body['error']['code']);
|
||||
}
|
||||
|
||||
public function test_batch_request()
|
||||
{
|
||||
$params = [
|
||||
[
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
],
|
||||
[
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 2],
|
||||
'id' => 2,
|
||||
'jsonrpc' => '2.0',
|
||||
],
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
// The batch response is an array with one envelope per sub-request.
|
||||
$this->assertIsArray($body);
|
||||
$this->assertCount(2, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The riskiest behavioral change: the service-call catch is now catch(\Throwable) and an
|
||||
* UNEXPECTED throwable must be collapsed to a generic -32000 with its message NOT leaked.
|
||||
* Driven end-to-end through the controller by rebinding the (@api) Comments service to a
|
||||
* stub that throws.
|
||||
*/
|
||||
public function test_service_throwing_unknown_error_is_generic_and_not_leaked()
|
||||
{
|
||||
$secret = 'super-secret-internal-detail';
|
||||
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class($secret)
|
||||
{
|
||||
public function __construct(private string $secret) {}
|
||||
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \RuntimeException($this->secret);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 42,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$response = $this->controller->post($params);
|
||||
$body = $this->bodyOf($response);
|
||||
|
||||
$this->assertSame(-32000, $body['error']['code']);
|
||||
$this->assertSame('Server error', $body['error']['message']);
|
||||
$this->assertSame(42, $body['id']);
|
||||
$this->assertStringNotContainsString($secret, $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed Leantime exception thrown by a service maps to ITS JSON-RPC code (here -32001 for
|
||||
* AuthorizationException), not the generic -32000, with the request id preserved.
|
||||
*/
|
||||
public function test_service_throwing_typed_exception_maps_to_its_rpc_code()
|
||||
{
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class
|
||||
{
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \Leantime\Core\Exceptions\AuthorizationException;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 7,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertSame(-32001, $body['error']['code']);
|
||||
$this->assertSame(7, $body['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A notification (no id) whose service call fails must NOT be responded to — the controller
|
||||
* returns an empty 200 instead of a JSON-RPC error envelope (JSON-RPC 2.0).
|
||||
*/
|
||||
public function test_notification_service_failure_returns_empty_200()
|
||||
{
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class
|
||||
{
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \RuntimeException('boom');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// No 'id' => JSON-RPC notification.
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$response = $this->controller->post($params);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertSame('', $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* @api detection must only recognize the tag at the START of a docblock line (" * @api").
|
||||
* A method whose docblock merely MENTIONS @api in prose (e.g. a de-@api'd internal helper
|
||||
* documented as "not exposed, unlike @api methods") must NOT become JSON-RPC reachable —
|
||||
* regression guard for the IDOR fix where "Not @api:" still matched the old /@api\b/ regex.
|
||||
*/
|
||||
public function test_api_detection_requires_the_tag_at_a_docblock_line_start(): void
|
||||
{
|
||||
$isApiMethod = new \ReflectionMethod(Jsonrpc::class, 'isApiMethod');
|
||||
$isApiMethod->setAccessible(true);
|
||||
$invoke = fn (string $class, string $method): bool => $isApiMethod->invoke($this->controller, $class, $method);
|
||||
|
||||
// A genuine ` * @api` docblock line IS recognized.
|
||||
$this->assertTrue($invoke(IsApiFixture::class, 'realApiMethod'));
|
||||
// A prose mention of @api (and a method with no docblock) must NOT be recognized.
|
||||
$this->assertFalse($invoke(IsApiFixture::class, 'proseMentionMethod'));
|
||||
$this->assertFalse($invoke(IsApiFixture::class, 'noDocblockMethod'));
|
||||
|
||||
// The real de-@api'd internal helpers must NOT be JSON-RPC reachable (IDOR fixes):
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Clients\Services\Clients::class, 'getUserClients'));
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Users\Services\Users::class, 'setProfilePicture'));
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Users\Services\Users::class, 'editOwn'));
|
||||
// ...while a genuine @api service method stays reachable.
|
||||
$this->assertTrue($invoke(\Leantime\Domain\Clients\Services\Clients::class, 'getAll'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture for isApiMethod() docblock-detection tests.
|
||||
*/
|
||||
class IsApiFixture
|
||||
{
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public function realApiMethod(): void {}
|
||||
|
||||
/**
|
||||
* @internal Not exposed over JSON-RPC, unlike @api methods — a prose mention only.
|
||||
*/
|
||||
public function proseMentionMethod(): void {}
|
||||
|
||||
public function noDocblockMethod(): void {}
|
||||
}
|
||||
220
tests/Unit/app/Domain/Api/Services/ApiServiceTest.php
Normal file
220
tests/Unit/app/Domain/Api/Services/ApiServiceTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Api\Services;
|
||||
|
||||
use Leantime\Domain\Api\Repositories\Api as ApiRepository;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Api service helpers extracted during the thin-controller
|
||||
* refactor (project relation reconciliation, API key creation/update, image
|
||||
* response building and user filtering).
|
||||
*/
|
||||
class ApiServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Api service, allowing each dependency to be overridden with
|
||||
* a stub so we can observe the persistence calls.
|
||||
*/
|
||||
private function makeService(
|
||||
?ApiRepository $apiRepo = null,
|
||||
?UserRepository $userRepo = null,
|
||||
?ProjectRepository $projectRepo = null,
|
||||
?MenuRepository $menuRepo = null,
|
||||
): ApiService {
|
||||
return new ApiService(
|
||||
$apiRepo ?? $this->make(ApiRepository::class),
|
||||
$userRepo ?? $this->make(UserRepository::class),
|
||||
$projectRepo ?? $this->make(ProjectRepository::class),
|
||||
$menuRepo ?? $this->make(MenuRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_project_relation_ids_extracts_project_ids(): void
|
||||
{
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'getUserProjectRelation' => fn () => [
|
||||
['projectId' => 5],
|
||||
['projectId' => 9],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->makeService(projectRepo: $projectRepo)->getProjectRelationIds(3);
|
||||
|
||||
$this->assertSame([5, 9], $result);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_sets_relations_when_projects_selected(): void
|
||||
{
|
||||
$editCalledWith = null;
|
||||
$deleteCalled = false;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '77',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function ($id, $projects) use (&$editCalledWith) {
|
||||
$editCalledWith = [$id, $projects];
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function () use (&$deleteCalled) {
|
||||
$deleteCalled = true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key', 'role' => '20'], ['3', '4']);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame('77', $result['id']);
|
||||
// id is cast to int when reconciling relations.
|
||||
$this->assertSame([77, ['3', '4']], $editCalledWith);
|
||||
$this->assertFalse($deleteCalled);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_clears_relations_when_leading_zero(): void
|
||||
{
|
||||
$editCalled = false;
|
||||
$deleteCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '88',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function () use (&$editCalled) {
|
||||
$editCalled = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function ($id) use (&$deleteCalledWith) {
|
||||
$deleteCalledWith = $id;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], ['0']);
|
||||
|
||||
$this->assertFalse($editCalled);
|
||||
$this->assertSame(88, $deleteCalledWith);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_skips_reconcile_when_no_projects(): void
|
||||
{
|
||||
$touched = false;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '5',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function () use (&$touched) {
|
||||
$touched = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function () use (&$touched) {
|
||||
$touched = true;
|
||||
},
|
||||
]);
|
||||
|
||||
// null projects and empty array both mean "do nothing".
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], null);
|
||||
|
||||
$this->assertFalse($touched);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_returns_false_when_user_not_created(): void
|
||||
{
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], ['3']);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_update_api_key_edits_user_and_reconciles_relations(): void
|
||||
{
|
||||
$editUserCalledWith = null;
|
||||
$editRelationsCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'firstname' => 'Old',
|
||||
'username' => 'lt_old',
|
||||
'status' => 'i',
|
||||
'role' => '10',
|
||||
],
|
||||
'editUser' => function ($values, $id) use (&$editUserCalledWith) {
|
||||
$editUserCalledWith = [$values, $id];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function ($id, $projects) use (&$editRelationsCalledWith) {
|
||||
$editRelationsCalledWith = [$id, $projects];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->updateApiKey(12, ['firstname' => 'New', 'status' => 'a', 'role' => '20'], ['7']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
// Posted firstname/status/role applied, username preserved from row, source forced to 'api'.
|
||||
$this->assertSame(12, $editUserCalledWith[1]);
|
||||
$this->assertSame('New', $editUserCalledWith[0]['firstname']);
|
||||
$this->assertSame('a', $editUserCalledWith[0]['status']);
|
||||
$this->assertSame('20', $editUserCalledWith[0]['role']);
|
||||
$this->assertSame('lt_old', $editUserCalledWith[0]['user']);
|
||||
$this->assertSame('api', $editUserCalledWith[0]['source']);
|
||||
$this->assertSame([12, ['7']], $editRelationsCalledWith);
|
||||
}
|
||||
|
||||
public function test_update_api_key_falls_back_to_row_values_when_not_posted(): void
|
||||
{
|
||||
$editUserCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'firstname' => 'Old',
|
||||
'username' => 'lt_old',
|
||||
'status' => 'i',
|
||||
'role' => '10',
|
||||
],
|
||||
'editUser' => function ($values) use (&$editUserCalledWith) {
|
||||
$editUserCalledWith = $values;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'deleteAllProjectRelations' => fn () => null,
|
||||
]);
|
||||
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->updateApiKey(12, [], null);
|
||||
|
||||
$this->assertSame('Old', $editUserCalledWith['firstname']);
|
||||
$this->assertSame('i', $editUserCalledWith['status']);
|
||||
$this->assertSame('10', $editUserCalledWith['role']);
|
||||
}
|
||||
|
||||
public function test_update_api_key_throws_on_invalid_id(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
$this->makeService()->updateApiKey(0, [], null);
|
||||
}
|
||||
}
|
||||
45
tests/Unit/app/Domain/Api/Services/I18nServiceTest.php
Normal file
45
tests/Unit/app/Domain/Api/Services/I18nServiceTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Api\Services;
|
||||
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Domain\Api\Services\I18n as I18nService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the I18n service that assembles the JavaScript i18n
|
||||
* dictionary payload extracted from the I18n controller.
|
||||
*/
|
||||
class I18nServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_build_js_dictionary_embeds_dictionary_and_date_overrides(): void
|
||||
{
|
||||
$language = $this->make(Language::class, [
|
||||
'ini_array' => [
|
||||
'some.key' => 'Some value',
|
||||
'language.dateformat' => 'IGNORED',
|
||||
'language.timeformat' => 'IGNORED',
|
||||
],
|
||||
'__' => fn (string $index) => $index === 'language.dateformat' ? 'm/d/Y' : 'H:i',
|
||||
]);
|
||||
|
||||
$payload = (new I18nService($language))->buildJsDictionary();
|
||||
|
||||
// The JS wrapper is present.
|
||||
$this->assertStringContainsString('leantime', $payload);
|
||||
$this->assertStringContainsString('i18n', $payload);
|
||||
$this->assertStringContainsString('dictionary:', $payload);
|
||||
|
||||
// Extract the JSON dictionary and assert the overrides won.
|
||||
preg_match('/dictionary: (\{.*\}),/', $payload, $matches);
|
||||
$this->assertNotEmpty($matches, 'Could not find dictionary JSON in payload');
|
||||
|
||||
$decoded = json_decode($matches[1], true);
|
||||
$this->assertSame('Some value', $decoded['some.key']);
|
||||
$this->assertSame('m/d/Y', $decoded['language.dateformat']);
|
||||
$this->assertSame('H:i', $decoded['language.timeformat']);
|
||||
$this->assertArrayHasKey('usersettings.timezone', $decoded);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user