OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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 {}
}

View 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);
}
}

View 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);
}
}