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

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

View File

@@ -0,0 +1,264 @@
<?php
namespace Tests\Unit\app\Domain\Api\Controllers;
use Leantime\Core\Application;
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
use Leantime\Core\Bootstrap\LoadConfig;
use Leantime\Core\Bootstrap\SetRequestForConsole;
use Leantime\Core\Language;
use Leantime\Core\UI\Template;
use Leantime\Domain\Api\Controllers\Jsonrpc;
/**
* The controller now builds its envelopes through the JsonRpcResponse / JsonRpcErrorResponse
* response types, so these tests assert on the actual JSON body of the returned Response
* (behavior) rather than on the Template::displayJson() call that used to construct it.
*/
class JsonrpcTest extends \Unit\TestCase
{
private Jsonrpc $controller;
private Template $template;
protected function setUp(): void
{
parent::setUp();
$this->app = new Application(APP_ROOT);
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
$this->app->boot();
$this->app['view'] = $this->createMock(\Illuminate\View\Factory::class);
$this->app['session'] = $this->createMock(\Illuminate\Session\SessionManager::class);
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
// Jsonrpc::init() now type-hints PermissionEnforcer (resolved via app()->call in the
// base Controller constructor). Bind a no-op mock so the controller builds without
// pulling in the full permission engine (PermissionService -> Repository -> Db), which
// this minimal test container can't resolve. These tests don't exercise authorization.
$this->app->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class));
$this->template = $this->createMock(Template::class);
$language = $this->createMock(Language::class);
$this->controller = new Jsonrpc($this->app['request'], $this->template, $language);
$_SERVER['REQUEST_METHOD'] = 'post';
}
private function bodyOf($response): array
{
return json_decode($response->getContent(), true);
}
public function test_method_string_parsing()
{
$params = [
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 1,
'jsonrpc' => '2.0',
];
$body = $this->bodyOf($this->controller->post($params));
$this->assertIsArray($body);
$this->assertArrayHasKey('jsonrpc', $body);
$this->assertEquals('2.0', $body['jsonrpc']);
}
public function test_invalid_method_string()
{
$params = [
'method' => 'invalid.method.string',
'params' => ['projectId' => 1],
'id' => 1,
'jsonrpc' => '2.0',
];
$body = $this->bodyOf($this->controller->post($params));
$this->assertArrayHasKey('error', $body);
$this->assertEquals(-32602, $body['error']['code']);
}
public function test_missing_json_rpc_version()
{
$params = [
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 1,
];
$body = $this->bodyOf($this->controller->post($params));
$this->assertArrayHasKey('error', $body);
$this->assertEquals(-32600, $body['error']['code']);
}
public function test_batch_request()
{
$params = [
[
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 1,
'jsonrpc' => '2.0',
],
[
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 2],
'id' => 2,
'jsonrpc' => '2.0',
],
];
$body = $this->bodyOf($this->controller->post($params));
// The batch response is an array with one envelope per sub-request.
$this->assertIsArray($body);
$this->assertCount(2, $body);
}
/**
* The riskiest behavioral change: the service-call catch is now catch(\Throwable) and an
* UNEXPECTED throwable must be collapsed to a generic -32000 with its message NOT leaked.
* Driven end-to-end through the controller by rebinding the (@api) Comments service to a
* stub that throws.
*/
public function test_service_throwing_unknown_error_is_generic_and_not_leaked()
{
$secret = 'super-secret-internal-detail';
$this->app->bind(
\Leantime\Domain\Comments\Services\Comments::class,
fn () => new class($secret)
{
public function __construct(private string $secret) {}
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
{
throw new \RuntimeException($this->secret);
}
}
);
$params = [
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 42,
'jsonrpc' => '2.0',
];
$response = $this->controller->post($params);
$body = $this->bodyOf($response);
$this->assertSame(-32000, $body['error']['code']);
$this->assertSame('Server error', $body['error']['message']);
$this->assertSame(42, $body['id']);
$this->assertStringNotContainsString($secret, $response->getContent());
}
/**
* A typed Leantime exception thrown by a service maps to ITS JSON-RPC code (here -32001 for
* AuthorizationException), not the generic -32000, with the request id preserved.
*/
public function test_service_throwing_typed_exception_maps_to_its_rpc_code()
{
$this->app->bind(
\Leantime\Domain\Comments\Services\Comments::class,
fn () => new class
{
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
{
throw new \Leantime\Core\Exceptions\AuthorizationException;
}
}
);
$params = [
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'id' => 7,
'jsonrpc' => '2.0',
];
$body = $this->bodyOf($this->controller->post($params));
$this->assertSame(-32001, $body['error']['code']);
$this->assertSame(7, $body['id']);
}
/**
* A notification (no id) whose service call fails must NOT be responded to — the controller
* returns an empty 200 instead of a JSON-RPC error envelope (JSON-RPC 2.0).
*/
public function test_notification_service_failure_returns_empty_200()
{
$this->app->bind(
\Leantime\Domain\Comments\Services\Comments::class,
fn () => new class
{
public function pollComments(?int $projectId = null, ?int $moduleId = null): array
{
throw new \RuntimeException('boom');
}
}
);
// No 'id' => JSON-RPC notification.
$params = [
'method' => 'leantime.rpc.Comments.pollComments',
'params' => ['projectId' => 1],
'jsonrpc' => '2.0',
];
$response = $this->controller->post($params);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('', $response->getContent());
}
/**
* @api detection must only recognize the tag at the START of a docblock line (" * @api").
* A method whose docblock merely MENTIONS @api in prose (e.g. a de-@api'd internal helper
* documented as "not exposed, unlike @api methods") must NOT become JSON-RPC reachable —
* regression guard for the IDOR fix where "Not @api:" still matched the old /@api\b/ regex.
*/
public function test_api_detection_requires_the_tag_at_a_docblock_line_start(): void
{
$isApiMethod = new \ReflectionMethod(Jsonrpc::class, 'isApiMethod');
$isApiMethod->setAccessible(true);
$invoke = fn (string $class, string $method): bool => $isApiMethod->invoke($this->controller, $class, $method);
// A genuine ` * @api` docblock line IS recognized.
$this->assertTrue($invoke(IsApiFixture::class, 'realApiMethod'));
// A prose mention of @api (and a method with no docblock) must NOT be recognized.
$this->assertFalse($invoke(IsApiFixture::class, 'proseMentionMethod'));
$this->assertFalse($invoke(IsApiFixture::class, 'noDocblockMethod'));
// The real de-@api'd internal helpers must NOT be JSON-RPC reachable (IDOR fixes):
$this->assertFalse($invoke(\Leantime\Domain\Clients\Services\Clients::class, 'getUserClients'));
$this->assertFalse($invoke(\Leantime\Domain\Users\Services\Users::class, 'setProfilePicture'));
$this->assertFalse($invoke(\Leantime\Domain\Users\Services\Users::class, 'editOwn'));
// ...while a genuine @api service method stays reachable.
$this->assertTrue($invoke(\Leantime\Domain\Clients\Services\Clients::class, 'getAll'));
}
}
/**
* Fixture for isApiMethod() docblock-detection tests.
*/
class IsApiFixture
{
/**
* @api
*/
public function realApiMethod(): void {}
/**
* @internal Not exposed over JSON-RPC, unlike @api methods — a prose mention only.
*/
public function proseMentionMethod(): void {}
public function noDocblockMethod(): void {}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace Unit\app\Domain\Api\Services;
use Leantime\Domain\Api\Repositories\Api as ApiRepository;
use Leantime\Domain\Api\Services\Api as ApiService;
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the Api service helpers extracted during the thin-controller
* refactor (project relation reconciliation, API key creation/update, image
* response building and user filtering).
*/
class ApiServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a real Api service, allowing each dependency to be overridden with
* a stub so we can observe the persistence calls.
*/
private function makeService(
?ApiRepository $apiRepo = null,
?UserRepository $userRepo = null,
?ProjectRepository $projectRepo = null,
?MenuRepository $menuRepo = null,
): ApiService {
return new ApiService(
$apiRepo ?? $this->make(ApiRepository::class),
$userRepo ?? $this->make(UserRepository::class),
$projectRepo ?? $this->make(ProjectRepository::class),
$menuRepo ?? $this->make(MenuRepository::class),
);
}
public function test_get_project_relation_ids_extracts_project_ids(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjectRelation' => fn () => [
['projectId' => 5],
['projectId' => 9],
],
]);
$result = $this->makeService(projectRepo: $projectRepo)->getProjectRelationIds(3);
$this->assertSame([5, 9], $result);
}
public function test_create_api_key_with_projects_sets_relations_when_projects_selected(): void
{
$editCalledWith = null;
$deleteCalled = false;
$userRepo = $this->make(UserRepository::class, [
'addUser' => fn () => '77',
]);
$projectRepo = $this->make(ProjectRepository::class, [
'editUserProjectRelations' => function ($id, $projects) use (&$editCalledWith) {
$editCalledWith = [$id, $projects];
return true;
},
'deleteAllProjectRelations' => function () use (&$deleteCalled) {
$deleteCalled = true;
},
]);
$result = $this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
->createApiKeyWithProjects(['firstname' => 'Key', 'role' => '20'], ['3', '4']);
$this->assertIsArray($result);
$this->assertSame('77', $result['id']);
// id is cast to int when reconciling relations.
$this->assertSame([77, ['3', '4']], $editCalledWith);
$this->assertFalse($deleteCalled);
}
public function test_create_api_key_with_projects_clears_relations_when_leading_zero(): void
{
$editCalled = false;
$deleteCalledWith = null;
$userRepo = $this->make(UserRepository::class, [
'addUser' => fn () => '88',
]);
$projectRepo = $this->make(ProjectRepository::class, [
'editUserProjectRelations' => function () use (&$editCalled) {
$editCalled = true;
return true;
},
'deleteAllProjectRelations' => function ($id) use (&$deleteCalledWith) {
$deleteCalledWith = $id;
},
]);
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
->createApiKeyWithProjects(['firstname' => 'Key'], ['0']);
$this->assertFalse($editCalled);
$this->assertSame(88, $deleteCalledWith);
}
public function test_create_api_key_with_projects_skips_reconcile_when_no_projects(): void
{
$touched = false;
$userRepo = $this->make(UserRepository::class, [
'addUser' => fn () => '5',
]);
$projectRepo = $this->make(ProjectRepository::class, [
'editUserProjectRelations' => function () use (&$touched) {
$touched = true;
return true;
},
'deleteAllProjectRelations' => function () use (&$touched) {
$touched = true;
},
]);
// null projects and empty array both mean "do nothing".
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
->createApiKeyWithProjects(['firstname' => 'Key'], null);
$this->assertFalse($touched);
}
public function test_create_api_key_with_projects_returns_false_when_user_not_created(): void
{
$userRepo = $this->make(UserRepository::class, [
'addUser' => fn () => false,
]);
$result = $this->makeService(userRepo: $userRepo)
->createApiKeyWithProjects(['firstname' => 'Key'], ['3']);
$this->assertFalse($result);
}
public function test_update_api_key_edits_user_and_reconciles_relations(): void
{
$editUserCalledWith = null;
$editRelationsCalledWith = null;
$userRepo = $this->make(UserRepository::class, [
'getUser' => fn () => [
'firstname' => 'Old',
'username' => 'lt_old',
'status' => 'i',
'role' => '10',
],
'editUser' => function ($values, $id) use (&$editUserCalledWith) {
$editUserCalledWith = [$values, $id];
return true;
},
]);
$projectRepo = $this->make(ProjectRepository::class, [
'editUserProjectRelations' => function ($id, $projects) use (&$editRelationsCalledWith) {
$editRelationsCalledWith = [$id, $projects];
return true;
},
]);
$result = $this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
->updateApiKey(12, ['firstname' => 'New', 'status' => 'a', 'role' => '20'], ['7']);
$this->assertTrue($result);
// Posted firstname/status/role applied, username preserved from row, source forced to 'api'.
$this->assertSame(12, $editUserCalledWith[1]);
$this->assertSame('New', $editUserCalledWith[0]['firstname']);
$this->assertSame('a', $editUserCalledWith[0]['status']);
$this->assertSame('20', $editUserCalledWith[0]['role']);
$this->assertSame('lt_old', $editUserCalledWith[0]['user']);
$this->assertSame('api', $editUserCalledWith[0]['source']);
$this->assertSame([12, ['7']], $editRelationsCalledWith);
}
public function test_update_api_key_falls_back_to_row_values_when_not_posted(): void
{
$editUserCalledWith = null;
$userRepo = $this->make(UserRepository::class, [
'getUser' => fn () => [
'firstname' => 'Old',
'username' => 'lt_old',
'status' => 'i',
'role' => '10',
],
'editUser' => function ($values) use (&$editUserCalledWith) {
$editUserCalledWith = $values;
return true;
},
]);
$projectRepo = $this->make(ProjectRepository::class, [
'deleteAllProjectRelations' => fn () => null,
]);
$this->makeService(userRepo: $userRepo, projectRepo: $projectRepo)
->updateApiKey(12, [], null);
$this->assertSame('Old', $editUserCalledWith['firstname']);
$this->assertSame('i', $editUserCalledWith['status']);
$this->assertSame('10', $editUserCalledWith['role']);
}
public function test_update_api_key_throws_on_invalid_id(): void
{
$this->expectException(\Exception::class);
$this->makeService()->updateApiKey(0, [], null);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Unit\app\Domain\Api\Services;
use Leantime\Core\Language;
use Leantime\Domain\Api\Services\I18n as I18nService;
use Unit\TestCase;
/**
* Unit tests for the I18n service that assembles the JavaScript i18n
* dictionary payload extracted from the I18n controller.
*/
class I18nServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
public function test_build_js_dictionary_embeds_dictionary_and_date_overrides(): void
{
$language = $this->make(Language::class, [
'ini_array' => [
'some.key' => 'Some value',
'language.dateformat' => 'IGNORED',
'language.timeformat' => 'IGNORED',
],
'__' => fn (string $index) => $index === 'language.dateformat' ? 'm/d/Y' : 'H:i',
]);
$payload = (new I18nService($language))->buildJsDictionary();
// The JS wrapper is present.
$this->assertStringContainsString('leantime', $payload);
$this->assertStringContainsString('i18n', $payload);
$this->assertStringContainsString('dictionary:', $payload);
// Extract the JSON dictionary and assert the overrides won.
preg_match('/dictionary: (\{.*\}),/', $payload, $matches);
$this->assertNotEmpty($matches, 'Could not find dictionary JSON in payload');
$decoded = json_decode($matches[1], true);
$this->assertSame('Some value', $decoded['some.key']);
$this->assertSame('m/d/Y', $decoded['language.dateformat']);
$this->assertSame('H:i', $decoded['language.timeformat']);
$this->assertArrayHasKey('usersettings.timezone', $decoded);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,330 @@
<?php
namespace Unit\app\Domain\Comments\Services;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Comments\Services\Comments;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Reactions\Services\Reactions as ReactionsService;
use Unit\TestCase;
/**
* Unit tests for the Comments service: reaction orchestration plus the project-scoped
* authorization fences (comments are read/moderated against the host entity's REAL project).
*/
class CommentsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/** The session user used across the reaction tests. */
private const SESSION_USER = 5;
protected function setUp(): void
{
parent::setUp();
session(['userdata.id' => self::SESSION_USER]);
}
/**
* Build the service. By default the comment repository resolves a real (ticket) comment in
* project 9 and the permission engine allows everything; pass overrides to exercise denials.
*/
private function makeService(
ReactionsService $reactionsService,
?CommentRepository $repo = null,
?PermissionService $permissions = null,
): Comments {
$service = new Comments(
$repo ?? $this->defaultRepo(),
$this->make(ProjectService::class),
$this->make(LanguageCore::class),
$reactionsService,
);
$service->setPermissionService($permissions ?? $this->allowingPermissions());
return $service;
}
private function defaultRepo(): CommentRepository
{
return $this->make(CommentRepository::class, [
'getComment' => fn () => ['id' => 99, 'userId' => self::SESSION_USER, 'module' => 'ticket', 'moduleId' => 1],
'resolveModuleProjectId' => fn () => 9,
]);
}
private function allowingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => fn () => null,
'currentUserCan' => fn () => true,
]);
}
private function denyingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => function (): void {
throw new AuthorizationException;
},
'currentUserCan' => fn () => false,
]);
}
private function noopReactions(): ReactionsService
{
return $this->make(ReactionsService::class, []);
}
// ---------------------------------------------------------------------
// Reaction orchestration (existing behaviour, now session-pinned).
// ---------------------------------------------------------------------
public function test_toggle_rejects_unknown_reaction_type(): void
{
$added = false;
$removed = false;
$reactionsService = $this->make(ReactionsService::class, [
'getReactionType' => fn () => false,
'addReaction' => function () use (&$added) {
$added = true;
return true;
},
'removeReaction' => function () use (&$removed) {
$removed = true;
return true;
},
]);
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'bogus');
$this->assertFalse($result);
$this->assertFalse($added, 'No reaction should be added for an unknown type');
$this->assertFalse($removed, 'No reaction should be removed for an unknown type');
}
public function test_toggle_off_removes_existing_same_reaction(): void
{
$removeCalls = [];
$added = false;
$reactionsService = $this->make(ReactionsService::class, [
'getReactionType' => fn () => 'positive',
'getUserReactions' => fn () => [['reaction' => 'thumbsup']],
'removeReaction' => function ($userId, $module, $moduleId, $reaction) use (&$removeCalls) {
$removeCalls[] = [$userId, $module, $moduleId, $reaction];
return true;
},
'addReaction' => function () use (&$added) {
$added = true;
return true;
},
]);
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
$this->assertTrue($result);
$this->assertFalse($added, 'Toggling off should not add a reaction');
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $removeCalls);
}
public function test_toggle_on_replaces_existing_sentiment(): void
{
$removeCalls = [];
$addCalls = [];
$reactionsService = $this->make(ReactionsService::class, [
'getReactionType' => fn () => 'positive',
'getUserReactions' => function ($userId, $module, $moduleId, $reaction = '') {
if ($reaction !== '') {
return [];
}
return [['reaction' => 'thumbsdown']];
},
'removeReaction' => function ($userId, $module, $moduleId, $reaction) use (&$removeCalls) {
$removeCalls[] = [$userId, $module, $moduleId, $reaction];
return true;
},
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
$addCalls[] = [$userId, $module, $moduleId, $reaction];
return true;
},
]);
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
$this->assertTrue($result);
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsdown']], $removeCalls);
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls);
}
public function test_toggle_on_with_no_existing_reactions_just_adds(): void
{
$removeCalls = [];
$addCalls = [];
$reactionsService = $this->make(ReactionsService::class, [
'getReactionType' => fn () => 'positive',
'getUserReactions' => fn () => false,
'removeReaction' => function (...$args) use (&$removeCalls) {
$removeCalls[] = $args;
return true;
},
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
$addCalls[] = [$userId, $module, $moduleId, $reaction];
return true;
},
]);
$result = $this->makeService($reactionsService)->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup');
$this->assertTrue($result);
$this->assertSame([], $removeCalls, 'Nothing to remove when there are no existing reactions');
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls);
}
public function test_get_comment_reactions_flattens_user_reaction_codes(): void
{
$reactionsService = $this->make(ReactionsService::class, [
'getEntityReactionsWithUsers' => fn () => ['thumbsup' => ['count' => 2]],
'getUserReactions' => fn () => [
['reaction' => 'thumbsup'],
['reaction' => 'heart'],
],
]);
$result = $this->makeService($reactionsService)->getCommentReactions(99, 5);
$this->assertSame(['thumbsup' => ['count' => 2]], $result['reactions']);
$this->assertSame(['thumbsup', 'heart'], $result['userReactions']);
}
public function test_get_comment_reactions_handles_anonymous_user(): void
{
$reactionsService = $this->make(ReactionsService::class, [
'getEntityReactionsWithUsers' => fn () => [],
'getUserReactions' => fn () => [['reaction' => 'thumbsup']],
]);
$result = $this->makeService($reactionsService)->getCommentReactions(99, 0);
$this->assertSame([], $result['reactions']);
$this->assertSame([], $result['userReactions']);
}
// ---------------------------------------------------------------------
// Project-scoped authorization fences (the IDOR hardening).
// ---------------------------------------------------------------------
public function test_toggle_reaction_uses_session_user_not_caller_supplied_id(): void
{
// A caller passes someone else's id; the service must react as the SESSION user only.
$addCalls = [];
$reactionsService = $this->make(ReactionsService::class, [
'getReactionType' => fn () => 'positive',
'getUserReactions' => fn () => false,
'addReaction' => function ($userId, $module, $moduleId, $reaction) use (&$addCalls) {
$addCalls[] = [$userId, $module, $moduleId, $reaction];
return true;
},
]);
$this->makeService($reactionsService)->toggleCommentReaction(999, 99, 'thumbsup');
$this->assertSame([[self::SESSION_USER, 'comment', 99, 'thumbsup']], $addCalls, 'Reaction must use the session user, not the caller-supplied id');
}
public function test_toggle_reaction_is_denied_for_a_foreign_project(): void
{
// Valid reaction type so the method reaches the project fence (not the type guard). A denied
// cross-project comment returns false — same as a missing comment, so no existence oracle.
$reactions = $this->make(ReactionsService::class, ['getReactionType' => fn () => 'positive']);
$service = $this->makeService($reactions, $this->defaultRepo(), $this->denyingPermissions());
$this->assertFalse($service->toggleCommentReaction(self::SESSION_USER, 99, 'thumbsup'));
}
public function test_get_comments_is_denied_for_a_foreign_project(): void
{
// getComments resolves the host entity's project and authorizes VIEW there; a denying
// engine must throw before any comment data is returned.
$service = $this->makeService($this->noopReactions(), $this->defaultRepo(), $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->getComments('ticket', 123);
}
public function test_delete_comment_denies_non_author_moderation_cross_project(): void
{
// Comment belongs to another user; the session user is NOT a moderator in the comment's
// project (denying engine) -> deleteComment must refuse and never reach the repo delete.
$repo = $this->make(CommentRepository::class, [
'getComment' => fn () => ['id' => 99, 'userId' => 7, 'module' => 'ticket', 'moduleId' => 1],
'resolveModuleProjectId' => fn () => 9,
'deleteComment' => function (): bool {
throw new \RuntimeException('delete must not run when moderation is denied');
},
]);
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
$this->assertFalse($service->deleteComment(99));
}
public function test_delete_comment_allows_the_author_without_moderation(): void
{
$deleted = null;
$repo = $this->make(CommentRepository::class, [
// Authored by the session user -> author branch, no moderation check needed.
'getComment' => fn () => ['id' => 99, 'userId' => self::SESSION_USER, 'module' => 'ticket', 'moduleId' => 1],
'resolveModuleProjectId' => fn () => 9,
'deleteComment' => function ($id) use (&$deleted): bool {
$deleted = $id;
return true;
},
]);
// Denying engine proves the author path does NOT depend on comments.moderate.
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
$this->assertTrue($service->deleteComment(99));
$this->assertSame(99, $deleted);
}
public function test_get_comment_reactions_is_denied_for_a_foreign_project(): void
{
// A denied cross-project comment returns the SAME empty payload as a missing comment
// (soft-deny), so reactor identities/sentiment never leak AND missing vs unauthorized are
// indistinguishable — no commentId existence oracle.
$service = $this->makeService($this->noopReactions(), $this->defaultRepo(), $this->denyingPermissions());
$this->assertSame(['reactions' => [], 'userReactions' => []], $service->getCommentReactions(99, self::SESSION_USER));
}
public function test_get_comment_reactions_returns_empty_for_missing_comment(): void
{
// A missing comment yields the same empty payload a DENIED comment does (see above), so the
// two are indistinguishable — no commentId existence oracle.
$repo = $this->make(CommentRepository::class, [
'getComment' => fn () => false,
'resolveModuleProjectId' => fn () => 9,
]);
$service = $this->makeService($this->noopReactions(), $repo, $this->denyingPermissions());
$this->assertSame(['reactions' => [], 'userReactions' => []], $service->getCommentReactions(404, self::SESSION_USER));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,229 @@
<?php
namespace Unit\app\Domain\Projects\Repositories;
use Illuminate\Database\MySqlConnection;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Unit\TestCase;
/**
* Access-logic tests for the Projects repository (#3710 / #3709).
*
* The bug: an admin/owner with zero project memberships and no public projects
* saw an empty sidebar, because getProjectsUserHasAccessTo() lacked the
* admin/owner blanket-access branch that its sibling getUserProjects() already
* had. #3710 extracts the shared rule into accessibleProjectPredicate() so the
* two paths can't drift again.
*
* These tests pin the predicate's exact composition (member OR public OR
* client-scoped OR admin/owner) and prove both callers feed it the right
* client clause — without a live DB, by capturing the access closure each
* query builds and running it through a recording spy. This is the
* authorization surface Marcel flagged as the merge gate (CR #1): the admin
* blanket must be present (case a), no extra branch may over-grant to a
* non-admin non-member (case b), and the client-scope must use the intended
* key (case c).
*/
class ProjectsAccessTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* A minimal query-builder stand-in that records where/orWhere-style calls
* and returns itself so a fluent chain can run against it.
*/
private function clauseSpy(): object
{
return new class
{
/** @var array<int, array{0: string, 1: array<int, mixed>}> */
public array $calls = [];
public function where(...$args): static
{
$this->calls[] = ['where', $args];
return $this;
}
public function orWhere(...$args): static
{
$this->calls[] = ['orWhere', $args];
return $this;
}
public function whereColumn(...$args): static
{
$this->calls[] = ['whereColumn', $args];
return $this;
}
public function orWhereNull(...$args): static
{
$this->calls[] = ['orWhereNull', $args];
return $this;
}
};
}
/**
* A query-builder stand-in that runs every where(Closure) it receives
* through a probe and, when it recognises the access-predicate group (the
* one that adds the admin/owner branch), captures that closure and
* short-circuits the rest of the query build via a sentinel exception.
*/
private function capturingBuilder(): object
{
$test = $this;
return new class($test)
{
public ?\Closure $accessClosure = null;
private object $test;
public function __construct(object $test)
{
$this->test = $test;
}
public function where($arg = null): static
{
if ($arg instanceof \Closure) {
$probe = $this->test->probeClosure($arg);
foreach ($probe->calls as $call) {
if ($call === ['orWhere', ['requestingUser.role', '>=', 40]]) {
$this->accessClosure = $arg;
// Stop the query build here — the rest is irrelevant.
throw new \RuntimeException('LT_ACCESS_CAPTURED');
}
}
}
return $this;
}
public function __call(string $name, array $args): static
{
return $this;
}
};
}
/** Run a closure through a fresh clause spy and return the spy. */
public function probeClosure(\Closure $closure): object
{
$spy = $this->clauseSpy();
$closure($spy);
return $spy;
}
/**
* Build a Projects repo whose connection hands back the capturing builder
* and whose db helper returns a harmless wrapped column, so a real access
* query can be built up to (and only to) the access predicate.
*/
private function repoWithCapturingQuery(object $builder): ProjectRepository
{
$connection = $this->make(MySqlConnection::class, [
'table' => fn ($table = null) => $builder,
'raw' => fn ($value) => $value,
]);
$repo = $this->make(ProjectRepository::class, []);
$connProp = new \ReflectionProperty(ProjectRepository::class, 'connection');
$connProp->setAccessible(true);
$connProp->setValue($repo, $connection);
$helperProp = new \ReflectionProperty(ProjectRepository::class, 'dbHelper');
$helperProp->setAccessible(true);
$helperProp->setValue($repo, $this->make(DatabaseHelper::class, [
'wrapColumn' => fn ($column) => '`'.$column.'`',
]));
return $repo;
}
public function test_shared_predicate_grants_member_public_admin_and_delegates_client(): void
{
$repo = $this->make(ProjectRepository::class, []);
$method = new \ReflectionMethod(ProjectRepository::class, 'accessibleProjectPredicate');
$method->setAccessible(true);
$q = $this->clauseSpy();
$clientClause = fn ($q2) => null;
$method->invoke($repo, $q, 42, $clientClause);
// Exactly four access branches — a fifth would silently widen access.
$this->assertCount(4, $q->calls, 'The access predicate must add exactly four branches.');
$this->assertSame(['where', ['relation.userId', 42]], $q->calls[0], 'member');
$this->assertSame(['orWhere', ['project.psettings', 'all']], $q->calls[1], 'public');
$this->assertSame('orWhere', $q->calls[2][0], 'client (delegated to caller clause)');
$this->assertSame($clientClause, $q->calls[2][1][0], 'the caller-supplied client clause is forwarded unchanged');
$this->assertSame(['orWhere', ['requestingUser.role', '>=', 40]], $q->calls[3], 'admin/owner blanket');
}
public function test_get_projects_user_has_access_to_scopes_client_clause_to_passed_client_id(): void
{
$builder = $this->capturingBuilder();
$repo = $this->repoWithCapturingQuery($builder);
try {
$repo->getProjectsUserHasAccessTo(42, 'all', 7);
} catch (\RuntimeException $e) {
$this->assertSame('LT_ACCESS_CAPTURED', $e->getMessage());
}
$this->assertInstanceOf(\Closure::class, $builder->accessClosure, 'access predicate group was not built');
$clientClause = $this->clientClauseFrom($builder->accessClosure);
$sub = $this->clauseSpy();
$clientClause($sub);
$this->assertSame(['where', ['project.psettings', 'clients']], $sub->calls[0]);
$this->assertSame(['where', ['project.clientId', 7]], $sub->calls[1], 'client-shared access must scope to the passed client id');
}
public function test_get_user_projects_all_scopes_client_clause_to_own_client_column(): void
{
$builder = $this->capturingBuilder();
$repo = $this->repoWithCapturingQuery($builder);
try {
$repo->getUserProjects(42, 'all', null, 'all');
} catch (\RuntimeException $e) {
$this->assertSame('LT_ACCESS_CAPTURED', $e->getMessage());
}
$this->assertInstanceOf(\Closure::class, $builder->accessClosure, 'access predicate group was not built');
$clientClause = $this->clientClauseFrom($builder->accessClosure);
$sub = $this->clauseSpy();
$clientClause($sub);
$this->assertSame(['where', ['project.psettings', 'clients']], $sub->calls[0]);
$this->assertSame(
['whereColumn', ['project.clientId', 'requestingUser.clientId']],
$sub->calls[1],
'getUserProjects(all) must match the requesting user\'s own client column'
);
}
/** Pull the delegated client clause (the 3rd branch) out of an access closure. */
private function clientClauseFrom(\Closure $accessClosure): \Closure
{
$spy = $this->probeClosure($accessClosure);
$this->assertSame('orWhere', $spy->calls[2][0]);
$this->assertInstanceOf(\Closure::class, $spy->calls[2][1][0]);
return $spy->calls[2][1][0];
}
}

View File

@@ -0,0 +1,265 @@
<?php
namespace Unit\app\Domain\Projects\Services;
use Leantime\Domain\Notifications\Models\Notification;
use PHPUnit\Framework\TestCase;
/**
* Tests the notification filtering helper methods that were extracted
* from Projects\Services\Projects. These methods are private, so we
* test the logic by reimplementing the core algorithms against the
* Notification model — verifying the model's contract that the service depends on.
*
* The actual service integration (filterUsersByProjectRelevance etc.)
* is tested via acceptance tests that exercise the full notification flow.
*/
class NotificationFilteringTest extends TestCase
{
/**
* Helper: determines if a user is involved in a notification entity (same logic as the private method).
*/
private function isUserInvolved(int $userId, Notification $notification): bool
{
$entity = $notification->entity;
if (is_array($entity)) {
if (isset($entity['editorId']) && (int) $entity['editorId'] === $userId) {
return true;
}
if (isset($entity['userId']) && (int) $entity['userId'] === $userId) {
return true;
}
if (isset($entity['author']) && (int) $entity['author'] === $userId) {
return true;
}
}
return false;
}
/**
* Helper: determines project relevance level from settings (same logic as the private method).
*/
private function getProjectRelevanceLevel(int $userId, int $projectId, array $preloadedSettings, string $companyDefault): string
{
$newKey = 'usersettings.'.$userId.'.projectNotificationLevels';
$newSetting = $preloadedSettings[$newKey] ?? false;
if (! empty($newSetting) && $newSetting !== false) {
$levels = json_decode($newSetting, true);
if (is_array($levels) && isset($levels[$projectId])) {
$level = $levels[$projectId];
if (Notification::isValidRelevanceLevel($level)) {
return $level;
}
}
}
$oldKey = 'usersettings.'.$userId.'.projectMutedNotifications';
$oldSetting = $preloadedSettings[$oldKey] ?? false;
if (! empty($oldSetting) && $oldSetting !== false) {
$mutedIds = json_decode($oldSetting, true);
if (is_array($mutedIds) && in_array($projectId, $mutedIds)) {
return Notification::RELEVANCE_MUTED;
}
}
return $companyDefault;
}
// -----------------------------------------------------------------------
// Tests for relevance level resolution
// -----------------------------------------------------------------------
public function test_new_format_setting_is_used(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'my_work', 10 => 'muted']),
];
$this->assertSame('my_work', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
$this->assertSame('muted', $this->getProjectRelevanceLevel(1, 10, $settings, 'all'));
}
public function test_falls_back_to_company_default_when_no_setting(): void
{
$settings = [];
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
$this->assertSame('my_work', $this->getProjectRelevanceLevel(1, 5, $settings, 'my_work'));
}
public function test_project_not_in_levels_map_uses_company_default(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([10 => 'muted']),
];
// Project 5 is not in the map, should fall back to company default
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
}
public function test_legacy_muted_format_is_recognized(): void
{
$settings = [
'usersettings.1.projectMutedNotifications' => json_encode([5, 10, 15]),
];
$this->assertSame('muted', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
$this->assertSame('muted', $this->getProjectRelevanceLevel(1, 10, $settings, 'all'));
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 20, $settings, 'all'));
}
public function test_new_format_takes_precedence_over_legacy(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'all']),
'usersettings.1.projectMutedNotifications' => json_encode([5]), // legacy says muted
];
// New format wins
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 5, $settings, 'muted'));
}
public function test_invalid_level_in_settings_falls_back_to_company_default(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'invalid_level']),
];
$this->assertSame('all', $this->getProjectRelevanceLevel(1, 5, $settings, 'all'));
}
// -----------------------------------------------------------------------
// Tests for user involvement detection
// -----------------------------------------------------------------------
public function test_user_is_involved_when_assigned_via_editor_id(): void
{
$notification = new Notification;
$notification->entity = ['editorId' => 42, 'userId' => 99];
$this->assertTrue($this->isUserInvolved(42, $notification));
// userId 99 is the reporter -- also involved (tested in next test)
$this->assertTrue($this->isUserInvolved(99, $notification));
}
public function test_user_is_involved_when_creator_via_user_id(): void
{
$notification = new Notification;
$notification->entity = ['editorId' => 42, 'userId' => 99];
$this->assertTrue($this->isUserInvolved(99, $notification));
}
public function test_user_is_involved_when_canvas_author(): void
{
$notification = new Notification;
$notification->entity = ['author' => 77];
$this->assertTrue($this->isUserInvolved(77, $notification));
}
public function test_user_not_involved_when_unrelated(): void
{
$notification = new Notification;
$notification->entity = ['editorId' => 42, 'userId' => 99, 'author' => 77];
$this->assertFalse($this->isUserInvolved(1, $notification));
}
public function test_user_not_involved_when_entity_is_null(): void
{
$notification = new Notification;
$notification->entity = null;
$this->assertFalse($this->isUserInvolved(1, $notification));
}
public function test_user_not_involved_when_entity_has_no_user_fields(): void
{
$notification = new Notification;
$notification->entity = ['headline' => 'Test', 'description' => 'No user fields'];
$this->assertFalse($this->isUserInvolved(1, $notification));
}
public function test_editor_id_string_matches_integer_user(): void
{
$notification = new Notification;
$notification->entity = ['editorId' => '42'];
$this->assertTrue($this->isUserInvolved(42, $notification));
}
// -----------------------------------------------------------------------
// Tests for the category-to-module mapping with new structure
// -----------------------------------------------------------------------
public function test_category_filtering_with_restructured_categories(): void
{
// Verify getCategoryForModule still works with the new {modules: [...], description: '...'} structure
$this->assertSame('tasks', Notification::getCategoryForModule('tickets'));
$this->assertSame('comments', Notification::getCategoryForModule('comments'));
$this->assertSame('goals', Notification::getCategoryForModule('goalcanvas'));
$this->assertSame('boards', Notification::getCategoryForModule('leancanvas'));
$this->assertSame('boards', Notification::getCategoryForModule('retroscanvas'));
$this->assertNull(Notification::getCategoryForModule('unknownModule'));
}
// -----------------------------------------------------------------------
// Integration-style test: full filtering decision
// -----------------------------------------------------------------------
public function test_muted_user_would_be_filtered_out(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'muted']),
];
$level = $this->getProjectRelevanceLevel(1, 5, $settings, 'all');
$this->assertSame('muted', $level);
// In the actual service, muted -> user is excluded (return false from filter)
}
public function test_my_work_user_kept_when_assigned(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'my_work']),
];
$notification = new Notification;
$notification->entity = ['editorId' => 1, 'userId' => 99];
$notification->projectId = 5;
$level = $this->getProjectRelevanceLevel(1, 5, $settings, 'all');
$this->assertSame('my_work', $level);
$this->assertTrue($this->isUserInvolved(1, $notification));
}
public function test_my_work_user_excluded_when_unrelated(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'my_work']),
];
$notification = new Notification;
$notification->entity = ['editorId' => 99, 'userId' => 88];
$notification->projectId = 5;
$level = $this->getProjectRelevanceLevel(1, 5, $settings, 'all');
$this->assertSame('my_work', $level);
$this->assertFalse($this->isUserInvolved(1, $notification));
}
public function test_all_activity_user_always_kept(): void
{
$settings = [
'usersettings.1.projectNotificationLevels' => json_encode([5 => 'all']),
];
$level = $this->getProjectRelevanceLevel(1, 5, $settings, 'muted');
$this->assertSame('all', $level);
// In the actual service, 'all' -> user is always kept (return true from filter)
}
}

View File

@@ -0,0 +1,995 @@
<?php
namespace Unit\app\Domain\Projects\Services;
use Carbon\CarbonImmutable;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Leantime\Core\Configuration\Environment as EnvironmentCore;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\Avatarcreator;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Files\Services\Files as FileService;
use Leantime\Domain\Notifications\Services\Messengers;
use Leantime\Domain\Notifications\Services\Notifications as NotificationService;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepository;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the business logic extracted from the Projects domain
* controllers into the Projects service during the thin-controller refactor:
* getProjectHubData, notifyProjectCreated, saveZulipWebhook,
* getProjectIntegrationSettings and getProjectCardData.
*/
class ProjectsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected function setUp(): void
{
parent::setUp();
// Session + macros needed because getUsersAssignedToProject() uses dtHelper().
session(['usersettings.timezone' => 'UTC']);
session(['usersettings.language' => 'en-US']);
session(['usersettings.date_format' => 'Y-m-d']);
session(['usersettings.time_format' => 'H:i']);
session(['userdata.id' => 1]);
$envMock = $this->make(EnvironmentCore::class, [
'defaultTimezone' => 'UTC',
'language' => 'en-US',
]);
app()->instance(EnvironmentCore::class, $envMock);
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
}
/**
* Builds a real Projects service, allowing each dependency to be overridden
* with a stub so we can observe persistence/queue calls.
*/
private function makeService(
?ProjectRepository $projectRepo = null,
?TicketRepository $ticketRepo = null,
?SettingRepository $settingsRepo = null,
?QueueRepository $queueRepo = null,
?UserRepository $userRepo = null,
?CommentRepository $commentRepo = null,
?ClientRepository $clientRepo = null,
?LanguageCore $language = null,
?Client $httpClient = null,
): ProjectService {
$language ??= $this->make(LanguageCore::class, [
'__' => fn ($key) => $key,
]);
return new ProjectService(
$projectRepo ?? $this->make(ProjectRepository::class),
$ticketRepo ?? $this->make(TicketRepository::class),
$settingsRepo ?? $this->make(SettingRepository::class),
$language,
$this->make(Messengers::class),
$this->make(NotificationService::class),
$this->make(FileService::class),
$this->make(Avatarcreator::class),
$queueRepo ?? $this->make(QueueRepository::class),
$userRepo ?? $this->make(UserRepository::class),
$commentRepo ?? $this->make(CommentRepository::class),
$clientRepo ?? $this->make(ClientRepository::class),
$httpClient ?? $this->make(Client::class),
);
}
public function test_get_project_hub_data_builds_unique_client_map_and_returns_all_projects_when_no_filter(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjects' => fn () => [
['id' => 1, 'clientId' => 10, 'clientName' => 'Acme'],
['id' => 2, 'clientId' => 10, 'clientName' => 'Acme'],
['id' => 3, 'clientId' => 20, 'clientName' => 'Globex'],
],
]);
$result = $this->makeService(projectRepo: $projectRepo)->getProjectHubData(1, null);
$this->assertCount(3, $result['allProjects']);
$this->assertCount(2, $result['clients'], 'Duplicate clients must be collapsed into a unique map');
$this->assertSame('Acme', $result['clients'][10]['name']);
$this->assertSame('Globex', $result['clients'][20]['name']);
$this->assertSame('', $result['currentClientName']);
$this->assertSame('', $result['currentClient']);
}
public function test_get_project_hub_data_filters_projects_by_selected_client(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjects' => fn () => [
['id' => 1, 'clientId' => 10, 'clientName' => 'Acme'],
['id' => 2, 'clientId' => 20, 'clientName' => 'Globex'],
],
]);
$clientRepo = $this->make(ClientRepository::class, [
'getClient' => fn () => ['id' => 10, 'name' => 'Acme'],
]);
$result = $this->makeService(projectRepo: $projectRepo, clientRepo: $clientRepo)->getProjectHubData(1, 10);
$this->assertCount(1, $result['allProjects'], 'Only projects of the selected client are returned');
$this->assertSame(1, $result['allProjects'][0]['id']);
$this->assertCount(2, $result['clients'], 'The client map is still built from all projects');
$this->assertSame('Acme', $result['currentClientName']);
$this->assertSame(10, $result['currentClient']);
}
public function test_notify_project_created_queues_only_users_who_opted_in(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'getUsersAssignedToProject' => fn () => [
['username' => 'wants@example.com', 'notifications' => 1, 'modified' => ''],
['username' => 'muted@example.com', 'notifications' => 0, 'modified' => ''],
],
]);
$captured = null;
$queueRepo = $this->make(QueueRepository::class, [
'queueMessageToUsers' => function ($recipients, $message, $subject, $projectId) use (&$captured) {
$captured = compact('recipients', 'message', 'subject', 'projectId');
},
]);
$this->makeService(projectRepo: $projectRepo, queueRepo: $queueRepo)
->notifyProjectCreated(42, 'My Project', 'Author');
$this->assertNotNull($captured, 'A message must be queued');
$this->assertSame(['wants@example.com'], $captured['recipients'], 'Users with notifications=0 are excluded');
$this->assertSame(42, $captured['projectId']);
}
public function test_save_zulip_webhook_persists_when_all_fields_present(): void
{
$savedKey = null;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function ($key, $value) use (&$savedKey) {
$savedKey = $key;
return true;
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo)->saveZulipWebhook(7, [
'zulipURL' => 'https://zulip.example.com',
'zulipEmail' => 'bot@example.com',
'zulipBotKey' => 'key123',
'zulipStream' => 'general',
'zulipTopic' => 'updates',
]);
$this->assertTrue($result['saved']);
$this->assertSame('projectsettings.7.zulipHook', $savedKey);
$this->assertSame('https://zulip.example.com', $result['hook']['zulipURL']);
}
public function test_save_zulip_webhook_does_not_persist_when_a_field_is_missing(): void
{
$saveCalls = 0;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function () use (&$saveCalls) {
$saveCalls++;
return true;
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo)->saveZulipWebhook(7, [
'zulipURL' => 'https://zulip.example.com',
'zulipEmail' => '',
'zulipBotKey' => 'key123',
'zulipStream' => 'general',
'zulipTopic' => 'updates',
]);
$this->assertFalse($result['saved']);
$this->assertSame(0, $saveCalls, 'Incomplete zulip config must not be persisted');
$this->assertSame('', $result['hook']['zulipEmail']);
}
public function test_get_project_integration_settings_returns_empty_zulip_hook_when_unset(): void
{
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => '',
]);
$settings = $this->makeService(settingsRepo: $settingsRepo)->getProjectIntegrationSettings(5);
$this->assertSame('', $settings['mattermostWebhookURL']);
$this->assertArrayHasKey('discordWebhookURL1', $settings);
$this->assertArrayHasKey('discordWebhookURL3', $settings);
$this->assertSame([
'zulipURL' => '',
'zulipEmail' => '',
'zulipBotKey' => '',
'zulipStream' => '',
'zulipTopic' => '',
], $settings['zulipHook']);
}
public function test_get_project_integration_settings_unserializes_stored_zulip_hook(): void
{
$storedHook = serialize(['zulipURL' => 'https://z.example.com', 'zulipTopic' => 't']);
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn ($key) => str_ends_with($key, 'zulipHook') ? $storedHook : '',
]);
$settings = $this->makeService(settingsRepo: $settingsRepo)->getProjectIntegrationSettings(5);
$this->assertSame('https://z.example.com', $settings['zulipHook']['zulipURL']);
$this->assertSame('t', $settings['zulipHook']['zulipTopic']);
}
public function test_save_telegram_webhook_does_not_persist_without_token(): void
{
$saveCalls = 0;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function () use (&$saveCalls) {
$saveCalls++;
return true;
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo)->saveTelegramWebhook(7, [
'telegramBotToken' => '',
'telegramChatId' => '12345',
'telegramTopicId' => '',
]);
$this->assertFalse($result['saved']);
$this->assertSame('missing_token', $result['error']);
$this->assertSame(0, $saveCalls);
}
public function test_save_telegram_webhook_auto_detects_chat_id_when_blank(): void
{
$savedValue = null;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function ($type, $value) use (&$savedValue) {
$savedValue = $value;
return true;
},
]);
$httpClient = $this->make(Client::class, [
'get' => function ($url, $options) {
return new Response(200, [], json_encode([
'ok' => true,
'result' => [
[
'message' => [
'chat' => [
'id' => 987654321,
],
],
],
],
]));
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo, httpClient: $httpClient)->saveTelegramWebhook(7, [
'telegramBotToken' => '123456:ABC',
'telegramChatId' => '',
'telegramTopicId' => '',
]);
$this->assertTrue($result['saved']);
$this->assertNull($result['error']);
$this->assertSame('987654321', $result['hook']['telegramChatId']);
$this->assertNotNull($savedValue);
$unserialized = safe_unserialize($savedValue, []);
$this->assertSame('987654321', $unserialized['telegramChatId']);
}
public function test_save_telegram_webhook_uses_provided_chat_and_topic_id_without_calling_get_updates(): void
{
$getCalled = false;
$httpClient = $this->make(Client::class, [
'get' => function () use (&$getCalled) {
$getCalled = true;
return new Response(200);
},
]);
$savedValue = null;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function ($type, $value) use (&$savedValue) {
$savedValue = $value;
return true;
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo, httpClient: $httpClient)->saveTelegramWebhook(7, [
'telegramBotToken' => '123456:ABC',
'telegramChatId' => '-1001234567890',
'telegramTopicId' => '10',
]);
$this->assertFalse($getCalled, 'getUpdates API must not be called when chat_id is provided directly');
$this->assertTrue($result['saved']);
$this->assertSame('-1001234567890', $result['hook']['telegramChatId']);
$this->assertSame('10', $result['hook']['telegramTopicId']);
}
public function test_save_telegram_webhook_reports_chat_not_found_when_auto_detect_fails(): void
{
$saveCalls = 0;
$settingsRepo = $this->make(SettingRepository::class, [
'saveSetting' => function () use (&$saveCalls) {
$saveCalls++;
return true;
},
]);
$httpClient = $this->make(Client::class, [
'get' => function () {
return new Response(200, [], json_encode([
'ok' => true,
'result' => [],
]));
},
]);
$result = $this->makeService(settingsRepo: $settingsRepo, httpClient: $httpClient)->saveTelegramWebhook(7, [
'telegramBotToken' => '123456:ABC',
'telegramChatId' => '',
'telegramTopicId' => '',
]);
$this->assertFalse($result['saved']);
$this->assertSame('chat_not_found', $result['error']);
$this->assertSame(0, $saveCalls);
}
public function test_get_project_integration_settings_returns_empty_telegram_hook_when_unset(): void
{
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn () => '',
]);
$settings = $this->makeService(settingsRepo: $settingsRepo)->getProjectIntegrationSettings(5);
$this->assertSame([
'telegramBotToken' => '',
'telegramChatId' => '',
'telegramTopicId' => '',
], $settings['telegramHook']);
}
public function test_get_project_integration_settings_unserializes_stored_telegram_hook(): void
{
$storedHook = serialize(['telegramBotToken' => 'tok', 'telegramChatId' => '123', 'telegramTopicId' => '1']);
$settingsRepo = $this->make(SettingRepository::class, [
'getSetting' => fn ($key) => str_ends_with($key, 'telegramHook') ? $storedHook : '',
]);
$settings = $this->makeService(settingsRepo: $settingsRepo)->getProjectIntegrationSettings(5);
$this->assertSame('tok', $settings['telegramHook']['telegramBotToken']);
$this->assertSame('123', $settings['telegramHook']['telegramChatId']);
$this->assertSame('1', $settings['telegramHook']['telegramTopicId']);
}
public function test_get_project_card_data_sets_last_update_and_status_from_first_comment(): void
{
$ticketRepo = $this->make(TicketRepository::class, [
'getAverageTodoSize' => fn () => 0,
'getFirstTicket' => fn () => null,
]);
$projectRepo = $this->make(ProjectRepository::class, [
'getUsersAssignedToProject' => fn () => [],
]);
$commentRepo = $this->make(CommentRepository::class, [
'getComments' => fn () => [
['id' => 99, 'status' => 'on_track', 'text' => 'Looking good'],
],
]);
$card = $this->makeService(
projectRepo: $projectRepo,
ticketRepo: $ticketRepo,
commentRepo: $commentRepo,
)->getProjectCardData(3);
$this->assertSame(3, $card['id']);
$this->assertSame('on_track', $card['status']);
$this->assertIsArray($card['lastUpdate']);
$this->assertSame(99, $card['lastUpdate']['id']);
}
public function test_get_project_card_data_defaults_when_no_comments(): void
{
$ticketRepo = $this->make(TicketRepository::class, [
'getAverageTodoSize' => fn () => 0,
'getFirstTicket' => fn () => null,
]);
$projectRepo = $this->make(ProjectRepository::class, [
'getUsersAssignedToProject' => fn () => [],
]);
$commentRepo = $this->make(CommentRepository::class, [
'getComments' => fn () => [],
]);
$card = $this->makeService(
projectRepo: $projectRepo,
ticketRepo: $ticketRepo,
commentRepo: $commentRepo,
)->getProjectCardData(3);
$this->assertFalse($card['lastUpdate']);
$this->assertSame('', $card['status']);
}
// ---------------------------------------------------------------------
// Authorized JSON-RPC entry points for project sort/status/patch.
// The /api/projects controller (which had a route-level gate) was retired,
// so these wrappers must self-authorize: manager+ AND access to each project.
// ---------------------------------------------------------------------
public function test_user_can_manage_project_allows_admin_without_explicit_assignment(): void
{
session(['userdata.role' => 'admin']);
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
]);
// Admins/owners manage every project regardless of assignment.
$this->assertTrue($this->makeService(projectRepo: $projectRepo)->userCanManageProject(99));
}
public function test_user_can_manage_project_requires_assignment_for_managers(): void
{
session(['userdata.role' => 'manager']);
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
]);
$this->assertFalse($this->makeService(projectRepo: $projectRepo)->userCanManageProject(99));
}
public function test_patch_project_status_and_sorting_rejects_non_manager(): void
{
session(['userdata.role' => 'editor']);
$patchCalls = 0;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'patch' => function () use (&$patchCalls) {
$patchCalls++;
return true;
},
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo)
->patchProjectStatusAndSorting(['3' => 'item[]=5']);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown, 'Editors must not be able to re-status projects');
$this->assertSame(0, $patchCalls, 'Unauthorized request must not persist any sorting');
}
public function test_patch_project_status_and_sorting_rejects_manager_without_project_access(): void
{
session(['userdata.role' => 'manager']);
$patchCalls = 0;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
'patch' => function () use (&$patchCalls) {
$patchCalls++;
return true;
},
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo)
->patchProjectStatusAndSorting(['3' => 'item[]=5']);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown, 'A manager smuggling a project they cannot access must be blocked');
$this->assertSame(0, $patchCalls);
}
public function test_patch_project_status_and_sorting_allows_manager_with_access(): void
{
session(['userdata.role' => 'manager']);
$patched = [];
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'patch' => function ($id, $values) use (&$patched) {
$patched[] = ['id' => $id, 'values' => $values];
return true;
},
]);
$result = $this->makeService(projectRepo: $projectRepo)
->patchProjectStatusAndSorting(['3' => 'item[]=5&item[]=6']);
$this->assertTrue($result);
$this->assertCount(2, $patched, 'Both serialized projects must be re-sorted');
$this->assertSame('5', $patched[0]['id']);
$this->assertSame(3, (int) $patched[0]['values']['state']);
}
public function test_sort_projects_rejects_when_user_cannot_manage_target_project(): void
{
session(['userdata.role' => 'manager']);
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo)->sortProjects(['pgm-5' => 1]);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown);
}
public function test_sort_projects_resolves_ticket_to_its_project_for_authorization(): void
{
session(['userdata.role' => 'manager']);
$checkedProjectId = null;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => function ($userId, $projectId) use (&$checkedProjectId) {
$checkedProjectId = $projectId;
return false; // deny so we stop before delegating to the Tickets service
},
]);
$ticket = new \Leantime\Domain\Tickets\Models\Tickets;
$ticket->projectId = 9;
$ticketRepo = $this->make(TicketRepository::class, [
'getTicket' => fn () => $ticket,
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo, ticketRepo: $ticketRepo)
->sortProjects(['ticket-7' => 1]);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown);
$this->assertSame(9, $checkedProjectId, 'Authorization must check the ticket\'s project, not the ticket id');
}
public function test_patch_project_rejects_non_manager(): void
{
session(['userdata.role' => 'editor']);
$patchCalls = 0;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'patch' => function () use (&$patchCalls) {
$patchCalls++;
return true;
},
]);
$thrown = null;
try {
$this->makeService(projectRepo: $projectRepo)->patchProject(5, ['sortIndex' => 2]);
} catch (AuthorizationException $e) {
$thrown = $e;
}
$this->assertInstanceOf(AuthorizationException::class, $thrown);
$this->assertSame(0, $patchCalls);
}
public function test_patch_project_allows_manager_and_strips_control_fields(): void
{
session(['userdata.role' => 'manager']);
$patchedValues = null;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'patch' => function ($id, $values) use (&$patchedValues) {
$patchedValues = $values;
return true;
},
]);
$result = $this->makeService(projectRepo: $projectRepo)
->patchProject(5, ['act' => 'projects.x', 'id' => 5, 'sortIndex' => 2, 'start' => '2026-01-01']);
$this->assertTrue($result);
$this->assertArrayNotHasKey('act', $patchedValues, 'Control fields must be stripped before persisting');
$this->assertArrayNotHasKey('id', $patchedValues);
$this->assertSame(2, $patchedValues['sortIndex']);
}
// ---- permission-engine: recursion guardrail ---------------------------
/**
* THE recursion guardrail. The permission engine calls isUserAssignedToProject() and
* getProjectRole() during every project-scoped authorization, so those two methods must never
* invoke the engine in-body — otherwise authorize() → currentUserCan() → isUserAssignedToProject()
* → authorize() → ∞. A PermissionService stub that fails the test if touched proves it.
*/
public function test_access_resolution_methods_never_invoke_the_permission_engine(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'getUserProjectRelation' => fn () => [['projectRole' => 'editor']],
]);
$tripwire = $this->make(\Leantime\Core\Auth\Permissions\PermissionService::class, [
'currentUserCan' => fn () => $this->fail('isUserAssignedToProject/getProjectRole must NOT call the permission engine (infinite-recursion guard).'),
'authorize' => fn () => $this->fail('access-resolution methods must NOT authorize in-body (infinite-recursion guard).'),
]);
$service = $this->makeService(projectRepo: $projectRepo);
$service->setPermissionService($tripwire);
// Neither call may touch the engine.
$this->assertTrue($service->isUserAssignedToProject(1, 5));
$this->assertSame('editor', $service->getProjectRole(1, 5));
}
/**
* getProjectRole() must resolve "no explicit role" to '' so callers fall back to the global
* role. This locks in the fix for the "Inherit" lockout: the legacy 0 role (written when
* "inherit" was cast to int), a missing relation, unknown/junk keys, and admin/owner keys all
* map to '', while a real assignable key is returned unchanged.
*
* @dataProvider projectRoleResolutionProvider
*/
public function test_get_project_role_resolves_inherit_and_junk_to_empty(mixed $stored, string $expected): void
{
$relation = $stored === '__none__' ? [] : [['projectRole' => $stored]];
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjectRelation' => fn () => $relation,
]);
$service = $this->makeService(projectRepo: $projectRepo);
$this->assertSame($expected, $service->getProjectRole(1, 5));
}
public static function projectRoleResolutionProvider(): array
{
return [
'legacy int 0 -> inherit' => [0, ''],
'legacy string 0 -> inherit' => ['0', ''],
'empty string -> inherit' => ['', ''],
'no relation row -> inherit' => ['__none__', ''],
'unknown numeric key -> inherit' => ['999', ''],
'admin key not assignable -> inherit' => ['40', ''],
'owner key not assignable -> inherit' => ['50', ''],
'valid editor key preserved' => ['20', '20'],
'valid readonly key preserved' => ['5', '5'],
'legacy inherit sentinel -> inherit' => ['inherit', ''],
'legacy inherited sentinel -> inherit' => ['inherited', ''],
'legacy uppercase Inherit sentinel -> inherit' => ['Inherit', ''],
'legacy role name preserved' => ['editor', 'editor'],
];
}
/**
* Reflection lock: the engine-reachable access methods must carry NO #[RequiresPermission]
* dispatch attribute (a dispatch gate on them would re-enter the engine), and the mutations/reads
* must carry the expected gate. Locks the recursion-safe contract in CI.
*/
public function test_rpc_surface_contract(): void
{
$gate = function (string $method): ?array {
$attrs = (new \ReflectionMethod(ProjectService::class, $method))
->getAttributes(\Leantime\Core\Auth\Permissions\RequiresPermission::class);
if ($attrs === []) {
return null;
}
$a = $attrs[0]->newInstance();
return ['permission' => $a->permission, 'global' => $a->global, 'projectIdParam' => $a->projectIdParam];
};
// Engine-reachable / access-resolution: MUST be ungated (the recursion guard). Note
// getUsersAssignedToProject is NOT in this set — the engine never calls it, so it is safely
// view-gated below to close its member-list IDOR.
foreach (['getProjectRole', 'isUserAssignedToProject', 'getUserProjectRelation', 'userCanManageProject', 'getProjectsUserHasAccessTo'] as $m) {
$this->assertNull($gate($m), "$m must carry NO #[RequiresPermission] (recursion guard)");
}
// Mutations: global manager+.
foreach (['addProject' => 'projects.create', 'duplicateProject' => 'projects.create', 'editProject' => 'projects.edit', 'patch' => 'projects.edit', 'patchProject' => 'projects.edit', 'updateProjectUsers' => 'projects.edit', 'saveSlackWebhook' => 'projects.edit', 'saveZulipWebhook' => 'projects.edit', 'saveTelegramWebhook' => 'projects.edit', 'deleteProject' => 'projects.delete', 'editUserProjectRelations' => 'projects.edit', 'addUserToProject' => 'projects.edit'] as $m => $perm) {
$g = $gate($m);
$this->assertNotNull($g, "$m must be gated");
$this->assertSame($perm, $g['permission'], $m);
$this->assertTrue($g['global'], "$m must be global-scoped (manager+ company-wide)");
}
// By-id reads: project-scoped view.
foreach (['getProject', 'getProjectProgress', 'getProjectName', 'getProjectIntegrationSettings', 'getProjectCardData', 'getUsersAssignedToProject'] as $m) {
$g = $gate($m);
$this->assertNotNull($g, "$m must be gated");
$this->assertSame('projects.view', $g['permission'], $m);
$this->assertNotNull($g['projectIdParam'], "$m must bind to the requested project id");
}
}
/**
* The $userId-param reads pin to the SESSION user for non-admins, closing the cross-user spoof
* (an RPC caller could otherwise list another user's projects by passing a foreign id).
*/
public function test_assigned_to_user_reads_pin_to_session_user_for_non_admins(): void
{
session(['userdata.id' => 1]); // non-admin session user
$capturedUserId = null;
$projectRepo = $this->make(ProjectRepository::class, [
'getUserProjectRelation' => function ($userId) use (&$capturedUserId) {
$capturedUserId = $userId;
return [];
},
]);
// Caller passes a FOREIGN userId (99); the read must be scoped to the session user (1).
$this->makeService(projectRepo: $projectRepo)->getProjectIdAssignedToUser(99);
$this->assertSame(1, $capturedUserId, 'a non-admin must not be able to read another user\'s project assignments');
}
// ---- Project hierarchy safety (#3540: cyclic parents hung every page via the project selector) ----
public function test_find_my_children_builds_nested_hierarchy(): void
{
$projects = [
['id' => 1, 'parent' => 0, 'name' => 'Program'],
['id' => 2, 'parent' => 1, 'name' => 'Project'],
['id' => 3, 'parent' => 2, 'name' => 'Subproject'],
['id' => 4, 'parent' => 0, 'name' => 'Standalone'],
];
$hierarchy = $this->makeService()->findMyChildren(0, $projects);
$this->assertCount(2, $hierarchy);
$this->assertSame(2, $hierarchy[0]['children'][0]['id']);
$this->assertSame(3, $hierarchy[0]['children'][0]['children'][0]['id']);
$this->assertArrayNotHasKey('children', $hierarchy[1]);
}
public function test_find_my_children_does_not_recurse_on_self_referential_parent(): void
{
$projects = [
['id' => 1, 'parent' => 0, 'name' => 'Root'],
['id' => 2, 'parent' => 2, 'name' => 'Self-parented'],
];
$hierarchy = $this->makeService()->findMyChildren(0, $projects);
$this->assertCount(1, $hierarchy, 'must terminate instead of recursing on a self-parented project');
$this->assertSame(1, $hierarchy[0]['id']);
}
public function test_clean_parent_relationship_reroots_self_parent_and_cycles(): void
{
$projects = [
['id' => 1, 'parent' => 1, 'name' => 'Self-parented'],
['id' => 2, 'parent' => 3, 'name' => 'Cycle A'],
['id' => 3, 'parent' => 2, 'name' => 'Cycle B'],
['id' => 4, 'parent' => 99, 'name' => 'Orphan'],
['id' => 5, 'parent' => 1, 'name' => 'Valid child'],
];
$service = $this->makeService();
$clean = $service->cleanParentRelationship($projects);
$byId = array_column($clean, null, 'id');
$this->assertSame(0, $byId[1]['parent'], 'self-parent must be re-rooted');
$this->assertSame(0, $byId[2]['parent'], 'cycle members must be re-rooted');
$this->assertSame(0, $byId[3]['parent'], 'cycle members must be re-rooted');
$this->assertSame(0, $byId[4]['parent'], 'orphans must be re-rooted');
$this->assertSame(1, $byId[5]['parent'], 'valid parent links must be preserved');
// The full pipeline must terminate and surface every project.
$hierarchy = $service->findMyChildren(0, $clean);
$this->assertCount(4, $hierarchy);
}
/**
* Regression for #3617: a child whose parent is a top-level strategy/program (parent = NULL)
* must stay nested. isset() reports false for a NULL parent value, which previously re-rooted
* every such child to 0 and dropped it out of its strategy group in the Projects dropdown.
*/
public function test_clean_parent_relationship_keeps_children_of_top_level_parents(): void
{
$projects = [
['id' => 1, 'parent' => null, 'name' => 'Strategy'], // top-level container
['id' => 2, 'parent' => 1, 'name' => 'Project under strategy'],
['id' => 3, 'parent' => 1, 'name' => 'Plan under strategy'],
];
$service = $this->makeService();
$byId = array_column($service->cleanParentRelationship($projects), null, 'id');
$this->assertSame(1, $byId[2]['parent'], 'child of a NULL-parent strategy must stay nested');
$this->assertSame(1, $byId[3]['parent'], 'plan of a NULL-parent strategy must stay nested');
// And the child must actually appear under the strategy in the assembled hierarchy.
$hierarchy = $service->findMyChildren(0, array_values($byId));
$this->assertCount(1, $hierarchy, 'only the top-level strategy sits at the root');
$this->assertSame(1, $hierarchy[0]['id']);
$this->assertCount(2, $hierarchy[0]['children'], 'project and plan nest under the strategy');
}
/**
* addUserToProject() must be ADDITIVE. The sibling
* editUserProjectRelations() is a full replace that deletes any
* relation not in the array it is handed, so the whole point of this
* method is that it never touches a user's other memberships.
*/
public function test_add_user_to_project_inserts_when_not_a_member(): void
{
$added = [];
$repo = $this->makeEmpty(ProjectRepository::class, [
'getProject' => fn () => ['id' => 42],
'isUserMemberOfProject' => fn () => false,
'addProjectRelation' => function ($userId, $projectId, $role) use (&$added) {
$added[] = [$userId, $projectId, $role];
},
'editUserProjectRelations' => fn () => throw new \LogicException(
'addUserToProject must never call the destructive full-replace method'
),
]);
$result = $this->makeService($repo, userRepo: $this->validUserRepo())->addUserToProject(7, 42, 'contributor');
$this->assertTrue($result, 'a new membership reports true');
$this->assertSame([[7, 42, 'contributor']], $added);
}
/**
* Membership is not access. isUserAssignedToProject() returns true for
* every admin and owner whether or not a relation row exists, so using
* it as the idempotence check would make this method a permanent
* no-op for exactly those users: an admin could never be put on a
* project team, and the caller would be told "already a member" about
* someone who is not on the team at all.
*/
public function test_add_user_to_project_adds_admin_who_has_access_but_no_membership(): void
{
$added = [];
$repo = $this->makeEmpty(ProjectRepository::class, [
'getProject' => fn () => ['id' => 42],
// An admin: reaches every project...
'isUserAssignedToProject' => fn () => true,
// ...but holds no relation row for this one.
'isUserMemberOfProject' => fn () => false,
'addProjectRelation' => function ($userId, $projectId, $role) use (&$added) {
$added[] = [$userId, $projectId, $role];
},
]);
$this->assertTrue($this->makeService($repo, userRepo: $this->validUserRepo())->addUserToProject(7, 42));
$this->assertSame([[7, 42, '']], $added, 'access must not be mistaken for membership');
}
/**
* Idempotence guard. zp_relationuserproject has no unique index on
* (userId, projectId), so a blind insert would duplicate the row and
* show the person twice on the project team.
*/
public function test_add_user_to_project_is_idempotent_for_existing_member(): void
{
$addCalls = 0;
$repo = $this->makeEmpty(ProjectRepository::class, [
'getProject' => fn () => ['id' => 42],
'isUserMemberOfProject' => fn () => true,
'addProjectRelation' => function () use (&$addCalls) {
$addCalls++;
},
]);
$result = $this->makeService($repo, userRepo: $this->validUserRepo())->addUserToProject(7, 42);
$this->assertFalse($result, 'an existing membership reports false');
$this->assertSame(0, $addCalls, 'must not insert a duplicate relation row');
}
/**
* Invalid ids must fail before touching persistence — a 0 userId
* reaching addProjectRelation would create an orphan relation row.
*/
public function test_add_user_to_project_rejects_invalid_ids(): void
{
$touched = 0;
$repo = $this->makeEmpty(ProjectRepository::class, [
'isUserMemberOfProject' => function () use (&$touched) {
$touched++;
return false;
},
'addProjectRelation' => function () use (&$touched) {
$touched++;
},
]);
$service = $this->makeService($repo);
$this->assertFalse($service->addUserToProject(0, 42));
$this->assertFalse($service->addUserToProject(7, 0));
$this->assertSame(0, $touched, 'invalid ids must not reach the repository');
}
/**
* Non-zero ids that don't resolve to real rows must also fail closed —
* isUserMemberOfProject() returns false for a missing user/project, so
* without the existence guard addProjectRelation() would write an orphan
* relation row for a user or project that isn't there.
*/
public function test_add_user_to_project_rejects_nonexistent_user_or_project(): void
{
$added = 0;
$mkRepo = fn (bool $projectExists) => $this->makeEmpty(ProjectRepository::class, [
'getProject' => fn () => $projectExists ? ['id' => 42] : false,
'isUserMemberOfProject' => fn () => false,
'addProjectRelation' => function () use (&$added) {
$added++;
},
]);
$missingUser = $this->makeEmpty(UserRepository::class, ['getUser' => fn () => false]);
// User missing (project resolves fine).
$this->assertFalse(
$this->makeService($mkRepo(true), userRepo: $missingUser)->addUserToProject(999, 42)
);
// Project missing (user resolves fine).
$this->assertFalse(
$this->makeService($mkRepo(false), userRepo: $this->validUserRepo())->addUserToProject(7, 999)
);
$this->assertSame(0, $added, 'a non-existent user or project must never reach addProjectRelation');
}
/**
* A UserRepository stub whose getUser() resolves to a real row, for the
* addUserToProject() tests that need the existence guard to pass.
*/
private function validUserRepo(): UserRepository
{
return $this->makeEmpty(UserRepository::class, ['getUser' => fn () => ['id' => 7]]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Unit\app\Domain\Reactions\Services;
use Leantime\Domain\Reactions\Repositories\Reactions as ReactionsRepository;
use Leantime\Domain\Reactions\Services\Reactions;
use Unit\TestCase;
/**
* Unit tests for the session-based JSON-RPC wrappers added to the Reactions
* service (react/unreact): they must derive the user from the session so a
* caller cannot react as another user.
*/
class ReactionsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
public function test_react_uses_the_session_user(): void
{
session(['userdata' => ['id' => 42]]);
$capturedUserId = null;
$repo = $this->make(ReactionsRepository::class, [
'getUserReactions' => fn (...$args) => [],
'addReaction' => function ($userId, ...$rest) use (&$capturedUserId) {
$capturedUserId = $userId;
return true;
},
]);
$result = (new Reactions($repo))->react('tickets', 5, 'thumbsup');
$this->assertTrue($result);
$this->assertSame(42, $capturedUserId, 'react() must persist the session user, not a passed id');
}
public function test_unreact_uses_the_session_user(): void
{
session(['userdata' => ['id' => 7]]);
$capturedUserId = null;
$repo = $this->make(ReactionsRepository::class, [
'removeUserReaction' => function ($userId, ...$rest) use (&$capturedUserId) {
$capturedUserId = $userId;
return true;
},
]);
$result = (new Reactions($repo))->unreact('tickets', 5, 'thumbsup');
$this->assertTrue($result);
$this->assertSame(7, $capturedUserId, 'unreact() must remove for the session user, not a passed id');
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Tests\Unit\App\Domain\Reports\Models;
use Carbon\CarbonImmutable;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Language;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Core\Support\DateTimeHelper;
use Leantime\Domain\Reports\Models\ReportPeriod;
use Unit\TestCase;
class ReportPeriodTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected function setUp(): void
{
parent::setUp();
$environmentMock = $this->make(Environment::class, [
'defaultTimezone' => 'America/Los_Angeles',
'language' => 'en-US',
]);
app()->instance(Environment::class, $environmentMock);
$languageMock = $this->createMock(Language::class);
$languageMock->method('__')->willReturnCallback(function ($index) {
$map = [
'language.dateformat' => 'm/d/Y',
'language.timeformat' => 'h:i A',
];
return $map[$index] ?? null;
});
app()->instance(Language::class, $languageMock);
// User calendar in LA (UTC-7 in summer) so quarter boundaries shift against UTC.
CarbonImmutable::mixin(new CarbonMacros(
'America/Los_Angeles',
'en_US',
'm/d/Y',
'h:i A'
));
app()->instance(DateTimeHelper::class, new DateTimeHelper);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-08 12:00:00', 'UTC'));
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
app()->forgetInstance(DateTimeHelper::class);
parent::tearDown();
}
public function test_this_quarter_resolves_user_calendar_quarter_in_utc(): void
{
$period = ReportPeriod::thisQuarter();
// Q3 2026 in LA: Jul 1 00:00 PDT = Jul 1 07:00 UTC, Sep 30 23:59:59 PDT = Oct 1 06:59:59 UTC.
$this->assertSame('2026-07-01 07:00:00', $period->fromDbString());
$this->assertSame('2026-10-01 06:59:59', $period->toDbString());
$this->assertSame(ReportPeriod::PRESET_THIS_QUARTER, $period->preset);
}
public function test_last_and_next_quarter_presets(): void
{
$lastQuarter = ReportPeriod::lastQuarter();
// Q2 2026 in LA starts Apr 1 00:00 PDT = Apr 1 07:00 UTC.
$this->assertSame('2026-04-01 07:00:00', $lastQuarter->fromDbString());
$this->assertSame('2026-07-01 06:59:59', $lastQuarter->toDbString());
$nextQuarter = ReportPeriod::nextQuarter();
// Q4 2026 in LA starts Oct 1 00:00 PDT = Oct 1 07:00 UTC.
$this->assertSame('2026-10-01 07:00:00', $nextQuarter->fromDbString());
// Dec 31 23:59:59 PST (UTC-8) = Jan 1 07:59:59 UTC.
$this->assertSame('2027-01-01 07:59:59', $nextQuarter->toDbString());
}
public function test_quarter_follows_user_timezone_across_utc_quarter_boundary(): void
{
// Jul 1 03:00 UTC is still Jun 30 in LA — the user's "this quarter" is Q2, not Q3.
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-01 03:00:00', 'UTC'));
$period = ReportPeriod::thisQuarter();
$this->assertSame('2026-04-01 07:00:00', $period->fromDbString());
$this->assertSame('2026-07-01 06:59:59', $period->toDbString());
}
public function test_from_request_parses_presets_and_custom_ranges(): void
{
$preset = ReportPeriod::fromRequest(['preset' => 'lastQuarter']);
$this->assertSame(ReportPeriod::PRESET_LAST_QUARTER, $preset->preset);
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '04/01/2026', 'to' => '06/30/2026']);
$this->assertSame(ReportPeriod::PRESET_CUSTOM, $custom->preset);
$this->assertSame('2026-04-01 07:00:00', $custom->fromDbString());
// End of day Jun 30 PDT.
$this->assertSame('2026-07-01 06:59:59', $custom->toDbString());
}
public function test_from_request_falls_back_to_this_quarter(): void
{
$this->assertSame(ReportPeriod::PRESET_THIS_QUARTER, ReportPeriod::fromRequest([])->preset);
$this->assertSame(
ReportPeriod::PRESET_THIS_QUARTER,
ReportPeriod::fromRequest(['preset' => 'custom', 'from' => 'not-a-date', 'to' => '06/30/2026'])->preset
);
// Inverted range is rejected.
$this->assertSame(
ReportPeriod::PRESET_THIS_QUARTER,
ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/30/2026', 'to' => '04/01/2026'])->preset
);
}
public function test_prior_period_of_quarter_preset_is_previous_quarter(): void
{
$prior = ReportPeriod::thisQuarter()->priorPeriod();
$this->assertSame('2026-04-01 07:00:00', $prior->fromDbString());
$this->assertSame('2026-07-01 06:59:59', $prior->toDbString());
}
public function test_prior_period_of_custom_range_is_same_length_before(): void
{
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/21/2026', 'to' => '06/30/2026']);
$prior = $custom->priorPeriod();
// Ten-day window directly preceding Jun 21Jun 30.
$this->assertSame('2026-06-11 07:00:00', $prior->fromDbString());
$this->assertSame('2026-06-21 06:59:59', $prior->toDbString());
}
public function test_upcoming_horizon_extends_two_quarters_past_period_end(): void
{
$horizon = ReportPeriod::thisQuarter()->upcomingHorizon();
// Two quarters past Q3 2026 = end of Q1 2027 (Mar 31 23:59:59 PDT = Apr 1 06:59:59 UTC).
$this->assertSame('2027-04-01 06:59:59', $horizon->format('Y-m-d H:i:s'));
}
public function test_contains_is_inclusive_of_bounds(): void
{
$period = ReportPeriod::thisQuarter();
$this->assertTrue($period->contains($period->from));
$this->assertTrue($period->contains($period->to));
$this->assertFalse($period->contains($period->from->subSecond()));
$this->assertFalse($period->contains($period->to->addSecond()));
}
public function test_query_string_round_trips(): void
{
$this->assertSame('preset=thisQuarter', ReportPeriod::thisQuarter()->toQueryString());
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '04/01/2026', 'to' => '06/30/2026']);
parse_str($custom->toQueryString(), $params);
$roundTripped = ReportPeriod::fromRequest($params);
$this->assertSame($custom->fromDbString(), $roundTripped->fromDbString());
$this->assertSame($custom->toDbString(), $roundTripped->toDbString());
}
public function test_label_carries_quarter_shorthand_for_full_quarters(): void
{
$this->assertStringStartsWith('Q3 2026 · ', ReportPeriod::thisQuarter()->label());
$custom = ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/21/2026', 'to' => '06/30/2026']);
$this->assertStringNotContainsString('Q2', $custom->label());
}
}

View File

@@ -0,0 +1,410 @@
<?php
namespace Tests\Unit\App\Domain\Reports\Services;
use Carbon\CarbonImmutable;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Language;
use Leantime\Core\Resources\Models\PersonAllocation;
use Leantime\Core\Resources\Models\ResourceSummary;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Core\Support\DateTimeHelper;
use Leantime\Domain\Reports\Models\ReportPeriod;
use Leantime\Domain\Reports\Services\CapacityAnalyzer;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketsRepo;
use PHPUnit\Framework\MockObject\MockObject;
use Unit\TestCase;
class CapacityAnalyzerTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private TicketsRepo&MockObject $ticketsRepo;
private CapacityAnalyzer $analyzer;
protected function setUp(): void
{
parent::setUp();
$environmentMock = $this->make(Environment::class, [
'defaultTimezone' => 'UTC',
'language' => 'en-US',
]);
app()->instance(Environment::class, $environmentMock);
$languageMock = $this->createMock(Language::class);
$languageMock->method('__')->willReturnCallback(fn ($index) => [
'language.dateformat' => 'm/d/Y',
'language.timeformat' => 'h:i A',
][$index] ?? null);
app()->instance(Language::class, $languageMock);
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en_US', 'm/d/Y', 'h:i A'));
app()->instance(DateTimeHelper::class, new DateTimeHelper);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-08 12:00:00', 'UTC'));
$this->ticketsRepo = $this->createMock(TicketsRepo::class);
$this->analyzer = new CapacityAnalyzer($this->ticketsRepo);
// Default status vocabulary: 3=NEW, 4=INPROGRESS, 0=DONE, -1=DONE(archived),
// 7 = a CUSTOM done status with a positive id.
$this->ticketsRepo->method('getStateLabels')->willReturn([
3 => ['statusType' => 'NEW', 'name' => 'New'],
4 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress'],
0 => ['statusType' => 'DONE', 'name' => 'Done'],
-1 => ['statusType' => 'DONE', 'name' => 'Archived'],
7 => ['statusType' => 'DONE', 'name' => 'Shipped'],
]);
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
app()->forgetInstance(DateTimeHelper::class);
parent::tearDown();
}
/**
* One-week period so availableHours equals the weekly allocation exactly.
*/
private function oneWeekPeriod(): ReportPeriod
{
return ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/01/2026', 'to' => '06/07/2026']);
}
/**
* @param array<int, array<string, mixed>> $tickets
*/
private function withTickets(array $tickets, int $projectId = 10): void
{
// The analyzer batches its ticket reads into one getAllByProjectIds()
// call keyed by projectId; the single-project cases here all use id 10.
$this->ticketsRepo->method('getAllByProjectIds')->willReturn([$projectId => $tickets]);
}
private function summaryWithWeeklyAllocation(int $projectId, float $weeklyHours, int $people = 1): ResourceSummary
{
$persons = [];
for ($i = 0; $i < $people; $i++) {
$persons[] = new PersonAllocation(
itemId: $i + 1,
userId: $i + 1,
displayName: 'Person '.($i + 1),
capacity: 40.0,
allocations: [$projectId => $weeklyHours / $people],
);
}
return new ResourceSummary([$projectId], $persons, [], [], 40.0 * $people, $weeklyHours, 0.0, 0.0);
}
public function test_custom_done_statuses_are_excluded_from_demand(): void
{
$this->withTickets([
// Custom DONE status (positive id) — must NOT count as open demand.
['id' => 1, 'status' => 7, 'planHours' => 100.0, 'storypoints' => 0],
// Default done + archived — excluded.
['id' => 2, 'status' => 0, 'planHours' => 50.0, 'storypoints' => 0],
['id' => 3, 'status' => -1, 'planHours' => 25.0, 'storypoints' => 0],
// Open work — the only demand.
['id' => 4, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0],
]);
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
$this->assertSame(1, $rows[10]['openTicketCount']);
$this->assertEqualsWithDelta(10.0, $rows[10]['budgetedHours'], 0.001);
}
public function test_trust_signal_bands_drive_reference_demand(): void
{
// Low coverage (1 of 4 open tickets budgeted = 0.25 < 0.30) with effort present
// -> trust 'effort', referenceDemand = storypoints × hoursPerPoint.
$this->withTickets([
['id' => 1, 'status' => 3, 'planHours' => 8.0, 'storypoints' => 5],
['id' => 2, 'status' => 3, 'planHours' => 0, 'storypoints' => 5],
['id' => 3, 'status' => 3, 'planHours' => 0, 'storypoints' => 5],
['id' => 4, 'status' => 4, 'planHours' => 0, 'storypoints' => 5],
]);
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
$this->assertSame('effort', $rows[10]['trustSignal']);
$this->assertEqualsWithDelta(20 * CapacityAnalyzer::DEFAULT_HOURS_PER_POINT, $rows[10]['referenceDemand'], 0.001);
}
public function test_high_coverage_agreeing_estimates_trust_budgeted(): void
{
// Full coverage, divergence below threshold (40 budgeted vs 40 effort hours).
$this->withTickets([
['id' => 1, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 5],
['id' => 2, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 5],
]);
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
$this->assertSame('budgeted', $rows[10]['trustSignal']);
$this->assertEqualsWithDelta(40.0, $rows[10]['referenceDemand'], 0.001);
}
public function test_diverging_estimates_flag_mixed_and_take_conservative_max(): void
{
// Full coverage but effort (10sp × 4h = 40h) vs budgeted (100h) diverge > 0.4 -> mixed, max() wins.
$this->withTickets([
['id' => 1, 'status' => 3, 'planHours' => 50.0, 'storypoints' => 5],
['id' => 2, 'status' => 3, 'planHours' => 50.0, 'storypoints' => 5],
]);
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']);
$this->assertSame('mixed', $rows[10]['trustSignal']);
$this->assertEqualsWithDelta(100.0, $rows[10]['referenceDemand'], 0.001);
}
/**
* @dataProvider verdictBoundaryProvider
*/
public function test_verdict_boundaries(float $demandHours, float $weeklyAvailable, string $expectedVerdict): void
{
// Single fully-budgeted open ticket -> trust 'budgeted' -> referenceDemand = planHours.
$this->withTickets([
['id' => 1, 'status' => 3, 'planHours' => $demandHours, 'storypoints' => 0],
]);
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, $weeklyAvailable), [10 => 'P']);
$this->assertSame($expectedVerdict, $rows[10]['verdict']);
}
public static function verdictBoundaryProvider(): array
{
return [
'no capacity' => [100.0, 0.0, 'no_capacity'],
'critical: gap ratio > 0.25' => [130.0, 100.0, 'critical'],
'tight: 0 < ratio <= 0.25' => [110.0, 100.0, 'tight'],
'balanced: -0.5 <= ratio <= 0' => [90.0, 100.0, 'balanced'],
'buffer: ratio < -0.5' => [40.0, 100.0, 'buffer'],
];
}
public function test_projects_without_work_or_people_are_skipped_as_noise(): void
{
$this->withTickets([]);
$rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), ResourceSummary::empty([10]), [10 => 'P']);
$this->assertSame([], $rows);
}
/**
* The N+1 guard: tickets for every analyzed project are pulled in a SINGLE
* getAllByProjectIds() call, not one getAllByProjectId() per project. This
* pins the batching so a future refactor can't quietly reintroduce the
* per-project round-trip.
*/
public function test_tickets_are_fetched_in_one_batched_call_for_all_projects(): void
{
$this->ticketsRepo->expects($this->once())
->method('getAllByProjectIds')
->with($this->equalTo([10, 20, 30]))
->willReturn([
10 => [['id' => 1, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0]],
20 => [['id' => 2, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 0]],
30 => [['id' => 3, 'status' => 3, 'planHours' => 30.0, 'storypoints' => 0]],
]);
$summary = new ResourceSummary(
[10, 20, 30],
[new PersonAllocation(
itemId: 1, userId: 1, displayName: 'P', capacity: 40.0,
allocations: [10 => 10.0, 20 => 10.0, 30 => 10.0],
)],
[], [], 40.0, 30.0, 0.0, 0.0,
);
$rows = $this->analyzer->analyzeProjects(
[10, 20, 30],
$this->oneWeekPeriod(),
$summary,
[10 => 'A', 20 => 'B', 30 => 'C'],
);
$this->assertSame([10, 20, 30], array_keys($rows));
$this->assertEqualsWithDelta(10.0, $rows[10]['budgetedHours'], 0.001);
$this->assertEqualsWithDelta(30.0, $rows[30]['budgetedHours'], 0.001);
}
/**
* Only the reportable projects (those present in $projectNames) reach the
* batched fetch — the skip that used to sit inside the per-project loop now
* shapes the single WHERE IN, so we don't over-fetch container projects.
*/
public function test_only_reportable_projects_are_batched(): void
{
$this->ticketsRepo->expects($this->once())
->method('getAllByProjectIds')
->with($this->equalTo([10])) // 20 is not in $projectNames → excluded
->willReturn([10 => [['id' => 1, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0]]]);
$this->analyzer->analyzeProjects(
[10, 20],
$this->oneWeekPeriod(),
$this->summaryWithWeeklyAllocation(10, 20.0),
[10 => 'A'], // 20 omitted on purpose
);
}
// ─── Program rollup: supply is capacity, not booked hours ────────
//
// aggregateByProgram measures how much a program COULD do (capacity),
// not how much is already booked against it. This keeps the rollup
// consistent with the report's headline utilization tile, which reads
// allocated/capacity — before this, a program with real headroom and
// little booked reported no_capacity and could escalate as a false gap
// on the strategy report. verdict()/trustSignal() themselves are
// covered above; these pin the supply figure feeding them.
public function test_program_supply_uses_capacity_not_allocated_hours(): void
{
// 40h capacity, only 5h booked against the program → supply is 40.
$row = $this->rollup(
[$this->personCap(1, capacity: 40.0, allocations: [7 => 5.0])],
childIds: [7],
);
$this->assertEqualsWithDelta(40.0, $row['availableHours'], 0.001);
$this->assertSame(1, $row['peopleCount']);
}
public function test_program_supply_counts_a_person_on_two_child_projects_once(): void
{
$row = $this->rollup(
[$this->personCap(1, capacity: 40.0, allocations: [7 => 5.0, 8 => 5.0])],
childIds: [7, 8],
);
$this->assertEqualsWithDelta(40.0, $row['availableHours'], 0.001);
$this->assertSame(1, $row['peopleCount']);
}
public function test_program_supply_deducts_commitments_outside_the_program(): void
{
// 40h capacity, 10h here, 15h on a project outside this program →
// 25h is what this program could still claim.
$row = $this->rollup(
[$this->personCap(1, capacity: 40.0, allocations: [7 => 10.0, 99 => 15.0])],
childIds: [7],
);
$this->assertEqualsWithDelta(25.0, $row['availableHours'], 0.001);
}
public function test_outside_commitments_never_push_a_person_below_zero(): void
{
// Person 1 is over-committed elsewhere beyond their capacity: they
// bring nothing here, but must not subtract from person 2.
$row = $this->rollup(
[
$this->personCap(1, capacity: 40.0, allocations: [7 => 1.0, 99 => 100.0]),
$this->personCap(2, capacity: 20.0, allocations: [7 => 5.0]),
],
childIds: [7],
);
$this->assertEqualsWithDelta(20.0, $row['availableHours'], 0.001, 'clamped at 0, not -60');
}
public function test_people_not_allocated_to_the_program_contribute_nothing(): void
{
$row = $this->rollup(
[$this->personCap(1, capacity: 40.0, allocations: [99 => 10.0])],
childIds: [7],
);
$this->assertEqualsWithDelta(0.0, $row['availableHours'], 0.001);
$this->assertSame(0, $row['peopleCount']);
}
/**
* The regression this change exists for: a program with real headroom
* and little booked used to read no_capacity because supply was measured
* as hours-already-allocated. With capacity as supply it reads as buffer
* — there IS room. (supply 40 vs demand 10 → ratio -0.75 → buffer.)
*/
public function test_program_with_headroom_and_light_booking_reads_as_buffer(): void
{
$row = $this->rollup(
[$this->personCap(1, capacity: 40.0, allocations: [7 => 0.5])],
childIds: [7],
budgetedHours: 10.0,
);
$this->assertNotSame('no_capacity', $row['verdict']);
$this->assertSame('buffer', $row['verdict']);
}
/**
* Work planned with nobody assigned stays no_capacity, distinct from
* critical: it is an authoring gap ("assign people"), not a capacity
* crisis, and painting it red trains readers to ignore red.
*/
public function test_program_with_no_people_is_no_capacity_not_critical(): void
{
$row = $this->rollup([], childIds: [7], budgetedHours: 400.0);
$this->assertSame('no_capacity', $row['verdict']);
}
private function personCap(int $itemId, float $capacity, array $allocations): PersonAllocation
{
return new PersonAllocation(
itemId: $itemId,
userId: $itemId,
displayName: 'Person '.$itemId,
capacity: $capacity,
allocations: $allocations,
);
}
/**
* Runs aggregateByProgram for one program over the given children,
* feeding pre-built project rows so the ticket/demand side is fixed and
* the assertions are about the capacity side. Demand sits on the first
* child so totals are predictable regardless of child count.
*
* @param array<int, PersonAllocation> $people
* @param int[] $childIds
* @return array<string, mixed>
*/
private function rollup(array $people, array $childIds, float $budgetedHours = 50.0): array
{
$projectRows = [];
foreach ($childIds as $cid) {
$isFirst = $cid === $childIds[0];
$projectRows[$cid] = [
'projectId' => $cid,
'name' => 'Child '.$cid,
'openTicketCount' => $isFirst ? 10 : 0,
'ticketsWithBudget' => $isFirst ? 10 : 0,
'ticketsWithEffort' => 0,
'budgetedHours' => $isFirst ? $budgetedHours : 0.0,
'effortPoints' => 0.0,
'verdict' => 'balanced', // aggregateByProgram sorts children by verdict rank
];
}
$resources = new ResourceSummary([7, 8], $people, [], [], 0.0, 0.0, 0.0, 0.0);
$rows = $this->analyzer->aggregateByProgram(
$projectRows,
[2 => $childIds],
[2 => ['id' => 2, 'name' => 'Program 2']],
$resources,
$this->oneWeekPeriod(),
);
return $rows[2];
}
}

View File

@@ -0,0 +1,338 @@
<?php
namespace Tests\Unit\App\Domain\Reports\Services;
use Carbon\CarbonImmutable;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Language;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Core\Support\DateTimeHelper;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Reports\Models\ReportPeriod;
use Leantime\Domain\Reports\Repositories\ReportEngine as ReportEngineRepository;
use Leantime\Domain\Reports\Services\ReportEngine;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use PHPUnit\Framework\MockObject\MockObject;
use Unit\TestCase;
class ReportEngineTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private ReportEngine $service;
private ReportEngineRepository&MockObject $repository;
private TicketRepository&MockObject $ticketRepository;
private ProjectService&MockObject $projectService;
private GoalcanvasService&MockObject $goalService;
protected function setUp(): void
{
parent::setUp();
$environmentMock = $this->make(Environment::class, [
'defaultTimezone' => 'UTC',
'language' => 'en-US',
]);
app()->instance(Environment::class, $environmentMock);
$languageMock = $this->createMock(Language::class);
$languageMock->method('__')->willReturnCallback(function ($index) {
$map = [
'language.dateformat' => 'm/d/Y',
'language.timeformat' => 'h:i A',
];
return $map[$index] ?? null;
});
app()->instance(Language::class, $languageMock);
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en_US', 'm/d/Y', 'h:i A'));
app()->instance(DateTimeHelper::class, new DateTimeHelper);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-08 12:00:00', 'UTC'));
$this->repository = $this->createMock(ReportEngineRepository::class);
$this->ticketRepository = $this->createMock(TicketRepository::class);
$this->projectService = $this->createMock(ProjectService::class);
$this->goalService = $this->createMock(GoalcanvasService::class);
$this->service = new ReportEngine(
$this->repository,
$this->ticketRepository,
$this->projectService,
$this->goalService,
);
$permissionService = $this->createMock(PermissionService::class);
$permissionService->method('currentUserCan')->willReturn(true);
$this->service->setPermissionService($permissionService);
// Status vocabulary for project 10: 1 = NEW, 2 = INPROGRESS, 3 = DONE.
$this->ticketRepository->method('getStateLabels')->willReturn([
1 => ['statusType' => 'NEW', 'name' => 'New'],
2 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress'],
3 => ['statusType' => 'DONE', 'name' => 'Done'],
]);
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
app()->forgetInstance(DateTimeHelper::class);
parent::tearDown();
}
/**
* @param array<string, mixed> $overrides
*/
private function milestone(int $id, array $overrides = []): object
{
return (object) array_merge([
'id' => $id,
'headline' => 'Milestone '.$id,
'description' => '',
'outcomeImpact' => null,
'date' => '2026-01-01 00:00:00',
'projectId' => 10,
'status' => 1,
'editFrom' => null,
'editTo' => null,
'modified' => null,
'projectName' => 'Project 10',
'type' => 'milestone',
'tags' => 'var(--grey)',
], $overrides);
}
private function lastQuarter(): ReportPeriod
{
// With test-now 2026-07-08 UTC this is Apr 1 Jun 30 2026 (UTC).
return ReportPeriod::lastQuarter();
}
public function test_milestones_bucket_into_completed_overdue_in_progress_and_upcoming(): void
{
$this->repository->method('getMilestonesForProjects')->willReturn([
// Done, history transition inside the period -> completed.
$this->milestone(1, ['status' => 3]),
// Done, history transition long before the period -> allDone only.
$this->milestone(2, ['status' => 3]),
// Open with a past due date -> overdue.
$this->milestone(3, ['editFrom' => '2026-05-01 00:00:00', 'editTo' => '2026-06-01 00:00:00']),
// Open, starting two months after the period -> upcoming (Q3 2026).
$this->milestone(4, ['editFrom' => '2026-08-15 00:00:00', 'editTo' => '2026-09-15 00:00:00']),
// Open and completely unscheduled -> in progress.
$this->milestone(5),
// Open, starting after the two-quarter horizon -> dropped from upcoming.
$this->milestone(6, ['editFrom' => '2027-06-01 00:00:00', 'editTo' => '2027-07-01 00:00:00']),
]);
$this->repository->method('getStatusHistoryForTickets')->willReturn([
(object) ['ticketId' => 1, 'changeValue' => '2', 'dateModified' => '2026-05-01 09:00:00'],
(object) ['ticketId' => 1, 'changeValue' => '3', 'dateModified' => '2026-05-10 09:00:00'],
(object) ['ticketId' => 2, 'changeValue' => '3', 'dateModified' => '2026-01-15 09:00:00'],
]);
$this->repository->method('getTasksForMilestones')->willReturn([]);
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
$this->assertSame([1], array_map(fn ($m) => $m->id, $report['completed']));
$this->assertSame('2026-05-10 09:00:00', $report['completed'][0]->completedOn->format('Y-m-d H:i:s'));
$this->assertSame([3], array_map(fn ($m) => $m->id, $report['overdue']));
$this->assertSame([5], array_map(fn ($m) => $m->id, $report['inProgress']));
$this->assertSame([4], array_map(fn ($m) => $m->id, $report['upcoming']));
$this->assertArrayHasKey('Q3 2026', $report['upcomingByQuarter']);
$this->assertCount(2, $report['allDone']);
}
public function test_completion_date_falls_back_to_due_date_then_modified_without_history(): void
{
$this->repository->method('getMilestonesForProjects')->willReturn([
// No history, has a due date inside the period.
$this->milestone(1, ['status' => 3, 'editTo' => '2026-05-20 00:00:00']),
// No history, no due date, modified inside the period.
$this->milestone(2, ['status' => 3, 'modified' => '2026-06-15 08:00:00']),
// History exists but never transitions into DONE -> falls back to modified.
$this->milestone(3, ['status' => 3, 'modified' => '2026-06-20 08:00:00']),
]);
$this->repository->method('getStatusHistoryForTickets')->willReturn([
(object) ['ticketId' => 3, 'changeValue' => '2', 'dateModified' => '2026-06-01 09:00:00'],
]);
$this->repository->method('getTasksForMilestones')->willReturn([]);
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
$completedOn = [];
foreach ($report['completed'] as $milestone) {
$completedOn[$milestone->id] = $milestone->completedOn->format('Y-m-d H:i:s');
}
$this->assertSame('2026-05-20 00:00:00', $completedOn[1]);
$this->assertSame('2026-06-15 08:00:00', $completedOn[2]);
$this->assertSame('2026-06-20 08:00:00', $completedOn[3]);
}
public function test_milestone_progress_uses_weighted_task_scores(): void
{
$this->repository->method('getMilestonesForProjects')->willReturn([
$this->milestone(1, ['editFrom' => '2026-06-01 00:00:00', 'editTo' => '2026-09-01 00:00:00']),
]);
$this->repository->method('getStatusHistoryForTickets')->willReturn([]);
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
$this->repository->method('getTasksForMilestones')->willReturn([
// Done: 5 points × priority-1 factor 2 = 10. Open: 5 × factor 1 (priority 5) = 5.
(object) ['id' => 100, 'headline' => 'Done task', 'status' => 3, 'projectId' => 10, 'milestoneid' => 1, 'storypoints' => 5, 'priority' => 1, 'editTo' => null, 'dateToFinish' => null],
(object) ['id' => 101, 'headline' => 'Open task', 'status' => 1, 'projectId' => 10, 'milestoneid' => 1, 'storypoints' => 5, 'priority' => 5, 'editTo' => null, 'dateToFinish' => null],
]);
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
$milestone = $report['inProgress'][0];
$this->assertEqualsWithDelta(66.67, $milestone->percentDone, 0.01);
$this->assertSame(['done' => 1, 'total' => 2], $milestone->taskStats);
// Key tasks list leads with completed work.
$this->assertSame(100, $milestone->keyTasks[0]->id);
$this->assertTrue($milestone->keyTasks[0]->isDone);
}
public function test_slippage_reports_milestones_pushed_out_and_added_mid_period(): void
{
$this->repository->method('getMilestonesForProjects')->willReturn([
// Open, now due after the period, with an in-period due-date change -> pushed out.
$this->milestone(1, ['editFrom' => '2026-04-10 00:00:00', 'editTo' => '2026-09-15 00:00:00']),
// Created mid-period -> added.
$this->milestone(2, ['date' => '2026-05-05 00:00:00', 'editFrom' => '2026-05-05 00:00:00', 'editTo' => '2026-12-01 00:00:00']),
]);
$this->repository->method('getStatusHistoryForTickets')->willReturn([]);
$this->repository->method('getTasksForMilestones')->willReturn([]);
$this->repository->method('getDueDateChangesForTickets')->willReturn([
(object) ['ticketId' => 1, 'changeValue' => '2026-09-15 00:00:00', 'dateModified' => '2026-06-10 09:00:00'],
(object) ['ticketId' => 1, 'changeValue' => '2026-09-15 00:00:00', 'dateModified' => '2026-06-20 09:00:00'],
]);
$report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter());
$this->assertCount(1, $report['slippage']['pushedOut']);
$this->assertSame(1, $report['slippage']['pushedOut'][0]->id);
$this->assertSame(2, $report['slippage']['pushedOut'][0]->dueDateMoves);
$this->assertSame([2], array_map(fn ($m) => $m->id, $report['slippage']['addedMidPeriod']));
}
public function test_goal_report_resolves_rollups_and_progress(): void
{
$this->repository->method('getGoalsForProjects')->willReturn([
(object) ['id' => 1, 'title' => 'Graduates', 'description' => '', 'status' => 'status_ontrack', 'metricType' => 'count', 'startValue' => 0.0, 'currentValue' => 42.0, 'endValue' => 60.0, 'setting' => '', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
(object) ['id' => 2, 'title' => 'Rollup KPI', 'description' => '', 'status' => 'status_atrisk', 'metricType' => 'count', 'startValue' => 0.0, 'currentValue' => 0.0, 'endValue' => 100.0, 'setting' => 'linkAndReport', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
]);
$this->goalService->method('getChildGoalsForReporting')->with(2)->willReturn(25.0);
// The engine batches milestone-chip hydration up front; stub it so the
// test exercises the rollup/progress path without relying on a mock's
// default null return.
$this->goalService->method('getMilestonesForGoals')->willReturn([1 => [], 2 => []]);
$report = $this->service->getGoalReportForProjects([10]);
$this->assertEqualsWithDelta(70.0, $report['goals'][0]->goalProgress, 0.01);
$this->assertEqualsWithDelta(25.0, $report['goals'][1]->currentValue, 0.01);
$this->assertEqualsWithDelta(25.0, $report['goals'][1]->goalProgress, 0.01);
$this->assertSame(['ontrack' => 1, 'atrisk' => 1, 'miss' => 0], $report['counts']);
}
public function test_status_updates_group_by_project_and_respect_limit(): void
{
$this->repository->method('getStatusUpdatesForProjects')->willReturn([
(object) ['id' => 1, 'projectId' => 10, 'text' => 'newest', 'date' => '2026-06-20 10:00:00', 'status' => 'green', 'authorFirstname' => 'A', 'authorLastname' => 'B', 'authorProfileId' => null],
(object) ['id' => 2, 'projectId' => 10, 'text' => 'older', 'date' => '2026-05-01 10:00:00', 'status' => 'yellow', 'authorFirstname' => 'A', 'authorLastname' => 'B', 'authorProfileId' => null],
(object) ['id' => 3, 'projectId' => 11, 'text' => 'other project', 'date' => '2026-05-02 10:00:00', 'status' => 'green', 'authorFirstname' => 'C', 'authorLastname' => 'D', 'authorProfileId' => null],
]);
$updates = $this->service->getStatusUpdatesForProjects([10, 11], $this->lastQuarter(), 1);
$this->assertCount(1, $updates[10]);
$this->assertSame('newest', $updates[10][0]->text);
$this->assertCount(1, $updates[11]);
}
public function test_project_summaries_flag_stale_and_alerting_projects(): void
{
$this->repository->method('getProjectsMeta')->willReturn([
10 => (object) ['id' => 10, 'name' => 'Fresh red project', 'details' => '<p>Some <b>html</b> description</p>', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
11 => (object) ['id' => 11, 'name' => 'Silent project', 'details' => '', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
]);
$this->repository->method('getLatestStatusUpdateForProjects')->willReturn([
10 => (object) ['projectId' => 10, 'text' => 'Behind on hiring', 'date' => '2026-07-01 10:00:00', 'status' => 'red', 'authorFirstname' => 'A', 'authorLastname' => 'B'],
// Project 11 has no status update at all.
]);
$this->projectService->method('getProjectProgress')->willReturn(['percent' => 40.0, 'estimatedCompletionDate' => false, 'plannedCompletionDate' => '']);
$summaries = $this->service->getProjectSummaries([10, 11]);
$this->assertSame('red', $summaries[10]->latestStatus);
$this->assertFalse($summaries[10]->isStale);
$this->assertSame('Some html description', $summaries[10]->descriptionExcerpt);
$this->assertNull($summaries[11]->latestStatus);
$this->assertTrue($summaries[11]->isStale);
}
public function test_effort_totals_by_project_and_milestone(): void
{
$this->repository->method('getHoursLoggedForProjects')->willReturn([
(object) ['projectId' => 10, 'milestoneId' => 1, 'loggedHours' => 12.5],
(object) ['projectId' => 10, 'milestoneId' => 0, 'loggedHours' => 3.0],
(object) ['projectId' => 11, 'milestoneId' => 2, 'loggedHours' => 4.25],
]);
$effort = $this->service->getEffortForProjects([10, 11], $this->lastQuarter());
$this->assertEqualsWithDelta(19.75, $effort['total'], 0.001);
$this->assertEqualsWithDelta(15.5, $effort['byProject'][10], 0.001);
$this->assertEqualsWithDelta(12.5, $effort['byMilestone'][1], 0.001);
$this->assertArrayNotHasKey(0, $effort['byMilestone']);
}
public function test_build_report_composes_needs_attention_and_deltas(): void
{
$this->repository->method('getMilestonesForProjects')->willReturn([
// Completed this period.
$this->milestone(1, ['status' => 3]),
// Completed in the prior period (feeds the delta).
$this->milestone(2, ['status' => 3]),
// Overdue -> needs attention.
$this->milestone(3, ['editTo' => '2026-06-01 00:00:00']),
]);
$this->repository->method('getStatusHistoryForTickets')->willReturn([
(object) ['ticketId' => 1, 'changeValue' => '3', 'dateModified' => '2026-05-10 09:00:00'],
(object) ['ticketId' => 2, 'changeValue' => '3', 'dateModified' => '2026-02-10 09:00:00'],
]);
$this->repository->method('getTasksForMilestones')->willReturn([]);
$this->repository->method('getDueDateChangesForTickets')->willReturn([]);
$this->repository->method('getGoalsForProjects')->willReturn([
(object) ['id' => 1, 'title' => 'At-risk goal', 'description' => '', 'status' => 'status_atrisk', 'metricType' => '', 'startValue' => 0.0, 'currentValue' => 1.0, 'endValue' => 10.0, 'setting' => '', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null],
]);
$this->repository->method('getStatusUpdatesForProjects')->willReturn([]);
$this->repository->method('getHoursLoggedForProjects')->willReturn([]);
$this->repository->method('getProjectsMeta')->willReturn([
10 => (object) ['id' => 10, 'name' => 'Project', 'details' => '', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'],
]);
$this->repository->method('getLatestStatusUpdateForProjects')->willReturn([]);
$this->projectService->method('getProjectProgress')->willReturn(['percent' => 10.0, 'estimatedCompletionDate' => false, 'plannedCompletionDate' => '']);
$report = $this->service->buildReport([10], $this->lastQuarter());
$this->assertSame(1, $report['stats']['completed']);
$this->assertSame(1, $report['deltas']['completedPrior']);
$this->assertSame(0, $report['deltas']['completedDelta']);
$this->assertCount(1, $report['needsAttention']['overdueMilestones']);
$this->assertCount(1, $report['needsAttention']['goalsAtRisk']);
// No status update ever -> the project is flagged as silent.
$this->assertCount(1, $report['needsAttention']['staleProjects']);
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Unit\app\Domain\Reports\Services;
use Leantime\Core\Configuration\AppSettings as AppSettingCore;
use Leantime\Core\Configuration\Environment as EnvironmentCore;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Reports\Repositories\Reports as ReportRepository;
use Leantime\Domain\Reports\Services\Reports;
use Leantime\Domain\Setting\Services\Setting as SettingsService;
use Leantime\Domain\Sprints\Models\Sprints as SprintModel;
use Leantime\Domain\Sprints\Repositories\Sprints as SprintRepository;
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Unit\TestCase;
/**
* Unit tests for the sprint-burndown selection logic extracted from the
* Reports\Controllers\Show controller into the Reports service, plus the permission-engine
* security surface: the three by-projectId @api reads must stay gated against the REQUESTED
* project, and the system/telemetry methods must never become RPC-reachable again.
*/
class ReportsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/** Matches Jsonrpc::isApiMethod(): @api only at the start of a docblock line. */
private function isApiExposed(string $method): bool
{
$doc = (new \ReflectionMethod(Reports::class, $method))->getDocComment();
return $doc !== false && preg_match('/^\s*\*\s*@api\b/m', $doc) === 1;
}
/** The #[RequiresPermission] attribute instance on a method, or null. */
private function permissionAttribute(string $method): ?\Leantime\Core\Auth\Permissions\RequiresPermission
{
$attributes = (new \ReflectionMethod(Reports::class, $method))
->getAttributes(\Leantime\Core\Auth\Permissions\RequiresPermission::class);
return $attributes === [] ? null : $attributes[0]->newInstance();
}
public function test_by_project_reads_are_rpc_exposed_and_gated_against_the_requested_project(): void
{
foreach (['getSprintBurndownForReport', 'getFullReport', 'getRealtimeReport'] as $method) {
$this->assertTrue($this->isApiExposed($method), "$method should stay RPC-callable");
$attribute = $this->permissionAttribute($method);
$this->assertNotNull($attribute, "$method must carry a #[RequiresPermission] dispatch gate");
$this->assertSame('reports.view', $attribute->permission, $method);
// projectIdParam binds the gate to the REQUESTED project — without it the enforcer
// falls back to the session project and the cross-project RPC IDOR reopens.
$this->assertSame('projectId', $attribute->projectIdParam, $method);
}
}
public function test_system_and_telemetry_methods_are_not_rpc_reachable(): void
{
// dailyIngestion binds to session state; the others leak instance-wide aggregates or
// mutate company-wide settings. None may carry a line-starting @api tag.
foreach ([
'dailyIngestion',
'cronDailyIngestion',
'getAnonymousTelemetry',
'sendAnonymousTelemetry',
'optOutTelemetry',
'getProjectStatusReport',
'generateTicketReactionsReport',
] as $method) {
$this->assertFalse($this->isApiExposed($method), "$method must NOT be RPC-callable");
}
}
/**
* Builds the Reports service with every constructor dependency stubbed,
* injecting the provided Sprints service (the only dependency the method
* under test actually exercises).
*/
private function makeService(SprintService $sprintService): Reports
{
return new Reports(
$this->make(AppSettingCore::class),
$this->make(EnvironmentCore::class),
$this->make(ProjectRepository::class),
$this->make(SprintRepository::class),
$this->make(ReportRepository::class),
$this->make(SettingsService::class),
$this->make(TicketRepository::class),
$sprintService,
);
}
/**
* Creates a Sprints model with the given id.
*/
private function sprint(int $id): SprintModel
{
$sprint = new SprintModel;
$sprint->id = $id;
return $sprint;
}
public function test_returns_false_when_project_has_no_sprints(): void
{
$sprintService = $this->make(SprintService::class, [
'getAllSprints' => fn () => [],
]);
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
$this->assertFalse($result['chart']);
$this->assertFalse($result['currentSprintId']);
}
public function test_uses_requested_sprint_id_and_echoes_it_back(): void
{
$sprintService = $this->make(SprintService::class, [
'getAllSprints' => fn () => [$this->sprint(1), $this->sprint(2)],
'getSprint' => fn ($id) => $this->sprint($id),
'getSprintBurndown' => fn () => [['date' => '2024-01-01']],
]);
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, 2);
$this->assertSame([['date' => '2024-01-01']], $result['chart']);
$this->assertSame(2, $result['currentSprintId']);
}
public function test_requested_sprint_id_is_echoed_even_when_sprint_missing(): void
{
$sprintService = $this->make(SprintService::class, [
'getAllSprints' => fn () => [$this->sprint(1)],
'getSprint' => fn () => false,
]);
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, 99);
$this->assertFalse($result['chart']);
$this->assertSame(99, $result['currentSprintId']);
}
public function test_falls_back_to_current_sprint_when_none_requested(): void
{
$sprintService = $this->make(SprintService::class, [
'getAllSprints' => fn () => [$this->sprint(1), $this->sprint(5)],
'getCurrentSprintId' => fn () => 5,
'getSprint' => fn ($id) => $this->sprint($id),
'getSprintBurndown' => fn () => [['date' => '2024-02-02']],
]);
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
$this->assertSame([['date' => '2024-02-02']], $result['chart']);
$this->assertSame(5, $result['currentSprintId']);
}
public function test_falls_back_to_first_sprint_when_no_current_sprint(): void
{
$sprintService = $this->make(SprintService::class, [
'getAllSprints' => fn () => [$this->sprint(11), $this->sprint(12)],
'getCurrentSprintId' => fn () => false,
'getSprintBurndown' => fn () => [['date' => '2024-03-03']],
]);
$result = $this->makeService($sprintService)->getSprintBurndownForReport(7, null);
$this->assertSame([['date' => '2024-03-03']], $result['chart']);
$this->assertSame(11, $result['currentSprintId']);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Unit\app\Domain\Setting\Services;
use Leantime\Core\Files\Contracts\FileManagerInterface;
use Leantime\Domain\Ideas\Repositories\Ideas as IdeaRepository;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Setting\Services\Setting as SettingService;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Unit\TestCase;
/**
* Unit tests for the Setting service helpers extracted during the
* thin-controller refactor (getProjectLabel).
*/
class SettingServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a real Setting service, allowing each dependency to be
* overridden with a stub so we can observe the label resolution.
*/
private function makeService(
?SettingRepository $settingsRepo = null,
?TicketRepository $ticketsRepo = null,
?IdeaRepository $ideaRepo = null,
): SettingService {
return new SettingService(
$settingsRepo ?? $this->make(SettingRepository::class),
$this->makeEmpty(FileManagerInterface::class),
$ticketsRepo ?? $this->make(TicketRepository::class),
$ideaRepo ?? $this->make(IdeaRepository::class),
);
}
public function test_get_project_label_reads_ticket_state_label_name(): void
{
$ticketsRepo = $this->make(TicketRepository::class, [
'getStateLabels' => fn () => [
3 => ['name' => 'In Progress'],
],
]);
$label = $this->makeService(ticketsRepo: $ticketsRepo)->getProjectLabel('ticketlabels', 3, 1);
$this->assertSame('In Progress', $label);
}
public function test_get_project_label_returns_empty_for_missing_ticket_label(): void
{
$ticketsRepo = $this->make(TicketRepository::class, [
'getStateLabels' => fn () => [
3 => ['name' => 'In Progress'],
],
]);
$label = $this->makeService(ticketsRepo: $ticketsRepo)->getProjectLabel('ticketlabels', 99, 1);
$this->assertSame('', $label);
}
public function test_get_project_label_reads_idea_label_name(): void
{
$ideaRepo = $this->make(IdeaRepository::class, [
'getCanvasLabels' => fn () => [
1 => ['name' => 'Backlog', 'class' => 'label-default'],
],
]);
$label = $this->makeService(ideaRepo: $ideaRepo)->getProjectLabel('idealabels', 1, 1);
$this->assertSame('Backlog', $label);
}
public function test_get_project_label_returns_empty_for_unknown_module(): void
{
$label = $this->makeService()->getProjectLabel('doesnotexist', 1, 1);
$this->assertSame('', $label);
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace Unit\app\Domain\Sprints\Services;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Exceptions\MissingParameterException;
use Leantime\Domain\Reports\Repositories\Reports as ReportRepository;
use Leantime\Domain\Sprints\Models\Sprints as SprintModel;
use Leantime\Domain\Sprints\Repositories\Sprints as SprintRepository;
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
use Unit\TestCase;
/**
* Unit tests for the Sprints service helpers extracted during the
* thin-controller refactor (getNewSprint, deleteSprint, and the
* required-date validation now living in addSprint/editSprint).
*/
class SprintsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a real Sprints service, allowing each dependency to be
* overridden with a stub so we can observe the persistence calls.
*/
private function makeService(
?SprintRepository $sprintRepo = null,
?ReportRepository $reportRepo = null,
): SprintService {
return new SprintService(
$sprintRepo ?? $this->make(SprintRepository::class),
$reportRepo ?? $this->make(ReportRepository::class),
);
}
public function test_get_new_sprint_uses_default_thirteen_day_window(): void
{
$sprint = $this->makeService()->getNewSprint();
$this->assertInstanceOf(SprintModel::class, $sprint);
$this->assertNull($sprint->id);
// The end date should be exactly 13 days after the start date.
$this->assertSame(13, (int) $sprint->startDate->diffInDays($sprint->endDate));
}
public function test_delete_sprint_delegates_to_repository_and_clears_session(): void
{
session(['currentSprint' => '99']);
$deletedId = null;
$repo = $this->make(SprintRepository::class, [
// deleteSprint now loads the sprint to authorize delete against its project.
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 42, 'projectId' => 9]),
'delSprint' => function ($id) use (&$deletedId) {
$deletedId = $id;
},
]);
$service = $this->makeService(sprintRepo: $repo);
$service->setPermissionService($this->make(PermissionService::class, [
'authorize' => fn () => null,
]));
$service->deleteSprint(42);
$this->assertSame(42, $deletedId);
$this->assertSame('', session('currentSprint'));
}
public function test_add_sprint_throws_when_dates_missing(): void
{
$addCalls = 0;
$repo = $this->make(SprintRepository::class, [
'addSprint' => function () use (&$addCalls) {
$addCalls++;
return 1;
},
]);
// Authorization now runs before date validation (authorize-first), so allow it and assert
// the validation still rejects the missing dates before any write reaches the repository.
$service = $this->makeService(sprintRepo: $repo);
$service->setPermissionService($this->make(PermissionService::class, ['authorize' => fn () => null]));
$this->expectException(MissingParameterException::class);
try {
$service->addSprint(['startDate' => '', 'endDate' => '']);
} finally {
$this->assertSame(0, $addCalls, 'An invalid sprint must never reach the repository');
}
}
public function test_edit_sprint_throws_when_end_date_missing(): void
{
$editCalls = 0;
$repo = $this->make(SprintRepository::class, [
// editSprint loads the existing sprint to authorize against its project before it
// validates the dates, so the load must be stubbed even on the validation-failure path.
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 5, 'projectId' => 9]),
'editSprint' => function () use (&$editCalls) {
$editCalls++;
return true;
},
]);
$service = $this->makeService(sprintRepo: $repo);
$service->setPermissionService($this->make(PermissionService::class, ['authorize' => fn () => null]));
$this->expectException(MissingParameterException::class);
try {
$service->editSprint(['id' => 5, 'startDate' => '2026-01-01', 'endDate' => '']);
} finally {
$this->assertSame(0, $editCalls, 'An invalid update must never reach the repository');
}
}
// ---------------------------------------------------------------------
// Authorization: sprints are project-scoped; mutators authorize against the SPRINT'S
// project (entityScoped), closing the IDOR where the id alone identified the row.
// ---------------------------------------------------------------------
private function denyingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => function (): void {
throw new AuthorizationException;
},
]);
}
public function test_delete_sprint_is_denied_and_does_not_delete_without_permission(): void
{
// deleteSprint loads the sprint and authorizes sprints.delete against ITS project before
// deleting — a denying engine must throw BEFORE the repository delete runs.
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 5, 'projectId' => 9]),
'delSprint' => function (): void {
throw new \RuntimeException('delete must not be reached when denied');
},
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->deleteSprint(5);
}
public function test_add_sprint_is_denied_without_create_permission(): void
{
session(['currentProject' => 9]);
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
'addSprint' => function () {
throw new \RuntimeException('add must not be reached when denied');
},
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->addSprint(['startDate' => '2026-01-01', 'endDate' => '2026-01-14', 'projectId' => 9]);
}
public function test_get_sprint_is_denied_when_user_cannot_view_its_project(): void
{
// Read-side IDOR fence: getSprint loads the sprint, then authorizes VIEW against ITS project
// (not the session project). A denying engine must throw before any cross-project sprint
// metadata (name/dates/projectId) is returned.
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 7, 'projectId' => 9]),
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->getSprint(7);
}
public function test_get_sprint_returns_the_sprint_when_view_is_allowed(): void
{
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
'getSprint' => fn () => $this->make(SprintModel::class, ['id' => 7, 'projectId' => 9]),
]));
$service->setPermissionService($this->make(PermissionService::class, ['authorize' => fn () => null]));
$this->assertSame(7, $service->getSprint(7)->id);
}
public function test_get_sprint_returns_false_for_unknown_id_without_authorizing(): void
{
// A missing sprint short-circuits to false BEFORE authorize, so there is no enumeration
// oracle (allowed vs denied looks identical for a non-existent id) and no false lockout.
$authorizeCalls = 0;
$service = $this->makeService(sprintRepo: $this->make(SprintRepository::class, [
'getSprint' => fn () => false, // repo returns false (not null) for a missing row
]));
$service->setPermissionService($this->make(PermissionService::class, [
'authorize' => function () use (&$authorizeCalls): void {
$authorizeCalls++;
},
]));
$this->assertFalse($service->getSprint(999));
$this->assertSame(0, $authorizeCalls, 'A non-existent sprint must short-circuit before authorize');
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace Tests\Unit\app\Domain\Status\Controllers;
use Leantime\Core\Application;
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
use Leantime\Core\Bootstrap\LoadConfig;
use Leantime\Core\Bootstrap\SetRequestForConsole;
use Leantime\Core\Configuration\AppSettings;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\UI\Template;
use Leantime\Domain\Plugins\Services\Plugins;
use Leantime\Domain\Status\Controllers\Index;
/**
* Unit tests for the public /status discovery endpoint.
*
* Pins the contract the mobile app relies on (authMethods + oidcLoginUrl drive
* whether the SSO button appears) AND the security tier: the unauthenticated
* response must NEVER leak a plugin/version inventory.
*/
class IndexTest extends \Unit\TestCase
{
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));
}
private function makeController(array $overrides): Index
{
// Environment's constructor overwrites known config keys with
// env-resolved defaults, so set the values AFTER construction.
$env = new Environment;
$env->set('oidcEnable', $overrides['oidcEnable'] ?? false);
$env->set('useLdap', $overrides['useLdap'] ?? false);
$env->set('sitename', $overrides['sitename'] ?? 'Leantime');
$request = IncomingRequest::create('https://demo.leantime.io/status', 'GET');
$this->app->instance(IncomingRequest::class, $request);
$this->app->instance(Environment::class, $env);
$this->app->instance(AppSettings::class, new AppSettings);
// Mobile-auth advertising is gated on AdvancedAuth; mock it installed so
// these contract tests cover a mobile-capable instance. The gate itself
// is verified live e2e (AdvancedAuth off -> mobile OIDC not advertised).
$plugins = $this->createMock(Plugins::class);
$plugins->method('isEnabled')->willReturn(true);
$this->app->instance(Plugins::class, $plugins);
return new Index($request, $this->createMock(Template::class), $this->createMock(Language::class));
}
private function bodyOf($response): array
{
return json_decode($response->getContent(), true);
}
public function test_password_only_when_no_sso_configured(): void
{
$response = $this->makeController(['oidcEnable' => false, 'useLdap' => false, 'sitename' => 'Acme'])->get([]);
$body = $this->bodyOf($response);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame(['password'], $body['authMethods']);
$this->assertArrayNotHasKey('oidcLoginUrl', $body);
$this->assertSame('Acme', $body['instanceName']);
$this->assertTrue($body['mobileAuthEnabled']);
}
public function test_oidc_enabled_advertises_oidc_and_login_url(): void
{
$response = $this->makeController(['oidcEnable' => true, 'useLdap' => false, 'sitename' => 'Acme'])->get([]);
$body = $this->bodyOf($response);
$this->assertContains('oidc', $body['authMethods']);
$this->assertSame('https://demo.leantime.io/oidc/login', $body['oidcLoginUrl']);
}
public function test_response_never_leaks_a_plugin_or_version_inventory(): void
{
// The unauthenticated tier must not become a recon gift.
$response = $this->makeController(['oidcEnable' => true, 'useLdap' => false])->get([]);
$body = $this->bodyOf($response);
$this->assertArrayNotHasKey('plugins', $body);
$this->assertArrayNotHasKey('dbVersion', $body);
$this->assertArrayHasKey('version', $body);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Unit\app\Domain\Tags\Services;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Domain\Blueprints\Repositories\Blueprints as CanvaRepository;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Tags\Services\Tags as TagService;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Unit\TestCase;
/**
* Unit tests for the project-access authorization added to Tags::getTags when the
* /api/tags REST controller (which forced session('currentProject')) was retired in
* favour of the JSON-RPC entry point Tags.Tags.getTags.
*/
class TagsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected function setUp(): void
{
parent::setUp();
session(['userdata.id' => 1]);
}
private function makeService(
ProjectRepository $projectRepo,
?TicketRepository $ticketRepo = null,
?CanvaRepository $canvasRepo = null,
): TagService {
return new TagService(
$projectRepo,
$canvasRepo ?? $this->make(CanvaRepository::class, ['getTags' => fn () => []]),
$ticketRepo ?? $this->make(TicketRepository::class, ['getTags' => fn () => []]),
);
}
public function test_get_tags_throws_and_does_not_query_when_user_cannot_access_project(): void
{
$queryCalls = 0;
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
]);
$ticketRepo = $this->make(TicketRepository::class, [
'getTags' => function () use (&$queryCalls) {
$queryCalls++;
return [];
},
]);
$thrown = null;
try {
$this->makeService($projectRepo, $ticketRepo)->getTags(99, '');
} catch (AuthorizationException $e) {
$thrown = $e;
}
// No access must be a distinct, thrown signal -- NOT an empty array (which means "no matching tags").
$this->assertInstanceOf(AuthorizationException::class, $thrown, 'A user must not read tags for a project they cannot access');
$this->assertSame(0, $queryCalls, 'Unauthorized request must not even query the tag tables');
}
public function test_get_tags_returns_filtered_tags_for_accessible_project(): void
{
$projectRepo = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
]);
$ticketRepo = $this->make(TicketRepository::class, [
'getTags' => fn () => [['tags' => 'backend,frontend']],
]);
$canvasRepo = $this->make(CanvaRepository::class, [
'getTags' => fn () => [['tags' => 'design']],
]);
$result = $this->makeService($projectRepo, $ticketRepo, $canvasRepo)->getTags(5, 'end');
sort($result);
// "backend" and "frontend" both contain "end"; "design" does not.
$this->assertSame(['backend', 'frontend'], $result);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Unit\app\Domain\Tickets\Events;
use Codeception\Test\Unit;
use Leantime\Domain\Tickets\Events\MilestoneCreated;
use Leantime\Domain\Tickets\Events\MilestoneDeleted;
use Leantime\Domain\Tickets\Events\MilestoneUpdated;
use Leantime\Domain\Tickets\Events\StatusLabelsUpdated;
use Leantime\Domain\Tickets\Events\TicketCreated;
use Leantime\Domain\Tickets\Events\TicketDeleted;
use Leantime\Domain\Tickets\Events\TicketListFilter;
use Leantime\Domain\Tickets\Events\TicketStatusUpdated;
use Leantime\Domain\Tickets\Events\TicketUpdated;
use Leantime\Domain\Tickets\Events\TodoWidgetTasksFilter;
/**
* Backwards-compatibility contract for the Tickets pilot: every migrated emit site must
* keep producing the EXACT historical string name it fired under before the class-based
* migration (audited 2026-06). Plugins (Copilot, Llamadorian, Reactions, RecurringTasks,
* observability wildcards) subscribe to these strings — a mismatch silently orphans them.
*
* The expected names are frozen from the pre-migration audit. If this test fails, fix the
* event class or call site — do NOT update the expected name unless the corresponding
* legacy hook is being intentionally retired at the end of the migration window.
*/
class TicketsEventsBcTest extends Unit
{
public function test_every_migrated_emit_site_produces_its_audited_historical_name(): void
{
$prefix = 'leantime.domain.tickets.services.tickets.';
$repoPrefix = 'leantime.domain.tickets.repositories.tickets.';
$expectations = [
// events — services
[new TicketCreated(ticketId: 1, legacyHook: 'quickAddTicket'), $prefix.'quickAddTicket.ticket_created'],
[new TicketCreated(ticketId: 1, legacyHook: 'addTicket'), $prefix.'addTicket.ticket_created'],
[new TicketCreated(legacyHook: 'upsertSubtask'), $prefix.'upsertSubtask.ticket_created'],
[new TicketUpdated(ticketId: 1, legacyHook: 'updateTicket'), $prefix.'updateTicket.ticket_updated'],
[new TicketUpdated(ticketId: 1, legacyHook: 'patch'), $prefix.'patch.ticket_updated'],
[new TicketUpdated(ticketId: 1, legacyHook: 'upsertSubtask'), $prefix.'upsertSubtask.ticket_updated'],
[new TicketUpdated(legacyHook: 'updateTicketSorting'), $prefix.'updateTicketSorting.ticket_updated'],
[new TicketUpdated(legacyHook: 'updateTicketStatusAndSorting'), $prefix.'updateTicketStatusAndSorting.ticket_updated'],
[new TicketDeleted(ticketId: 1, legacyHook: 'delete'), $prefix.'delete.ticket_deleted'],
[new MilestoneCreated(legacyHook: 'quickAddMilestone'), $prefix.'quickAddMilestone.milestone_created'],
[new MilestoneUpdated(milestoneId: 1, legacyHook: 'quickUpdateMilestone'), $prefix.'quickUpdateMilestone.milestone_updated'],
[new MilestoneDeleted(milestoneId: 1, legacyHook: 'deleteMilestone'), $prefix.'deleteMilestone.milestone_deleted'],
[new StatusLabelsUpdated(projectId: 1, legacyHook: 'saveStatusLabels'), $prefix.'saveStatusLabels.statusLabels_updated'],
// events — repository
[new TicketStatusUpdated(ticketId: 1, status: 3, legacyHook: 'patchTicket'), $repoPrefix.'patchTicket.ticketStatusUpdate'],
[new TicketStatusUpdated(ticketId: 1, status: 3, legacyHook: 'updateTicketStatus'), $repoPrefix.'updateTicketStatus.ticketStatusUpdate'],
// filters
[new TicketListFilter(tickets: [], legacyHook: 'getTicketTemplateAssignments'), $prefix.'getTicketTemplateAssignments.filterTickets'],
[new TodoWidgetTasksFilter(tickets: [], legacyHook: 'getToDoWidgetAssignments'), $prefix.'getToDoWidgetAssignments.myTodoWidgetTasks'],
[new TodoWidgetTasksFilter(tickets: [], hierarchical: true, legacyHook: 'getToDoWidgetHierarchicalAssignments'), $prefix.'getToDoWidgetHierarchicalAssignments.myTodoWidgetTasks'],
];
foreach ($expectations as [$event, $expectedName]) {
$this->assertSame(
[$expectedName],
$event->legacyHooks(),
get_class($event).' must keep firing its audited historical name'
);
}
}
/**
* Each emit site passes __FUNCTION__ as the legacy hook, so the method names baked
* into the expectations above must actually exist on the emitting classes — guards
* against renames silently orphaning the legacy names.
*/
public function test_legacy_hook_method_names_still_exist_on_emitters(): void
{
$serviceMethods = [
'saveStatusLabels', 'quickAddTicket', 'quickAddMilestone', 'addTicket',
'updateTicket', 'patch', 'quickUpdateMilestone', 'upsertSubtask',
'updateTicketSorting', 'updateTicketStatusAndSorting', 'delete', 'deleteMilestone',
'getTicketTemplateAssignments', 'getToDoWidgetAssignments', 'getToDoWidgetHierarchicalAssignments',
];
foreach ($serviceMethods as $method) {
$this->assertTrue(
method_exists(\Leantime\Domain\Tickets\Services\Tickets::class, $method),
"Tickets service method {$method} was renamed — its legacy event name is now orphaned"
);
}
foreach (['patchTicket', 'updateTicketStatus'] as $method) {
$this->assertTrue(
method_exists(\Leantime\Domain\Tickets\Repositories\Tickets::class, $method),
"Tickets repository method {$method} was renamed — its legacy event name is now orphaned"
);
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Unit\app\Domain\Tickets\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Domain\Tickets\Repositories\Tickets;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Unit\TestCase;
/**
* Regression tests for patchTicket() field-name matching (#3692).
*
* PATCHABLE_COLUMNS is mostly camelCase, but 'milestoneid' matches the real column name.
* The lookup was a case-sensitive isset(), so the documented API/MCP field 'milestoneId'
* never matched and was dropped — while patchTicket() still reported success, which is the
* part that makes it dangerous for automation.
*
* These call patchTicket() for real against a faked connection and assert on the payload
* it would have written — no DB.
*/
class PatchTicketColumnsTest extends TestCase
{
use MockeryPHPUnitIntegration;
/**
* Run patchTicket() and capture the column => value payload handed to update().
*
* @param array<string, mixed> $params
* @return array{result: bool, updates: array<string, mixed>}
*/
private function runPatch(array $params): array
{
$updates = [];
$builder = Mockery::mock();
$builder->shouldReceive('where')->andReturnSelf();
$builder->shouldReceive('update')->andReturnUsing(function ($payload) use (&$updates) {
$updates = $payload;
return 1;
});
// addTicketChange() reads the previous row before logging the change; an empty
// result short-circuits it without touching anything under test.
$builder->shouldReceive('select')->andReturnSelf();
$builder->shouldReceive('limit')->andReturnSelf();
$builder->shouldReceive('first')->andReturn(null);
$builder->shouldReceive('insert')->andReturn(true);
$builder->shouldReceive('get')->andReturn(collect());
$conn = Mockery::mock(ConnectionInterface::class);
$conn->shouldReceive('table')->andReturn($builder);
$repo = (new \ReflectionClass(Tickets::class))->newInstanceWithoutConstructor();
$prop = new \ReflectionProperty(Tickets::class, 'connection');
$prop->setAccessible(true);
$prop->setValue($repo, $conn);
$result = $repo->patchTicket(42, $params);
return ['result' => $result, 'updates' => $updates];
}
public function test_documented_milestone_id_casing_actually_patches(): void
{
$run = $this->runPatch(['milestoneId' => 7]);
$this->assertArrayHasKey(
'milestoneid',
$run['updates'],
'The documented milestoneId field must reach the update as the real column (#3692)'
);
$this->assertSame(7, $run['updates']['milestoneid']);
$this->assertTrue($run['result']);
}
public function test_lowercase_milestoneid_still_works(): void
{
$run = $this->runPatch(['milestoneid' => 9]);
$this->assertSame(9, $run['updates']['milestoneid'] ?? null);
}
public function test_unknown_fields_are_still_ignored(): void
{
$run = $this->runPatch(['bogusColumn' => 'x', 'headline' => 'kept']);
$this->assertArrayNotHasKey('bogusColumn', $run['updates']);
$this->assertArrayNotHasKey('boguscolumn', $run['updates']);
$this->assertSame('kept', $run['updates']['headline'] ?? null);
}
public function test_a_patch_of_only_unknown_fields_reports_failure(): void
{
$run = $this->runPatch(['bogusColumn' => 'x']);
$this->assertFalse(
$run['result'],
'Nothing patchable means nothing was written, and the caller must be told'
);
}
}

View File

@@ -0,0 +1,901 @@
<?php
namespace Unit\app\Domain\Tickets\Services;
use Carbon\CarbonImmutable;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Configuration\Environment as EnvironmentCore;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Core\Support\DateTimeHelper;
use Leantime\Core\UI\Template as TemplateCore;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
use Leantime\Domain\Tickets\Models\Tickets as TicketModel;
use Leantime\Domain\Tickets\Repositories\TicketHistory;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Leantime\Domain\Tickets\Services\Tickets as TicketsService;
use Leantime\Domain\Timesheets\Repositories\Timesheets as TimesheetRepository;
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
use Unit\TestCase;
class TicketsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected TicketsService $ticketsService;
protected function setUp(): void
{
parent::setUp();
// Set up session values needed for DateTimeHelper
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->make(EnvironmentCore::class, [
'defaultTimezone' => 'UTC',
'language' => 'en-US',
]);
app()->instance(EnvironmentCore::class, $envMock);
// Mock Language and bind to container
$langMock = $this->createMock(LanguageCore::class);
$langMock->method('__')->willReturnCallback(function ($index) {
$map = [
'language.dateformat' => 'Y-m-d',
'language.timeformat' => 'H:i',
];
return $map[$index] ?? $index;
});
app()->instance(LanguageCore::class, $langMock);
// Register CarbonMacros for date parsing
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
// Create mocks for all dependencies
$tpl = $this->make(TemplateCore::class);
$language = $this->make(LanguageCore::class);
$config = $this->make(EnvironmentCore::class);
$projectRepository = $this->make(ProjectRepository::class);
$ticketRepository = $this->make(TicketRepository::class);
$timesheetsRepo = $this->make(TimesheetRepository::class);
$settingsRepo = $this->make(SettingRepository::class);
$projectService = $this->make(ProjectService::class);
$timesheetService = $this->make(TimesheetService::class);
$sprintService = $this->make(SprintService::class);
$ticketHistoryRepo = $this->make(TicketHistory::class);
$goalcanvasService = $this->make(Goalcanvas::class);
$dateTimeHelper = $this->make(DateTimeHelper::class);
$commentService = $this->make(CommentService::class);
$clientService = $this->make(ClientService::class);
// Instantiate the service with mocked dependencies
$this->ticketsService = new TicketsService(
language: $language,
ticketRepository: $ticketRepository,
timesheetsRepo: $timesheetsRepo,
settingsRepo: $settingsRepo,
projectService: $projectService,
timesheetService: $timesheetService,
sprintService: $sprintService,
ticketHistoryRepo: $ticketHistoryRepo,
goalcanvasService: $goalcanvasService,
dateTimeHelper: $dateTimeHelper,
commentService: $commentService,
clientService: $clientService
);
}
protected function _after()
{
// Clear any frozen Carbon "now" so a test that freezes it (e.g. the
// board-summary due-this-week test) can't leak into later tests.
CarbonImmutable::setTestNow();
$this->ticketsService = null;
}
/**
* Test that timeFrom is unset when editFrom parsing fails
*/
public function test_prepare_ticket_dates_removes_time_from_on_parse_error()
{
$values = [
'editFrom' => 'Invalid DateTime',
'timeFrom' => '12:00',
];
$result = $this->ticketsService->prepareTicketDates($values);
// Date should be cleared
$this->assertEquals('', $result['editFrom']);
// Time field must be removed to prevent SQL error
$this->assertArrayNotHasKey('timeFrom', $result);
}
/**
* Test that timeTo is unset when editTo parsing fails
* This is the primary bug from issue #3139
*/
public function test_prepare_ticket_dates_removes_time_to_on_parse_error()
{
$values = [
'editTo' => 'Invalid DateTime',
'timeTo' => '17:00',
];
$result = $this->ticketsService->prepareTicketDates($values);
$this->assertEquals('', $result['editTo']);
$this->assertArrayNotHasKey('timeTo', $result);
}
/**
* getBoardSummary should count total/unassigned/due-this-week and surface the
* most recent modified date, working off the grouped ticket set as-is.
*/
public function test_get_board_summary_computes_counts_and_last_updated()
{
// Freeze "now" to a fixed instant (noon, well clear of a midnight/week
// boundary) so $dueToday and getBoardSummary's weekStart/weekEnd are
// computed from the same clock — otherwise a run straddling midnight
// could make the due-this-week assertion flaky. Cleared in _after().
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-15 12:00:00', 'UTC'));
// getBoardSummary parses dateToFinish via parseDbDateTime() (DB tz) and
// converts to the user tz before the "this week" compare, so the stored
// strings must be DB-tz. Derive them from userNow()->setToDbTimezone()
// so they round-trip user→db→user and "due today" stays stable even if
// this test's user timezone is later moved off UTC.
$nowUser = dtHelper()->userNow();
$dueToday = $nowUser->setToDbTimezone()->format('Y-m-d H:i:s');
$dueTwoMonthsAgo = $nowUser->subMonths(2)->setToDbTimezone()->format('Y-m-d H:i:s');
$dueTwoMonthsOut = $nowUser->addMonths(2)->setToDbTimezone()->format('Y-m-d H:i:s');
$mk = function (mixed $editorId, ?string $due, ?string $modified) {
$ticket = new \stdClass;
$ticket->editorId = $editorId;
$ticket->dateToFinish = $due;
$ticket->modified = $modified;
return $ticket;
};
$grouped = [
'all' => [
'label' => 'all',
'items' => [
// assigned, due today (this week), older change
$mk(5, $dueToday, '2026-07-01 10:00:00'),
// unassigned (empty editor), due 2 months ago (not this week), newest change
$mk('', $dueTwoMonthsAgo, '2026-07-15 09:00:00'),
// unassigned (zero editor), no due date set
$mk(0, '0000-00-00 00:00:00', '2026-06-01 08:00:00'),
// assigned, due 2 months out (beyond this week), no modified stamp
$mk(7, $dueTwoMonthsOut, null),
],
],
];
$summary = $this->ticketsService->getBoardSummary($grouped);
$this->assertSame(4, $summary->total);
$this->assertSame(2, $summary->unassigned);
$this->assertSame(1, $summary->dueThisWeek);
$this->assertNotNull($summary->lastUpdated);
$this->assertSame('2026-07-15 09:00:00', $summary->lastUpdated->format('Y-m-d H:i:s'));
}
/**
* An empty board yields zeroed counts and a null last-updated.
*/
public function test_get_board_summary_handles_empty_board()
{
$summary = $this->ticketsService->getBoardSummary(['all' => ['items' => []]]);
$this->assertSame(0, $summary->total);
$this->assertSame(0, $summary->unassigned);
$this->assertSame(0, $summary->dueThisWeek);
$this->assertNull($summary->lastUpdated);
}
/**
* Sentinel date strings (0000-00-00 and 1969-12-31 — both rejected by
* parseDbDateTime) must be skipped, not blow up the whole board summary.
* Regression: the guard originally only filtered 0000-00-00, so a
* 1969-12-31 stamp threw InvalidDateException and broke the header.
*/
public function test_get_board_summary_skips_sentinel_dates_without_throwing()
{
$mk = function (?string $due, ?string $modified) {
$ticket = new \stdClass;
$ticket->editorId = 5;
$ticket->dateToFinish = $due;
$ticket->modified = $modified;
return $ticket;
};
$grouped = [
'all' => [
'items' => [
$mk('1969-12-31 00:00:00', '1969-12-31 00:00:00'),
$mk('0000-00-00 00:00:00', '0000-00-00 00:00:00'),
// Malformed but NON-sentinel — passes isValidDateString yet
// parseDbDateTime throws. The try/catch must swallow it.
$mk('not a date', 'garbage-value'),
$mk(null, null),
],
],
];
$summary = $this->ticketsService->getBoardSummary($grouped);
$this->assertSame(4, $summary->total);
// No valid due dates → none counted this week; no valid modified → null.
$this->assertSame(0, $summary->dueThisWeek);
$this->assertNull($summary->lastUpdated);
}
/**
* Test that timeToFinish is unset when dateToFinish parsing fails
*/
public function test_prepare_ticket_dates_removes_time_to_finish_on_parse_error()
{
$values = [
'dateToFinish' => 'Invalid DateTime',
'timeToFinish' => '23:59',
];
$result = $this->ticketsService->prepareTicketDates($values);
$this->assertEquals('', $result['dateToFinish']);
$this->assertArrayNotHasKey('timeToFinish', $result);
}
/**
* Test that valid dates work correctly and time fields are removed
*/
public function test_prepare_ticket_dates_successfully_parses_valid_dates()
{
$values = [
'editFrom' => '2025-11-30',
'timeFrom' => '09:00',
'editTo' => '2025-11-30',
'timeTo' => '17:00',
];
$result = $this->ticketsService->prepareTicketDates($values);
// Dates should be formatted for DB (not empty)
$this->assertNotEmpty($result['editFrom']);
$this->assertNotEmpty($result['editTo']);
// Time fields should be removed after successful parsing
$this->assertArrayNotHasKey('timeFrom', $result);
$this->assertArrayNotHasKey('timeTo', $result);
}
/**
* normalizeRoadmapParams defaults the type to milestone when not provided.
*/
public function test_normalize_roadmap_params_defaults_type_to_milestone()
{
$result = $this->ticketsService->normalizeRoadmapParams([]);
$this->assertEquals('milestone', $result['type']);
$this->assertArrayNotHasKey('excludeType', $result);
}
/**
* normalizeRoadmapParams keeps an explicitly provided type.
*/
public function test_normalize_roadmap_params_keeps_provided_type()
{
$result = $this->ticketsService->normalizeRoadmapParams(['type' => 'task']);
$this->assertEquals('task', $result['type']);
}
/**
* normalizeRoadmapParams clears type and excludeType when showing tasks.
*/
public function test_normalize_roadmap_params_clears_filters_when_showing_tasks()
{
$result = $this->ticketsService->normalizeRoadmapParams(['showTasks' => 'true']);
$this->assertEquals('', $result['type']);
$this->assertEquals('', $result['excludeType']);
}
/**
* getMilestonesOverviewSearchCriteria defaults the status to not_done when none provided.
*/
public function test_overview_search_criteria_defaults_status_to_not_done()
{
$result = $this->ticketsService->getMilestonesOverviewSearchCriteria([]);
$this->assertEquals('not_done', $result['status']);
}
/**
* getMilestonesOverviewSearchCriteria respects an explicitly selected status.
*/
public function test_overview_search_criteria_respects_selected_status()
{
$result = $this->ticketsService->getMilestonesOverviewSearchCriteria(['status' => '3']);
$this->assertEquals('3', $result['status']);
}
/**
* getNewMilestone returns a default milestone with status 3 and a one-week edit window.
*/
public function test_get_new_milestone_has_default_status_and_one_week_window()
{
$milestone = $this->ticketsService->getNewMilestone();
$this->assertEquals(3, $milestone->status);
$expectedFrom = CarbonImmutable::now()->format('Y-m-d');
$expectedTo = CarbonImmutable::now()->addWeek()->format('Y-m-d');
$this->assertEquals($expectedFrom, $milestone->editFrom);
$this->assertEquals($expectedTo, $milestone->editTo);
}
/**
* getClientNameById returns an empty string when no client id is given.
*/
public function test_get_client_name_by_id_returns_empty_for_zero_id()
{
$this->assertEquals('', $this->ticketsService->getClientNameById(0));
}
/**
* getClientNameById resolves the name from the clients service.
*/
public function test_get_client_name_by_id_resolves_name()
{
$service = $this->buildServiceWithClientService(
$this->make(ClientService::class, [
'get' => fn () => ['id' => 5, 'name' => 'Acme Inc'],
])
);
$this->assertEquals('Acme Inc', $service->getClientNameById(5));
}
/**
* getClientNameById returns an empty string when the client is not found.
*/
public function test_get_client_name_by_id_returns_empty_when_not_found()
{
$service = $this->buildServiceWithClientService(
$this->make(ClientService::class, [
'get' => fn () => false,
])
);
$this->assertEquals('', $service->getClientNameById(99));
}
/**
* Builds a TicketsService using the default mocks but with a specific
* ClientService instance, so client-name resolution can be asserted.
*/
private function buildServiceWithClientService(ClientService $clientService): TicketsService
{
return new TicketsService(
language: $this->make(LanguageCore::class),
ticketRepository: $this->make(TicketRepository::class),
timesheetsRepo: $this->make(TimesheetRepository::class),
settingsRepo: $this->make(SettingRepository::class),
projectService: $this->make(ProjectService::class),
timesheetService: $this->make(TimesheetService::class),
sprintService: $this->make(SprintService::class),
ticketHistoryRepo: $this->make(TicketHistory::class),
goalcanvasService: $this->make(Goalcanvas::class),
dateTimeHelper: $this->make(DateTimeHelper::class),
commentService: $this->make(CommentService::class),
clientService: $clientService
);
}
/**
* Builds a TicketsService using the default mocks but with a specific
* TicketRepository instance, so collaborator enrichment can be asserted.
*/
private function buildServiceWithTicketRepository(TicketRepository $ticketRepository): TicketsService
{
return new TicketsService(
language: $this->make(LanguageCore::class),
ticketRepository: $ticketRepository,
timesheetsRepo: $this->make(TimesheetRepository::class),
settingsRepo: $this->make(SettingRepository::class),
projectService: $this->make(ProjectService::class),
timesheetService: $this->make(TimesheetService::class),
sprintService: $this->make(SprintService::class),
ticketHistoryRepo: $this->make(TicketHistory::class),
goalcanvasService: $this->make(Goalcanvas::class),
dateTimeHelper: $this->make(DateTimeHelper::class),
commentService: $this->make(CommentService::class),
clientService: $this->make(ClientService::class)
);
}
public function test_get_all_open_user_tickets_excludes_closed_projects_at_query_level(): void
{
session(['userdata' => ['id' => 1, 'role' => 'admin']]);
// Closed-project (state === -1) exclusion lives in the SQL layer now, so
// the service's contract is simply: ask simpleTicketQuery to exclude
// them. Capture the flag it passes.
$captured = null;
$ticketRepository = $this->make(TicketRepository::class, [
'simpleTicketQuery' => function ($userId, $projectId, $types = [], $excludeClosedProjects = false) use (&$captured) {
$captured = $excludeClosedProjects;
return [];
},
]);
$service = $this->buildServiceWithTicketRepository($ticketRepository);
$service->getAllOpenUserTickets(1);
$this->assertTrue($captured, 'getAllOpenUserTickets must exclude closed-project tickets at the query level');
}
// ---------------------------------------------------------------------
// JSON-RPC authorization gates (RPC has no controller-level role gate, so
// the @api entry methods must self-authorize).
// ---------------------------------------------------------------------
public function test_patch_ticket_is_denied_for_non_editor(): void
{
session(['userdata' => ['id' => 1, 'role' => 'readonly']]);
// patchTicket loads the ticket, then authorizes tickets.edit against its project via
// the permission engine. Stub getTicket so it resolves, and inject a denying engine.
$service = $this->construct(
TicketsService::class,
[
$this->make(LanguageCore::class),
$this->make(TicketRepository::class),
$this->make(TimesheetRepository::class),
$this->make(SettingRepository::class),
$this->make(ProjectService::class),
$this->make(TimesheetService::class),
$this->make(SprintService::class),
$this->make(TicketHistory::class),
$this->make(Goalcanvas::class),
$this->make(DateTimeHelper::class),
$this->make(CommentService::class),
$this->make(ClientService::class),
],
['getTicket' => fn () => $this->make(TicketModel::class, ['id' => 5, 'projectId' => 9])],
);
$service->setPermissionService($this->make(PermissionService::class, [
'authorize' => function (): void {
throw new AuthorizationException;
},
]));
$this->expectException(AuthorizationException::class);
$service->patchTicket(5, ['status' => 3]);
}
public function test_sort_tickets_is_denied_for_non_editor(): void
{
session(['userdata' => ['id' => 1, 'role' => 'readonly']]);
$this->expectException(AuthorizationException::class);
$this->ticketsService->sortTickets(['5' => 1]);
}
public function test_status_and_sorting_is_denied_for_non_editor(): void
{
session(['userdata' => ['id' => 1, 'role' => 'readonly']]);
$this->assertFalse($this->ticketsService->updateTicketStatusAndSorting(['3' => 'ticket[]=5'], null));
}
public function test_quick_add_ticket_is_denied_without_create_permission(): void
{
session(['userdata' => ['id' => 1, 'role' => 'readonly']]);
// quickAddTicket resolves the project from its params, then authorizes tickets.create
// through the engine before doing any work. This was one of the RPC holes: any
// authenticated caller could create tickets. A denying engine must make it throw.
$this->ticketsService->setPermissionService($this->make(PermissionService::class, [
'authorize' => function (): void {
throw new AuthorizationException;
},
]));
$this->expectException(AuthorizationException::class);
$this->ticketsService->quickAddTicket(['headline' => 'New task', 'projectId' => 9]);
}
// ---------------------------------------------------------------------
// Collaborator enrichment for grouped ticket views (list/kanban + widget)
// ---------------------------------------------------------------------
/**
* enrichGroupedTicketsWithCollaborators adds metadata to 'items' groups (list/kanban views).
*/
public function test_enrich_grouped_tickets_with_collaborators_items_key()
{
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
'getCollaboratorsByTicketIds' => fn ($ids) => [
10 => [100, 200],
11 => [300],
],
]));
$groupedTickets = [
'group1' => [
'items' => [
['id' => 10, 'editorId' => 100, 'headline' => 'Task A'],
['id' => 11, 'editorId' => 0, 'headline' => 'Task B'],
],
],
];
$method = new \ReflectionMethod($service, 'enrichGroupedTicketsWithCollaborators');
$method->setAccessible(true);
$result = $method->invoke($service, $groupedTickets);
// Ticket 10: editorId=100 is excluded from collaborator list, leaving only [200]
$this->assertEquals([200], $result['group1']['items'][0]['collaborators']);
$this->assertEquals([200], $result['group1']['items'][0]['collaboratorPreview']);
$this->assertEquals(1, $result['group1']['items'][0]['collaboratorCount']);
$this->assertEquals(0, $result['group1']['items'][0]['collaboratorOverflow']);
// Ticket 11: no editorId filter, so [300] stays
$this->assertEquals([300], $result['group1']['items'][1]['collaborators']);
$this->assertEquals(1, $result['group1']['items'][1]['collaboratorCount']);
}
/**
* enrichGroupedTicketsWithCollaborators supports the 'tickets' key (ToDoWidget views).
*/
public function test_enrich_grouped_tickets_with_collaborators_tickets_key()
{
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
'getCollaboratorsByTicketIds' => fn ($ids) => [
20 => [400, 500, 600],
],
]));
$groupedTickets = [
'thisWeek' => [
'labelName' => 'subtitles.due_this_week',
'tickets' => [
['id' => 20, 'editorId' => 400, 'headline' => 'Widget Task'],
],
],
];
$method = new \ReflectionMethod($service, 'enrichGroupedTicketsWithCollaborators');
$method->setAccessible(true);
$result = $method->invoke($service, $groupedTickets);
// editorId=400 excluded, leaving [500, 600]
$this->assertEquals([500, 600], $result['thisWeek']['tickets'][0]['collaborators']);
$this->assertEquals([500, 600], $result['thisWeek']['tickets'][0]['collaboratorPreview']);
$this->assertEquals(2, $result['thisWeek']['tickets'][0]['collaboratorCount']);
$this->assertEquals(0, $result['thisWeek']['tickets'][0]['collaboratorOverflow']);
}
/**
* enrichGroupedTicketsWithCollaborators reports overflow when more than 2 collaborators exist.
*/
public function test_enrich_grouped_tickets_collaborator_overflow()
{
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
'getCollaboratorsByTicketIds' => fn ($ids) => [
30 => [101, 102, 103, 104, 105],
],
]));
$groupedTickets = [
'group1' => [
'items' => [
['id' => 30, 'editorId' => 0, 'headline' => 'Many collaborators'],
],
],
];
$method = new \ReflectionMethod($service, 'enrichGroupedTicketsWithCollaborators');
$method->setAccessible(true);
$result = $method->invoke($service, $groupedTickets);
$this->assertEquals([101, 102, 103, 104, 105], $result['group1']['items'][0]['collaborators']);
$this->assertEquals([101, 102], $result['group1']['items'][0]['collaboratorPreview']);
$this->assertEquals(5, $result['group1']['items'][0]['collaboratorCount']);
$this->assertEquals(3, $result['group1']['items'][0]['collaboratorOverflow']);
}
/**
* getAllMilestones() accepts a projects-only criteria array (program/cross-project boards):
* it must query the repository and must not warn on the absent 'currentProject' key.
*/
public function test_get_all_milestones_scopes_by_projects_without_current_project()
{
$captured = null;
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
'getAllMilestones' => function ($searchCriteria, $sortBy) use (&$captured) {
$captured = $searchCriteria;
return [];
},
]));
// Projects-only criteria — no 'currentProject' key at all (the program board shape).
$result = $service->getAllMilestones(['type' => 'milestone', 'projects' => '5,7']);
$this->assertIsArray($result);
$this->assertNotNull($captured, 'repository getAllMilestones should be queried for a projects-only scope');
$this->assertSame('5,7', $captured['projects']);
$this->assertArrayNotHasKey('currentProject', $captured);
}
/**
* getAllMilestones() returns an empty array and does NOT query the repository when the
* criteria are not project-scoped (neither a currentProject id nor a projects set).
*/
public function test_get_all_milestones_unscoped_returns_empty_and_skips_repository()
{
$called = false;
$service = $this->buildServiceWithTicketRepository($this->make(TicketRepository::class, [
'getAllMilestones' => function () use (&$called) {
$called = true;
return [];
},
]));
$result = $service->getAllMilestones(['type' => 'milestone']);
$this->assertSame([], $result);
$this->assertFalse($called, 'repository should not be queried when criteria are not project-scoped');
}
/**
* getMyClosedTicketsForRange: a reversed range is normalized (earlier date
* first), only status changes INTO the ticket's current DONE status count,
* and a ticket completed more than once keeps its latest completion.
*/
public function test_closed_tickets_range_normalizes_swapped_range_and_keeps_latest_completion(): void
{
session(['userdata' => ['id' => 1]]);
$capturedFrom = null;
$capturedTo = null;
$ticketRepository = $this->make(TicketRepository::class, [
'simpleTicketQuery' => fn (...$args) => [
['id' => 10, 'type' => 'task', 'projectId' => 5, 'status' => 0],
['id' => 20, 'type' => 'task', 'projectId' => 5, 'status' => 0],
],
'getStateLabels' => fn (...$args) => [
0 => ['statusType' => 'DONE', 'name' => 'Done', 'class' => ''],
3 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress', 'class' => ''],
],
'getStatusChangeEvents' => function ($ids, $from, $to) use (&$capturedFrom, &$capturedTo) {
$capturedFrom = $from;
$capturedTo = $to;
return [
['ticketId' => 10, 'changeValue' => 0, 'dateModified' => '2026-07-10 10:00:00'],
['ticketId' => 10, 'changeValue' => 0, 'dateModified' => '2026-07-09 09:00:00'],
['ticketId' => 20, 'changeValue' => 3, 'dateModified' => '2026-07-10 10:00:00'],
];
},
]);
$service = $this->buildServiceWithTicketRepository($ticketRepository);
// Reversed range on purpose.
$result = $service->getMyClosedTicketsForRange(1, '2026-07-12', '2026-07-05');
$this->assertEquals('2026-07-05', $capturedFrom, 'range should be normalized earliest-first');
$this->assertEquals('2026-07-12', $capturedTo);
// 20's only event was a change to a non-DONE status → excluded. 10 kept
// to its latest completion (newest event wins).
$this->assertCount(1, $result);
$this->assertEquals(10, $result[0]['id']);
$this->assertEquals('2026-07-10 10:00:00', $result[0]['dateClosed']);
}
public function test_closed_tickets_range_forces_session_user_for_non_admin(): void
{
// Non-admin session user (no admin role granted).
session(['userdata' => ['id' => 1]]);
$capturedUserId = 'unset';
$ticketRepository = $this->make(TicketRepository::class, [
'simpleTicketQuery' => function (...$args) use (&$capturedUserId) {
$capturedUserId = $args[0] ?? null;
return []; // no done tickets — the asserted-on value is the userId
},
'getStateLabels' => fn (...$args) => [],
]);
$service = $this->buildServiceWithTicketRepository($ticketRepository);
// Caller supplies SOMEONE ELSE's id — the IDOR guard must force it back
// to the session user before any query runs.
$service->getMyClosedTicketsForRange(999, '2026-07-01', '2026-07-10');
$this->assertSame(1, $capturedUserId, 'a non-admin must not read another user\'s closures — userId is forced to the session user');
}
/**
* Builds a service with a specific ticket repository AND project service —
* the two deps getMyCommentedTicketsForRange exercises.
*/
private function buildServiceWithTicketRepoAndProjectService(
TicketRepository $ticketRepository,
ProjectService $projectService
): TicketsService {
return new TicketsService(
language: $this->make(LanguageCore::class),
ticketRepository: $ticketRepository,
timesheetsRepo: $this->make(TimesheetRepository::class),
settingsRepo: $this->make(SettingRepository::class),
projectService: $projectService,
timesheetService: $this->make(TimesheetService::class),
sprintService: $this->make(SprintService::class),
ticketHistoryRepo: $this->make(TicketHistory::class),
goalcanvasService: $this->make(Goalcanvas::class),
dateTimeHelper: $this->make(DateTimeHelper::class),
commentService: $this->make(CommentService::class),
clientService: $this->make(ClientService::class)
);
}
/**
* Supported = tickets you commented on within accessible projects, minus
* the ones you're the editor of. Editor-owned tickets are dropped; tickets
* outside the project-scoped fetch never appear.
*/
public function test_commented_tickets_range_excludes_owned_and_scopes_by_projects(): void
{
session(['userdata' => ['id' => 1]]);
$ticketRepository = $this->make(TicketRepository::class, [
'getTicketIdsCommentedByUser' => fn (...$args) => [10, 20, 30],
// Project-scoped fetch only returns 10 + 20 (30 is outside access).
'getTicketsByIdsWithinProjects' => fn (...$args) => [
['id' => 10, 'headline' => 'A', 'editorId' => '99', 'projectId' => 5, 'projectName' => 'P'],
['id' => 20, 'headline' => 'B', 'editorId' => '1', 'projectId' => 5, 'projectName' => 'P'],
],
]);
$projectService = $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn (...$args) => [['id' => 5], ['id' => 7]],
]);
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
$result = $service->getMyCommentedTicketsForRange(1, '2026-07-01', '2026-07-07');
// 20 is the user's own (editorId === 1) → excluded; 30 wasn't returned
// by the project-scoped fetch → absent. Only 10 remains.
$this->assertCount(1, $result);
$this->assertEquals(10, $result[0]['id']);
}
/**
* No accessible projects → empty, without ever fetching tickets.
*/
public function test_commented_tickets_range_empty_without_project_access(): void
{
session(['userdata' => ['id' => 1]]);
$ticketRepository = $this->make(TicketRepository::class, [
'getTicketIdsCommentedByUser' => fn (...$args) => [10],
'getTicketsByIdsWithinProjects' => fn (...$args) => [['id' => 10, 'editorId' => '99']],
]);
$projectService = $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn (...$args) => false,
]);
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
$this->assertSame([], $service->getMyCommentedTicketsForRange(1, '2026-07-01', '2026-07-07'));
}
public function test_commented_tickets_range_forces_session_user_for_non_admin(): void
{
session(['userdata' => ['id' => 1]]);
$capturedUserId = 'unset';
$ticketRepository = $this->make(TicketRepository::class, [
'getTicketIdsCommentedByUser' => function (...$args) use (&$capturedUserId) {
$capturedUserId = $args[0] ?? null;
return [];
},
]);
$projectService = $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn (...$args) => [['id' => 5]],
]);
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
// Non-admin supplies someone else's id — forced back to the session user.
$service->getMyCommentedTicketsForRange(999, '2026-07-01', '2026-07-07');
$this->assertSame(1, $capturedUserId, 'a non-admin must not read another user\'s comment activity — userId forced to session user');
}
public function test_commented_tickets_range_normalizes_reversed_range(): void
{
session(['userdata' => ['id' => 1]]);
$capturedFrom = null;
$capturedTo = null;
$ticketRepository = $this->make(TicketRepository::class, [
'getTicketIdsCommentedByUser' => function (...$args) use (&$capturedFrom, &$capturedTo) {
$capturedFrom = $args[1] ?? null;
$capturedTo = $args[2] ?? null;
return [];
},
]);
$projectService = $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn (...$args) => [['id' => 5]],
]);
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
// Reversed on purpose — must be swapped earliest-first before the query.
$service->getMyCommentedTicketsForRange(1, '2026-07-12', '2026-07-05');
$this->assertSame('2026-07-05', $capturedFrom, 'range normalized earliest-first');
$this->assertSame('2026-07-12', $capturedTo);
}
public function test_commented_tickets_range_short_circuits_when_no_comments(): void
{
session(['userdata' => ['id' => 1]]);
$fetchCalled = false;
$ticketRepository = $this->make(TicketRepository::class, [
'getTicketIdsCommentedByUser' => fn (...$args) => [], // nothing commented
'getTicketsByIdsWithinProjects' => function (...$args) use (&$fetchCalled) {
$fetchCalled = true;
return [];
},
]);
$projectService = $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn (...$args) => [['id' => 5]],
]);
$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);
$result = $service->getMyCommentedTicketsForRange(1, '2026-07-01', '2026-07-07');
$this->assertSame([], $result);
$this->assertFalse($fetchCalled, 'an empty commented set must short-circuit before the ticket fetch');
}
}

View File

@@ -0,0 +1,507 @@
<?php
namespace Unit\app\Domain\Timesheets\Services;
use Carbon\CarbonImmutable;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Configuration\Environment as EnvironmentCore;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\CarbonMacros;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Leantime\Domain\Timesheets\Permissions\TimesheetsPermissions;
use Leantime\Domain\Timesheets\Repositories\Timesheets as TimesheetRepository;
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the Timesheets service helpers extracted during the
* thin-controller refactor (getUsersTickets, validateAndSaveTime,
* resolveShowAllTicketFilter, getWeeklyTimesheetsWithTicketIds).
*/
class TimesheetsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected function setUp(): void
{
parent::setUp();
// Session values required by dtHelper() and Auth role checks.
session(['usersettings.timezone' => 'UTC']);
session(['usersettings.language' => 'en-US']);
session(['usersettings.date_format' => 'Y-m-d']);
session(['usersettings.time_format' => 'H:i']);
session(['userdata.id' => 1]);
$envMock = $this->make(EnvironmentCore::class, [
'defaultTimezone' => 'UTC',
'language' => 'en-US',
]);
app()->instance(EnvironmentCore::class, $envMock);
$langMock = $this->createMock(LanguageCore::class);
$langMock->method('__')->willReturnCallback(function ($index) {
$map = [
'language.dateformat' => 'Y-m-d',
'language.timeformat' => 'H:i',
];
return $map[$index] ?? $index;
});
app()->instance(LanguageCore::class, $langMock);
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
}
/** Permission stub that grants everything (default for the non-authz helper tests). */
private function allowingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => fn () => null,
'currentUserCan' => fn () => true,
]);
}
/**
* Permission stub that grants a specific allow-list of keys (others denied). Used to model a
* non-manager (editor): timesheets.view/create/edit/delete granted, timesheets.manage denied.
*
* @param array<int, string> $granted
*/
private function permissionsGranting(array $granted): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => function (string $key) use ($granted): void {
if (! in_array($key, $granted, true)) {
throw new AuthorizationException;
}
},
'currentUserCan' => fn (string $key) => in_array($key, $granted, true),
]);
}
/**
* Builds a real Timesheets service with each dependency stubbable and a permission service
* (defaults to allow-all).
*/
private function makeService(
?TimesheetRepository $timesheetsRepo = null,
?UserRepository $userRepo = null,
?TicketRepository $ticketRepo = null,
?PermissionService $perms = null,
): TimesheetService {
$service = new TimesheetService(
$timesheetsRepo ?? $this->make(TimesheetRepository::class),
$userRepo ?? $this->make(UserRepository::class),
$ticketRepo ?? $this->make(TicketRepository::class),
);
$service->setPermissionService($perms ?? $this->allowingPermissions());
return $service;
}
// Non-manager (editor) verb set: own-time keys, but NOT timesheets.manage.
private const EDITOR_KEYS = [
TimesheetsPermissions::VIEW,
TimesheetsPermissions::CREATE,
TimesheetsPermissions::EDIT,
TimesheetsPermissions::DELETE,
];
public function test_get_users_tickets_normalizes_false_to_empty_array(): void
{
$ticketRepo = $this->make(TicketRepository::class, [
'getUsersTickets' => fn () => false,
]);
$result = $this->makeService(ticketRepo: $ticketRepo)->getUsersTickets(1, -1);
$this->assertSame([], $result);
}
public function test_get_users_tickets_passes_through_array_result(): void
{
$tickets = [['id' => 5], ['id' => 9]];
$ticketRepo = $this->make(TicketRepository::class, [
'getUsersTickets' => fn () => $tickets,
]);
$result = $this->makeService(ticketRepo: $ticketRepo)->getUsersTickets(1, -1);
$this->assertSame($tickets, $result);
}
public function test_validate_and_save_time_returns_no_ticket_when_ticket_missing(): void
{
$addCalls = 0;
$repo = $this->make(TimesheetRepository::class, [
'addTime' => function () use (&$addCalls) {
$addCalls++;
},
]);
$values = $this->makeService(timesheetsRepo: $repo)->getDefaultTimeValues();
$status = $this->makeService(timesheetsRepo: $repo)->validateAndSaveTime($values);
$this->assertSame('NO_TICKET', $status);
$this->assertSame(0, $addCalls, 'Invalid time must never reach the repository');
}
public function test_validate_and_save_time_returns_no_kind_when_kind_missing(): void
{
$service = $this->makeService();
$values = $service->getDefaultTimeValues();
$values['ticket'] = 3;
$values['project'] = 2;
$this->assertSame('NO_KIND', $service->validateAndSaveTime($values));
}
public function test_validate_and_save_time_returns_no_date_when_date_missing(): void
{
$service = $this->makeService();
$values = $service->getDefaultTimeValues();
$values['ticket'] = 3;
$values['project'] = 2;
$values['kind'] = 'DEVELOPMENT';
$this->assertSame('NO_DATE', $service->validateAndSaveTime($values));
}
public function test_validate_and_save_time_returns_no_hours_when_hours_invalid(): void
{
$service = $this->makeService();
$values = $service->getDefaultTimeValues();
$values['ticket'] = 3;
$values['project'] = 2;
$values['kind'] = 'DEVELOPMENT';
$values['date'] = '2026-05-29 00:00:00';
$values['hours'] = 0;
$this->assertSame('NO_HOURS', $service->validateAndSaveTime($values));
}
public function test_resolve_ticket_filter_returns_minus_one_when_no_filters(): void
{
$this->assertSame('-1', $this->makeService()->resolveShowAllTicketFilter(-1, -1, null));
}
public function test_resolve_ticket_filter_keeps_ticket_when_project_matches(): void
{
// Project 7 selected, ticket on project 7 -> keep the ticket filter.
$this->assertSame('42', $this->makeService()->resolveShowAllTicketFilter(7, '42', 7));
}
public function test_resolve_ticket_filter_collapses_on_project_mismatch(): void
{
// Ticket belongs to project 3 but filter is project 7 -> mismatch -> '-1'.
$this->assertSame('-1', $this->makeService()->resolveShowAllTicketFilter(7, '42', 3));
}
public function test_resolve_ticket_filter_collapses_when_no_project_selected(): void
{
// No project selected (-1) but a ticket filter set -> '-1'.
$this->assertSame('-1', $this->makeService()->resolveShowAllTicketFilter(-1, '42', null));
}
public function test_resolve_ticket_filter_ignores_missing_ticket_project(): void
{
// Ticket not accessible (null project id) -> no mismatch, keep ticket filter.
$this->assertSame('42', $this->makeService()->resolveShowAllTicketFilter(7, '42', null));
}
public function test_get_weekly_timesheets_with_ticket_ids_derives_existing_ids(): void
{
$fromDate = dtHelper()->userNow()->startOfWeek()->setToDbTimezone();
$workDate = $fromDate->format('Y-m-d H:i:s');
$rows = [
[
'ticketId' => 11,
'kind' => 'DEVELOPMENT',
'clientName' => 'Acme',
'name' => 'Project A',
'headline' => 'Task A',
'workDate' => $workDate,
'hours' => 2,
'description' => 'work',
],
[
'ticketId' => 22,
'kind' => 'TESTING',
'clientName' => 'Acme',
'name' => 'Project A',
'headline' => 'Task B',
'workDate' => $workDate,
'hours' => 1,
'description' => 'qa',
],
];
$repo = $this->make(TimesheetRepository::class, [
'getWeeklyTimesheets' => fn () => $rows,
]);
$result = $this->makeService(timesheetsRepo: $repo)
->getWeeklyTimesheetsWithTicketIds(-1, $fromDate, 1);
$this->assertArrayHasKey('timesheets', $result);
$this->assertArrayHasKey('existingTicketIds', $result);
$this->assertSame([11, 22], array_values($result['existingTicketIds']));
}
// ---------------------------------------------------------------------
// Ownership / fail-closed authorization (session user id = 1).
// ---------------------------------------------------------------------
public function test_get_timesheet_returns_own_entry_for_editor(): void
{
$repo = $this->make(TimesheetRepository::class, [
'getTimesheet' => fn () => ['id' => 5, 'userId' => 1, 'projectId' => 9],
]);
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->getTimesheet(5);
$this->assertSame(5, $result['id']);
}
public function test_get_timesheet_soft_denies_another_users_entry_for_editor(): void
{
// Editor (no timesheets.manage) reading another user's entry → false, same as not-found.
$repo = $this->make(TimesheetRepository::class, [
'getTimesheet' => fn () => ['id' => 5, 'userId' => 2, 'projectId' => 9],
]);
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->getTimesheet(5);
$this->assertFalse($result);
}
public function test_get_timesheet_returns_false_for_missing(): void
{
$repo = $this->make(TimesheetRepository::class, ['getTimesheet' => fn () => false]);
$this->assertFalse($this->makeService(timesheetsRepo: $repo)->getTimesheet(999));
}
public function test_update_invoices_requires_manage(): void
{
$updated = 0;
$repo = $this->make(TimesheetRepository::class, [
'updateInvoices' => function () use (&$updated) {
$updated++;
return true;
},
]);
$this->expectException(AuthorizationException::class);
try {
$this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->updateInvoices([1], [], []);
} finally {
$this->assertSame(0, $updated, 'Invoices must not be touched without timesheets.manage');
}
}
public function test_get_all_for_own_user_needs_only_view(): void
{
$repo = $this->make(TimesheetRepository::class, ['getAll' => fn () => [['id' => 1]]]);
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
->getAll(dtHelper()->userNow(), dtHelper()->userNow(), userId: 1);
$this->assertSame([['id' => 1]], $result);
}
public function test_get_all_for_another_user_requires_manage(): void
{
$repo = $this->make(TimesheetRepository::class, ['getAll' => fn () => [['id' => 1]]]);
$this->expectException(AuthorizationException::class);
$this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
->getAll(dtHelper()->userNow(), dtHelper()->userNow(), userId: 2);
}
public function test_get_all_for_all_users_requires_manage(): void
{
// userId null = the company-wide report → manager only.
$repo = $this->make(TimesheetRepository::class, ['getAll' => fn () => [['id' => 1]]]);
$this->expectException(AuthorizationException::class);
$this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
->getAll(dtHelper()->userNow(), dtHelper()->userNow(), userId: null);
}
public function test_delete_time_denies_another_users_entry_for_editor(): void
{
$deleted = 0;
$repo = $this->make(TimesheetRepository::class, [
'getTimesheet' => fn () => ['id' => 5, 'userId' => 2],
'deleteTime' => function () use (&$deleted) {
$deleted++;
},
]);
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->deleteTime(5);
$this->assertFalse($result);
$this->assertSame(0, $deleted, "An editor must not delete another user's time");
}
public function test_delete_time_allows_own_entry_for_editor(): void
{
$deleted = 0;
$repo = $this->make(TimesheetRepository::class, [
'getTimesheet' => fn () => ['id' => 5, 'userId' => 1],
'deleteTime' => function () use (&$deleted) {
$deleted++;
},
]);
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->deleteTime(5);
$this->assertTrue($result);
$this->assertSame(1, $deleted);
}
public function test_users_ticket_hours_soft_denies_another_user_for_editor(): void
{
$loaded = 0;
$repo = $this->make(TimesheetRepository::class, [
'getUsersTicketHours' => function () use (&$loaded) {
$loaded++;
return 7;
},
]);
$result = $this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))->getUsersTicketHours(3, 2);
$this->assertSame(0, $result);
$this->assertSame(0, $loaded, "An editor must not read another user's ticket hours");
}
public function test_users_tickets_soft_denies_another_user_for_editor(): void
{
$repo = $this->make(TimesheetRepository::class);
$ticketRepo = $this->make(TicketRepository::class, ['getUsersTickets' => fn () => [['id' => 1]]]);
$result = $this->makeService(timesheetsRepo: $repo, ticketRepo: $ticketRepo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
->getUsersTickets(2, -1);
$this->assertSame([], $result);
}
public function test_add_time_pins_non_manager_to_own_user(): void
{
$captured = null;
$repo = $this->make(TimesheetRepository::class, [
'addTime' => function ($values) use (&$captured) {
$captured = $values;
},
]);
// Editor (no manage) tries to log for user 2 → pinned to self (user 1).
$this->makeService(timesheetsRepo: $repo, perms: $this->permissionsGranting(self::EDITOR_KEYS))
->addTime(['userId' => 2, 'hours' => 1]);
$this->assertSame(1, $captured['userId'], 'A non-manager must be pinned to their own userId');
}
// ---- Weekly grid bucketing across DST (#3310: Monday entry echoed on previous week's Sunday) ----
/** Switches the datetime helpers to America/New_York for the DST bucketing tests. */
private function useNewYorkTimezone(): void
{
session(['usersettings.timezone' => 'America/New_York']);
CarbonImmutable::mixin(new CarbonMacros('America/New_York', 'en-US', 'Y-m-d', 'H:i'));
}
/** A timesheet row as returned by the repository's weekly query. */
private function weeklyRow(string $workDate, float $hours = 1.0): array
{
return [
'workDate' => $workDate,
'ticketId' => 42,
'kind' => 'GENERAL_BILLABLE',
'hours' => $hours,
'description' => 'work',
'clientName' => 'Acme',
'name' => 'Project',
'headline' => 'Ticket',
];
}
public function test_weekly_grid_buckets_entry_into_its_local_calendar_day(): void
{
$this->useNewYorkTimezone();
// Week of Mon 2026-01-05 (EST, UTC-5): anchor is local midnight in UTC.
$fromDate = CarbonImmutable::parse('2026-01-05 05:00:00', 'UTC');
// Entry logged for Wednesday 2026-01-07 (local midnight EST → 05:00 UTC).
$repo = $this->make(TimesheetRepository::class, [
'getWeeklyTimesheets' => fn () => [$this->weeklyRow('2026-01-07 05:00:00', 2.5)],
]);
$groups = $this->makeService(timesheetsRepo: $repo)->getWeeklyTimesheets(-1, $fromDate, 1);
$this->assertCount(1, $groups);
$group = array_values($groups)[0];
$this->assertSame(2.5, (float) $group['day3']['hours'], 'Wednesday entry must land in the Wednesday column');
$this->assertSame(2.5, (float) $group['rowSum']);
}
public function test_monday_entry_does_not_render_in_previous_weeks_sunday_column(): void
{
$this->useNewYorkTimezone();
// US DST 2026 starts Sun 2026-03-08. An entry logged for Monday 2026-03-09
// (local midnight EDT) is stored as 04:00 UTC — inside the PREVIOUS week's flat
// +168h UTC window anchored at Mon 2026-03-02 05:00 UTC (EST).
$previousWeekAnchor = CarbonImmutable::parse('2026-03-02 05:00:00', 'UTC');
$repo = $this->make(TimesheetRepository::class, [
'getWeeklyTimesheets' => fn () => [$this->weeklyRow('2026-03-09 04:00:00')],
]);
$groups = $this->makeService(timesheetsRepo: $repo)->getWeeklyTimesheets(-1, $previousWeekAnchor, 1);
$this->assertSame([], $groups, 'A Monday entry must not appear in the previous week (was rendered on Sunday)');
}
public function test_monday_entry_renders_on_monday_in_its_own_week(): void
{
$this->useNewYorkTimezone();
// The same entry viewed in ITS week: anchor Mon 2026-03-09 local midnight EDT = 04:00 UTC.
$weekAnchor = CarbonImmutable::parse('2026-03-09 04:00:00', 'UTC');
$repo = $this->make(TimesheetRepository::class, [
'getWeeklyTimesheets' => fn () => [$this->weeklyRow('2026-03-09 04:00:00')],
]);
$groups = $this->makeService(timesheetsRepo: $repo)->getWeeklyTimesheets(-1, $weekAnchor, 1);
$this->assertCount(1, $groups);
$group = array_values($groups)[0];
$this->assertSame(1.0, (float) $group['day1']['hours'], 'Monday entry must land in the Monday column');
}
public function test_entry_stored_under_previous_dst_offset_still_buckets_to_intended_day(): void
{
$this->useNewYorkTimezone();
// Entry logged for Monday 2026-03-09 while the clock was still EST (05:00 UTC),
// viewed in the EDT-anchored week (anchor 04:00 UTC). Locally that's Mon 01:00 —
// rounding to the nearest midnight keeps it on Monday.
$weekAnchor = CarbonImmutable::parse('2026-03-09 04:00:00', 'UTC');
$repo = $this->make(TimesheetRepository::class, [
'getWeeklyTimesheets' => fn () => [$this->weeklyRow('2026-03-09 05:00:00')],
]);
$groups = $this->makeService(timesheetsRepo: $repo)->getWeeklyTimesheets(-1, $weekAnchor, 1);
$group = array_values($groups)[0];
$this->assertSame(1.0, (float) $group['day1']['hours'], 'Offset-drifted Monday entry must stay in the Monday column');
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Unit\app\Domain\TwoFA\Services;
use Leantime\Domain\TwoFA\Services\TwoFA;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use RobThree\Auth\TwoFactorAuth;
use Unit\TestCase;
/**
* Unit tests for the TwoFA service extracted from the TwoFA/Edit controller.
*/
class TwoFAServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
public function test_disable_clears_secret_and_flag(): void
{
$captured = null;
$repo = $this->make(UserRepository::class, [
'patchUser' => function ($id, $params) use (&$captured) {
$captured = [$id, $params];
return true;
},
]);
(new TwoFA($repo))->disable2FA(7);
$this->assertSame(7, $captured[0]);
$this->assertSame(0, $captured[1]['twoFAEnabled']);
$this->assertNull($captured[1]['twoFASecret']);
}
public function test_save_secret_persists_without_enabling(): void
{
$captured = null;
$repo = $this->make(UserRepository::class, [
'patchUser' => function ($id, $params) use (&$captured) {
$captured = $params;
return true;
},
]);
(new TwoFA($repo))->saveSecret(7, 'SECRETBASE32');
$this->assertSame(['twoFASecret' => 'SECRETBASE32'], $captured);
}
public function test_verify_and_enable_rejects_invalid_code(): void
{
$persistCalls = 0;
$repo = $this->make(UserRepository::class, [
'patchUser' => function () use (&$persistCalls) {
$persistCalls++;
return true;
},
]);
$tfa = new TwoFactorAuth('Leantime', 6, 30, 'sha1');
$secret = $tfa->createSecret(160);
$validCode = $tfa->getCode($secret);
// A numerically-adjacent code is not a valid TOTP code for this secret.
$wrongCode = str_pad((string) ((((int) $validCode) + 1) % 1000000), 6, '0', STR_PAD_LEFT);
$result = (new TwoFA($repo))->verifyAndEnable(7, $secret, $wrongCode);
$this->assertFalse($result);
$this->assertSame(0, $persistCalls, 'An invalid code must not enable 2FA');
}
public function test_verify_and_enable_accepts_valid_code(): void
{
$captured = null;
$repo = $this->make(UserRepository::class, [
'patchUser' => function ($id, $params) use (&$captured) {
$captured = $params;
return true;
},
]);
$tfa = new TwoFactorAuth('Leantime', 6, 30, 'sha1');
$secret = $tfa->createSecret(160);
$validCode = $tfa->getCode($secret);
$result = (new TwoFA($repo))->verifyAndEnable(7, $secret, $validCode);
$this->assertTrue($result);
$this->assertSame(1, $captured['twoFAEnabled']);
$this->assertSame($secret, $captured['twoFASecret']);
}
public function test_get_setup_data_generates_secret_and_qr_when_not_enabled(): void
{
$repo = $this->make(UserRepository::class, [
'getUser' => fn ($id) => ['username' => 'jane@example.com', 'twoFASecret' => '', 'twoFAEnabled' => 0],
]);
$setup = (new TwoFA($repo))->getSetupData(7);
$this->assertNotEmpty($setup['secret']);
$this->assertFalse($setup['twoFAEnabled']);
$this->assertIsString($setup['qrData']);
$this->assertStringStartsWith('data:image/png', $setup['qrData']);
}
public function test_get_setup_data_omits_qr_when_already_enabled(): void
{
$repo = $this->make(UserRepository::class, [
'getUser' => fn ($id) => ['username' => 'jane@example.com', 'twoFASecret' => 'EXISTINGSECRET', 'twoFAEnabled' => 1],
]);
$setup = (new TwoFA($repo))->getSetupData(7);
$this->assertSame('EXISTINGSECRET', $setup['secret']);
$this->assertTrue($setup['twoFAEnabled']);
$this->assertNull($setup['qrData']);
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Unit\app\Domain\Users;
use Leantime\Core\Auth\Permissions\DefaultRolePermissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Domain\Users\Controllers\EditUser;
use Leantime\Domain\Users\Permissions\UsersPermissions;
use Leantime\Domain\Users\Services\Users as UsersService;
use ReflectionMethod;
use Unit\TestCase;
/**
* Regression guard for the write-path authorization on user records.
*
* A third-party review (2026-07-18) flagged: the Blade capacity section
* gates on `Roles::admin`, but the controller `buildValuesFromPost`
* reads `weekly_hours` + `employment_type` straight from `$_POST`. If
* `EditUser::post()` did not independently require admin at the server
* boundary, a non-admin could set these fields by crafting a POST.
*
* The server gate exists — every write surface carries
* `#[RequiresPermission(UsersPermissions::EDIT, global: true)]`, and
* `users.edit` is granted only to admin+ by `DefaultRolePermissions`.
* PermissionEnforcer throws `AuthorizationException` before the method
* body runs (Frontcontroller for legacy convention routes,
* CheckPermissions middleware for Laravel routes, Jsonrpc for the
* RPC surface).
*
* This test guards against silent removal of that attribute (any of
* the three surfaces) or a future default-permission grant that would
* hand `users.edit` to a lower role. Both would silently reopen the
* bypass the reviewer flagged.
*/
class EditUserAuthorizationTest extends TestCase
{
// ─── Attribute presence on every write surface ────────────────────
public function test_controller_post_requires_users_edit_permission_globally(): void
{
// Legacy convention route: /users/editUser/{id}. If someone
// strips this attribute, PermissionEnforcer stops enforcing
// and any authenticated user can POST — the bypass scenario.
$this->assertRequiresPermission(
EditUser::class,
'post',
UsersPermissions::EDIT,
);
}
public function test_controller_get_requires_users_edit_permission_globally(): void
{
// GET is gated too — otherwise a non-admin could view the
// admin edit form (info leak) even without being able to POST.
$this->assertRequiresPermission(
EditUser::class,
'get',
UsersPermissions::EDIT,
);
}
public function test_service_edit_user_requires_users_edit_permission_globally(): void
{
// Service-layer surface — any caller (JSON-RPC, plugins,
// service-to-service) also passes through PermissionEnforcer
// because the attribute is on the method, not the controller.
$this->assertRequiresPermission(
UsersService::class,
'editUser',
UsersPermissions::EDIT,
);
}
public function test_service_update_user_requires_users_edit_permission_globally(): void
{
// updateUser is the JSON-RPC entry point — wraps editUser +
// project reconciliation. Its attribute is what secures the
// RPC path (RPC bypasses the controller gate, per the
// RequiresPermission docblock).
$this->assertRequiresPermission(
UsersService::class,
'updateUser',
UsersPermissions::EDIT,
);
}
// ─── Default-grant hierarchy — who has users.edit ─────────────────
public function test_users_edit_is_granted_to_admin_and_owner_only(): void
{
// The other half of the bypass guarantee: the attribute above
// is only meaningful if `users.edit` isn't handed out to a
// lower role by default. Owner + admin get it; manager gets
// only users.create; editor/commenter/readonly get no users.*.
$catalog = [new Permission(UsersPermissions::EDIT, 'Edit users', false)];
$this->assertContains(
UsersPermissions::EDIT,
DefaultRolePermissions::grantsFor('admin', $catalog),
'admin must retain users.edit — the primary gate'
);
$this->assertContains(
UsersPermissions::EDIT,
DefaultRolePermissions::grantsFor('owner', $catalog),
'owner must retain users.edit — inherits admin grants'
);
// Everything below admin must NOT have it. If a future default
// hands users.edit to manager or below, this test fails and
// the reviewer's bypass concern re-materialises silently.
foreach (['manager', 'editor', 'commenter', 'readonly'] as $role) {
$this->assertNotContains(
UsersPermissions::EDIT,
DefaultRolePermissions::grantsFor($role, $catalog),
sprintf('%s must NOT have users.edit by default', $role)
);
}
}
// ─── Helpers ──────────────────────────────────────────────────────
private function assertRequiresPermission(string $class, string $method, string $permission): void
{
$reflection = new ReflectionMethod($class, $method);
$attributes = $reflection->getAttributes(RequiresPermission::class);
$this->assertCount(
1,
$attributes,
sprintf('%s::%s must declare exactly one #[RequiresPermission] attribute', $class, $method)
);
$attr = $attributes[0]->newInstance();
$this->assertSame(
$permission,
$attr->permission,
sprintf('%s::%s must require %s', $class, $method, $permission)
);
$this->assertTrue(
$attr->global,
sprintf('%s::%s must be global-scoped (users.* are company-wide, not project-scoped)', $class, $method)
);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Unit\app\Domain\Users\Enums;
use Leantime\Domain\Users\Enums\EmploymentType;
use Unit\TestCase;
/**
* Behaviors under test — the semantics that downstream capacity math
* will trust:
*
* 1. Volunteer is the only case excluded from capacity accounting
* (countsAgainstCapacity()=false). Everyone else counts.
* 2. Over-cap warning tone maps cleanly per type — FTE = warn (target
* exceeded, a burnout signal but not a violation), PT/Contractor =
* danger (violates an explicit ceiling or billable cap), Volunteer
* = none (best-effort work has no cap to violate).
* 3. tryFrom() rejects arbitrary strings — the enum is the write-path
* validation surface, so an unrecognised value must return null
* rather than throw or coerce.
* 4. Every case has both a human label AND an i18n key so admin UIs
* can render translated selects without special-casing.
*/
class EmploymentTypeTest extends TestCase
{
public function test_volunteer_is_the_only_case_excluded_from_capacity(): void
{
// The whole point of the Volunteer type — best-effort work is
// additive to team throughput but shouldn't fire over-cap
// warnings that would flag someone for helping too much.
$this->assertFalse(EmploymentType::Volunteer->countsAgainstCapacity());
$this->assertTrue(EmploymentType::FTE->countsAgainstCapacity());
$this->assertTrue(EmploymentType::PartTime->countsAgainstCapacity());
$this->assertTrue(EmploymentType::Contractor->countsAgainstCapacity());
}
public function test_overcap_tone_reflects_target_vs_ceiling_semantics(): void
{
// FTE going over is a burnout signal — amber, not red. PT + Contractor
// ceilings are explicit commitments (part-time hours the user set,
// billable caps the org set) — over = red. Volunteer never fires.
$this->assertSame('warn', EmploymentType::FTE->overCapTone());
$this->assertSame('danger', EmploymentType::PartTime->overCapTone());
$this->assertSame('danger', EmploymentType::Contractor->overCapTone());
$this->assertSame('none', EmploymentType::Volunteer->overCapTone());
}
public function test_try_from_rejects_arbitrary_strings(): void
{
// This is the exact surface the write path relies on. If tryFrom
// ever starts coercing garbage into a case, the repo guard opens
// a store-arbitrary-string escape hatch.
$this->assertNull(EmploymentType::tryFrom('ATTACKER'));
$this->assertNull(EmploymentType::tryFrom(''));
$this->assertNull(EmploymentType::tryFrom('FTE')); // wrong case — enum values are lowercase
$this->assertNull(EmploymentType::tryFrom('full-time'));
}
public function test_try_from_accepts_the_four_canonical_values(): void
{
$this->assertSame(EmploymentType::FTE, EmploymentType::tryFrom('fte'));
$this->assertSame(EmploymentType::PartTime, EmploymentType::tryFrom('pt'));
$this->assertSame(EmploymentType::Contractor, EmploymentType::tryFrom('contractor'));
$this->assertSame(EmploymentType::Volunteer, EmploymentType::tryFrom('volunteer'));
}
public function test_every_case_has_a_label_and_lang_key(): void
{
foreach (EmploymentType::cases() as $type) {
$this->assertNotSame('', $type->label(), sprintf('%s has empty label', $type->name));
$this->assertStringStartsWith('users.employment_type.', $type->langKey());
// The i18n suffix must be the enum's value — templates read
// the value and look up the string, so a mismatch means the
// admin select renders as a raw key.
$this->assertStringEndsWith('.'.$type->value, $type->langKey());
}
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace Unit\app\Domain\Users\Repositories;
use Leantime\Domain\Users\Enums\EmploymentType;
use Leantime\Domain\Users\Repositories\Users as UsersRepository;
use Unit\TestCase;
/**
* Behaviors under test — the persistence contract for the two new
* capacity attributes on zp_user (weekly_hours + employment_type,
* added in migration 30523):
*
* 1. Values omitted from the update payload MUST NOT be nulled out —
* the array_key_exists guard is the whole partial-update contract
* downstream capacity math will rely on. If a caller sends only
* {name: 'x'}, weekly_hours must remain untouched.
* 2. Empty string ('') and null both mean "clear this" → persisted
* as NULL, not 0 (the "not configured" state is meaningful; it's
* what suppresses over-cap warnings).
* 3. weekly_hours accepts int-ish strings and clamps to 0..168.
* Anything outside that range OR non-numeric normalises to NULL
* rather than storing garbage.
* 4. employment_type is validated through EmploymentType::tryFrom() —
* only the four canonical values persist; unknown strings (including
* a crafted POST) normalise to NULL.
*
* The tests exercise the private normalizers via a testable subclass
* that swaps the DB write for a captured payload — same shape as the
* real query builder, no actual DB touched.
*/
class UsersRepositoryTest extends TestCase
{
public function test_weekly_hours_omitted_from_payload_is_not_written(): void
{
// The array_key_exists guard's whole reason for existing: a
// partial-update caller (e.g. a form that only edits name) must
// not accidentally clear a capacity value someone else set.
$repo = $this->makeRepo();
$repo->editUser($this->baseValues([/* no weekly_hours key */]), 1);
$this->assertArrayNotHasKey('weekly_hours', $repo->lastUpdate);
}
public function test_employment_type_omitted_from_payload_is_not_written(): void
{
$repo = $this->makeRepo();
$repo->editUser($this->baseValues([/* no employment_type key */]), 1);
$this->assertArrayNotHasKey('employment_type', $repo->lastUpdate);
}
public function test_weekly_hours_empty_string_persists_as_null(): void
{
// Distinct from omission — empty string means "the form was
// rendered, the user cleared the field, they want it unset."
// Persisting 0 here would fabricate a value they did not enter.
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['weekly_hours' => '']), 1);
$this->assertArrayHasKey('weekly_hours', $repo->lastUpdate);
$this->assertNull($repo->lastUpdate['weekly_hours']);
}
public function test_weekly_hours_null_persists_as_null(): void
{
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['weekly_hours' => null]), 1);
$this->assertNull($repo->lastUpdate['weekly_hours']);
}
public function test_weekly_hours_valid_int_string_persists_as_int(): void
{
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['weekly_hours' => '40']), 1);
$this->assertSame(40, $repo->lastUpdate['weekly_hours']);
}
public function test_weekly_hours_out_of_range_persists_as_null(): void
{
// Upper bound is 168 (hours in a week). Anything higher has no
// physical meaning — downstream capacity math would divide by
// absurd numbers. Same on the lower side for negatives.
foreach (['169', '99999', '-1', '-500'] as $value) {
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['weekly_hours' => $value]), 1);
$this->assertNull(
$repo->lastUpdate['weekly_hours'],
sprintf('weekly_hours=%s should normalise to NULL, got %s', $value, var_export($repo->lastUpdate['weekly_hours'] ?? 'MISSING', true))
);
}
}
public function test_weekly_hours_boundary_values_are_accepted(): void
{
// 0 and 168 are inclusive — 0 is a valid "no hours" (e.g. an
// inactive account that hasn't been offboarded), 168 is a
// theoretical ceiling.
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['weekly_hours' => '0']), 1);
$this->assertSame(0, $repo->lastUpdate['weekly_hours']);
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['weekly_hours' => '168']), 1);
$this->assertSame(168, $repo->lastUpdate['weekly_hours']);
}
public function test_weekly_hours_non_numeric_persists_as_null(): void
{
// Belt-and-suspenders: HTML enforces type=number but a crafted
// POST can send anything.
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['weekly_hours' => 'forty']), 1);
$this->assertNull($repo->lastUpdate['weekly_hours']);
}
public function test_employment_type_valid_case_persists(): void
{
foreach (EmploymentType::cases() as $type) {
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['employment_type' => $type->value]), 1);
$this->assertSame(
$type->value,
$repo->lastUpdate['employment_type'],
sprintf('%s should round-trip', $type->name)
);
}
}
public function test_employment_type_unknown_string_persists_as_null(): void
{
// The write-path guard against the exact IDOR-adjacent scenario
// Marcel flagged — a crafted POST used to store garbage that
// later EmploymentType::from() would throw on.
foreach (['ATTACKER_STRING', 'FTE', 'full-time', 'admin'] as $bogus) {
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['employment_type' => $bogus]), 1);
$this->assertNull(
$repo->lastUpdate['employment_type'],
sprintf('employment_type=%s should normalise to NULL', $bogus)
);
}
}
public function test_employment_type_empty_string_persists_as_null(): void
{
$repo = $this->makeRepo();
$repo->editUser($this->baseValues(['employment_type' => '']), 1);
$this->assertNull($repo->lastUpdate['employment_type']);
}
/**
* Build a testable UsersRepository that captures the DB payload
* without touching the connection. Overrides the one query builder
* call editUser makes; every other method is inherited unchanged.
*/
private function makeRepo(): object
{
return new class extends UsersRepository
{
public array $lastUpdate = [];
public function __construct()
{
// Skip parent constructor — no DB connection needed for
// this test. The normalizers are pure functions of the
// payload, and editUser's only external call is the
// update() we override below.
}
public function editUser(array $values, $id): bool
{
// Re-run the exact normalisation logic from the parent
// (copied here since the parent method also calls the
// connection). Kept in lockstep with the parent — any
// change to the parent's normalization must mirror here.
unset($this->userMemo[$id]);
$updateData = [
'firstname' => $values['firstname'],
'lastname' => $values['lastname'],
'username' => $values['user'],
'phone' => $values['phone'] ?? '',
'status' => $values['status'],
'role' => $values['role'],
'hours' => $values['hours'] ?? 0,
'wage' => $values['wage'] ?? 0,
'clientId' => $values['clientId'],
'jobTitle' => $values['jobTitle'] ?? '',
'jobLevel' => $values['jobLevel'] ?? '',
'department' => $values['department'] ?? '',
// 'modified' omitted from capture — non-deterministic timestamp.
];
if (array_key_exists('weekly_hours', $values)) {
$updateData['weekly_hours'] = $this->normalizeWeeklyHoursForTest($values['weekly_hours']);
}
if (array_key_exists('employment_type', $values)) {
$updateData['employment_type'] = $this->normalizeEmploymentTypeForTest($values['employment_type']);
}
$this->lastUpdate = $updateData;
return true;
}
// Bridges to the parent's private normalizers via reflection —
// this lets the test exercise the SAME code path production
// uses, not a copy that could drift.
private function normalizeWeeklyHoursForTest(mixed $value): ?int
{
$r = new \ReflectionMethod(UsersRepository::class, 'normalizeWeeklyHours');
return $r->invoke($this, $value);
}
private function normalizeEmploymentTypeForTest(mixed $value): ?string
{
$r = new \ReflectionMethod(UsersRepository::class, 'normalizeEmploymentType');
return $r->invoke($this, $value);
}
};
}
/**
* The minimum payload editUser expects, plus whatever keys the test
* wants to override or add. Uses defaults for all the non-capacity
* fields since editUser doesn't guard those (a separate concern).
*/
private function baseValues(array $overrides = []): array
{
return array_merge([
'firstname' => 'Test',
'lastname' => 'User',
'user' => 'test@example.com',
'phone' => '',
'status' => 'a',
'role' => 20,
'clientId' => 0,
], $overrides);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Unit\app\Domain\Users\Services;
use Illuminate\Support\Facades\RateLimiter;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\Avatarcreator;
use Leantime\Core\UI\Theme as ThemeCore;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
use Leantime\Domain\Files\Services\Files;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Setting\Services\Setting as SettingService;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Leantime\Domain\Users\Services\Users;
use Unit\TestCase;
/**
* Regression guard for the invite-spam rate limit. When an inviter exceeds the per-user cap,
* createUserInvite() must short-circuit and never reach the DB insert — proving the limiter is
* the real backstop for every entry point (web, JSON-RPC, resend all funnel through here).
*/
class InviteRateLimitTest extends TestCase
{
private const INVITER_ID = 4242;
protected function setUp(): void
{
parent::setUp();
// The array cache store persists within a single test run, so clear the keys this test
// touches to keep it independent of ordering and of any prior limiter state.
foreach ($this->limiterKeys() as $key) {
RateLimiter::clear($key);
}
}
protected function tearDown(): void
{
foreach ($this->limiterKeys() as $key) {
RateLimiter::clear($key);
}
parent::tearDown();
}
public function test_create_user_invite_returns_false_and_skips_db_when_user_cap_exceeded(): void
{
session(['userdata' => ['id' => self::INVITER_ID, 'name' => 'Inviter', 'mail' => 'inviter@example.com']]);
// Exhaust the per-user hourly cap (default 10) on the exact key the service computes.
[$userKey] = $this->limiterKeys();
for ($i = 0; $i < 10; $i++) {
RateLimiter::hit($userKey, 3600);
}
// The DB layer must never be touched once the cap is hit.
$userRepo = $this->createMock(UserRepository::class);
$userRepo->expects($this->never())->method('addUser');
$service = new Users(
$userRepo,
$this->createMock(LanguageCore::class),
$this->createMock(ProjectRepository::class),
$this->createMock(ClientRepository::class),
$this->createMock(AuthService::class),
$this->createMock(Files::class),
$this->createMock(Avatarcreator::class),
$this->createMock(SettingService::class),
$this->createMock(ThemeCore::class),
$this->createMock(ProjectService::class),
);
$result = $service->createUserInvite([
'user' => 'newuser@example.com',
'firstname' => 'New',
'lastname' => 'User',
'role' => '20',
]);
$this->assertFalse($result, 'createUserInvite must return false once the invite cap is exceeded');
}
/**
* The user + tenant limiter keys, computed exactly as Users::invitesRateLimited() does.
*
* @return array{0: string, 1: string}
*/
private function limiterKeys(): array
{
$scope = defined('BASE_URL') ? BASE_URL : 'default';
return [
'invites:'.$scope.':user:'.self::INVITER_ID,
'invites:'.$scope.':tenant',
];
}
}

View File

@@ -0,0 +1,509 @@
<?php
namespace Unit\app\Domain\Users\Services;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\Avatarcreator;
use Leantime\Core\UI\Theme as ThemeCore;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
use Leantime\Domain\Files\Services\Files;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Setting\Services\Setting as SettingService;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Leantime\Domain\Users\Services\Users as UserService;
use Unit\TestCase;
/**
* Unit tests for the Users service helpers extracted during the
* thin-controller refactor (saveModalDismissal).
*/
class UsersServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
/**
* Builds a real Users service with mocked dependencies, injecting the
* provided (stubbed) repository so we can observe persistence calls.
* Optional overrides let individual tests swap in stubbed collaborators.
*
* @param array<string, mixed> $overrides Keyed by dependency short name.
*/
private function makeService(UserRepository $userRepo, array $overrides = []): UserService
{
return new UserService(
$userRepo,
$overrides['language'] ?? $this->make(LanguageCore::class),
$overrides['projectRepository'] ?? $this->make(ProjectRepository::class),
$overrides['clientRepo'] ?? $this->make(ClientRepository::class),
$overrides['authService'] ?? $this->make(AuthService::class),
$overrides['fileService'] ?? $this->make(Files::class),
$overrides['avatarcreator'] ?? $this->make(Avatarcreator::class),
$overrides['settingsService'] ?? $this->make(SettingService::class),
$overrides['themeCore'] ?? $this->make(ThemeCore::class),
$overrides['projectService'] ?? $this->make(ProjectService::class),
);
}
protected function setUp(): void
{
parent::setUp();
session(['userdata.id' => 1]);
session()->forget('usersettings');
}
public function test_session_only_dismissal_records_session_without_persisting(): void
{
$persistCalls = 0;
$repo = $this->make(UserRepository::class, [
'patchUser' => function () use (&$persistCalls) {
$persistCalls++;
return true;
},
]);
$result = $this->makeService($repo)->saveModalDismissal('welcomeModal', false);
$this->assertTrue($result);
$this->assertSame(1, session('usersettings.modals.welcomeModal'));
$this->assertSame(0, $persistCalls, 'A non-permanent dismissal must not touch the repository');
}
public function test_permanent_dismissal_persists_to_user_settings(): void
{
$persistCalls = 0;
$repo = $this->make(UserRepository::class, [
'patchUser' => function ($id, $params) use (&$persistCalls) {
$persistCalls++;
// The service must persist the serialized usersettings blob.
$this->assertArrayHasKey('settings', $params);
return true;
},
]);
$result = $this->makeService($repo)->saveModalDismissal('welcomeModal', true);
$this->assertTrue($result);
$this->assertSame('1', session('usersettings.modals.welcomeModal'));
$this->assertSame(1, $persistCalls, 'A permanent dismissal must persist via the repository');
}
public function test_get_user_project_ids_flattens_relation_rows(): void
{
$repo = $this->make(UserRepository::class);
$projectService = $this->make(ProjectService::class, [
'getUserProjectRelation' => fn () => [
['projectId' => 5],
['projectId' => 9],
['projectId' => 12],
],
]);
$ids = $this->makeService($repo, ['projectService' => $projectService])->getUserProjectIds(3);
$this->assertSame([5, 9, 12], $ids);
}
public function test_validate_user_update_rejects_empty_username(): void
{
$repo = $this->make(UserRepository::class);
$service = $this->makeService($repo);
$result = $service->validateUserUpdate(
['user' => ''],
['username' => 'old@example.com'],
7,
[]
);
$this->assertSame('passwords_dont_match', $result);
}
public function test_validate_user_update_rejects_invalid_email(): void
{
$repo = $this->make(UserRepository::class);
$service = $this->makeService($repo);
$result = $service->validateUserUpdate(
['user' => 'not-an-email'],
['username' => 'old@example.com'],
7,
[]
);
$this->assertSame('no_valid_email', $result);
}
public function test_validate_user_update_rejects_taken_email_on_change(): void
{
$repo = $this->make(UserRepository::class, [
'usernameExist' => fn () => true,
]);
$service = $this->makeService($repo);
$result = $service->validateUserUpdate(
['user' => 'new@example.com'],
['username' => 'old@example.com'],
7,
[]
);
$this->assertSame('user_exists', $result);
}
public function test_validate_user_update_passes_for_unchanged_valid_email(): void
{
$repo = $this->make(UserRepository::class, [
'usernameExist' => fn () => true,
]);
$service = $this->makeService($repo);
// Email unchanged, so usernameExist must NOT block it.
$result = $service->validateUserUpdate(
['user' => 'same@example.com'],
['username' => 'same@example.com'],
7,
[]
);
$this->assertSame('valid', $result);
}
public function test_invite_new_user_rejects_invalid_email(): void
{
$repo = $this->make(UserRepository::class, [
'usernameExist' => fn () => false,
]);
$service = $this->makeService($repo);
$result = $service->inviteNewUser(
['user' => 'nope'],
sessionClientId: null,
isManager: false
);
$this->assertSame('no_valid_email', $result);
}
public function test_invite_new_user_rejects_existing_user(): void
{
$repo = $this->make(UserRepository::class, [
'usernameExist' => fn () => true,
]);
$service = $this->makeService($repo);
$result = $service->inviteNewUser(
['user' => 'taken@example.com'],
sessionClientId: null,
isManager: false
);
$this->assertSame('user_exists', $result);
}
public function test_change_own_password_rejects_wrong_current_password(): void
{
$repo = $this->make(UserRepository::class, [
'getUser' => fn () => [
'id' => 1,
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
'firstname' => 'A',
'lastname' => 'B',
'username' => 'a@b.com',
'phone' => '',
'notifications' => 1,
'twoFAEnabled' => 0,
],
]);
$service = $this->makeService($repo);
$result = $service->changeOwnPassword(1, 'wrong', 'NewPass1!', 'NewPass1!');
$this->assertSame('previous_password_incorrect', $result);
}
public function test_change_own_password_rejects_mismatched_confirmation(): void
{
$repo = $this->make(UserRepository::class, [
'getUser' => fn () => [
'id' => 1,
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
'firstname' => 'A',
'lastname' => 'B',
'username' => 'a@b.com',
'phone' => '',
'notifications' => 1,
'twoFAEnabled' => 0,
],
]);
$service = $this->makeService($repo);
$result = $service->changeOwnPassword(1, 'correct-horse', 'NewPass1!', 'Different1!');
$this->assertSame('passwords_dont_match', $result);
}
public function test_change_own_password_persists_on_success(): void
{
$savedValues = null;
$repo = $this->make(UserRepository::class, [
'getUser' => fn () => [
'id' => 1,
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
'firstname' => 'A',
'lastname' => 'B',
'username' => 'a@b.com',
'phone' => '',
'notifications' => 1,
'twoFAEnabled' => 0,
],
'editOwn' => function ($values) use (&$savedValues) {
$savedValues = $values;
return true;
},
]);
$service = $this->makeService($repo);
$result = $service->changeOwnPassword(1, 'correct-horse', 'NewPass1!', 'NewPass1!');
$this->assertSame('success', $result);
$this->assertSame('NewPass1!', $savedValues['password']);
}
public function test_save_own_profile_blocks_duplicate_email(): void
{
$editCalls = 0;
$repo = $this->make(UserRepository::class, [
'getUser' => fn () => [
'id' => 1,
'firstname' => 'A',
'lastname' => 'B',
'username' => 'old@example.com',
'phone' => '',
'notifications' => 1,
'twoFAEnabled' => 0,
],
'usernameExist' => fn () => true,
'editOwn' => function () use (&$editCalls) {
$editCalls++;
return true;
},
]);
$service = $this->makeService($repo);
$result = $service->saveOwnProfile(1, ['user' => 'taken@example.com']);
$this->assertSame('user_exists', $result);
$this->assertSame(0, $editCalls, 'A duplicate email must not be persisted');
}
// ---------------------------------------------------------------------
// searchProjectUsers() — JSON-RPC entry for the @mention autocomplete.
// ---------------------------------------------------------------------
public function test_search_project_users_filters_by_query(): void
{
session(['userdata' => ['id' => 1], 'currentProject' => 5]);
$projectRepository = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => true,
'getProject' => fn () => ['psettings' => 'restricted', 'clientId' => 0],
'getUsersAssignedToProject' => fn () => [
['id' => 1, 'firstname' => 'Alice'],
['id' => 2, 'firstname' => 'Bob'],
],
]);
$users = $this->makeService($this->make(UserRepository::class), ['projectRepository' => $projectRepository])
->searchProjectUsers(5, 'alice');
$this->assertCount(1, $users);
$this->assertSame('Alice', $users[0]['firstname']);
}
public function test_search_project_users_returns_empty_without_project_access(): void
{
session(['userdata' => ['id' => 1], 'currentProject' => 5]);
$projectRepository = $this->make(ProjectRepository::class, [
'isUserAssignedToProject' => fn () => false,
]);
$users = $this->makeService($this->make(UserRepository::class), ['projectRepository' => $projectRepository])
->searchProjectUsers(5);
$this->assertSame([], $users);
}
// ---------------------------------------------------------------------
// Authorization. The company-wide manage-others methods
// (editUser/updateUser/addUser/getAll/…) gate via the dispatch-time
// #[RequiresPermission(global: true)] attribute (covered by PermissionEnforcerTest).
// These two methods authorize in their own body, so they gate on direct calls too.
// ---------------------------------------------------------------------
private function denyingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'currentUserCan' => fn () => false,
'authorize' => function (): void {
throw new AuthorizationException;
},
]);
}
private function allowingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'currentUserCan' => fn () => true,
'authorize' => fn () => null,
]);
}
public function test_delete_user_throws_without_delete_permission(): void
{
$service = $this->makeService($this->make(UserRepository::class));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->deleteUser(5);
}
public function test_patch_user_allows_self_with_limited_fields_without_edit_permission(): void
{
session(['userdata' => ['id' => 7]]);
$patched = [];
$service = $this->makeService($this->make(UserRepository::class, [
'patchUser' => function ($id, $fields) use (&$patched) {
$patched = ['id' => $id, 'fields' => $fields];
return true;
},
]));
$service->setPermissionService($this->denyingPermissions()); // no users.edit
// Editing OWN account (id === session user) is allowed even without users.edit...
$result = $service->patchUser(7, ['firstname' => 'Bob', 'role' => '50']);
$this->assertTrue($result);
$this->assertSame(7, $patched['id']);
$this->assertArrayHasKey('firstname', $patched['fields']);
// ...but the privileged 'role' field is stripped — no self privilege-escalation.
$this->assertArrayNotHasKey('role', $patched['fields']);
}
public function test_patch_user_denies_other_account_without_edit_permission(): void
{
session(['userdata' => ['id' => 7]]);
$service = $this->makeService($this->make(UserRepository::class, [
'patchUser' => fn () => true,
]));
$service->setPermissionService($this->denyingPermissions());
// Patching ANOTHER account without users.edit must fail (closes the RPC escalation hole).
$this->assertFalse($service->patchUser(99, ['role' => '50']));
}
public function test_patch_user_allows_other_account_with_edit_permission(): void
{
session(['userdata' => ['id' => 7]]);
$patched = [];
$service = $this->makeService($this->make(UserRepository::class, [
'patchUser' => function ($id, $fields) use (&$patched) {
$patched = ['id' => $id, 'fields' => $fields];
return true;
},
]));
$service->setPermissionService($this->allowingPermissions()); // has users.edit
$result = $service->patchUser(99, ['role' => '20']);
$this->assertTrue($result);
$this->assertSame(99, $patched['id']);
// A users.edit holder may set privileged fields on another account.
$this->assertArrayHasKey('role', $patched['fields']);
}
public function test_self_service_methods_ignore_caller_supplied_id_and_pin_to_session(): void
{
// Self-service methods (editOwn/saveOwn*/getOwn*/changeOwnPassword) must operate on the
// authenticated user only — over JSON-RPC a caller controls the $userId argument, so a
// foreign id must NOT be honored (otherwise it is a cross-account IDOR). Representative
// check via changeOwnPassword: the credential lookup must hit the SESSION user (7), not
// the attacker-supplied id (99).
session(['userdata' => ['id' => 7]]);
$seenId = null;
$repo = $this->make(UserRepository::class, [
'getUser' => function ($id) use (&$seenId) {
$seenId = $id;
return [
'id' => $id,
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b.com',
'phone' => '', 'notifications' => 1, 'twoFAEnabled' => 0,
];
},
]);
$this->makeService($repo)->changeOwnPassword(99, 'wrong', 'NewPass1!', 'NewPass1!');
$this->assertSame(7, $seenId, 'self-service must pin to the session user, not the caller-supplied id');
}
/**
* Regression for #3556: getUser is @api and intentionally ungated, so any
* authenticated client can request an arbitrary id. It must never return
* credentials — password hash, plaintext 2FA seed, session token, or the
* password-reset token/metadata — while keeping the safe profile fields
* the view composers rely on.
*/
public function test_get_user_strips_sensitive_fields_from_api_response(): void
{
$fullRow = [
'id' => 5,
'firstname' => 'Ada',
'lastname' => 'Lovelace',
'username' => 'ada@example.com',
'role' => '20',
'password' => '$2y$10$abcdefghijklmnopqrstuv',
'twoFASecret' => 'SECRET2FASEED',
'session' => 'sess-token-xyz',
'sessiontime' => '1700000000',
'pwReset' => 'reset-token',
'pwResetExpiration' => '2026-01-01 00:00:00',
'pwResetCount' => 2,
];
$repo = $this->make(UserRepository::class, [
'getUser' => fn () => $fullRow,
]);
$user = $this->makeService($repo)->getUser(5);
$this->assertIsArray($user);
// Safe profile fields survive so composers/avatars keep working.
$this->assertSame('Ada', $user['firstname']);
$this->assertSame('ada@example.com', $user['username']);
// Every credential/session/reset field is stripped.
foreach (['password', 'twoFASecret', 'session', 'sessiontime', 'pwReset', 'pwResetExpiration', 'pwResetCount'] as $secret) {
$this->assertArrayNotHasKey($secret, $user, "getUser must not leak {$secret} over the API");
}
}
}

View File

@@ -0,0 +1,328 @@
<?php
namespace Unit\app\Domain\Widgets\Services;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Reports\Services\Reports as ReportService;
use Leantime\Domain\Setting\Services\Setting as SettingService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Leantime\Domain\Users\Services\Users as UserService;
use Leantime\Domain\Widgets\Services\Dashboard;
use Leantime\Domain\Widgets\Services\Widgets;
use Unit\TestCase;
/**
* Unit tests for the Widgets Dashboard service that backs the Welcome and
* "My To-Dos" dashboard widgets.
*/
class DashboardServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
protected function setUp(): void
{
parent::setUp();
// Provide everything dtHelper() needs so getWelcomeWidgetData() can call
// dtHelper()->userNow() without reaching for the Environment/Language.
session(['usersettings.timezone' => 'UTC']);
session(['usersettings.language' => 'en-US']);
session(['usersettings.date_format' => 'Y-m-d']);
session(['usersettings.time_format' => 'H:i']);
}
/**
* Builds a Dashboard service with the supplied (mocked) collaborators,
* filling in empty stubs for any not provided.
*/
private function makeService(array $overrides = []): Dashboard
{
return new Dashboard(
$overrides['tickets'] ?? $this->make(TicketService::class),
$overrides['settings'] ?? $this->make(SettingService::class),
$overrides['projects'] ?? $this->make(ProjectService::class),
$overrides['users'] ?? $this->make(UserService::class),
$overrides['reports'] ?? $this->make(ReportService::class),
$overrides['widgets'] ?? $this->make(Widgets::class),
);
}
public function test_resolve_quick_add_due_date_keeps_existing_date(): void
{
$service = $this->makeService();
$result = $service->resolveQuickAddDueDate(['dateToFinish' => '2026-01-02', 'group' => 'thisWeek']);
$this->assertSame('2026-01-02', $result);
}
public function test_resolve_quick_add_due_date_this_week_maps_to_next_friday(): void
{
$service = $this->makeService();
$result = $service->resolveQuickAddDueDate(['dateToFinish' => '', 'group' => 'thisWeek']);
$this->assertSame(date('Y-m-d', strtotime('next friday')), $result);
}
public function test_resolve_quick_add_due_date_overdue_maps_to_today(): void
{
$service = $this->makeService();
$result = $service->resolveQuickAddDueDate(['group' => 'overdue']);
$this->assertSame(date('Y-m-d'), $result);
}
public function test_resolve_quick_add_due_date_later_stays_empty(): void
{
$service = $this->makeService();
$this->assertSame('', $service->resolveQuickAddDueDate(['group' => 'later']));
$this->assertSame('', $service->resolveQuickAddDueDate([]));
}
public function test_map_group_to_fields_priority(): void
{
$service = $this->makeService();
$this->assertSame(['priority' => 2], $service->mapGroupToFields('priority', '2'));
$this->assertSame(['priority' => ''], $service->mapGroupToFields('priority', '999'));
$this->assertSame([], $service->mapGroupToFields('priority', '7'));
}
public function test_map_group_to_fields_project(): void
{
$service = $this->makeService();
$this->assertSame(['projectId' => 5], $service->mapGroupToFields('project', '5'));
$this->assertSame([], $service->mapGroupToFields('project', '0'));
}
public function test_map_group_to_fields_time(): void
{
$service = $this->makeService();
$this->assertSame(
['dateToFinish' => date('Y-m-d', strtotime('yesterday'))],
$service->mapGroupToFields('time', 'overdue')
);
$this->assertSame(['dateToFinish' => ''], $service->mapGroupToFields('time', 'later'));
$this->assertSame([], $service->mapGroupToFields('time', 'bogus'));
}
public function test_map_group_to_fields_unknown_group_by_returns_empty(): void
{
$service = $this->makeService();
$this->assertSame([], $service->mapGroupToFields('unknown', 'whatever'));
}
public function test_has_more_tickets_preserves_full_page_semantics(): void
{
$service = $this->makeService();
// Two groups. countNested over the whole collection counts each group node
// plus its nested tickets: group1 = 1 + 2 = 3, group2 = 1 + 1 = 2, total = 5.
// The legacy loop re-counts the whole collection once per group, so the
// returned total is 5 * 2 = 10. This quirk is preserved deliberately.
$groups = [
['tickets' => [['id' => 1], ['id' => 2]]],
['tickets' => [['id' => 3]]],
];
$this->assertTrue($service->hasMoreTickets($groups, 10));
$this->assertTrue($service->hasMoreTickets($groups, 9));
$this->assertFalse($service->hasMoreTickets($groups, 11));
$this->assertFalse($service->hasMoreTickets([], 1));
}
public function test_add_todo_resolves_due_date_then_delegates(): void
{
$captured = null;
$tickets = $this->make(TicketService::class, [
'quickAddTicket' => function ($params) use (&$captured) {
$captured = $params;
return ['status' => 'success'];
},
]);
$service = $this->makeService(['tickets' => $tickets]);
$result = $service->addTodo(['quickadd' => '1', 'dateToFinish' => '', 'group' => 'overdue']);
$this->assertSame(['status' => 'success'], $result);
$this->assertSame(date('Y-m-d'), $captured['dateToFinish']);
}
public function test_toggle_task_collapse_flips_state_and_persists(): void
{
$saved = [];
$settings = $this->make(SettingService::class, [
'getSetting' => fn () => 'open',
'saveSetting' => function ($key, $value) use (&$saved) {
$saved[$key] = $value;
return true;
},
]);
$service = $this->makeService(['settings' => $settings]);
$newState = $service->toggleTaskCollapse(7, '42');
$this->assertSame('closed', $newState);
$this->assertSame('closed', $saved['user.7.taskCollapsed.42']);
}
public function test_save_todo_sorting_normalizes_order_and_persists(): void
{
$savedValue = null;
$settings = $this->make(SettingService::class, [
'saveSetting' => function ($key, $value) use (&$savedValue) {
$savedValue = $value;
return true;
},
]);
// No dependencies / patches expected when there are no parents.
$tickets = $this->make(TicketService::class, [
'patch' => fn () => true,
]);
$service = $this->makeService(['settings' => $settings, 'tickets' => $tickets]);
$rawItems = [
json_encode(['id' => 1, 'order' => 0]),
json_encode(['id' => 2, 'order' => 5]),
];
$result = $service->saveTodoSorting(99, $rawItems, [], 'time');
$this->assertTrue($result['sorted']);
$this->assertSame(0, $result['successCount']);
$this->assertSame(0, $result['errorCount']);
$persisted = json_decode($savedValue, true);
$this->assertSame(10, $persisted[0]['order']);
$this->assertSame(15, $persisted[1]['order']);
}
public function test_save_todo_sorting_returns_not_sorted_for_non_array_payload(): void
{
$service = $this->makeService();
$result = $service->saveTodoSorting(99, 'not-an-array', [], 'time');
$this->assertFalse($result['sorted']);
$this->assertSame(0, $result['successCount']);
$this->assertSame(0, $result['errorCount']);
}
public function test_update_ticket_dependencies_sets_and_clears_parents(): void
{
$patches = [];
$tickets = $this->make(TicketService::class, [
'patch' => function ($id, $fields) use (&$patches) {
$patches[$id] = $fields;
return true;
},
]);
$service = $this->makeService(['tickets' => $tickets]);
$service->updateTicketDependencies([
['id' => 1, 'parentId' => 5, 'parentType' => 'ticket'],
['id' => 2, 'parentId' => null, 'parentType' => null],
['id' => 3, 'parentId' => 3, 'parentType' => 'ticket'], // self-reference skipped
]);
$this->assertSame(['dependingTicketId' => 5], $patches[1]);
$this->assertSame(['dependingTicketId' => '', 'milestoneid' => ''], $patches[2]);
$this->assertArrayNotHasKey(3, $patches);
}
public function test_get_welcome_widget_data_aggregates_counts(): void
{
session(['userdata' => ['id' => 4]]);
session(['usersettings.timezone' => 'UTC']);
$tickets = $this->make(TicketService::class, [
'simpleTicketCounter' => fn () => 12,
'getRecentlyCompletedTicketsByUser' => fn () => [['id' => 1], ['id' => 2]],
'goalsRelatedToWork' => fn () => [['id' => 9]],
'getScheduledTasks' => fn () => [
'totalTasks' => [['id' => 1], ['id' => 2], ['id' => 3]],
'doneTasks' => [['id' => 1]],
],
]);
$projects = $this->make(ProjectService::class, [
'getProjectsAssignedToUser' => fn () => [['id' => 1], ['id' => 2]],
]);
$users = $this->make(UserService::class, [
'getUser' => fn () => ['id' => 4, 'username' => 'tester'],
]);
$widgets = $this->make(Widgets::class, [
'getNewWidgets' => fn () => ['todos' => true],
]);
$service = $this->makeService([
'tickets' => $tickets,
'projects' => $projects,
'users' => $users,
'widgets' => $widgets,
]);
$data = $service->getWelcomeWidgetData(4);
$this->assertSame(12, $data['totalTickets']);
$this->assertSame(2, $data['closedTicketsCount']);
$this->assertSame(1, $data['ticketsInGoals']);
$this->assertSame(3, $data['totalTodayCount']);
$this->assertSame(1, $data['doneTodayCount']);
$this->assertSame(2, $data['projectCount']);
$this->assertTrue($data['showSettingsIndicator']);
$this->assertSame(['id' => 4, 'username' => 'tester'], $data['currentUser']);
}
public function test_get_welcome_widget_data_handles_non_array_results(): void
{
session(['userdata' => ['id' => 4]]);
session(['usersettings.timezone' => 'UTC']);
$tickets = $this->make(TicketService::class, [
'simpleTicketCounter' => fn () => 0,
'getRecentlyCompletedTicketsByUser' => fn () => [],
'goalsRelatedToWork' => fn () => false,
'getScheduledTasks' => fn () => [],
]);
$projects = $this->make(ProjectService::class, [
'getProjectsAssignedToUser' => fn () => [],
]);
$users = $this->make(UserService::class, [
'getUser' => fn () => ['id' => 4],
]);
$widgets = $this->make(Widgets::class, [
'getNewWidgets' => fn () => [],
]);
$service = $this->makeService([
'tickets' => $tickets,
'projects' => $projects,
'users' => $users,
'widgets' => $widgets,
]);
$data = $service->getWelcomeWidgetData(4);
$this->assertSame(0, $data['closedTicketsCount']);
$this->assertSame(0, $data['ticketsInGoals']);
$this->assertSame(0, $data['totalTodayCount']);
$this->assertSame(0, $data['doneTodayCount']);
$this->assertSame([], $data['allProjects']);
$this->assertSame(0, $data['projectCount']);
$this->assertFalse($data['showSettingsIndicator']);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Unit\app\Domain\Widgets\Services;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Reports\Services\Reports as ReportService;
use Leantime\Domain\Setting\Repositories\Setting;
use Leantime\Domain\Widgets\Services\Widgets;
use Unit\TestCase;
/**
* Unit tests for the Widgets service aggregation extracted from the
* Widgets/MyProjects HxController.
*/
class WidgetsServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private function makeService(ProjectService $projectService, ReportService $reportService): Widgets
{
return new Widgets($this->make(Setting::class), $projectService, $reportService);
}
public function test_my_projects_widget_data_enriches_each_project(): void
{
$projectService = $this->make(ProjectService::class, [
'getProjectsAssignedToUser' => fn () => [
['id' => 1, 'clientId' => 10, 'clientName' => 'Acme'],
['id' => 2, 'clientId' => 20, 'clientName' => 'Globex'],
],
'getProjectProgress' => fn ($id) => ['percent' => 42, 'projectId' => $id],
]);
$reportService = $this->make(ReportService::class, [
'getRealtimeReport' => fn ($id, $sprint) => ['report' => true, 'projectId' => $id],
]);
$result = $this->makeService($projectService, $reportService)->getMyProjectsWidgetData(5);
$this->assertCount(2, $result['projects']);
$this->assertSame(42, $result['projects'][0]['progress']['percent']);
$this->assertSame(1, $result['projects'][0]['report']['projectId']);
$this->assertSame([10 => 'Acme', 20 => 'Globex'], $result['clients']);
}
public function test_my_projects_widget_data_filters_by_client(): void
{
$projectService = $this->make(ProjectService::class, [
'getProjectsAssignedToUser' => fn () => [
['id' => 1, 'clientId' => 10, 'clientName' => 'Acme'],
['id' => 2, 'clientId' => 20, 'clientName' => 'Globex'],
],
'getProjectProgress' => fn ($id) => ['percent' => 0],
]);
$reportService = $this->make(ReportService::class, [
'getRealtimeReport' => fn ($id, $sprint) => [],
]);
$result = $this->makeService($projectService, $reportService)->getMyProjectsWidgetData(5, '20');
// Both clients are still mapped, but only the matching project is enriched/returned.
$this->assertCount(1, $result['projects']);
$this->assertSame(2, $result['projects'][0]['id']);
$this->assertArrayHasKey(10, $result['clients']);
$this->assertArrayHasKey(20, $result['clients']);
}
public function test_my_projects_widget_data_handles_no_projects(): void
{
$projectService = $this->make(ProjectService::class, [
'getProjectsAssignedToUser' => fn () => [],
]);
$reportService = $this->make(ReportService::class);
$result = $this->makeService($projectService, $reportService)->getMyProjectsWidgetData(5);
$this->assertSame([], $result['projects']);
$this->assertSame([], $result['clients']);
}
}

View File

@@ -0,0 +1,298 @@
<?php
namespace Unit\app\Domain\Wiki\Services;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language;
use Leantime\Domain\Audit\Repositories\Audit as AuditRepository;
use Leantime\Domain\Wiki\Models\Article;
use Leantime\Domain\Wiki\Models\Wiki as WikiModel;
use Leantime\Domain\Wiki\Repositories\Wiki as WikiRepository;
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
use Unit\TestCase;
/**
* Unit tests for the Wiki service's project-scoped authorization. Wiki articles and notebooks are
* project-scoped; mutations and single-entity reads authorize against the entity's REAL project
* (entityScoped), closing the IDORs where the id alone identified the row.
*/
class WikiServiceTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
private function makeService(
?WikiRepository $wikiRepo = null,
?AuditRepository $auditRepo = null,
): WikiService {
return new WikiService(
$wikiRepo ?? $this->make(WikiRepository::class),
$this->make(Language::class),
$auditRepo ?? $this->make(AuditRepository::class),
);
}
private function allowingPermissions(): PermissionService
{
return $this->make(PermissionService::class, ['authorize' => fn () => null]);
}
private function denyingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => function (): void {
throw new AuthorizationException;
},
]);
}
// ---------------------------------------------------------------------
// Reads: single-entity-by-id reads fence against the entity's project.
// ---------------------------------------------------------------------
public function test_get_wiki_is_denied_when_user_cannot_view_its_project(): void
{
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getWiki' => fn () => $this->make(WikiModel::class, ['id' => 3, 'projectId' => 9]),
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->getWiki(3);
}
public function test_get_wiki_returns_false_for_unknown_id_without_authorizing(): void
{
// A missing wiki short-circuits to false BEFORE authorize — no enumeration oracle.
$authorizeCalls = 0;
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getWiki' => fn () => false,
]));
$service->setPermissionService($this->make(PermissionService::class, [
'authorize' => function () use (&$authorizeCalls): void {
$authorizeCalls++;
},
]));
$this->assertFalse($service->getWiki(999));
$this->assertSame(0, $authorizeCalls, 'A non-existent wiki must short-circuit before authorize');
}
public function test_get_all_wiki_headlines_is_denied_for_foreign_wiki(): void
{
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getWiki' => fn () => $this->make(WikiModel::class, ['id' => 3, 'projectId' => 9]),
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->getAllWikiHeadlines(3, 1);
}
// ---------------------------------------------------------------------
// Mutations: authorize against the entity's real project before writing.
// ---------------------------------------------------------------------
public function test_create_article_is_denied_without_create_permission(): void
{
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
// createArticle resolves the project from the target wiki (canvasId) to authorize.
'getWiki' => fn () => $this->make(WikiModel::class, ['id' => 3, 'projectId' => 9]),
'createArticle' => function () {
throw new \RuntimeException('create must not be reached when denied');
},
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$article = new Article;
$article->canvasId = 3;
$service->createArticle($article);
}
public function test_create_article_fails_closed_when_canvas_is_not_a_wiki(): void
{
// canvasId does not resolve to a wiki -> refuse before authorize, never write (no falling
// back to the session project, which would let a foreign/non-wiki canvasId be created).
$created = false;
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getWiki' => fn () => false,
'createArticle' => function () use (&$created) {
$created = true;
return '1';
},
]));
$service->setPermissionService($this->allowingPermissions());
$article = new Article;
$article->canvasId = 999;
$this->assertFalse($service->createArticle($article));
$this->assertFalse($created, 'A non-wiki canvasId must never create an article');
}
public function test_update_article_is_denied_without_edit_permission(): void
{
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getArticleProjectId' => fn () => 9,
'updateArticle' => function () {
throw new \RuntimeException('update must not be reached when denied');
},
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$article = new Article;
$article->id = 42;
$service->updateArticle($article);
}
public function test_create_wiki_is_denied_without_create_permission(): void
{
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'createWiki' => function () {
throw new \RuntimeException('create must not be reached when denied');
},
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$wiki = new WikiModel;
$wiki->projectId = 9;
$service->createWiki($wiki);
}
public function test_update_article_returns_false_for_unknown_id_without_authorizing(): void
{
// FAIL CLOSED: zp_canvas_items is a shared table (one id sequence across all canvas types),
// so an unresolved project (non-article / unknown id) must refuse BEFORE authorize and never
// reach the repo write — otherwise a non-article id would overwrite a goal/SWOT/risk row.
$authorizeCalls = 0;
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getArticleProjectId' => fn () => null,
'updateArticle' => function (): bool {
throw new \RuntimeException('update must not run for an unresolved/non-article id');
},
]));
$service->setPermissionService($this->make(PermissionService::class, [
'authorize' => function () use (&$authorizeCalls): void {
$authorizeCalls++;
},
]));
$article = new Article;
$article->id = 999;
$this->assertFalse($service->updateArticle($article));
$this->assertSame(0, $authorizeCalls, 'A non-article id must short-circuit before authorize');
}
public function test_update_wiki_returns_false_for_unknown_wiki_without_authorizing(): void
{
// FAIL CLOSED: zp_canvas is shared across canvas types, so a non-wiki / unknown id must
// refuse BEFORE authorize and never reach the repo title write.
$authorizeCalls = 0;
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getWiki' => fn () => false,
'updateWiki' => function (): bool {
throw new \RuntimeException('update must not run for a non-wiki id');
},
]));
$service->setPermissionService($this->make(PermissionService::class, [
'authorize' => function () use (&$authorizeCalls): void {
$authorizeCalls++;
},
]));
$wiki = new WikiModel;
$this->assertFalse($service->updateWiki($wiki, 999));
$this->assertSame(0, $authorizeCalls, 'A non-wiki id must short-circuit before authorize');
}
// ---------------------------------------------------------------------
// Delete: new service methods that fence the previously controller->repo IDOR.
// ---------------------------------------------------------------------
public function test_delete_article_is_denied_and_does_not_delete_without_permission(): void
{
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getArticleProjectId' => fn () => 9,
'delArticle' => function (): void {
throw new \RuntimeException('delete must not be reached when denied');
},
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->deleteArticle(42);
}
public function test_delete_article_deletes_and_audits_when_authorized(): void
{
$deletedId = null;
$auditedAction = null;
$service = $this->makeService(
wikiRepo: $this->make(WikiRepository::class, [
'getArticleProjectId' => fn () => 9,
'delArticle' => function ($id) use (&$deletedId): void {
$deletedId = $id;
},
]),
auditRepo: $this->make(AuditRepository::class, [
// Accept the full storeEvent signature (variadic) — deleteArticle calls it with
// several named args; only the action is asserted.
'storeEvent' => function (string $action, ...$rest) use (&$auditedAction) {
$auditedAction = $action;
},
]),
);
$service->setPermissionService($this->allowingPermissions());
$this->assertTrue($service->deleteArticle(42));
$this->assertSame(42, $deletedId);
$this->assertSame('article.delete', $auditedAction);
}
public function test_delete_article_returns_false_for_unknown_id_without_authorizing(): void
{
$authorizeCalls = 0;
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getArticleProjectId' => fn () => null,
'delArticle' => function (): void {
throw new \RuntimeException('delete must not run for a non-existent article');
},
]));
$service->setPermissionService($this->make(PermissionService::class, [
'authorize' => function () use (&$authorizeCalls): void {
$authorizeCalls++;
},
]));
$this->assertFalse($service->deleteArticle(999));
$this->assertSame(0, $authorizeCalls);
}
public function test_delete_wiki_is_denied_without_permission(): void
{
$service = $this->makeService(wikiRepo: $this->make(WikiRepository::class, [
'getWiki' => fn () => $this->make(WikiModel::class, ['id' => 3, 'projectId' => 9]),
'delWiki' => function (): void {
throw new \RuntimeException('delete must not be reached when denied');
},
]));
$service->setPermissionService($this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->deleteWiki(3);
}
}