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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,392 @@
<?php
declare(strict_types=1);
namespace Unit\app\Plugins\PgmPro\Domain\Resources\Services;
use Leantime\Core\Db\Db;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Plugins\PgmPro\Domain\Resources\Repositories\ResourceStructureRepository;
use Leantime\Plugins\PgmPro\Domain\Resources\Services\ResourceStructureService;
use Leantime\Plugins\PgmPro\Services\Programs;
use Unit\TestCase;
/**
* Behaviors under test:
*
* getForProjects — the ResourcesGateway contract:
* 1. Empty projectIds → empty ResourceSummary (no walk).
* 2. Projects with no resource canvas → empty ResourceSummary (honest
* "resources not authored here" state, not an error).
* 3. status='stub' PEOPLE are excluded from totals — matches the tab's
* teamStats split. A seeded stub defaults to capacity:40, and counting
* it would make the report disagree with the tab the moment anyone
* uses LM-Inputs seeding.
* 4. status='stub' BUDGET LINES are excluded from totals AND from the
* budget[] array — stubs are 0/0 so they don't move totals, but they
* inflate `count(budget)` and can flip `isEmpty()`.
*
* Seeders — the "skip already present" contract:
* 5. seedPeopleFromChildProjects is idempotent by userId — a second run
* with the same source data adds zero rows and reports the correct
* skipped count.
* 6. seedBudgetFromChildProjects is idempotent by projectId — same.
*
* All tests avoid touching the DB via a testable subclass of the SUT that
* overrides `findResourceCanvasIds()`; the repository and the two service
* collaborators are stubbed with anonymous classes so the test asserts on
* the exact call surface.
*/
class ResourceStructureServiceTest extends TestCase
{
protected function setUp(): void
{
// The OSS CI checkout has an empty app/Plugins (private submodule) — the classes
// under test only exist when the plugins are present. Skip, don't error.
if (! class_exists(ResourceStructureService::class)) {
$this->markTestSkipped('PgmPro plugin is not present in this checkout.');
}
parent::setUp();
}
public function test_get_for_projects_returns_empty_summary_when_project_ids_is_empty(): void
{
$svc = $this->makeService(canvasIds: [1], items: []);
$summary = $svc->getForProjects([]);
$this->assertSame([], $summary->projectIds);
$this->assertTrue($summary->isEmpty());
}
public function test_get_for_projects_returns_empty_summary_when_no_resource_canvas(): void
{
$svc = $this->makeService(canvasIds: [], items: []);
$summary = $svc->getForProjects([1, 2]);
$this->assertSame([1, 2], $summary->projectIds);
$this->assertTrue($summary->isEmpty());
$this->assertSame(0.0, $summary->totalCapacity);
}
public function test_get_for_projects_excludes_stub_people_from_totals(): void
{
// Active person: capacity 40, allocated 30. Stub person: default 40.
// The report and the tab must agree — the tab excludes stubs from
// teamStats, so the gateway must too. Otherwise a seeded-but-unlinked
// person shows 40h in the report and 0h in the tab.
$svc = $this->makeService(
canvasIds: [100],
items: [
100 => [
'people' => [
[
'id' => 1,
'description' => 'Sarah Chen',
'status' => 'active',
'parsedData' => ['userId' => 1, 'capacity' => 40, 'allocations' => ['7' => 30]],
],
[
'id' => 2,
'description' => 'Unlinked seed',
'status' => 'stub',
'parsedData' => ['capacity' => 40, 'allocations' => []],
],
],
],
],
);
$summary = $svc->getForProjects([7]);
$this->assertCount(1, $summary->people, 'Stubs must not appear in the people array');
$this->assertSame(40.0, $summary->totalCapacity, 'Stub 40h capacity must not be counted');
$this->assertSame(30.0, $summary->totalAllocated);
}
public function test_get_for_projects_excludes_stub_budget_lines_from_array_and_totals(): void
{
$svc = $this->makeService(
canvasIds: [100],
items: [
100 => [
'budget' => [
[
'id' => 10,
'description' => 'Community Health Fairs',
'status' => 'active',
'parsedData' => ['projectId' => 7, 'budgeted' => 10000, 'spent' => 2000],
],
[
'id' => 11,
'description' => '',
'status' => 'stub',
'parsedData' => ['projectId' => 8, 'budgeted' => 0, 'spent' => 0],
],
],
],
],
);
$summary = $svc->getForProjects([7, 8]);
$this->assertCount(1, $summary->budget, 'Stub budget rows must not inflate the array count (would flip isEmpty)');
$this->assertSame(10000.0, $summary->totalBudgeted);
$this->assertSame(2000.0, $summary->totalSpent);
}
public function test_get_for_projects_aggregates_across_multiple_canvases(): void
{
// A strategy with two programs, each with its own resource canvas.
// The gateway must sum across both.
$svc = $this->makeService(
canvasIds: [100, 200],
items: [
100 => [
'people' => [
['id' => 1, 'description' => 'A', 'status' => 'active',
'parsedData' => ['userId' => 1, 'capacity' => 40, 'allocations' => ['7' => 20]]],
],
],
200 => [
'people' => [
['id' => 2, 'description' => 'B', 'status' => 'active',
'parsedData' => ['userId' => 2, 'capacity' => 30, 'allocations' => ['9' => 15]]],
],
],
],
);
$summary = $svc->getForProjects([2, 15, 7, 9]);
$this->assertCount(2, $summary->people);
$this->assertSame(70.0, $summary->totalCapacity);
$this->assertSame(35.0, $summary->totalAllocated);
}
public function test_seed_people_from_child_projects_is_idempotent_by_user_id(): void
{
// Two child projects; user 5 assigned to both, user 7 to one.
// First run: 2 people added (5 and 7). Second run: 0 added, 2 skipped
// per encounter — the "skip already present" contract is what makes
// the seeder safe to re-run on demand from the UI.
$existingItems = [];
$addCalls = [];
$repo = $this->makeRepo(
canvasId: 500,
itemsByBoxProvider: function (int $canvasId, string $box) use (&$existingItems) {
return $existingItems;
},
onAddItem: function (int $canvasId, array $values) use (&$existingItems, &$addCalls): int {
$addCalls[] = $values;
$existingItems[] = [
'id' => count($existingItems) + 1,
'description' => $values['description'],
'status' => $values['status'],
'parsedData' => $values['data'],
];
return count($existingItems);
},
);
$programs = $this->makePrograms(childProjects: [
['id' => 7, 'name' => 'Project A'],
['id' => 8, 'name' => 'Project B'],
]);
$projects = $this->makeProjects(usersByProject: [
7 => [['id' => 5, 'firstname' => 'Sarah', 'lastname' => 'Chen', 'jobTitle' => 'PM']],
8 => [
['id' => 5, 'firstname' => 'Sarah', 'lastname' => 'Chen', 'jobTitle' => 'PM'],
['id' => 7, 'firstname' => 'Aisha', 'lastname' => 'Patel', 'jobTitle' => 'Dev'],
],
]);
$svc = new ResourceStructureService($repo, $this->makeDb(), $projects, $programs);
$first = $svc->seedPeopleFromChildProjects(500);
$this->assertSame(2, $first['added'], 'First run adds the two distinct users');
$this->assertSame(1, $first['skipped'], 'Sarah appearing on the second project is skipped');
$this->assertSame(500, $first['canvasId']);
$second = $svc->seedPeopleFromChildProjects(500);
$this->assertSame(0, $second['added'], 'Second run must add nothing (idempotent by userId)');
$this->assertSame(3, $second['skipped'], 'Every source-user encounter is a skip on re-run');
// Also validates the add-payload shape: userId is captured, status is 'active' (not 'stub').
$this->assertCount(2, $addCalls);
$this->assertSame('active', $addCalls[0]['status']);
$this->assertSame(5, $addCalls[0]['data']['userId']);
}
public function test_get_for_projects_hydrates_a_dependency_with_none_of_the_optional_fields(): void
{
// Back-compat guarantee for pre-existing dependency canvas items —
// owner/dueDate/notes/lastModified were added later. An item authored
// before those existed must hydrate cleanly (no undefined-index
// warning, all four optional slots null) so Page 3 renders the empty
// state instead of crashing or flashing blank cells.
$svc = $this->makeService(
canvasIds: [100],
items: [
100 => [
'dependency' => [
[
'id' => 42,
'description' => 'Community Health Fair Partner',
'status' => 'active',
'parsedData' => [
'partnerName' => 'City Health Dept',
'type' => 'partnership',
'confirmed' => true,
// owner, dueDate, notes intentionally absent.
],
// 'modified' intentionally absent too — pre-hydrated rows
// that predate lastModified capture.
],
],
],
],
);
$summary = $svc->getForProjects([7]);
$this->assertCount(1, $summary->dependencies);
$dep = $summary->dependencies[0];
$this->assertSame(42, $dep->itemId);
$this->assertSame('City Health Dept', $dep->partnerName);
$this->assertSame('partnership', $dep->type);
$this->assertTrue($dep->confirmed);
$this->assertNull($dep->owner);
$this->assertNull($dep->dueDate);
$this->assertNull($dep->notes);
$this->assertNull($dep->lastModified);
}
public function test_seed_budget_from_child_projects_is_idempotent_by_project_id(): void
{
// Same skip-when-present contract, keyed on projectId. A child with
// dollarBudget=0 is skipped entirely (no line to seed).
$existingItems = [];
$addCalls = [];
$repo = $this->makeRepo(
canvasId: 500,
itemsByBoxProvider: function (int $canvasId, string $box) use (&$existingItems) {
return $existingItems;
},
onAddItem: function (int $canvasId, array $values) use (&$existingItems, &$addCalls): int {
$addCalls[] = $values;
$existingItems[] = [
'id' => count($existingItems) + 1,
'description' => $values['description'],
'status' => $values['status'],
'parsedData' => $values['data'],
];
return count($existingItems);
},
);
$programs = $this->makePrograms(childProjects: [
['id' => 7, 'name' => 'Health Fairs', 'dollarBudget' => 45000, 'color' => '#3E937A'],
['id' => 8, 'name' => 'Zero-budget project', 'dollarBudget' => 0, 'color' => '#000'],
['id' => 9, 'name' => 'Walk-in Days', 'dollarBudget' => 12000, 'color' => '#C09035'],
]);
$svc = new ResourceStructureService($repo, $this->makeDb(), $this->makeProjects(), $programs);
$first = $svc->seedBudgetFromChildProjects(500);
$this->assertSame(2, $first['added'], 'The zero-budget project is skipped (no line to seed)');
$this->assertSame(0, $first['skipped']);
$second = $svc->seedBudgetFromChildProjects(500);
$this->assertSame(0, $second['added'], 'Re-run must add nothing');
$this->assertSame(2, $second['skipped'], 'Both real-budget projects skip on re-run');
}
// ─── Test doubles ────────────────────────────────────────────────
/**
* Testable subclass of the SUT that overrides `findResourceCanvasIds()`
* with a canned list, so the test doesn't need to mock a Db query chain.
* `getItemsByBox()` returns items keyed by `[canvasId][box]`.
*
* @param int[] $canvasIds
* @param array<int, array<string, array<int, array<string, mixed>>>> $items
*/
private function makeService(array $canvasIds, array $items): ResourceStructureService
{
$repo = $this->makeRepo(
canvasId: 0,
itemsByBoxProvider: fn (int $canvasId, string $box) => $items[$canvasId][$box] ?? [],
);
return new class($repo, $this->makeDb(), $this->makeProjects(), $this->makePrograms(), $canvasIds) extends ResourceStructureService
{
/** @param int[] $canvasIds */
public function __construct(
ResourceStructureRepository $repo,
Db $dbCore,
ProjectService $projectService,
Programs $programService,
private array $canvasIds,
) {
parent::__construct($repo, $dbCore, $projectService, $programService);
}
protected function findResourceCanvasIds(array $projectIds): array
{
return $this->canvasIds;
}
};
}
private function makeRepo(
int $canvasId = 0,
?\Closure $itemsByBoxProvider = null,
?\Closure $onAddItem = null,
): ResourceStructureRepository {
$repo = $this->createMock(ResourceStructureRepository::class);
$repo->method('getOrCreateResourceCanvas')->willReturn($canvasId);
$repo->method('getItemsByBox')->willReturnCallback(
$itemsByBoxProvider ?? fn (int $canvasId, string $box) => []
);
if ($onAddItem !== null) {
$repo->method('addItem')->willReturnCallback($onAddItem);
}
return $repo;
}
private function makeDb(): Db
{
// Not used by the SUT paths under test — `findResourceCanvasIds` is
// overridden in makeService(), and seeders don't touch dbCore.
return $this->createMock(Db::class);
}
/**
* @param array<int, array<int, array<string, mixed>>> $usersByProject projectId => user rows
*/
private function makeProjects(array $usersByProject = []): ProjectService
{
$projects = $this->createMock(ProjectService::class);
$projects->method('getUsersAssignedToProject')->willReturnCallback(
fn (int $projectId) => $usersByProject[$projectId] ?? []
);
return $projects;
}
/**
* @param array<int, array<string, mixed>> $childProjects
*/
private function makePrograms(array $childProjects = []): Programs
{
$programs = $this->createMock(Programs::class);
$programs->method('getColoredProgramProjects')->willReturn($childProjects);
return $programs;
}
}