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