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,175 @@
<?php
namespace Acceptance\API;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Install;
use Tests\Support\Page\Acceptance\Login;
class ApiCest
{
private string $apiKey;
private Login $loginPage;
private Install $installPage;
public function _before(AcceptanceTester $I, Login $loginPage, Install $installPage)
{
$this->loginPage = $loginPage;
$this->installPage = $installPage;
// Ensure database is installed before running API tests
$this->installPage->install(
'test@leantime.io',
'Test123456!',
'John',
'Smith',
'Smith & Co'
);
}
#[Group('api')]
#[Depends('Acceptance\LoginCest:loginSuccessfully')]
public function createAPIKey(AcceptanceTester $I)
{
$this->loginPage->login('test@leantime.io', 'test');
// Generate API key if not exists
$I->amOnPage('setting/editCompanySettings#/api/newApiKey');
$I->waitForElementVisible('#firstname', 120);
$I->fillField(['id' => 'firstname'], 'APIUser');
$I->selectOption(['id' => 'role'], 'Administrator');
$I->waitForElementClickable('#project_1');
$I->wait(2);
$I->checkOption('#project_1');
$I->clickWithRetry('#save');
$I->waitForElement('#apiKey');
$this->apiKey = $I->grabValueFrom('#apiKey');
$I->resetCookie('leantime_session', []);
$I->deleteSessionSnapshot('leantime_session');
}
#[Group('api')]
#[Depends('createAPIKey')]
public function testJsonRpcEndpoint(AcceptanceTester $I)
{
$I->haveHttpHeader('Content-Type', 'application/json');
$I->haveHttpHeader('x-api-key', $this->apiKey);
$I->sendPost('/api/jsonrpc', [
'jsonrpc' => '2.0',
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 1,
]);
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType([
'jsonrpc' => 'string',
'result' => 'array',
'id' => 'integer',
]);
}
#[Group('api')]
#[Depends('createAPIKey')]
public function testJsonRpcEndpointStringId(AcceptanceTester $I)
{
$I->haveHttpHeader('Content-Type', 'application/json');
$I->haveHttpHeader('x-api-key', $this->apiKey);
$I->sendPost('/api/jsonrpc', [
'jsonrpc' => '2.0',
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 'one',
]);
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType([
'jsonrpc' => 'string',
'result' => 'array',
'id' => 'string',
]);
}
#[Group('api')]
#[Depends('createAPIKey')]
public function testInvalidJsonRpcRequest(AcceptanceTester $I)
{
$I->haveHttpHeader('Content-Type', 'application/json');
$I->haveHttpHeader('x-api-key', $this->apiKey);
$I->sendPost('/api/jsonrpc', [
'jsonrpc' => '2.0',
'method' => 'invalid.method',
'params' => ['projectId' => 1],
'id' => 1,
]);
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType([
'jsonrpc' => 'string',
'error' => [
'code' => 'integer',
'message' => 'string',
'data' => 'string',
],
'id' => 'integer',
]);
}
#[Group('api')]
#[Depends('createAPIKey')]
public function testValidReturnId(AcceptanceTester $I)
{
$I->haveHttpHeader('Content-Type', 'application/json');
$I->haveHttpHeader('x-api-key', $this->apiKey);
$I->sendPost('/api/jsonrpc', [
'jsonrpc' => '2.0',
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 123,
]);
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson([
'jsonrpc' => '2.0',
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 'integer',
]);
}
#[Group('api')]
#[Depends('createAPIKey')]
public function testMissingApiKey(AcceptanceTester $I)
{
$I->haveHttpHeader('Content-Type', 'application/json');
$I->sendPost('/api/jsonrpc', [
'jsonrpc' => '2.0',
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 1,
]);
$I->seeResponseCodeIs(401);
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace Acceptance\API;
use Codeception\Attribute\Group;
use PHPUnit\Framework\Assert;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Install;
/**
* Bearer-token JSON-RPC contract test.
*
* Sibling to ApiCest (x-api-key auth). Exists because the JSON-RPC endpoint accepts three auth
* modes (session cookie, x-api-key, Bearer token) and only the first two had coverage. The Bearer
* path is what the mobile app + any AdvancedAuth integrator hits, and a permission-engine deploy in
* 2026-06 silently broke it (every gated read 401'd) without CI noticing.
*
* Each test is self-contained: the minted token lives in zp_access_tokens and the Db module deletes
* haveInDatabase() rows after each test, so a method keeps everything it needs alive within itself
* (a multi-method #[Depends] chain would lose the token between the mint and the assertions). Each
* authed call asserts 200, JSON, and NOT a -32001 permission denial — that last assertion is the
* regression gate.
*
* Two scenarios:
* - bearerAuthHonorsGatedReads — the OWNER (role 50). Owner short-circuits project-role resolution.
* - nonManagerBearerHonorsProjectScopedReads — an EDITOR (role 20). Sub-manager roles run a wholly
* different authorization path (getProjectRole + isUserAssignedToProject + a resolved projectId),
* which owner-only testing never exercises. This is the path that would silently break for real
* non-admin mobile users.
*/
class BearerApiCest
{
private string $bearerToken;
public function _before(AcceptanceTester $I, Install $installPage)
{
// Fresh install — same fixture as ApiCest so this Cest can run standalone or alongside it.
$installPage->install('test@leantime.io', 'Test123456!', 'John', 'Smith', 'Smith & Co');
}
#[Group('bearer-api')]
public function bearerAuthHonorsGatedReads(AcceptanceTester $I)
{
// 1) No bearer → 401. Done first, before any Authorization header is set (Codeception
// headers are sticky across requests within a test).
$I->haveHttpHeader('Content-Type', 'application/json');
$I->sendPost('/api/jsonrpc', json_encode([
'jsonrpc' => '2.0',
'method' => 'leantime.rpc.Tickets.Tickets.getAllOpenUserTickets',
'params' => new \stdClass,
'id' => 1,
], JSON_THROW_ON_ERROR));
$I->seeResponseCodeIs(401);
// 2) Mint a Bearer token directly in the DB — no UI flow, no AdvancedAuth plugin. A token
// is just a random string whose sha256 is stored in zp_access_tokens, exactly as
// AccessTokenRepository::createToken persists it. (Done via the Db module because the
// Laravel container is not reliably bootstrapped against the test DB in the acceptance
// process, so app()->getUserByEmail() resolves the wrong connection.)
$userId = $I->grabFromDatabase('zp_user', 'id', ['username' => 'test@leantime.io']);
Assert::assertNotEmpty($userId, 'Test user not found after install');
$this->bearerToken = bin2hex(random_bytes(20)); // 40-char opaque token
$I->haveInDatabase('zp_access_tokens', [
'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth',
'tokenable_id' => (int) $userId,
'name' => 'bearer-api-cest',
'token' => hash('sha256', $this->bearerToken),
'abilities' => json_encode(['*']),
'created_at' => date('Y-m-d H:i:s'),
]);
// 3) Gated reads over Bearer must all resolve (200, no -32001).
$this->assertRpcSucceeds($I, 'leantime.rpc.Users.Users.getUser', new \stdClass);
$this->assertRpcSucceeds($I, 'leantime.rpc.Projects.Projects.getProjectsUserHasAccessTo', new \stdClass);
$this->assertRpcSucceeds($I, 'leantime.rpc.Tickets.Tickets.getAllOpenUserTickets', new \stdClass);
$this->assertRpcSucceeds($I, 'leantime.rpc.Notifications.Notifications.getUnreadCount', new \stdClass);
// 4) The exact pair that broke mobile: create a ticket, then fetch it by id.
// quickAddTicket($params) takes a single array argument literally named "params".
$created = $this->rpc($I, 'leantime.rpc.Tickets.Tickets.quickAddTicket', [
'params' => ['headline' => 'Bearer-auth contract test', 'projectId' => 1],
]);
$newId = $created['result'] ?? null;
Assert::assertIsInt($newId, 'quickAddTicket should return an int id, got: '.json_encode($created));
$this->assertRpcSucceeds($I, 'leantime.rpc.Tickets.Tickets.getTicket', ['id' => $newId]);
// 5) Entity-scoped comments resolve the project from (module, moduleId).
$this->assertRpcSucceeds($I, 'leantime.rpc.Comments.Comments.getComments', ['module' => 'ticket', 'moduleId' => 1]);
// 6) Session-scoped mobile endpoints: no userId param, the caller is resolved from the
// token. These previously 404'd on mobile because the userId-taking originals are
// deliberately NOT @api (IDOR guard); the session-scoped siblings are the fix.
// getInbox is the inbox list (getAllNotifications stays non-@api — it takes a $userId).
$inbox = $this->rpc($I, 'leantime.rpc.Notifications.Notifications.getInbox', new \stdClass);
Assert::assertArrayNotHasKey('error', $inbox, 'getInbox must be exposed + session-scoped: '.json_encode($inbox));
Assert::assertIsArray($inbox['result'] ?? null, 'getInbox should return an array: '.json_encode($inbox));
// getExternalCalendarEvents (subscribed iCal feeds) is already session-scoped; it only
// lacked the @api tag. No external calendars on a fresh install => an empty array.
$extEvents = $this->rpc($I, 'leantime.rpc.Calendar.Calendar.getExternalCalendarEvents', new \stdClass);
Assert::assertArrayNotHasKey('error', $extEvents, 'getExternalCalendarEvents must be exposed: '.json_encode($extEvents));
Assert::assertIsArray($extEvents['result'] ?? null, 'getExternalCalendarEvents should return an array: '.json_encode($extEvents));
// getICalUrl is now exposed too. It legitimately errors with -32602 when the user has no
// iCal feed configured yet, so a valid response is EITHER a URL string OR -32602 — but
// NEVER -32601 (method-not-found regression) or -32001 (permission regression).
$icalUrl = $this->rpc($I, 'leantime.rpc.Calendar.Calendar.getICalUrl', new \stdClass);
$icalErr = $icalUrl['error']['code'] ?? null;
Assert::assertNotSame(-32601, $icalErr, 'getICalUrl must be exposed via @api (not method-not-found): '.json_encode($icalUrl));
Assert::assertNotSame(-32001, $icalErr, 'getICalUrl must not be permission-denied for the owner: '.json_encode($icalUrl));
Assert::assertTrue(
array_key_exists('result', $icalUrl) || $icalErr === -32602,
'getICalUrl should return a URL or -32602 (no feed configured): '.json_encode($icalUrl)
);
}
#[Group('bearer-api')]
public function nonManagerBearerHonorsProjectScopedReads(AcceptanceTester $I)
{
// A non-manager (editor, role 20) assigned to a project. Unlike the owner, this role does
// NOT short-circuit effectiveRoleForProject() — it exercises getProjectRole() +
// isUserAssignedToProject() + a resolved project role over Bearer. That path only works
// when the session role context is correctly established (the regression), so this is the
// guard for "works for the owner but -32001s for real non-admin users."
$ownerProjectId = (int) $I->grabFromDatabase('zp_projects', 'id', ['name' => 'My Project']);
Assert::assertNotEmpty($ownerProjectId, 'Seed project not found after install');
$editorId = (int) $I->haveInDatabase('zp_user', [
'firstname' => 'Ed',
'lastname' => 'Itor',
'username' => 'editor@leantime.io',
'password' => 'x',
'role' => '20',
'status' => 'A',
'createdOn' => date('Y-m-d H:i:s'),
]);
$I->haveInDatabase('zp_relationuserproject', [
'userId' => $editorId,
'projectId' => $ownerProjectId,
'projectRole' => '',
]);
$this->bearerToken = bin2hex(random_bytes(20));
$I->haveInDatabase('zp_access_tokens', [
'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth',
'tokenable_id' => $editorId,
'name' => 'bearer-api-cest-editor',
'token' => hash('sha256', $this->bearerToken),
'abilities' => json_encode(['*']),
'created_at' => date('Y-m-d H:i:s'),
]);
// Cross-project "my work" read (no projectId) + a project-scoped read by id, both as the
// editor over Bearer. Must resolve (200, no -32001).
$this->assertRpcSucceeds($I, 'leantime.rpc.Tickets.Tickets.getAllOpenUserTickets', new \stdClass);
$this->assertRpcSucceeds($I, 'leantime.rpc.Projects.Projects.getProject', ['id' => $ownerProjectId]);
$this->assertRpcSucceeds($I, 'leantime.rpc.Projects.Projects.getProjectProgress', ['projectId' => $ownerProjectId]);
// Cross-project "my work" with the projectId=0 sentinel mobile sends — must actually
// SUCCEED, not just avoid -32001 (it previously -32001'd on 0 / -32602 when omitted, for
// every role incl. owner). Assert no error at all + an array result, so a regression to any
// error code (not only -32001) fails the test.
$body = $this->rpc($I, 'leantime.rpc.Tickets.Tickets.getOpenUserTicketsThisWeekAndLater', ['userId' => $editorId, 'projectId' => 0]);
Assert::assertArrayNotHasKey('error', $body, 'projectId=0 cross-project read must not error: '.json_encode($body));
Assert::assertIsArray($body['result'] ?? null, 'expected an array result, got: '.json_encode($body));
// markTicketDone (mobile swipe-complete) must be EXPOSED (was -32601) AND succeed for the
// assigned editor. Assert no error + result true, so a regression to -32601/-32602/false is
// caught — assertRpcSucceeds (absence of -32001 only) would not catch those.
$assignedTicketId = (int) $I->grabFromDatabase('zp_tickets', 'id', ['projectId' => $ownerProjectId]);
Assert::assertNotEmpty($assignedTicketId, 'Seed ticket not found in project');
$done = $this->rpc($I, 'leantime.rpc.Tickets.Tickets.markTicketDone', ['id' => $assignedTicketId]);
Assert::assertArrayNotHasKey('error', $done, 'markTicketDone must be exposed + authorized: '.json_encode($done));
Assert::assertTrue($done['result'] ?? false, 'markTicketDone should return true: '.json_encode($done));
// getMyCalendar is the session-scoped calendar feed (getCalendar itself trusts a userId and
// stays non-@api). Its calendar.view gate is project-scoped, but on an API call there is no
// session project, so it resolves capability-only against the effective role — which a
// non-manager editor holds (calendar.view is readonly+). This is the exact path that would
// -32001 if a project-scoped gate fell closed on a null project, so prove it resolves for a
// non-manager, not just the owner.
$cal = $this->rpc($I, 'leantime.rpc.Calendar.Calendar.getMyCalendar', new \stdClass);
Assert::assertArrayNotHasKey('error', $cal, 'getMyCalendar must resolve for a non-manager editor: '.json_encode($cal));
Assert::assertIsArray($cal['result'] ?? null, 'getMyCalendar should return an array: '.json_encode($cal));
// And the inbox list resolves for the editor too (session-scoped, ungated).
$editorInbox = $this->rpc($I, 'leantime.rpc.Notifications.Notifications.getInbox', new \stdClass);
Assert::assertArrayNotHasKey('error', $editorInbox, 'getInbox must resolve for a non-manager editor: '.json_encode($editorInbox));
Assert::assertIsArray($editorInbox['result'] ?? null, 'getInbox should return an array: '.json_encode($editorInbox));
// IDOR guard: markNotificationUnread is session-scoped (matches on (id, session user)).
// Seed a notification owned by the OWNER, read=1, then — as the editor — try to flip it
// unread by its id. With the previous unscoped where('id') update this would succeed; now
// it must NOT: the result is false and the row stays read.
$ownerId = (int) $I->grabFromDatabase('zp_user', 'id', ['username' => 'test@leantime.io']);
$ownerNotifId = (int) $I->haveInDatabase('zp_notifications', [
'userId' => $ownerId,
'read' => 1,
'type' => 'mention',
'module' => 'ticket',
'moduleId' => 1,
'message' => 'owner-only notification',
'datetime' => date('Y-m-d H:i:s'),
'url' => '',
'authorId' => $ownerId,
]);
// result === false proves the IDOR is closed: the session-scoped repo matched (id, editor)
// => 0 rows => update affected nothing. (A DB read-back is avoided here because `read` is a
// MySQL reserved word and Codeception's grabFromDatabase doesn't quote the column.)
$unread = $this->rpc($I, 'leantime.rpc.Notifications.Notifications.markNotificationUnread', ['id' => $ownerNotifId]);
Assert::assertArrayNotHasKey('error', $unread, 'markNotificationUnread should respond cleanly: '.json_encode($unread));
Assert::assertFalse($unread['result'] ?? true, 'editor must NOT mark the owner\'s notification unread (IDOR): '.json_encode($unread));
}
/** POST /api/jsonrpc with the test bearer + method, return the decoded body. */
private function rpc(AcceptanceTester $I, string $method, array|\stdClass $params): array
{
$I->haveHttpHeader('Content-Type', 'application/json');
$I->haveHttpHeader('Authorization', 'Bearer '.$this->bearerToken);
// JSON string body (not a PHP array): an array body with a stdClass param makes
// Codeception's REST module form-encode it, so it never arrives as JSON-RPC.
$I->sendPost('/api/jsonrpc', json_encode([
'jsonrpc' => '2.0',
'method' => $method,
'params' => $params,
'id' => 1,
], JSON_THROW_ON_ERROR));
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
return json_decode($I->grabResponse(), true) ?? [];
}
/**
* Assert a Bearer call is NOT denied by the permission engine (-32001). Other engine codes
* (e.g. -32602 invalid params) are out of scope — this gates auth → user-context → project-role
* on the Bearer path, not method correctness. A -32001 here is the regression.
*/
private function assertRpcSucceeds(AcceptanceTester $I, string $method, array|\stdClass $params): void
{
$body = $this->rpc($I, $method, $params);
Assert::assertNotSame(
-32001,
$body['error']['code'] ?? null,
sprintf('Bearer-auth call to %s was denied by the permission engine (-32001). Response: %s', $method, json_encode($body))
);
}
}

View File

@@ -0,0 +1,250 @@
<?php
namespace Acceptance\API;
use Codeception\Attribute\Group;
use Codeception\Scenario;
use PHPUnit\Framework\Assert;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Install;
/**
* MCP server contract test (laravel/mcp over the /mcp HTTP transport).
*
* Sibling to BearerApiCest: same Bearer-token mint (a row in zp_access_tokens), but against the
* MCP endpoint the McpServer plugin registers instead of the JSON-RPC API. Exists because the MCP
* tool layer is exercised by AI clients (Claude Code, MCP inspector), not by the web UI, so
* nothing else in CI notices when it breaks — e.g. tools calling since-removed service methods, or
* the protocol-version handshake rejecting current clients (both happened in 2026-07 and were only
* caught by live testing).
*
* Group `mcp` is NOT part of the default CI groups: the McpServer plugin lives in the private
* app/Plugins submodule, which is absent in OSS CI. Every test therefore skips itself when the
* endpoint 404s. Run locally (plugins checked out) with:
*
* docker compose ... exec leantime-dev php vendor/bin/codecept run -g mcp --steps
*
* Each test is self-contained (token + plugin row minted per test) because the Db module rolls
* back haveInDatabase() rows after each test.
*/
class McpCest
{
private const MCP_PATH = '/mcp';
public function _before(AcceptanceTester $I, Install $installPage)
{
// Fresh install — same fixture as ApiCest/BearerApiCest.
$installPage->install('test@leantime.io', 'Test123456!', 'John', 'Smith', 'Smith & Co');
}
#[Group('mcp')]
public function mcpEndpointRequiresAuth(AcceptanceTester $I, Scenario $scenario)
{
$this->enableMcpPluginOrSkip($I, $scenario);
$I->haveHttpHeader('Content-Type', 'application/json');
$I->sendPost(self::MCP_PATH, json_encode($this->initializeRequest('2025-06-18'), JSON_THROW_ON_ERROR));
$I->seeResponseCodeIs(401);
}
#[Group('mcp')]
public function mcpHandshakeNegotiatesProtocolVersions(AcceptanceTester $I, Scenario $scenario)
{
$this->enableMcpPluginOrSkip($I, $scenario);
$this->mintBearerToken($I);
// Baseline: a revision from laravel/mcp v0.1.1's built-in list.
$response = $this->mcp($I, $this->initializeRequest('2025-06-18'));
Assert::assertSame('2025-06-18', $response['result']['protocolVersion'] ?? null, 'initialize failed: '.json_encode($response));
Assert::assertNotEmpty($response['result']['serverInfo']['name'] ?? null);
// Regression gate for the Claude Code handshake: current clients request 2025-11-25.
// v0.1.1 rejects unknown revisions instead of downgrading (spec says downgrade), so the
// server class must keep newer revisions in $supportedProtocolVersion (plugins#60).
$response = $this->mcp($I, $this->initializeRequest('2025-11-25'));
Assert::assertArrayNotHasKey(
'error',
$response,
'Server rejected protocol 2025-11-25 — Claude Code cannot connect. '
.'LeantimeMcpServer::$supportedProtocolVersion must include it: '.json_encode($response)
);
}
#[Group('mcp')]
public function mcpListsAllRegisteredTools(AcceptanceTester $I, Scenario $scenario)
{
$this->enableMcpPluginOrSkip($I, $scenario);
$this->mintBearerToken($I);
// tools/list paginates (15 per page in v0.1.1) — walk every cursor.
$toolNames = [];
$cursor = null;
$guard = 0;
do {
$params = $cursor === null ? new \stdClass : ['cursor' => $cursor];
$response = $this->mcp($I, [
'jsonrpc' => '2.0',
'id' => 2,
'method' => 'tools/list',
'params' => $params,
]);
Assert::assertArrayNotHasKey('error', $response, 'tools/list failed: '.json_encode($response));
foreach ($response['result']['tools'] ?? [] as $tool) {
$toolNames[] = $tool['name'];
}
$cursor = $response['result']['nextCursor'] ?? null;
} while ($cursor !== null && ++$guard < 20);
Assert::assertGreaterThanOrEqual(56, count($toolNames), 'Expected the full tool catalog, got: '.implode(', ', $toolNames));
// One representative per domain — catches a whole domain falling out of the registry.
foreach (['findTasks', 'getAllProjects', 'getAllGoals', 'getCalendar', 'getComments', 'logTime'] as $expected) {
Assert::assertContains($expected, $toolNames, "Tool {$expected} missing from tools/list");
}
}
#[Group('mcp')]
public function mcpToolCallLifecycle(AcceptanceTester $I, Scenario $scenario)
{
$this->enableMcpPluginOrSkip($I, $scenario);
$this->mintBearerToken($I);
// Create a task, read it back, patch it — the minimal write→read→write contract that
// exercises DI-constructed tools, session auth context, and the Tickets service layer.
$response = $this->callTool($I, 'addTask', [
'headline' => 'MCP contract test task',
'projectId' => 1,
]);
$text = $this->toolText($response);
Assert::assertFalse($response['result']['isError'] ?? true, 'addTask errored: '.$text);
Assert::assertSame(1, preg_match('/ID:?\s*(\d+)/', $text, $matches), 'addTask did not return an id: '.$text);
$taskId = (int) $matches[1];
$response = $this->callTool($I, 'getTicket', ['id' => $taskId]);
Assert::assertFalse($response['result']['isError'] ?? true, 'getTicket errored');
Assert::assertStringContainsString('MCP contract test task', $this->toolText($response));
$response = $this->callTool($I, 'editTask', [
'id' => $taskId,
'params' => ['headline' => 'MCP contract test task (edited)'],
]);
Assert::assertFalse($response['result']['isError'] ?? true, 'editTask errored: '.$this->toolText($response));
$response = $this->callTool($I, 'getTicket', ['id' => $taskId]);
Assert::assertStringContainsString('(edited)', $this->toolText($response), 'edit did not persist');
}
#[Group('mcp')]
public function mcpRejectsUnknownTool(AcceptanceTester $I, Scenario $scenario)
{
$this->enableMcpPluginOrSkip($I, $scenario);
$this->mintBearerToken($I);
// laravel/mcp reports unknown tools as an isError tool result ("Tool not found"),
// not a JSON-RPC error object.
$response = $this->callTool($I, 'definitelyNotATool', []);
Assert::assertTrue($response['result']['isError'] ?? false, 'Unknown tool should produce an error result: '.json_encode($response));
}
/**
* Enable the McpServer plugin for this test (row is rolled back by the Db module afterwards),
* or skip when the plugin code is not present (public OSS checkout — app/Plugins is a private
* submodule). The test runner shares the app container, so the folder check is authoritative.
*/
private function enableMcpPluginOrSkip(AcceptanceTester $I, Scenario $scenario): void
{
if (! is_dir(dirname(__DIR__, 3).'/app/Plugins/McpServer')) {
$scenario->skip('McpServer plugin not present (app/Plugins submodule not checked out)');
}
$I->haveInDatabase('zp_plugins', [
'name' => 'leantime/mcpServer',
'enabled' => 1,
'description' => 'MCP Server (acceptance fixture)',
'version' => '1.0.0',
'installdate' => date('Y-m-d H:i:s'),
'foldername' => 'McpServer',
'homepage' => 'https://leantime.io',
'authors' => '[]',
'license' => '',
'format' => 'folder',
]);
}
/**
* Mint a Bearer token directly in the DB — sha256 of an opaque string, exactly as
* AccessTokenRepository::createToken persists it (same approach as BearerApiCest).
*/
private function mintBearerToken(AcceptanceTester $I): void
{
$userId = $I->grabFromDatabase('zp_user', 'id', ['username' => 'test@leantime.io']);
Assert::assertNotEmpty($userId, 'Test user not found after install');
$token = bin2hex(random_bytes(20));
$I->haveInDatabase('zp_access_tokens', [
'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth',
'tokenable_id' => (int) $userId,
'name' => 'mcp-cest',
'token' => hash('sha256', $token),
'abilities' => json_encode(['*']),
'created_at' => date('Y-m-d H:i:s'),
]);
$I->haveHttpHeader('Authorization', 'Bearer '.$token);
}
/**
* POST a JSON-RPC payload to /mcp and return the decoded response.
*/
private function mcp(AcceptanceTester $I, array $payload): array
{
$I->haveHttpHeader('Content-Type', 'application/json');
$I->haveHttpHeader('Accept', 'application/json');
$I->sendPost(self::MCP_PATH, json_encode($payload, JSON_THROW_ON_ERROR));
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
return json_decode($I->grabResponse(), true, 512, JSON_THROW_ON_ERROR);
}
/**
* Invoke an MCP tool via tools/call.
*/
private function callTool(AcceptanceTester $I, string $name, array $arguments): array
{
return $this->mcp($I, [
'jsonrpc' => '2.0',
'id' => 3,
'method' => 'tools/call',
'params' => [
'name' => $name,
'arguments' => $arguments === [] ? new \stdClass : $arguments,
],
]);
}
/**
* Text content of a tools/call response ('' when the shape is unexpected).
*/
private function toolText(array $response): string
{
return $response['result']['content'][0]['text'] ?? '';
}
/**
* A spec-shaped initialize request for the given protocol revision.
*/
private function initializeRequest(string $protocolVersion): array
{
return [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'initialize',
'params' => [
'protocolVersion' => $protocolVersion,
'capabilities' => new \stdClass,
'clientInfo' => ['name' => 'mcp-cest', 'version' => '1.0'],
],
];
}
}