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,35 @@
# Codeception Test Suite Configuration
#
# Suite for acceptance tests.
# Perform tests in browser using the WebDriver or PhpBrowser.
# If you need both WebDriver and PHPBrowser tests - create a separate suite.
actor: AcceptanceTester
bootstrap: bootstrap.php
modules:
enabled:
- \Tests\Support\Helper\Acceptance
- REST:
url: 'https://leantime-dev'
depends: PhpBrowser
- WebDriver:
url: 'https://leantime-dev'
host: selenium
browser: chrome
wait: 20
log_js_errors: true
window_size: "2560x1440"
capabilities:
acceptInsecureCerts: true
goog:chromeOptions:
args: [ "--headless" ]
- Db:
dsn: "mysql:host=leantime-db;port=3306;dbname=leantime_test"
user: root
password: leantime
populate: true
cleanup: true
step_decorators:
- Codeception\Step\ConditionalAssertion
- Codeception\Step\TryTo
- Codeception\Step\Retry

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'],
],
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Acceptance;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Login;
/**
* Acceptance tests for the consolidated Blueprints domain.
*
* Exercises the new /blueprints/{slug}/... routing (the dispatch bridge),
* the YAML-driven board rendering, default-board creation, and the legacy
* /{slug}canvas/... -> /blueprints/{slug}/... redirects.
*/
class BlueprintsCest
{
public function _before(AcceptanceTester $I, Login $loginPage): void
{
$loginPage->login('test@leantime.io', 'Test123456!');
}
#[Group('blueprints')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function swotBoardRendersAndCreatesDefaultBoard(AcceptanceTester $I): void
{
$I->wantTo('Open a SWOT blueprint board and have a default board created');
$I->amOnPage('/blueprints/swot/showCanvas');
$I->waitForElementVisible('.pageheader', 30);
$I->dontSee('Whoops');
$I->dontSee('Fatal error');
// Visiting the board auto-creates a default board for the current project.
$I->seeInDatabase('zp_canvas', ['type' => 'swotcanvas']);
}
#[Group('blueprints')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function leanBoardRenders(AcceptanceTester $I): void
{
$I->wantTo('Open a Lean Canvas blueprint board');
$I->amOnPage('/blueprints/lean/showCanvas');
$I->waitForElementVisible('.pageheader', 30);
$I->dontSee('Whoops');
$I->seeInDatabase('zp_canvas', ['type' => 'leancanvas']);
}
#[Group('blueprints')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function legacyCanvasUrlRedirectsToBlueprints(AcceptanceTester $I): void
{
$I->wantTo('Be redirected from a legacy /swotcanvas URL to the blueprints route');
$I->amOnPage('/swotcanvas/showCanvas');
$I->waitForElementVisible('.pageheader', 30);
$I->seeInCurrentUrl('/blueprints/swot/');
}
#[Group('blueprints')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function rendersEditableSwotBoxesWiredToBlueprintRoutes(AcceptanceTester $I): void
{
$I->wantTo('See the SWOT boxes render with add-item links pointing at the blueprints route');
$I->amOnPage('/blueprints/swot/showCanvas');
$I->waitForElementVisible('.pageheader', 30);
// Each box (e.g. "strengths") renders an add-item affordance wired to the
// consolidated /blueprints/{slug}/editCanvasItem route, confirming the
// YAML-driven box rendering produced the right links.
$I->seeElementInDOM('#swot_strengths');
$I->seeElementInDOM('a[href*="/blueprints/swot/editCanvasItem?type=swot_strengths"]');
$I->seeElementInDOM('a[href*="/blueprints/swot/editCanvasItem?type=swot_threats"]');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Acceptance;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Login;
class CreateUserCest
{
public function _before(AcceptanceTester $I, Login $loginPage): void
{
$loginPage->login('test@leantime.io', 'Test123456!');
}
#[Group('user')]
#[Depends('Acceptance\LoginCest:loginSuccessfully')]
public function createAUser(AcceptanceTester $I): void
{
$I->wantTo('Create a user');
$I->amOnPage('/users/showAll');
$I->clickWithRetry('.userEditModal');
$I->waitForElement('#firstname', 120);
$I->fillField('#firstname', 'John');
$I->fillField('#lastname', 'Doe');
$I->selectOption('#role', 'Read Only');
$I->selectOption('#client', 'Not assigned to a client');
$I->fillField('#user', 'john@doe.com');
$I->fillField('#phone', '1234567890');
$I->fillField('#jobTitle', 'Testing');
$I->fillField('#jobLevel', 'Testing');
$I->fillField('#department', 'Testing');
$I->clickWithRetry('#save');
$I->waitForElement('.growl', 120);
$I->seeInDatabase('zp_user', [
'username' => 'john@doe.com',
]);
}
#[Group('user')]
#[Depends('Acceptance\LoginCest:loginSuccessfully')]
public function editAUser(AcceptanceTester $I): void
{
$I->wantTo('Edit a user');
// Set CSRF token before making the request
$I->setCSRFToken();
$I->amOnPage('/users/editUser/1/');
$I->waitForElement('.pagetitle', 120);
$I->see('Edit User');
$I->fillField(['name' => 'jobTitle'], 'Testing');
$I->clickWithRetry('#save');
$I->waitForElement('.growl', 120);
$I->seeInSource('User edited successfully');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Acceptance;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Login;
/**
* Acceptance tests for the Goals (Goalcanvas) domain after it was decoupled
* from the deprecated Canvas domain (its repository now extends Blueprints and
* its controllers no longer extend the Canvas controllers). These smoke tests
* confirm the goal pages still render end-to-end through the decoupled stack.
*/
class GoalsCest
{
public function _before(AcceptanceTester $I, Login $loginPage): void
{
$loginPage->login('test@leantime.io', 'Test123456!');
}
#[Group('goals')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function goalDashboardLoads(AcceptanceTester $I): void
{
$I->wantTo('Open the goals dashboard');
$I->amOnPage('/goalcanvas/dashboard');
$I->waitForElementVisible('.pageheader', 30);
$I->dontSee('Whoops');
$I->dontSee('Fatal error');
}
#[Group('goals')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function goalCanvasLoads(AcceptanceTester $I): void
{
$I->wantTo('Open the goals canvas board');
$I->amOnPage('/goalcanvas/showCanvas');
$I->waitForElementVisible('.pageheader', 30);
$I->dontSee('Whoops');
$I->dontSee('Fatal error');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Acceptance;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Install;
class InstallCest
{
public function _before(AcceptanceTester $I) {}
#[Group('install, api')]
public function installPageWorks(AcceptanceTester $I): void
{
$I->amOnPage('/install');
$I->waitForElementVisible('.registrationForm', 10);
$I->see('Install');
}
#[Group('install, api')]
#[Depends('installPageWorks')]
public function createDBSuccessfully(AcceptanceTester $I, Install $installPage): void
{
$installPage->install(
'test@leantime.io',
'Test123456!',
'John',
'Smith',
'Smith & Co'
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Acceptance;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Login;
class LoginCest
{
public function _before(AcceptanceTester $I) {}
#[Group('login')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function loginPageWorks(AcceptanceTester $I): void
{
$I->amOnPage('/auth/login');
$I->see('Login');
}
#[Group('login')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function loginDeniedForWrongCredentials(AcceptanceTester $I): void
{
$I->amOnPage('/auth/login');
$I->waitForElementVisible('#login', 10);
$I->fillField(['name' => 'username'], 'test@leantime.io');
$I->fillField(['name' => 'password'], 'WrongPassword');
$I->click('Login');
$I->waitForElementVisible('.login-alert');
$I->see('Username or password incorrect!');
}
#[Group('login')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function loginSuccessfully(AcceptanceTester $I, Login $loginPage): void
{
$loginPage->login('test@leantime.io', 'Test123456!');
}
#[Group('login')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function loginFormIsHidden(AcceptanceTester $I): void
{
$_ENV['LEAN_DISABLE_LOGIN_FORM'] = true;
$I->amOnPage('/auth/login');
$I->dontSeeElementInDOM('div#login');
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Acceptance;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Login;
class TicketsCest
{
public function _before(AcceptanceTester $I, Login $loginPage)
{
$loginPage->login('test@leantime.io', 'Test123456!');
}
#[Group('timesheet', 'ticket')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function createTicket(AcceptanceTester $I)
{
$I->wantTo('Create a ticket');
$I->amOnPage('/tickets/showKanban#/tickets/newTicket');
$I->waitForElementVisible('.main-title-input', 120);
$I->fillField(['class' => 'main-title-input'], 'Test Ticket');
$I->waitForElementClickable('.tagsinput', 15);
$I->clickWithRetry('.tagsinput', 90);
$I->wait(2);
$I->type('test-tag,');
$I->waitForElementClickable('[data-tiptap-editor] .ProseMirror', 120);
$I->wait(2);
$I->clickWithRetry('[data-tiptap-editor] .ProseMirror');
$I->type('Test Description');
$I->waitForElementClickable('.saveTicketBtn', 120);
$I->clickWithRetry('.saveTicketBtn');
$I->waitForElement('.growl', 120);
// Match by headline, not a hardcoded auto-increment id: the id depends on how many
// tickets earlier Cests created, so any change in suite composition (e.g. BearerApiCest
// creating a ticket) would shift it. Headline keeps this order-independent.
$I->seeInDatabase('zp_tickets', [
'headline' => 'Test Ticket',
'description like' => '%<p>Test Description</p>%',
]);
}
#[Group('ticket')]
#[Depends('createTicket')]
public function editTicket(AcceptanceTester $I)
{
$I->wantTo('Edit a ticket');
// Resolve the ticket created by createTicket() by headline rather than a hardcoded id,
// so this is independent of how many tickets other Cests created.
$ticketId = $I->grabFromDatabase('zp_tickets', 'id', ['headline' => 'Test Ticket']);
$I->amOnPage('/tickets/showKanban#/tickets/showTicket/'.$ticketId);
// Currently (and only in tests) the editor is not loaded when clicked on less the page is reloaded first.
$I->reloadPage();
$I->waitForElementVisible('.main-title-input', 120);
$I->waitForElementClickable('[data-tiptap-editor] .ProseMirror', 120);
$I->wait(2);
$I->clickWithRetry('[data-tiptap-editor] .ProseMirror');
$I->type('Test Description Edited');
$I->waitForElementClickable('.saveTicketBtn', 120);
$I->clickWithRetry('.saveTicketBtn');
$I->waitForElement('.growl', 120);
$I->wait(2);
$I->seeInDatabase('zp_tickets', [
'id' => $ticketId,
'headline' => 'Test Ticket',
'description like' => '%Test Description Edited%',
]);
}
}

View File

@@ -0,0 +1,308 @@
<?php
namespace Acceptance;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Login;
class TimesheetCest
{
public function _before(AcceptanceTester $I, Login $loginPage): void
{
$loginPage->login('test@leantime.io', 'Test123456!');
}
/**
* Create timesheet on my page.
*/
#[Group('timesheet')]
#[Depends('Acceptance\TicketsCest:createTicket')]
public function createMyTimesheet(AcceptanceTester $I): void
{
$I->wantTo('Add hours to tickets on my timesheet');
$I->amOnPage('/timesheets/showMy');
// Select project.
$I->waitForElementNotVisible('.project-select', 120);
$I->clickWithRetry('#projectSelect .chosen-single');
$I->waitForElementVisible('.chosen-drop', 120);
$I->clickWithRetry('#projectSelect .chosen-results .active-result');
// Select ticket.
$I->waitForElementNotVisible('.ticket-select', 120);
$I->clickWithRetry('#ticketSelect .chosen-single');
$I->waitForElementVisible('.chosen-drop', 120);
$I->clickWithRetry('#ticketSelect .chosen-results .active-result');
// Select type.
$I->waitForElementVisible('.kind-select', 120);
$I->selectOption('.kind-select', 'General, billable');
// Set hours in active
$I->fillField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', 1);
$I->fillField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', 2);
$I->clickWithRetry('.saveTimesheetBtn');
$I->waitForElement('.growl', 60);
$I->seeInField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', '1');
$I->seeInField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', '2');
$I->wait(3);
$I->seeInSource('<td id="finalSum">3</td>');
$I->seeInDatabase('zp_timesheets', [
'id' => 1,
'hours' => 1,
'kind' => 'GENERAL_BILLABLE',
]);
$I->seeInDatabase('zp_timesheets', [
'id' => 2,
'hours' => 2,
'kind' => 'GENERAL_BILLABLE',
]);
}
#[Group('timesheet')]
#[Depends('createMyTimesheet')]
public function checkForEmptyTimesheet(AcceptanceTester $I): void
{
$I->wantTo('I do not what to see empty ("zero") time registrations');
$I->amOnPage('/timesheets/showMy');
// Do not what to see empty time regs.
$I->dontSeeInDatabase('zp_timesheets', [
'hours' => 0,
]);
}
#[Group('timesheet')]
#[Depends('createMyTimesheet')]
public function notShiftingTimesheet(AcceptanceTester $I): void
{
$I->wantTo('Try registering time on the same ticket on another day. Should not remove existing registrations.');
$I->amOnPage('/timesheets/showMy');
// Since we assume "Create Timesheet was created we just add values to day 3 and 4.
// Set hours.
$I->fillField('//*[contains(@class, "rowday3")]//input[@class="hourCell"]', 1);
$I->fillField('//*[contains(@class, "rowday4")]//input[@class="hourCell"]', 2);
$I->clickWithRetry('.saveTimesheetBtn');
$I->waitForElement('.growl', 60);
$I->wait(10);
$I->seeInField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', '1');
$I->seeInField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', '2');
$I->seeInField('//*[contains(@class, "rowday3")]//input[@class="hourCell"]', '1');
$I->seeInField('//*[contains(@class, "rowday4")]//input[@class="hourCell"]', '2');
$I->seeInSource('<td id="finalSum">6</td>');
}
#[Group('timesheet')]
#[Depends('createMyTimesheet')]
public function sameTicketTimesheet(AcceptanceTester $I): void
{
$I->wantTo('Test timesheet updated with data one same ticket');
$I->amOnPage('/timesheets/showMy');
// Set hours in active
$I->fillField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', 1);
$I->fillField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', 2);
$I->clickWithRetry('.saveTimesheetBtn');
$I->waitForElement('.growl', 60);
$I->seeInSource('<td id="finalSum">6</td>');
}
/**
* Save the timesheet once more to ensure number do not change.
*
* If the cell IDs are not correct, this will break the registrations.
*/
#[Group('timesheet')]
#[Depends('createMyTimesheet')]
public function saveOnceMoreTimezone(AcceptanceTester $I): void
{
$I->wantTo('Save the timesheet once more to ensure number do not change');
$I->amOnPage('/timesheets/showMy');
$I->clickWithRetry('.saveTimesheetBtn');
$I->waitForElement('.growl', 120);
$I->seeInSource('Timesheet saved successfully');
// An page reload will trigger an "resend submission popup".
$I->amOnPage('/timesheets/showMy');
$I->waitForElementVisible('//*[contains(@class, "rowday1")]//input[@class="hourCell"]');
$I->seeInField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', '1');
$I->seeInField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', '2');
$I->seeInSource('<td id="finalSum">6</td>');
}
#[Group('timesheet')]
#[Depends('createMyTimesheet')]
public function changeTimezone(AcceptanceTester $I): void
{
$I->wantTo('Change timezone and see the correct timesheet');
// Change timezone and see the correct timesheet.
$this->changeUsersTimeZone($I, 'Europe/Copenhagen');
// Check timesheet
$I->amOnPage('/timesheets/showMy');
$I->waitForElementVisible('//*[contains(@class, "rowday1")]//input[@class="hourCell"]');
$I->seeInField('//*[contains(@class, "rowday1")]//input[@class="hourCell"]', '1');
$I->seeInField('//*[contains(@class, "rowday2")]//input[@class="hourCell"]', '2');
$I->seeInSource('<td id="finalSum">6</td>');
// Switch back.
$this->changeUsersTimeZone($I);
}
#[Group('timesheet')]
#[Depends('createMyTimesheet')]
public function editTimesheet(AcceptanceTester $I): void
{
$I->wantTo('Edit timesheet');
$I->amOnPage('/timesheets/showMyList');
$I->waitForElementVisible('#allTimesheetsTable');
$I->see('#1 - Edit');
$I->clickWithRetry('#editTimesheet-1');
$I->waitForElementVisible('#hours');
$I->fillField('#hours', 2);
$I->clickWithRetry('.stdformbutton input[type=submit]');
$I->waitForElement('.growl', 120);
$I->seeInDatabase('zp_timesheets', [
'id' => '1',
'hours' => 2,
]);
// Close modal.
$I->waitForElementVisible('.nyroModalClose');
$I->clickWithRetry('.nyroModalClose');
// Check that data have been updated.
$I->wait(5);
$I->waitForElementVisible('#allTimesheetsTable');
$I->see('2', '//*//tr[@class="odd"]//td', '2');
$I->see('2', '//*//tr[@class="odd"]//td', '-2');
}
#[Group('timesheet')]
#[Depends('createMyTimesheet', 'editTimesheet')]
public function logTimeOnTicketTimesheet(AcceptanceTester $I): void
{
$I->wantTo('Open ticket and add time');
$I->amOnPage('/#/tickets/showTicket/10');
$I->waitForElementVisible('#ui-id-8');
$I->clickWithRetry('#ui-id-8');
$I->waitForElementVisible('#hours');
$I->fillField('#hours', 4);
$I->clickWithRetry('.formModal input[type=submit][name=saveTimes]');
$I->wait(1);
// Go and see if the total is correct.
$I->amOnPage('/timesheets/showMy');
$I->waitForElementVisible('#finalSum');
$I->seeInSource('<td id="finalSum">11</td>');
}
#[Group('timesheet')]
#[Depends('createMyTimesheet', 'editTimesheet', 'logTimeOnTicketTimesheet')]
public function showAllTimesheet(AcceptanceTester $I): void
{
$I->wantTo('Show all timesheet list');
$I->amOnPage('/timesheets/showAll');
$I->waitForElementVisible('#allTimesheetsTable');
$I->see('2', '//*//tr[@class="odd"]//td', '2');
$I->see('2', '//*//tr[@class="odd"]//td', '-2');
}
#[Group('timesheet')]
#[Depends('createMyTimesheet')]
public function showAllEditsTimesheet(AcceptanceTester $I): void
{
$I->wantTo('Show all timesheet list');
$I->amOnPage('/timesheets/showAll');
$I->waitForElementVisible('#allTimesheetsTable');
// Maker paid
$I->checkOption('//*//input[@id="checkAllPaid"]');
$I->clickWithRetry('#allTimesheetsTable_wrapper input[name=saveInvoice]');
$I->waitForElementVisible('#allTimesheetsTable_wrapper');
$I->wait(2);
$I->cantSeeElement('//*//input[@class="paid"]');
// Make Invoiced
$I->checkOption('//*/input[@id="checkAllEmpl"]');
$I->clickWithRetry('#allTimesheetsTable_wrapper input[name=saveInvoice]');
$I->waitForElementVisible('#allTimesheetsTable_wrapper');
$I->wait(2);
$I->cantSeeElement('//*//input[@class="invoicedEmpl"]');
// Make MGR Approval
$I->checkOption('//*//input[@id="checkAllComp"]');
$I->clickWithRetry('#allTimesheetsTable_wrapper input[name=saveInvoice]');
$I->waitForElementVisible('#allTimesheetsTable_wrapper');
$I->wait(2);
$I->cantSeeElement('//*//input[@class="invoicedComp"]');
}
#[Group('timesheet')]
#[Depends('createMyTimesheet')]
public function deleteTimesheet(AcceptanceTester $I): void
{
$I->wantTo('Delete timesheet');
$I->amOnPage('/timesheets/showMyList');
$I->waitForElementVisible('#allTimesheetsTable');
$I->see('#1 - Edit');
$I->clickWithRetry('#editTimesheet-1');
$I->waitForElementVisible('.delete');
$I->clickWithRetry('.stdformbutton .delete');
$I->wait(5);
$I->see('Should the timesheet really be deleted?');
$I->clickWithRetry('.nyroModalLink input[type=submit]');
$I->waitForElementVisible('#allTimesheetsTable');
$I->wait(5);
$I->cantSee('#1 - Edit');
}
/**
* Change the timezone for the logged-in user.
*
* @param AcceptanceTester $I The AcceptanceTester object representing the test runner.
* @param string $timezone The timezone to be set. Defaults to 'America/Los_Angeles'.
*/
private function changeUsersTimeZone(AcceptanceTester $I, string $timezone = 'America/Los_Angeles'): void
{
$I->amOnPage('/users/editOwn#settings');
$I->waitForElementVisible('#timezone');
$I->selectOption('#timezone', $timezone);
$I->waitForElementClickable('#saveSettings');
$I->clickWithRetry('#saveSettings', 90);
$I->waitForText($timezone, 20);
$I->wait(5);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Acceptance;
use Codeception\Attribute\Depends;
use Codeception\Attribute\Group;
use Tests\Support\AcceptanceTester;
use Tests\Support\Page\Acceptance\Login;
/**
* Acceptance tests for the Wiki domain after it was decoupled from the
* deprecated Canvas domain (its repository now extends Blueprints). These
* smoke tests confirm the wiki still renders and persists through the
* decoupled repository.
*/
class WikiCest
{
public function _before(AcceptanceTester $I, Login $loginPage): void
{
$loginPage->login('test@leantime.io', 'Test123456!');
}
#[Group('wiki')]
#[Depends('Acceptance\InstallCest:createDBSuccessfully')]
public function wikiLoads(AcceptanceTester $I): void
{
$I->wantTo('Open the wiki');
$I->amOnPage('/wiki/show');
$I->waitForElementVisible('.pageheader', 30);
$I->dontSee('Whoops');
$I->dontSee('Fatal error');
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
// Don't run the script unless using the 'run' command
if (! isset($_SERVER['argv'][1]) || $_SERVER['argv'][1] !== 'run') {
return;
}
require __DIR__.'/../../vendor/autoload.php';
// Load test environment
$testEnv = __DIR__.'/../../.dev/test.env';
if (file_exists($testEnv)) {
\Dotenv\Dotenv::createImmutable(dirname($testEnv), basename($testEnv))->load();
}
define('PROJECT_ROOT', realpath(__DIR__.'/../../').'/');
define('DEV_ROOT', PROJECT_ROOT.'.dev/');
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(\Leantime\Core\Console\ConsoleKernel::class)->bootstrap();
$bootstrapper = get_class(new class
{
/**
* @var self
*/
protected static $instance;
/**
* Get the singleton instance of this class
*/
public static function getInstance(): self
{
if (! isset(self::$instance)) {
self::$instance = new self;
}
return self::$instance;
}
/**
* Start the testing environment
*/
public function start(): void
{
$this->setFolderPermissions();
$this->createDatabase();
$this->createStep('Starting Codeception Testing Framework');
}
/**
* Destroy the testing environment
*/
public function destroy(): void
{
$this->createStep('Stopping Codeception Testing Framework');
}
/**
* Create the test database
*/
protected function createDatabase(): void
{
$host = getenv('LEAN_DB_HOST');
$user = getenv('LEAN_DB_USER');
$pass = getenv('LEAN_DB_PASSWORD');
$db = getenv('LEAN_DB_DATABASE');
$this->createStep('Dropping Test Database');
$result = $this->executeCommand([
'mysql',
"--host=$host",
"--user=$user",
"--password=$pass",
'--skip-ssl',
'-e',
"DROP DATABASE IF EXISTS $db;",
], ['cwd' => DEV_ROOT]);
$this->createStep('Creating Test Database');
$this->executeCommand([
'mysql',
"--host=$host",
"--user=$user",
"--password=$pass",
'--skip-ssl',
'-e',
'CREATE DATABASE IF NOT EXISTS leantime_test;',
], ['cwd' => DEV_ROOT]);
$this->createStep('Clearing Application Cache');
$this->executeCommand(
'find '.PROJECT_ROOT.'storage/framework/cache/ -type f ! -name ".gitignore" -delete && '.
'find '.PROJECT_ROOT.'storage/framework/sessions/ -type f ! -name ".gitignore" -delete && '.
'find '.PROJECT_ROOT.'storage/framework/views/ -type f ! -name ".gitignore" -delete',
['cwd' => PROJECT_ROOT],
false,
);
// Restart Apache to clear OPcache and any in-memory state
$this->executeCommand('apache2ctl graceful', ['cwd' => PROJECT_ROOT], false);
sleep(2);
}
protected function setFolderPermissions(): void
{
$this->createStep('Setting folder permissions on cache folder');
// Set file permissions
$this->executeCommand(
array_filter(
[
'chown',
'-R',
'www-data:www-data',
'/var/www/html/storage/',
]
),
[
'cwd' => DEV_ROOT,
]
);
$this->executeCommand(
array_filter(
[
'chown',
'-R',
'www-data:www-data',
'/var/www/html/storage/logs',
]
),
[
'cwd' => DEV_ROOT,
]
);
}
/**
* Create a step in the output
*/
protected function createStep(string $message): void
{
$chars = strlen($message);
$line = str_repeat('=', $chars);
echo "\n$line\n$message\n$line\n";
}
/**
* Execute a command
*/
protected function executeCommand(
string|array $command,
array $args = [],
bool $required = true,
): Process|string {
$process = is_array($command)
? new Process($command)
: Process::fromShellCommandline($command);
if (isset($args['cwd'])) {
$process->setWorkingDirectory($args['cwd']);
}
if (isset($args['timeout'])) {
$process->setTimeout($args['timeout']);
}
if (isset($args['options'])) {
$process->setOptions($args['options']);
}
if (isset($args['background']) && $args['background']) {
$process->start();
} else {
$process->run(fn ($type, $buffer) => $this->commandOutputHandler($type, $buffer));
}
if (
$required
&& (! isset($args['background']) || ! $args['background'])
&& ! $process->isSuccessful()
) {
throw new ProcessFailedException($process);
}
if (
isset($args['getOutput'])
&& $args['getOutput']
) {
if (isset($args['background']) && $args['background']) {
throw new RuntimeException('Cannot get output from background process');
}
return $process->getOutput();
}
return $process;
}
/**
* Handle command output
*/
private function commandOutputHandler(string $type, string $buffer): void
{
echo $type === Process::ERR ? "\nSTDERR: $buffer" : "\nSTDOUT: $buffer";
}
});
register_shutdown_function(fn () => $bootstrapper::getInstance()->destroy());
$bootstrapper::getInstance()->start();

View File

@@ -0,0 +1,132 @@
# Bearer-auth JSON-RPC requests — for local exploration / debugging the
# Bearer auth path on /api/jsonrpc.
#
# Setup (one-time per environment):
# 1. Copy http-client.sample.env.json -> http-client.env.json
# 2. Edit http-client.env.json: set base-url and bearer-token
# 3. To mint a fresh bearer (no AdvancedAuth plugin required):
# php bin/leantime auth:create-bearer-token --email=you@example.com --quiet-output
# Copy the output into the bearer-token env var.
#
# These mirror the BearerApiCest.php contract suite, which is what runs
# in CI on every PR. Use this file when you need to poke at the auth
# behavior manually (different user, different project, different role)
# without running the full Codeception harness.
###
# whoami — server-authoritative session resolution from Bearer
POST {{ base-url }}/api/jsonrpc
Authorization: Bearer {{ bearer-token }}
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "leantime.rpc.Users.Users.getUser",
"params": {},
"id": 1
}
###
# Projects accessible to the bearer's user
POST {{ base-url }}/api/jsonrpc
Authorization: Bearer {{ bearer-token }}
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "leantime.rpc.Projects.Projects.getProjectsUserHasAccessTo",
"params": {},
"id": 1
}
###
# Open tickets across all accessible projects
POST {{ base-url }}/api/jsonrpc
Authorization: Bearer {{ bearer-token }}
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "leantime.rpc.Tickets.Tickets.getAllOpenUserTickets",
"params": {},
"id": 1
}
###
# Get a single ticket — projectIdParam-resolved permission check.
# This is the call that started returning -32001 in the 2026-06 deploy.
POST {{ base-url }}/api/jsonrpc
Authorization: Bearer {{ bearer-token }}
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "leantime.rpc.Tickets.Tickets.getTicket",
"params": {"id": 1},
"id": 1
}
###
# Quick-add a ticket — RPC mutation under bearer
POST {{ base-url }}/api/jsonrpc
Authorization: Bearer {{ bearer-token }}
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "leantime.rpc.Tickets.Tickets.quickAddTicket",
"params": {
"params": {
"headline": "Bearer-auth manual test",
"projectId": 1
}
},
"id": 1
}
###
# Get comments on a ticket — entityScoped permission check.
# Second of the 2026-06 regression pair.
POST {{ base-url }}/api/jsonrpc
Authorization: Bearer {{ bearer-token }}
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "leantime.rpc.Comments.Comments.getComments",
"params": {"module": "ticket", "moduleId": 1},
"id": 1
}
###
# Unread notifications count
POST {{ base-url }}/api/jsonrpc
Authorization: Bearer {{ bearer-token }}
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "leantime.rpc.Notifications.Notifications.getUnreadCount",
"params": {},
"id": 1
}
###
# Missing bearer — expect 401
POST {{ base-url }}/api/jsonrpc
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "leantime.rpc.Tickets.Tickets.getAllOpenUserTickets",
"params": {},
"id": 1
}

View File

@@ -0,0 +1,74 @@
# Poll Comments
# @no-redirect
POST {{ base-url }}/api/jsonrpc
x-api-key: {{ x-api-key }}
Content-Type: Content-Type: application/json
User-Agent: IntelliJ HTTP Client/PhpStorm 2024.2.4
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: */*
x-http2-scheme: https
Content-Length: 234
[
{
"method": "leantime.rpc.Comments.pollComments",
"params": {"projectId": 3},
"id": 1,
"jsonrpc": "2.0"
},
{
"method": "leantime.rpc.Comments.pollComments",
"params": {"projectId": 3},
"id": 1,
"jsonrpc": "2.0"
}
]
<> 2025-09-30T081831.303.html
###
# api key auth
# @no-redirect
POST {{ base-url }}/api/jsonrpc
x-api-key: {{x-api-key}}
Content-Type: Content-Type: application/json
User-Agent: IntelliJ HTTP Client/PhpStorm 2024.3
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: */*
Content-Length: 193
x-http2-scheme: https
<> 2025-06-09T190524.200.json
###
# create client
# @no-redirect
POST {{ base-url }}/api/jsonrpc
x-api-key: {{x-api-key}}
Content-Type: Content-Type: application/json
User-Agent: IntelliJ HTTP Client/PhpStorm 2024.3
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: */*
Content-Length: 193
x-http2-scheme: https
{
"method": "leantime.rpc.Clients.Clients.create",
"jsonrpc": "2.0",
"id": "create-client-1",
"params": {
"values": {
"name": "Test Client"
}
}
}
<> 2025-06-09T190503.200.json

112
tests/Httprequests/MCP.http Normal file
View File

@@ -0,0 +1,112 @@
# List Prompts
# @no-redirect
POST {{ base-url }}/mcp
Authorization: Bearer {{ bearer-token }}
Content-Type: Content-Type: application/json
User-Agent: IntelliJ HTTP Client/PhpStorm 2024.3
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: application/json
x-http2-scheme: https
Mcp-Session-Id: f703118b3b1e83a46fb02c22511da140
Content-Length: 106
{
"method": "prompts/list",
"jsonrpc": "2.0",
"id": "create-client-1",
"params": {
}
}
<> 2025-06-28T161808.200.json
###
# Initiatlize
# @no-redirect
POST {{ base-url }}/mcp
Authorization: Bearer {{ bearer-token }}
Content-Type: Content-Type: application/json
User-Agent: IntelliJ HTTP Client/PhpStorm 2024.3
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: application/json
x-http2-scheme: https
Mcp-Session-Id: f703118b3b1e83a46fb02c22511da140
Content-Length: 200
{
"method": "initialize",
"jsonrpc": "2.0",
"id": "1",
"params": {"protocolVersion":"2025-03-26","capabilities":[],"clientInfo":{"name":"mcp-remote-fallback-test","version":"0.0.0"}}
}
<> 2025-06-28T161740.400.json
###
# Tool Call getCalendar
# @no-redirect
POST {{ base-url }}/mcp
Authorization: Bearer {{ bearer-token }}
Content-Type: Content-Type: application/json
User-Agent: IntelliJ HTTP Client/PhpStorm 2024.3
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: */*
x-http2-scheme: https
Content-Length: 236
{
"method": "tools/call",
"jsonrpc": "2.0",
"id": "1",
"params": {
"name": "getCalendar",
"arguments": {
"from": "2025-06-15 00:00:00-0100",
"until": "2025-06-17 00:00:00-0100"
}
}
}
<> 2025-06-20T122107.200.json
###
# Tools List
# @no-redirect
POST {{ base-url }}/mcp
Authorization: Bearer {{ bearer-token }}
Content-Type: Content-Type: application/json
User-Agent: IntelliJ HTTP Client/PhpStorm 2024.3
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: */*
x-http2-scheme: https
Content-Length: 104
{
"method": "tools/list",
"jsonrpc": "2.0",
"id": "create-client-1",
"params": {
}
}
<> 2025-06-16T081130.200.json
###
# Auth no body
# @no-redirect
POST {{ base-url }}/mcp
Authorization: Bearer {{ bearer-token }}
Content-Type: Content-Type: application/json
User-Agent: IntelliJ HTTP Client/PhpStorm 2024.3
Accept-Encoding: br, deflate, gzip, x-gzip
Accept: */*
x-http2-scheme: https
content-length: 0
<> 2025-06-16T081028.200.json
###

View File

@@ -0,0 +1,7 @@
{
"dev": {
"base-url": "https://localhost",
"x-api-key": "APIKEY",
"bearer-token": "TOKEN"
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use Facebook\WebDriver\Exception\ElementClickInterceptedException;
use Facebook\WebDriver\Exception\StaleElementReferenceException;
/**
* Inherited Methods
*
* @method void wantTo($text)
* @method void wantToTest($text)
* @method void execute($callable)
*/
class AcceptanceTester extends \Codeception\Actor
{
use _generated\AcceptanceTesterActions;
/*
* Click on an element with retries and waits
*/
public function clickWithRetry($selector, $timeout = 10)
{
$maxAttempts = 3;
$attempt = 1;
while ($attempt <= $maxAttempts) {
try {
// Wait for element to be clickable
$this->waitForElementClickable($selector, $timeout);
// Scroll element into view
$this->executeJS("document.querySelector('".$selector."').scrollIntoView({behavior: 'smooth', block: 'center'});");
$this->wait(1); // Small wait after scroll
// Try to click
$this->click($selector);
return;
} catch (ElementClickInterceptedException|StaleElementReferenceException $e) {
if ($attempt === $maxAttempts) {
throw $e;
}
$this->wait(1);
$attempt++;
}
}
}
/**
* Take debug screenshot on failure
*/
public function _failed(\Codeception\TestInterface $test, $fail)
{
$this->makeScreenshot('failed_'.$test->getName());
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Tests\Support;
/**
* Inherited Methods
*
* @method void wantToTest($text)
* @method void wantTo($text)
* @method void execute($callable)
* @method void expectTo($prediction)
* @method void expect($prediction)
* @method void amGoingTo($argumentation)
* @method void am($role)
* @method void lookForwardTo($achieveValue)
* @method void comment($description)
* @method void pause()
*/
class ApiTester extends \Codeception\Actor
{
use _generated\ApiTesterActions;
}

View File

View File

@@ -0,0 +1,36 @@
<?php
namespace Tests\Support\Helper;
use Codeception\Module;
use Illuminate\Support\Facades\Session;
use Leantime\Core\Application;
class Acceptance extends Module
{
protected Application $app;
public function _initialize()
{
$this->app = require dirname(__DIR__, 2).'/bootstrap.php';
if (! defined('BASE_URL')) {
define('BASE_URL', 'https://leantime-dev');
}
}
public function getApplication(): Application
{
return $this->app;
}
public function setCSRFToken()
{
$token = bin2hex(random_bytes(32));
Session::put('_token', $token);
Session::save();
// Set the token in the cookie as well
$this->getModule('WebDriver')->setCookie('XSRF-TOKEN', $token);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Tests\Support\Helper;
use Codeception\Module;
use Leantime\Core\Application;
class Api extends Module
{
protected Application $app;
public function _initialize()
{
$this->app = require dirname(__DIR__, 2).'/bootstrap.php';
}
public function getApplication(): Application
{
return $this->app;
}
public function haveHttpHeader($header, $value)
{
$this->getModule('REST')->haveHttpHeader($header, $value);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Tests\Support\Helper;
use Codeception\Module;
use Leantime\Core\Application;
class Unit extends Module
{
/**
* @var Application
*/
protected $app;
public function _initialize()
{
$this->app = bootstrap_minimal_app();
}
public function getApplication()
{
return $this->app;
}
public function haveInDatabase($table, array $data)
{
return $this->getModule('Db')->haveInDatabase($table, $data);
}
public function seeInDatabase($table, array $criteria)
{
return $this->getModule('Db')->seeInDatabase($table, $criteria);
}
public function dontSeeInDatabase($table, array $criteria)
{
return $this->getModule('Db')->dontSeeInDatabase($table, $criteria);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Tests\Support\Page\Acceptance;
use Codeception\Util\Fixtures;
use Tests\Support\AcceptanceTester;
class Install
{
protected AcceptanceTester $I;
protected $app;
public function __construct(AcceptanceTester $I)
{
$this->I = $I;
$this->app = $I->getApplication();
}
public function install($email, $password, $firstname, $lastname, $company): void
{
if (Fixtures::exists('installed')) {
$this->suppressModals();
return;
}
$this->I->amOnPage('/install');
$this->I->fillField(['name' => 'email'], $email);
$this->I->fillField(['name' => 'firstname'], $firstname);
$this->I->fillField(['name' => 'lastname'], $lastname);
$this->I->fillField(['name' => 'company'], $company);
$this->I->click('Install');
// A successful install redirects straight into the onboarding wizard
// (auth/userInvite), starting on the "Setting Account Details" step —
// there is no longer a standalone success .alert page. Wait for the
// jobTitle field (the first field this method fills) to confirm the
// step has rendered before continuing.
$this->I->waitForElementVisible(['name' => 'jobTitle'], 90);
$this->I->fillField(['name' => 'jobTitle'], 'CEO');
$this->I->fillField(['name' => 'password'], $password);
$this->I->click('Next');
$this->I->waitForText('Determining A Visual Experience', 90);
$this->I->click('Next');
$this->I->waitForText('Creating A Comfortable View', 90);
$this->I->click('Next');
$this->I->waitForText('Shaping A Daily Flow', 90);
$this->I->click('Next');
$this->I->waitForText('Your Leantime journey is about to begin', 90);
$this->I->click('Complete Sign up');
Fixtures::add('installed', true);
$this->suppressModals();
}
/**
* Suppress all helper modals for testing
*/
private function suppressModals(): void
{
$userService = $this->app->make(\Leantime\Domain\Users\Services\Users::class);
session(['userdata.id' => 1]);
// Suppress all known modals
$userService->updateUserSettings('modals', 'projectDashboard', true);
$userService->updateUserSettings('modals', 'home', true);
$userService->updateUserSettings('modals', 'kanban', true);
$userService->updateUserSettings('modals', 'roadmap', true);
$userService->updateUserSettings('modals', 'goals', true);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Tests\Support\Page\Acceptance;
use Codeception\Util\Fixtures;
class Login
{
/**
* @var \Tests\Support\AcceptanceTester;
*/
protected $I;
public function __construct(\Tests\Support\AcceptanceTester $I, Install $installPage)
{
$this->I = $I;
$this->installPage = $installPage;
}
public function login($username, $password)
{
if ($this->loadSessionShapshot('leantime_session')) {
return;
}
if (! Fixtures::exists('installed')) {
$this->installPage->install(
'test@leantime.io',
'Test123456!',
'John',
'Smith',
'Smith & Co'
);
}
$this->I->amOnPage('/auth/login');
$this->I->fillField(['name' => 'username'], $username);
$this->I->fillField(['name' => 'password'], $password);
$this->I->click('Login');
$this->I->waitForElementVisible('.welcome-widget', 120);
$this->I->see('Hi John');
$this->saveSessionSnapshot('leantime_session');
}
protected function loadSessionShapshot(string $name): bool
{
if (! Fixtures::exists($name)) {
return false;
}
$this->I->setCookie($name, Fixtures::get($name));
return true;
}
protected function saveSessionSnapshot(string $name): void
{
Fixtures::add($name, $this->I->grabCookie($name));
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
/**
* Inherited Methods
*
* @method void wantTo($text)
* @method void wantToTest($text)
* @method void execute($callable)
* @method void expectTo($prediction)
* @method void expect($prediction)
* @method void amGoingTo($argumentation)
* @method void am($role)
* @method void lookForwardTo($achieveValue)
* @method void comment($description)
* @method void pause($vars = [])
*
* @SuppressWarnings(PHPMD)
*/
class UnitTester extends \Codeception\Actor
{
use _generated\UnitTesterActions;
/**
* Define custom actions here
*/
}

2
tests/Support/_generated/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

13
tests/Unit.suite.yml Normal file
View File

@@ -0,0 +1,13 @@
# Codeception Test Suite Configuration
# The actor attribute identifies the testing scenario. In this case, it's a `UnitTester`
actor: UnitTester
error_level: E_ALL & ~E_STRICT & ~E_DEPRECATED
modules:
enabled:
- Asserts
coverage:
enabled: true

0
tests/Unit/.gitkeep Normal file
View File

35
tests/Unit/TestCase.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace Unit;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Leantime\Core\Application;
abstract class TestCase extends BaseTestCase
{
/**
* Creates the application.
*/
public function createApplication(): Application
{
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
// Set up default configurations for testing
config([
'app.env' => 'testing',
'cache.default' => 'array',
'session.driver' => 'array',
'database.default' => [],
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Test\Unit;
use GuzzleHttp\HandlerStack;
use Leantime\Core\Http\Client\ApiClient;
class ApiClientTest extends \Unit\TestCase
{
public function test_o_auth2(): void
{
$baseUri = 'http://test.com';
$stack = HandlerStack::create();
$requestDefaults = [];
$client = ApiClient::oAuth2($baseUri, $stack, $requestDefaults);
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
$this->assertSame($stack, $client->getConfig('handler'));
$this->assertEquals('oauth', $client->getConfig('auth'));
}
public function test_o_auth2_grants(): void
{
$baseUri = 'http://test.com';
$creds = [
'client_id' => 'testclient',
'client_secret' => 'testsecret',
];
$stack = ApiClient::oAuth2Grants($baseUri, $creds);
$this->assertInstanceOf(HandlerStack::class, $stack);
}
public function test_o_auth1(): void
{
$baseUri = 'http://test.com';
$creds = [
'consumer_key' => 'testconsumer',
'consumer_secret' => 'testsecret',
'token' => 'testtoken',
'token_secret' => 'testtokensecret',
];
$client = ApiClient::oAuth1($baseUri, $creds);
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
$this->assertEquals('oauth', $client->getConfig('auth'));
}
public function test_basic_auth(): void
{
$baseUri = 'http://test.com';
$creds = [
'username' => 'testuser',
'password' => 'testpass',
];
$client = ApiClient::basicAuth($baseUri, $creds);
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
$this->assertEquals($creds, $client->getConfig('auth'));
}
public function test_digest(): void
{
$baseUri = 'http://test.com';
$creds = [
'username' => 'testuser',
'password' => 'testpass',
'digest' => 'testdigest',
];
$client = ApiClient::digest($baseUri, $creds);
$config = $client->getConfig();
$this->assertEquals('http://test.com', $config[1]['base_uri']);
$this->assertEquals($creds, $config[1]['auth']);
}
public function test_ntlm(): void
{
$baseUri = 'http://test.com';
$creds = [
'username' => 'testuser',
'password' => 'testpass',
'ntlm' => 'testntlm',
];
$client = ApiClient::ntlm($baseUri, $creds);
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
$this->assertEquals($creds, $client->getConfig('auth'));
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Tests\Unit\App\Core;
use Leantime\Core\Application;
use Leantime\Core\Bootstrap\LoadConfig;
use Leantime\Core\Bootstrap\SetRequestForConsole;
use Leantime\Core\Configuration\Environment;
class ApplicationUrlTest extends \Unit\TestCase
{
protected $app;
protected $config;
protected function setUp(): void
{
parent::setUp();
$this->bootstrapApplication();
}
protected function bootstrapApplication()
{
$this->app = new Application(APP_ROOT);
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
$this->app->boot();
$this->config = $this->app['config'];
}
public function test_base_url_is_set_correctly_from_config(): void
{
// BASE_URL constant is set from LEAN_APP_URL in the environment.
// Verify the config matches whatever BASE_URL was resolved to.
$this->assertEquals(BASE_URL, $this->config->get('app.url'));
// Test with LEAN_APP_URL set to a known value
putenv('LEAN_APP_URL=https://example.com');
$_ENV['LEAN_APP_URL'] = 'https://example.com';
// Reinitialize application to test new environment
$this->bootstrapApplication();
$this->assertEquals('https://example.com', $this->config->get('app.url'));
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
}
public function test_base_url_handles_trailing_slash(): void
{
$_ENV['LEAN_APP_URL'] = 'https://example.com/';
$this->bootstrapApplication();
$this->assertEquals('https://example.com', $this->config->get('app.url'));
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
}
public function test_base_url_handles_subdirectory(): void
{
$_ENV['LEAN_APP_URL'] = 'https://example.com/leantime';
$this->bootstrapApplication();
$this->assertEquals('https://example.com/leantime', $this->config->get('app.url'));
$this->assertEquals('https://example.com/leantime', $this->config->get('appUrl'));
}
public function test_base_url_handles_port(): void
{
$_ENV['LEAN_APP_URL'] = 'https://example.com:8443';
$this->bootstrapApplication();
$this->assertEquals('https://example.com:8443', $this->config->get('app.url'));
$this->assertEquals('https://example.com:8443', $this->config->get('appUrl'));
}
public function test_base_url_handles_reverse_proxy(): void
{
// Simulate reverse proxy headers
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
$_SERVER['HTTP_X_FORWARDED_HOST'] = 'example.com';
$_ENV['LEAN_APP_URL'] = 'https://example.com';
$this->bootstrapApplication();
$this->assertEquals('https://example.com', $this->config->get('app.url'));
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
}
protected function tearDown(): void
{
parent::tearDown();
// Clean up environment
putenv('LEAN_APP_URL');
unset($_SERVER['HTTP_X_FORWARDED_PROTO']);
unset($_SERVER['HTTP_X_FORWARDED_HOST']);
}
}

View File

@@ -0,0 +1,299 @@
<?php
namespace Tests\Unit\app\Core\Auth\Permissions;
use Leantime\Core\Auth\Permissions\DefaultRolePermissions;
use Leantime\Core\Auth\Permissions\Permission;
/**
* Locks the built-in role -> permission matrix against a representative catalog so a change
* to DefaultRolePermissions that would over- or under-grant a role fails loudly. This is the
* grant-equivalence guard for the pilot: it proves the seeded grants match the documented
* role capabilities (readonly view-only; commenter comment/upload; editor content CRUD;
* manager moderation + all project perms; admin everything-but-company-settings; owner all).
*/
class DefaultRolePermissionsTest extends \Unit\TestCase
{
/** @return array<int, Permission> */
private function catalog(): array
{
return [
new Permission('tickets.view', 'View', true),
new Permission('tickets.comment', 'Comment', true),
new Permission('tickets.upload', 'Upload', true),
new Permission('tickets.create', 'Create', true),
new Permission('tickets.edit', 'Edit', true),
new Permission('tickets.delete', 'Delete', true),
new Permission('sprints.view', 'View', true),
new Permission('sprints.create', 'Create', true),
new Permission('sprints.edit', 'Edit', true),
new Permission('sprints.delete', 'Delete', true),
new Permission('wiki.view', 'View', true),
new Permission('wiki.create', 'Create', true),
new Permission('wiki.edit', 'Edit', true),
new Permission('wiki.delete', 'Delete', true),
new Permission('ideas.view', 'View', true),
new Permission('ideas.create', 'Create', true),
new Permission('ideas.edit', 'Edit', true),
new Permission('ideas.delete', 'Delete', true),
new Permission('blueprints.view', 'View', true),
new Permission('blueprints.create', 'Create', true),
new Permission('blueprints.edit', 'Edit', true),
new Permission('blueprints.delete', 'Delete', true),
new Permission('goals.view', 'View', true),
new Permission('goals.create', 'Create', true),
new Permission('goals.edit', 'Edit', true),
new Permission('goals.delete', 'Delete', true),
new Permission('files.view', 'View', true),
new Permission('files.upload', 'Upload', true),
new Permission('files.delete', 'Delete', true),
new Permission('reports.view', 'View', true),
// Calendar: project-scoped capability verbs (view→readonly+, create/edit/delete→editor+)
// + a GLOBAL manage verb (admin+ cross-user override; managers do NOT get it).
new Permission('calendar.view', 'View', true),
new Permission('calendar.create', 'Create', true),
new Permission('calendar.edit', 'Edit', true),
new Permission('calendar.delete', 'Delete', true),
new Permission('calendar.manage', 'Manage any calendar', false),
new Permission('comments.view', 'View', true),
new Permission('comments.create', 'Create', true),
new Permission('comments.moderate', 'Moderate', true),
// Company-wide (not project-scoped):
new Permission('users.view', 'View users', false),
new Permission('users.create', 'Invite/create users', false),
new Permission('users.edit', 'Edit users', false),
new Permission('users.delete', 'Delete users', false),
new Permission('users.import', 'Import users', false),
new Permission('clients.view', 'View clients', false),
new Permission('clients.create', 'Create clients', false),
new Permission('clients.edit', 'Edit clients', false),
new Permission('clients.delete', 'Delete clients', false),
new Permission('company.settings.view', 'View company settings', false),
new Permission('company.settings.edit', 'Edit company settings', false),
// Timesheets are company-wide (global): editor gets own-time view/create/edit/delete,
// manager+ gets manage (cross-user invoicing/reports).
new Permission('timesheets.view', 'View timesheets', false),
new Permission('timesheets.create', 'Log time', false),
new Permission('timesheets.edit', 'Edit timesheets', false),
new Permission('timesheets.delete', 'Delete timesheets', false),
new Permission('timesheets.manage', 'Manage timesheets', false),
// Project-scoped (rename a project's ticket/idea state labels — manager+ in project):
new Permission('projectsettings.labels.manage', 'Rename project labels', true),
// Projects: view is project-scoped (readonly+ data read); create/edit/delete are GLOBAL
// company actions (manager+; editors do NOT get them since global perms aren't matched
// by the editor project-verb rule — same shape as the timesheets globals).
new Permission('projects.view', 'View a project', true),
new Permission('projects.create', 'Create projects', false),
new Permission('projects.edit', 'Edit projects', false),
new Permission('projects.delete', 'Delete projects', false),
];
}
private function grantsFor(string $role): array
{
return DefaultRolePermissions::grantsFor($role, $this->catalog());
}
public function test_readonly_can_only_view_project_content(): void
{
$this->assertEqualsCanonicalizing(['tickets.view', 'comments.view', 'sprints.view', 'wiki.view', 'ideas.view', 'blueprints.view', 'goals.view', 'files.view', 'reports.view', 'calendar.view', 'projects.view'], $this->grantsFor('readonly'));
}
public function test_commenter_adds_comment_upload_and_can_create_comments(): void
{
$grants = $this->grantsFor('commenter');
$this->assertContains('tickets.view', $grants); // inherited
$this->assertContains('tickets.comment', $grants);
$this->assertContains('tickets.upload', $grants);
$this->assertContains('comments.create', $grants); // explicit commenter grant
$this->assertNotContains('tickets.create', $grants);
$this->assertNotContains('tickets.delete', $grants);
$this->assertNotContains('sprints.create', $grants); // commenter views but cannot create
$this->assertContains('sprints.view', $grants); // inherited from readonly
$this->assertNotContains('wiki.create', $grants); // commenter views but cannot create
$this->assertContains('wiki.view', $grants); // inherited from readonly
$this->assertNotContains('ideas.create', $grants); // commenter views but cannot create
$this->assertContains('ideas.view', $grants); // inherited from readonly
$this->assertNotContains('blueprints.create', $grants); // commenter views but cannot create
$this->assertContains('blueprints.view', $grants); // inherited from readonly
$this->assertNotContains('goals.create', $grants); // commenter views but cannot create
$this->assertContains('goals.view', $grants); // inherited from readonly
// Files: a commenter inherits view and gains the standard upload verb (attachments), but
// cannot delete (editor+).
$this->assertContains('files.view', $grants); // inherited from readonly
$this->assertContains('files.upload', $grants); // commenter upload verb
$this->assertNotContains('files.delete', $grants); // editor+
// Reports: view-only feature, inherited from readonly (maintainer-approved loosening of
// the legacy editor+ page gate — it only aggregates readonly-visible data).
$this->assertContains('reports.view', $grants);
// Timesheets are editor+ (global); a commenter logs no time.
$this->assertNotContains('timesheets.view', $grants);
$this->assertNotContains('timesheets.create', $grants);
$this->assertNotContains('comments.moderate', $grants);
}
public function test_editor_gets_content_crud_but_not_moderation_or_company(): void
{
$grants = $this->grantsFor('editor');
$this->assertContains('tickets.create', $grants);
$this->assertContains('tickets.edit', $grants);
$this->assertContains('tickets.delete', $grants);
// Sprints uses the same standard project verbs, so editor auto-gets create/edit/delete.
$this->assertContains('sprints.create', $grants);
$this->assertContains('sprints.edit', $grants);
$this->assertContains('sprints.delete', $grants);
// Wiki uses the same standard project verbs, so editor auto-gets create/edit/delete.
$this->assertContains('wiki.create', $grants);
$this->assertContains('wiki.edit', $grants);
$this->assertContains('wiki.delete', $grants);
// Ideas uses the same standard project verbs, so editor auto-gets create/edit/delete.
$this->assertContains('ideas.create', $grants);
$this->assertContains('ideas.edit', $grants);
$this->assertContains('ideas.delete', $grants);
// Blueprints (canvas) uses the same standard project verbs, so editor auto-gets create/edit/delete.
$this->assertContains('blueprints.create', $grants);
$this->assertContains('blueprints.edit', $grants);
$this->assertContains('blueprints.delete', $grants);
$this->assertContains('goals.create', $grants);
$this->assertContains('goals.edit', $grants);
$this->assertContains('goals.delete', $grants);
// Files uses standard project verbs, so editor auto-gets upload + delete (and view).
$this->assertContains('files.view', $grants);
$this->assertContains('files.upload', $grants);
$this->assertContains('files.delete', $grants);
// Timesheets are GLOBAL-scoped, so the project verb rule does NOT match them — editor gets
// its own-time keys explicitly (view/create/edit/delete) but NOT the manager-only `manage`.
$this->assertContains('timesheets.view', $grants);
$this->assertContains('timesheets.create', $grants);
$this->assertContains('timesheets.edit', $grants);
$this->assertContains('timesheets.delete', $grants);
$this->assertNotContains('timesheets.manage', $grants);
// Calendar uses standard PROJECT verbs, so editor auto-gets view/create/edit/delete; the
// GLOBAL manage verb (cross-user override) stays admin+.
$this->assertContains('calendar.view', $grants);
$this->assertContains('calendar.create', $grants);
$this->assertContains('calendar.edit', $grants);
$this->assertContains('calendar.delete', $grants);
$this->assertNotContains('calendar.manage', $grants);
// Projects: editor can VIEW projects (inherited from readonly) but project create/edit/delete
// are GLOBAL company actions reserved for manager+ (editors do NOT manage projects).
$this->assertContains('projects.view', $grants);
$this->assertNotContains('projects.create', $grants);
$this->assertNotContains('projects.edit', $grants);
$this->assertNotContains('projects.delete', $grants);
$this->assertContains('comments.create', $grants); // inherited
$this->assertNotContains('comments.moderate', $grants); // manager+ only
$this->assertNotContains('users.view', $grants); // company-wide, admin+
$this->assertNotContains('users.create', $grants); // company-wide, manager+
$this->assertNotContains('clients.view', $grants); // company-wide, admin+
$this->assertNotContains('company.settings.view', $grants);
// Label renaming uses the 'manage' verb (not 'edit'), so it stays manager+ and does NOT
// leak to editor via the project create/edit/delete grant.
$this->assertNotContains('projectsettings.labels.manage', $grants);
$this->assertNotContains('company.settings.edit', $grants);
}
public function test_manager_moderates_and_holds_all_project_perms_but_no_company(): void
{
$grants = $this->grantsFor('manager');
$this->assertContains('comments.moderate', $grants);
$this->assertContains('tickets.delete', $grants);
// Timesheets: manager gets the company-wide manage verb AND inherits editor's own-time keys.
$this->assertContains('timesheets.manage', $grants);
$this->assertContains('timesheets.view', $grants);
$this->assertContains('timesheets.edit', $grants);
// Calendar: manager holds all four project capability verbs (project '*' rule) but NOT the
// cross-user override — calendar.manage is GLOBAL-scoped and admin-only (legacy override was
// Auth::userIsAtLeast(admin)).
$this->assertContains('calendar.view', $grants);
$this->assertContains('calendar.create', $grants);
$this->assertContains('calendar.edit', $grants);
$this->assertContains('calendar.delete', $grants);
$this->assertNotContains('calendar.manage', $grants);
// Projects: manager gets the GLOBAL project-management keys (the matrix edit) + inherits view.
$this->assertContains('projects.view', $grants);
$this->assertContains('projects.create', $grants);
$this->assertContains('projects.edit', $grants);
$this->assertContains('projects.delete', $grants);
// Managers may INVITE users (within their own client — scoped in the controller), but
// cannot view the roster, edit, delete, or import accounts (those stay admin+).
$this->assertContains('users.create', $grants);
$this->assertNotContains('users.view', $grants);
$this->assertNotContains('users.edit', $grants);
$this->assertNotContains('users.delete', $grants);
$this->assertNotContains('users.import', $grants);
// Client management stays admin+ (managers have no real client access today — ShowAll
// redirects them and ShowClient 403s them), so a manager gets NO clients.* —
// grant-equivalent with the current behavior, not the aspirational target matrix.
$this->assertNotContains('clients.view', $grants);
$this->assertNotContains('clients.create', $grants);
$this->assertNotContains('clients.edit', $grants);
$this->assertNotContains('clients.delete', $grants);
// Renaming a project's labels is a manager-in-project capability (project '*' grant).
$this->assertContains('projectsettings.labels.manage', $grants);
$this->assertNotContains('company.settings.view', $grants);
$this->assertNotContains('company.settings.edit', $grants);
}
public function test_admin_gets_company_wide_including_company_settings(): void
{
$grants = $this->grantsFor('admin');
$this->assertContains('users.view', $grants);
$this->assertContains('users.create', $grants);
$this->assertContains('users.edit', $grants);
$this->assertContains('users.delete', $grants);
$this->assertContains('users.import', $grants); // full user management
$this->assertContains('clients.view', $grants);
$this->assertContains('clients.create', $grants);
$this->assertContains('clients.edit', $grants);
$this->assertContains('clients.delete', $grants); // full client management
$this->assertContains('projectsettings.labels.manage', $grants);
$this->assertContains('comments.moderate', $grants);
$this->assertContains('tickets.delete', $grants);
$this->assertContains('calendar.manage', $grants); // cross-user calendar override (admin+)
// Per policy (admin views + edits company settings), admins hold both company.settings
// keys via an explicit grant alongside the wildcard-with-exclude rule.
$this->assertContains('company.settings.view', $grants);
$this->assertContains('company.settings.edit', $grants);
}
public function test_owner_gets_everything_including_company_settings(): void
{
$grants = $this->grantsFor('owner');
$this->assertContains('company.settings.view', $grants);
$this->assertContains('company.settings.edit', $grants);
$this->assertContains('projectsettings.labels.manage', $grants);
$this->assertContains('clients.delete', $grants);
$this->assertContains('users.view', $grants);
$this->assertContains('comments.moderate', $grants);
$this->assertContains('tickets.delete', $grants);
}
/**
* Regression: a rule that combines an explicit `keys` allow-list with an `exclude` list must
* still honor the exclude. matches() previously returned early for `keys` rules and bypassed
* the exclude entirely, which could over-grant an excluded permission.
*/
public function test_keys_rule_still_honors_exclude(): void
{
$matches = new \ReflectionMethod(DefaultRolePermissions::class, 'matches');
$matches->setAccessible(true);
$rule = [
'scope' => 'global',
'keys' => ['company.settings.view', 'company.settings.edit'],
'exclude' => ['company.settings.edit'],
];
$included = new Permission('company.settings.view', 'View', false);
$excluded = new Permission('company.settings.edit', 'Edit', false);
$this->assertTrue($matches->invoke(null, $included, $rule), 'A keys-listed, non-excluded permission still matches');
$this->assertFalse($matches->invoke(null, $excluded, $rule), 'A keys-listed permission that is also excluded must NOT match');
}
}

View File

@@ -0,0 +1,206 @@
<?php
namespace Tests\Unit\app\Core\Auth\Permissions;
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Auth\Permissions\RequiresPermission;
/**
* Verifies the PermissionEnforcer resolves the project scope correctly for each
* RequiresPermission mode: entityScoped defers (the method self-authorizes its loaded entity),
* global checks the company-wide role, projectIdParam reads the named request param, and the
* default falls back to the session project. A method with no attribute is a complete no-op.
*/
class PermissionEnforcerTest extends \Unit\TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Build an enforcer whose engine records every currentUserCan(...) call into $calls and
* answers $allow, so we can assert exactly what the enforcer asked the engine.
*
* @param array<int, array{key: string, projectId: ?int, forceGlobal: bool}> $calls
*/
private function spyEnforcer(array &$calls, bool $allow = true): PermissionEnforcer
{
$permissions = $this->make(PermissionService::class, [
'currentUserCan' => function (string $key, ?int $projectId = null, ?bool $forceGlobal = false) use (&$calls, $allow): bool {
$calls[] = ['key' => $key, 'projectId' => $projectId, 'forceGlobal' => (bool) $forceGlobal];
return $allow;
},
]);
return new PermissionEnforcer($permissions);
}
public function test_entity_scoped_defers_and_never_calls_the_engine(): void
{
// entityScoped methods authorize their loaded entity's project in their own body, so the
// enforcer must not run a check here (it can't see the entity, would use the wrong
// project). Even a denying engine must produce no call and no throw.
$calls = [];
$enforcer = $this->spyEnforcer($calls, allow: false);
$enforcer->enforce(PermissionEnforcerFixture::class, 'entityScopedAction', ['id' => 5]);
$this->assertSame([], $calls, 'entityScoped should defer to the in-method authorize()');
}
public function test_global_checks_company_role_not_a_project(): void
{
$calls = [];
$enforcer = $this->spyEnforcer($calls);
$enforcer->enforce(PermissionEnforcerFixture::class, 'globalAction', []);
$this->assertSame([['key' => 'users.create', 'projectId' => null, 'forceGlobal' => true]], $calls);
}
public function test_project_id_param_is_read_from_the_named_argument(): void
{
$calls = [];
$enforcer = $this->spyEnforcer($calls);
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', ['projectId' => 42]);
$this->assertSame([['key' => 'tickets.view', 'projectId' => 42, 'forceGlobal' => false]], $calls);
}
public function test_default_falls_back_to_the_session_project(): void
{
session(['currentProject' => 7]);
$calls = [];
$enforcer = $this->spyEnforcer($calls);
$enforcer->enforce(PermissionEnforcerFixture::class, 'sessionAction', []);
$this->assertSame([['key' => 'tickets.view', 'projectId' => 7, 'forceGlobal' => false]], $calls);
}
public function test_unannotated_method_is_a_noop(): void
{
$calls = [];
$enforcer = $this->spyEnforcer($calls, allow: false);
$enforcer->enforce(PermissionEnforcerFixture::class, 'plainAction', []);
$this->assertSame([], $calls);
}
public function test_mandatory_project_param_absent_fails_closed(): void
{
// paramAction declares projectIdParam:'projectId' and types it `int` (no default) — the
// project is mandatory. With it absent, the enforcer must NOT fall back to the session
// project (which would authorize the wrong project); it denies without consulting the
// engine. allow:true proves the denial comes from the unresolved-project path, not a
// negative engine answer.
config(['permissions.enforce' => true]);
$calls = [];
$enforcer = $this->spyEnforcer($calls, allow: true);
$threw = false;
try {
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', []);
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
$threw = true;
}
$this->assertTrue($threw, 'an unresolvable mandatory project param must deny');
$this->assertSame([], $calls, 'the engine must not be consulted when the project is unresolvable');
}
public function test_mandatory_project_param_explicit_null_fails_closed(): void
{
// isset() was the original bug: it is false for an explicit null, so a null projectId
// silently fell through to the session project. A mandatory param passed null now denies.
config(['permissions.enforce' => true]);
$calls = [];
$enforcer = $this->spyEnforcer($calls, allow: true);
$threw = false;
try {
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', ['projectId' => null]);
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
$threw = true;
}
$this->assertTrue($threw, 'an explicit-null mandatory project param must deny');
$this->assertSame([], $calls);
}
public function test_invalid_project_param_is_treated_as_unresolved(): void
{
// A bare (int) cast would mis-resolve every one of these: [7] (array) → 1, '-5' → -5,
// '7abc' → 7, and an out-of-range digit string → PHP_INT_MAX. None name a real project,
// so each must be unresolved → deny for a mandatory param, never silently coerced.
config(['permissions.enforce' => true]);
$invalid = [
['projectId' => [7]], // non-scalar
['projectId' => '-5'], // negative
['projectId' => '7abc'], // non-numeric
['projectId' => '999999999999999999999999'], // overflows the platform int range
];
foreach ($invalid as $params) {
$calls = [];
$enforcer = $this->spyEnforcer($calls, allow: true);
$threw = false;
try {
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', $params);
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
$threw = true;
}
$this->assertTrue($threw, 'non-positive-integer project param must be unresolved → deny: '.json_encode($params));
$this->assertSame([], $calls);
}
}
public function test_optional_project_param_keeps_the_session_fallback(): void
{
// optionalParamAction defaults projectId to null ("current project"), so an absent value
// is legitimate — the enforcer authorizes against the session project, exactly as the
// method itself will operate. This is what makes the poll/dashboard endpoints keep working.
session(['currentProject' => 7]);
$calls = [];
$enforcer = $this->spyEnforcer($calls);
$enforcer->enforce(PermissionEnforcerFixture::class, 'optionalParamAction', []);
$this->assertSame([['key' => 'tickets.view', 'projectId' => 7, 'forceGlobal' => false]], $calls);
}
}
/**
* Fixture exercising each RequiresPermission resolution mode. Bodies are intentionally empty —
* only the attributes matter to the enforcer.
*/
class PermissionEnforcerFixture
{
#[RequiresPermission('tickets.edit', entityScoped: true)]
public function entityScopedAction(int $id): void {}
#[RequiresPermission('users.create', global: true)]
public function globalAction(): void {}
#[RequiresPermission('tickets.view', projectIdParam: 'projectId')]
public function paramAction(int $projectId): void {}
// Same attribute, but the project param is OPTIONAL (defaults to null) — "current project"
// semantics. An absent/null value must keep the session fallback, not deny.
#[RequiresPermission('tickets.view', projectIdParam: 'projectId')]
public function optionalParamAction(?int $projectId = null): void {}
#[RequiresPermission('tickets.view')]
public function sessionAction(): void {}
public function plainAction(): void {}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Unit\app\Core\Controller;
use Illuminate\Support\Facades\Cache;
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Http\IncomingRequest;
use Unit\TestCase;
/**
* Regression coverage for stale route-cache entries. Routes resolved by the
* Frontcontroller are cached across requests in the installation store and can
* outlive a deploy: a controller whose run() was replaced by get()/post() left
* a cached ['method' => 'run'] entry behind, and callAction('run') then hit
* __call() and produced a 500 (seen in production on /calendar/showMyCalendar
* and /timesheets/showMy). A cached entry must only be trusted if its class and
* method still exist; otherwise it gets dropped and the route re-resolved.
*/
class FrontcontrollerRouteCacheTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// The cached-route read path is only taken when debug is off (writes happen
// regardless), so the validation under test requires debug to be disabled.
config(['debug' => false]);
}
private function frontcontroller(): Frontcontroller
{
// Built by hand: container resolution would pull in the real
// PermissionEnforcer, which needs a database connection.
return new Frontcontroller(
IncomingRequest::create('/calendar/showMyCalendar', 'GET'),
$this->createMock(PermissionEnforcer::class),
);
}
private function cacheKey(string $module, string $action, string $method): string
{
return 'routes.'.$module.'.Controllers.'.$action.'.'.$method;
}
public function test_stale_cached_method_is_dropped_and_route_reresolved(): void
{
// Simulate a pre-deploy cache entry pointing at the removed run() method.
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
Cache::store('installation')->set($key, [
'class' => \Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class,
'method' => 'run',
]);
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
$this->assertSame('get', $result['method']);
$this->assertSame(\Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class, $result['class']);
// The stale entry must have been replaced with the fresh resolution.
$this->assertSame($result, Cache::store('installation')->get($key));
}
public function test_cached_entry_with_missing_class_is_dropped(): void
{
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
Cache::store('installation')->set($key, [
'class' => 'Leantime\\Domain\\Calendar\\Controllers\\NoLongerExists',
'method' => 'get',
]);
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
$this->assertSame(\Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class, $result['class']);
$this->assertSame('get', $result['method']);
}
public function test_valid_cached_entry_is_returned_as_is(): void
{
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
$cached = [
'class' => \Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class,
'method' => 'get',
];
Cache::store('installation')->set($key, $cached);
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
$this->assertSame($cached, $result);
}
}

View File

@@ -0,0 +1,395 @@
<?php
namespace Unit\app\Core\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Concerns\InteractsWithFilters;
use Leantime\Core\Events\Contracts\LeantimeEvent;
use Leantime\Core\Events\Contracts\LeantimeFilter;
use Leantime\Core\Events\EventDispatcher;
use Unit\TestCase;
/**
* Fixture event mirroring a migrated domain event: typed payload plus the
* `legacyHook: __FUNCTION__` discriminator pattern — each dispatch rebuilds the single
* historical name of its emit site (never a static list of all sites).
*/
class FixtureThingUpdated implements LeantimeEvent
{
use InteractsWithEvents;
public function __construct(
public readonly int $thingId,
private readonly ?string $legacyHook = null,
) {}
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.things.services.things.'.$this->legacyHook.'.thing_updated'];
}
}
/**
* Fixture event without legacy hooks (an event introduced after the class-based system).
*/
class FixtureThingCreated implements LeantimeEvent
{
use InteractsWithEvents;
public function __construct(public readonly int $thingId) {}
}
/**
* Fixture class-based listener (resolved through the container, handle() receives the
* typed event object).
*/
class FixtureThingListener
{
public static array $received = [];
public function handle(FixtureThingUpdated $event): void
{
self::$received[] = $event;
}
}
/**
* Fixture invokable listener (no handle() method) to cover the __invoke fallback for
* array-form registrations like [FixtureInvokableListener::class].
*/
class FixtureInvokableListener
{
public static ?object $received = null;
public function __invoke(FixtureThingCreated $event): void
{
self::$received = $event;
}
}
/**
* Fixture filter mirroring a migrated domain filter: payload plus typed context.
*/
class FixtureThingsFilter implements LeantimeFilter
{
use InteractsWithFilters;
public function __construct(public array $things, public readonly int $userId) {}
public function payload(): mixed
{
return $this->things;
}
public function legacyHooks(): array
{
return [
'leantime.domain.things.services.things.getThings.filterThings',
];
}
}
class ClassEventDispatchTest extends TestCase
{
private array $staticSnapshot = [];
private const STATIC_PROPS = [
'eventRegistry',
'filterRegistry',
'available_hooks',
'patternMatchCache',
'compiledPatternCache',
'eventRegistryVersion',
'filterRegistryVersion',
];
protected function setUp(): void
{
parent::setUp();
$reflection = new \ReflectionClass(EventDispatcher::class);
foreach (self::STATIC_PROPS as $prop) {
$property = $reflection->getProperty($prop);
$this->staticSnapshot[$prop] = $property->getValue();
}
FixtureThingListener::$received = [];
}
protected function tearDown(): void
{
$reflection = new \ReflectionClass(EventDispatcher::class);
foreach ($this->staticSnapshot as $prop => $value) {
$property = $reflection->getProperty($prop);
$property->setValue(null, $value);
}
parent::tearDown();
}
/**
* A closure listener registered on the FQCN receives the bare typed event object.
*/
public function test_fqcn_closure_listener_receives_typed_event_object(): void
{
$received = null;
EventDispatcher::add_event_listener(FixtureThingUpdated::class, function ($event) use (&$received) {
$received = $event;
});
FixtureThingUpdated::dispatch(thingId: 42);
$this->assertInstanceOf(FixtureThingUpdated::class, $received);
$this->assertSame(42, $received->thingId);
}
/**
* A class-string listener registered on the FQCN is container-resolved and its
* handle() method receives the typed event object. This is the cacheable
* registration style new code should use (no closures).
*/
public function test_fqcn_class_listener_handle_receives_typed_event_object(): void
{
EventDispatcher::add_event_listener(FixtureThingUpdated::class, FixtureThingListener::class);
FixtureThingUpdated::dispatch(thingId: 7);
$this->assertCount(1, FixtureThingListener::$received);
$this->assertSame(7, FixtureThingListener::$received[0]->thingId);
}
/**
* An invokable listener registered in array form ([Class::class], no handle()
* method) falls back to __invoke() — same as the string registration form.
*/
public function test_array_form_invokable_listener_falls_back_to_invoke(): void
{
FixtureInvokableListener::$received = null;
EventDispatcher::add_event_listener(FixtureThingCreated::class, [FixtureInvokableListener::class]);
FixtureThingCreated::dispatch(thingId: 11);
$this->assertInstanceOf(FixtureThingCreated::class, FixtureInvokableListener::$received);
$this->assertSame(11, FixtureInvokableListener::$received->thingId);
}
/**
* BACKWARDS COMPATIBILITY: a listener registered on the exact historical string name
* fires and receives today's array payload (event properties + current_route +
* currentEvent) — NOT the event object. Existing plugins keep working unchanged.
*/
public function test_legacy_string_listener_receives_legacy_array_payload(): void
{
$received = null;
EventDispatcher::add_event_listener(
'leantime.domain.things.services.things.updateThing.thing_updated',
function ($params) use (&$received) {
$received = $params;
}
);
FixtureThingUpdated::dispatch(thingId: 42, legacyHook: 'updateThing');
$this->assertIsArray($received);
$this->assertSame(42, $received['thingId']);
$this->assertSame(
'leantime.domain.things.services.things.updateThing.thing_updated',
$received['currentEvent']
);
$this->assertArrayHasKey('current_route', $received);
}
/**
* BACKWARDS COMPATIBILITY: plugin wildcard subscriptions (leantime.domain.*.services.*)
* match the legacy name of a class-based event — exactly ONCE per dispatch, because
* each emit site contributes only its own historical name via the legacyHook
* discriminator. Both historical names stay reachable from their respective sites.
*/
public function test_wildcard_listener_fires_once_per_dispatch_for_legacy_hook(): void
{
$called = 0;
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
$called++;
});
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
$this->assertSame(1, $called);
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
$this->assertSame(2, $called);
}
/**
* BACKWARDS COMPATIBILITY: an exact subscriber to one historical site's name does
* NOT fire when a different site emits the same logical event — per-site semantics
* are preserved through the migration window.
*/
public function test_exact_legacy_listener_keeps_per_site_semantics(): void
{
$called = 0;
EventDispatcher::add_event_listener(
'leantime.domain.things.services.things.patchThing.thing_updated',
function () use (&$called) {
$called++;
}
);
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
$this->assertSame(0, $called);
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
$this->assertSame(1, $called);
}
/**
* Wildcard string listeners do NOT accidentally match the FQCN (backslashes and
* case don't fit the dotted lowercase patterns).
*/
public function test_wildcard_listener_does_not_match_fqcn(): void
{
$called = 0;
EventDispatcher::add_event_listener('leantime.*', function () use (&$called) {
$called++;
});
FixtureThingCreated::dispatch(thingId: 1);
$this->assertSame(0, $called);
}
/**
* An event with no legacy hooks only reaches FQCN listeners.
*/
public function test_event_without_legacy_hooks_fires_fqcn_listener_only(): void
{
$received = null;
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
$received = $event;
});
FixtureThingCreated::dispatch(thingId: 9);
$this->assertInstanceOf(FixtureThingCreated::class, $received);
$this->assertContains(FixtureThingCreated::class, EventDispatcher::get_available_hooks()['events']);
}
/**
* FQCN listeners run in priority order, lower number first.
*/
public function test_fqcn_listeners_run_in_priority_order(): void
{
$order = [];
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
$order[] = 30;
}, 30);
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
$order[] = 10;
}, 10);
FixtureThingCreated::dispatch(thingId: 1);
$this->assertSame([10, 30], $order);
}
/**
* Class filter: FQCN listeners thread the payload and receive the filter object as
* typed context; the final payload is returned.
*/
public function test_class_filter_threads_payload_through_fqcn_listeners(): void
{
$receivedFilter = null;
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) use (&$receivedFilter) {
$receivedFilter = $filter;
$things[] = 'added-by-listener';
return $things;
});
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
$this->assertSame(['original', 'added-by-listener'], $result);
$this->assertInstanceOf(FixtureThingsFilter::class, $receivedFilter);
$this->assertSame(5, $receivedFilter->userId);
}
/**
* BACKWARDS COMPATIBILITY: a filter listener on the historical string name receives
* today's ($payload, $availableParams) signature — params include the filter's
* public properties plus current_route/currentEvent — and its return value threads
* into the final result, after FQCN listeners.
*/
public function test_class_filter_threads_payload_through_legacy_listeners(): void
{
$receivedParams = null;
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) {
$things[] = 'fqcn';
return $things;
});
EventDispatcher::add_filter_listener(
'leantime.domain.things.services.things.getThings.filterThings',
function ($things, $params) use (&$receivedParams) {
$receivedParams = $params;
$things[] = 'legacy';
return $things;
}
);
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
// FQCN group runs first, then the legacy group threads its output.
$this->assertSame(['original', 'fqcn', 'legacy'], $result);
$this->assertSame(5, $receivedParams['userId']);
$this->assertArrayHasKey('current_route', $receivedParams);
}
/**
* A filter with no listeners at all returns the payload unchanged.
*/
public function test_class_filter_without_listeners_returns_payload_unchanged(): void
{
$result = FixtureThingsFilter::dispatch(things: ['untouched'], userId: 1);
$this->assertSame(['untouched'], $result);
}
/**
* The instance apply() ergonomic returns the filtered payload too.
*/
public function test_class_filter_apply_instance_method(): void
{
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things) {
$things[] = 'applied';
return $things;
});
$filter = new FixtureThingsFilter(things: ['a'], userId: 2);
$this->assertSame(['a', 'applied'], $filter->apply());
}
/**
* Class events route correctly through Laravel's event() helper / the instance
* dispatch() of the Dispatcher interface as well.
*/
public function test_class_event_routes_through_laravel_event_helper(): void
{
$received = null;
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
$received = $event;
});
event(new FixtureThingCreated(thingId: 3));
$this->assertInstanceOf(FixtureThingCreated::class, $received);
$this->assertSame(3, $received->thingId);
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace Unit\app\Core\Events;
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Core\Events\EventDispatcher;
use Leantime\Core\WorkStructure\Events\StructureRegistered;
use Unit\TestCase;
/**
* Fixture emitter that dispatches through the DispatchesEvents trait exactly like a
* domain service does, so the auto-generated event names (lowercased FQCN + method +
* raw hook) match the real runtime format.
*/
class CharacterizationEmitter
{
use DispatchesEvents;
public function updateThing(): void
{
self::dispatchEvent('thing_updated', ['thingId' => 7]);
}
public function filterThing(int $payload): mixed
{
return self::dispatchFilter('thing_filter', $payload, ['mode' => 'strict']);
}
}
/**
* Characterization tests locking the CURRENT EventDispatcher behavior before the
* class-based event bridge is added. These tests document the string-event contract
* that existing plugins rely on; they must keep passing unchanged.
*/
class EventDispatcherCharacterizationTest extends TestCase
{
private array $staticSnapshot = [];
private const STATIC_PROPS = [
'eventRegistry',
'filterRegistry',
'available_hooks',
'patternMatchCache',
'compiledPatternCache',
'eventRegistryVersion',
'filterRegistryVersion',
];
protected function setUp(): void
{
parent::setUp();
$reflection = new \ReflectionClass(EventDispatcher::class);
foreach (self::STATIC_PROPS as $prop) {
$property = $reflection->getProperty($prop);
$this->staticSnapshot[$prop] = $property->getValue();
}
}
protected function tearDown(): void
{
$reflection = new \ReflectionClass(EventDispatcher::class);
foreach ($this->staticSnapshot as $prop => $value) {
$property = $reflection->getProperty($prop);
$property->setValue(null, $value);
}
parent::tearDown();
}
/**
* The DispatchesEvents trait builds the full event name as
* strtolower(FQCN with \ -> .) + '.' + emitting method + '.' + raw hook.
* Plugins subscribe to exactly these strings — the format must not drift.
*/
public function test_trait_builds_full_event_name_from_class_and_method(): void
{
(new CharacterizationEmitter)->updateThing();
$this->assertContains(
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
EventDispatcher::get_available_hooks()['events']
);
}
/**
* A listener registered on the full string name receives a SINGLE array argument:
* the dispatched payload merged with current_route and currentEvent.
*/
public function test_string_event_listener_receives_define_params_array(): void
{
$received = null;
EventDispatcher::add_event_listener(
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
function ($params) use (&$received) {
$received = $params;
}
);
(new CharacterizationEmitter)->updateThing();
$this->assertIsArray($received);
$this->assertSame(7, $received['thingId']);
$this->assertSame(
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
$received['currentEvent']
);
$this->assertArrayHasKey('current_route', $received);
}
/**
* Filter listeners receive ($payload, $availableParams) where availableParams is the
* emitter-provided context merged with current_route/currentEvent, and the payload is
* threaded through listeners in priority order (lower priority number runs first).
*/
public function test_filter_threads_payload_in_priority_order_and_passes_params(): void
{
$fullName = 'unit.app.core.events.characterizationemitter.filterThing.thing_filter';
$receivedParams = null;
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) use (&$receivedParams) {
$receivedParams = $params;
return $payload + 1;
}, 20);
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) {
return $payload * 2;
}, 10);
$result = (new CharacterizationEmitter)->filterThing(5);
// priority 10 runs first: 5 * 2 = 10, then priority 20: 10 + 1 = 11
$this->assertSame(11, $result);
$this->assertSame('strict', $receivedParams['mode']);
$this->assertSame($fullName, $receivedParams['currentEvent']);
$this->assertArrayHasKey('current_route', $receivedParams);
}
/**
* Plugins rely on wildcard subscriptions (e.g. leantime.domain.*.services.*) matching
* the auto-generated full names. The * wildcard must keep matching.
*/
public function test_wildcard_listener_matches_full_event_name(): void
{
$called = 0;
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
$called++;
});
EventDispatcher::dispatch_event('leantime.domain.faux.services.faux.doIt.did_it', ['x' => 1], '');
$this->assertSame(1, $called);
}
/**
* Event listeners for one hook run in priority order, lower number first.
*/
public function test_event_listeners_run_in_priority_order(): void
{
$order = [];
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
$order[] = 30;
}, 30);
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
$order[] = 10;
}, 10);
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
$order[] = 20;
}, 20);
EventDispatcher::dispatch_event('char.priority.event', [], '');
$this->assertSame([10, 20, 30], $order);
}
/**
* Current behavior for plain object events (Laravel Dispatchable path, e.g. the
* WorkStructure events): the object resolves to its FQCN as the listener name and a
* 'leantime' source listener receives the defineParams array with the object at [0].
*/
public function test_plain_object_event_fires_fqcn_string_listener(): void
{
$received = null;
EventDispatcher::add_event_listener(StructureRegistered::class, function ($params) use (&$received) {
$received = $params;
});
StructureRegistered::dispatch(1, 'My Structure', 'system');
$this->assertIsArray($received);
$this->assertInstanceOf(StructureRegistered::class, $received[0]);
$this->assertSame(1, $received[0]->structureId);
}
/**
* The pattern-match cache is invalidated when a listener is added (version counter),
* so listeners registered after a first dispatch still fire on later dispatches.
*/
public function test_pattern_cache_busts_when_listener_added_after_dispatch(): void
{
$first = 0;
$second = 0;
EventDispatcher::add_event_listener('char.cache.*', function () use (&$first) {
$first++;
});
EventDispatcher::dispatch_event('char.cache.bust', [], '');
EventDispatcher::add_event_listener('char.cache.*', function () use (&$second) {
$second++;
});
EventDispatcher::dispatch_event('char.cache.bust', [], '');
$this->assertSame(2, $first);
$this->assertSame(1, $second);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Unit\app\Core\Events;
use Codeception\Test\Unit;
use Leantime\Core\Events\Contracts\LeantimeEvent;
use Leantime\Core\Events\Contracts\LeantimeFilter;
use Leantime\Core\Events\EventVerb;
/**
* Enforces the shared event vocabulary across all domains:
*
* - event classes are named {Entity}{Verb} with the verb from the central EventVerb
* enum (TicketCreated, MilestoneDeleted — never TicketChanged/TicketEdited)
* - filter classes are named {Thing}Filter (TodoWidgetTasksFilter)
*
* Scans every class in app/Domain/* /Events and app/Core/* /Events that implements
* LeantimeEvent or LeantimeFilter. Failing this test means a synonym crept in — use an
* existing verb or (rarely) add one to EventVerb.
*/
class EventVocabularyTest extends Unit
{
public function test_event_class_names_end_with_central_vocabulary_verb(): void
{
$discovered = $this->discoverEventClasses();
// Guard against a vacuous pass: if discovery silently finds nothing (e.g. a
// broken base path), the loop below would assert nothing. The pilot ships ten
// contract classes in Tickets, so discovery must find them.
$this->assertContains(
\Leantime\Domain\Tickets\Events\TicketUpdated::class,
$discovered,
'Event class discovery found nothing — the vocabulary check would pass vacuously.'
);
$violations = [];
foreach ($discovered as $class) {
$implements = class_implements($class);
$shortName = substr($class, strrpos($class, '\\') + 1);
if (in_array(LeantimeFilter::class, $implements, true)) {
if (! str_ends_with($shortName, 'Filter')) {
$violations[] = "$class — filter classes must be named {Thing}Filter";
}
continue;
}
if (in_array(LeantimeEvent::class, $implements, true)) {
$endsWithVerb = false;
foreach (EventVerb::cases() as $verb) {
if (str_ends_with($shortName, $verb->name)) {
$endsWithVerb = true;
break;
}
}
if (! $endsWithVerb) {
$violations[] = "$class — event classes must be named {Entity}{Verb} with a verb from EventVerb";
}
}
}
$this->assertSame([], $violations, "Event vocabulary violations:\n".implode("\n", $violations));
}
/**
* Finds all classes in Domain and Core Events/ folders that implement one of the
* class-based hook contracts.
*
* @return array<int, class-string>
*/
private function discoverEventClasses(): array
{
// Anchor on the canonical app-root constant rather than a brittle relative
// dirname() hop, so the scan can't silently miss the Events folders.
$appRoot = defined('APP_ROOT') ? APP_ROOT : dirname(__DIR__, 5);
$files = array_merge(
glob($appRoot.'/app/Domain/*/Events/*.php') ?: [],
glob($appRoot.'/app/Core/*/Events/*.php') ?: [],
);
$classes = [];
foreach ($files as $file) {
$relative = str_replace([$appRoot.'/app/', '/', '.php'], ['', '\\', ''], $file);
$class = 'Leantime\\'.$relative;
if (! class_exists($class)) {
continue;
}
$implements = class_implements($class) ?: [];
if (in_array(LeantimeEvent::class, $implements, true)
|| in_array(LeantimeFilter::class, $implements, true)) {
$classes[] = $class;
}
}
return $classes;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Unit\app\Core\Events;
use Codeception\Test\Unit;
use Leantime\Core\Events\EventDispatcher;
class EventsTest extends Unit
{
/**
* This test will check the dispatch_event method of the EventDispatcher class.
* It will dispatch an event and assert if it is added to the available_hooks array.
*/
public function test_dispatch_event()
{
$eventName = 'test.event.name';
$payload = ['testKey' => 'testValue'];
$context = 'testContext';
// Dispatch event
EventDispatcher::dispatch_event($eventName, $payload, $context);
// Get all available hooks
$available_hooks = EventDispatcher::get_available_hooks();
// Test that the dispatched event has been registered in available_hooks
$this->assertContains("$context.$eventName", $available_hooks['events']);
}
/**
* This test will check the findEventListeners method of the EventDispatcher class.
*/
public function test_find_event_listeners()
{
$eventName = 'test.event.name';
$listenerName = 'test.listener';
$payload = ['testKey' => 'testValue'];
$context = 'testContext';
$eventListeners = [$listenerName => [$payload]];
EventDispatcher::add_event_listener($listenerName, function () {}, 10);
// Test that the event listener has been found
$this->assertEquals([$payload], EventDispatcher::findEventListeners($listenerName, $eventListeners));
}
/**
* This test will check the get_registries method of the EventDispatcher class.
* It will add new event listener and a new filter listener and check both listeners
* are in the registry arrays.
*/
public function test_get_registries()
{
$eventName = 'event.test.name';
$filterName = 'filter.test.name';
// Add an event listener
EventDispatcher::add_event_listener($eventName, function () {}, 10);
// Add a filter listener
EventDispatcher::add_filter_listener($filterName, function () {}, 10);
// Get registries
$registries = EventDispatcher::get_registries();
// Check registries
$this->assertContains($eventName, $registries['events']);
$this->assertContains($filterName, $registries['filters']);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Test\Unit;
class ExampleTest extends \Unit\TestCase
{
public function test_example(): void
{
// A simple test to demonstrate the testing process
$this->assertTrue(true);
}
public function test_string_operations(): void
{
// A slightly more complex test
$string = 'Hello, Leantime!';
$this->assertEquals('Hello, Leantime!', $string);
$this->assertStringContainsString('Leantime', $string);
$this->assertStringStartsWith('Hello', $string);
$this->assertStringEndsWith('!', $string);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Unit\app\Core\Exceptions;
use Leantime\Core\Exceptions\AuthException;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Exceptions\Contracts\LeantimeExceptionInterface;
use Leantime\Core\Exceptions\EntityExistsException;
use Leantime\Core\Exceptions\InvalidArgumentException;
use Leantime\Core\Exceptions\MissingParameterException;
use Leantime\Core\Exceptions\NotFoundException;
use Leantime\Core\Exceptions\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Unit\TestCase;
/**
* The typed exception hierarchy: each carries its own HTTP status (honored by the global
* ExceptionHandler because it implements HttpExceptionInterface) and JSON-RPC error code
* (read by JsonRpcErrorResponse). Auth + validation are modeled as exceptions per the design.
*/
class LeantimeExceptionTest extends TestCase
{
public function test_authorization_exception_carries_403_and_rpc_auth_code(): void
{
$e = new AuthorizationException;
$this->assertInstanceOf(LeantimeExceptionInterface::class, $e);
$this->assertInstanceOf(HttpExceptionInterface::class, $e);
$this->assertSame(403, $e->getStatusCode());
$this->assertSame(-32001, $e->getRpcCode());
$this->assertSame([], $e->getErrorData());
$this->assertNotSame('', $e->getClientMessage());
}
public function test_not_found_exception_carries_404(): void
{
$e = new NotFoundException;
$this->assertSame(404, $e->getStatusCode());
$this->assertSame(-32002, $e->getRpcCode());
}
public function test_validation_exception_carries_422_field_errors_and_invalid_params_code(): void
{
$errors = ['headline' => ['The headline is required.']];
$e = ValidationException::withMessages($errors);
$this->assertSame(422, $e->getStatusCode());
$this->assertSame(-32602, $e->getRpcCode());
$this->assertSame($errors, $e->getErrorData());
}
public function test_validate_bridge_returns_validated_data_on_success(): void
{
$validated = ValidationException::validate(
['name' => 'Acme', 'extra' => 'ignored'],
['name' => 'required|string'],
);
// validated() returns only the validated keys.
$this->assertSame(['name' => 'Acme'], $validated);
}
public function test_validate_bridge_throws_leantime_type_with_field_errors_on_failure(): void
{
try {
ValidationException::validate(['name' => ''], ['name' => 'required']);
$this->fail('Expected a ValidationException to be thrown.');
} catch (ValidationException $e) {
$this->assertArrayHasKey('name', $e->getErrorData());
$this->assertSame(-32602, $e->getRpcCode());
}
}
public function test_retrofitted_exceptions_expose_http_status_and_rpc_code(): void
{
$this->assertSame(409, (new EntityExistsException)->getStatusCode());
$this->assertSame(-32005, (new EntityExistsException)->getRpcCode());
$this->assertSame(422, (new InvalidArgumentException)->getStatusCode());
$this->assertSame(-32602, (new InvalidArgumentException)->getRpcCode());
$missing = new MissingParameterException('x missing');
$this->assertSame(422, $missing->getStatusCode());
$this->assertSame(-32602, $missing->getRpcCode());
$this->assertInstanceOf(LeantimeExceptionInterface::class, $missing);
}
public function test_retrofitted_exception_preserves_legacy_get_code(): void
{
// BC: the HTTP status historically lived in getCode(); keep it readable there too.
$this->assertSame(409, (new EntityExistsException('dupe'))->getCode());
}
public function test_auth_exception_is_a_deprecated_authorization_alias(): void
{
$e = new AuthException('Invalid domain user');
// It IS an AuthorizationException (a deprecated alias, not a second auth exception),
// so it keeps the 403 status + -32001 rpc code while the AdvancedAuth plugin and
// external installs that still throw the old class name keep working.
$this->assertInstanceOf(AuthorizationException::class, $e);
$this->assertSame(403, $e->getStatusCode());
$this->assertSame(-32001, $e->getRpcCode());
// Legacy ($message, $code) signature preserved, including getCode().
$this->assertSame('Invalid domain user', $e->getMessage());
$this->assertSame(403, $e->getCode());
}
}

View File

@@ -0,0 +1,408 @@
<?php
namespace Tests\Unit\app\Core\Files;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Files\Exceptions\FileValidationException;
use Leantime\Core\Files\FileManager;
use Leantime\Core\Language;
use Leantime\Core\Support\CarbonMacros;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Response;
use Unit\TestCase;
class FileManagerTest extends TestCase
{
private $filesystemManager;
private $config;
private $fileManager;
private $storage;
protected function setUp(): void
{
parent::setUp();
// Set up session values needed for DateTimeHelper (used by dtHelper())
session(['usersettings.timezone' => 'UTC']);
session(['usersettings.language' => 'en-US']);
session(['usersettings.date_format' => 'Y-m-d']);
session(['usersettings.time_format' => 'H:i']);
// Mock Environment and bind to container for dtHelper()
$envMock = $this->createMock(Environment::class);
$envMock->defaultTimezone = 'UTC';
$envMock->language = 'en-US';
app()->instance(Environment::class, $envMock);
// Mock Language and bind to container
$langMock = $this->createMock(Language::class);
$langMock->method('__')->willReturnCallback(function ($index) {
$map = [
'language.dateformat' => 'Y-m-d',
'language.timeformat' => 'H:i',
];
return $map[$index] ?? $index;
});
app()->instance(Language::class, $langMock);
// Register CarbonMacros for date parsing
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
// Mock the FilesystemManager
$this->filesystemManager = $this->createMock(FilesystemManager::class);
// Mock the Environment
$this->config = $this->createMock(Environment::class);
// Mock the storage disk
$this->storage = $this->createMock(FilesystemAdapter::class);
// Setup the FileManager with mocked dependencies
$this->fileManager = new FileManager(
$this->filesystemManager,
$this->config
);
// Create a test file in userfiles directory
$testDir = base_path('userfiles/test');
if (! is_dir($testDir)) {
mkdir($testDir, 0777, true);
}
file_put_contents($testDir.'/test.txt', 'test content');
}
protected function tearDown(): void
{
// Clean up test file
@unlink(base_path('userfiles/test/test.txt'));
@rmdir(base_path('userfiles/test'));
parent::tearDown();
}
public function test_upload_file_successfully()
{
// Mock session data
session(['userdata.id' => 123]);
// Create a mock uploaded file
$file = $this->createMock(UploadedFile::class);
$file->method('isValid')->willReturn(true);
$file->method('getError')->willReturn(0);
$file->method('getSize')->willReturn(1000); // 1KB
$file->method('getClientOriginalName')->willReturn('test-file.txt');
$file->method('getClientOriginalExtension')->willReturn('txt');
$file->method('getRealPath')->willReturn(base_path('userfiles/test/test.txt'));
// Setup filesystem manager to return our mocked storage
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
$this->storage->method('mimeType')->willReturn('text/plain');
// Setup storage to successfully store the file
$this->storage->method('put')->willReturn(true);
// Mock the PHP stream functions
$this->storage->method('put')
->with($this->anything(), $this->anything(), $this->anything())
->willReturn(true);
// Execute the method under test
$result = $this->fileManager->upload($file);
// Assert the result is an array with expected keys
$this->assertIsArray($result);
$this->assertArrayHasKey('fileName', $result);
$this->assertArrayHasKey('realName', $result);
$this->assertArrayHasKey('extension', $result);
$this->assertEquals('test-file.txt', $result['realName']);
$this->assertEquals('txt', $result['extension']);
}
public function test_upload_file_with_invalid_file()
{
// Create a mock uploaded file that is invalid
$file = $this->createMock(UploadedFile::class);
$file->method('isValid')->willReturn(false);
$file->method('getErrorMessage')->willReturn('Test error message');
// // Mock the Log facade
// Log::shouldReceive('error')
// ->once()
// ->with('File upload failed: Invalid file upload attempt: Test error message', ['exception' => new FileValidationException('test'), 'file'=> '']);
// Execute the method under test
$result = $this->fileManager->upload($file);
// Assert the result is false
$this->assertFalse($result);
}
public function test_upload_file_with_file_too_large()
{
// Create a mock uploaded file that is too large
$file = $this->createMock(UploadedFile::class);
$file->method('isValid')->willReturn(true);
$file->method('getError')->willReturn(0);
$file->method('getSize')->willReturn(PHP_INT_MAX); // Very large file
// // Mock the Log facade
// Log::shouldReceive('error')
// ->once()
// ->with('File upload failed: File size exceeds the maximum allowed size of', ['exception' => new FileValidationException('test'), 'file'=> '']);
// Execute the method under test
$result = $this->fileManager->upload($file);
// Assert the result is false
$this->assertFalse($result);
}
public function test_get_file_successfully()
{
// Setup filesystem manager to return our mocked storage
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
// Setup storage to successfully find and read the file
$this->storage->expects($this->once())->method('exists')->willReturn(true);
$this->storage->method('mimeType')->willReturn('text/plain');
$this->storage->method('get')->willReturn('file content');
$this->storage->method('size')->willReturn(12);
$this->storage->method('lastModified')->willReturn(1700000000);
// Execute the method under test
$result = $this->fileManager->getFile('test.txt', 'original-name.txt');
// Assert the result is a Response with correct headers
$this->assertInstanceOf(Response::class, $result);
$this->assertEquals('file content', $result->getContent());
$this->assertEquals('text/plain', $result->headers->get('Content-Type'));
$this->assertEquals('12', $result->headers->get('Content-Length'));
$this->assertStringContainsString('original-name.txt', $result->headers->get('Content-Disposition'));
}
public function test_get_file_not_found()
{
// Setup filesystem manager to return our mocked storage
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
// Setup storage to not find the file
$this->storage->method('exists')->willReturn(false);
// Execute the method under test
$result = $this->fileManager->getFile('non-existent-file.txt', 'original-name.txt');
// Assert the result is false
$this->assertFalse($result);
}
public function test_get_file_url_local_storage()
{
// Setup filesystem manager to return our mocked storage
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
$this->storage->method('mimeType')->willReturn('text/plain');
// Setup storage to successfully find the file and return a URL
$this->storage->method('exists')->willReturn(true);
$this->storage->method('url')->willReturn('http://example.com/files/test.txt');
// Configure cache behavior
$this->config->method('get')->willReturn(true);
Cache::shouldReceive('remember')
->once()
->andReturn('http://example.com/files/test.txt');
// Execute the method under test
$result = $this->fileManager->getFileUrl('test.txt');
// Assert the result is the expected URL
$this->assertEquals('http://example.com/files/test.txt', $result);
}
public function test_get_file_url_s3_storage()
{
// Configure environment to use S3
$this->config->useS3 = true;
$this->config->method('get')->willReturn(60);
// Setup storage to return mime type
$this->storage->method('mimeType')->willReturn('text/plain');
// Setup filesystem manager to return our mocked storage
$this->filesystemManager->method('disk')->with('s3')->willReturn($this->storage);
// Setup storage to successfully find the file and return a temporary URL
$this->storage->method('exists')->willReturn(true);
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
// Execute the method under test
$result = $this->fileManager->getFileUrl('test.txt', 's3');
// Assert the result is the expected URL
$this->assertEquals('https://s3.example.com/files/test.txt?signature=abc123', $result);
}
public function test_get_file_url_file_not_found()
{
// Setup filesystem manager to return our mocked storage
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
// Setup storage to not find the file
$this->storage->method('exists')->willReturn(false);
// Execute the method under test
$result = $this->fileManager->getFileUrl('non-existent-file.txt');
// Assert the result is false
$this->assertFalse($result);
}
public function test_delete_file_successfully()
{
// Setup filesystem manager to return our mocked storage
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
// Setup storage to successfully find and delete the file
$this->storage->method('exists')->willReturn(true);
$this->storage->method('delete')->willReturn(true);
// Execute the method under test
$result = $this->fileManager->deleteFile('test.txt');
// Assert the result is true
$this->assertTrue($result);
}
public function test_delete_file_not_found()
{
// Setup filesystem manager to return our mocked storage
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
// Setup storage to not find the file
$this->storage->method('exists')->willReturn(false);
// Mock the Log facade
Log::shouldReceive('info')
->once()
->with('File not found for deletion: test.txt on disk local');
// Execute the method under test
$result = $this->fileManager->deleteFile('test.txt');
// Assert the result is false
$this->assertFalse($result);
}
public function test_delete_file_with_empty_filename()
{
// Mock the Log facade
Log::shouldReceive('warning')
->once()
->with('Attempted to delete a file with empty filename');
// Execute the method under test
$result = $this->fileManager->deleteFile('');
// Assert the result is false
$this->assertFalse($result);
}
public function test_get_maximum_file_upload_size()
{
// Test the static method
$result = FileManager::getMaximumFileUploadSize();
// Assert the result is an integer
$this->assertIsInt($result);
// The result should be the minimum of post_max_size and upload_max_filesize
$expected = min(
$this->convertPHPSizeToBytes(ini_get('post_max_size')),
$this->convertPHPSizeToBytes(ini_get('upload_max_filesize'))
);
$this->assertEquals($expected, $result);
}
/**
* Helper method to convert PHP size strings to bytes
*/
private function convertPHPSizeToBytes(string $sSize): int
{
$sSuffix = strtoupper(substr($sSize, -1));
if (! in_array($sSuffix, ['P', 'T', 'G', 'M', 'K'])) {
return (int) $sSize;
}
$iValue = substr($sSize, 0, -1);
switch ($sSuffix) {
case 'P':
$iValue *= 1024;
// Fallthrough intended
case 'T':
$iValue *= 1024;
// Fallthrough intended
case 'G':
$iValue *= 1024;
// Fallthrough intended
case 'M':
$iValue *= 1024;
// Fallthrough intended
case 'K':
$iValue *= 1024;
break;
}
return (int) $iValue;
}
public function test_sanitize_filename()
{
// Use reflection to test private method
$reflection = new \ReflectionClass(FileManager::class);
$method = $reflection->getMethod('sanitizeFilename');
$method->setAccessible(true);
// Test with a normal filename
$result = $method->invoke($this->fileManager, 'test.txt');
$this->assertEquals('test.txt', $result);
// Test with a path
$result = $method->invoke($this->fileManager, '/path/to/test.txt');
$this->assertEquals('test.txt', $result);
// Test with special characters
$result = $method->invoke($this->fileManager, 'test@file#$.txt');
$this->assertEquals('test-file--.txt', $result);
// Allow chinese characters
$result = $method->invoke($this->fileManager, '测试文件.txt');
$this->assertEquals('测试文件.txt', $result);
}
public function test_get_avatar_with_cache_hit()
{
// We already have a test file at userfiles/test/test.txt
$testFile = base_path('userfiles/test/test.txt');
// Verify the test file exists
$this->assertFileExists($testFile);
// Verify content
$this->assertEquals('test content', file_get_contents($testFile));
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Unit\app\Core\Http\Responses;
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
use Leantime\Core\Http\Responses\ImageResponse;
use SVG\SVG;
use Symfony\Component\HttpFoundation\Response;
use Unit\TestCase;
/**
* Unit tests for the ImageResponse response type used by the domain image controllers
* (Users\Controllers\ProfileImage, Projects\Controllers\ProjectImage). It is returned
* directly from controllers and converted by Laravel's router via the Responsable contract.
*/
class ImageResponseTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
public function test_it_is_a_leantime_response(): void
{
$this->assertInstanceOf(LeantimeResponseInterface::class, new ImageResponse('/tmp/x'));
}
public function test_to_response_renders_svg_with_cache_headers(): void
{
$svg = $this->make(SVG::class, [
'toXMLString' => fn () => '<svg></svg>',
]);
$response = (new ImageResponse($svg))->toResponse(null);
$this->assertSame('<svg></svg>', $response->getContent());
$this->assertSame('image/svg+xml', $response->headers->get('Content-type'));
$this->assertStringContainsString('max-age=86400', $response->headers->get('Cache-Control'));
}
public function test_to_response_passes_through_an_existing_response(): void
{
$existing = new Response('already built');
// An uploaded file is already a built Response; it must be returned untouched.
$this->assertSame($existing, (new ImageResponse($existing))->toResponse(null));
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Unit\app\Core\Http\Responses;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Exceptions\ValidationException;
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
use Leantime\Core\Http\Responses\JsonRpcErrorResponse;
use RuntimeException;
use Unit\TestCase;
/**
* The JSON-RPC 2.0 error envelope response type and its fromException() bridge — the single
* place a thrown exception becomes a client-facing error. Typed Leantime exceptions expose
* their own code/message/data; any other throwable is collapsed to a generic server error so
* internal detail is never leaked.
*/
class JsonRpcErrorResponseTest extends TestCase
{
public function test_it_is_a_leantime_response(): void
{
$this->assertInstanceOf(LeantimeResponseInterface::class, new JsonRpcErrorResponse(-32000, 'x'));
}
public function test_error_envelope(): void
{
$response = (new JsonRpcErrorResponse(-32602, 'Invalid params', ['field' => ['bad']], 3))->toResponse(null);
$body = json_decode($response->getContent(), true);
$this->assertSame('2.0', $body['jsonrpc']);
$this->assertSame(-32602, $body['error']['code']);
$this->assertSame('Invalid params', $body['error']['message']);
$this->assertSame(['field' => ['bad']], $body['error']['data']);
$this->assertSame(3, $body['id']);
}
public function test_from_validation_exception_maps_code_and_field_errors(): void
{
$errors = ['name' => ['Name is required.']];
$response = JsonRpcErrorResponse::fromException(ValidationException::withMessages($errors), 5)->toResponse(null);
$body = json_decode($response->getContent(), true);
$this->assertSame(-32602, $body['error']['code']);
$this->assertSame($errors, $body['error']['data']);
$this->assertSame(5, $body['id']);
}
public function test_from_authorization_exception_uses_auth_code(): void
{
$response = JsonRpcErrorResponse::fromException(new AuthorizationException, 1)->toResponse(null);
$body = json_decode($response->getContent(), true);
$this->assertSame(-32001, $body['error']['code']);
}
public function test_unknown_throwable_is_generic_and_does_not_leak(): void
{
$secret = 'internal-db-dsn-with-password';
$response = JsonRpcErrorResponse::fromException(new RuntimeException($secret), 9)->toResponse(null);
$body = json_decode($response->getContent(), true);
$this->assertSame(-32000, $body['error']['code']);
$this->assertSame('Server error', $body['error']['message']);
$this->assertNull($body['error']['data']);
$this->assertStringNotContainsString($secret, $response->getContent());
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Unit\app\Core\Http\Responses;
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
use Leantime\Core\Http\Responses\JsonRpcResponse;
use Unit\TestCase;
/**
* The JSON-RPC 2.0 success envelope response type. Centralizes the `{jsonrpc, result, id}`
* wire format previously inlined in the Jsonrpc controller.
*/
class JsonRpcResponseTest extends TestCase
{
public function test_it_is_a_leantime_response(): void
{
$this->assertInstanceOf(LeantimeResponseInterface::class, new JsonRpcResponse('x', 1));
}
public function test_success_envelope(): void
{
$response = (new JsonRpcResponse(['ok' => true], 7))->toResponse(null);
$body = json_decode($response->getContent(), true);
$this->assertSame('2.0', $body['jsonrpc']);
$this->assertSame(['ok' => true], $body['result']);
$this->assertSame(7, $body['id']);
}
public function test_scalar_result_and_string_id_pass_through(): void
{
$response = (new JsonRpcResponse(42, 'abc'))->toResponse(null);
$body = json_decode($response->getContent(), true);
$this->assertSame(42, $body['result']);
$this->assertSame('abc', $body['id']);
}
public function test_notification_without_id_returns_empty_200(): void
{
// A JSON-RPC notification (no id) MUST NOT be responded to.
$response = (new JsonRpcResponse('ignored', null))->toResponse(null);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('', $response->getContent());
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Unit\app\Core\Middleware;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Middleware\AuthCheck;
use Leantime\Domain\Api\Services\Api;
use Leantime\Domain\Users\Services\Users;
/**
* Guards the Bearer-auth regression (3.9.0): the permission engine reads the user's id + role from
* session('userdata'), which the x-api-key guard establishes as a side effect of getAPIKeyUser()
* but the Sanctum (Bearer) guard never did — so every gated @api method denied Bearer requests.
* establishApiUserSession() makes the API auth path uniform: any guard that resolves a user has the
* same userdata built from the canonical user row, through the same setApiUserSession() builder.
*
* This tests the middleware's responsibility — resolve the user id, fetch the canonical row, and
* hand it to the session builder, idempotently. The builder itself is covered by ApiServiceTest.
*/
class AuthCheckTest extends \Unit\TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* A request whose user() resolver returns an object with the given id — i.e. a guard (Sanctum
* or x-api-key) has authenticated, but userdata has not been established yet.
*/
private function apiRequestForUser(int $userId): IncomingRequest
{
$request = IncomingRequest::create('/api/jsonrpc', 'POST');
$request->setUserResolver(fn () => (object) ['id' => $userId]);
return $request;
}
/** Invoke the protected establishApiUserSession() on a constructor-less AuthCheck. */
private function establish(IncomingRequest $request): void
{
$authCheck = $this->make(AuthCheck::class);
(fn () => $this->establishApiUserSession($request))->call($authCheck);
}
public function test_establishes_userdata_from_the_canonical_row_when_missing(): void
{
session()->forget('userdata');
$row = ['id' => 42, 'firstname' => 'Gloria', 'role' => 20];
app()->instance(Users::class, $this->make(Users::class, [
'getUser' => fn ($id = null) => (int) $id === 42 ? $row : false,
]));
$captured = null;
app()->instance(Api::class, $this->make(Api::class, [
'setApiUserSession' => function (array $user, bool $isExternalAuth = false) use (&$captured) {
$captured = ['user' => $user, 'external' => $isExternalAuth];
},
]));
$this->establish($this->apiRequestForUser(42));
$this->assertSame($row, $captured['user'] ?? null, 'the canonical row must be handed to the session builder');
$this->assertTrue($captured['external'] ?? false, 'API sessions are external auth');
}
public function test_is_idempotent_when_userdata_already_exists(): void
{
// x-api-key (and stateful web) already populated userdata before this runs — leave it,
// and never re-resolve the user.
session(['userdata' => ['id' => 7, 'role' => 'admin']]);
app()->instance(Users::class, $this->make(Users::class, [
'getUser' => function ($id = null) {
$this->fail('must not re-resolve the user when userdata already exists');
},
]));
$called = false;
app()->instance(Api::class, $this->make(Api::class, [
'setApiUserSession' => function (array $user, bool $isExternalAuth = false) use (&$called) {
$called = true;
},
]));
$this->establish($this->apiRequestForUser(42));
$this->assertFalse($called, 'must not rebuild an already-established session');
$this->assertSame(7, session('userdata.id'), 'existing userdata must be left untouched');
}
/**
* The mobile SSO exchange (/oidc/mobile/exchange) arrives with no session
* cookie — the validated one-time code + PKCE verifier are the authorization —
* so it must be allow-listed as public. Guards that allow-list from regressing.
*/
public function test_oidc_mobile_exchange_is_a_public_route(): void
{
$authCheck = $this->make(AuthCheck::class);
$this->assertTrue(
$authCheck->isPublicController('oidc.mobile.exchange'),
'the mobile exchange endpoint must be public (no session at exchange time)'
);
// Negative control: an oidc sub-route that is NOT allow-listed stays private.
$this->assertFalse($authCheck->isPublicController('oidc.settings.save'));
}
public function test_status_discovery_is_a_public_route(): void
{
$authCheck = $this->make(AuthCheck::class);
// The mobile app hits /status unauthenticated at connect time to discover
// login methods, so the route must be public.
$this->assertTrue($authCheck->isPublicController('status.index'));
$this->assertTrue($authCheck->isPublicController('status'));
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace Unit\app\Core\Middleware;
use Illuminate\Session\ArraySessionHandler;
use Illuminate\Session\Store;
use Leantime\Core\Middleware\StartSession;
use ReflectionMethod;
use Unit\TestCase;
/**
* Regression coverage for the optimistic session-concurrency strategy in
* StartSession. The original blanket-locking existed because a no-lock version
* lost session writes: two concurrent requests would each overwrite the whole
* session blob, clobbering each other (e.g. a project switch reverted by a
* background widget). The merge-on-write strategy must persist ONLY the keys a
* request actually changed, re-reading the freshest state first, so a concurrent
* writer's keys survive.
*/
class SessionMergeTest extends TestCase
{
private function middleware(): StartSession
{
return new StartSession(app('session'));
}
private function invokeDiff(array $initial, array $current): array
{
$method = new ReflectionMethod(StartSession::class, 'diffSession');
$method->setAccessible(true);
return $method->invoke($this->middleware(), $initial, $current);
}
private function invokeMerge(Store $session, array $changed, array $removed): void
{
$method = new ReflectionMethod(StartSession::class, 'mergeSessionChanges');
$method->setAccessible(true);
$method->invoke($this->middleware(), $session, $changed, $removed);
}
public function test_diff_detects_added_changed_and_removed_keys(): void
{
[$changed, $removed] = $this->invokeDiff(
['currentProject' => 1, 'keep' => 'same', 'goingAway' => 'x'],
['currentProject' => 2, 'keep' => 'same', 'brandNew' => 'y'],
);
$this->assertSame(['currentProject' => 2, 'brandNew' => 'y'], $changed);
$this->assertSame(['goingAway'], $removed);
}
public function test_pure_read_produces_no_diff(): void
{
[$changed, $removed] = $this->invokeDiff(
['currentProject' => 1, 'nested' => ['a' => 1]],
['currentProject' => 1, 'nested' => ['a' => 1]],
);
$this->assertSame([], $changed);
$this->assertSame([], $removed);
}
/**
* The core race: request B loads the session, request A switches the project
* and commits first, then B persists. B only changed `lastPage`, so the merge
* must keep A's `currentProject = 2` rather than reverting it to the value B
* originally loaded.
*/
public function test_merge_preserves_a_concurrent_writers_key(): void
{
$handler = new ArraySessionHandler(120);
$name = 'leantime_session';
// Store::setId() rejects ids that aren't 40-char alphanumeric and
// generates a random one instead, so the id must be a valid session id
// for the three stores to share state through the handler.
$id = str_repeat('a', 40);
// Seed the persisted session.
$seed = new Store($name, $handler, $id);
$seed->start();
$seed->put('currentProject', 1);
$seed->put('userdata.id', 99);
$seed->save();
// Request B starts and loads the current state.
$requestB = new Store($name, $handler, $id);
$requestB->start();
$bInitial = $requestB->all();
$requestB->put('lastPage', '/dashboard/home'); // B's only change
// Request A switches the project and commits BEFORE B persists.
$requestA = new Store($name, $handler, $id);
$requestA->start();
$requestA->put('currentProject', 2);
$requestA->save();
// B persists via the merge strategy (diff of B's change against B's snapshot).
[$changed, $removed] = $this->invokeDiff($bInitial, $requestB->all());
$this->invokeMerge($requestB, $changed, $removed);
// Read the final persisted state.
$verify = new Store($name, $handler, $id);
$verify->start();
$this->assertSame(2, $verify->get('currentProject'), 'concurrent project switch was clobbered');
$this->assertSame('/dashboard/home', $verify->get('lastPage'), 'B\'s own write was lost');
$this->assertSame(99, $verify->get('userdata.id'), 'untouched key was dropped');
}
public function test_merge_applies_removed_keys(): void
{
$handler = new ArraySessionHandler(120);
$name = 'leantime_session';
$id = str_repeat('b', 40);
$seed = new Store($name, $handler, $id);
$seed->start();
$seed->put('currentIdeaCanvas', 5);
$seed->put('currentProject', 3);
$seed->save();
$request = new Store($name, $handler, $id);
$request->start();
$initial = $request->all();
$request->forget('currentIdeaCanvas');
[$changed, $removed] = $this->invokeDiff($initial, $request->all());
$this->invokeMerge($request, $changed, $removed);
$verify = new Store($name, $handler, $id);
$verify->start();
$this->assertFalse($verify->has('currentIdeaCanvas'), 'removed key should not be persisted');
$this->assertSame(3, $verify->get('currentProject'));
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Unit\app\Core\Middleware;
use Leantime\Core\Configuration\AppSettings;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Middleware\Updated;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
/**
* Guards the stale-session redirect loop: the Updated middleware caches the
* db-version in the session and (before the fix) never re-read the database
* once a value was cached. After an admin ran an update in THEIR session,
* every other live session kept the old cached version, concluded "not
* updated", and bounced between every page and /install/update until the
* user's cookies were cleared.
*
* The fix self-heals: when a CACHED value would trigger the redirect, the
* middleware re-reads the real version from the database first — one extra
* query, only on the would-redirect path.
*/
class UpdatedTest extends \Unit\TestCase
{
use \Codeception\Test\Feature\Stub;
private function appSettings(string $codeVersion): void
{
$settings = new AppSettings;
$settings->dbVersion = $codeVersion;
app()->instance(AppSettings::class, $settings);
}
/** @param array<int, string|false> $dbVersions consecutive getSetting('db-version') results */
private function settingRepo(array $dbVersions, ?int &$reads = null): void
{
$reads = 0;
app()->instance(SettingRepository::class, $this->make(SettingRepository::class, [
// Mirrors the real signature so forwarded arguments can't ever
// make the stub brittle.
'getSetting' => function (string $type = 'db-version') use (&$reads, $dbVersions) {
$value = $dbVersions[min($reads, count($dbVersions) - 1)];
$reads++;
return $value;
},
]));
}
/** Run the middleware; returns [response, nextWasCalled]. */
private function handleRequest(): array
{
// The redirect path resolves Frontcontroller from the container; its
// real constructor needs the full HTTP stack, so bind a bare instance
// (its redirect()/getCurrentRoute() members are static and work as-is).
app()->instance(
\Leantime\Core\Controller\Frontcontroller::class,
$this->make(\Leantime\Core\Controller\Frontcontroller::class)
);
$called = false;
$response = (new Updated)->handle(
IncomingRequest::create('/dashboard/home', 'GET'),
function () use (&$called) {
$called = true;
return new \Symfony\Component\HttpFoundation\Response('ok');
}
);
return [$response, $called];
}
public function test_stale_session_cache_self_heals_after_an_update_ran_elsewhere(): void
{
// Session still remembers 3.5.25 from before the admin upgraded; the
// DATABASE already says 3.5.26 (matching the code). The middleware
// must re-read and pass through — not redirect-loop the user.
session(['dbVersion' => '3.5.25', 'isUpdated' => false]);
$this->appSettings('3.5.26');
$this->settingRepo(['3.5.26'], $reads);
[, $nextCalled] = $this->handleRequest();
$this->assertTrue($nextCalled, 'a session whose cache is stale but whose DB is current must pass through');
$this->assertSame(1, $reads, 'the DB is consulted exactly once to heal the cache');
$this->assertSame('3.5.26', session('dbVersion'), 'the healed version is re-cached');
$this->assertTrue(session('isUpdated'));
}
public function test_current_session_cache_passes_through_without_touching_the_db(): void
{
session(['dbVersion' => '3.5.26', 'isUpdated' => true]);
$this->appSettings('3.5.26');
$this->settingRepo(['3.5.26'], $reads);
[, $nextCalled] = $this->handleRequest();
$this->assertTrue($nextCalled);
$this->assertSame(0, $reads, 'an up-to-date cached version costs zero settings reads');
}
public function test_genuinely_outdated_install_still_redirects_to_update(): void
{
// Both the cache AND the database are behind the code: the redirect is
// correct. The self-heal costs one confirming read, then redirects.
session(['dbVersion' => '3.5.25', 'isUpdated' => false]);
$this->appSettings('3.5.26');
$this->settingRepo(['3.5.25'], $reads);
[$response, $nextCalled] = $this->handleRequest();
$this->assertFalse($nextCalled, 'a genuinely outdated install must not pass through');
$this->assertSame(1, $reads);
$this->assertStringContainsString('/install/update', $response->headers->get('Location') ?? '', 'the redirect still points at the updater');
$this->assertFalse(session('isUpdated'));
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Unit\app\Core\Resources\Models;
use Leantime\Core\Resources\Models\BudgetLine;
use Leantime\Core\Resources\Models\Dependency;
use Leantime\Core\Resources\Models\PersonAllocation;
use Leantime\Core\Resources\Models\ResourceSummary;
use Unit\TestCase;
/**
* ResourceSummary value-object arithmetic tests. Utilization math is
* consumed directly by the report tiles, so its edge cases (zero-divide,
* empty aggregation) need to be locked in.
*/
class ResourceSummaryTest extends TestCase
{
public function test_empty_returns_a_summary_with_zero_totals_and_marks_is_empty(): void
{
$summary = ResourceSummary::empty([1, 2, 3]);
$this->assertSame([1, 2, 3], $summary->projectIds);
$this->assertSame([], $summary->people);
$this->assertSame([], $summary->budget);
$this->assertSame([], $summary->dependencies);
$this->assertSame(0.0, $summary->totalCapacity);
$this->assertSame(0.0, $summary->totalAllocated);
$this->assertTrue($summary->isEmpty());
}
public function test_capacity_utilization_is_zero_when_no_capacity_declared(): void
{
$summary = ResourceSummary::empty([1]);
// Divide-by-zero must not occur — the report tile calls this
// unconditionally.
$this->assertSame(0.0, $summary->capacityUtilization());
}
public function test_capacity_utilization_returns_percent_when_capacity_declared(): void
{
$summary = new ResourceSummary(
projectIds: [1],
people: [$this->makePerson(40, [1 => 30])],
budget: [],
dependencies: [],
totalCapacity: 40.0,
totalAllocated: 30.0,
totalBudgeted: 0.0,
totalSpent: 0.0,
);
$this->assertSame(75.0, $summary->capacityUtilization());
}
public function test_budget_utilization_edge_cases(): void
{
$noBudget = ResourceSummary::empty([1]);
$this->assertSame(0.0, $noBudget->budgetUtilization());
$withBudget = new ResourceSummary(
projectIds: [1],
people: [],
budget: [$this->makeBudget(1000.0, 250.0)],
dependencies: [],
totalCapacity: 0.0,
totalAllocated: 0.0,
totalBudgeted: 1000.0,
totalSpent: 250.0,
);
$this->assertSame(25.0, $withBudget->budgetUtilization());
}
public function test_person_allocation_totals_and_availability(): void
{
$person = $this->makePerson(40, [1 => 20, 2 => 15]);
$this->assertSame(35.0, $person->totalAllocated());
$this->assertSame(5.0, $person->available());
}
public function test_person_over_allocation_reports_zero_available(): void
{
// available() clamps at 0; over-allocation is a real product state
// callers detect by comparing totalAllocated() > capacity directly.
$person = $this->makePerson(40, [1 => 45]);
$this->assertSame(45.0, $person->totalAllocated());
$this->assertSame(0.0, $person->available());
$this->assertGreaterThan($person->capacity, $person->totalAllocated());
}
public function test_is_empty_true_only_when_all_three_sections_empty(): void
{
$onlyPeople = new ResourceSummary(
projectIds: [1],
people: [$this->makePerson(40, [])],
budget: [],
dependencies: [],
totalCapacity: 40.0,
totalAllocated: 0.0,
totalBudgeted: 0.0,
totalSpent: 0.0,
);
$this->assertFalse($onlyPeople->isEmpty());
}
public function test_is_empty_false_when_only_dependencies_present(): void
{
// A partnership-heavy program can have zero people and zero budget
// authored but still be non-empty; the section must render.
$onlyDeps = new ResourceSummary(
projectIds: [1],
people: [],
budget: [],
dependencies: [$this->makeDependency()],
totalCapacity: 0.0,
totalAllocated: 0.0,
totalBudgeted: 0.0,
totalSpent: 0.0,
);
$this->assertFalse($onlyDeps->isEmpty());
}
/**
* @param array<int, float> $allocations
*/
private function makePerson(float $capacity, array $allocations): PersonAllocation
{
return new PersonAllocation(
itemId: 1,
userId: null,
displayName: 'Test',
capacity: $capacity,
allocations: $allocations,
);
}
private function makeBudget(float $budgeted, float $spent): BudgetLine
{
return new BudgetLine(
itemId: 1,
projectId: 1,
label: 'Test',
budgeted: $budgeted,
spent: $spent,
color: null,
);
}
private function makeDependency(): Dependency
{
return new Dependency(
itemId: 1,
partnerName: 'Test',
type: 'partner',
confirmed: false,
);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Unit\app\Core\Resources\Services;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Resources\Contracts\ResourcesGateway;
use Leantime\Core\Resources\Models\ResourceSummary;
use Leantime\Core\Resources\Services\ResourcesRegistry;
use Unit\TestCase;
/**
* Unit tests for ResourcesRegistry — the one-plugin-owns-it contract.
*
* Behaviors under test:
* - null on read when no provider is registered (honest "not installed")
* - a registered provider is returned by resolve()
* - re-registering the SAME provider class REPLACES the stored instance
* (last write wins; safe because two instances of the same class are
* functionally interchangeable — deliberately NOT idempotent)
* - a DIFFERENT provider class trying to register is refused (first wins)
*/
class ResourcesRegistryTest extends TestCase
{
public function test_resolve_returns_null_when_no_provider_registered(): void
{
$registry = new ResourcesRegistry;
$this->assertNull($registry->resolve());
$this->assertFalse($registry->hasProvider());
}
public function test_resolve_returns_registered_gateway(): void
{
$registry = new ResourcesRegistry;
$gateway = $this->makeGateway();
$registry->register($gateway);
$this->assertSame($gateway, $registry->resolve());
$this->assertTrue($registry->hasProvider());
}
public function test_reregistering_same_provider_class_replaces_instance(): void
{
$registry = new ResourcesRegistry;
$first = $this->makeGateway();
$second = $this->makeGateway(); // same anonymous class
$registry->register($first);
$registry->register($second);
// Second registration replaces first because it's the same class.
// (No warning is emitted — this is the "plugin re-registered on
// hot-reload" case, not a conflict.) NOT idempotent in the strict
// sense: state changes to point at the newer instance.
$this->assertSame($second, $registry->resolve());
}
public function test_different_provider_class_registration_is_refused(): void
{
Log::spy();
$registry = new ResourcesRegistry;
$first = $this->makeGateway();
$second = $this->makeOtherGateway();
$registry->register($first);
$registry->register($second);
$this->assertSame(
$first,
$registry->resolve(),
'First registration must win when a different class tries to register',
);
// Logging the refused registration is part of the contract — a silent
// refusal would let double-installs go unnoticed.
Log::shouldHaveReceived('warning')->once()->withArgs(
fn (string $message): bool => str_contains($message, 'ResourcesRegistry')
&& str_contains($message, 'already registered')
);
}
private function makeGateway(): ResourcesGateway
{
return new class implements ResourcesGateway
{
public function getForProjects(array $projectIds, ?string $actualsFrom = null, ?string $actualsTo = null): ResourceSummary
{
return ResourceSummary::empty($projectIds);
}
public function getForProgram(int $programId): ResourceSummary
{
return ResourceSummary::empty([$programId]);
}
};
}
private function makeOtherGateway(): ResourcesGateway
{
return new class implements ResourcesGateway
{
public function getForProjects(array $projectIds, ?string $actualsFrom = null, ?string $actualsTo = null): ResourceSummary
{
return ResourceSummary::empty($projectIds);
}
public function getForProgram(int $programId): ResourceSummary
{
return ResourceSummary::empty([$programId]);
}
};
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace Tests\Unit\app\Core\Support;
use LasseRafn\InitialAvatarGenerator\InitialAvatar;
use LasseRafn\Initials\Initials;
use Leantime\Core\Support\Avatarcreator;
use Leantime\Core\UI\Theme;
use SVG\SVG;
use Unit\TestCase;
class AvatarcreatorTest extends TestCase
{
private $avatarGenerator;
private $initials;
private $theme;
private $avatarCreator;
protected function setUp(): void
{
parent::setUp();
$this->avatarGenerator = $this->createMock(InitialAvatar::class);
$this->avatarGenerator->method('background')->willReturn($this->avatarGenerator);
$this->avatarGenerator->method('font')->willReturn($this->avatarGenerator);
$this->avatarGenerator->method('color')->willReturn($this->avatarGenerator);
$this->avatarGenerator->method('generateSvg')->willReturn(SVG::fromString('<svg></svg>'));
$this->initials = $this->createMock(Initials::class);
$this->theme = $this->createMock(Theme::class);
$this->avatarCreator = new Avatarcreator(
$this->avatarGenerator,
$this->initials,
$this->theme
);
}
public function test_set_background_color()
{
$this->avatarGenerator->expects($this->once())
->method('background')
->with('#ffffff');
$this->avatarCreator->setBackground('#ffffff');
}
public function test_set_file_prefix()
{
$this->avatarCreator->setFilePrefix('test-prefix');
$this->assertEquals('test-prefix', $this->avatarCreator->getFilePrefix());
}
public function test_set_initials_with_valid_name()
{
$this->initials->expects($this->once())
->method('name')
->with('john-doe');
$this->avatarGenerator->expects($this->once())
->method('name')
->with('john-doe');
$this->avatarCreator->setInitials('John Doe');
}
public function test_set_initials_with_empty_name()
{
$this->initials->expects($this->once())
->method('name')
->with('👻');
$this->avatarCreator->setInitials('');
}
public function test_get_initials()
{
$this->initials->expects($this->once())
->method('getInitials')
->willReturn('JD');
$this->assertEquals('JD', $this->avatarCreator->getInitials());
}
public function test_get_avatar_with_cache_hit()
{
$this->initials->method('getInitials')->willReturn('JD');
// Create test file
$cacheDir = storage_path('framework/cache/avatars');
if (! is_dir($cacheDir)) {
mkdir($cacheDir, 0777, true);
}
$testFile = $cacheDir.'/user-jd.svg';
file_put_contents($testFile, '<svg>test</svg>');
$result = $this->avatarCreator->getAvatar('John Doe');
$this->assertEquals(SVG::fromString('<svg>test</svg>'), $result);
unlink($testFile);
}
public function test_get_avatar_with_cache_miss()
{
$this->initials->method('getInitials')->willReturn('JD');
$this->avatarGenerator->method('generateSvg')
->willReturn(SVG::fromString('<svg></svg>'));
$result = $this->avatarCreator->getAvatar('John Doe');
$cacheDir = storage_path('framework/cache/avatars');
$testFile = $cacheDir.'/user-jd.svg';
$this->assertFileExists($testFile);
}
public function test_get_avatar_with_special_characters()
{
$this->initials->method('getInitials')->willReturn('JD');
$this->avatarGenerator->method('generateSvg')
->willReturn(SVG::fromString('<svg></svg>'));
$result = $this->avatarCreator->getAvatar('John@Doe#$%');
$cacheDir = storage_path('framework/cache/avatars');
$testFile = $cacheDir.'/user-jd.svg';
$this->assertFileExists($testFile);
}
public function test_get_avatar_with_non_latin_characters()
{
$this->initials->method('getInitials')->willReturn('李王');
$this->avatarGenerator->method('generateSvg')
->willReturn(SVG::fromString('<svg></svg>'));
$result = $this->avatarCreator->getAvatar('李王');
$cacheDir = storage_path('framework/cache/avatars');
$testFile = $cacheDir.'/user-李王.svg';
$this->assertFileExists($testFile);
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Tests\Unit\App\Core\Support;
use Carbon\CarbonImmutable;
use Leantime\Core\Language;
use Leantime\Core\Support\CarbonMacros;
use Unit\TestCase;
class CarbonMacrosTest extends TestCase
{
private CarbonMacros $carbonMacros;
private Language $languageMock;
protected function setUp(): void
{
parent::setUp();
$this->languageMock = $this->createMock(Language::class);
$this->languageMock->method('__')
->willReturnCallback(function ($key) {
return match ($key) {
'language.dateformat' => 'm/d/Y',
'language.timeformat' => 'h:i A',
'language.dayNamesShort' => 'zo,ma,di,wo,do,vr,za',
'language.dayNamesMin' => 'zo,ma,di,wo,do,vr,za',
'language.monthNamesShort' => 'jan,feb,mrt,apr,mei,jun,jul,aug,sep,okt,nov,dec',
default => $key
};
});
app()->instance(Language::class, $this->languageMock);
// Initialize with test values
$this->carbonMacros = new CarbonMacros(
userTimezone: 'America/Los_Angeles',
userLanguage: 'en_US',
userDateFormat: 'm/d/Y',
userTimeFormat: 'h:i A',
dbFormat: 'Y-m-d H:i:s',
dbTimezone: 'UTC'
);
// Mix in the macros to CarbonImmutable
CarbonImmutable::mixin($this->carbonMacros);
}
public function test_format_date_for_user(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$formatted = $date->formatDateForUser();
// Should be formatted according to user's timezone (PST) and format (m/d/Y)
$this->assertEquals('12/25/2023', $formatted);
}
public function test_format_time_for_user(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$formatted = $date->formatTimeForUser();
// UTC 14:30 is 06:30 AM in PST
$this->assertEquals('06:30 AM', $formatted);
}
public function test_format_24h_time_for_user(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$formatted = $date->format24HTimeForUser();
// UTC 14:30 is 06:30 in PST
$this->assertEquals('06:30', $formatted);
}
public function test_format_date_time_for_db(): void
{
// Create a date in user's timezone
$date = CarbonImmutable::create(2023, 12, 25, 6, 30, 0, 'America/Los_Angeles');
$formatted = $date->formatDateTimeForDb();
// Should be converted to UTC and formatted as Y-m-d H:i:s
$this->assertEquals('2023-12-25 14:30:00', $formatted);
}
public function test_set_to_user_timezone(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$converted = $date->setToUserTimezone();
$this->assertEquals('America/Los_Angeles', $converted->timezone->getName());
$this->assertEquals('06:30', $converted->format('H:i'));
}
public function test_set_to_db_timezone(): void
{
$date = CarbonImmutable::create(2023, 12, 25, 6, 30, 0, 'America/Los_Angeles');
$converted = $date->setToDbTimezone();
$this->assertEquals('UTC', $converted->timezone->getName());
$this->assertEquals('14:30', $converted->format('H:i'));
}
public function test_dutch_language_support(): void
{
$macros = new CarbonMacros(
userTimezone: 'Europe/Amsterdam',
userLanguage: 'nl_NL',
userDateFormat: 'd-m-Y',
userTimeFormat: 'H:i',
dbFormat: 'Y-m-d H:i:s',
dbTimezone: 'UTC'
);
CarbonImmutable::mixin($macros);
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
$formatted = $date->formatDateForUser();
$this->assertEquals('25-12-2023', $formatted);
}
}

View File

@@ -0,0 +1,290 @@
<?php
namespace Tests\Unit\App\Core\Support;
use Carbon\CarbonImmutable;
use Carbon\Exceptions\InvalidDateException;
use Carbon\Exceptions\InvalidFormatException;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Language;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Core\Support\DateTimeHelper;
use Unit\TestCase;
class DateTimeHelperTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private DateTimeHelper $dateTimeHelper;
private Environment $environmentMock;
private Language $languageMock;
protected function setUp(): void
{
parent::setUp();
// Mock the Environment class
$this->environmentMock = $this->make(Environment::class, [
'defaultTimezone' => 'UTC',
'language' => 'en-US',
]);
app()->instance(Environment::class, $this->environmentMock);
$this->languageMock = $this->createMock(Language::class);
$this->languageMock->method('__')->willReturnCallback(function ($index) {
$map = [
'language.dateformat' => 'm/d/Y',
'language.timeformat' => 'h:i A',
];
return $map[$index] ?? null;
});
app()->instance(\Leantime\Core\Language::class, $this->languageMock);
// Register mocks with the application container
//
// app()->instance(Language::class, $this->languageMock);
// America Los_Angeles is UTC - 8 so all db times need to come back from UTC - 8 hours
CarbonImmutable::mixin(new CarbonMacros(
'America/Los_Angeles',
'en-US',
'm/d/Y',
'h:i A'
));
// Create the DateTimeHelper instance
$this->dateTimeHelper = new DateTimeHelper;
}
public function test_parse_iso8601_with_timezone_offset_midnight(): void
{
// Test ISO 8601 with timezone offset (2025-04-16T00:00:00-04:00)
$dateString = '2025-04-16T00:00:00-04:00';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('00', $parsedDate->format('H'));
$this->assertEquals('00', $parsedDate->format('i'));
$this->assertEquals('00', $parsedDate->format('s'));
$this->assertEquals('-04:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_with_timezone_offset(): void
{
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-04:00)
$dateString = '2025-04-16T23:59:59-04:00';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
$this->assertEquals('-04:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_with_timezone_offset_hhmm(): void
{
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-0400)
$dateString = '2025-04-16T23:59:59-0400';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
$this->assertEquals('-04:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_with_timezone_offset_hh(): void
{
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-04)
$dateString = '2025-04-16T23:59:59-04';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
$this->assertEquals('-04:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_with_zulu_time(): void
{
// Test ISO 8601 with Z/Zulu time (2025-04-16T23:59:59Z)
$dateString = '2025-04-16T23:59:59Z';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
// Z time should be parsed as UTC
$this->assertEquals('+00:00', $parsedDate->format('P'));
}
public function test_parse_iso8601_without_timezone(): void
{
// Test ISO 8601 without timezone (2025-04-16T23:59:59)
$dateString = '2025-04-16T23:59:59';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
}
public function test_parse_user_date_format(): void
{
// Test parsing date in user format (m/d/Y)
$dateString = '04/16/2025';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
}
public function test_parse_user_date_and_time_format(): void
{
// Test parsing date and time in user format (m/d/Y h:i A)
$dateString = '04/16/2025';
$timeString = '11:59 PM';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, $timeString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
}
public function test_parse_user_date_with_start_of_day(): void
{
// Test parsing date with start of day
$dateString = '04/16/2025';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, 'start');
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('00', $parsedDate->format('H'));
$this->assertEquals('00', $parsedDate->format('i'));
$this->assertEquals('00', $parsedDate->format('s'));
}
public function test_parse_user_date_with_end_of_day(): void
{
// Test parsing date with end of day
$dateString = '04/16/2025';
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, 'end');
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
}
public function test_invalid_date_string(): void
{
// Test with invalid date string
$this->expectException(InvalidFormatException::class);
$this->dateTimeHelper->parseUserDateTime('not-a-date');
}
public function test_empty_date_string(): void
{
// Test with empty date string
$this->expectException(InvalidDateException::class);
$this->dateTimeHelper->parseUserDateTime('');
}
public function test_parse_db_date_time(): void
{
// Test parsing DB date time
$dbDate = '2025-04-16 23:59:59';
$parsedDate = $this->dateTimeHelper->parseDbDateTime($dbDate);
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
$this->assertEquals('2025', $parsedDate->format('Y'));
$this->assertEquals('04', $parsedDate->format('m'));
$this->assertEquals('16', $parsedDate->format('d'));
$this->assertEquals('23', $parsedDate->format('H'));
$this->assertEquals('59', $parsedDate->format('i'));
$this->assertEquals('59', $parsedDate->format('s'));
}
public function test_parse_user_24h_time(): void
{
// Test parsing 24h time
$timeString = '23:59';
$parsedTime = $this->dateTimeHelper->parseUser24hTime($timeString);
$this->assertInstanceOf(CarbonImmutable::class, $parsedTime);
$this->assertEquals('23', $parsedTime->format('H'));
$this->assertEquals('59', $parsedTime->format('i'));
}
public function test_user_now(): void
{
// Test user now returns current time
$now = $this->dateTimeHelper->userNow();
$this->assertInstanceOf(CarbonImmutable::class, $now);
// Should be within a few seconds of now
$this->assertLessThan(5, abs(time() - $now->timestamp));
}
public function test_db_now(): void
{
// Test db now returns current time in UTC
$now = $this->dateTimeHelper->dbNow();
$this->assertInstanceOf(CarbonImmutable::class, $now);
// Should be within a few seconds of now
$this->assertLessThan(5, abs(time() - $now->timestamp));
// Should be in UTC timezone
$this->assertEquals('UTC', $now->timezone->getName());
}
public function test_is_valid_date_string(): void
{
// Test valid date strings
$this->assertTrue($this->dateTimeHelper->isValidDateString('2025-04-16 23:59:59'));
$this->assertTrue($this->dateTimeHelper->isValidDateString('2025-04-16T23:59:59-04:00'));
// Test invalid date strings
$this->assertFalse($this->dateTimeHelper->isValidDateString(''));
$this->assertFalse($this->dateTimeHelper->isValidDateString(null));
$this->assertFalse($this->dateTimeHelper->isValidDateString('1969-12-31 00:00:00'));
$this->assertFalse($this->dateTimeHelper->isValidDateString('0000-00-00 00:00:00'));
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Unit\app\Core\Support;
use Carbon\CarbonImmutable;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Core\Support\Format;
use Tests\DateTimeHelper;
use Tests\Language;
use Tests\MockObject;
use Unit\TestCase;
class FormatTest extends TestCase
{
/**
* @var DateTimeHelper|MockObject
*/
private $carbonMacrosMock;
/**
* @var Language|MockObject
*/
private $languageMock;
protected function setUp(): void
{
parent::setUp();
$this->languageMock = $this->createMock(\Leantime\Core\Language::class);
app()->instance(\Leantime\Core\Support\CarbonMacros::class, $this->carbonMacrosMock);
app()->instance(\Leantime\Core\Language::class, $this->languageMock);
// America Los_Angeles is UTC - 8 so all db times need to come back from UTC - 8 hours
CarbonImmutable::mixin(new CarbonMacros(
'America/Los_Angeles',
'en-US',
'm/d/Y',
'h:i A'
));
}
public function test_date(): void
{
$formattedDateString = '12/31/2021';
$dbDate = '2022-01-01 00:00:00';
$format = new Format($dbDate, '');
$this->assertSame($formattedDateString, $format->date());
}
public function test_time(): void
{
$formattedTimeString = '04:00 PM';
$dbDate = '2022-01-01 00:00:00';
$format = new Format($dbDate, '');
$this->assertSame($formattedTimeString, $format->time());
}
public function test_time24(): void
{
$formattedTimeString = '16:00';
$dbDate = '2022-01-01 00:00:00';
$format = new Format($dbDate, '');
$this->assertSame($formattedTimeString, $format->time24());
}
// Similarly you can add tests for other 'Format' class methods.
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Unit\app\Core\Support;
use Leantime\Core\Support\NameSanitizer;
use Unit\TestCase;
/**
* Regression tests for the invite-spam abuse fix: person names were stored and
* emailed raw, letting spammers use the firstname field as an email payload.
* The sanitizer must strip abuse vectors (contact numbers, URLs, emails, bidi
* tricks) while letting legitimate names in any script through unchanged.
*/
class NameSanitizerTest extends TestCase
{
public function test_legitimate_names_pass_unchanged(): void
{
$this->assertSame('Marcel', NameSanitizer::clean('Marcel'));
$this->assertSame('María José', NameSanitizer::clean('María José'));
$this->assertSame('汪小明', NameSanitizer::clean('汪小明'));
$this->assertSame('محمد علي', NameSanitizer::clean('محمد علي'));
$this->assertSame("O'Connor-Smith", NameSanitizer::clean("O'Connor-Smith"));
}
public function test_strips_contact_number_from_spam_payload(): void
{
// The actual payload from the 2026-07 abuse reports
$this->assertStringNotContainsString('992600898', NameSanitizer::clean('+汪汪992600898-ن颂58嗏،Virtual'));
}
public function test_strips_html(): void
{
$this->assertSame('alert(1)', NameSanitizer::clean('<script>alert(1)</script>'));
}
public function test_strips_urls_and_emails(): void
{
$this->assertSame('Buy cheap', NameSanitizer::clean('Buy http://spam.example.com cheap'));
$this->assertSame('Visit now', NameSanitizer::clean('Visit www.spam.example now'));
$this->assertSame('mail me', NameSanitizer::clean('mail spam@evil.example me'));
}
public function test_strips_control_and_bidi_characters(): void
{
$this->assertSame('JohnSmith', NameSanitizer::clean("John\u{202E}Smith"));
$this->assertSame('AB', NameSanitizer::clean("A\u{200B}\u{0000}B"));
}
public function test_caps_length_and_handles_non_strings(): void
{
$this->assertSame(50, mb_strlen(NameSanitizer::clean(str_repeat('A', 200))));
$this->assertSame('', NameSanitizer::clean(null));
$this->assertSame('', NameSanitizer::clean(12345));
$this->assertSame('', NameSanitizer::clean(['array']));
}
public function test_collapses_whitespace(): void
{
$this->assertSame('John Smith', NameSanitizer::clean(" John Smith \n"));
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Unit\app\Core\Support;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Uri;
use Leantime\Core\Support\OutboundUrlGuard;
use Unit\TestCase;
/**
* Covers the SSRF guard's address classification and redirect re-validation using IP literals and
* direct calls, so nothing here depends on live DNS or the network.
*/
class OutboundUrlGuardTest extends TestCase
{
/**
* @dataProvider ipProvider
*/
public function test_is_ip_allowed(string $ip, bool $expected): void
{
$this->assertSame($expected, OutboundUrlGuard::isIpAllowed($ip));
}
public static function ipProvider(): array
{
return [
'loopback v4' => ['127.0.0.1', false],
'private 10/8' => ['10.1.2.3', false],
'private 172.16/12' => ['172.16.5.5', false],
'private 192.168/16' => ['192.168.1.1', false],
'cgnat 100.64/10' => ['100.64.0.1', false],
'link-local metadata' => ['169.254.169.254', false],
'reserved 0.0.0.0/8' => ['0.0.0.0', false],
'public v4 (google dns)' => ['8.8.8.8', true],
'public v4 (cloudflare)' => ['1.1.1.1', true],
'loopback v6' => ['::1', false],
'public v6 (cloudflare)' => ['2606:4700:4700::1111', true],
'ipv4-mapped loopback' => ['::ffff:127.0.0.1', false],
'ipv4-mapped cgnat' => ['::ffff:100.64.0.1', false],
'ipv4-mapped public' => ['::ffff:8.8.8.8', true],
];
}
/**
* @dataProvider urlProvider
*/
public function test_is_allowed_url(string $url, bool $expected): void
{
$this->assertSame($expected, OutboundUrlGuard::isAllowedUrl($url));
}
public static function urlProvider(): array
{
return [
'loopback literal' => ['http://127.0.0.1/feed.ics', false],
'cgnat literal' => ['http://100.64.0.1/', false],
'metadata literal' => ['http://169.254.169.254/latest/meta-data/', false],
'public literal' => ['https://8.8.8.8/', true],
'non-http scheme' => ['ftp://8.8.8.8/', false],
'file scheme' => ['file:///etc/passwd', false],
'garbage' => ['not-a-url', false],
];
}
public function test_redirect_options_block_disallowed_hop(): void
{
$onRedirect = OutboundUrlGuard::redirectOptions()['on_redirect'];
$this->expectException(\RuntimeException::class);
$onRedirect(new Request('GET', 'https://8.8.8.8/'), new Response(302), new Uri('http://169.254.169.254/'));
}
public function test_redirect_options_allow_public_hop(): void
{
$onRedirect = OutboundUrlGuard::redirectOptions()['on_redirect'];
// A public → public redirect must not throw.
$onRedirect(new Request('GET', 'https://8.8.8.8/'), new Response(302), new Uri('https://1.1.1.1/'));
$this->assertTrue(true);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Unit\app\Core\UI;
use Leantime\Core\UI\Template;
use Unit\TestCase;
/**
* Regression tests for Template::escape() (#3636).
*
* escape() used htmlentities(), which converts non-ASCII to named entities on top of the
* XSS-relevant characters. Nearly every call site renders through {{ }}, which escapes the
* resulting ampersand a second time, so users with non-English data saw a literal
* "M&uuml;ller" in dropdowns, filters and Ideas.
*
* These pin both halves of the contract: non-ASCII survives, and the escaping is still as
* strong as it was for the call sites that render through {!! !!}.
*/
class TemplateEscapeTest extends TestCase
{
private function escape(?string $value): string
{
// Built without the constructor: escape() only reaches convertRelativePaths(), which
// depends on the BASE_URL constant rather than any instance state, so none of
// Template's collaborators (session, db, theme) need to exist here.
$template = (new \ReflectionClass(Template::class))->newInstanceWithoutConstructor();
return $template->escape($value);
}
public function test_non_ascii_is_left_alone(): void
{
$this->assertSame('Müller', $this->escape('Müller'));
$this->assertSame('Ä Ö Ü ä ö ü ß', $this->escape('Ä Ö Ü ä ö ü ß'));
$this->assertStringNotContainsString(
'&uuml;',
$this->escape('Müller'),
'Umlauts must not be turned into named entities (#3636)'
);
}
public function test_xss_relevant_characters_are_still_escaped(): void
{
$this->assertSame('&lt;script&gt;alert(1)&lt;/script&gt;', $this->escape('<script>alert(1)</script>'));
$this->assertSame('&quot; onerror=&quot;alert(1)', $this->escape('" onerror="alert(1)'));
$this->assertSame('&#039; onmouseover=&#039;x', $this->escape("' onmouseover='x"));
$this->assertSame('a &lt; b &amp; c &gt; d', $this->escape('a < b & c > d'));
}
public function test_ampersand_is_escaped_exactly_once(): void
{
// The double-escape the user actually saw came from & being encoded here and again
// by Blade. One pass here must produce exactly one &amp;.
$this->assertSame('Müller &amp; Söhne', $this->escape('Müller & Söhne'));
}
public function test_null_is_an_empty_string(): void
{
$this->assertSame('', $this->escape(null));
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Unit\app\Core\UI;
use Leantime\Core\Configuration\AppSettings;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Files\FileManager;
use Leantime\Core\Language;
use Leantime\Core\UI\Theme;
use Leantime\Domain\Setting\Repositories\Setting;
class ThemeTest extends \Unit\TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* The test object
*
* @var Theme
*/
protected $theme;
protected $settingsRepoMock;
protected $languageMock;
protected $configMock;
protected $appSettingsMock;
protected $fileManagerMock;
protected function setUp(): void
{
parent::setUp();
if (! defined('BASE_URL')) {
define('BASE_URL', 'http://localhost');
}
$this->settingsRepoMock = $this->make(Setting::class, [
]);
$this->languageMock = $this->make(Language::class, [
]);
$this->fileManagerMock = $this->make(FileManager::class, [
]);
$this->configMock = $this->make(Environment::class, [
'primarycolor' => '#123',
'secondarycolor' => '#123',
]);
$this->appSettingsMock = $this->make(AppSettings::class, [
'appVersion' => '123',
]);
}
protected function _after()
{
$this->theme = null;
}
// Write tests below
/**
* Test GetMenuTypes method
*/
public function test_get_default_color_scheme_with_color_env_set()
{
// Load class to be tested
$this->theme = new Theme(
settingsRepo: $this->settingsRepoMock,
language: $this->languageMock,
config: $this->configMock,
appSettings: $this->appSettingsMock,
fileManager: $this->fileManagerMock
);
$colorScheme = $this->theme->getColorScheme();
$this->assertEquals('companyColors', $colorScheme);
}
/**
* Test GetMenuTypes method
*/
public function test_get_default_color_scheme_without_env()
{
$configMock = $this->make(Environment::class, []);
$theme = new Theme(
settingsRepo: $this->settingsRepoMock,
language: $this->languageMock,
config: $configMock,
appSettings: $this->appSettingsMock,
fileManager: $this->fileManagerMock
);
$colorScheme = $theme->getColorScheme();
$this->assertEquals('themeDefault', $colorScheme);
}
}

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

View File

@@ -0,0 +1,316 @@
<?php
namespace Unit\app\Domain\Auth\Services;
use Illuminate\Session\SessionManager;
use Leantime\Core\Configuration\Environment as EnvironmentCore;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
use Leantime\Domain\Auth\Repositories\Auth as AuthRepository;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the pure/business logic extracted into the Auth service during
* the thin-controller refactor (resolveSafeRedirect, shouldHideLoginForm,
* checkPasswordStrength, resetPassword).
*/
class AuthServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a real Auth service with mocked dependencies. The Environment and
* Setting repository can be overridden so config/setting driven behavior can
* be exercised.
*/
private function makeService(
?EnvironmentCore $config = null,
?SettingRepository $settingsRepo = null,
?AuthRepository $authRepo = null
): AuthService {
return new AuthService(
$config ?? $this->make(EnvironmentCore::class),
$this->make(SessionManager::class),
$this->make(LanguageCore::class),
$settingsRepo ?? $this->make(SettingRepository::class),
$authRepo ?? $this->make(AuthRepository::class),
$this->make(UserRepository::class),
$this->make(AccessTokenRepository::class),
);
}
public function test_resolve_safe_redirect_defaults_to_dashboard(): void
{
$service = $this->makeService();
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(null));
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(''));
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect('/'));
}
public function test_resolve_safe_redirect_allows_internal_path(): void
{
$service = $this->makeService();
$this->assertSame(BASE_URL.'/tickets/showAll', $service->resolveSafeRedirect('tickets/showAll'));
}
public function test_resolve_safe_redirect_blocks_external_url(): void
{
$service = $this->makeService();
// An absolute external URL is a valid URL, so it is rejected and the
// default dashboard target is returned instead.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect('https://evil.example.com')
);
}
public function test_resolve_safe_redirect_allows_same_origin_absolute_url(): void
{
$service = $this->makeService();
// Same-origin absolute URL — must be treated the same as a relative
// path by stripping the BASE_URL prefix. This is the exact scenario
// the maintainer flagged: the login form often submits a full
// absolute URL in the redirectUrl hidden field.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect(BASE_URL.'/dashboard/home')
);
}
public function test_resolve_safe_redirect_allows_same_origin_absolute_url_with_deep_path(): void
{
$service = $this->makeService();
$this->assertSame(
BASE_URL.'/tickets/showAll',
$service->resolveSafeRedirect(BASE_URL.'/tickets/showAll')
);
}
public function test_resolve_safe_redirect_allows_url_encoded_same_origin_absolute_url(): void
{
$service = $this->makeService();
// URL-encoded same-origin absolute URL — rawurldecode is called first,
// then BASE_URL is stripped.
$this->assertSame(
BASE_URL.'/tickets/showAll',
$service->resolveSafeRedirect(urlencode(BASE_URL.'/tickets/showAll'))
);
}
public function test_resolve_safe_redirect_rejects_external_url_disguised_with_base_url_prefix(): void
{
$service = $this->makeService();
// An external URL whose path happens to start with the same characters
// as BASE_URL — str_starts_with won't match because the scheme+host
// differ. This gets rejected as external.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect('https://evil.example.com/'.BASE_URL.'/dashboard/home')
);
}
public function test_resolve_safe_redirect_rejects_host_prefix_without_a_boundary(): void
{
$service = $this->makeService();
// The real prefix hazard: a host that merely *begins* with our host, e.g.
// BASE_URL https://host vs https://hostile.example.com. A bare
// str_starts_with($url, BASE_URL) strips the prefix and rewrites this into the
// bogus internal path /ile.example.com/pwn instead of rejecting it outright.
// Stripping only on a boundary (end, '/', '?', '#') keeps it external.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect(BASE_URL.'ile.example.com/pwn')
);
}
public function test_resolve_safe_redirect_returns_dashboard_for_base_url_itself(): void
{
$service = $this->makeService();
// Exactly BASE_URL (with and without a trailing slash) has no path to go to —
// it must fall back to the dashboard rather than the bare app root.
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(BASE_URL));
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(BASE_URL.'/'));
}
public function test_resolve_safe_redirect_blocks_logout_including_variants(): void
{
$service = $this->makeService();
// Redirecting to logout right after login is a forced-logout loop. An exact
// string match on '/auth/logout' is walkable with a trailing slash, a query
// string or different casing, so the normalized path is what gets compared.
foreach ([
'/auth/logout',
'/auth/logout/',
'auth/logout',
'/auth/logout?next=/dashboard/home',
'/auth/logout#x',
'/AUTH/logout',
BASE_URL.'/auth/logout',
] as $variant) {
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect($variant),
sprintf('logout variant "%s" must not be an accepted redirect target', $variant)
);
}
}
public function test_resolve_safe_redirect_strips_control_characters(): void
{
$service = $this->makeService();
// Encoded CR/LF must never reach the Location header, and leading whitespace
// must not be usable to pad a protocol-relative URL past the '//' guard.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect('%09//evil.example.com')
);
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect(' //evil.example.com')
);
$this->assertStringNotContainsString(
"\r",
$service->resolveSafeRedirect('tickets/showAll%0d%0aSet-Cookie:x')
);
$this->assertStringNotContainsString(
"\n",
$service->resolveSafeRedirect('tickets/showAll%0d%0aSet-Cookie:x')
);
}
public function test_resolve_safe_redirect_preserves_plus_in_query_strings(): void
{
$service = $this->makeService();
// rawurldecode (not urldecode) is used precisely so a '+' in a query string
// survives instead of silently becoming a space.
$this->assertSame(
BASE_URL.'/tickets/showAll?searchTerm=a+b',
$service->resolveSafeRedirect('tickets/showAll?searchTerm=a+b')
);
}
public function test_resolve_safe_redirect_rejects_protocol_relative_url(): void
{
$service = $this->makeService();
// Protocol-relative URL (//attacker.com) — FILTER_VALIDATE_URL
// treats these as valid URLs, so they are correctly rejected
// and the default dashboard redirect is returned.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect('//attacker.com')
);
}
public function test_resolve_safe_redirect_rejects_backslash_protocol_trick(): void
{
$service = $this->makeService();
// Backslash variant (\/\/attacker.com) — some parsers treat
// this as a protocol-relative URL. Verify it is rejected.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect('\/\/attacker.com')
);
}
public function test_check_password_strength_rejects_weak_and_accepts_strong(): void
{
$service = $this->makeService();
$this->assertFalse($service->checkPasswordStrength('weak'));
$this->assertFalse($service->checkPasswordStrength('alllowercase1!'));
$this->assertFalse($service->checkPasswordStrength('NoNumber!!'));
$this->assertFalse($service->checkPasswordStrength('NoSpecial123'));
$this->assertFalse($service->checkPasswordStrength('Aa1!aaa')); // 7 chars
$this->assertTrue($service->checkPasswordStrength('StrongPass1!'));
}
public function test_reset_password_reports_mismatch(): void
{
$service = $this->makeService();
$this->assertSame('mismatch', $service->resetPassword('', '', 'hash'));
$this->assertSame('mismatch', $service->resetPassword('StrongPass1!', 'Different1!', 'hash'));
}
public function test_reset_password_reports_weak(): void
{
$service = $this->makeService();
$this->assertSame('weak', $service->resetPassword('weak', 'weak', 'hash'));
}
public function test_reset_password_success_and_error_map_to_repository(): void
{
$successRepo = $this->make(AuthRepository::class, [
'changePW' => fn () => true,
]);
$this->assertSame('success', $this->makeService(null, null, $successRepo)
->resetPassword('StrongPass1!', 'StrongPass1!', 'hash'));
$failRepo = $this->make(AuthRepository::class, [
'changePW' => fn () => false,
]);
$this->assertSame('error', $this->makeService(null, null, $failRepo)
->resetPassword('StrongPass1!', 'StrongPass1!', 'hash'));
}
public function test_should_hide_login_form_when_setting_on(): void
{
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => 'on',
]);
$this->assertTrue($this->makeService(null, $settingsRepo)->shouldHideLoginForm());
}
public function test_should_hide_login_form_falls_back_to_config(): void
{
$config = new EnvironmentCore;
$config->set('disableLoginForm', true);
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => false,
]);
$this->assertTrue($this->makeService($config, $settingsRepo)->shouldHideLoginForm());
$config2 = new EnvironmentCore;
$config2->set('disableLoginForm', false);
$this->assertFalse($this->makeService($config2, $settingsRepo)->shouldHideLoginForm());
}
public function test_login_input_placeholder_depends_on_ldap(): void
{
$ldapConfig = new EnvironmentCore;
$ldapConfig->set('useLdap', true);
$this->assertSame(
'input.placeholders.enter_email_or_username',
$this->makeService($ldapConfig)->getLoginInputPlaceholder()
);
$noLdapConfig = new EnvironmentCore;
$noLdapConfig->set('useLdap', false);
$this->assertSame(
'input.placeholders.enter_email',
$this->makeService($noLdapConfig)->getLoginInputPlaceholder()
);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Unit\app\Domain\Auth\Services;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\Auth;
use Leantime\Domain\Auth\Services\AuthUser;
/**
* Regression guard for the 3.9.x Bearer-auth role bug.
*
* AuthUser is the userdata builder on the Sanctum (Bearer) guard path: AccessToken::findToken ->
* AuthUser::setUser -> setUserSession. It stored the RAW DB role int ("50") in session('userdata'),
* while the permission engine's Auth::getRoleToCheck() validates the session role against
* Roles::getRoles() (the role-NAME list). So "50" resolved to false and the engine denied every
* #[RequiresPermission] @api method with -32001 — for every Bearer/Sanctum integrator, on any
* server that exposes the Authorization header (production). CI missed it because its Apache hid
* the header, routing Bearer through the fallback path (which builds userdata via
* Api::setApiUserSession, and that one DOES convert the role).
*
* The fix: AuthUser::setUserSession must store the role NAME string, matching the other two
* userdata builders. This asserts the resulting session role is engine-valid for every built-in
* role — it FAILS on the raw-int bug and PASSES on the fix, independent of web server config.
*/
class AuthUserSessionRoleTest extends \Unit\TestCase
{
private function userRow(int $role): array
{
return [
'id' => 1,
'firstname' => 'Test',
'username' => 'test@leantime.io',
'profileId' => 0,
'clientId' => 0,
'role' => $role,
'settings' => '',
'twoFAEnabled' => false,
'twoFASecret' => '',
'createdOn' => '2026-01-01 00:00:00',
'modified' => '2026-01-01 00:00:00',
];
}
public function test_sanctum_guard_session_role_is_engine_valid_for_every_builtin_role(): void
{
// setUserSession touches no instance state, so a constructor-less instance avoids the DB.
$authUser = (new \ReflectionClass(AuthUser::class))->newInstanceWithoutConstructor();
$setUserSession = new \ReflectionMethod(AuthUser::class, 'setUserSession');
$setUserSession->setAccessible(true);
foreach (array_keys(Roles::getRoles()) as $roleInt) {
session()->forget('userdata');
$setUserSession->invoke($authUser, $this->userRow((int) $roleInt));
$sessionRole = session('userdata.role');
$this->assertContains(
$sessionRole,
Roles::getRoles(),
"AuthUser stored an engine-invalid role for DB int $roleInt: ".var_export($sessionRole, true)
);
$this->assertNotFalse(
Auth::getRoleToCheck(false),
"getRoleToCheck() rejected the Sanctum-guard session role for DB int $roleInt"
);
}
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace Unit\app\Domain\Auth\Services;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\UI\Theme;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Auth\Services\Onboarding as OnboardingService;
use Leantime\Domain\Setting\Services\Setting as SettingService;
use Leantime\Domain\Users\Services\Users as UserService;
use Unit\TestCase;
/**
* Unit tests for the onboarding/invite business logic extracted from the
* UserInvite controller during the thin-controller refactor.
*/
class OnboardingServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a real Onboarding service with mocked dependencies.
*/
private function makeService(
?UserService $userService = null,
?SettingService $settingService = null,
?Theme $theme = null,
?AuthService $authService = null
): OnboardingService {
return new OnboardingService(
$authService ?? $this->make(AuthService::class),
$userService ?? $this->make(UserService::class),
$settingService ?? $this->make(SettingService::class),
$theme ?? $this->make(Theme::class),
$this->make(LanguageCore::class),
);
}
protected function setUp(): void
{
parent::setUp();
session()->forget('tempPassword');
}
public function test_save_account_rejects_weak_password(): void
{
$userService = $this->make(UserService::class, [
'checkPasswordStrength' => fn () => false,
'editUser' => function () {
$this->fail('editUser must not be called for a weak password');
},
]);
$result = $this->makeService($userService)->saveAccount(
['id' => 5, 'username' => 'jane@example.com'],
'Jane Doe',
'Engineer',
'weak'
);
$this->assertSame('weak', $result);
$this->assertNull(session('tempPassword'));
}
public function test_save_account_splits_name_and_persists(): void
{
$captured = null;
$userService = $this->make(UserService::class, [
'checkPasswordStrength' => fn () => true,
'editUser' => function ($values, $id) use (&$captured) {
$captured = ['values' => $values, 'id' => $id];
return true;
},
]);
$result = $this->makeService($userService)->saveAccount(
['id' => 5, 'username' => 'jane@example.com'],
'Jane Doe',
'Engineer',
'StrongPass1!'
);
$this->assertSame('saved', $result);
$this->assertSame(5, $captured['id']);
$this->assertSame('Jane', $captured['values']['firstname']);
$this->assertSame('Doe', $captured['values']['lastname']);
$this->assertSame('Engineer', $captured['values']['jobTitle']);
$this->assertSame('i', $captured['values']['status']);
$this->assertSame('jane@example.com', $captured['values']['user']);
$this->assertSame('StrongPass1!', $captured['values']['password']);
// Temp password is stored so the user can be auto-logged-in later.
$this->assertSame('StrongPass1!', session('tempPassword'));
}
public function test_save_account_handles_single_word_name(): void
{
$captured = null;
$userService = $this->make(UserService::class, [
'checkPasswordStrength' => fn () => true,
'editUser' => function ($values) use (&$captured) {
$captured = $values;
return true;
},
]);
$this->makeService($userService)->saveAccount(
['id' => 9, 'username' => 'mono@example.com'],
'Cher',
'',
'StrongPass1!'
);
$this->assertSame('Cher', $captured['firstname']);
$this->assertSame('', $captured['lastname']);
}
public function test_save_account_reports_error_when_persist_fails(): void
{
$userService = $this->make(UserService::class, [
'checkPasswordStrength' => fn () => true,
'editUser' => fn () => false,
]);
$result = $this->makeService($userService)->saveAccount(
['id' => 5, 'username' => 'jane@example.com'],
'Jane Doe',
'Engineer',
'StrongPass1!'
);
$this->assertSame('error', $result);
}
public function test_get_invite_settings_applies_defaults_when_unset(): void
{
$settingService = $this->make(SettingService::class, [
'getSetting' => fn () => false,
]);
$theme = $this->make(Theme::class, [
'getAvailableColorSchemes' => fn () => ['companyColors'],
'getAvailableFonts' => fn () => ['Roboto'],
'getAll' => fn () => ['default'],
]);
$settings = $this->makeService(null, $settingService, $theme)
->getInviteSettings(['id' => 7]);
$this->assertSame('default', $settings['userTheme']);
$this->assertSame('light', $settings['userColorMode']);
$this->assertSame('companyColors', $settings['userColorScheme']);
$this->assertSame('Roboto', $settings['themeFont']);
$this->assertSame($this->makeService()->getDefaultWorkdays(), $settings['workdays']);
$this->assertSame($this->makeService()->getDefaultDaySchedule(), $settings['daySchedule']);
$this->assertArrayHasKey('dayHourOptions', $settings);
$this->assertArrayHasKey('dateTimeValues', $settings);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Unit\app\Domain\Auth\Services;
use Leantime\Domain\Api\Services\Api;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\AuthUser;
use Leantime\Domain\Auth\Services\UserSessionBuilder;
/**
* Guards the userdata-builder bug family (3.9.x Bearer regression + the twoFAVerified twin).
*
* Every auth path now builds session('userdata') through UserSessionBuilder, so a field can't
* silently drift between paths. These tests pin the two invariants that historically broke:
* - role is ALWAYS the engine-valid NAME string (never the raw DB int), for every built-in role;
* - the two token paths (Sanctum/Bearer via AuthUser, x-api-key via Api) agree on role +
* twoFAVerified.
*/
class UserSessionBuilderTest extends \Unit\TestCase
{
private function userRow(int $role): array
{
return [
'id' => 1,
'firstname' => 'Test',
'username' => 'test@leantime.io',
'profileId' => 0,
'clientId' => 0,
'role' => $role,
'settings' => '',
'twoFAEnabled' => false,
'twoFASecret' => '',
'createdOn' => '2026-01-01 00:00:00',
'modified' => '2026-01-01 00:00:00',
];
}
public function test_role_is_engine_valid_name_string_for_every_builtin_role(): void
{
foreach (array_keys(Roles::getRoles()) as $roleInt) {
$userdata = UserSessionBuilder::build($this->userRow((int) $roleInt));
$this->assertSame(Roles::getRoleString((int) $roleInt), $userdata['role']);
$this->assertContains(
$userdata['role'],
Roles::getRoles(),
"Factory produced an engine-invalid role for DB int $roleInt: ".var_export($userdata['role'], true)
);
}
}
public function test_flags_are_honored(): void
{
$tokenSession = UserSessionBuilder::build($this->userRow(50), isExternalAuth: true, twoFAVerified: true);
$this->assertTrue($tokenSession['isExternalAuth']);
$this->assertTrue($tokenSession['twoFAVerified']);
$default = UserSessionBuilder::build($this->userRow(50));
$this->assertFalse($default['isExternalAuth']);
$this->assertFalse($default['twoFAVerified']);
}
public function test_both_token_paths_build_consistent_role_and_twofa(): void
{
// The Sanctum/Bearer path (AuthUser) and the x-api-key path (Api) are both token auth and
// must produce the same role + twoFAVerified — these are the exact two fields that drifted.
// setUserSession/setApiUserSession touch no instance state, so construct without the DB.
$row = $this->userRow(50);
session()->forget('userdata');
$authUser = (new \ReflectionClass(AuthUser::class))->newInstanceWithoutConstructor();
$m = new \ReflectionMethod(AuthUser::class, 'setUserSession');
$m->setAccessible(true);
$m->invoke($authUser, $row);
$guardSession = session('userdata');
session()->forget('userdata');
$api = (new \ReflectionClass(Api::class))->newInstanceWithoutConstructor();
$api->setApiUserSession($row, false);
$apiKeySession = session('userdata');
$this->assertSame($guardSession['role'], $apiKeySession['role'], 'token paths disagree on role');
$this->assertSame($guardSession['twoFAVerified'], $apiKeySession['twoFAVerified'], 'token paths disagree on twoFAVerified');
$this->assertContains($guardSession['role'], Roles::getRoles());
$this->assertTrue($guardSession['twoFAVerified'], 'token sessions should be 2FA-verified');
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Unit\app\Domain\Blueprints\Controllers;
use Illuminate\Routing\RouteDependencyResolverTrait;
use Leantime\Core\Http\IncomingRequest;
use ReflectionMethod;
use Unit\TestCase;
/**
* Regression coverage for canvas modal actions 404ing (PR #3544).
*
* The Blueprints controllers are routed under `blueprints/{canvasSlug}/{action}/{id?}`.
* Their action methods were declared `get(?string $id = null)` — omitting the
* `{canvasSlug}` segment — so Laravel bound the FIRST route param (`canvasSlug`) to
* the first method param (`$id`). `$id` then held the slug ("swot"), the real id was
* dropped, and EditCanvasItem/EditCanvasComment rendered the `errors.error404` partial
* (at HTTP 200) for every add/edit. The fix declares `$canvasSlug` first so `$id`
* binds to the real id. These tests assert that binding via the real route table —
* no DB/session/browser needed.
*/
class CanvasRouteBindingTest extends TestCase
{
/** Routed Blueprints actions that take an {id?} segment, by action name. */
public static function canvasActionProvider(): array
{
return [
'showCanvas' => ['showCanvas'],
'editCanvasItem' => ['editCanvasItem'],
'editCanvasComment' => ['editCanvasComment'],
'boardDialog' => ['boardDialog'],
'delCanvas' => ['delCanvas'],
'delCanvasItem' => ['delCanvasItem'],
'export' => ['export'],
];
}
/**
* Register the real Blueprints routes into the current app's router. Uses the
* actual routes.php (not a hand-rolled copy) so the test tracks the real
* definitions. Re-required per test because each test gets a fresh app/router.
*/
private function matchRoute(string $uri): \Illuminate\Routing\Route
{
require APP_ROOT.'/app/Domain/Blueprints/routes.php';
$request = IncomingRequest::create($uri, 'GET');
$route = $this->app->make('router')->getRoutes()->match($request);
$request->setRouteResolver(fn () => $route);
return $route;
}
/**
* Map the arguments the controller action actually RECEIVES for $route, modelling
* exactly what Illuminate\Routing\ControllerDispatcher::dispatch() does:
*
* $controller->{$method}(...array_values($resolvedParameters));
*
* The values are spread POSITIONALLY, so what matters is each method parameter's
* position, not the route key. This is the layer the bug lived in: with
* `get(?string $id)` the slug (first positional value) landed in `$id`. Returns a
* [paramName => boundValue] map.
*/
private function actionReceives(\Illuminate\Routing\Route $route): array
{
$resolver = new class($this->app)
{
use RouteDependencyResolverTrait;
public function __construct(public $container) {}
public function resolve($route): array
{
return $this->resolveClassMethodDependencies(
$route->parametersWithoutNulls(),
$route->getControllerClass(),
$route->getActionMethod(),
);
}
};
$positional = array_values($resolver->resolve($route));
$params = (new ReflectionMethod($route->getControllerClass(), $route->getActionMethod()))->getParameters();
$bound = [];
foreach ($params as $i => $param) {
$bound[$param->getName()] = $positional[$i] ?? null;
}
return $bound;
}
/**
* @dataProvider canvasActionProvider
*/
public function test_route_binds_real_id_not_canvas_slug(string $action): void
{
$received = $this->actionReceives($this->matchRoute("/blueprints/swot/{$action}/42"));
$this->assertSame('swot', $received['canvasSlug'] ?? null, "{$action}: \$canvasSlug must receive the slug");
$this->assertSame('42', $received['id'] ?? null, "{$action}: \$id must receive the route id, not the slug");
}
/**
* @dataProvider canvasActionProvider
*/
public function test_missing_id_does_not_leak_slug_into_id(string $action): void
{
$received = $this->actionReceives($this->matchRoute("/blueprints/swot/{$action}"));
$this->assertSame('swot', $received['canvasSlug'] ?? null, "{$action}: \$canvasSlug must receive the slug");
$this->assertNull($received['id'] ?? null, "{$action}: omitted {id?} must not leak the slug into \$id");
}
/**
* The structural invariant behind the fix: any Blueprints action routed under the
* {canvasSlug} prefix must declare `canvasSlug` as its first parameter, so route
* params line up with method params. Guards against re-introducing the bug on a
* new action.
*
* @dataProvider canvasActionProvider
*/
public function test_action_declares_canvas_slug_as_first_parameter(string $action): void
{
$route = $this->matchRoute("/blueprints/swot/{$action}");
$params = (new ReflectionMethod($route->getControllerClass(), $route->getActionMethod()))->getParameters();
$this->assertNotEmpty($params, "{$action}: action must declare parameters");
$this->assertSame('canvasSlug', $params[0]->getName(), "{$action}: first parameter must be \$canvasSlug");
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Unit\app\Domain\Blueprints\Models;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Unit\TestCase;
/**
* Phase 4 of the content-templates rollout: blueprint YAMLs gain an
* optional `startContent:` field that references a ContentTemplates key.
* This locks in the field's parse rules.
*/
class CanvasTemplateStartContentTest extends TestCase
{
public function test_start_content_is_null_when_absent(): void
{
$tpl = new CanvasTemplate([
'slug' => 'swot',
'icon' => 'fa-x',
'boxes' => [],
]);
$this->assertNull($tpl->startContent);
}
public function test_start_content_is_null_when_empty_string(): void
{
$tpl = new CanvasTemplate([
'slug' => 'swot',
'icon' => 'fa-x',
'boxes' => [],
'startContent' => '',
]);
$this->assertNull($tpl->startContent);
}
public function test_start_content_carries_through_when_set(): void
{
$tpl = new CanvasTemplate([
'slug' => 'leancanvas',
'icon' => 'fa-x',
'boxes' => [],
'startContent' => 'lean-starter-saas',
]);
$this->assertSame('lean-starter-saas', $tpl->startContent);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Unit\app\Domain\Blueprints\Models;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Unit\TestCase;
/**
* Unit tests for the CanvasTemplate value object: identifier derivation and the
* label-resolution rules (omitted/"default"/null fall back to base defaults,
* an explicit empty array means "no labels", an explicit array is used as-is).
*/
class CanvasTemplateTest extends TestCase
{
public function test_derives_database_type_comment_module_and_session_key(): void
{
$template = new CanvasTemplate(['slug' => 'swot']);
$this->assertSame('swotcanvas', $template->getDatabaseType());
$this->assertSame('swotcanvasitem', $template->getCommentModule());
$this->assertSame('currentSWOTCanvas', $template->getSessionKey());
}
public function test_applies_scalar_defaults_when_not_provided(): void
{
$template = new CanvasTemplate(['slug' => 'x']);
$this->assertSame('fa-x', $template->icon);
$this->assertSame('', $template->disclaimer);
$this->assertSame(2, $template->minColumns);
$this->assertSame(0, $template->minWidthOffset);
$this->assertSame([], $template->boxes);
$this->assertSame([], $template->layout);
}
public function test_omitted_status_labels_fall_back_to_defaults(): void
{
$template = new CanvasTemplate(['slug' => 'x']);
$this->assertArrayHasKey('status_draft', $template->statusLabels);
$this->assertArrayHasKey('status_valid', $template->statusLabels);
$this->assertArrayHasKey('relates_none', $template->relatesLabels);
}
public function test_default_keyword_falls_back_to_defaults(): void
{
$template = new CanvasTemplate(['slug' => 'x', 'statusLabels' => 'default', 'relatesLabels' => 'default']);
$this->assertArrayHasKey('status_draft', $template->statusLabels);
$this->assertArrayHasKey('relates_customers', $template->relatesLabels);
}
public function test_explicit_empty_array_means_no_labels(): void
{
// This is the SWOT case: statusLabels: {} (hide the status dropdown).
$template = new CanvasTemplate(['slug' => 'swot', 'statusLabels' => []]);
$this->assertSame([], $template->statusLabels);
// relatesLabels was omitted, so it still gets the defaults.
$this->assertArrayHasKey('relates_none', $template->relatesLabels);
}
public function test_explicit_labels_are_used_as_is(): void
{
$custom = [
'status_observation' => ['icon' => 'fa-eye', 'color' => 'blue', 'title' => 'status.ea.observation', 'dropdown' => 'info', 'active' => true],
];
$template = new CanvasTemplate(['slug' => 'ea', 'statusLabels' => $custom]);
$this->assertSame($custom, $template->statusLabels);
$this->assertArrayNotHasKey('status_draft', $template->statusLabels);
}
public function test_data_labels_default_and_override(): void
{
$defaulted = new CanvasTemplate(['slug' => 'x']);
$this->assertArrayHasKey(1, $defaulted->dataLabels);
$this->assertSame('assumptions', $defaulted->dataLabels[1]['field']);
$custom = [1 => ['title' => 'label.description', 'field' => 'conclusion', 'active' => true]];
$overridden = new CanvasTemplate(['slug' => 'swot', 'dataLabels' => $custom]);
$this->assertSame($custom, $overridden->dataLabels);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Unit\app\Domain\Blueprints\Services;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\BlueprintsExport;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Unit\TestCase;
/**
* Unit tests for the BlueprintsExport service (XML generation).
*
* exportToXml reads the board + items through the Blueprints SERVICE (getBoard / getBoardItems),
* which authorizes VIEW against the board's real project and returns false / [] for a
* missing/foreign/unauthorized board — so these stub the service, not the repository.
*/
class BlueprintsExportTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
public function test_exports_a_canvas_board_to_xml(): void
{
$service = $this->make(BlueprintsService::class, [
'getBoard' => fn () => [['title' => 'My SWOT', 'projectId' => 1]],
'getBoardItems' => fn () => [
[
'box' => 'swot_strengths', 'description' => 'Strong brand', 'author' => 5,
'status' => '', 'relates' => '', 'assumptions' => '', 'data' => '', 'conclusion' => '',
'created' => '2026-01-01 00:00:00', 'modified' => '2026-01-02 00:00:00',
'authorFirstname' => 'Jo', 'authorLastname' => 'Doe',
],
],
'getTranslatedBoxes' => fn () => [
'swot_strengths' => ['title' => 'Strengths'],
'swot_weaknesses' => ['title' => 'Weaknesses'],
],
]);
$xml = (new BlueprintsExport($service, new TemplateRegistry))->exportToXml(7, 'swot');
$this->assertNotNull($xml);
$this->assertStringContainsString('<canvas key="swotcanvas">', $xml);
$this->assertStringContainsString('<title>My SWOT</title>', $xml);
$this->assertStringContainsString('<element key="swot_strengths">', $xml);
$this->assertStringContainsString('<description>Strong brand</description>', $xml);
// An empty box still emits its element wrapper.
$this->assertStringContainsString('<element key="swot_weaknesses">', $xml);
}
public function test_returns_null_for_unknown_canvas_type(): void
{
$export = new BlueprintsExport(
$this->make(BlueprintsService::class),
new TemplateRegistry,
);
$this->assertNull($export->exportToXml(7, 'doesnotexist'));
}
public function test_returns_null_when_board_does_not_exist(): void
{
// getBoard returns false for a missing/foreign/unauthorized board.
$service = $this->make(BlueprintsService::class, ['getBoard' => fn () => false]);
$export = new BlueprintsExport($service, new TemplateRegistry);
$this->assertNull($export->exportToXml(7, 'swot'));
}
}

View File

@@ -0,0 +1,822 @@
<?php
namespace Unit\app\Domain\Blueprints\Services;
use Codeception\Test\Feature\Stub;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the Blueprints service: label translation helpers and the
* board-progress calculation (filled boxes / total boxes, max across boards).
*/
class BlueprintsServiceTest extends TestCase
{
use Stub;
/**
* Build the service with a language stub that prefixes keys with "T:" so we
* can assert translation happened, plus optional repo/registry overrides.
*/
private function service(?BlueprintsRepository $repo = null, ?TemplateRegistry $registry = null): BlueprintsService
{
$language = $this->make(LanguageCore::class, ['__' => fn (string $index) => 'T:'.$index]);
return new BlueprintsService(
$repo ?? $this->make(BlueprintsRepository::class),
$registry ?? new TemplateRegistry,
$language,
new ContentTemplateRegistry,
);
}
public function test_translated_boxes_run_titles_through_language(): void
{
$template = new CanvasTemplate([
'slug' => 'swot',
'boxes' => ['swot_strengths' => ['icon' => 'fa-x', 'title' => 'box.swot.strengths']],
]);
$boxes = $this->service()->getTranslatedBoxes($template);
$this->assertSame('T:box.swot.strengths', $boxes['swot_strengths']['title']);
$this->assertSame('fa-x', $boxes['swot_strengths']['icon']);
}
public function test_translates_status_relates_and_data_labels(): void
{
$service = $this->service();
$template = new CanvasTemplate(['slug' => 'x']); // base defaults
$this->assertSame('T:status.draft', $service->getTranslatedStatusLabels($template)['status_draft']['title']);
$this->assertSame('T:relates.none', $service->getTranslatedRelatesLabels($template)['relates_none']['title']);
$this->assertSame('T:label.assumptions', $service->getTranslatedDataLabels($template)[1]['title']);
}
public function test_disclaimer_is_empty_when_unset_and_translated_otherwise(): void
{
$service = $this->service();
$this->assertSame('', $service->getTranslatedDisclaimer(new CanvasTemplate(['slug' => 'x'])));
$this->assertSame(
'T:text.lean.disclaimer',
$service->getTranslatedDisclaimer(new CanvasTemplate(['slug' => 'lean', 'disclaimer' => 'text.lean.disclaimer']))
);
}
public function test_board_progress_is_fraction_of_filled_boxes(): void
{
// SWOT has 4 boxes; board 1 has 2 boxes with items -> 0.5.
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProgressCount' => fn () => [
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_strengths', 'boxItems' => 3],
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_threats', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_weaknesses', 'boxItems' => 0],
],
]);
$progress = $this->service($repo)->getBoardProgress('1', ['swotcanvas']);
$this->assertEqualsWithDelta(0.5, $progress['swotcanvas'], 0.001);
}
public function test_board_progress_takes_max_across_boards(): void
{
// Board 2 has all 4 SWOT boxes filled -> max progress 1.0.
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProgressCount' => fn () => [
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_strengths', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_strengths', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_weaknesses', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_opportunities', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_threats', 'boxItems' => 1],
],
]);
$progress = $this->service($repo)->getBoardProgress('1', ['swotcanvas']);
$this->assertEqualsWithDelta(1.0, $progress['swotcanvas'], 0.001);
}
// ---------------------------------------------------------------------
// Boards overview (absorbed from the former Strategy service).
// ---------------------------------------------------------------------
public function test_build_recent_progress_seeds_metadata_and_removes_used_type(): void
{
$service = $this->service();
$metadata = $service->getBoardMetadata();
$recentlyUpdated = [
['type' => 'valuecanvas', 'title' => 'My Value Board', 'modified' => '2026-05-20 10:00:00', 'id' => 11],
];
$result = $service->buildRecentProgressCanvas($recentlyUpdated, $metadata);
$this->assertArrayHasKey('valuecanvas', $result);
$this->assertSame(1, $result['valuecanvas']['count']);
$this->assertSame('My Value Board', $result['valuecanvas']['lastTitle']);
$this->assertSame('2026-05-20 10:00:00', $result['valuecanvas']['lastUpdate']);
$this->assertSame(11, $result['valuecanvas']['lastCanvasId']);
// Board links point at the consolidated Blueprints routes.
$this->assertSame('blueprints/value', $result['valuecanvas']['module']);
// The consumed type must be removed from the remaining "other" boards map.
$this->assertArrayNotHasKey('valuecanvas', $metadata);
$this->assertArrayHasKey('swotcanvas', $metadata);
}
public function test_build_recent_progress_increments_count_for_repeat_type(): void
{
$service = $this->service();
$metadata = $service->getBoardMetadata();
$recentlyUpdated = [
['type' => 'swotcanvas', 'title' => 'First', 'modified' => '2026-05-21 09:00:00', 'id' => 1],
['type' => 'swotcanvas', 'title' => 'Second', 'modified' => '2026-05-22 09:00:00', 'id' => 2],
['type' => 'swotcanvas', 'title' => 'Third', 'modified' => '2026-05-23 09:00:00', 'id' => 3],
];
$result = $service->buildRecentProgressCanvas($recentlyUpdated, $metadata);
$this->assertSame(3, $result['swotcanvas']['count']);
// The seeded values come from the FIRST occurrence only.
$this->assertSame('First', $result['swotcanvas']['lastTitle']);
$this->assertSame(1, $result['swotcanvas']['lastCanvasId']);
}
public function test_build_recent_progress_with_empty_input_returns_empty(): void
{
$service = $this->service();
$metadata = $service->getBoardMetadata();
$metadataCountBefore = count($metadata);
$result = $service->buildRecentProgressCanvas([], $metadata);
$this->assertSame([], $result);
// Nothing consumed, so the metadata map is untouched.
$this->assertCount($metadataCountBefore, $metadata);
}
public function test_boards_overview_assembles_render_ready_struct(): void
{
$recentlyUpdated = [
['type' => 'leancanvas', 'title' => 'Lean A', 'modified' => '2026-05-25 12:00:00', 'id' => 99],
];
$progress = ['leancanvas' => 0.5];
// getBoardsOverview now self-calls getLastUpdatedCanvas()/getBoardProgress(),
// so partial-mock just those two and exercise the real assembly logic.
$service = $this->make(BlueprintsService::class, [
'getLastUpdatedCanvas' => fn () => $recentlyUpdated,
'getBoardProgress' => fn () => $progress,
]);
$overview = $service->getBoardsOverview(7);
$this->assertArrayHasKey('recentProgressCanvas', $overview);
$this->assertArrayHasKey('otherBoards', $overview);
$this->assertArrayHasKey('recentlyUpdatedCanvas', $overview);
$this->assertArrayHasKey('canvasProgress', $overview);
$this->assertSame($recentlyUpdated, $overview['recentlyUpdatedCanvas']);
$this->assertSame($progress, $overview['canvasProgress']);
// leancanvas was recently used, so it lands in recentProgressCanvas
// and is removed from the remaining "other" boards.
$this->assertArrayHasKey('leancanvas', $overview['recentProgressCanvas']);
$this->assertSame('Lean A', $overview['recentProgressCanvas']['leancanvas']['lastTitle']);
$this->assertArrayNotHasKey('leancanvas', $overview['otherBoards']);
}
public function test_boards_overview_passes_project_id_to_self_calls(): void
{
$capturedLastUpdatedId = null;
$capturedProgressId = null;
$service = $this->make(BlueprintsService::class, [
'getLastUpdatedCanvas' => function ($projectId) use (&$capturedLastUpdatedId) {
$capturedLastUpdatedId = $projectId;
return [];
},
'getBoardProgress' => function ($projectId) use (&$capturedProgressId) {
$capturedProgressId = $projectId;
return [];
},
]);
$service->getBoardsOverview(7);
$this->assertSame(7, $capturedLastUpdatedId);
$this->assertSame('7', $capturedProgressId, 'getBoardProgress receives the project id cast to string');
}
// ---------------------------------------------------------------------
// Secured by-id board/item CRUD chokepoint.
//
// Canvas boards/items live in the shared zp_canvas / zp_canvas_items tables (one id
// sequence across every variant). Every by-id operation must authorize against the
// entity's REAL project (resolved by id + canvas type), never the session project. Reads
// soft-deny (return the neutral "missing" value) so they are not a cross-project existence
// oracle; writes fail CLOSED with an AuthorizationException and never touch the repo.
// ---------------------------------------------------------------------
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 securedService(BlueprintsRepository $repo, PermissionService $perms): BlueprintsService
{
$service = $this->service($repo);
$service->setPermissionService($perms);
return $service;
}
public function test_get_canvas_item_returns_false_for_missing_or_foreign_item_without_loading_it(): void
{
// Resolver null = missing id OR an id whose board is a different canvas type. Must
// return false WITHOUT loading the item — no cross-project existence oracle.
$loaded = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'getSingleCanvasItem' => function () use (&$loaded) {
$loaded++;
return ['id' => 1];
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
$this->assertFalse($service->getCanvasItem(123, 'swotcanvas'));
$this->assertSame(0, $loaded, 'A missing/foreign item must not be loaded');
}
public function test_get_canvas_item_soft_denies_when_view_not_permitted(): void
{
$loaded = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getSingleCanvasItem' => function () use (&$loaded) {
$loaded++;
return ['id' => 1];
},
]);
$service = $this->securedService($repo, $this->make(PermissionService::class, ['currentUserCan' => fn () => false]));
$this->assertFalse($service->getCanvasItem(1, 'swotcanvas'));
$this->assertSame(0, $loaded, 'An unauthorized item returns the same neutral result as a missing one');
}
public function test_get_canvas_item_is_type_scoped_and_returns_item_when_authorized(): void
{
$resolvedType = null;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => function ($id, $type) use (&$resolvedType) {
$resolvedType = $type;
return 9;
},
'getSingleCanvasItem' => fn () => ['id' => 7, 'canvasId' => 3],
]);
$service = $this->securedService($repo, $this->make(PermissionService::class, ['currentUserCan' => fn () => true]));
$item = $service->getCanvasItem(7, 'swotcanvas');
$this->assertSame(7, $item['id']);
$this->assertSame('swotcanvas', $resolvedType, 'The resolver must be type-scoped so a foreign canvas type cannot match');
}
public function test_get_board_items_returns_empty_for_foreign_board(): void
{
$loaded = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn () => null,
'getCanvasItemsById' => function () use (&$loaded) {
$loaded++;
return [['id' => 1]];
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
$this->assertSame([], $service->getBoardItems(999, 'swotcanvas', 'swotcanvasitem'));
$this->assertSame(0, $loaded, 'A foreign/unknown board must not have its items read');
}
public function test_patch_canvas_item_throws_and_never_writes_for_unresolved_item(): void
{
$patched = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'patchCanvasItem' => function () use (&$patched) {
$patched++;
return true;
},
]);
// allow-all permissions: the deny must come from the null resolution, not the role.
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->patchCanvasItem(5, ['status' => 'x'], 'swotcanvas');
$this->fail('Expected AuthorizationException for an unresolved item');
} catch (AuthorizationException) {
// expected
}
$this->assertSame(0, $patched, 'A missing/foreign item must never be patched');
}
public function test_patch_canvas_item_throws_when_edit_denied(): void
{
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'patchCanvasItem' => fn () => true,
]);
$service = $this->securedService($repo, $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->patchCanvasItem(5, ['status' => 'x'], 'swotcanvas');
}
public function test_update_canvas_item_resolves_project_from_item_id_not_payload_canvas_id(): void
{
// Relocation fence: the project is resolved from the EXISTING item's id, not from the
// attacker-supplied canvasId in the payload.
$resolvedItemId = null;
$wrote = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => function ($id) use (&$resolvedItemId) {
$resolvedItemId = $id;
return 9;
},
'editCanvasItem' => function () use (&$wrote) {
$wrote++;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
$service->updateCanvasItem(['itemId' => 42, 'canvasId' => 9999, 'description' => 'x'], 'swotcanvas');
$this->assertSame(42, $resolvedItemId, 'Project must be resolved from itemId, not the payload canvasId');
$this->assertSame(1, $wrote);
}
public function test_create_canvas_item_throws_and_never_inserts_for_unknown_board(): void
{
$inserted = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn () => null,
'addCanvasItem' => function () use (&$inserted) {
$inserted++;
return '1';
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->createCanvasItem(['canvasId' => 9999, 'box' => 'x'], 'swotcanvas');
$this->fail('Expected AuthorizationException for an unknown target board');
} catch (AuthorizationException) {
}
$this->assertSame(0, $inserted, 'An item must never be created into an unknown/foreign board');
}
public function test_delete_canvas_item_throws_and_never_deletes_for_unresolved_item(): void
{
$deleted = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'delCanvasItem' => function () use (&$deleted) {
$deleted++;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->deleteCanvasItem(5, 'swotcanvas');
$this->fail('Expected AuthorizationException for an unresolved item');
} catch (AuthorizationException) {
}
$this->assertSame(0, $deleted, 'A missing/foreign item must never be deleted');
}
public function test_delete_board_throws_and_never_deletes_for_unresolved_board(): void
{
$deleted = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn () => null,
'deleteCanvas' => function () use (&$deleted) {
$deleted++;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->deleteBoard(5, 'swotcanvas');
$this->fail('Expected AuthorizationException for an unresolved board');
} catch (AuthorizationException) {
}
$this->assertSame(0, $deleted, 'A missing/foreign board must never be deleted');
}
public function test_copy_board_throws_when_source_unresolved_and_never_copies(): void
{
$copied = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn () => null,
'copyCanvas' => function () use (&$copied) {
$copied++;
return 1;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->copyBoard(5, 7, 1, 'Copy', 'swotcanvas');
$this->fail('Expected AuthorizationException for an unresolved source board');
} catch (AuthorizationException) {
}
$this->assertSame(0, $copied, 'A board must never be copied from an unknown/foreign source');
}
public function test_merge_board_requires_both_boards_to_resolve(): void
{
// Source (1) resolves but target (2) does not -> deny, never merge.
$merged = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn ($id) => $id === 1 ? 9 : null,
'mergeCanvas' => function () use (&$merged) {
$merged++;
return true;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->mergeBoard(2, 1, 'swotcanvas');
$this->fail('Expected AuthorizationException when a board does not resolve');
} catch (AuthorizationException) {
}
$this->assertSame(0, $merged, 'Merge must not run unless BOTH boards resolve');
}
public function test_import_authorizes_create_on_target_project_before_doing_anything(): void
{
// import() authorizes CREATE against the passed projectId first — a denial throws
// before the file/template/repo are ever touched (it is reachable via JSON-RPC with an
// arbitrary projectId).
$repo = $this->make(BlueprintsRepository::class, [
'existCanvas' => function (): bool {
$this->fail('import must deny before touching the repository');
return false;
},
]);
$service = $this->securedService($repo, $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->import('/tmp/does-not-matter.xml', 'swot', 55, 1);
}
// ---------------------------------------------------------------------
// import() path-validation regression tests (SSRF / LFI / CWE-918).
// ---------------------------------------------------------------------
public function test_import_rejects_ssrf_url_wrappers(): void
{
// URL wrappers such as http://, ftp:// resolve to false via realpath(),
// but even if/when a stream wrapper could produce a realpath, the
// allow-list check catches it. This test also guards the more
// subtle case of file:///etc/passwd which some PHP builds resolve.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
// SSRF: HTTP URL — realpath() returns false, caught as "file not found".
$this->assertFalse(
$service->import('http://169.254.169.254/latest/meta-data/', 'lean', 55, 1),
'HTTP URL must be rejected'
);
// SSRF: FTP URL.
$this->assertFalse(
$service->import('ftp://evil.com/blueprint.xml', 'lean', 55, 1),
'FTP URL must be rejected'
);
// LFI: file:// wrapper. Some PHP builds resolve file:///etc/passwd
// via realpath() and would read it without the allow-list guard.
$this->assertFalse(
$service->import('file:///etc/passwd', 'lean', 55, 1),
'file:// URL must be rejected'
);
}
public function test_import_rejects_lfi_absolute_path_to_system_file(): void
{
// Create an .xml file in a directory that is NOT in the allowed list.
// base_path('storage') is reliably outside sys_temp_dir, userfiles, and
// Blueprints/imports — unlike /var/tmp which can equal sys_get_temp_dir()
// on some systems.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
$outOfBounds = base_path('storage/leantime_lfi_test_'.uniqid('', true).'.xml');
file_put_contents($outOfBounds, '<canvas key="leancanvas"><title>LFI Test</title></canvas>');
try {
$this->assertFalse(
$service->import($outOfBounds, 'lean', 55, 1),
'Absolute path to an .xml file outside allowed directories must be rejected'
);
} finally {
if (file_exists($outOfBounds)) {
unlink($outOfBounds);
}
}
}
public function test_import_rejects_dot_dot_path_traversal(): void
{
// Create a real .xml file outside the allow-list (in storage/),
// then reach it via a path that starts in sys_get_temp_dir() and
// traverses up to the filesystem root with ../ before descending
// into the project. realpath() must resolve the ../ segments and
// the allow-list must reject the canonicalized path — this proves
// both canonicalization AND allow-list work, not just extension
// validation.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
$outOfBounds = base_path('storage/traversal_target_'.uniqid('', true).'.xml');
file_put_contents($outOfBounds, '<canvas key="leancanvas"><title>Traversal Test</title></canvas>');
// Walk from temp dir up to root (depth + 1 levels), then down
// into the project storage directory.
$upLevels = substr_count(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + 1;
$fromRoot = ltrim($outOfBounds, DIRECTORY_SEPARATOR);
$traversal = sys_get_temp_dir().DIRECTORY_SEPARATOR
.str_repeat('..'.DIRECTORY_SEPARATOR, $upLevels + 1)
.$fromRoot;
try {
$this->assertFalse(
$service->import($traversal, 'lean', 55, 1),
'Path traversal (../) to a valid .xml outside allowed dirs must be rejected'
);
} finally {
if (file_exists($outOfBounds)) {
unlink($outOfBounds);
}
}
}
public function test_import_rejects_sibling_prefix_bypass(): void
{
// str_starts_with without DIRECTORY_SEPARATOR anchoring would
// allow imports-evil/x to match against allowed …/imports.
// Create a sibling of the Blueprints imports directory (under
// the project root, guaranteed writable) to test the anchor.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
$allowedDir = APP_ROOT.'/app/Domain/Blueprints/imports';
if (! is_dir($allowedDir)) {
mkdir($allowedDir, 0700, true);
}
$siblingDir = APP_ROOT.'/app/Domain/Blueprints/imports-sibling-'.uniqid('', true);
if (! is_dir($siblingDir)) {
mkdir($siblingDir, 0700, true);
}
$siblingFile = $siblingDir.'/blueprint.xml';
file_put_contents($siblingFile, '<canvas key="leancanvas"><title>Test</title></canvas>');
try {
$this->assertFalse(
$service->import($siblingFile, 'lean', 55, 1),
'Sibling-prefix path (e.g. /tmp-evil/…) must NOT match allowed /tmp'
);
} finally {
unlink($siblingFile);
rmdir($siblingDir);
}
}
public function test_import_rejects_disallowed_file_extensions(): void
{
// Only .xml is permitted. Other extensions must be
// rejected even when the file sits in an allowed directory.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
// Use tempnam() + rename to get unique filenames — fixed names
// in the shared temp dir can collide with crashed-run leftovers
// or concurrent test processes.
$phpBase = tempnam(sys_get_temp_dir(), 'leantime.');
$phpFile = $phpBase.'.php';
rename($phpBase, $phpFile);
file_put_contents($phpFile, '<?php echo "pwned";');
$txtBase = tempnam(sys_get_temp_dir(), 'leantime.');
$txtFile = $txtBase.'.txt';
rename($txtBase, $txtFile);
file_put_contents($txtFile, 'not xml');
try {
$this->assertFalse(
$service->import($phpFile, 'lean', 55, 1),
'.php extension must be rejected in an allowed directory'
);
$this->assertFalse(
$service->import($txtFile, 'lean', 55, 1),
'.txt extension must be rejected in an allowed directory'
);
} finally {
if (file_exists($phpFile)) {
unlink($phpFile);
}
if (file_exists($txtFile)) {
unlink($txtFile);
}
}
}
public function test_import_accepts_xml_file_in_allowed_temp_dir(): void
{
// A .xml file placed in sys_get_temp_dir() (the normal upload flow)
// must pass path validation and successfully import via the repo.
// The repository is stubbed so the import completes and returns a
// known canvas id, proving that path validation did NOT block it.
$expectedId = 42;
// addCanvas()/addCanvasItem() are declared `false|string` (insertGetId), so the
// stubs must return strings — import() casts the id to int on the way out.
$repo = $this->make(BlueprintsRepository::class, [
'existCanvas' => fn () => false,
'addCanvas' => fn () => (string) $expectedId,
'addCanvasItem' => fn () => '1',
]);
$service = $this->securedService($repo, $this->allowingPermissions());
// import() resolves UserRepository via app()->make(). Unit tests
// disable the database, so bind a stub that never touches it.
$usersStub = $this->make(UserRepository::class, [
'getUserIdByName' => fn () => 1,
]);
app()->instance(UserRepository::class, $usersStub);
// Mirrors what BlueprintsExport::buildXml() actually emits — in particular
// status/relates carry their value in a `key` attribute, which is what
// import() reads. Element text there is silently dropped.
$xml = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<canvas key="leancanvas">
<title>Security Test Canvas</title>
<content>
<element key="problem">
<item>
<author id="1" firstname="A" lastname="B"/>
<description>Test item</description>
<status key="status_draft" />
<relates key="relates_none" />
<assumptions>none</assumptions>
<data>none</data>
<conclusion>none</conclusion>
</item>
</element>
</content>
</canvas>
XML;
$tmpBase = tempnam(sys_get_temp_dir(), 'leantime.');
$tempFile = $tmpBase.'.xml';
rename($tmpBase, $tempFile);
file_put_contents($tempFile, $xml);
try {
$result = $service->import($tempFile, 'lean', 55, 1);
// Path validation passed and repo returned the expected canvas id.
$this->assertSame(
$expectedId,
$result,
'XML file in allowed dir must pass path validation and be imported'
);
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
}
public function test_create_board_applies_start_content_against_the_slug_not_the_db_type(): void
{
// Regression test for Phase 4: createBoard() is called with the DATABASE
// type ("swotcanvas") but both the Blueprints TemplateRegistry and the
// ContentTemplateRegistry key by the SLUG ("swot"). The original code
// called TemplateRegistry::get($canvasType), which required a slug and
// silently returned null for the db-type form — making applyStartContent
// a no-op. This test locks in the fix: getByDatabaseType() bridges, and
// the resolved slug flows to the ContentTemplates lookups.
$blueprint = new CanvasTemplate([
'slug' => 'swot',
'startContent' => 'starter-swot',
]);
$registry = new class($blueprint) extends TemplateRegistry
{
public function __construct(private CanvasTemplate $bp) {}
public function get(string $slug): ?CanvasTemplate
{
// Bug reproduction: original code called this with 'swotcanvas'.
// The real registry only knows 'swot' — so it returned null and
// applyStartContent bailed. Test-side we mirror that behavior.
return $slug === 'swot' ? $this->bp : null;
}
public function getByDatabaseType(string $dbType): ?CanvasTemplate
{
// Mirror the shipped str_ends_with/substr strip so this stub and
// the production slug-resolution can't drift (per review CR).
$suffix = 'canvas';
$slug = str_ends_with($dbType, $suffix) && strlen($dbType) > strlen($suffix)
? substr($dbType, 0, -strlen($suffix))
: $dbType;
return $this->get($slug);
}
};
$contentTemplates = new class extends ContentTemplateRegistry
{
/** @var string[] */
public array $seenSlugs = [];
// Override the parent constructor (the stub needs no deps) and record
// the slugs get() is consulted with, so the test asserts on them
// afterward. Avoids a by-reference property — PHP ^8.2 can't promote
// by reference, and a typed-property reference is brittle.
public function __construct() {}
public function get(string $appliesTo, string $key): ?ContentTemplate
{
$this->seenSlugs[] = $appliesTo;
return null; // null lookup exits applyStartContent early, but the assertion is on WHAT slug reached us.
}
};
$repo = $this->make(BlueprintsRepository::class, [
'addCanvas' => fn () => '77',
]);
$language = $this->make(LanguageCore::class, ['__' => fn (string $index) => 'T:'.$index]);
$service = new BlueprintsService($repo, $registry, $language, $contentTemplates);
$service->setPermissionService($this->allowingPermissions());
$service->createBoard(['projectId' => 5, 'title' => 't'], 'swotcanvas');
$this->assertSame(['swot'], $contentTemplates->seenSlugs, 'ContentTemplates must be consulted with the SLUG, not the DB type');
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Unit\app\Domain\Blueprints\Services;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Unit\TestCase;
/**
* Unit tests for TemplateRegistry, which loads the canvas YAML definitions from
* app/Domain/Blueprints/Templates/definitions into CanvasTemplate objects.
*/
class TemplateRegistryTest extends TestCase
{
private function registry(): TemplateRegistry
{
return new TemplateRegistry;
}
public function test_loads_a_known_definition(): void
{
$template = $this->registry()->get('swot');
$this->assertInstanceOf(CanvasTemplate::class, $template);
$this->assertSame('swot', $template->slug);
$this->assertSame('swotcanvas', $template->getDatabaseType());
// SWOT has four boxes.
$this->assertCount(4, $template->boxes);
$this->assertArrayHasKey('swot_strengths', $template->boxes);
}
public function test_unknown_slug_returns_null(): void
{
$this->assertNull($this->registry()->get('doesnotexist'));
}
public function test_slug_lookup_is_case_insensitive_and_trimmed(): void
{
$this->assertInstanceOf(CanvasTemplate::class, $this->registry()->get(' SWOT '));
}
public function test_get_by_database_type_strips_canvas_suffix(): void
{
$template = $this->registry()->getByDatabaseType('leancanvas');
$this->assertInstanceOf(CanvasTemplate::class, $template);
$this->assertSame('lean', $template->slug);
}
public function test_caches_and_returns_same_instance(): void
{
$registry = $this->registry();
$this->assertSame($registry->get('swot'), $registry->get('swot'));
}
public function test_all_loads_every_definition(): void
{
$slugs = $this->registry()->slugs();
// The 16 consolidated variants all have a YAML definition.
$expected = ['cp', 'dbm', 'ea', 'em', 'insights', 'lbm', 'lean', 'minempathy', 'obm', 'retros', 'risks', 'sb', 'sm', 'sq', 'swot', 'value'];
foreach ($expected as $slug) {
$this->assertContains($slug, $slugs, "Missing definition for '$slug'");
}
}
public function test_obm_carries_min_width_offset(): void
{
// OBM is the one layout that needed an extra +50px min-width offset.
$this->assertSame(50, $this->registry()->get('obm')->minWidthOffset);
}
}

View File

@@ -0,0 +1,372 @@
<?php
namespace Unit\app\Domain\Calendar\Services;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Exceptions\MissingParameterException;
use Leantime\Core\Language;
use Leantime\Domain\Calendar\Repositories\Calendar as CalendarRepository;
use Leantime\Domain\Menu\Repositories\Menu;
use Leantime\Domain\Setting\Repositories\Setting;
use Leantime\Domain\Tickets\Services\Tickets;
use Spatie\IcalendarGenerator\Components\Calendar as IcalCalendar;
use Unit\TestCase;
class CalendarServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected $calendarRepository;
protected $language;
protected $settingsRepository;
protected $config;
protected $calendar;
/**
* The test object
*
* @var Menu
*/
protected $menu;
protected function setUp(): void
{
parent::setUp();
if (! defined('BASE_URL')) {
define('BASE_URL', 'http://localhost');
}
$this->calendarRepository = $this->make(CalendarRepository::class);
$this->language = $this->make(Language::class);
$this->settingsRepository = $this->make(Setting::class, [
'getSetting' => 'secret',
]);
$this->config = $this->make(Environment::class, [
'sessionPassword' => '123abc',
]);
// Load class to be tested
$this->calendar = new \Leantime\Domain\Calendar\Services\Calendar(
calendarRepo: $this->calendarRepository,
language: $this->language,
settingsRepo: $this->settingsRepository,
config: $this->config
);
}
protected function _after()
{
$this->calendar = null;
}
// Write tests below
/**
* Test GetMenuTypes method
*/
public function test_get_i_cal_url()
{
// Sha is generated from id -1 and sessionpassword 123abc
$sha = 'ba62fbd0d08f6607d6b3213dcccc1b50f4d82f19';
$url = $this->calendar->getICalUrl(1);
$this->assertEquals(BASE_URL.'/calendar/ical/secret_'.$sha, $url, 'hash is not correct');
}
/**
* A token that does not split into exactly two hashes must throw.
*/
public function test_get_ical_by_request_token_rejects_malformed_token()
{
$this->expectException(MissingParameterException::class);
// No underscore -> only one part -> invalid.
$this->calendar->getIcalByRequestToken('notavalidtoken');
}
/**
* A token taken from the request id (no 3-part act) must parse into
* userHash/calHash and route them to the repository correctly.
*/
public function test_get_ical_by_request_token_parses_id_token_and_routes_hashes()
{
$capturedUserHash = null;
$capturedCalHash = null;
$calendarRepo = $this->make(CalendarRepository::class, [
'getCalendarBySecretHash' => function (string $userHash, string $calHash) use (&$capturedUserHash, &$capturedCalHash) {
$capturedUserHash = $userHash;
$capturedCalHash = $calHash;
return [
[
'id' => 1,
'title' => 'Event',
'description' => 'desc',
'dateFrom' => '2025-04-16 10:00:00',
'dateTo' => '2025-04-16 11:00:00',
'allDay' => false,
'eventType' => 'calendar',
'dateContext' => 'plan',
'url' => '',
],
];
},
]);
$service = new \Leantime\Domain\Calendar\Services\Calendar(
calendarRepo: $calendarRepo,
language: $this->language,
settingsRepo: $this->settingsRepository,
config: $this->config
);
// Token format is {icalHash}_{userHash}.
$result = $service->getIcalByRequestToken('calhash123_userhash456');
$this->assertInstanceOf(IcalCalendar::class, $result);
$this->assertEquals('userhash456', $capturedUserHash, 'user hash should come from the second token segment');
$this->assertEquals('calhash123', $capturedCalHash, 'cal hash should come from the first token segment');
}
/**
* When the frontcontroller act value carries the token as its third
* dot-separated segment it must take precedence over the id token.
*/
public function test_get_ical_by_request_token_prefers_act_segment()
{
$capturedUserHash = null;
$capturedCalHash = null;
$calendarRepo = $this->make(CalendarRepository::class, [
'getCalendarBySecretHash' => function (string $userHash, string $calHash) use (&$capturedUserHash, &$capturedCalHash) {
$capturedUserHash = $userHash;
$capturedCalHash = $calHash;
return [
[
'id' => 1,
'title' => 'Event',
'description' => 'desc',
'dateFrom' => '2025-04-16 10:00:00',
'dateTo' => '2025-04-16 11:00:00',
'allDay' => false,
'eventType' => 'calendar',
'dateContext' => 'plan',
'url' => '',
],
];
},
]);
$service = new \Leantime\Domain\Calendar\Services\Calendar(
calendarRepo: $calendarRepo,
language: $this->language,
settingsRepo: $this->settingsRepository,
config: $this->config
);
// act = calendar.ical.{icalHash}_{userHash}; id token is ignored.
$result = $service->getIcalByRequestToken('ignored', 'calendar.ical.actcal_actuser');
$this->assertInstanceOf(IcalCalendar::class, $result);
$this->assertEquals('actuser', $capturedUserHash, 'user hash should come from the act segment');
$this->assertEquals('actcal', $capturedCalHash, 'cal hash should come from the act segment');
}
// ---- permission-engine authorization ---------------------------------
/** Builds the service with a stubbed repo + PermissionService, as the session user (id 1). */
private function makeServiceWithPermissions(
CalendarRepository $repo,
\Leantime\Core\Auth\Permissions\PermissionService $perms
): \Leantime\Domain\Calendar\Services\Calendar {
session(['userdata.id' => 1]);
$service = new \Leantime\Domain\Calendar\Services\Calendar(
calendarRepo: $repo,
language: $this->language,
settingsRepo: $this->settingsRepository,
config: $this->config
);
$service->setPermissionService($perms);
return $service;
}
/** PermissionService stub: currentUserCan returns the given value for every key. */
private function permissions(bool $allow): \Leantime\Core\Auth\Permissions\PermissionService
{
return $this->make(\Leantime\Core\Auth\Permissions\PermissionService::class, [
'currentUserCan' => fn () => $allow,
'authorize' => fn () => null,
]);
}
public function test_get_event_returns_own_event(): void
{
$repo = $this->make(CalendarRepository::class, [
'getEvent' => fn () => ['id' => 5, 'userId' => 1, 'description' => 'mine'],
]);
// can(MANAGE) = false, but the session user (1) owns the event.
$service = $this->makeServiceWithPermissions($repo, $this->permissions(false));
$this->assertSame(1, $service->getEvent(5)['userId']);
}
public function test_get_event_soft_denies_foreign_event_without_manage(): void
{
$repo = $this->make(CalendarRepository::class, [
'getEvent' => fn () => ['id' => 5, 'userId' => 2, 'description' => 'someone else'],
]);
// Event owned by user 2; session user 1 lacks calendar.manage → soft-deny.
$service = $this->makeServiceWithPermissions($repo, $this->permissions(false));
$this->assertFalse($service->getEvent(5));
}
public function test_get_event_allows_foreign_event_with_manage(): void
{
$repo = $this->make(CalendarRepository::class, [
'getEvent' => fn () => ['id' => 5, 'userId' => 2, 'description' => 'someone else'],
]);
// calendar.manage (admin+) is the cross-user override.
$service = $this->makeServiceWithPermissions($repo, $this->permissions(true));
$this->assertSame(2, $service->getEvent(5)['userId']);
}
public function test_get_external_calendar_ignores_passed_userid_and_uses_session(): void
{
$capturedUserId = null;
$repo = $this->make(CalendarRepository::class, [
'getExternalCalendar' => function ($id, $userId) use (&$capturedUserId) {
$capturedUserId = $userId;
return ['id' => $id, 'url' => 'https://example.com/cal.ics'];
},
]);
$service = $this->makeServiceWithPermissions($repo, $this->permissions(true));
// Caller passes a FOREIGN userId (99); the service must query as the session user (1).
$service->getExternalCalendar(7, 99);
$this->assertSame(1, $capturedUserId, 'external calendar lookup must use the session user, not the passed id');
}
public function test_get_my_external_calendars_ignores_passed_userid_and_uses_session(): void
{
$capturedUserId = null;
$repo = $this->make(CalendarRepository::class, [
'getMyExternalCalendars' => function ($userId) use (&$capturedUserId) {
$capturedUserId = $userId;
return [];
},
]);
$service = $this->makeServiceWithPermissions($repo, $this->permissions(true));
$service->getMyExternalCalendars(99);
$this->assertSame(1, $capturedUserId, 'calendar list must use the session user, not the passed id');
}
public function test_rpc_surface_is_locked(): void
{
$reflect = fn (string $m) => (new \ReflectionMethod(\Leantime\Domain\Calendar\Services\Calendar::class, $m))->getDocComment();
$isApi = fn (string $m) => ($d = $reflect($m)) !== false && preg_match('/^\s*\*\s*@api\b/m', $d) === 1;
$gate = function (string $m): ?string {
$attrs = (new \ReflectionMethod(\Leantime\Domain\Calendar\Services\Calendar::class, $m))
->getAttributes(\Leantime\Core\Auth\Permissions\RequiresPermission::class);
return $attrs === [] ? null : $attrs[0]->newInstance()->permission;
};
// The iCal feed methods are served by the public hash-authed route — never RPC-callable.
$this->assertFalse($isApi('getIcalByHash'), 'getIcalByHash must not be @api');
$this->assertFalse($isApi('getIcalByRequestToken'), 'getIcalByRequestToken must not be @api');
// Every @api method carries a calendar.* dispatch gate.
$expected = [
'getEvent' => 'calendar.view',
'getExternalCalendar' => 'calendar.view',
'getMyExternalCalendars' => 'calendar.view',
'getCachedExternalCalendarContent' => 'calendar.view',
'addEvent' => 'calendar.create',
'addExternalCalendarUrl' => 'calendar.create',
'editEvent' => 'calendar.edit',
'editExternalCalendar' => 'calendar.edit',
'patch' => 'calendar.edit',
'delEvent' => 'calendar.delete',
'deleteGCal' => 'calendar.delete',
];
foreach ($expected as $method => $permission) {
$this->assertTrue($isApi($method), "$method should stay @api");
$this->assertSame($permission, $gate($method), "$method must carry the $permission gate");
}
}
// ---- calendar feed robustness ----------------------------------------
/**
* Regression for #3536: a ticket with a valid planned start (editFrom) but an
* empty/sentinel editTo used to throw in parseDbDateTime(), 500-ing the whole
* "My Work" calendar feed and leaving the dashboard widget loading forever.
* editTo must now be guarded and fall back to editFrom.
*/
public function test_get_calendar_survives_ticket_with_empty_edit_to(): void
{
$repo = $this->make(CalendarRepository::class, [
'getAll' => fn () => [],
]);
$ticket = [
'id' => 10,
'headline' => 'Planned task',
'description' => '',
'projectId' => 3,
'status' => 3,
'dateToFinish' => '', // invalid -> due-date block is skipped
'editFrom' => '2026-06-18 09:00:00', // valid planned start
'editTo' => '', // empty end date -> previously threw
];
$tickets = $this->make(Tickets::class, [
'getOpenUserTicketsThisWeekAndLater' => fn () => ['thisWeek' => ['tickets' => [$ticket]]],
'getStatusLabels' => fn () => [],
]);
app()->instance(Tickets::class, $tickets);
$service = new \Leantime\Domain\Calendar\Services\Calendar(
calendarRepo: $repo,
language: $this->language,
settingsRepo: $this->settingsRepository,
config: $this->config
);
$events = $service->getCalendar(1);
$editEvents = array_values(array_filter(
$events,
fn ($event) => ($event['dateContext'] ?? null) === 'edit'
));
$this->assertCount(1, $editEvents, 'the planned-edit event should still be produced (no exception)');
$this->assertSame('2026-06-18 09:00:00', $editEvents[0]['dateFrom']);
$this->assertSame(
'2026-06-18 09:00:00',
$editEvents[0]['dateTo'],
'editTo should fall back to editFrom when empty'
);
}
}

View File

@@ -0,0 +1,170 @@
<?php
namespace Unit\app\Domain\Clients\Services;
use Leantime\Core\Exceptions\EntityExistsException;
use Leantime\Core\Exceptions\MissingParameterException;
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Files\Services\Files as FileService;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the Clients service helpers extracted during the
* thin-controller refactor (createClient, updateClient, removeUser,
* getClientPageData).
*/
class ClientsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a real Clients service, allowing each dependency to be
* overridden with a stub so we can observe the persistence calls.
*/
private function makeService(
?ClientRepository $clientRepo = null,
?UserRepository $userRepo = null,
?ProjectRepository $projectRepo = null,
?CommentService $commentService = null,
?FileService $fileService = null,
): ClientService {
return new ClientService(
$projectRepo ?? $this->make(ProjectRepository::class),
$clientRepo ?? $this->make(ClientRepository::class),
$commentService ?? $this->make(CommentService::class),
$fileService ?? $this->make(FileService::class),
$userRepo ?? $this->make(UserRepository::class),
);
}
public function test_create_client_returns_new_id_for_valid_unique_client(): void
{
$repo = $this->make(ClientRepository::class, [
'isClient' => fn () => false,
'addClient' => fn () => '42',
]);
$id = $this->makeService(clientRepo: $repo)->createClient(['name' => 'Acme']);
$this->assertSame(42, $id);
}
public function test_create_client_throws_when_name_missing(): void
{
$addCalls = 0;
$repo = $this->make(ClientRepository::class, [
'isClient' => fn () => false,
'addClient' => function () use (&$addCalls) {
$addCalls++;
return 1;
},
]);
$this->expectException(MissingParameterException::class);
try {
$this->makeService(clientRepo: $repo)->createClient(['name' => '']);
} finally {
$this->assertSame(0, $addCalls, 'An invalid client must never reach the repository');
}
}
public function test_create_client_throws_when_client_already_exists(): void
{
$addCalls = 0;
$repo = $this->make(ClientRepository::class, [
'isClient' => fn () => true,
'addClient' => function () use (&$addCalls) {
$addCalls++;
return 1;
},
]);
$this->expectException(EntityExistsException::class);
try {
$this->makeService(clientRepo: $repo)->createClient(['name' => 'Acme']);
} finally {
$this->assertSame(0, $addCalls, 'A duplicate client must never be persisted');
}
}
public function test_update_client_throws_when_name_missing(): void
{
$editCalls = 0;
$repo = $this->make(ClientRepository::class, [
'editClient' => function () use (&$editCalls) {
$editCalls++;
return true;
},
]);
$this->expectException(MissingParameterException::class);
try {
$this->makeService(clientRepo: $repo)->updateClient(['id' => 5, 'name' => '']);
} finally {
$this->assertSame(0, $editCalls, 'An invalid update must never reach the repository');
}
}
public function test_update_client_persists_valid_values(): void
{
$captured = null;
$repo = $this->make(ClientRepository::class, [
'editClient' => function ($values, $id) use (&$captured) {
$captured = ['values' => $values, 'id' => $id];
return true;
},
]);
$result = $this->makeService(clientRepo: $repo)->updateClient(['id' => 5, 'name' => 'Acme']);
$this->assertTrue($result);
$this->assertSame(5, $captured['id']);
$this->assertSame('Acme', $captured['values']['name']);
}
public function test_remove_user_returns_false_for_missing_ids(): void
{
$removeCalls = 0;
$userRepo = $this->make(UserRepository::class, [
'removeFromClient' => function () use (&$removeCalls) {
$removeCalls++;
return true;
},
]);
$service = $this->makeService(userRepo: $userRepo);
$this->assertFalse($service->removeUser(0, 5));
$this->assertFalse($service->removeUser(5, 0));
$this->assertSame(0, $removeCalls, 'Guarded calls must not hit the repository');
}
public function test_remove_user_delegates_to_user_repository(): void
{
$removedUserId = null;
$userRepo = $this->make(UserRepository::class, [
'removeFromClient' => function ($userId) use (&$removedUserId) {
$removedUserId = $userId;
return true;
},
]);
$result = $this->makeService(userRepo: $userRepo)->removeUser(3, 7);
$this->assertTrue($result);
$this->assertSame(7, $removedUserId);
}
}

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

View File

@@ -0,0 +1,182 @@
<?php
namespace Unit\app\Domain\Connector\Services;
use Leantime\Domain\Connector\Models\Integration as IntegrationModel;
use Leantime\Domain\Connector\Repositories\Integrations as IntegrationsRepo;
use Leantime\Domain\Connector\Repositories\LeantimeEntities;
use Leantime\Domain\Connector\Services\Integrations;
use Unit\TestCase;
/**
* Unit tests for the integration-wizard orchestration extracted from the
* Connector\Integration controller into the Integrations service.
*/
class IntegrationsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds the service with a real (DB-free) LeantimeEntities repo and a
* stubbed integration repository.
*/
private function makeService(array $repoOverrides = []): Integrations
{
return new Integrations(
$this->make(IntegrationsRepo::class, $repoOverrides),
new LeantimeEntities,
);
}
public function test_get_entity_fields_returns_field_map_for_known_entity(): void
{
$fields = $this->makeService()->getEntityFields('tickets');
$this->assertArrayHasKey('headline', $fields);
$this->assertSame('Title', $fields['headline']['name']);
}
public function test_get_entity_fields_returns_empty_array_for_unknown_entity(): void
{
$this->assertSame([], $this->makeService()->getEntityFields('does-not-exist'));
}
public function test_get_available_entities_includes_core_entities(): void
{
$entities = $this->makeService()->getAvailableEntities();
$this->assertArrayHasKey('tickets', $entities);
$this->assertArrayHasKey('projects', $entities);
$this->assertArrayHasKey('users', $entities);
}
public function test_resolve_provider_fields_uses_stored_fields_when_present(): void
{
$integration = new IntegrationModel;
$integration->fields = 'colA,colB,colC';
$provider = new class
{
public function getFields(): array
{
return ['providerOnly'];
}
};
$result = $this->makeService()->resolveProviderFields($integration, $provider);
$this->assertSame(['colA', 'colB', 'colC'], $result);
}
public function test_resolve_provider_fields_falls_back_to_provider(): void
{
$integration = new IntegrationModel;
$integration->fields = '';
$provider = new class
{
public function getFields(): array
{
return ['fieldFromProvider'];
}
};
$result = $this->makeService()->resolveProviderFields($integration, $provider);
$this->assertSame(['fieldFromProvider'], $result);
}
public function test_resolve_import_entity_uses_request_value_and_persists(): void
{
session()->forget('currentImportEntity');
$patchCalls = [];
$service = $this->makeService([
'patch' => function ($id, $params) use (&$patchCalls) {
$patchCalls[] = [$id, $params];
return true;
},
]);
$integration = new IntegrationModel;
$integration->id = 42;
$entity = $service->resolveImportEntity(['leantimeEntities' => 'tickets'], $integration);
$this->assertSame('tickets', $entity);
$this->assertSame('tickets', $integration->entity);
$this->assertSame('tickets', session('currentImportEntity'));
$this->assertSame([[42, ['entity' => 'tickets']]], $patchCalls);
}
public function test_resolve_import_entity_falls_back_to_session(): void
{
session(['currentImportEntity' => 'projects']);
$patchCalls = [];
$service = $this->makeService([
'patch' => function ($id, $params) use (&$patchCalls) {
$patchCalls[] = [$id, $params];
return true;
},
]);
$integration = new IntegrationModel;
$integration->id = 7;
$entity = $service->resolveImportEntity([], $integration);
$this->assertSame('projects', $entity);
$this->assertSame('projects', $integration->entity);
$this->assertSame([[7, ['entity' => 'projects']]], $patchCalls);
}
public function test_resolve_import_entity_returns_null_when_unresolvable(): void
{
session(['currentImportEntity' => '']);
$patched = false;
$service = $this->makeService([
'patch' => function () use (&$patched) {
$patched = true;
return true;
},
]);
$integration = new IntegrationModel;
$integration->id = 1;
$entity = $service->resolveImportEntity([], $integration);
$this->assertNull($entity);
$this->assertFalse($patched, 'No record should be patched when the entity cannot be resolved');
}
public function test_get_cached_import_payload_decodes_session_serialized_data(): void
{
$fields = [['sourceField' => 'a', 'leantimeField' => 'headline']];
$values = [['a' => 'Hello']];
session(['serFields' => serialize($fields)]);
session(['serValues' => serialize($values)]);
$payload = $this->makeService()->getCachedImportPayload();
$this->assertSame($fields, $payload['fields']);
$this->assertSame($values, $payload['values']);
}
public function test_get_cached_import_payload_defaults_to_empty_arrays(): void
{
session()->forget('serFields');
session()->forget('serValues');
$payload = $this->makeService()->getCachedImportPayload();
$this->assertSame([], $payload['fields']);
$this->assertSame([], $payload['values']);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Unit\app\Domain\ContentTemplates\Models;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
use Unit\TestCase;
/**
* Unit tests for the ContentTemplate value object.
*
* Two responsibilities:
* - From a parsed YAML array, pull metadata fields and the appliesTo-keyed payload.
* - Mark itself as "usable" only when key, appliesTo, and a non-empty payload are present.
*/
class ContentTemplateTest extends TestCase
{
public function test_from_array_extracts_canvas_payload_under_applies_to_key(): void
{
$tpl = ContentTemplate::fromArray([
'key' => 'education-k12',
'title' => 'K-12 Education Program',
'description' => 'After-school tutoring.',
'appliesTo' => 'logicmodel',
'sector' => 'education',
'icon' => 'fa-graduation-cap',
'author' => 'Leantime',
'version' => '1.0.0',
'license' => 'CC0',
'logicmodel' => [
'items' => [
['box' => 'lm_inputs', 'title' => 'Funding', 'description' => 'Annual grants.'],
],
],
]);
$this->assertSame('education-k12', $tpl->key);
$this->assertSame('K-12 Education Program', $tpl->title);
$this->assertSame('logicmodel', $tpl->appliesTo);
$this->assertSame('education', $tpl->sector);
$this->assertSame('fa-graduation-cap', $tpl->icon);
$this->assertSame('Leantime', $tpl->author);
$this->assertSame('1.0.0', $tpl->version);
$this->assertSame('CC0', $tpl->license);
$this->assertCount(1, $tpl->payload['items']);
$this->assertTrue($tpl->isUsable());
}
public function test_from_array_extracts_wiki_payload_under_applies_to_key(): void
{
$tpl = ContentTemplate::fromArray([
'key' => 'meeting-notes',
'title' => 'Meeting Notes',
'description' => 'Standard meeting template.',
'appliesTo' => 'wiki',
'wiki' => [
'articles' => [
['title' => 'Notes', 'content' => '<h1>Hi</h1>'],
],
],
]);
$this->assertSame('wiki', $tpl->appliesTo);
$this->assertCount(1, $tpl->payload['articles']);
$this->assertTrue($tpl->isUsable());
}
public function test_optional_fields_default_to_null_when_missing(): void
{
$tpl = ContentTemplate::fromArray([
'key' => 'x',
'title' => 'X',
'description' => '',
'appliesTo' => 'logicmodel',
'logicmodel' => ['items' => [['box' => 'a']]],
]);
$this->assertNull($tpl->sector);
$this->assertNull($tpl->icon);
$this->assertNull($tpl->author);
$this->assertNull($tpl->version);
$this->assertNull($tpl->license);
}
public function test_is_usable_returns_false_for_missing_key_or_applies_to_or_empty_payload(): void
{
$missingKey = ContentTemplate::fromArray([
'title' => 'X',
'description' => '',
'appliesTo' => 'logicmodel',
'logicmodel' => ['items' => [['box' => 'a']]],
]);
$missingAppliesTo = ContentTemplate::fromArray([
'key' => 'x',
'title' => 'X',
'description' => '',
]);
$emptyPayload = ContentTemplate::fromArray([
'key' => 'x',
'title' => 'X',
'description' => '',
'appliesTo' => 'logicmodel',
]);
$this->assertFalse($missingKey->isUsable());
$this->assertFalse($missingAppliesTo->isUsable());
$this->assertFalse($emptyPayload->isUsable());
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace Unit\app\Domain\ContentTemplates\Services\Appliers;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
use Leantime\Domain\ContentTemplates\Services\Appliers\CanvasItemsApplier;
use Leantime\Domain\ContentTemplates\Services\Appliers\WikiApplier;
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
use Unit\TestCase;
/**
* Unit tests for the appliers' supports() routing logic and early-return
* safety. Actual DB writes are exercised in integration tests once Phase 2
* wires real templates; here we lock in the routing contract.
*/
class AppliersTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
public function test_wiki_applier_supports_only_wiki(): void
{
$applier = new WikiApplier($this->makeDbCore());
$this->assertTrue($applier->supports('wiki'));
$this->assertFalse($applier->supports('logicmodel'));
$this->assertFalse($applier->supports('goal'));
$this->assertFalse($applier->supports(''));
}
public function test_canvas_applier_supports_any_non_wiki_non_empty_applies_to(): void
{
$applier = new CanvasItemsApplier($this->makeDbCore());
$this->assertTrue($applier->supports('logicmodel'));
$this->assertTrue($applier->supports('goal'));
$this->assertTrue($applier->supports('leancanvas'));
$this->assertTrue($applier->supports('swot'));
$this->assertTrue($applier->supports('any-future-canvas-type'));
$this->assertFalse($applier->supports('wiki'));
$this->assertFalse($applier->supports(''));
}
public function test_canvas_applier_returns_zero_for_invalid_target_id(): void
{
$applier = new CanvasItemsApplier($this->makeDbCore());
$this->assertSame(0, $applier->apply(0, $this->makeCanvasTemplate()));
$this->assertSame(0, $applier->apply(-1, $this->makeCanvasTemplate()));
}
public function test_canvas_applier_returns_zero_for_unusable_template(): void
{
$applier = new CanvasItemsApplier($this->makeDbCore());
$unusable = ContentTemplate::fromArray([
'key' => '',
'title' => 'X',
'description' => '',
'appliesTo' => 'logicmodel',
]);
$this->assertSame(0, $applier->apply(42, $unusable));
}
public function test_canvas_applier_returns_zero_for_empty_items_payload(): void
{
$applier = new CanvasItemsApplier($this->makeDbCore());
$emptyItems = ContentTemplate::fromArray([
'key' => 'empty',
'title' => 'Empty',
'description' => '',
'appliesTo' => 'logicmodel',
'logicmodel' => ['items' => []],
]);
$this->assertSame(0, $applier->apply(42, $emptyItems));
}
public function test_wiki_applier_returns_zero_for_invalid_target_id(): void
{
$applier = new WikiApplier($this->makeDbCore());
$this->assertSame(0, $applier->apply(0, $this->makeWikiTemplate()));
}
public function test_wiki_applier_returns_zero_for_empty_articles_payload(): void
{
$applier = new WikiApplier($this->makeDbCore());
$emptyArticles = ContentTemplate::fromArray([
'key' => 'empty',
'title' => 'Empty',
'description' => '',
'appliesTo' => 'wiki',
'wiki' => ['articles' => []],
]);
$this->assertSame(0, $applier->apply(42, $emptyArticles));
}
public function test_registry_applier_for_falls_back_to_supports_when_no_explicit_binding(): void
{
$registry = new ContentTemplateRegistry;
$canvas = new CanvasItemsApplier($this->makeDbCore());
$wiki = new WikiApplier($this->makeDbCore());
// Bind WikiApplier on 'wiki' and CanvasItemsApplier on 'logicmodel'.
// Ask the registry for 'cp' (a canvas type that nobody explicitly
// bound). The fallback should find CanvasItemsApplier via supports().
$registry->registerApplier('wiki', $wiki);
$registry->registerApplier('logicmodel', $canvas);
$this->assertSame($canvas, $registry->applierFor('cp'));
$this->assertSame($canvas, $registry->applierFor('swot'));
$this->assertSame($wiki, $registry->applierFor('wiki'));
$this->assertSame($canvas, $registry->applierFor('logicmodel'));
}
public function test_registry_applier_for_returns_null_when_no_applier_supports_type(): void
{
$registry = new ContentTemplateRegistry;
$registry->registerApplier('wiki', new WikiApplier($this->makeDbCore()));
// 'logicmodel' isn't bound and WikiApplier doesn't support it.
$this->assertNull($registry->applierFor('logicmodel'));
}
private function makeDbCore(): DbCore
{
// The supports() / early-return paths never call the connection, so a
// bare stub is enough. Methods that DO write are exercised in
// integration tests (Phase 2+).
return $this->make(DbCore::class);
}
private function makeCanvasTemplate(): ContentTemplate
{
return ContentTemplate::fromArray([
'key' => 'k',
'title' => 'T',
'description' => '',
'appliesTo' => 'logicmodel',
'logicmodel' => [
'items' => [
['box' => 'lm_inputs', 'title' => 'A', 'description' => 'aa'],
],
],
]);
}
private function makeWikiTemplate(): ContentTemplate
{
return ContentTemplate::fromArray([
'key' => 'k',
'title' => 'T',
'description' => '',
'appliesTo' => 'wiki',
'wiki' => [
'articles' => [
['title' => 'A', 'content' => '<p>aa</p>'],
],
],
]);
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace Unit\app\Domain\ContentTemplates\Services;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
use Unit\TestCase;
/**
* Unit tests for ContentTemplateRegistry.
*
* Sets up a tmp library root containing one logicmodel template and one wiki
* template, then exercises load / forAppliesTo / get / overrides.
*/
class ContentTemplateRegistryTest extends TestCase
{
private string $tmpRoot;
protected function setUp(): void
{
parent::setUp();
$this->tmpRoot = sys_get_temp_dir().'/ct-registry-test-'.uniqid();
mkdir($this->tmpRoot.'/logicmodel', 0o777, true);
mkdir($this->tmpRoot.'/wiki', 0o777, true);
file_put_contents($this->tmpRoot.'/logicmodel/sample.yaml', <<<'YAML'
key: "sample"
title: "Sample LM"
description: "Test fixture."
appliesTo: "logicmodel"
sector: "test"
logicmodel:
items:
- box: "lm_inputs"
title: "Item"
description: "Desc"
YAML);
file_put_contents($this->tmpRoot.'/wiki/notes.yaml', <<<'YAML'
key: "notes"
title: "Notes"
description: "Wiki test."
appliesTo: "wiki"
wiki:
articles:
- title: "Hello"
content: "<p>Hi</p>"
YAML);
}
protected function tearDown(): void
{
$this->rmrf($this->tmpRoot);
parent::tearDown();
}
public function test_for_applies_to_returns_templates_under_that_bucket(): void
{
$registry = new ContentTemplateRegistry;
$registry->registerLibraryRoot($this->tmpRoot);
$lm = $registry->forAppliesTo('logicmodel');
$wiki = $registry->forAppliesTo('wiki');
// The registry constructor auto-registers the core library root, so
// built-in templates show up alongside the tmp ones. Pin contract on
// "tmp fixtures are present and well-shaped" rather than exact count.
$this->assertArrayHasKey('sample', $lm);
$this->assertInstanceOf(ContentTemplate::class, $lm['sample']);
$this->assertSame('logicmodel', $lm['sample']->appliesTo);
$this->assertArrayHasKey('notes', $wiki);
$this->assertSame('wiki', $wiki['notes']->appliesTo);
}
public function test_get_returns_single_template_by_applies_to_and_key(): void
{
$registry = new ContentTemplateRegistry;
$registry->registerLibraryRoot($this->tmpRoot);
$tpl = $registry->get('logicmodel', 'sample');
$this->assertNotNull($tpl);
$this->assertSame('Sample LM', $tpl->title);
$this->assertSame('test', $tpl->sector);
}
public function test_get_returns_null_for_unknown_template(): void
{
$registry = new ContentTemplateRegistry;
$registry->registerLibraryRoot($this->tmpRoot);
$this->assertNull($registry->get('logicmodel', 'does-not-exist'));
$this->assertNull($registry->get('unknown-applies-to', 'sample'));
}
public function test_directory_name_overrides_applies_to_in_yaml(): void
{
// YAML claims appliesTo=wiki but the file is in the logicmodel
// directory. The registry should rewrite the appliesTo to match the
// directory, so a typo in the YAML can't pollute the wrong bucket.
file_put_contents($this->tmpRoot.'/logicmodel/lies.yaml', <<<'YAML'
key: "lies"
title: "Liar"
description: "Wrong appliesTo claim."
appliesTo: "wiki"
wiki:
articles:
- title: "Bait"
content: ""
logicmodel:
items:
- box: "lm_inputs"
title: "Item"
YAML);
$registry = new ContentTemplateRegistry;
$registry->registerLibraryRoot($this->tmpRoot);
$byLm = $registry->get('logicmodel', 'lies');
$this->assertNotNull($byLm);
$this->assertSame('logicmodel', $byLm->appliesTo);
$this->assertNull($registry->get('wiki', 'lies'));
}
public function test_later_library_root_overrides_earlier_on_collision(): void
{
$secondRoot = sys_get_temp_dir().'/ct-registry-test-second-'.uniqid();
mkdir($secondRoot.'/logicmodel', 0o777, true);
file_put_contents($secondRoot.'/logicmodel/sample.yaml', <<<'YAML'
key: "sample"
title: "Override Title"
description: "From second root."
appliesTo: "logicmodel"
logicmodel:
items:
- box: "lm_outputs"
title: "Override Item"
YAML);
try {
$registry = new ContentTemplateRegistry;
$registry->registerLibraryRoot($this->tmpRoot);
$registry->registerLibraryRoot($secondRoot);
$tpl = $registry->get('logicmodel', 'sample');
$this->assertNotNull($tpl);
$this->assertSame('Override Title', $tpl->title);
} finally {
$this->rmrf($secondRoot);
}
}
public function test_register_library_root_is_idempotent(): void
{
$registry = new ContentTemplateRegistry;
$registry->registerLibraryRoot($this->tmpRoot);
$registry->registerLibraryRoot($this->tmpRoot);
$registry->registerLibraryRoot($this->tmpRoot.'/');
$lm = $registry->forAppliesTo('logicmodel');
$this->assertCount(1, $lm);
}
public function test_invalid_yaml_is_skipped_without_crashing(): void
{
file_put_contents($this->tmpRoot.'/logicmodel/broken.yaml', "key: [unterminated\n");
$registry = new ContentTemplateRegistry;
$registry->registerLibraryRoot($this->tmpRoot);
$lm = $registry->forAppliesTo('logicmodel');
$this->assertCount(1, $lm);
$this->assertArrayHasKey('sample', $lm);
$this->assertArrayNotHasKey('broken', $lm);
}
private function rmrf(string $dir): void
{
if (! is_dir($dir)) {
return;
}
foreach (scandir($dir) as $f) {
if ($f === '.' || $f === '..') {
continue;
}
$path = $dir.'/'.$f;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Unit\app\Domain\ContentTemplates\Support;
use Leantime\Core\Language;
use Leantime\Domain\ContentTemplates\Support\TranslationResolver;
use Unit\TestCase;
/**
* Unit tests for TranslationResolver — the small helper that lets YAML
* content templates carry t:KEY translation references through to
* consumers without each consumer learning the convention.
*/
class TranslationResolverTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected function setUp(): void
{
parent::setUp();
// __() routes through the bound Language. Stub it to a deterministic
// prefix so the test doesn't depend on real locale files being present.
$this->app->instance(Language::class, $this->make(Language::class, [
'__' => fn (string $key): string => 'T:'.$key,
]));
}
public function test_passes_through_strings_without_t_references_untouched(): void
{
$this->assertSame('plain string', TranslationResolver::resolve('plain string'));
$this->assertSame('', TranslationResolver::resolve(''));
$this->assertSame('<h1>Hello</h1>', TranslationResolver::resolve('<h1>Hello</h1>'));
}
public function test_whole_string_t_prefix_resolves_via_translator(): void
{
$this->assertSame('T:templates.prd.title', TranslationResolver::resolve('t:templates.prd.title'));
$this->assertSame('T:status.draft', TranslationResolver::resolve('t:status.draft'));
}
public function test_substring_t_substitution_replaces_each_occurrence_in_place(): void
{
$resolved = TranslationResolver::resolve('<h1>{{ t:templates.prd.title }}</h1>');
$this->assertSame('<h1>T:templates.prd.title</h1>', $resolved);
// Multiple substitutions in one string, with various whitespace inside braces.
$resolved = TranslationResolver::resolve('{{t:templates.author}} Gloria — {{ t:templates.dates }} 2026');
$this->assertSame('T:templates.author Gloria — T:templates.dates 2026', $resolved);
}
public function test_resolve_array_walks_recursively_and_resolves_strings_only(): void
{
$resolved = TranslationResolver::resolveArray([
'title' => 't:templates.prd.title',
'description' => '{{ t:templates.prd.description }} (extra)',
'nested' => [
'content' => '<p>{{ t:templates.author }}</p>',
'count' => 7, // non-strings pass through
'flag' => true,
],
'plain' => 'no references here',
]);
$this->assertSame([
'title' => 'T:templates.prd.title',
'description' => 'T:templates.prd.description (extra)',
'nested' => [
'content' => '<p>T:templates.author</p>',
'count' => 7,
'flag' => true,
],
'plain' => 'no references here',
], $resolved);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Unit\app\Domain\ContentTemplates;
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
use Unit\TestCase;
/**
* Integration check for Phase 3 of the content-templates rollout: wiki
* YAML templates dropped into Library/wiki/ are discoverable via the
* registry and carry the article shape the legacy template list expects.
*
* Not a controller test — that lives in Acceptance. This pins the data
* contract between the YAML on disk and the consumer.
*/
class WikiTemplatesDiscoveryTest extends TestCase
{
public function test_built_in_wiki_templates_are_discoverable_via_registry(): void
{
$registry = new ContentTemplateRegistry;
$wikiTemplates = $registry->forAppliesTo('wiki');
// Phase 3 ships at least the two demo templates (decision-record,
// weekly-status). Asserting on count keeps this honest if either gets
// removed.
$this->assertGreaterThanOrEqual(2, count($wikiTemplates));
$this->assertArrayHasKey('decision-record', $wikiTemplates);
$this->assertArrayHasKey('weekly-status', $wikiTemplates);
}
public function test_built_in_wiki_template_has_single_article_with_html_content(): void
{
$registry = new ContentTemplateRegistry;
$tpl = $registry->get('wiki', 'decision-record');
$this->assertNotNull($tpl);
$this->assertSame('wiki', $tpl->appliesTo);
$articles = $tpl->payload['articles'] ?? [];
$this->assertNotEmpty($articles);
$this->assertIsArray($articles[0]);
// The wiki Templates partial maps payload.articles[0].content into the
// legacy Template->content field. HTML body, not markdown.
$this->assertNotEmpty($articles[0]['content'] ?? '');
$this->assertStringContainsString('<h1>', $articles[0]['content']);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Unit\app\Domain\CsvImport\Services;
use Leantime\Domain\Connector\Models\Integration;
use Leantime\Domain\Connector\Services\Integrations;
use Leantime\Domain\CsvImport\Services\CsvImport;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Unit\TestCase;
/**
* Unit tests for the CSV upload processing extracted from the
* CsvImport Upload controller into the CsvImport service.
*/
class CsvImportTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private ?string $tmpFile = null;
protected function _after(): void
{
if ($this->tmpFile !== null && file_exists($this->tmpFile)) {
unlink($this->tmpFile);
}
$this->tmpFile = null;
}
/**
* Write the given CSV content to a temp file and wrap it in an UploadedFile.
*/
private function makeCsvUpload(string $content): UploadedFile
{
$this->tmpFile = tempnam(sys_get_temp_dir(), 'csvimport_test_');
file_put_contents($this->tmpFile, $content);
return new UploadedFile($this->tmpFile, 'import.csv', 'text/csv', null, true);
}
/**
* Build the service with a mocked Integrations dependency.
*/
private function makeService(Integrations $integrationService): CsvImport
{
return new CsvImport($integrationService);
}
public function test_process_upload_stores_records_and_builds_integration_from_header(): void
{
session()->forget('csv_records');
$captured = null;
$integrationService = $this->make(Integrations::class, [
'create' => function (object|array $object) use (&$captured) {
$captured = $object;
return 77;
},
]);
$csv = "name,email,role\nAlice,alice@example.com,admin\nBob,bob@example.com,editor\n";
$file = $this->makeCsvUpload($csv);
$id = $this->makeService($integrationService)->processUpload($file);
// Returns the integration id from the service.
$this->assertSame(77, $id);
// Integration model is built from the comma-joined header row.
$this->assertInstanceOf(Integration::class, $captured);
$this->assertSame('name,email,role', $captured->fields);
// All data rows (excluding the header) are materialized into the session.
$records = session('csv_records');
$this->assertCount(2, $records);
$this->assertSame('Alice', $records[0]['name']);
$this->assertSame('alice@example.com', $records[0]['email']);
$this->assertSame('Bob', $records[1]['name']);
$this->assertSame('editor', $records[1]['role']);
}
public function test_process_upload_persists_all_rows_not_an_exhausted_iterator(): void
{
// Regression test for the latent iterator-exhaustion bug: the session
// must contain the actual rows, not an empty set.
session()->forget('csv_records');
$integrationService = $this->make(Integrations::class, [
'create' => fn () => 1,
]);
$csv = "col\nfirst\nsecond\nthird\n";
$file = $this->makeCsvUpload($csv);
$this->makeService($integrationService)->processUpload($file);
$records = session('csv_records');
$this->assertCount(3, $records);
$this->assertSame('first', $records[0]['col']);
$this->assertSame('third', $records[2]['col']);
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Unit\app\Domain\Dashboard\Services;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Dashboard\Services\Dashboard;
use Leantime\Domain\Reactions\Services\Reactions as ReactionService;
use Unit\TestCase;
/**
* Unit tests for the Dashboard service logic extracted from the
* Dashboard\Show controller.
*/
class DashboardServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private function makeService(
?CommentService $commentService = null,
?CommentRepository $commentRepository = null,
?ReactionService $reactionsService = null
): Dashboard {
return new Dashboard(
$commentService ?? $this->make(CommentService::class),
$commentRepository ?? $this->make(CommentRepository::class),
$reactionsService ?? $this->make(ReactionService::class),
);
}
public function test_get_project_comments_attaches_replies_per_comment(): void
{
$commentService = $this->make(CommentService::class, [
'getComments' => fn () => [
['id' => 1, 'text' => 'first'],
['id' => 2, 'text' => 'second'],
],
]);
$commentRepository = $this->make(CommentRepository::class, [
'getReplies' => fn ($id) => [['id' => 100 + $id, 'commentParent' => $id]],
]);
$result = $this->makeService($commentService, $commentRepository)
->getProjectCommentsWithReplies(7);
$this->assertCount(2, $result);
$this->assertSame([['id' => 101, 'commentParent' => 1]], $result[0]['replies']);
$this->assertSame([['id' => 102, 'commentParent' => 2]], $result[1]['replies']);
}
public function test_get_project_comments_returns_empty_when_no_comments(): void
{
$commentService = $this->make(CommentService::class, [
'getComments' => fn () => false,
]);
$result = $this->makeService($commentService)->getProjectCommentsWithReplies(7);
$this->assertSame([], $result);
}
public function test_get_project_comments_rejects_invalid_project_id(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->makeService()->getProjectCommentsWithReplies(0);
}
public function test_count_project_comments_casts_to_int(): void
{
$commentRepository = $this->make(CommentRepository::class, [
'countComments' => fn () => '5',
]);
$result = $this->makeService(null, $commentRepository)->countProjectComments(3);
$this->assertSame(5, $result);
}
public function test_count_project_comments_rejects_invalid_project_id(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->makeService()->countProjectComments(-1);
}
public function test_user_has_favorited_project_true_when_reactions_present(): void
{
$reactionsService = $this->make(ReactionService::class, [
'getUserReactions' => fn () => [['reaction' => 'favorite']],
]);
$result = $this->makeService(null, null, $reactionsService)
->userHasFavoritedProject(42, 9);
$this->assertTrue($result);
}
public function test_user_has_favorited_project_false_when_empty(): void
{
$reactionsService = $this->make(ReactionService::class, [
'getUserReactions' => fn () => [],
]);
$result = $this->makeService(null, null, $reactionsService)
->userHasFavoritedProject(42, 9);
$this->assertFalse($result);
}
public function test_user_has_favorited_project_false_when_repo_returns_false(): void
{
$reactionsService = $this->make(ReactionService::class, [
'getUserReactions' => fn () => false,
]);
$result = $this->makeService(null, null, $reactionsService)
->userHasFavoritedProject(42, 9);
$this->assertFalse($result);
}
}

View File

@@ -0,0 +1,524 @@
<?php
namespace Unit\app\Domain\Files\Services;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Files\FileManager;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Files\Repositories\Files as FileRepository;
use Leantime\Domain\Files\Services\Files;
use Symfony\Component\HttpFoundation\Response;
use Unit\TestCase;
/**
* Unit tests for the Files service: the pure helpers extracted during the thin-controller refactor
* (getImageExtensions, isOwnerRestrictedModule, handleFileAction) plus the authorization the native
* permission engine added. The authz tests prove the four IDOR-prone @api methods fail closed:
* - getFilesByModule resolves the target's project and denies non-members (no enumeration)
* - upload authorizes against the target project (commenter+) on the JSON-RPC path too
* - deleteFile preserves owner-delete but scopes the non-owner path to the file's project (editor+),
* closing the old manager-global cross-project bypass
* - getFileForUser authorizes the SESSION user, never the spoofable $userId argument
*/
class FilesServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected function setUp(): void
{
parent::setUp();
// The current (session) user the service authorizes as.
session(['userdata.id' => 1]);
}
/** Permission stub that grants everything. */
private function allowingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'currentUserCan' => fn () => true,
'authorize' => fn () => null,
]);
}
/** Permission stub that denies everything (authorize throws, currentUserCan is false). */
private function denyingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'currentUserCan' => fn () => false,
'authorize' => function (): void {
throw new AuthorizationException;
},
]);
}
private function makeService(
?FileRepository $repo = null,
?FileManager $fileManager = null,
?PermissionService $perms = null,
): Files {
$service = new Files(
$repo ?? $this->make(FileRepository::class),
$fileManager ?? $this->make(FileManager::class),
$this->make(LanguageCore::class),
);
$service->setPermissionService($perms ?? $this->allowingPermissions());
return $service;
}
// ---- pure helpers -----------------------------------------------------
public function test_get_image_extensions_returns_the_shared_whitelist(): void
{
/** @var Files $service */
$service = $this->make(Files::class);
$extensions = $service->getImageExtensions();
$this->assertContains('jpg', $extensions);
$this->assertContains('webp', $extensions);
$this->assertSame(
['jpg', 'jpeg', 'png', 'gif', 'psd', 'bmp', 'tif', 'thm', 'yuv', 'webp'],
$extensions
);
}
public function test_is_owner_restricted_module_flags_private_modules(): void
{
/** @var Files $service */
$service = $this->make(Files::class);
$this->assertTrue($service->isOwnerRestrictedModule(['module' => 'private']));
$this->assertTrue($service->isOwnerRestrictedModule(['module' => 'user']));
$this->assertTrue($service->isOwnerRestrictedModule(['module' => 'lead']));
$this->assertTrue($service->isOwnerRestrictedModule(['module' => 'export']));
}
public function test_is_owner_restricted_module_allows_shared_modules(): void
{
/** @var Files $service */
$service = $this->make(Files::class);
$this->assertFalse($service->isOwnerRestrictedModule(['module' => 'project']));
$this->assertFalse($service->isOwnerRestrictedModule(['module' => 'ticket']));
$this->assertFalse($service->isOwnerRestrictedModule(['module' => 'client']));
$this->assertFalse($service->isOwnerRestrictedModule([]));
}
// ---- handleFileAction (controller helper; delegates self-authorize) ---
public function test_handle_file_action_deletes_when_del_file_present(): void
{
$captured = null;
/** @var Files $service */
$service = $this->make(Files::class, [
'deleteFile' => function ($fileId) use (&$captured) {
$captured = $fileId;
return true;
},
]);
$result = $service->handleFileAction(['delFile' => '42'], [], 'project', 7);
$this->assertSame('delete', $result['action']);
$this->assertTrue($result['success']);
$this->assertSame('42', $captured);
}
public function test_handle_file_action_reports_failed_delete(): void
{
/** @var Files $service */
$service = $this->make(Files::class, [
'deleteFile' => fn () => false,
]);
$result = $service->handleFileAction(['delFile' => '42'], [], 'project', 7);
$this->assertSame('delete', $result['action']);
$this->assertFalse($result['success']);
}
public function test_handle_file_action_uploads_when_file_present(): void
{
$uploadArgs = null;
/** @var Files $service */
$service = $this->make(Files::class, [
'upload' => function ($files, $module, $moduleId) use (&$uploadArgs) {
$uploadArgs = [$files, $module, $moduleId];
return ['fileId' => 99];
},
]);
$files = ['file' => ['name' => 'a.png']];
$result = $service->handleFileAction(['upload' => '1'], $files, 'project', 7);
$this->assertSame('upload', $result['action']);
$this->assertTrue($result['success']);
$this->assertSame([$files, 'project', 7], $uploadArgs);
}
public function test_handle_file_action_reports_upload_without_file(): void
{
/** @var Files $service */
$service = $this->make(Files::class, [
'upload' => fn () => $this->fail('upload should not be called when no file is present'),
]);
$result = $service->handleFileAction(['upload' => '1'], [], 'project', 7);
$this->assertSame('upload', $result['action']);
$this->assertFalse($result['success']);
}
public function test_handle_file_action_returns_null_action_for_empty_payload(): void
{
/** @var Files $service */
$service = $this->make(Files::class);
$result = $service->handleFileAction([], [], 'project', 7);
$this->assertNull($result['action']);
$this->assertFalse($result['success']);
}
// ---- getFilesByModule -------------------------------------------------
public function test_get_files_by_module_denies_non_member(): void
{
$repo = $this->make(FileRepository::class, [
'getFilesByModule' => fn () => $this->fail('Repository must not be queried when files.view is denied'),
]);
$service = $this->makeService($repo, null, $this->denyingPermissions());
// module=project → projectId resolves to moduleId (5) directly; can(VIEW,5)=false → soft-deny.
$this->assertSame([], $service->getFilesByModule('project', 5));
}
public function test_get_files_by_module_allows_member(): void
{
$repo = $this->make(FileRepository::class, [
'getFilesByModule' => fn () => [['id' => 99, 'module' => 'project', 'moduleId' => 5]],
]);
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertCount(1, $service->getFilesByModule('project', 5));
}
public function test_get_files_by_module_empty_module_returns_empty_without_dumping(): void
{
$repo = $this->make(FileRepository::class, [
'getFilesByModule' => fn () => $this->fail('An empty module must never dump the file table'),
]);
// Even with allow-all permissions, an empty module has no project context and must refuse.
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertSame([], $service->getFilesByModule(''));
}
public function test_get_files_by_module_owner_restricted_denies_other_user(): void
{
$repo = $this->make(FileRepository::class, [
'getFilesByModule' => fn () => $this->fail('Owner-restricted listing must not return another user\'s files'),
]);
// module=user, entityId=2 (not the session user 1) → soft-deny regardless of role.
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertSame([], $service->getFilesByModule('user', 2));
}
public function test_get_files_by_module_owner_restricted_allows_owner(): void
{
$repo = $this->make(FileRepository::class, [
'getFilesByModule' => fn () => [['id' => 7, 'module' => 'user', 'moduleId' => 1]],
]);
// module=user, entityId=1 == session user → allowed.
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertCount(1, $service->getFilesByModule('user', 1));
}
public function test_get_files_by_module_client_without_id_returns_empty(): void
{
$repo = $this->make(FileRepository::class, [
'getFilesByModule' => fn () => $this->fail('client listing with no id must not dump every client\'s files'),
]);
// module=client has no project mapping; with no specific client id it must refuse rather
// than enumerate all client files (the legitimate ShowClient path always passes an id).
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertSame([], $service->getFilesByModule('client'));
}
public function test_get_files_by_module_client_with_id_passes_through(): void
{
$repo = $this->make(FileRepository::class, [
'getFilesByModule' => fn () => [['id' => 8, 'module' => 'client', 'moduleId' => 3]],
]);
// A specific client id is the legitimate ShowClient call; authz remains a Clients-domain
// follow-up, so it passes through.
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertCount(1, $service->getFilesByModule('client', 3));
}
// ---- deleteFile -------------------------------------------------------
public function test_delete_file_allows_owner_even_without_permission(): void
{
$deleted = false;
$repo = $this->make(FileRepository::class, [
'getFile' => fn () => ['id' => 10, 'userId' => 1, 'module' => 'project', 'moduleId' => 5],
'deleteFile' => function () use (&$deleted) {
$deleted = true;
return true;
},
]);
// Deny-all permissions: the owner path must still delete (file.userId === session user 1).
$service = $this->makeService($repo, null, $this->denyingPermissions());
$this->assertTrue($service->deleteFile(10));
$this->assertTrue($deleted, 'Owner deletion should reach the repository');
}
public function test_delete_file_denies_non_owner_without_permission(): void
{
$repo = $this->make(FileRepository::class, [
'getFile' => fn () => ['id' => 10, 'userId' => 2, 'module' => 'project', 'moduleId' => 5],
'deleteFile' => fn () => $this->fail('A non-owner without files.delete must not delete'),
]);
// File owned by user 2; session user is 1 without files.delete in project 5 → soft-deny.
$service = $this->makeService($repo, null, $this->denyingPermissions());
$this->assertFalse($service->deleteFile(10));
}
public function test_delete_file_allows_non_owner_with_project_permission(): void
{
$deleted = false;
$repo = $this->make(FileRepository::class, [
'getFile' => fn () => ['id' => 10, 'userId' => 2, 'module' => 'project', 'moduleId' => 5],
'deleteFile' => function () use (&$deleted) {
$deleted = true;
return true;
},
]);
// Non-owner, but allow-all grants files.delete in the file's project (editor+).
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertTrue($service->deleteFile(10));
$this->assertTrue($deleted);
}
public function test_delete_file_missing_returns_false(): void
{
$repo = $this->make(FileRepository::class, [
'getFile' => fn () => false,
]);
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertFalse($service->deleteFile(999));
}
public function test_delete_file_owner_restricted_non_owner_denied(): void
{
$repo = $this->make(FileRepository::class, [
'getFile' => fn () => ['id' => 11, 'userId' => 2, 'module' => 'user', 'moduleId' => 2],
'deleteFile' => fn () => $this->fail('A no-project file may only be deleted by its uploader'),
]);
// Owner-restricted (module=user → no project); session user 1 is not the owner (2) →
// deny even with allow-all (the old manager-global delete of others' files is dropped).
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertFalse($service->deleteFile(11));
}
// ---- upload -----------------------------------------------------------
public function test_upload_throws_when_project_upload_denied(): void
{
$service = $this->makeService(null, null, $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
// Passes the initial validation (module/moduleId present, file is an array) and reaches the
// project authorize() before any file is written → throws on deny.
$service->upload(['file' => []], 'project', 5);
}
public function test_upload_throws_for_project_scoped_module_with_unresolvable_project(): void
{
// A project-scoped module (ticket) whose project can't be resolved (invalid/deleted id)
// fails closed even with allow-all permissions — no orphan-file upload bypass.
$repo = $this->make(FileRepository::class, ['getProjectIdForFile' => fn () => null]);
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->expectException(AuthorizationException::class);
$service->upload(['file' => []], 'ticket', 999);
}
public function test_user_can_upload_to_module_denies_project_scoped_with_unresolvable_project(): void
{
$repo = $this->make(FileRepository::class, ['getProjectIdForFile' => fn () => null]);
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertFalse($service->userCanUploadToModule('ticket', 999));
}
public function test_user_can_upload_to_module_reflects_project_permission(): void
{
$this->assertTrue(
$this->makeService(null, null, $this->allowingPermissions())->userCanUploadToModule('project', 5)
);
$this->assertFalse(
$this->makeService(null, null, $this->denyingPermissions())->userCanUploadToModule('project', 5)
);
}
public function test_user_can_upload_to_module_nonproject_preserved(): void
{
// Non-project modules (user avatar, ...) have no project context; preserved as allowed even
// under deny-all (their flows pin moduleId server-side).
$service = $this->makeService(null, null, $this->denyingPermissions());
$this->assertTrue($service->userCanUploadToModule('user', 9));
}
// ---- getFileForUser ---------------------------------------------------
public function test_get_file_for_user_denies_non_member(): void
{
$repo = $this->make(FileRepository::class, [
'getFileByEncName' => fn () => [
'id' => 12, 'realName' => 'doc.pdf', 'extension' => 'pdf',
'module' => 'project', 'moduleId' => 5, 'userId' => 2,
],
]);
$fileManager = $this->make(FileManager::class, [
'getFile' => fn () => $this->fail('A non-member must not receive file bytes'),
]);
$service = $this->makeService($repo, $fileManager, $this->denyingPermissions());
$response = $service->getFileForUser('abc123', 1);
$this->assertSame(403, $response->getStatusCode());
}
public function test_get_file_for_user_allows_member(): void
{
$repo = $this->make(FileRepository::class, [
'getFileByEncName' => fn () => [
'id' => 12, 'realName' => 'doc.pdf', 'extension' => 'pdf',
'module' => 'project', 'moduleId' => 5, 'userId' => 2,
],
]);
$fileManager = $this->make(FileManager::class, [
'getFile' => fn () => new Response('bytes', 200),
]);
$service = $this->makeService($repo, $fileManager, $this->allowingPermissions());
$response = $service->getFileForUser('abc123', 1);
$this->assertSame(200, $response->getStatusCode());
}
public function test_get_file_for_user_owner_restricted_uses_session_user_not_arg(): void
{
// Owner-restricted file owned by user 2. Session user is 1. Even though the caller passes
// userId=2 (spoofing the owner) the check uses the SESSION user (1) and denies.
$repo = $this->make(FileRepository::class, [
'getFileByEncName' => fn () => [
'id' => 13, 'realName' => 'secret.txt', 'extension' => 'txt',
'module' => 'private', 'moduleId' => 2, 'userId' => 2,
],
]);
$fileManager = $this->make(FileManager::class, [
'getFile' => fn () => $this->fail('Owner-restricted file must not be served to a non-owner'),
]);
$service = $this->makeService($repo, $fileManager, $this->allowingPermissions());
$response = $service->getFileForUser('enc', 2);
$this->assertSame(403, $response->getStatusCode());
}
public function test_get_file_for_user_missing_returns_404(): void
{
$repo = $this->make(FileRepository::class, [
'getFileByEncName' => fn () => false,
]);
$service = $this->makeService($repo, null, $this->allowingPermissions());
$this->assertSame(404, $service->getFileForUser('missing', 1)->getStatusCode());
}
public function test_get_file_for_user_denies_orphaned_project_file(): void
{
// A ticket file whose ticket was deleted resolves to no project; rather than fall through
// to the non-project serve path it must be denied (fail closed), even with allow-all perms.
$repo = $this->make(FileRepository::class, [
'getFileByEncName' => fn () => [
'id' => 14, 'realName' => 'a.pdf', 'extension' => 'pdf',
'module' => 'ticket', 'moduleId' => 999, 'userId' => 2,
],
'getProjectIdForFile' => fn () => null,
]);
$fileManager = $this->make(FileManager::class, [
'getFile' => fn () => $this->fail('an orphaned ticket file must not be served'),
]);
$service = $this->makeService($repo, $fileManager, $this->allowingPermissions());
$this->assertSame(403, $service->getFileForUser('enc', 1)->getStatusCode());
}
// ---- handleFileAction result reflects upload() outcome ----------------
public function test_handle_file_action_reports_failure_when_upload_is_denied(): void
{
/** @var Files $service */
$service = $this->make(Files::class, [
'upload' => function () {
throw new AuthorizationException;
},
]);
$result = $service->handleFileAction(['upload' => '1'], ['file' => ['name' => 'a.png']], 'ticket', 5);
$this->assertSame('upload', $result['action']);
$this->assertFalse($result['success']);
}
public function test_handle_file_action_reports_failure_when_upload_returns_error_string(): void
{
/** @var Files $service */
$service = $this->make(Files::class, [
'upload' => fn () => 'Error uploading file',
]);
$result = $service->handleFileAction(['upload' => '1'], ['file' => ['name' => 'a.png']], 'project', 5);
$this->assertSame('upload', $result['action']);
$this->assertFalse($result['success']);
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace Unit\app\Domain\Goalcanvas\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Unit\TestCase;
/**
* Repository-level regression tests for the goal↔milestone link chokepoints.
* Two contracts here are load-bearing and were previously untested (every
* service test mocks the repository away):
*
* 1. addGoalMilestoneLink fails CLOSED — the same-project product rule
* (goal↔milestone links never cross projects) plus the live-milestone
* type check are enforced in real SQL here and nowhere else.
* 2. removeGoalMilestoneLink / removeAllGoalMilestoneLinks keep the legacy
* milestoneId column in sync — without the clear, the column-union in
* getGoalsByMilestone() resurrects an explicitly unlinked goal.
*
* Faked connection, no DB — the fakes model each table's terminal calls.
*/
class GoalcanvasMilestoneLinkTest extends TestCase
{
use MockeryPHPUnitIntegration;
/** @var array<int, array<string, mixed>> rows captured by edge inserts */
private array $insertedEdges = [];
/** @var array<int, array<string, mixed>> update payloads captured on zp_canvas_items */
private array $columnUpdates = [];
/**
* Build a Goalcanvas repo whose dbConnection serves the given per-table
* behavior.
*
* @param int|null $goalProjectId value('cb.projectId') for the goal lookup (null = goal missing/foreign)
* @param object|null $milestoneRow first(['projectId']) for the milestone lookup (null = not a live milestone)
* @param bool $edgeExists exists() for the dedup check inside the insert transaction
* @param int $edgeDeleteCount delete() return for edge removals
* @param int $columnUpdateCount update() return for the legacy-column clear
*/
private function repo(
?int $goalProjectId = null,
?object $milestoneRow = null,
bool $edgeExists = false,
int $edgeDeleteCount = 0,
int $columnUpdateCount = 0
): Goalcanvas {
$this->insertedEdges = [];
$this->columnUpdates = [];
$inserted = &$this->insertedEdges;
$updates = &$this->columnUpdates;
$builderFor = function (string $table) use ($goalProjectId, $milestoneRow, $edgeExists, $edgeDeleteCount, $columnUpdateCount, &$inserted, &$updates) {
return new class($table, $goalProjectId, $milestoneRow, $edgeExists, $edgeDeleteCount, $columnUpdateCount, $inserted, $updates)
{
public function __construct(
private string $table,
private ?int $goalProjectId,
private ?object $milestoneRow,
private bool $edgeExists,
private int $edgeDeleteCount,
private int $columnUpdateCount,
array &$inserted,
array &$updates
) {
// Explicit reference assignment (not promotion) so the
// captures stay version-proof across supported PHP.
$this->inserted = &$inserted;
$this->updates = &$updates;
}
/** @var array<int, array<string, mixed>> */
private array $inserted;
/** @var array<int, array<string, mixed>> */
private array $updates;
/** Stands in for addGoalMilestoneLink's zp_canvas_items↔zp_canvas join. */
public function join(...$a): static
{
return $this;
}
public function where(...$a): static
{
// Record scalar predicates so tests can pin WHICH rows an
// update/delete was scoped to (a fake that discards its
// where() arguments can't catch a lost predicate).
if (count($a) === 2 && is_scalar($a[1])) {
$this->wheres[$a[0]] = $a[1];
}
return $this;
}
/** @var array<string, mixed> scalar where() predicates seen by this builder */
public array $wheres = [];
/** Stands in for the in-transaction dedup's ->lockForUpdate(). */
public function lockForUpdate(): static
{
return $this;
}
/** Stands in for addGoalMilestoneLink's ->value('cb.projectId') goal-project resolve. */
public function value($column)
{
return $this->goalProjectId;
}
/** Stands in for addGoalMilestoneLink's zp_tickets ->first(['projectId']) milestone lookup. */
public function first($columns = ['*'])
{
return $this->milestoneRow;
}
/** Stands in for the edge-dedup ->exists() inside the insert transaction. */
public function exists(): bool
{
return $this->edgeExists;
}
/** Stands in for the zp_entity_relationship edge insert. */
public function insert($row): bool
{
$this->inserted[] = $row;
return true;
}
/** Stands in for the tracked_by edge ->delete() in the removal methods. */
public function delete(): int
{
return $this->edgeDeleteCount;
}
/** Stands in for the legacy zp_canvas_items.milestoneId column clear. */
public function update(array $values): int
{
$this->updates[] = ['table' => $this->table, 'values' => $values, 'wheres' => $this->wheres];
return $this->columnUpdateCount;
}
};
};
$conn = Mockery::mock(ConnectionInterface::class);
$conn->shouldReceive('table')->andReturnUsing($builderFor);
$conn->shouldReceive('transaction')->andReturnUsing(fn (callable $fn) => $fn());
$repo = (new \ReflectionClass(Goalcanvas::class))->newInstanceWithoutConstructor();
$prop = new \ReflectionProperty(Goalcanvas::class, 'dbConnection');
$prop->setAccessible(true);
$prop->setValue($repo, $conn);
return $repo;
}
// ── addGoalMilestoneLink: the fail-closed same-project chokepoint ──
public function test_add_link_rejects_a_cross_project_milestone(): void
{
// Goal lives in project 1, milestone in project 2 — the product rule
// (links never cross projects) must fail the write closed.
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 2]);
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
$this->assertSame([], $this->insertedEdges, 'no edge may be written for a cross-project link');
}
public function test_add_link_rejects_a_dead_or_non_milestone_ticket(): void
{
// The milestone lookup filters type='milestone' AND status<>-1 — a
// task id or a soft-deleted milestone resolves to null.
$repo = $this->repo(goalProjectId: 1, milestoneRow: null);
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
$this->assertSame([], $this->insertedEdges);
}
public function test_add_link_rejects_an_unknown_or_non_goal_item(): void
{
// The goal lookup filters box='goal' + canvas type='goalcanvas' — a
// foreign/shared-table id resolves to null (fail closed, no oracle).
$repo = $this->repo(goalProjectId: null, milestoneRow: (object) ['projectId' => 1]);
$this->assertFalse($repo->addGoalMilestoneLink(5, 42, 7));
$this->assertSame([], $this->insertedEdges);
}
public function test_add_link_writes_a_correct_edge_for_a_same_project_milestone(): void
{
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1]);
$this->assertTrue($repo->addGoalMilestoneLink(5, 42, 7));
$this->assertCount(1, $this->insertedEdges);
$edge = $this->insertedEdges[0];
$this->assertSame(5, $edge['entityA']);
$this->assertSame('GoalItem', $edge['entityAType']);
$this->assertSame(42, $edge['entityB']);
$this->assertSame('Ticket', $edge['entityBType']);
$this->assertSame('tracked_by', $edge['relationship']);
$this->assertSame(7, $edge['createdBy']);
}
public function test_add_link_is_idempotent_when_the_edge_exists(): void
{
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1], edgeExists: true);
$this->assertTrue($repo->addGoalMilestoneLink(5, 42, 7), 'an existing link reports success');
$this->assertSame([], $this->insertedEdges, 'but is not duplicated');
}
public function test_add_link_stores_unknown_author_as_null_not_zero(): void
{
$repo = $this->repo(goalProjectId: 1, milestoneRow: (object) ['projectId' => 1]);
$repo->addGoalMilestoneLink(5, 42, 0);
$this->assertNull($this->insertedEdges[0]['createdBy']);
}
// ── removeGoalMilestoneLink / removeAll: the clear-on-unlink dual-write ──
public function test_unlink_clears_the_legacy_column_alongside_the_edge(): void
{
$repo = $this->repo(edgeDeleteCount: 1, columnUpdateCount: 1);
$this->assertTrue($repo->removeGoalMilestoneLink(5, 42));
$this->assertCount(1, $this->columnUpdates, 'the legacy milestoneId column must be cleared with the edge');
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
// The clear must be SCOPED: only the goal's row, and only when the
// column still points at the milestone being unlinked — dropping the
// milestoneId predicate would blank an unrelated newer link.
$this->assertSame(5, $this->columnUpdates[0]['wheres']['id'] ?? null);
$this->assertSame('42', $this->columnUpdates[0]['wheres']['milestoneId'] ?? null);
}
public function test_unlink_reports_success_when_only_the_stale_column_held_the_link(): void
{
// No edge row (already gone), but the legacy column still pointed at
// the milestone — clearing it is a real unlink and must not read as a
// failure.
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 1);
$this->assertTrue($repo->removeGoalMilestoneLink(5, 42));
}
public function test_unlink_reports_failure_when_neither_store_held_the_link(): void
{
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 0);
$this->assertFalse($repo->removeGoalMilestoneLink(5, 42));
}
public function test_remove_all_links_clears_edges_and_the_legacy_column(): void
{
$repo = $this->repo(edgeDeleteCount: 3, columnUpdateCount: 1);
$this->assertTrue($repo->removeAllGoalMilestoneLinks(5));
$this->assertCount(1, $this->columnUpdates);
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
}
public function test_remove_milestone_from_all_goals_clears_edges_and_columns(): void
{
$repo = $this->repo(edgeDeleteCount: 2, columnUpdateCount: 2);
$this->assertTrue($repo->removeMilestoneFromAllGoals(42));
$this->assertNotEmpty($this->columnUpdates, 'the milestone-delete cascade must clear matching legacy columns');
$this->assertSame(['milestoneId' => ''], $this->columnUpdates[0]['values']);
$this->assertSame('42', $this->columnUpdates[0]['wheres']['milestoneId'] ?? null, 'only columns pointing at THIS milestone are cleared');
}
public function test_remove_milestone_from_all_goals_reports_only_edge_deletions(): void
{
// PINS CURRENT BEHAVIOR: unlike removeGoalMilestoneLink /
// removeAllGoalMilestoneLinks (which return deleted OR columnCleared),
// the cascade returns only whether edges were deleted — a stale-column
// -only cleanup reads as false. No caller branches on this today; if
// the sibling semantics are ever unified, this test should fail and be
// updated deliberately.
$repo = $this->repo(edgeDeleteCount: 0, columnUpdateCount: 3);
$this->assertFalse($repo->removeMilestoneFromAllGoals(42));
$this->assertNotEmpty($this->columnUpdates, 'stale columns are still cleared even though the return is false');
}
}

View File

@@ -0,0 +1,911 @@
<?php
namespace Unit\app\Domain\Goalcanvas\Services;
use Codeception\Stub\Expected;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Unit\TestCase;
/**
* Unit tests for the Goalcanvas service:
* - progress math: goalProgress is (currentValue - startValue) / (endValue - startValue) * 100,
* clamped to 0..100, and child-goal value aggregation for linkAndReport goals;
* - the fail-closed by-id board/item CRUD chokepoint (every by-id op resolves the entity's real
* project via the inherited resolvers, scoped to the "goalcanvas" type, and authorizes a
* goals.* verb — reads soft-deny without loading, writes throw without writing).
*/
class GoalcanvasServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private function allowingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => fn () => null,
'currentUserCan' => fn () => true,
]);
}
private function service(GoalcanvaRepository $repo, ?PermissionService $perms = null, ?ProjectService $projects = null): GoalcanvasService
{
$service = new GoalcanvasService($repo, $projects ?? $this->projectsWithAccess([]));
$service->setPermissionService($perms ?? $this->allowingPermissions());
return $service;
}
/** A Projects service granting access to exactly the given project ids. */
private function projectsWithAccess(array $projectIds): ProjectService
{
return $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn () => array_map(static fn ($id) => ['id' => $id], $projectIds),
]);
}
private function denyingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => function (): void {
throw new AuthorizationException;
},
'currentUserCan' => fn () => false,
]);
}
/**
* A repo where goal #7 lives in project 9 and returns the given milestone
* chip rows from getMilestonesForGoals.
*/
private function goalRepoWithMilestones(array $milestones, int $projectId = 9): GoalcanvaRepository
{
return $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn (...$args) => $projectId,
'getCanvasItemProjectIds' => fn (...$args) => [7 => (int) $projectId],
'getMilestonesForGoals' => fn (...$args) => [7 => $milestones],
]);
}
private function milestone(int $id, string $statusType, int $percentDone, ?string $from, ?string $to, int $projectId = 9): array
{
return [
'id' => $id, 'headline' => "MS $id", 'color' => '#ccc', 'projectId' => $projectId,
'editFrom' => $from, 'editTo' => $to, 'status' => 3, 'statusType' => $statusType,
'percentDone' => $percentDone,
];
}
public function test_computes_goal_progress_as_percentage_of_range(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [],
'getCanvasItemsById' => fn () => [
['id' => 1, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => 50.0],
],
]);
$goals = $this->service($repo)->getCanvasItemsById(1);
$this->assertEqualsWithDelta(50, $goals[0]['goalProgress'], 0.01);
}
public function test_clamps_progress_between_zero_and_one_hundred(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [],
'getCanvasItemsById' => fn () => [
['id' => 1, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => 150.0],
['id' => 2, 'setting' => 'linkonly', 'startValue' => 0.0, 'endValue' => 100.0, 'currentValue' => -20.0],
],
]);
$goals = $this->service($repo)->getCanvasItemsById(1);
$this->assertSame(100, $goals[0]['goalProgress']);
$this->assertSame(0, $goals[1]['goalProgress']);
}
public function test_zero_range_yields_zero_progress(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [],
'getCanvasItemsById' => fn () => [
['id' => 1, 'setting' => 'linkonly', 'startValue' => 50.0, 'endValue' => 50.0, 'currentValue' => 50.0],
],
]);
$goals = $this->service($repo)->getCanvasItemsById(1);
$this->assertSame(0, $goals[0]['goalProgress']);
}
public function test_child_goal_reporting_sums_by_setting(): void
{
// linkonly children contribute their own currentValue; linkAndReport
// children contribute their rolled-up childCurrentValue.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getCanvasItemsByKPI' => fn () => [
['setting' => 'linkonly', 'currentValue' => 10.0, 'childCurrentValue' => 999.0],
['setting' => 'linkAndReport', 'currentValue' => 0.0, 'childCurrentValue' => 5.0],
],
]);
$sum = $this->service($repo)->getChildGoalsForReporting(1);
$this->assertEqualsWithDelta(15.0, $sum, 0.01);
}
// ---------------------------------------------------------------------
// Fail-closed by-id board/item CRUD chokepoint.
// ---------------------------------------------------------------------
public function test_get_canvas_items_returns_empty_for_foreign_board_without_loading(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'getCanvasItemsById' => function () use (&$loaded) {
$loaded++;
return [['id' => 1]];
},
]);
$this->assertSame([], $this->service($repo)->getCanvasItemsById(999));
$this->assertSame(0, $loaded, 'A foreign/unknown board must not have its goals read');
}
public function test_child_goal_reporting_returns_zero_for_foreign_parent(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'getCanvasItemsByKPI' => function () use (&$loaded) {
$loaded++;
return [];
},
]);
$this->assertSame(0, $this->service($repo)->getChildGoalsForReporting(999));
$this->assertSame(0, $loaded, 'A foreign/unknown parent goal must not have its children read');
}
public function test_get_goal_item_soft_denies_when_view_not_permitted(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getSingleCanvasItem' => function () use (&$loaded) {
$loaded++;
return ['id' => 1];
},
]);
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
$this->assertFalse($this->service($repo, $perms)->getGoalItem(1));
$this->assertSame(0, $loaded, 'An unauthorized item returns false without loading (no oracle)');
}
public function test_update_goal_item_resolves_project_from_item_id_not_payload_canvas_id(): void
{
$resolvedItemId = null;
$wrote = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => function ($id) use (&$resolvedItemId) {
$resolvedItemId = $id;
return 9;
},
'editCanvasItem' => function () use (&$wrote) {
$wrote++;
},
]);
$this->service($repo)->updateGoalItem(['itemId' => 42, 'canvasId' => 9999, 'description' => 'x']);
$this->assertSame(42, $resolvedItemId, 'Project resolved from itemId, not the payload canvasId');
$this->assertSame(1, $wrote);
}
public function test_patch_goal_item_throws_and_never_writes_for_unresolved_item(): void
{
$patched = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'patchCanvasItem' => function () use (&$patched) {
$patched++;
return true;
},
]);
try {
$this->service($repo)->patchGoalItem(5, ['status' => 'x']);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $patched);
}
public function test_delete_goal_item_throws_and_never_deletes_for_unresolved_item(): void
{
$deleted = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'delCanvasItem' => function () use (&$deleted) {
$deleted++;
},
]);
try {
$this->service($repo)->deleteGoalItem(5);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $deleted);
}
public function test_create_goal_item_throws_and_never_inserts_for_unknown_board(): void
{
$inserted = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'addCanvasItem' => function () use (&$inserted) {
$inserted++;
return '1';
},
]);
try {
$this->service($repo)->createGoalItem(['canvasId' => 9999, 'box' => 'goal']);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $inserted);
}
public function test_create_goal_api_throws_for_unknown_board(): void
{
$inserted = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'createGoal' => function () use (&$inserted) {
$inserted++;
return '1';
},
]);
try {
$this->service($repo)->createGoal(['canvasId' => 9999, 'box' => 'goal']);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $inserted);
}
public function test_delete_goal_board_throws_for_unresolved_board(): void
{
$deleted = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'deleteCanvas' => function () use (&$deleted) {
$deleted++;
},
]);
try {
$this->service($repo)->deleteGoalBoard(5);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $deleted);
}
public function test_update_goalboard_throws_for_unresolved_board(): void
{
$updated = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'updateCanvas' => function () use (&$updated) {
$updated++;
return 1;
},
]);
try {
$this->service($repo)->updateGoalboard(['id' => 5, 'title' => 'x']);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $updated);
}
public function test_merge_goal_board_requires_both_boards_to_resolve(): void
{
$merged = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn ($id) => $id === 1 ? 9 : null,
'mergeCanvas' => function () use (&$merged) {
$merged++;
return true;
},
]);
try {
$this->service($repo)->mergeGoalBoard(2, 1);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $merged);
}
public function test_copy_goal_board_throws_when_source_unresolved(): void
{
$copied = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasProjectId' => fn () => null,
'copyCanvas' => function () use (&$copied) {
$copied++;
return 1;
},
]);
try {
$this->service($repo)->copyGoalBoard(5, 7, 1, 'Copy');
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $copied);
}
// ---------------------------------------------------------------------
// Dual-write: syncing tracked_by edges from a single milestoneId write.
// ---------------------------------------------------------------------
/**
* Build a repo whose edge writers record into $added / $removed (passed by
* reference), so a test can assert exactly which links were created/removed.
*
* @param array<int, int> $currentEdges Milestone ids the goal is already linked to
* @param array<string, callable> $extraStubs Extra repo method stubs (the write path)
* @param array<int, array{0:int,1:int}> $added Receives [goalId, milestoneId] per add
* @param array<int, int> $removed Receives milestoneId per remove
*/
private function edgeRepo(array $currentEdges, array $extraStubs, array &$added, array &$removed): GoalcanvaRepository
{
return $this->make(GoalcanvaRepository::class, array_merge([
'getCanvasProjectId' => fn () => 9,
'getCanvasItemProjectId' => fn () => 9,
'getMilestoneIdsForGoal' => fn () => $currentEdges,
'addGoalMilestoneLink' => function ($goalId, $milestoneId, $userId) use (&$added) {
// $userId is required (no default) so the tests fail loudly if
// production ever stops passing the author argument.
$added[] = [(int) $goalId, (int) $milestoneId];
return true;
},
'removeGoalMilestoneLink' => function ($goalId, $milestoneId) use (&$removed) {
$removed[] = (int) $milestoneId;
return true;
},
], $extraStubs));
}
public function test_create_goal_links_milestone_edge_when_milestone_id_present(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['createGoal' => fn () => '50'], $added, $removed);
$this->service($repo)->createGoal(['canvasId' => 9, 'milestoneId' => 42]);
$this->assertSame([[50, 42]], $added, 'The new goal is linked to the given milestone');
$this->assertSame([], $removed);
}
public function test_create_goal_item_links_milestone_edge_when_milestone_id_present(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['addCanvasItem' => fn () => '50'], $added, $removed);
$this->service($repo)->createGoalItem(['canvasId' => 9, 'box' => 'goal', 'milestoneId' => 42]);
$this->assertSame([[50, 42]], $added, 'A new goal item is linked to the given milestone');
$this->assertSame([], $removed);
}
public function test_update_goal_item_links_milestone_edge_when_milestone_id_present(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['editCanvasItem' => fn () => null], $added, $removed);
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => 42]);
$this->assertSame([[7, 42]], $added);
$this->assertSame([], $removed);
}
public function test_patch_goal_item_links_milestone_edge_when_milestone_id_present(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => true], $added, $removed);
$this->service($repo)->patchGoalItem(7, ['milestoneId' => 42]);
$this->assertSame([[7, 42]], $added);
$this->assertSame([], $removed);
}
public function test_patch_goal_item_skips_edge_sync_when_patch_fails(): void
{
$added = [];
$removed = [];
// patchCanvasItem returns false → the milestoneId edge sync must NOT run,
// else the tracked_by edges would drift from the (unchanged) column.
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => false], $added, $removed);
$result = $this->service($repo)->patchGoalItem(7, ['milestoneId' => 42]);
$this->assertFalse($result);
$this->assertSame([], $added, 'no edge added when the underlying patch failed');
$this->assertSame([], $removed);
}
public function test_patch_goal_item_ignores_non_numeric_milestone_id(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([], ['patchCanvasItem' => fn () => true], $added, $removed);
// '42abc' must not cast to milestone edge 42.
$this->service($repo)->patchGoalItem(7, ['milestoneId' => '42abc']);
$this->assertSame([], $added, 'a non-numeric milestoneId creates no edge');
$this->assertSame([], $removed);
}
public function test_delete_goal_item_removes_all_edges_on_successful_delete(): void
{
$deleted = 0;
$cleared = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'delCanvasItem' => function () use (&$deleted) {
$deleted++;
},
'removeAllGoalMilestoneLinks' => function ($goalId) use (&$cleared) {
$cleared = (int) $goalId;
return true;
},
]);
$this->service($repo)->deleteGoalItem(5);
$this->assertSame(1, $deleted);
$this->assertSame(5, $cleared, 'deleting a goal item clears its tracked_by edges');
}
public function test_empty_milestone_id_clears_existing_edges_without_adding(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([11], ['editCanvasItem' => fn () => null], $added, $removed);
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => '']);
$this->assertSame([], $added, 'An empty milestoneId adds nothing');
$this->assertSame([11], $removed, 'It unlinks the existing edge');
}
public function test_zero_milestone_id_clears_existing_edges_without_adding(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([11], ['patchCanvasItem' => fn () => true], $added, $removed);
$this->service($repo)->patchGoalItem(7, ['milestoneId' => '0']);
$this->assertSame([], $added, 'A "0" milestoneId adds nothing');
$this->assertSame([11], $removed, 'It unlinks the existing edge');
}
public function test_unchanged_milestone_id_does_not_churn_edges(): void
{
$added = [];
$removed = [];
$repo = $this->edgeRepo([42], ['editCanvasItem' => fn () => null], $added, $removed);
$this->service($repo)->updateGoalItem(['itemId' => 7, 'milestoneId' => 42]);
$this->assertSame([], $added, 'Re-saving the same milestone neither adds');
$this->assertSame([], $removed, 'nor removes an edge');
}
public function test_missing_milestone_id_key_leaves_edges_untouched(): void
{
// No milestoneId key at all (e.g. a description-only edit) must not
// touch edges — the sync only runs when the key is present.
$synced = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'editCanvasItem' => fn () => null,
'getMilestoneIdsForGoal' => function () use (&$synced) {
$synced++;
return [];
},
]);
$this->service($repo)->updateGoalItem(['itemId' => 7, 'description' => 'x']);
$this->assertSame(0, $synced, 'Edge sync must not run when milestoneId is absent');
}
// Multi-milestone chip UI actions + report read (edge model).
// ---------------------------------------------------------------------
public function test_add_milestone_to_goal_authorizes_edit_and_links(): void
{
$linked = null;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'addGoalMilestoneLink' => function ($goalId, $milestoneId, $userId = null) use (&$linked) {
$linked = [(int) $goalId, (int) $milestoneId];
return true;
},
]);
$this->assertTrue($this->service($repo)->addMilestoneToGoal(7, 42));
$this->assertSame([7, 42], $linked, 'The link is created against the resolved goal');
}
public function test_add_milestone_to_goal_throws_and_never_links_for_foreign_goal(): void
{
$linked = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'addGoalMilestoneLink' => function () use (&$linked) {
$linked++;
return true;
},
]);
try {
$this->service($repo)->addMilestoneToGoal(999, 42);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $linked, 'A foreign/unknown goal must not have a milestone linked');
}
public function test_add_milestone_to_goal_throws_and_never_links_when_edit_denied(): void
{
$linked = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'addGoalMilestoneLink' => function () use (&$linked) {
$linked++;
return true;
},
]);
try {
$this->service($repo, $this->denyingPermissions())->addMilestoneToGoal(7, 42);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $linked, 'EDIT-denied on the goal project must not link');
}
public function test_remove_milestone_from_goal_authorizes_edit_and_unlinks(): void
{
$unlinked = null;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'removeGoalMilestoneLink' => function ($goalId, $milestoneId) use (&$unlinked) {
$unlinked = [(int) $goalId, (int) $milestoneId];
return true;
},
]);
$this->assertTrue($this->service($repo)->removeMilestoneFromGoal(7, 42));
$this->assertSame([7, 42], $unlinked);
}
public function test_remove_milestone_from_goal_throws_and_never_unlinks_for_foreign_goal(): void
{
$unlinked = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'removeGoalMilestoneLink' => function () use (&$unlinked) {
$unlinked++;
return true;
},
]);
try {
$this->service($repo)->removeMilestoneFromGoal(999, 42);
$this->fail('Expected AuthorizationException');
} catch (AuthorizationException) {
}
$this->assertSame(0, $unlinked);
}
public function test_get_goal_milestones_returns_chips_and_summarizes_by_status(): void
{
// Chips carry their projectId and pass the accessible-projects strip.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [
7 => [
['id' => 1, 'headline' => 'A', 'statusType' => 'DONE', 'projectId' => 9],
['id' => 2, 'headline' => 'B', 'statusType' => 'INPROGRESS', 'projectId' => 9],
['id' => 3, 'headline' => 'C', 'statusType' => 'NEW', 'projectId' => 9],
['id' => 4, 'headline' => 'D', 'statusType' => 'NEW', 'projectId' => 9],
],
],
]);
$result = $this->service($repo, projects: $this->projectsWithAccess([9]))->getGoalMilestones(7);
$this->assertCount(4, $result['milestones']);
$this->assertSame(
['total' => 4, 'done' => 1, 'inProgress' => 1, 'notStarted' => 2],
$result['summary'],
);
}
public function test_get_goal_milestones_strips_legacy_cross_project_chips_and_counts_only_shown(): void
{
// Same defensive strip as the rollup reads: a legacy cross-project row
// (milestone in project 8, caller can only access 9) must not surface
// in the editor chips, and the summary counts what is actually shown.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getMilestonesForGoals' => fn () => [
7 => [
['id' => 1, 'headline' => 'Mine', 'statusType' => 'DONE', 'projectId' => 9],
['id' => 2, 'headline' => 'Foreign', 'statusType' => 'INPROGRESS', 'projectId' => 8],
],
],
]);
$result = $this->service($repo, projects: $this->projectsWithAccess([9]))->getGoalMilestones(7);
$this->assertCount(1, $result['milestones']);
$this->assertSame('Mine', $result['milestones'][0]['headline']);
$this->assertSame(
['total' => 1, 'done' => 1, 'inProgress' => 0, 'notStarted' => 0],
$result['summary'],
);
}
public function test_get_goal_milestones_soft_denies_foreign_goal_without_loading(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'getMilestonesForGoals' => function () use (&$loaded) {
$loaded++;
return [];
},
]);
$result = $this->service($repo)->getGoalMilestones(999);
$this->assertSame([], $result['milestones']);
$this->assertSame(['total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0], $result['summary']);
$this->assertSame(0, $loaded, 'A foreign/unknown goal must not have its milestones read (no oracle)');
}
public function test_get_goal_milestones_soft_denies_when_view_not_permitted(): void
{
$loaded = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getMilestonesForGoals' => function () use (&$loaded) {
$loaded++;
return [7 => [['id' => 1, 'headline' => 'A', 'statusType' => 'DONE']]];
},
]);
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
$result = $this->service($repo, $perms)->getGoalMilestones(7);
$this->assertSame([], $result['milestones']);
$this->assertSame(0, $loaded, 'VIEW-denied returns the empty shape without loading');
}
public function test_get_milestones_for_goals_omits_unauthorized_goals(): void
{
// goal 1 lives in project 7 (VIEW allowed), goal 2 in project 8 (denied) —
// the report-read path must present the authorized goal and drop the rest.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectIds' => fn () => [1 => 7, 2 => 8],
'getMilestonesForGoals' => fn (array $ids) => in_array(1, $ids, true)
? [1 => [['id' => 10, 'headline' => 'M1', 'projectId' => 7]]]
: [],
]);
$perms = $this->make(PermissionService::class, [
'currentUserCan' => fn (string $permission, ?int $projectId = null) => $projectId === 7,
]);
$result = $this->service($repo, $perms, $this->projectsWithAccess([7]))->getMilestonesForGoals([1, 2]);
$this->assertArrayHasKey(1, $result, 'authorized goal is present');
$this->assertArrayNotHasKey(2, $result, 'unauthorized goal is omitted');
$this->assertSame([['id' => 10, 'headline' => 'M1', 'projectId' => 7]], $result[1]);
}
public function test_get_goals_by_milestone_soft_denies_unknown_or_foreign_milestone(): void
{
// The MCP getGoalsByMilestone tool wraps this verbatim, so the service
// itself must gate: an unknown/non-milestone id returns [] without
// reading any goals (no oracle).
$read = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getMilestoneProjectId' => fn () => null,
'getGoalsByMilestone' => function () use (&$read) {
$read++;
return [['id' => 1]];
},
]);
$this->assertSame([], $this->service($repo)->getGoalsByMilestone(999));
$this->assertSame(0, $read, 'goals must not be read for an unresolvable milestone');
}
public function test_get_goals_by_milestone_soft_denies_when_view_not_permitted(): void
{
$read = 0;
$repo = $this->make(GoalcanvaRepository::class, [
'getMilestoneProjectId' => fn () => 8,
'getGoalsByMilestone' => function () use (&$read) {
$read++;
return [['id' => 1]];
},
]);
$perms = $this->make(PermissionService::class, ['currentUserCan' => fn () => false]);
$this->assertSame([], $this->service($repo, $perms)->getGoalsByMilestone(5));
$this->assertSame(0, $read, 'VIEW-denied returns [] without reading goals');
}
public function test_get_goals_by_milestone_returns_goals_for_an_accessible_milestone(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getMilestoneProjectId' => fn () => 9,
'getGoalsByMilestone' => fn () => [['id' => 1, 'title' => 'G']],
]);
$this->assertSame(
[['id' => 1, 'title' => 'G']],
$this->service($repo)->getGoalsByMilestone(5)
);
}
public function test_get_milestones_for_goals_is_empty_safe(): void
{
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectIds' => fn () => [],
]);
$this->assertSame([], $this->service($repo)->getMilestonesForGoals([]));
}
// ─── Milestone rollup / mobile Progress (many-to-many) ────────────────
public function test_get_goal_rollup_aggregates_status_progress_and_span(): void
{
$repo = $this->goalRepoWithMilestones([
$this->milestone(1, 'INPROGRESS', 40, '2025-01-10 00:00:00', '2025-03-01 00:00:00'),
$this->milestone(2, 'NEW', 0, '2025-02-01 00:00:00', '2025-04-15 00:00:00'),
$this->milestone(3, 'DONE', 100, '2024-12-01 00:00:00', '2025-01-20 00:00:00'),
]);
$rollup = $this->service($repo, null, $this->projectsWithAccess([9]))->getGoalRollup(7);
$this->assertSame(3, $rollup['total']);
$this->assertSame(1, $rollup['done']);
$this->assertSame(1, $rollup['inProgress']);
$this->assertSame(1, $rollup['notStarted']);
$this->assertSame(47, $rollup['percentComplete']); // round((40+0+100)/3)
$this->assertSame('2024-12-01 00:00:00', $rollup['startDate']); // earliest start
$this->assertSame('2025-04-15 00:00:00', $rollup['endDate']); // latest due
$this->assertSame(1, $rollup['currentMilestoneId']); // first not-done
}
public function test_get_goal_rollup_skips_zero_sentinel_dates(): void
{
$repo = $this->goalRepoWithMilestones([
$this->milestone(1, 'INPROGRESS', 50, '0000-00-00 00:00:00', '2025-05-01 00:00:00'),
$this->milestone(2, 'NEW', 0, '2025-01-01 00:00:00', '0000-00-00 00:00:00'),
]);
$rollup = $this->service($repo, null, $this->projectsWithAccess([9]))->getGoalRollup(7);
$this->assertSame('2025-01-01 00:00:00', $rollup['startDate']); // m1 start skipped
$this->assertSame('2025-05-01 00:00:00', $rollup['endDate']); // m2 due skipped
}
public function test_get_milestones_by_goal_strips_inaccessible_project_milestones(): void
{
$repo = $this->goalRepoWithMilestones([
$this->milestone(1, 'NEW', 0, null, null, projectId: 9),
$this->milestone(2, 'NEW', 0, null, null, projectId: 99), // not accessible
]);
// goal is in project 9 (VIEW ok); only project 9 is accessible.
$list = $this->service($repo, null, $this->projectsWithAccess([9]))->getMilestonesByGoal(7);
$this->assertCount(1, $list);
$this->assertSame(1, $list[0]['id']);
}
public function test_get_milestones_by_goal_soft_denies_unauthorized_goal(): void
{
$repo = $this->goalRepoWithMilestones([
$this->milestone(1, 'NEW', 0, null, null),
]);
$list = $this->service($repo, $this->denyingPermissions(), $this->projectsWithAccess([9]))
->getMilestonesByGoal(7);
$this->assertSame([], $list);
}
public function test_get_goal_rollup_returns_empty_shape_for_foreign_goal(): void
{
// getCanvasItemProjectId returns null (missing/foreign/wrong type) -> deny.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => null,
]);
$rollup = $this->service($repo)->getGoalRollup(7);
$this->assertSame(0, $rollup['total']);
$this->assertNull($rollup['currentMilestoneId']);
}
public function test_add_milestone_to_goal_does_not_unlink_others_many_to_many(): void
{
// Marcel's correction: a milestone can belong to multiple goals, so
// linking it to a goal must NOT unlink it from any other goal.
$repo = $this->make(GoalcanvaRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'addGoalMilestoneLink' => Expected::once(fn ($goalId, $milestoneId, $userId = null) => true),
'removeMilestoneFromAllGoals' => Expected::never(),
]);
$this->assertTrue($this->service($repo)->addMilestoneToGoal(7, 42));
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace Unit\app\Domain\Help\Services;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Domain\Help\Services\FirstTaskStep;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use RuntimeException;
use Unit\TestCase;
/**
* Regression tests for the first-login onboarding dead-end in GH #3683.
*
* Readonly users have no TicketsPermissions::CREATE, so quickAddTicket() throws an
* AuthorizationException. That used to happen before the firstLoginCompleted flag was
* written, leaving the onboarding modal re-rendering forever with no way out. The flag
* write is the invariant here; the first task is only a convenience.
*/
class FirstTaskStepTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/** Captures every saveSetting() call as [key, value]. */
private array $savedSettings = [];
/** Captures every headline quickAddTicket() was called with. */
private array $createdHeadlines = [];
protected function setUp(): void
{
parent::setUp();
session(['userdata.id' => 1]);
$this->savedSettings = [];
$this->createdHeadlines = [];
}
/**
* Builds the step with a spying Setting repo and a Tickets service that either
* records the headline or throws the given exception.
*/
private function makeStep(?\Throwable $ticketFailure = null): FirstTaskStep
{
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function ($key, $value) {
$this->savedSettings[] = [$key, $value];
return true;
},
]);
$ticketService = $this->make(TicketService::class, [
'quickAddTicket' => function ($params) use ($ticketFailure) {
if ($ticketFailure !== null) {
throw $ticketFailure;
}
$this->createdHeadlines[] = $params['headline'];
return 1;
},
]);
return new FirstTaskStep($settingsRepo, $ticketService);
}
/** The flag write, asserted as "written exactly once with true". */
private function assertOnboardingCompleted(): void
{
$this->assertSame(
[['user.1.firstLoginCompleted', true]],
$this->savedSettings,
'firstLoginCompleted must be persisted exactly once, regardless of ticket creation'
);
}
public function test_readonly_user_completes_onboarding_when_ticket_creation_is_denied(): void
{
$result = $this->makeStep(new AuthorizationException)->handle(['headline' => 'Water the plants']);
$this->assertTrue($result, 'handle() must report success even when the user cannot create tickets');
$this->assertOnboardingCompleted();
$this->assertSame([], $this->createdHeadlines);
}
public function test_unexpected_ticket_failure_still_completes_onboarding(): void
{
$result = $this->makeStep(new RuntimeException('database went away'))->handle(['headline' => 'Water the plants']);
$this->assertTrue($result);
$this->assertOnboardingCompleted();
}
public function test_permitted_user_creates_the_first_task_and_completes_onboarding(): void
{
$result = $this->makeStep()->handle(['headline' => 'Water the plants']);
$this->assertTrue($result);
$this->assertSame(['Water the plants'], $this->createdHeadlines);
$this->assertOnboardingCompleted();
}
public function test_headline_is_trimmed_before_the_task_is_created(): void
{
$this->makeStep()->handle(['headline' => ' Water the plants ']);
$this->assertSame(['Water the plants'], $this->createdHeadlines);
}
/**
* A blank, whitespace-only, missing or non-string headline must not create an empty
* task — but must still complete onboarding.
*
* @dataProvider blankHeadlineProvider
*/
public function test_blank_headline_skips_task_creation_but_completes_onboarding(array $params): void
{
$result = $this->makeStep()->handle($params);
$this->assertTrue($result);
$this->assertSame([], $this->createdHeadlines, 'No task should be created for a blank headline');
$this->assertOnboardingCompleted();
}
public static function blankHeadlineProvider(): array
{
return [
'empty string' => [['headline' => '']],
'whitespace only' => [['headline' => " \t "]],
'missing key' => [[]],
'null' => [['headline' => null]],
'array (malformed POST)' => [['headline' => ['nope']]],
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Unit\app\Domain\Help\Services;
use Leantime\Domain\Help\Services\Helper;
use Leantime\Domain\Setting\Repositories\Setting;
use Unit\TestCase;
/**
* Unit tests for the onboarding / modal orchestration extracted from the
* Help FirstLogin and ShowOnboardingDialog controllers into the Helper service.
*/
class HelperTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a Helper service with a stubbed Setting repository.
*/
private function makeService(): Helper
{
return new Helper($this->make(Setting::class));
}
public function test_resolve_first_login_step_returns_end_step(): void
{
$step = $this->makeService()->resolveFirstLoginStep('end');
$this->assertTrue($step['isEnd']);
$this->assertSame('end', $step['key']);
$this->assertNull($step['next']);
$this->assertSame('help.firstLoginEnd', $step['template']);
}
public function test_handle_first_login_step_rejects_missing_step(): void
{
$result = $this->makeService()->handleFirstLoginStep([]);
$this->assertFalse($result['valid']);
$this->assertSame('', $result['next']);
}
public function test_handle_first_login_step_rejects_non_numeric_step(): void
{
$result = $this->makeService()->handleFirstLoginStep(['currentStep' => 'foo']);
$this->assertFalse($result['valid']);
$this->assertSame('', $result['next']);
}
public function test_handle_first_login_step_rejects_unknown_numeric_step(): void
{
$result = $this->makeService()->handleFirstLoginStep(['currentStep' => '999']);
$this->assertFalse($result['valid']);
$this->assertSame('', $result['next']);
}
public function test_get_helper_modal_by_route_returns_notfound_for_unknown_route(): void
{
$modal = $this->makeService()->getHelperModalByRoute('does.notExist');
$this->assertSame('notfound', $modal['template']);
}
public function test_mark_modal_seen_for_module_sanitizes_and_records_session(): void
{
session()->forget('usersettings.modals');
$template = $this->makeService()->markModalSeenForModule('<b>dashboard</b>');
$expected = htmlspecialchars('<b>dashboard</b>');
$this->assertSame($expected, $template);
$this->assertSame(1, session('usersettings.modals.'.$expected));
}
public function test_mark_modal_seen_for_route_resolves_template_and_records_session(): void
{
session()->forget('usersettings.modals');
$template = $this->makeService()->markModalSeenForRoute('dashboard.show');
$this->assertSame('projectDashboard', $template);
$this->assertSame(1, session('usersettings.modals.projectDashboard'));
}
public function test_mark_modal_seen_for_route_unknown_route_marks_notfound(): void
{
session()->forget('usersettings.modals');
$template = $this->makeService()->markModalSeenForRoute('does.notExist');
$this->assertSame('notfound', $template);
$this->assertSame(1, session('usersettings.modals.notfound'));
}
}

View File

@@ -0,0 +1,538 @@
<?php
namespace Unit\app\Domain\Ideas\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\Ideas\Repositories\Ideas as IdeasRepository;
use Leantime\Domain\Ideas\Services\Ideas as IdeaService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Unit\TestCase;
/**
* Unit tests for the Ideas service: board/item helpers plus the project-scoped authorization
* fences. Idea boards (zp_canvas type 'idea') and items (zp_canvas_items) are project-scoped; reads
* and mutations fence against the entity's REAL project (item -> board -> project), failing closed
* on the shared canvas tables. The pre-existing userCanAccessCanvasItem checks are migrated onto the
* permission engine.
*/
class IdeasServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private const SESSION_USER = 5;
protected function setUp(): void
{
parent::setUp();
session(['userdata.id' => self::SESSION_USER]);
}
private function makeService(
?IdeasRepository $ideasRepo = null,
?CommentRepository $commentsRepo = null,
?LanguageCore $language = null,
?PermissionService $perms = null,
): IdeaService {
$service = new IdeaService(
$ideasRepo ?? $this->make(IdeasRepository::class),
$commentsRepo ?? $this->make(CommentRepository::class),
$this->make(ProjectService::class),
$this->make(TicketService::class),
$language ?? $this->make(LanguageCore::class),
);
$service->setPermissionService($perms ?? $this->allowingPermissions());
return $service;
}
/** A repo whose board #3 / item #5 both resolve to project 9. */
private function ideaRepoInProject9(array $overrides = []): IdeasRepository
{
return $this->make(IdeasRepository::class, array_merge([
'getSingleCanvas' => fn () => [['id' => 3, 'projectId' => 9, 'title' => 'Board']],
'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'],
], $overrides));
}
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,
]);
}
// ---------------------------------------------------------------------
// Item factory / normalization (behavioural).
// ---------------------------------------------------------------------
public function test_get_idea_item_returns_empty_default_for_null_id(): void
{
$item = $this->makeService()->getIdeaItem(null, 'research');
$this->assertSame('', $item['id']);
$this->assertSame('research', $item['box']);
$this->assertSame('idea', $item['status']);
$this->assertSame('', $item['milestoneId']);
}
public function test_get_idea_item_defaults_type_to_idea(): void
{
$this->assertSame('idea', $this->makeService()->getIdeaItem(null)['box']);
}
public function test_get_idea_item_normalizes_zero_box_to_idea(): void
{
$repo = $this->ideaRepoInProject9([
'getSingleCanvasItem' => fn () => ['id' => 5, 'box' => '0', 'canvasId' => 3, 'description' => 'x'],
]);
$item = $this->makeService(ideasRepo: $repo)->getIdeaItem(5);
$this->assertSame('idea', $item['box']);
$this->assertSame(5, $item['id']);
}
public function test_get_idea_item_keeps_non_zero_box(): void
{
$repo = $this->ideaRepoInProject9([
'getSingleCanvasItem' => fn () => ['id' => 7, 'box' => 'prototype', 'canvasId' => 3],
]);
$this->assertSame('prototype', $this->makeService(ideasRepo: $repo)->getIdeaItem(7)['box']);
}
public function test_ensure_board_exists_returns_zero_when_boards_present(): void
{
$addCalls = 0;
$repo = $this->make(IdeasRepository::class, [
'addCanvas' => function () use (&$addCalls) {
$addCalls++;
return '99';
},
]);
$this->assertSame(0, $this->makeService(ideasRepo: $repo)->ensureBoardExists(1, 2, [['id' => 10]]));
$this->assertSame(0, $addCalls, 'No board should be created when one already exists');
}
public function test_ensure_board_exists_creates_default_when_none(): void
{
$repo = $this->make(IdeasRepository::class, ['addCanvas' => fn () => '99']);
$language = $this->make(LanguageCore::class, ['__' => fn () => 'Board']);
$this->assertSame(99, $this->makeService(ideasRepo: $repo, language: $language)->ensureBoardExists(1, 2, []));
}
public function test_get_all_boards_normalizes_false_to_empty_array(): void
{
$repo = $this->make(IdeasRepository::class, ['getAllCanvas' => fn () => false]);
$this->assertSame([], $this->makeService(ideasRepo: $repo)->getAllBoards(1));
}
public function test_get_board_items_normalizes_false_to_empty_array(): void
{
$repo = $this->ideaRepoInProject9(['getCanvasItemsById' => fn () => false]);
$this->assertSame([], $this->makeService(ideasRepo: $repo)->getBoardItems(3));
}
public function test_get_board_title_returns_empty_when_not_found(): void
{
$repo = $this->make(IdeasRepository::class, ['getSingleCanvas' => fn () => false]);
$this->assertSame('', $this->makeService(ideasRepo: $repo)->getBoardTitle(123));
}
public function test_get_board_title_returns_first_row_title(): void
{
$repo = $this->make(IdeasRepository::class, [
'getSingleCanvas' => fn () => [['id' => 1, 'title' => 'My Board', 'projectId' => 9]],
]);
$this->assertSame('My Board', $this->makeService(ideasRepo: $repo)->getBoardTitle(1));
}
// ---------------------------------------------------------------------
// Read fences (single-entity-by-id).
// ---------------------------------------------------------------------
public function test_get_board_is_denied_for_a_foreign_project(): void
{
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->getBoard(3);
}
public function test_get_board_items_is_denied_for_a_foreign_project(): void
{
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->getBoardItems(3);
}
// ---------------------------------------------------------------------
// Mutation fences (fail closed + project-scoped).
// ---------------------------------------------------------------------
public function test_patch_idea_item_is_denied_without_edit(): void
{
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->patchIdeaItem(5, ['box' => 'done']);
}
public function test_patch_idea_item_allowed_with_edit(): void
{
$repo = $this->ideaRepoInProject9(['patchCanvasItem' => fn () => true]);
$this->assertTrue($this->makeService(ideasRepo: $repo)->patchIdeaItem(5, ['box' => 'done']));
}
public function test_patch_idea_item_fails_closed_for_unknown_item(): void
{
$patched = false;
$repo = $this->make(IdeasRepository::class, [
'getSingleCanvasItem' => fn () => false,
'patchCanvasItem' => function () use (&$patched): bool {
$patched = true;
return true;
},
]);
$this->assertFalse($this->makeService(ideasRepo: $repo)->patchIdeaItem(999, ['box' => 'done']));
$this->assertFalse($patched, 'A non-idea/unknown id must never reach the repo patch');
}
public function test_update_idea_item_fails_closed_for_unknown_item(): void
{
$edited = false;
$repo = $this->make(IdeasRepository::class, [
'getSingleCanvasItem' => fn () => false,
'editCanvasItem' => function () use (&$edited): void {
$edited = true;
},
]);
$input = ['itemId' => 999, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 3, 'milestoneId' => ''];
$this->assertSame(0, $this->makeService(ideasRepo: $repo)->updateIdeaItem($input, 9, self::SESSION_USER));
$this->assertFalse($edited, 'A non-idea/unknown id must never reach the repo edit');
}
public function test_update_idea_item_is_denied_without_edit(): void
{
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
$input = ['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 3, 'milestoneId' => ''];
$this->expectException(AuthorizationException::class);
$service->updateIdeaItem($input, 9, self::SESSION_USER);
}
public function test_create_idea_item_is_denied_without_create(): void
{
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->createIdeaItem(['box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'canvasId' => 3], 9, self::SESSION_USER);
}
public function test_create_board_is_denied_without_create(): void
{
$service = $this->makeService(perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->createBoard('New board', 9, self::SESSION_USER);
}
public function test_update_board_is_denied_without_edit(): void
{
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->updateBoard(3, 'Renamed');
}
public function test_update_board_fails_closed_for_unknown_board(): void
{
$updated = false;
$repo = $this->make(IdeasRepository::class, [
'getSingleCanvas' => fn () => [],
'updateCanvas' => function () use (&$updated) {
$updated = true;
return 1;
},
]);
$this->assertFalse($this->makeService(ideasRepo: $repo)->updateBoard(999, 'Renamed'));
$this->assertFalse($updated, 'A non-idea/unknown board id must never reach the repo update');
}
public function test_delete_canvas_is_denied_and_does_not_delete(): void
{
$repo = $this->ideaRepoInProject9([
'deleteCanvas' => function (): void {
throw new \RuntimeException('delete must not run when denied');
},
]);
$service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->deleteCanvas(3);
}
public function test_delete_canvas_item_is_denied_and_does_not_delete(): void
{
$repo = $this->ideaRepoInProject9([
'delCanvasItem' => function (): void {
throw new \RuntimeException('delete must not run when denied');
},
]);
$service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->deleteCanvasItem(5);
}
// ---------------------------------------------------------------------
// Batch mutators reject (return false) without writing.
// ---------------------------------------------------------------------
public function test_reorder_ideas_rejects_batch_when_cannot_edit(): void
{
$sorted = false;
$repo = $this->ideaRepoInProject9([
'updateIdeaSorting' => function () use (&$sorted): bool {
$sorted = true;
return true;
},
]);
$service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions());
$this->assertFalse($service->reorderIdeas([['id' => 5, 'sortIndex' => 1]]));
$this->assertFalse($sorted, 'A denied batch must never reach the repo sort');
}
public function test_bulk_update_status_rejects_batch_when_cannot_edit(): void
{
$repo = $this->ideaRepoInProject9(['bulkUpdateIdeaStatus' => fn () => true]);
$service = $this->makeService(ideasRepo: $repo, perms: $this->denyingPermissions());
$this->assertFalse($service->bulkUpdateStatus(['done' => 'item[]=5']));
}
// ---------------------------------------------------------------------
// Comment delete: author allowed; non-author requires moderation.
// ---------------------------------------------------------------------
public function test_remove_idea_comment_allows_the_author_without_moderation(): void
{
$deleted = null;
$commentsRepo = $this->make(CommentRepository::class, [
'getComment' => fn () => ['id' => 1, 'userId' => self::SESSION_USER, 'moduleId' => 5],
'deleteComment' => function ($id) use (&$deleted): bool {
$deleted = $id;
return true;
},
]);
// Denying engine proves the author path does NOT require comments.moderate.
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), commentsRepo: $commentsRepo, perms: $this->denyingPermissions());
$service->removeIdeaComment(1);
$this->assertSame(1, $deleted);
}
public function test_remove_idea_comment_denies_non_author_without_moderation(): void
{
$commentsRepo = $this->make(CommentRepository::class, [
'getComment' => fn () => ['id' => 1, 'userId' => 7, 'moduleId' => 5],
'deleteComment' => function (): bool {
throw new \RuntimeException('delete must not run when moderation is denied');
},
]);
$service = $this->makeService(ideasRepo: $this->ideaRepoInProject9(), commentsRepo: $commentsRepo, perms: $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->removeIdeaComment(1);
}
// ---------------------------------------------------------------------
// Fail-closed on a non-resolving (non-idea) id: never authorize against a null project, never
// fall back to the caller-supplied project. (A null project here means "not an idea entity".)
// ---------------------------------------------------------------------
public function test_get_idea_comments_fails_closed_for_non_idea_entity(): void
{
$fetched = false;
$repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]);
$commentsRepo = $this->make(CommentRepository::class, [
'getComments' => function () use (&$fetched) {
$fetched = true;
return [];
},
]);
// Denying engine: if it reached authorize(VIEW, null) it would throw; fail-closed returns [] first.
$service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo, perms: $this->denyingPermissions());
$this->assertSame([], $service->getIdeaComments('ticket', 123));
$this->assertFalse($fetched, 'A non-idea entity must short-circuit before the comment read');
}
public function test_create_idea_item_fails_closed_for_non_idea_board(): void
{
$created = false;
$repo = $this->make(IdeasRepository::class, [
'getSingleCanvas' => fn () => [], // canvasId is not an idea board
'addCanvasItem' => function () use (&$created) {
$created = true;
return '1';
},
]);
$service = $this->makeService(ideasRepo: $repo);
$this->assertSame(0, $service->createIdeaItem(['box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'canvasId' => 999], 9, self::SESSION_USER));
$this->assertFalse($created, 'A non-idea board canvasId must never create an item against the caller project');
}
public function test_add_idea_comment_fails_closed_for_non_idea_item(): void
{
$added = false;
$repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]);
$commentsRepo = $this->make(CommentRepository::class, [
'addComment' => function () use (&$added) {
$added = true;
return '1';
},
]);
$service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo);
$this->assertFalse($service->addIdeaComment('hi', 999, 0, 9, self::SESSION_USER));
$this->assertFalse($added, 'A non-idea item must never receive a comment against the caller project');
}
public function test_remove_idea_comment_fails_closed_for_non_author_non_idea_comment(): void
{
$deleted = false;
$repo = $this->make(IdeasRepository::class, ['getSingleCanvasItem' => fn () => false]);
$commentsRepo = $this->make(CommentRepository::class, [
'getComment' => fn () => ['id' => 1, 'userId' => 7, 'moduleId' => 999],
'deleteComment' => function () use (&$deleted): bool {
$deleted = true;
return true;
},
]);
// Allowing engine: proves the refusal is the fail-closed null guard, not a denied authorize.
$service = $this->makeService(ideasRepo: $repo, commentsRepo: $commentsRepo, perms: $this->allowingPermissions());
$service->removeIdeaComment(1);
$this->assertFalse($deleted, 'A non-author comment on a non-idea item must not be deleted');
}
// ---------------------------------------------------------------------
// Relocation / mass-assignment fences (Copilot review): the incoming canvasId/params can move
// an item to another board, so the target board's project must also be authorized.
// ---------------------------------------------------------------------
public function test_patch_idea_item_strips_relocation_and_identity_fields(): void
{
// patchCanvasItem updates any column it receives; canvasId/id/author must be stripped so a
// caller can't relocate the item to another board/project or rewrite its identity.
$patched = null;
$repo = $this->ideaRepoInProject9([
'patchCanvasItem' => function ($id, $params) use (&$patched): bool {
$patched = $params;
return true;
},
]);
$service = $this->makeService(ideasRepo: $repo);
$service->patchIdeaItem(5, ['status' => 'done', 'canvasId' => 999, 'id' => 1, 'author' => 7]);
$this->assertArrayNotHasKey('canvasId', $patched);
$this->assertArrayNotHasKey('id', $patched);
$this->assertArrayNotHasKey('author', $patched);
$this->assertSame('done', $patched['status'], 'Legitimate fields still pass through');
}
public function test_update_idea_item_denies_relocation_to_a_foreign_board(): void
{
// Item lives in project 9 (board 3); the edit's canvasId points at board 99 in project 7.
// The user may edit project 9 but NOT project 7 -> the relocation is denied before the write.
$repo = $this->make(IdeasRepository::class, [
'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'],
'getSingleCanvas' => fn ($id) => $id === 99 ? [['projectId' => 7]] : [['projectId' => 9]],
'editCanvasItem' => function (): void {
throw new \RuntimeException('relocation must be blocked before the write');
},
]);
$perms = $this->make(PermissionService::class, [
'authorize' => function (string $p, ?int $projectId = null): void {
if ($projectId === 7) {
throw new AuthorizationException;
}
},
'currentUserCan' => fn () => true,
]);
$service = $this->makeService(ideasRepo: $repo, perms: $perms);
$this->expectException(AuthorizationException::class);
$service->updateIdeaItem(['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 99, 'milestoneId' => ''], 9, self::SESSION_USER);
}
public function test_update_idea_item_fails_closed_when_target_board_is_not_an_idea_board(): void
{
$edited = false;
$repo = $this->make(IdeasRepository::class, [
'getSingleCanvasItem' => fn () => ['id' => 5, 'canvasId' => 3, 'box' => 'idea'],
'getSingleCanvas' => fn ($id) => $id === 3 ? [['projectId' => 9]] : [], // target 99 -> not an idea board
'editCanvasItem' => function () use (&$edited): void {
$edited = true;
},
]);
$service = $this->makeService(ideasRepo: $repo);
$this->assertSame(0, $service->updateIdeaItem(['itemId' => 5, 'box' => 'idea', 'description' => 'x', 'status' => 'idea', 'data' => '', 'tags' => '', 'canvasId' => 99, 'milestoneId' => ''], 9, self::SESSION_USER));
$this->assertFalse($edited, 'A non-idea target board must never receive the relocated item');
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace Unit\app\Domain\Install\Services;
use Leantime\Core\Configuration\AppSettings;
use Leantime\Domain\Install\Repositories\Install as InstallRepository;
use Leantime\Domain\Install\Services\Install as InstallService;
use Leantime\Domain\Setting\Services\Setting as SettingService;
use Unit\TestCase;
/**
* Unit tests for the Install service helpers extracted during the
* thin-controller refactor (validateInstallInput, needsUpdate).
*/
class InstallServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a real Install service, allowing each dependency to be
* overridden with a stub.
*/
private function makeService(
?AppSettings $appSettings = null,
?InstallRepository $installRepo = null,
?SettingService $settingService = null,
): InstallService {
return new InstallService(
$appSettings ?? $this->make(AppSettings::class),
$installRepo ?? $this->make(InstallRepository::class),
$settingService ?? $this->make(SettingService::class),
);
}
public function test_validate_install_input_passes_for_complete_values(): void
{
$service = $this->makeService();
$service->validateInstallInput([
'email' => 'admin@example.com',
'firstname' => 'Ada',
'lastname' => 'Lovelace',
'company' => 'Analytical Engines',
]);
// No exception thrown means success.
$this->assertTrue(true);
}
public function test_validate_install_input_throws_email_key_first(): void
{
$service = $this->makeService();
try {
$service->validateInstallInput([
'email' => '',
'firstname' => '',
'lastname' => '',
'company' => '',
]);
$this->fail('Expected InvalidArgumentException was not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertSame('notification.enter_email', $e->getMessage());
}
}
public function test_validate_install_input_throws_firstname_key_when_only_email_present(): void
{
$service = $this->makeService();
try {
$service->validateInstallInput([
'email' => 'admin@example.com',
'firstname' => '',
'lastname' => '',
'company' => '',
]);
$this->fail('Expected InvalidArgumentException was not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertSame('notification.enter_firstname', $e->getMessage());
}
}
public function test_validate_install_input_throws_lastname_key_when_company_also_missing(): void
{
$service = $this->makeService();
try {
$service->validateInstallInput([
'email' => 'admin@example.com',
'firstname' => 'Ada',
'lastname' => '',
'company' => '',
]);
$this->fail('Expected InvalidArgumentException was not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertSame('notification.enter_lastname', $e->getMessage());
}
}
public function test_validate_install_input_throws_company_key_last(): void
{
$service = $this->makeService();
try {
$service->validateInstallInput([
'email' => 'admin@example.com',
'firstname' => 'Ada',
'lastname' => 'Lovelace',
'company' => '',
]);
$this->fail('Expected InvalidArgumentException was not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertSame('notification.enter_company', $e->getMessage());
}
}
public function test_needs_update_is_true_when_versions_differ(): void
{
$appSettings = $this->make(AppSettings::class);
$appSettings->dbVersion = '3.5.1';
$settingService = $this->make(SettingService::class, [
'getSetting' => fn () => '3.5.0',
]);
$needsUpdate = $this->makeService(
appSettings: $appSettings,
settingService: $settingService,
)->needsUpdate();
$this->assertTrue($needsUpdate);
}
public function test_needs_update_is_false_when_versions_match(): void
{
$appSettings = $this->make(AppSettings::class);
$appSettings->dbVersion = '3.5.1';
$settingService = $this->make(SettingService::class, [
'getSetting' => fn () => '3.5.1',
]);
$needsUpdate = $this->makeService(
appSettings: $appSettings,
settingService: $settingService,
)->needsUpdate();
$this->assertFalse($needsUpdate);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Unit\app\Domain\Install;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Leantime\Domain\Install\Repositories\Install;
use Leantime\Domain\Install\Services\SchemaBuilder;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Unit\TestCase;
/**
* Regression tests for migration update_sql_30504 (#3706) — the push-notification
* columns on zp_access_tokens.
*
* The migration used to ALTER the table unguarded. Installs whose zp_access_tokens
* was never created (update_sql_30400 swallowed a failed CREATE and still returned
* success, so 3.4.0 was recorded as applied) then hit
* "1146 Table 'zp_access_tokens' doesn't exist" here and could not upgrade at all.
*
* These pin the self-heal: create the table when it is missing, and leave it alone
* when it is not — no DB, facades faked.
*/
class UpdateSql30504Test extends TestCase
{
use MockeryPHPUnitIntegration;
/**
* Run update_sql_30504 with the Schema/DB facades faked.
*
* @param bool $tableExists what Schema::hasTable reports for zp_access_tokens
* @return array{result: mixed, recreated: bool}
*/
private function runMigration(bool $tableExists): array
{
// swap() rather than shouldReceive(): the latter resolves the real facade root
// first, and unit tests run with `database.default => []`, so building the
// DatabaseManager blows up before any expectation is set.
$schema = Mockery::mock();
$schema->shouldReceive('hasTable')->with('zp_access_tokens')->andReturn($tableExists);
// The column/index work is not under test here: accept the calls and skip the
// closures so no Blueprint is needed.
$schema->shouldReceive('table')->andReturnNull();
$schema->shouldReceive('hasColumn')->andReturn(true);
Schema::swap($schema);
$db = Mockery::mock();
$db->shouldReceive('select')->andReturn([]);
DB::swap($db);
$recreated = false;
$builder = Mockery::mock(SchemaBuilder::class);
$builder->shouldReceive('createAccessTokensTable')
->andReturnUsing(function () use (&$recreated): void {
$recreated = true;
});
app()->instance(SchemaBuilder::class, $builder);
$install = (new \ReflectionClass(Install::class))->newInstanceWithoutConstructor();
return ['result' => $install->update_sql_30504(), 'recreated' => $recreated];
}
public function test_recreates_the_table_when_it_is_missing_instead_of_failing(): void
{
$run = $this->runMigration(tableExists: false);
$this->assertTrue(
$run['recreated'],
'A missing zp_access_tokens must be recreated, not left for the ALTER to die on (#3706)'
);
$this->assertTrue(
$run['result'],
'The migration must succeed on an install that reached 30504 without the table'
);
}
public function test_leaves_an_existing_table_alone(): void
{
$run = $this->runMigration(tableExists: true);
$this->assertFalse(
$run['recreated'],
'An install that already has zp_access_tokens must not have it recreated'
);
$this->assertTrue($run['result'], 'The migration must still report success');
}
}

View File

@@ -0,0 +1,281 @@
<?php
namespace Unit\app\Domain\Install;
use Illuminate\Database\ConnectionInterface;
use Leantime\Domain\Install\Repositories\Install;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Unit\TestCase;
/**
* Regression tests for migration update_sql_30524 (#3686) — the load-bearing
* backfill that copies each goal's legacy zp_canvas_items.milestoneId into a
* `tracked_by` edge on zp_entity_relationship. A mis-backfill silently corrupts
* goal↔milestone links for every existing install (and it already had a
* near-miss: it shipped unregistered in $dbUpdates once), so its behavior is
* pinned here with a faked connection — no DB.
*
* Covers: correct edge direction, junk/non-numeric skipped, deleted /
* non-milestone tickets skipped, SAME-PROJECT enforcement (a legacy
* cross-project row is never promoted to an edge), NULL author for unknown,
* idempotent re-run (existing edge not duplicated), and the table-guard no-op.
*/
class UpdateSql30524Test extends TestCase
{
use MockeryPHPUnitIntegration;
/**
* Run update_sql_30524 against a fully-faked, TABLE-AWARE connection.
*
* @param array<int, object> $canvasGoals rows shaped {id, milestoneId, author, canvasId}
* @param array<int, int> $liveMilestones ticket id => projectId for live milestones
* @param array<int, int> $canvasProjects canvas id => projectId
* @param array<int, array{0:int,1:int}> $existingEdges [goalId, milestoneId] pairs already linked
* @param bool $tablesExist Schema-guard toggle: false makes hasTable/hasColumn report missing tables (the no-op path)
* @return array{result: mixed, inserted: array<int, array<string, mixed>>}
*/
private function runMigration(
array $canvasGoals,
array $liveMilestones,
array $canvasProjects,
array $existingEdges,
bool $tablesExist = true
): array {
$inserted = [];
$capture = function ($rows) use (&$inserted): void {
foreach ($rows as $row) {
$inserted[] = $row;
}
};
$schema = Mockery::mock();
$schema->shouldReceive('hasTable')->andReturn($tablesExist);
$schema->shouldReceive('hasColumn')->andReturn($tablesExist);
$conn = Mockery::mock(ConnectionInterface::class);
$conn->shouldReceive('getSchemaBuilder')->andReturn($schema);
$conn->shouldReceive('table')->andReturnUsing(
fn (string $table) => $this->fakeBuilder($table, $canvasGoals, $liveMilestones, $canvasProjects, $existingEdges, $capture)
);
$install = (new \ReflectionClass(Install::class))->newInstanceWithoutConstructor();
$prop = new \ReflectionProperty(Install::class, 'connection');
$prop->setAccessible(true);
$prop->setValue($install, $conn);
return ['result' => $install->update_sql_30524(), 'inserted' => $inserted];
}
/**
* The builder is table-aware: the migration reads goal rows (chunkById on
* zp_canvas_items), live milestones with their project (get on zp_tickets),
* goal projects via canvases (get on zp_canvas), and existing edges
* (get on zp_entity_relationship) — each table serves its own shape.
*/
private function fakeBuilder(
string $table,
array $canvasGoals,
array $liveMilestones,
array $canvasProjects,
array $existingEdges,
callable $capture
): object {
return new class($table, $canvasGoals, $liveMilestones, $canvasProjects, $existingEdges, $capture)
{
public function __construct(
private string $table,
private array $canvasGoals,
private array $liveMilestones,
private array $canvasProjects,
private array $existingEdges,
private $capture
) {}
public function where(...$a): static
{
return $this;
}
public function whereNotNull(...$a): static
{
return $this;
}
public function whereIn(...$a): static
{
return $this;
}
public function select(...$a): static
{
return $this;
}
public function orderBy(...$a): static
{
return $this;
}
public function chunkById($count, $callback): bool
{
$callback(collect($this->canvasGoals));
return true;
}
public function get($columns = ['*'])
{
if ($this->table === 'zp_tickets') {
return collect(array_map(
fn ($id, $projectId) => (object) ['id' => $id, 'projectId' => $projectId],
array_keys($this->liveMilestones),
array_values($this->liveMilestones)
));
}
if ($this->table === 'zp_canvas') {
return collect(array_map(
fn ($id, $projectId) => (object) ['id' => $id, 'projectId' => $projectId],
array_keys($this->canvasProjects),
array_values($this->canvasProjects)
));
}
// zp_entity_relationship — the existing-edge dedup read.
return collect(array_map(
fn ($e) => (object) ['entityA' => $e[0], 'entityB' => $e[1]],
$this->existingEdges
));
}
public function insert($rows): bool
{
($this->capture)($rows);
return true;
}
};
}
private function goal(int $id, ?string $milestoneId, ?int $author, int $canvasId = 1): object
{
return (object) ['id' => $id, 'milestoneId' => $milestoneId, 'author' => $author, 'canvasId' => $canvasId];
}
public function test_backfills_a_tracked_by_edge_in_the_correct_direction(): void
{
$out = $this->runMigration(
canvasGoals: [$this->goal(5, '42', 7)],
liveMilestones: [42 => 1],
canvasProjects: [1 => 1],
existingEdges: [],
);
$this->assertTrue($out['result']);
$this->assertCount(1, $out['inserted']);
$edge = $out['inserted'][0];
$this->assertSame(5, $edge['entityA']);
$this->assertSame('GoalItem', $edge['entityAType']);
$this->assertSame(42, $edge['entityB']);
$this->assertSame('Ticket', $edge['entityBType']);
$this->assertSame('tracked_by', $edge['relationship']);
$this->assertSame(7, $edge['createdBy']);
}
public function test_skips_junk_and_non_numeric_milestone_ids(): void
{
$out = $this->runMigration(
canvasGoals: [$this->goal(6, 'abc', 1), $this->goal(7, ' ', 1), $this->goal(8, '4x', 1)],
liveMilestones: [42 => 1],
canvasProjects: [1 => 1],
existingEdges: [],
);
$this->assertSame([], $out['inserted'], 'non-numeric milestoneId values are skipped');
}
public function test_skips_deleted_or_non_milestone_tickets(): void
{
// Goal points at ticket 99, which is not in the live-milestone set
// (deleted, or demoted to a task).
$out = $this->runMigration(
canvasGoals: [$this->goal(9, '99', 1)],
liveMilestones: [],
canvasProjects: [1 => 1],
existingEdges: [],
);
$this->assertSame([], $out['inserted'], 'a milestone that is not live is not backfilled');
}
public function test_skips_cross_project_legacy_rows(): void
{
// Product rule: goal↔milestone links are SAME-PROJECT only. A legacy
// column row pointing at another project's milestone must not be
// promoted into a first-class edge (goal's canvas 1 -> project 1;
// milestone 42 lives in project 2).
$out = $this->runMigration(
canvasGoals: [$this->goal(5, '42', 7, canvasId: 1)],
liveMilestones: [42 => 2],
canvasProjects: [1 => 1],
existingEdges: [],
);
$this->assertSame([], $out['inserted'], 'a cross-project legacy row is never promoted to an edge');
}
public function test_migrates_same_project_rows_alongside_skipped_cross_project_ones(): void
{
// Mixed chunk: goal 5's milestone is same-project (migrates), goal 6's
// is cross-project (skipped) — the guard is per-pair, not per-chunk.
$out = $this->runMigration(
canvasGoals: [$this->goal(5, '42', 7, canvasId: 1), $this->goal(6, '43', 7, canvasId: 1)],
liveMilestones: [42 => 1, 43 => 2],
canvasProjects: [1 => 1],
existingEdges: [],
);
$this->assertCount(1, $out['inserted']);
$this->assertSame(5, $out['inserted'][0]['entityA']);
$this->assertSame(42, $out['inserted'][0]['entityB']);
}
public function test_is_idempotent_when_the_edge_already_exists(): void
{
$out = $this->runMigration(
canvasGoals: [$this->goal(5, '42', 7)],
liveMilestones: [42 => 1],
canvasProjects: [1 => 1],
existingEdges: [[5, 42]],
);
$this->assertSame([], $out['inserted'], 'a re-run does not duplicate an existing edge');
}
public function test_unknown_author_is_stored_as_null_not_zero(): void
{
$out = $this->runMigration(
canvasGoals: [$this->goal(9, '42', null)],
liveMilestones: [42 => 1],
canvasProjects: [1 => 1],
existingEdges: [],
);
$this->assertCount(1, $out['inserted']);
$this->assertNull($out['inserted'][0]['createdBy'], 'unknown author stays NULL, never 0');
}
public function test_is_a_no_op_when_the_required_tables_are_absent(): void
{
$out = $this->runMigration(
canvasGoals: [$this->goal(5, '42', 7)],
liveMilestones: [42 => 1],
canvasProjects: [1 => 1],
existingEdges: [],
tablesExist: false,
);
$this->assertTrue($out['result']);
$this->assertSame([], $out['inserted'], 'missing schema short-circuits before any write');
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Unit\app\Domain\Install;
use Illuminate\Database\ConnectionInterface;
use Leantime\Domain\Install\Repositories\Install;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Unit\TestCase;
/**
* Regression tests for migration update_sql_30526 — the hygiene pass that
* deletes goal↔milestone `tracked_by` edges violating the same-project product
* rule (promoted by the pre-guard 30524/30525 backfill) and edges orphaned by
* the pre-cascade generic ticket delete. Behavior pinned with a faked
* connection — no DB.
*
* The migration issues two pluck reads on the aliased edge table (cross-project
* first, then orphans) and one chunked whereIn-delete on the plain table; the
* fake serves them in that order.
*/
class UpdateSql30526Test extends TestCase
{
use MockeryPHPUnitIntegration;
/**
* @param int[] $crossProjectIds edge ids the cross-project read returns
* @param int[] $orphanIds edge ids the orphan read returns
* @return array{result: mixed, deleted: int[]}
*/
private function runMigration(array $crossProjectIds, array $orphanIds, bool $tablesExist = true): array
{
$deleted = [];
// The two aliased pluck reads arrive in a fixed order (cross-project,
// then orphans) — serve them from a queue shared across builder
// instances.
$pluckQueue = new \ArrayObject([$crossProjectIds, $orphanIds]);
$schema = Mockery::mock();
$schema->shouldReceive('hasTable')->andReturn($tablesExist);
$conn = Mockery::mock(ConnectionInterface::class);
$conn->shouldReceive('getSchemaBuilder')->andReturn($schema);
$conn->shouldReceive('table')->andReturnUsing(
function (string $table) use ($pluckQueue, &$deleted) {
return new class($pluckQueue, $deleted)
{
private array $whereInIds = [];
public function __construct(private \ArrayObject $pluckQueue, private array &$deleted) {}
public function join(...$a): static
{
return $this;
}
public function leftJoin(...$a): static
{
return $this;
}
public function where(...$a): static
{
return $this;
}
public function whereColumn(...$a): static
{
return $this;
}
public function whereIn($column, $ids): static
{
$this->whereInIds = $ids;
return $this;
}
public function pluck($column)
{
$sets = $this->pluckQueue->getArrayCopy();
$next = array_shift($sets);
$this->pluckQueue->exchangeArray($sets);
return collect($next ?? []);
}
public function delete(): int
{
foreach ($this->whereInIds as $id) {
$this->deleted[] = (int) $id;
}
return count($this->whereInIds);
}
};
}
);
$install = (new \ReflectionClass(Install::class))->newInstanceWithoutConstructor();
$prop = new \ReflectionProperty(Install::class, 'connection');
$prop->setAccessible(true);
$prop->setValue($install, $conn);
return ['result' => $install->update_sql_30526(), 'deleted' => $deleted];
}
public function test_deletes_cross_project_and_orphaned_edges(): void
{
$out = $this->runMigration(crossProjectIds: [11, 12], orphanIds: [13]);
$this->assertTrue($out['result']);
$this->assertSame([11, 12, 13], $out['deleted']);
}
public function test_deduplicates_an_edge_that_is_both_cross_project_and_orphaned(): void
{
$out = $this->runMigration(crossProjectIds: [11], orphanIds: [11, 12]);
$this->assertTrue($out['result']);
$this->assertSame([11, 12], $out['deleted'], 'an id in both sets is deleted once');
}
public function test_a_clean_graph_deletes_nothing(): void
{
$out = $this->runMigration(crossProjectIds: [], orphanIds: []);
$this->assertTrue($out['result']);
$this->assertSame([], $out['deleted']);
}
public function test_is_a_no_op_when_the_required_tables_are_absent(): void
{
$out = $this->runMigration(crossProjectIds: [11], orphanIds: [12], tablesExist: false);
$this->assertTrue($out['result']);
$this->assertSame([], $out['deleted'], 'missing schema short-circuits before any delete');
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace Unit\app\Domain\Menu\Repositories;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Language;
use Leantime\Domain\Menu\Repositories\Menu;
use Leantime\Domain\Setting\Repositories\Setting;
use Leantime\Domain\Tickets\Services\Tickets;
use Unit\TestCase;
class MenuRepositoryTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* The test object
*
* @var Menu
*/
protected $menu;
protected function setUp(): void
{
parent::setUp();
if (! defined('BASE_URL')) {
define('BASE_URL', 'http://localhost');
}
// Mock classes
$settingsRepo = $this->make(Setting::class);
$language = $this->make(Language::class);
$config = $this->make(Environment::class);
$ticketService = $this->make(Tickets::class, [
'getLastTicketViewUrl' => function () {
return '';
},
'getLastTimelineViewUrl' => function () {
return '';
},
]);
// Load class to be tested
$this->menu = new Menu(
settingsRepo: $settingsRepo,
language: $language,
config: $config,
ticketsService: $ticketService
);
}
protected function _after()
{
$this->menu = null;
}
// Write tests below
/**
* Test GetMenuTypes method
*/
public function test_get_menu_types()
{
$result = $this->menu->getMenuTypes();
// Assert that the result is an array
$this->assertIsArray($result);
// Assert that menu types have the expected keys
$this->assertContains(Menu::DEFAULT_MENU, array_keys($result));
// Further assertions can be done depending on use case and requirements
}
public function test_get_default_menu_structure()
{
$expected = $this->menu::DEFAULT_MENU;
$defaultStructure = $this->menu->getMenuStructure();
// Menu structure checks if roles are set in a menu item and will disable a menu item if not allowed to see
// User executing the test is not logged in, has no session so it being disabled is correct
$this->menu->menuStructures[$expected][40]['submenu'][80]['type'] = 'disabled';
$this->menu->menuStructures[$expected][30]['submenu'][30]['href'] = '/ideas/showBoards';
$this->assertEquals($this->menu->menuStructures[$expected], $defaultStructure, 'Default menu structure does not match the expected structure');
}
public function test_get_full_menu_structure()
{
$expected = 'full_menu';
$fullMenuStructure = $this->menu->getMenuStructure('full_menu');
$this->menu->menuStructures[$expected][80]['submenu'][83]['type'] = 'disabled';
$this->assertEquals($this->menu->menuStructures[$expected], $fullMenuStructure, 'Full menu structure does not match the expected structure');
}
public function test_get_invalid_menu_structure()
{
$expected = [];
$invalidMenuStructure = $this->menu->getMenuStructure('invalid');
$this->assertEquals($expected, $invalidMenuStructure, 'Invalid menu structure does not match the expected structure');
}
public function test_get_filtered_menu_structure()
{
\Leantime\Core\Events\EventDispatcher::add_filter_listener('leantime.domain.menu.repositories.menu.getMenuStructure.menuStructures.company', function ($menu) {
if (isset($menu[15]) && isset($menu[15]['submenu'])) {
unset($menu[15]['submenu'][20]);
}
return $menu;
}, 10);
$fullMenuStructure = $this->menu->getMenuStructure('company');
$this->assertIsArray($fullMenuStructure[15]['submenu']);
$this->assertFalse(isset($fullMenuStructure[15]['submenu'][20]), 'menu item was not removed');
}
public function test_inject_new_project_menu_type()
{
\Leantime\Core\Events\EventDispatcher::add_filter_listener('leantime.domain.menu.repositories.menu.getMenuStructure.menuStructures', function ($menuStructure) {
$testStructure = [
10 => ['item' => 'myNewMenu', 'type' => 'item'],
];
$menuStructure['testType'] = $testStructure;
return $menuStructure;
}, 10);
$fullMenuStructure = $this->menu->getMenuStructure('testType');
$this->assertIsArray($fullMenuStructure);
$this->assertEquals('myNewMenu', $fullMenuStructure[10]['item']);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Unit\app\Domain\Menu\Services;
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
use Leantime\Domain\Menu\Services\Menu;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Setting\Services\Setting;
use Leantime\Domain\Users\Services\Users;
use Unit\TestCase;
/**
* Unit tests for the pure project-selector helper logic extracted from the
* Menu ProjectSelector HxController into the Menu service.
*/
class MenuServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a Menu service with stubbed collaborators. The helpers under test
* (settings link + redirect url) do not touch any collaborator, so the
* dependencies just need to exist.
*/
private function makeService(): Menu
{
return new Menu(
$this->make(ProjectService::class),
$this->make(Users::class),
$this->make(Setting::class),
$this->make(MenuRepository::class),
);
}
public function test_settings_link_is_populated_for_project_menu(): void
{
$service = $this->makeService();
$link = $service->getProjectSelectorSettingsLink('project');
$this->assertSame('projects', $link['module']);
$this->assertSame('showProject', $link['action']);
$this->assertArrayHasKey('label', $link);
$this->assertArrayHasKey('settingsIcon', $link);
$this->assertArrayHasKey('settingsTooltip', $link);
}
public function test_settings_link_is_populated_for_default_menu(): void
{
$service = $this->makeService();
$link = $service->getProjectSelectorSettingsLink('default');
$this->assertSame('projects', $link['module']);
$this->assertSame('showProject', $link['action']);
}
public function test_settings_link_is_empty_for_other_menu_types(): void
{
$service = $this->makeService();
$link = $service->getProjectSelectorSettingsLink('personal');
$this->assertSame([
'label' => '',
'module' => '',
'action' => '',
'settingsIcon' => '',
'settingsTooltip' => '',
], $link);
}
public function test_redirect_url_rewrites_show_project_to_dashboard(): void
{
$service = $this->makeService();
$this->assertSame(
'/dashboard/show',
$service->getProjectSelectorRedirectUrl('/projects/showProject/5')
);
}
public function test_redirect_url_is_unchanged_for_other_uris(): void
{
$service = $this->makeService();
$this->assertSame(
'/tickets/showKanban',
$service->getProjectSelectorRedirectUrl('/tickets/showKanban')
);
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace Unit\app\Domain\Notifications;
use Leantime\Domain\Notifications\Models\Notification;
use PHPUnit\Framework\TestCase;
class NotificationCategoryTest extends TestCase
{
/**
* @dataProvider moduleToCategoryProvider
*/
public function test_get_category_for_module_return_correct_category(string $module, ?string $expectedCategory): void
{
$this->assertSame($expectedCategory, Notification::getCategoryForModule($module));
}
public static function moduleToCategoryProvider(): array
{
return [
'tickets maps to tasks' => ['tickets', 'tasks'],
'comments maps to comments' => ['comments', 'comments'],
'goalcanvas maps to goals' => ['goalcanvas', 'goals'],
'ideas maps to ideas' => ['ideas', 'ideas'],
'projects maps to projects' => ['projects', 'projects'],
'leancanvas maps to boards' => ['leancanvas', 'boards'],
'swotcanvas maps to boards' => ['swotcanvas', 'boards'],
'retroscanvas maps to boards' => ['retroscanvas', 'boards'],
'cpcanvas maps to boards' => ['cpcanvas', 'boards'],
'unknown module returns null' => ['someOtherModule', null],
];
}
public function test_all_categories_have_required_structure(): void
{
$categories = Notification::NOTIFICATION_CATEGORIES;
$this->assertArrayHasKey('tasks', $categories);
$this->assertArrayHasKey('comments', $categories);
$this->assertArrayHasKey('goals', $categories);
$this->assertArrayHasKey('ideas', $categories);
$this->assertArrayHasKey('projects', $categories);
$this->assertArrayHasKey('boards', $categories);
$this->assertCount(6, $categories);
// Each category must have 'modules' and 'description' keys
foreach ($categories as $key => $config) {
$this->assertArrayHasKey('modules', $config, "Category '$key' missing 'modules' key");
$this->assertArrayHasKey('description', $config, "Category '$key' missing 'description' key");
$this->assertIsArray($config['modules'], "Category '$key' modules must be an array");
$this->assertIsString($config['description'], "Category '$key' description must be a string");
$this->assertNotEmpty($config['description'], "Category '$key' description must not be empty");
}
}
public function test_goalcanvas_is_not_boards(): void
{
// goalcanvas specifically maps to 'goals', NOT 'boards'
$this->assertSame('goals', Notification::getCategoryForModule('goalcanvas'));
$this->assertNotSame('boards', Notification::getCategoryForModule('goalcanvas'));
}
public function test_get_category_keys_returns_all_keys(): void
{
$keys = Notification::getCategoryKeys();
$this->assertCount(6, $keys);
$this->assertContains('tasks', $keys);
$this->assertContains('comments', $keys);
$this->assertContains('goals', $keys);
$this->assertContains('ideas', $keys);
$this->assertContains('projects', $keys);
$this->assertContains('boards', $keys);
}
public function test_relevance_levels_are_defined(): void
{
$this->assertSame('all', Notification::RELEVANCE_ALL);
$this->assertSame('my_work', Notification::RELEVANCE_MY_WORK);
$this->assertSame('muted', Notification::RELEVANCE_MUTED);
$levels = Notification::RELEVANCE_LEVELS;
$this->assertCount(3, $levels);
$this->assertArrayHasKey('all', $levels);
$this->assertArrayHasKey('my_work', $levels);
$this->assertArrayHasKey('muted', $levels);
}
/**
* @dataProvider relevanceLevelValidationProvider
*/
public function test_is_valid_relevance_level(string $level, bool $expected): void
{
$this->assertSame($expected, Notification::isValidRelevanceLevel($level));
}
public static function relevanceLevelValidationProvider(): array
{
return [
'all is valid' => ['all', true],
'my_work is valid' => ['my_work', true],
'muted is valid' => ['muted', true],
'empty is invalid' => ['', false],
'random string is invalid' => ['something_else', false],
'ALL uppercase is invalid' => ['ALL', false],
];
}
public function test_notification_model_has_action_property(): void
{
$notification = new Notification;
$this->assertSame('', $notification->action);
$notification->action = 'created';
$this->assertSame('created', $notification->action);
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Unit\app\Domain\Notifications\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Notifications\Services\Messengers;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Unit\TestCase;
class MessengersServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private function makeNotification(int $projectId = 1, string $message = 'Test notification'): NotificationModel
{
$notification = new NotificationModel;
$notification->projectId = $projectId;
$notification->message = $message;
$notification->url = ['url' => 'https://example.com/ticket/123'];
return $notification;
}
public function test_send_notification_to_messengers_skips_telegram_when_unconfigured(): void
{
$posted = false;
$client = $this->make(Client::class, [
'post' => function () use (&$posted) {
$posted = true;
return new Response(200);
},
]);
$settingRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => false,
]);
$language = $this->make(LanguageCore::class);
$messengers = new Messengers($client, $settingRepo, $language);
$messengers->sendNotificationToMessengers($this->makeNotification(), 'Test Project', ['telegram']);
$this->assertFalse($posted);
}
public function test_telegram_webhook_returns_false_when_hook_missing_required_fields(): void
{
$posted = false;
$client = $this->make(Client::class, [
'post' => function () use (&$posted) {
$posted = true;
return new Response(200);
},
]);
$settingRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => serialize([
'telegramBotToken' => '12345:ABC',
'telegramChatId' => '',
'telegramTopicId' => '',
]),
]);
$language = $this->make(LanguageCore::class);
$messengers = new Messengers($client, $settingRepo, $language);
$messengers->sendNotificationToMessengers($this->makeNotification(), 'Test Project', ['telegram']);
$this->assertFalse($posted);
}
public function test_telegram_webhook_posts_to_api_and_succeeds(): void
{
$capturedUrl = null;
$capturedOptions = null;
$client = $this->make(Client::class, [
'post' => function ($url, $options) use (&$capturedUrl, &$capturedOptions) {
$capturedUrl = $url;
$capturedOptions = $options;
return new Response(200, [], json_encode(['ok' => true]));
},
]);
$settingRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => serialize([
'telegramBotToken' => '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
'telegramChatId' => '987654321',
'telegramTopicId' => '',
]),
]);
$language = $this->make(LanguageCore::class);
$messengers = new Messengers($client, $settingRepo, $language);
$messengers->sendNotificationToMessengers($this->makeNotification(1, 'Task created'), 'Acme Project', ['telegram']);
$this->assertSame('https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/sendMessage', $capturedUrl);
$this->assertArrayHasKey('json', $capturedOptions);
$this->assertSame('987654321', $capturedOptions['json']['chat_id']);
$this->assertStringContainsString('<b>Acme Project</b>', $capturedOptions['json']['text']);
$this->assertStringContainsString('Task created', $capturedOptions['json']['text']);
$this->assertStringContainsString('https://example.com/ticket/123', $capturedOptions['json']['text']);
$this->assertSame('HTML', $capturedOptions['json']['parse_mode']);
$this->assertArrayNotHasKey('message_thread_id', $capturedOptions['json']);
}
public function test_telegram_webhook_includes_message_thread_id_when_topic_id_provided(): void
{
$capturedOptions = null;
$client = $this->make(Client::class, [
'post' => function ($url, $options) use (&$capturedOptions) {
$capturedOptions = $options;
return new Response(200, [], json_encode(['ok' => true]));
},
]);
$settingRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => serialize([
'telegramBotToken' => '123456:ABC-DEF',
'telegramChatId' => '-1001234567890',
'telegramTopicId' => '42',
]),
]);
$language = $this->make(LanguageCore::class);
$messengers = new Messengers($client, $settingRepo, $language);
$messengers->sendNotificationToMessengers($this->makeNotification(), 'Test Project', ['telegram']);
$this->assertArrayHasKey('json', $capturedOptions);
$this->assertSame('-1001234567890', $capturedOptions['json']['chat_id']);
$this->assertSame(42, $capturedOptions['json']['message_thread_id']);
}
public function test_telegram_webhook_catches_guzzle_exception_and_returns_false(): void
{
$client = $this->make(Client::class, [
'post' => function () {
throw new RequestException('API connection error', new Request('POST', 'test'));
},
]);
$settingRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => serialize([
'telegramBotToken' => '123456:ABC-DEF',
'telegramChatId' => '987654321',
'telegramTopicId' => '',
]),
]);
$language = $this->make(LanguageCore::class);
$messengers = new Messengers($client, $settingRepo, $language);
$reflectedMethod = new \ReflectionMethod($messengers, 'telegramWebhook');
$reflectedMethod->setAccessible(true);
$result = $reflectedMethod->invoke($messengers, $this->makeNotification());
$this->assertFalse($result);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Unit\app\Domain\Notifications\Services;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Notifications\Repositories\Notifications as NotificationRepository;
use Leantime\Domain\Notifications\Services\Notifications;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the flash-notification orchestration extracted from the
* Notifications GetLatestGrowl controller into the Notifications service.
*/
class NotificationsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a Notifications service with stubbed dependencies. The
* consumeFlashNotification logic only touches the session, so the
* collaborators just need to exist.
*/
private function makeService(): Notifications
{
return new Notifications(
$this->make(NotificationRepository::class),
$this->make(UserRepository::class),
$this->make(LanguageCore::class),
);
}
public function test_consume_flash_notification_returns_null_when_empty(): void
{
session(['notification' => '']);
$service = $this->makeService();
$this->assertNull($service->consumeFlashNotification());
}
public function test_consume_flash_notification_returns_payload_and_clears_session(): void
{
session(['notification' => 'Saved!']);
session(['notificationType' => 'success']);
session(['eventId' => 'ticket-42']);
$service = $this->makeService();
$payload = $service->consumeFlashNotification();
$this->assertSame([
'notification' => 'Saved!',
'type' => 'success',
'eventId' => 'ticket-42',
], $payload);
// Read-once: session keys are cleared after consumption.
$this->assertSame('', session('notification'));
$this->assertSame('', session('notificationType'));
$this->assertSame('', session('eventId'));
}
public function test_consume_flash_notification_defaults_missing_type_and_event(): void
{
session()->forget('notificationType');
session()->forget('eventId');
session(['notification' => 'Hello']);
$service = $this->makeService();
$payload = $service->consumeFlashNotification();
$this->assertSame([
'notification' => 'Hello',
'type' => '',
'eventId' => '',
], $payload);
}
// ---------------------------------------------------------------------
// markRead() — session-based JSON-RPC wrapper (must target the session user)
// ---------------------------------------------------------------------
public function test_mark_read_all_targets_the_session_user(): void
{
session(['userdata' => ['id' => 42]]);
$capturedUserId = null;
$repo = $this->make(NotificationRepository::class, [
'markAllNotificationRead' => function ($userId, ...$rest) use (&$capturedUserId) {
$capturedUserId = $userId;
return true;
},
]);
$service = new Notifications(
$repo,
$this->make(UserRepository::class),
$this->make(LanguageCore::class),
);
$this->assertTrue($service->markRead('all'));
$this->assertSame(42, $capturedUserId, "markRead('all') must use the session user");
}
public function test_mark_read_specific_id_delegates_to_repo(): void
{
session(['userdata' => ['id' => 7]]);
$calledWith = null;
$repo = $this->make(NotificationRepository::class, [
'markNotificationRead' => function ($id, ...$rest) use (&$calledWith) {
$calledWith = $id;
return true;
},
]);
$service = new Notifications(
$repo,
$this->make(UserRepository::class),
$this->make(LanguageCore::class),
);
$this->assertTrue($service->markRead(5));
$this->assertSame(5, $calledWith);
}
}

View File

@@ -0,0 +1,244 @@
<?php
namespace Tests\Unit\app\Domain\Oidc\Controllers;
use Leantime\Core\Application;
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
use Leantime\Core\Bootstrap\LoadConfig;
use Leantime\Core\Bootstrap\SetRequestForConsole;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\UI\Template;
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
use Leantime\Domain\Oidc\Controllers\Mobile;
use Leantime\Domain\Oidc\Services\OidcMobileCode;
use Leantime\Domain\Plugins\Services\Plugins;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
/**
* Unit tests for the mobile SSO exchange endpoint.
*
* These pin the security-critical contract: POST-only, PKCE-before-consume
* (a bad verifier must NOT burn the code), and orphan-token prevention
* (mint only after user existence is confirmed).
*/
class MobileTest extends \Unit\TestCase
{
private OidcMobileCode $codes;
private AccessTokenRepository $tokens;
private UserRepository $userRepo;
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->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class));
$this->codes = $this->createMock(OidcMobileCode::class);
$this->tokens = $this->createMock(AccessTokenRepository::class);
$this->userRepo = $this->createMock(UserRepository::class);
$this->app->instance(OidcMobileCode::class, $this->codes);
$this->app->instance(AccessTokenRepository::class, $this->tokens);
$this->app->instance(UserRepository::class, $this->userRepo);
// Mobile SSO is gated on AdvancedAuth (see Mobile::exchange). Mock the
// plugin as installed so these exchange-contract tests run past the gate;
// the gate itself is verified live e2e (AdvancedAuth off -> 404).
$plugins = $this->createMock(Plugins::class);
$plugins->method('isEnabled')->willReturn(true);
$this->app->instance(Plugins::class, $plugins);
// Back the RateLimiter facade with a fresh in-memory store so throttle
// state is deterministic and isolated per test.
\Illuminate\Support\Facades\Facade::setFacadeApplication($this->app);
$this->app->instance(
\Illuminate\Cache\RateLimiter::class,
new \Illuminate\Cache\RateLimiter(
new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore)
)
);
}
private function makeController(string $method = 'POST', array $body = []): Mobile
{
// IncomingRequest inherits getMethod() from Symfony's Request, where it
// reads from the request's internal server bag. Building a real instance
// is simpler and more accurate than mocking through the inheritance chain.
// $body populates the POST (request) bag — what the controller reads via
// ->post(); a query string on the URL is deliberately NOT read.
$request = IncomingRequest::create('/oidc/mobile/exchange', $method, $body);
$this->app->instance(IncomingRequest::class, $request);
return new Mobile($request, $this->createMock(Template::class), $this->createMock(Language::class));
}
private function bodyOf($response): array
{
return json_decode($response->getContent(), true);
}
public function test_get_is_rejected_with_405(): void
{
// Peek must never be called — the request is rejected before the code store is touched.
$this->codes->expects($this->never())->method('peekCode');
$controller = $this->makeController('GET', ['code' => 'x', 'code_verifier' => 'y']);
$response = $controller->exchange([]);
$this->assertSame(405, $response->getStatusCode());
$this->assertSame('POST', $response->headers->get('Allow'));
$this->assertSame('method_not_allowed', $this->bodyOf($response)['error']);
}
public function test_missing_code_returns_400(): void
{
$this->codes->expects($this->never())->method('peekCode');
$controller = $this->makeController();
$response = $controller->exchange([]);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('missing_code', $this->bodyOf($response)['error']);
}
public function test_unknown_code_returns_401_and_does_not_consume(): void
{
$this->codes->method('peekCode')->with('bad')->willReturn(null);
// Nothing to consume for an unknown code — but assert it explicitly.
$this->codes->expects($this->never())->method('consumeCode');
$controller = $this->makeController('POST', ['code' => 'bad', 'code_verifier' => 'v']);
$response = $controller->exchange([]);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame('invalid_code', $this->bodyOf($response)['error']);
}
public function test_invalid_verifier_does_not_burn_the_code(): void
{
// The core DoS-protection contract: a wrong verifier from a scheme-
// hijacker must not consume the code, so the legitimate app can still
// exchange it.
$this->codes->method('peekCode')->willReturn([
'userId' => 42,
'challenge' => 'somechallenge',
]);
$this->codes->expects($this->never())->method('consumeCode');
$this->tokens->expects($this->never())->method('createToken');
$controller = $this->makeController('POST', ['code' => 'good', 'code_verifier' => 'wrong-verifier']);
$response = $controller->exchange([]);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame('invalid_verifier', $this->bodyOf($response)['error']);
}
public function test_missing_user_returns_401_without_minting(): void
{
// PKCE(S256) of the verifier 'testverifier' — used below to pass PKCE.
$verifier = 'testverifier';
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$this->codes->method('peekCode')->willReturn(['userId' => 99, 'challenge' => $challenge]);
$this->userRepo->method('getUser')->with(99)->willReturn([]);
$this->codes->expects($this->never())->method('consumeCode');
$this->tokens->expects($this->never())->method('createToken');
$controller = $this->makeController('POST', ['code' => 'good', 'code_verifier' => $verifier]);
$response = $controller->exchange([]);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame('invalid_user', $this->bodyOf($response)['error']);
}
public function test_valid_exchange_consumes_code_and_mints_token(): void
{
$verifier = 'testverifier';
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$this->codes->method('peekCode')->with('good')->willReturn(['userId' => 7, 'challenge' => $challenge]);
$this->userRepo->method('getUser')->with(7)->willReturn([
'id' => 7, 'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b',
'password' => 'SHOULD_NOT_APPEAR', 'twoFAEnabled' => 1,
]);
// Code consumed exactly once, AFTER all validation; returns true (this
// caller won the single-use race), so minting proceeds.
$this->codes->expects($this->once())->method('consumeCode')->with('good')->willReturn(true);
// Minted with full scope AND an explicit expiry (not non-expiring).
$this->tokens->expects($this->once())->method('createToken')
->with(7, 'mobile-sso', ['*'], $this->isInstanceOf(\DateTimeInterface::class))
->willReturn(['token' => 'the-token']);
$controller = $this->makeController('POST', ['code' => 'good', 'code_verifier' => $verifier]);
$response = $controller->exchange([]);
$this->assertSame(200, $response->getStatusCode());
$body = $this->bodyOf($response);
$this->assertSame('the-token', $body['token']);
// Only safe identity fields — never password / 2FA state.
$this->assertSame(['id', 'firstname', 'lastname', 'username'], array_keys($body['user']));
}
public function test_secrets_in_query_string_are_ignored(): void
{
// The code + verifier must come from the POST body, never the URL query
// (URLs land in access logs). A ?code=... is not read, so this is a
// missing_code — and the code store is never touched.
$this->codes->expects($this->never())->method('peekCode');
$request = IncomingRequest::create('/oidc/mobile/exchange?code=fromquery&code_verifier=v', 'POST');
$this->app->instance(IncomingRequest::class, $request);
$controller = new Mobile($request, $this->createMock(Template::class), $this->createMock(Language::class));
$response = $controller->exchange([]);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('missing_code', $this->bodyOf($response)['error']);
}
public function test_exchange_is_rate_limited_per_ip(): void
{
// Once the per-IP cap is hit, further attempts are refused with 429
// BEFORE the code store is consulted — throttling code/verifier probing.
$this->codes->expects($this->never())->method('peekCode');
for ($i = 0; $i < 10; $i++) {
\Illuminate\Support\Facades\RateLimiter::hit('oidc.mobile.exchange:127.0.0.1', 60);
}
$controller = $this->makeController('POST', ['code' => 'x', 'code_verifier' => 'y']);
$response = $controller->exchange([]);
$this->assertSame(429, $response->getStatusCode());
$this->assertSame('too_many_requests', $this->bodyOf($response)['error']);
$this->assertNotNull($response->headers->get('Retry-After'));
}
public function test_lost_consume_race_does_not_mint(): void
{
// Two concurrent exchanges both peek the same valid code; the one whose
// atomic consumeCode() returns false (the other burned it first) must
// NOT mint a second token from a single-use code.
$verifier = 'testverifier';
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$this->codes->method('peekCode')->willReturn(['userId' => 7, 'challenge' => $challenge]);
$this->userRepo->method('getUser')->with(7)->willReturn([
'id' => 7, 'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b',
]);
$this->codes->method('consumeCode')->with('good')->willReturn(false);
$this->tokens->expects($this->never())->method('createToken');
$controller = $this->makeController('POST', ['code' => 'good', 'code_verifier' => $verifier]);
$response = $controller->exchange([]);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame('invalid_code', $this->bodyOf($response)['error']);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Tests\Unit\app\Domain\Oidc\Services;
use Leantime\Domain\Oidc\Services\OidcMobileCode;
/**
* Unit tests for the mobile SSO one-time-code store.
*
* Pins the single-use contract: peekCode() is non-destructive, and consumeCode()
* burns the code exactly once (returns true for the caller that burns it, false
* for a second/unknown code). Runs against the array cache store from
* \Unit\TestCase, which supports the atomic lock consumeCode() takes.
*/
class OidcMobileCodeTest extends \Unit\TestCase
{
private OidcMobileCode $codes;
protected function setUp(): void
{
parent::setUp();
$this->codes = new OidcMobileCode;
}
public function test_peek_is_non_destructive_and_returns_the_payload(): void
{
$code = $this->codes->createCode(42, 'challenge-abc');
$first = $this->codes->peekCode($code);
$second = $this->codes->peekCode($code);
$this->assertSame(['userId' => 42, 'challenge' => 'challenge-abc'], $first);
$this->assertSame($first, $second, 'peekCode() must not consume the code');
}
public function test_consume_returns_true_once_then_false(): void
{
$code = $this->codes->createCode(7, 'ch');
$this->assertTrue($this->codes->consumeCode($code), 'first consume burns the code');
$this->assertFalse($this->codes->consumeCode($code), 'a single-use code cannot be consumed twice');
}
public function test_consumed_code_no_longer_peeks(): void
{
$code = $this->codes->createCode(5, 'ch');
$this->codes->consumeCode($code);
$this->assertNull($this->codes->peekCode($code), 'a burned code is gone');
}
public function test_consume_unknown_code_returns_false(): void
{
$this->assertFalse($this->codes->consumeCode('never-minted'));
}
public function test_peek_unknown_code_returns_null(): void
{
$this->assertNull($this->codes->peekCode('never-minted'));
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace Unit\app\Domain\Plugins\Services;
use GuzzleHttp\Psr7\Response as PsrResponse;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response as HttpResponse;
use Leantime\Domain\Plugins\Models\MarketplacePlugin;
use Leantime\Domain\Plugins\Services\Plugins as PluginService;
use Unit\TestCase;
/**
* Unit tests for the Plugins service helpers extracted during the
* thin-controller refactor (buildMarketplacePluginFromRequest, isBundle,
* parseMarketplaceError, performPluginAction).
*/
class PluginsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
public function test_build_marketplace_plugin_from_request_decodes_json_fields(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class);
$plugin = $service->buildMarketplacePluginFromRequest([
'identifier' => 'acme-plugin',
'name' => 'Acme Plugin',
'categories' => json_encode([['slug' => 'reporting', 'name' => 'Reporting']]),
]);
$this->assertInstanceOf(MarketplacePlugin::class, $plugin);
$this->assertSame('acme-plugin', $plugin->identifier);
$this->assertSame('Acme Plugin', $plugin->name);
$this->assertSame([['slug' => 'reporting', 'name' => 'Reporting']], $plugin->categories);
}
public function test_build_marketplace_plugin_from_request_keeps_non_json_strings(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class);
$plugin = $service->buildMarketplacePluginFromRequest([
'name' => 'Just a string',
'excerpt' => 'Not json',
]);
$this->assertSame('Just a string', $plugin->name);
$this->assertSame('Not json', $plugin->excerpt);
}
public function test_build_marketplace_plugin_from_request_ignores_disallowed_control_fields(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class);
$plugin = $service->buildMarketplacePluginFromRequest([
'identifier' => 'acme-plugin',
'type' => 'system',
'marketplaceUrl' => 'https://attacker.example.com',
]);
// Allowlisted field is set; sensitive control fields are ignored and keep their defaults.
$this->assertSame('acme-plugin', $plugin->identifier);
$this->assertSame('marketplace', $plugin->type);
$this->assertSame('', $plugin->marketplaceUrl);
}
public function test_is_bundle_true_when_bundles_category_present(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class);
$plugin = new MarketplacePlugin;
$plugin->categories = [
['slug' => 'reporting'],
['slug' => 'bundles'],
];
$this->assertTrue($service->isBundle($plugin));
}
public function test_is_bundle_false_when_no_bundles_category(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class);
$plugin = new MarketplacePlugin;
$plugin->categories = [
['slug' => 'reporting'],
];
$this->assertFalse($service->isBundle($plugin));
}
public function test_parse_marketplace_error_extracts_clean_message(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class);
$exception = new RequestException(
new HttpResponse(new PsrResponse(500, [], '{"error":"License invalid"}'))
);
$this->assertSame('License invalid', $service->parseMarketplaceError($exception));
}
public function test_parse_marketplace_error_falls_back_to_generic_message(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class);
$exception = new RequestException(
new HttpResponse(new PsrResponse(200, [], 'not-json'))
);
$this->assertSame('There was an error installing the plugin', $service->parseMarketplaceError($exception));
}
public function test_perform_plugin_action_returns_success_descriptor(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class, [
'enablePlugin' => fn () => true,
]);
$this->assertSame(
['notification.plugin_enable_success', 'success'],
$service->performPluginAction('enable', 5)
);
}
public function test_perform_plugin_action_returns_error_descriptor_on_failure(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class, [
'disablePlugin' => fn () => false,
]);
$this->assertSame(
['notification.plugin_disable_error', 'error'],
$service->performPluginAction('disable', 5)
);
}
public function test_perform_plugin_action_rejects_unknown_action(): void
{
/** @var PluginService $service */
$service = $this->make(PluginService::class);
$this->expectException(\InvalidArgumentException::class);
$service->performPluginAction('explode', 5);
}
}

Some files were not shown because too many files have changed in this diff Show More