OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
35
tests/Acceptance.suite.yml
Normal file
35
tests/Acceptance.suite.yml
Normal 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
|
||||
175
tests/Acceptance/API/ApiCest.php
Normal file
175
tests/Acceptance/API/ApiCest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
252
tests/Acceptance/API/BearerApiCest.php
Normal file
252
tests/Acceptance/API/BearerApiCest.php
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
250
tests/Acceptance/API/McpCest.php
Normal file
250
tests/Acceptance/API/McpCest.php
Normal 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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
79
tests/Acceptance/BlueprintsCest.php
Normal file
79
tests/Acceptance/BlueprintsCest.php
Normal 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"]');
|
||||
}
|
||||
}
|
||||
58
tests/Acceptance/CreateUserCest.php
Normal file
58
tests/Acceptance/CreateUserCest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
46
tests/Acceptance/GoalsCest.php
Normal file
46
tests/Acceptance/GoalsCest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
35
tests/Acceptance/InstallCest.php
Normal file
35
tests/Acceptance/InstallCest.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
52
tests/Acceptance/LoginCest.php
Normal file
52
tests/Acceptance/LoginCest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
75
tests/Acceptance/TicketsCest.php
Normal file
75
tests/Acceptance/TicketsCest.php
Normal 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%',
|
||||
]);
|
||||
}
|
||||
}
|
||||
308
tests/Acceptance/TimesheetCest.php
Normal file
308
tests/Acceptance/TimesheetCest.php
Normal 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);
|
||||
|
||||
}
|
||||
}
|
||||
34
tests/Acceptance/WikiCest.php
Normal file
34
tests/Acceptance/WikiCest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
218
tests/Acceptance/bootstrap.php
Normal file
218
tests/Acceptance/bootstrap.php
Normal 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();
|
||||
132
tests/Httprequests/JsonRPC-bearer.http
Normal file
132
tests/Httprequests/JsonRPC-bearer.http
Normal 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
|
||||
}
|
||||
74
tests/Httprequests/JsonRPC.http
Normal file
74
tests/Httprequests/JsonRPC.http
Normal 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
112
tests/Httprequests/MCP.http
Normal 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
|
||||
|
||||
###
|
||||
7
tests/Httprequests/http-client.sample.env.json
Normal file
7
tests/Httprequests/http-client.sample.env.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dev": {
|
||||
"base-url": "https://localhost",
|
||||
"x-api-key": "APIKEY",
|
||||
"bearer-token": "TOKEN"
|
||||
}
|
||||
}
|
||||
59
tests/Support/AcceptanceTester.php
Normal file
59
tests/Support/AcceptanceTester.php
Normal 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());
|
||||
}
|
||||
}
|
||||
22
tests/Support/ApiTester.php
Normal file
22
tests/Support/ApiTester.php
Normal 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;
|
||||
}
|
||||
0
tests/Support/Data/.gitkeep
Normal file
0
tests/Support/Data/.gitkeep
Normal file
36
tests/Support/Helper/Acceptance.php
Normal file
36
tests/Support/Helper/Acceptance.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
tests/Support/Helper/Api.php
Normal file
27
tests/Support/Helper/Api.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
tests/Support/Helper/Unit.php
Normal file
39
tests/Support/Helper/Unit.php
Normal 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);
|
||||
}
|
||||
}
|
||||
81
tests/Support/Page/Acceptance/Install.php
Normal file
81
tests/Support/Page/Acceptance/Install.php
Normal 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);
|
||||
}
|
||||
}
|
||||
63
tests/Support/Page/Acceptance/Login.php
Normal file
63
tests/Support/Page/Acceptance/Login.php
Normal 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));
|
||||
}
|
||||
}
|
||||
30
tests/Support/UnitTester.php
Normal file
30
tests/Support/UnitTester.php
Normal 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
2
tests/Support/_generated/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
13
tests/Unit.suite.yml
Normal file
13
tests/Unit.suite.yml
Normal 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
0
tests/Unit/.gitkeep
Normal file
35
tests/Unit/TestCase.php
Normal file
35
tests/Unit/TestCase.php
Normal 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' => [],
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
96
tests/Unit/app/Core/ApiClientTest.php
Normal file
96
tests/Unit/app/Core/ApiClientTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
109
tests/Unit/app/Core/ApplicationUrlTest.php
Normal file
109
tests/Unit/app/Core/ApplicationUrlTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
206
tests/Unit/app/Core/Auth/Permissions/PermissionEnforcerTest.php
Normal file
206
tests/Unit/app/Core/Auth/Permissions/PermissionEnforcerTest.php
Normal 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 {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
395
tests/Unit/app/Core/Events/ClassEventDispatchTest.php
Normal file
395
tests/Unit/app/Core/Events/ClassEventDispatchTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
104
tests/Unit/app/Core/Events/EventVocabularyTest.php
Normal file
104
tests/Unit/app/Core/Events/EventVocabularyTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
69
tests/Unit/app/Core/Events/EventsTest.php
Normal file
69
tests/Unit/app/Core/Events/EventsTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
22
tests/Unit/app/Core/ExampleTest.php
Normal file
22
tests/Unit/app/Core/ExampleTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
109
tests/Unit/app/Core/Exceptions/LeantimeExceptionTest.php
Normal file
109
tests/Unit/app/Core/Exceptions/LeantimeExceptionTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
408
tests/Unit/app/Core/Files/FileManagerTest.php
Normal file
408
tests/Unit/app/Core/Files/FileManagerTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
45
tests/Unit/app/Core/Http/Responses/ImageResponseTest.php
Normal file
45
tests/Unit/app/Core/Http/Responses/ImageResponseTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
47
tests/Unit/app/Core/Http/Responses/JsonRpcResponseTest.php
Normal file
47
tests/Unit/app/Core/Http/Responses/JsonRpcResponseTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
118
tests/Unit/app/Core/Middleware/AuthCheckTest.php
Normal file
118
tests/Unit/app/Core/Middleware/AuthCheckTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
137
tests/Unit/app/Core/Middleware/SessionMergeTest.php
Normal file
137
tests/Unit/app/Core/Middleware/SessionMergeTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
117
tests/Unit/app/Core/Middleware/UpdatedTest.php
Normal file
117
tests/Unit/app/Core/Middleware/UpdatedTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
164
tests/Unit/app/Core/Resources/Models/ResourceSummaryTest.php
Normal file
164
tests/Unit/app/Core/Resources/Models/ResourceSummaryTest.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
117
tests/Unit/app/Core/Resources/Services/ResourcesRegistryTest.php
Normal file
117
tests/Unit/app/Core/Resources/Services/ResourcesRegistryTest.php
Normal 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]);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
147
tests/Unit/app/Core/Support/AvatarcreatorTest.php
Normal file
147
tests/Unit/app/Core/Support/AvatarcreatorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
122
tests/Unit/app/Core/Support/CarbonMacrosTest.php
Normal file
122
tests/Unit/app/Core/Support/CarbonMacrosTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
290
tests/Unit/app/Core/Support/DateTimeHelperTest.php
Normal file
290
tests/Unit/app/Core/Support/DateTimeHelperTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
73
tests/Unit/app/Core/Support/FormatTest.php
Normal file
73
tests/Unit/app/Core/Support/FormatTest.php
Normal 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.
|
||||
}
|
||||
61
tests/Unit/app/Core/Support/NameSanitizerTest.php
Normal file
61
tests/Unit/app/Core/Support/NameSanitizerTest.php
Normal 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"));
|
||||
}
|
||||
}
|
||||
84
tests/Unit/app/Core/Support/OutboundUrlGuardTest.php
Normal file
84
tests/Unit/app/Core/Support/OutboundUrlGuardTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
61
tests/Unit/app/Core/UI/TemplateEscapeTest.php
Normal file
61
tests/Unit/app/Core/UI/TemplateEscapeTest.php
Normal 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ü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(
|
||||
'ü',
|
||||
$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('<script>alert(1)</script>', $this->escape('<script>alert(1)</script>'));
|
||||
$this->assertSame('" onerror="alert(1)', $this->escape('" onerror="alert(1)'));
|
||||
$this->assertSame('' onmouseover='x', $this->escape("' onmouseover='x"));
|
||||
$this->assertSame('a < b & c > 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 &.
|
||||
$this->assertSame('Müller & Söhne', $this->escape('Müller & Söhne'));
|
||||
}
|
||||
|
||||
public function test_null_is_an_empty_string(): void
|
||||
{
|
||||
$this->assertSame('', $this->escape(null));
|
||||
}
|
||||
}
|
||||
112
tests/Unit/app/Core/UI/ThemeTest.php
Normal file
112
tests/Unit/app/Core/UI/ThemeTest.php
Normal 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);
|
||||
|
||||
}
|
||||
}
|
||||
264
tests/Unit/app/Domain/Api/Controllers/JsonrpcTest.php
Normal file
264
tests/Unit/app/Domain/Api/Controllers/JsonrpcTest.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Domain\Api\Controllers;
|
||||
|
||||
use Leantime\Core\Application;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Bootstrap\LoadConfig;
|
||||
use Leantime\Core\Bootstrap\SetRequestForConsole;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Leantime\Domain\Api\Controllers\Jsonrpc;
|
||||
|
||||
/**
|
||||
* The controller now builds its envelopes through the JsonRpcResponse / JsonRpcErrorResponse
|
||||
* response types, so these tests assert on the actual JSON body of the returned Response
|
||||
* (behavior) rather than on the Template::displayJson() call that used to construct it.
|
||||
*/
|
||||
class JsonrpcTest extends \Unit\TestCase
|
||||
{
|
||||
private Jsonrpc $controller;
|
||||
|
||||
private Template $template;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->app = new Application(APP_ROOT);
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
|
||||
$this->app->boot();
|
||||
$this->app['view'] = $this->createMock(\Illuminate\View\Factory::class);
|
||||
$this->app['session'] = $this->createMock(\Illuminate\Session\SessionManager::class);
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
|
||||
// Jsonrpc::init() now type-hints PermissionEnforcer (resolved via app()->call in the
|
||||
// base Controller constructor). Bind a no-op mock so the controller builds without
|
||||
// pulling in the full permission engine (PermissionService -> Repository -> Db), which
|
||||
// this minimal test container can't resolve. These tests don't exercise authorization.
|
||||
$this->app->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class));
|
||||
|
||||
$this->template = $this->createMock(Template::class);
|
||||
$language = $this->createMock(Language::class);
|
||||
$this->controller = new Jsonrpc($this->app['request'], $this->template, $language);
|
||||
$_SERVER['REQUEST_METHOD'] = 'post';
|
||||
}
|
||||
|
||||
private function bodyOf($response): array
|
||||
{
|
||||
return json_decode($response->getContent(), true);
|
||||
}
|
||||
|
||||
public function test_method_string_parsing()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertIsArray($body);
|
||||
$this->assertArrayHasKey('jsonrpc', $body);
|
||||
$this->assertEquals('2.0', $body['jsonrpc']);
|
||||
}
|
||||
|
||||
public function test_invalid_method_string()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'invalid.method.string',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertArrayHasKey('error', $body);
|
||||
$this->assertEquals(-32602, $body['error']['code']);
|
||||
}
|
||||
|
||||
public function test_missing_json_rpc_version()
|
||||
{
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertArrayHasKey('error', $body);
|
||||
$this->assertEquals(-32600, $body['error']['code']);
|
||||
}
|
||||
|
||||
public function test_batch_request()
|
||||
{
|
||||
$params = [
|
||||
[
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 1,
|
||||
'jsonrpc' => '2.0',
|
||||
],
|
||||
[
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 2],
|
||||
'id' => 2,
|
||||
'jsonrpc' => '2.0',
|
||||
],
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
// The batch response is an array with one envelope per sub-request.
|
||||
$this->assertIsArray($body);
|
||||
$this->assertCount(2, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The riskiest behavioral change: the service-call catch is now catch(\Throwable) and an
|
||||
* UNEXPECTED throwable must be collapsed to a generic -32000 with its message NOT leaked.
|
||||
* Driven end-to-end through the controller by rebinding the (@api) Comments service to a
|
||||
* stub that throws.
|
||||
*/
|
||||
public function test_service_throwing_unknown_error_is_generic_and_not_leaked()
|
||||
{
|
||||
$secret = 'super-secret-internal-detail';
|
||||
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class($secret)
|
||||
{
|
||||
public function __construct(private string $secret) {}
|
||||
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \RuntimeException($this->secret);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 42,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$response = $this->controller->post($params);
|
||||
$body = $this->bodyOf($response);
|
||||
|
||||
$this->assertSame(-32000, $body['error']['code']);
|
||||
$this->assertSame('Server error', $body['error']['message']);
|
||||
$this->assertSame(42, $body['id']);
|
||||
$this->assertStringNotContainsString($secret, $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed Leantime exception thrown by a service maps to ITS JSON-RPC code (here -32001 for
|
||||
* AuthorizationException), not the generic -32000, with the request id preserved.
|
||||
*/
|
||||
public function test_service_throwing_typed_exception_maps_to_its_rpc_code()
|
||||
{
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class
|
||||
{
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \Leantime\Core\Exceptions\AuthorizationException;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'id' => 7,
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$body = $this->bodyOf($this->controller->post($params));
|
||||
|
||||
$this->assertSame(-32001, $body['error']['code']);
|
||||
$this->assertSame(7, $body['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A notification (no id) whose service call fails must NOT be responded to — the controller
|
||||
* returns an empty 200 instead of a JSON-RPC error envelope (JSON-RPC 2.0).
|
||||
*/
|
||||
public function test_notification_service_failure_returns_empty_200()
|
||||
{
|
||||
$this->app->bind(
|
||||
\Leantime\Domain\Comments\Services\Comments::class,
|
||||
fn () => new class
|
||||
{
|
||||
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
|
||||
{
|
||||
throw new \RuntimeException('boom');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// No 'id' => JSON-RPC notification.
|
||||
$params = [
|
||||
'method' => 'leantime.rpc.Comments.pollComments',
|
||||
'params' => ['projectId' => 1],
|
||||
'jsonrpc' => '2.0',
|
||||
];
|
||||
|
||||
$response = $this->controller->post($params);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertSame('', $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* @api detection must only recognize the tag at the START of a docblock line (" * @api").
|
||||
* A method whose docblock merely MENTIONS @api in prose (e.g. a de-@api'd internal helper
|
||||
* documented as "not exposed, unlike @api methods") must NOT become JSON-RPC reachable —
|
||||
* regression guard for the IDOR fix where "Not @api:" still matched the old /@api\b/ regex.
|
||||
*/
|
||||
public function test_api_detection_requires_the_tag_at_a_docblock_line_start(): void
|
||||
{
|
||||
$isApiMethod = new \ReflectionMethod(Jsonrpc::class, 'isApiMethod');
|
||||
$isApiMethod->setAccessible(true);
|
||||
$invoke = fn (string $class, string $method): bool => $isApiMethod->invoke($this->controller, $class, $method);
|
||||
|
||||
// A genuine ` * @api` docblock line IS recognized.
|
||||
$this->assertTrue($invoke(IsApiFixture::class, 'realApiMethod'));
|
||||
// A prose mention of @api (and a method with no docblock) must NOT be recognized.
|
||||
$this->assertFalse($invoke(IsApiFixture::class, 'proseMentionMethod'));
|
||||
$this->assertFalse($invoke(IsApiFixture::class, 'noDocblockMethod'));
|
||||
|
||||
// The real de-@api'd internal helpers must NOT be JSON-RPC reachable (IDOR fixes):
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Clients\Services\Clients::class, 'getUserClients'));
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Users\Services\Users::class, 'setProfilePicture'));
|
||||
$this->assertFalse($invoke(\Leantime\Domain\Users\Services\Users::class, 'editOwn'));
|
||||
// ...while a genuine @api service method stays reachable.
|
||||
$this->assertTrue($invoke(\Leantime\Domain\Clients\Services\Clients::class, 'getAll'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture for isApiMethod() docblock-detection tests.
|
||||
*/
|
||||
class IsApiFixture
|
||||
{
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public function realApiMethod(): void {}
|
||||
|
||||
/**
|
||||
* @internal Not exposed over JSON-RPC, unlike @api methods — a prose mention only.
|
||||
*/
|
||||
public function proseMentionMethod(): void {}
|
||||
|
||||
public function noDocblockMethod(): void {}
|
||||
}
|
||||
220
tests/Unit/app/Domain/Api/Services/ApiServiceTest.php
Normal file
220
tests/Unit/app/Domain/Api/Services/ApiServiceTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Api\Services;
|
||||
|
||||
use Leantime\Domain\Api\Repositories\Api as ApiRepository;
|
||||
use Leantime\Domain\Api\Services\Api as ApiService;
|
||||
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Api service helpers extracted during the thin-controller
|
||||
* refactor (project relation reconciliation, API key creation/update, image
|
||||
* response building and user filtering).
|
||||
*/
|
||||
class ApiServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Api service, allowing each dependency to be overridden with
|
||||
* a stub so we can observe the persistence calls.
|
||||
*/
|
||||
private function makeService(
|
||||
?ApiRepository $apiRepo = null,
|
||||
?UserRepository $userRepo = null,
|
||||
?ProjectRepository $projectRepo = null,
|
||||
?MenuRepository $menuRepo = null,
|
||||
): ApiService {
|
||||
return new ApiService(
|
||||
$apiRepo ?? $this->make(ApiRepository::class),
|
||||
$userRepo ?? $this->make(UserRepository::class),
|
||||
$projectRepo ?? $this->make(ProjectRepository::class),
|
||||
$menuRepo ?? $this->make(MenuRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_project_relation_ids_extracts_project_ids(): void
|
||||
{
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'getUserProjectRelation' => fn () => [
|
||||
['projectId' => 5],
|
||||
['projectId' => 9],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->makeService(projectRepo: $projectRepo)->getProjectRelationIds(3);
|
||||
|
||||
$this->assertSame([5, 9], $result);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_sets_relations_when_projects_selected(): void
|
||||
{
|
||||
$editCalledWith = null;
|
||||
$deleteCalled = false;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '77',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function ($id, $projects) use (&$editCalledWith) {
|
||||
$editCalledWith = [$id, $projects];
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function () use (&$deleteCalled) {
|
||||
$deleteCalled = true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key', 'role' => '20'], ['3', '4']);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame('77', $result['id']);
|
||||
// id is cast to int when reconciling relations.
|
||||
$this->assertSame([77, ['3', '4']], $editCalledWith);
|
||||
$this->assertFalse($deleteCalled);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_clears_relations_when_leading_zero(): void
|
||||
{
|
||||
$editCalled = false;
|
||||
$deleteCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '88',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function () use (&$editCalled) {
|
||||
$editCalled = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function ($id) use (&$deleteCalledWith) {
|
||||
$deleteCalledWith = $id;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], ['0']);
|
||||
|
||||
$this->assertFalse($editCalled);
|
||||
$this->assertSame(88, $deleteCalledWith);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_skips_reconcile_when_no_projects(): void
|
||||
{
|
||||
$touched = false;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => '5',
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function () use (&$touched) {
|
||||
$touched = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'deleteAllProjectRelations' => function () use (&$touched) {
|
||||
$touched = true;
|
||||
},
|
||||
]);
|
||||
|
||||
// null projects and empty array both mean "do nothing".
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], null);
|
||||
|
||||
$this->assertFalse($touched);
|
||||
}
|
||||
|
||||
public function test_create_api_key_with_projects_returns_false_when_user_not_created(): void
|
||||
{
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'addUser' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo)
|
||||
->createApiKeyWithProjects(['firstname' => 'Key'], ['3']);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_update_api_key_edits_user_and_reconciles_relations(): void
|
||||
{
|
||||
$editUserCalledWith = null;
|
||||
$editRelationsCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'firstname' => 'Old',
|
||||
'username' => 'lt_old',
|
||||
'status' => 'i',
|
||||
'role' => '10',
|
||||
],
|
||||
'editUser' => function ($values, $id) use (&$editUserCalledWith) {
|
||||
$editUserCalledWith = [$values, $id];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'editUserProjectRelations' => function ($id, $projects) use (&$editRelationsCalledWith) {
|
||||
$editRelationsCalledWith = [$id, $projects];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->updateApiKey(12, ['firstname' => 'New', 'status' => 'a', 'role' => '20'], ['7']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
// Posted firstname/status/role applied, username preserved from row, source forced to 'api'.
|
||||
$this->assertSame(12, $editUserCalledWith[1]);
|
||||
$this->assertSame('New', $editUserCalledWith[0]['firstname']);
|
||||
$this->assertSame('a', $editUserCalledWith[0]['status']);
|
||||
$this->assertSame('20', $editUserCalledWith[0]['role']);
|
||||
$this->assertSame('lt_old', $editUserCalledWith[0]['user']);
|
||||
$this->assertSame('api', $editUserCalledWith[0]['source']);
|
||||
$this->assertSame([12, ['7']], $editRelationsCalledWith);
|
||||
}
|
||||
|
||||
public function test_update_api_key_falls_back_to_row_values_when_not_posted(): void
|
||||
{
|
||||
$editUserCalledWith = null;
|
||||
|
||||
$userRepo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'firstname' => 'Old',
|
||||
'username' => 'lt_old',
|
||||
'status' => 'i',
|
||||
'role' => '10',
|
||||
],
|
||||
'editUser' => function ($values) use (&$editUserCalledWith) {
|
||||
$editUserCalledWith = $values;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$projectRepo = $this->make(ProjectRepository::class, [
|
||||
'deleteAllProjectRelations' => fn () => null,
|
||||
]);
|
||||
|
||||
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
|
||||
->updateApiKey(12, [], null);
|
||||
|
||||
$this->assertSame('Old', $editUserCalledWith['firstname']);
|
||||
$this->assertSame('i', $editUserCalledWith['status']);
|
||||
$this->assertSame('10', $editUserCalledWith['role']);
|
||||
}
|
||||
|
||||
public function test_update_api_key_throws_on_invalid_id(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
$this->makeService()->updateApiKey(0, [], null);
|
||||
}
|
||||
}
|
||||
45
tests/Unit/app/Domain/Api/Services/I18nServiceTest.php
Normal file
45
tests/Unit/app/Domain/Api/Services/I18nServiceTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Api\Services;
|
||||
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Domain\Api\Services\I18n as I18nService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the I18n service that assembles the JavaScript i18n
|
||||
* dictionary payload extracted from the I18n controller.
|
||||
*/
|
||||
class I18nServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_build_js_dictionary_embeds_dictionary_and_date_overrides(): void
|
||||
{
|
||||
$language = $this->make(Language::class, [
|
||||
'ini_array' => [
|
||||
'some.key' => 'Some value',
|
||||
'language.dateformat' => 'IGNORED',
|
||||
'language.timeformat' => 'IGNORED',
|
||||
],
|
||||
'__' => fn (string $index) => $index === 'language.dateformat' ? 'm/d/Y' : 'H:i',
|
||||
]);
|
||||
|
||||
$payload = (new I18nService($language))->buildJsDictionary();
|
||||
|
||||
// The JS wrapper is present.
|
||||
$this->assertStringContainsString('leantime', $payload);
|
||||
$this->assertStringContainsString('i18n', $payload);
|
||||
$this->assertStringContainsString('dictionary:', $payload);
|
||||
|
||||
// Extract the JSON dictionary and assert the overrides won.
|
||||
preg_match('/dictionary: (\{.*\}),/', $payload, $matches);
|
||||
$this->assertNotEmpty($matches, 'Could not find dictionary JSON in payload');
|
||||
|
||||
$decoded = json_decode($matches[1], true);
|
||||
$this->assertSame('Some value', $decoded['some.key']);
|
||||
$this->assertSame('m/d/Y', $decoded['language.dateformat']);
|
||||
$this->assertSame('H:i', $decoded['language.timeformat']);
|
||||
$this->assertArrayHasKey('usersettings.timezone', $decoded);
|
||||
}
|
||||
}
|
||||
316
tests/Unit/app/Domain/Auth/Services/AuthServiceTest.php
Normal file
316
tests/Unit/app/Domain/Auth/Services/AuthServiceTest.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
161
tests/Unit/app/Domain/Auth/Services/OnboardingServiceTest.php
Normal file
161
tests/Unit/app/Domain/Auth/Services/OnboardingServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
372
tests/Unit/app/Domain/Calendar/Services/CalendarServiceTest.php
Normal file
372
tests/Unit/app/Domain/Calendar/Services/CalendarServiceTest.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
170
tests/Unit/app/Domain/Clients/Services/ClientsServiceTest.php
Normal file
170
tests/Unit/app/Domain/Clients/Services/ClientsServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
330
tests/Unit/app/Domain/Comments/Services/CommentsServiceTest.php
Normal file
330
tests/Unit/app/Domain/Comments/Services/CommentsServiceTest.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Comments\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Comments\Services\Comments;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reactions\Services\Reactions as ReactionsService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Comments service: reaction orchestration plus the project-scoped
|
||||
* authorization fences (comments are read/moderated against the host entity's REAL project).
|
||||
*/
|
||||
class CommentsServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/** The session user used across the reaction tests. */
|
||||
private const SESSION_USER = 5;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
session(['userdata.id' => self::SESSION_USER]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the service. By default the comment repository resolves a real (ticket) comment in
|
||||
* project 9 and the permission engine allows everything; pass overrides to exercise denials.
|
||||
*/
|
||||
private function makeService(
|
||||
ReactionsService $reactionsService,
|
||||
?CommentRepository $repo = null,
|
||||
?PermissionService $permissions = null,
|
||||
): Comments {
|
||||
$service = new Comments(
|
||||
$repo ?? $this->defaultRepo(),
|
||||
$this->make(ProjectService::class),
|
||||
$this->make(LanguageCore::class),
|
||||
$reactionsService,
|
||||
);
|
||||
$service->setPermissionService($permissions ?? $this->allowingPermissions());
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function defaultRepo(): CommentRepository
|
||||
{
|
||||
return $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => self::SESSION_USER, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
]);
|
||||
}
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => fn () => null,
|
||||
'currentUserCan' => fn () => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
'currentUserCan' => fn () => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function noopReactions(): ReactionsService
|
||||
{
|
||||
return $this->make(ReactionsService::class, []);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Reaction orchestration (existing behaviour, now session-pinned).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_toggle_rejects_unknown_reaction_type(): void
|
||||
{
|
||||
$added = false;
|
||||
$removed = false;
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => false,
|
||||
'addReaction' => function () use (&$added) {
|
||||
$added = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
'removeReaction' => function () use (&$removed) {
|
||||
$removed = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'bogus');
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertFalse($added, 'No reaction should be added for an unknown type');
|
||||
$this->assertFalse($removed, 'No reaction should be removed for an unknown type');
|
||||
}
|
||||
|
||||
public function test_toggle_off_removes_existing_same_reaction(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$added = false;
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => [['reaction' => 'thumbsup']],
|
||||
'removeReaction' => function ($userId, $module, $moduleId, $reaction) use (&$removeCalls) {
|
||||
$removeCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function () use (&$added) {
|
||||
$added = true;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertFalse($added, 'Toggling off should not add a reaction');
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $removeCalls);
|
||||
}
|
||||
|
||||
public function test_toggle_on_replaces_existing_sentiment(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$addCalls = [];
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => function ($userId, $module, $moduleId, $reaction = '') {
|
||||
if ($reaction !== '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [['reaction' => 'thumbsdown']];
|
||||
},
|
||||
'removeReaction' => function ($userId, $module, $moduleId, $reaction) use (&$removeCalls) {
|
||||
$removeCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsdown']], $removeCalls);
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls);
|
||||
}
|
||||
|
||||
public function test_toggle_on_with_no_existing_reactions_just_adds(): void
|
||||
{
|
||||
$removeCalls = [];
|
||||
$addCalls = [];
|
||||
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => false,
|
||||
'removeReaction' => function (...$args) use (&$removeCalls) {
|
||||
$removeCalls[] = $args;
|
||||
|
||||
return true;
|
||||
},
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame([], $removeCalls, 'Nothing to remove when there are no existing reactions');
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_flattens_user_reaction_codes(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getEntityReactionsWithUsers' => fn () => ['thumbsup' => ['count' => 2]],
|
||||
'getUserReactions' => fn () => [
|
||||
['reaction' => 'thumbsup'],
|
||||
['reaction' => 'heart'],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->getCommentReactions(99, 5);
|
||||
|
||||
$this->assertSame(['thumbsup' => ['count' => 2]], $result['reactions']);
|
||||
$this->assertSame(['thumbsup', 'heart'], $result['userReactions']);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_handles_anonymous_user(): void
|
||||
{
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getEntityReactionsWithUsers' => fn () => [],
|
||||
'getUserReactions' => fn () => [['reaction' => 'thumbsup']],
|
||||
]);
|
||||
|
||||
$result = $this->makeService($reactionsService)->getCommentReactions(99, 0);
|
||||
|
||||
$this->assertSame([], $result['reactions']);
|
||||
$this->assertSame([], $result['userReactions']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Project-scoped authorization fences (the IDOR hardening).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_toggle_reaction_uses_session_user_not_caller_supplied_id(): void
|
||||
{
|
||||
// A caller passes someone else's id; the service must react as the SESSION user only.
|
||||
$addCalls = [];
|
||||
$reactionsService = $this->make(ReactionsService::class, [
|
||||
'getReactionType' => fn () => 'positive',
|
||||
'getUserReactions' => fn () => false,
|
||||
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
|
||||
$addCalls[] = [$userId, $module, $moduleId, $reaction];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService($reactionsService)->toggleCommentReaction(999, 99, 'thumbsup');
|
||||
|
||||
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls, 'Reaction must use the session user, not the caller-supplied id');
|
||||
}
|
||||
|
||||
public function test_toggle_reaction_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// Valid reaction type so the method reaches the project fence (not the type guard). A denied
|
||||
// cross-project comment returns false — same as a missing comment, so no existence oracle.
|
||||
$reactions = $this->make(ReactionsService::class, ['getReactionType' => fn () => 'positive']);
|
||||
$service = $this->makeService($reactions, $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup'));
|
||||
}
|
||||
|
||||
public function test_get_comments_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// getComments resolves the host entity's project and authorizes VIEW there; a denying
|
||||
// engine must throw before any comment data is returned.
|
||||
$service = $this->makeService($this->noopReactions(), $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->getComments('ticket', 123);
|
||||
}
|
||||
|
||||
public function test_delete_comment_denies_non_author_moderation_cross_project(): void
|
||||
{
|
||||
// Comment belongs to another user; the session user is NOT a moderator in the comment's
|
||||
// project (denying engine) -> deleteComment must refuse and never reach the repo delete.
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => 7, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
'deleteComment' => function (): bool {
|
||||
throw new \RuntimeException('delete must not run when moderation is denied');
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertFalse($service->deleteComment(99));
|
||||
}
|
||||
|
||||
public function test_delete_comment_allows_the_author_without_moderation(): void
|
||||
{
|
||||
$deleted = null;
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
// Authored by the session user -> author branch, no moderation check needed.
|
||||
'getComment' => fn () => ['id' => 99, 'userId' => self::SESSION_USER, 'module' => 'ticket', 'moduleId' => 1],
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
'deleteComment' => function ($id) use (&$deleted): bool {
|
||||
$deleted = $id;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
// Denying engine proves the author path does NOT depend on comments.moderate.
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertTrue($service->deleteComment(99));
|
||||
$this->assertSame(99, $deleted);
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_is_denied_for_a_foreign_project(): void
|
||||
{
|
||||
// A denied cross-project comment returns the SAME empty payload as a missing comment
|
||||
// (soft-deny), so reactor identities/sentiment never leak AND missing vs unauthorized are
|
||||
// indistinguishable — no commentId existence oracle.
|
||||
$service = $this->makeService($this->noopReactions(), $this->defaultRepo(), $this->denyingPermissions());
|
||||
|
||||
$this->assertSame(['reactions' => [], 'userReactions' => []], $service->getCommentReactions(99, self::SESSION_USER));
|
||||
}
|
||||
|
||||
public function test_get_comment_reactions_returns_empty_for_missing_comment(): void
|
||||
{
|
||||
// A missing comment yields the same empty payload a DENIED comment does (see above), so the
|
||||
// two are indistinguishable — no commentId existence oracle.
|
||||
$repo = $this->make(CommentRepository::class, [
|
||||
'getComment' => fn () => false,
|
||||
'resolveModuleProjectId' => fn () => 9,
|
||||
]);
|
||||
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
|
||||
|
||||
$this->assertSame(['reactions' => [], 'userReactions' => []], $service->getCommentReactions(404, self::SESSION_USER));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
103
tests/Unit/app/Domain/CsvImport/Services/CsvImportTest.php
Normal file
103
tests/Unit/app/Domain/CsvImport/Services/CsvImportTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
524
tests/Unit/app/Domain/Files/Services/FilesServiceTest.php
Normal file
524
tests/Unit/app/Domain/Files/Services/FilesServiceTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
137
tests/Unit/app/Domain/Help/Services/FirstTaskStepTest.php
Normal file
137
tests/Unit/app/Domain/Help/Services/FirstTaskStepTest.php
Normal 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']]],
|
||||
];
|
||||
}
|
||||
}
|
||||
96
tests/Unit/app/Domain/Help/Services/HelperTest.php
Normal file
96
tests/Unit/app/Domain/Help/Services/HelperTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
538
tests/Unit/app/Domain/Ideas/Services/IdeasServiceTest.php
Normal file
538
tests/Unit/app/Domain/Ideas/Services/IdeasServiceTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
151
tests/Unit/app/Domain/Install/Services/InstallServiceTest.php
Normal file
151
tests/Unit/app/Domain/Install/Services/InstallServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
89
tests/Unit/app/Domain/Install/UpdateSql30504Test.php
Normal file
89
tests/Unit/app/Domain/Install/UpdateSql30504Test.php
Normal 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');
|
||||
}
|
||||
}
|
||||
281
tests/Unit/app/Domain/Install/UpdateSql30524Test.php
Normal file
281
tests/Unit/app/Domain/Install/UpdateSql30524Test.php
Normal 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');
|
||||
}
|
||||
}
|
||||
139
tests/Unit/app/Domain/Install/UpdateSql30526Test.php
Normal file
139
tests/Unit/app/Domain/Install/UpdateSql30526Test.php
Normal 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');
|
||||
}
|
||||
}
|
||||
147
tests/Unit/app/Domain/Menu/Repositories/MenuRepositoryTest.php
Normal file
147
tests/Unit/app/Domain/Menu/Repositories/MenuRepositoryTest.php
Normal 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']);
|
||||
|
||||
}
|
||||
}
|
||||
92
tests/Unit/app/Domain/Menu/Services/MenuServiceTest.php
Normal file
92
tests/Unit/app/Domain/Menu/Services/MenuServiceTest.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
116
tests/Unit/app/Domain/Notifications/NotificationCategoryTest.php
Normal file
116
tests/Unit/app/Domain/Notifications/NotificationCategoryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
244
tests/Unit/app/Domain/Oidc/Controllers/MobileTest.php
Normal file
244
tests/Unit/app/Domain/Oidc/Controllers/MobileTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
62
tests/Unit/app/Domain/Oidc/Services/OidcMobileCodeTest.php
Normal file
62
tests/Unit/app/Domain/Oidc/Services/OidcMobileCodeTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
155
tests/Unit/app/Domain/Plugins/Services/PluginsServiceTest.php
Normal file
155
tests/Unit/app/Domain/Plugins/Services/PluginsServiceTest.php
Normal 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
Reference in New Issue
Block a user