OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
96
tests/Unit/app/Core/ApiClientTest.php
Normal file
96
tests/Unit/app/Core/ApiClientTest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Test\Unit;
|
||||
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use Leantime\Core\Http\Client\ApiClient;
|
||||
|
||||
class ApiClientTest extends \Unit\TestCase
|
||||
{
|
||||
public function test_o_auth2(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$stack = HandlerStack::create();
|
||||
$requestDefaults = [];
|
||||
|
||||
$client = ApiClient::oAuth2($baseUri, $stack, $requestDefaults);
|
||||
|
||||
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
|
||||
$this->assertSame($stack, $client->getConfig('handler'));
|
||||
$this->assertEquals('oauth', $client->getConfig('auth'));
|
||||
}
|
||||
|
||||
public function test_o_auth2_grants(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'client_id' => 'testclient',
|
||||
'client_secret' => 'testsecret',
|
||||
];
|
||||
|
||||
$stack = ApiClient::oAuth2Grants($baseUri, $creds);
|
||||
|
||||
$this->assertInstanceOf(HandlerStack::class, $stack);
|
||||
}
|
||||
|
||||
public function test_o_auth1(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'consumer_key' => 'testconsumer',
|
||||
'consumer_secret' => 'testsecret',
|
||||
'token' => 'testtoken',
|
||||
'token_secret' => 'testtokensecret',
|
||||
];
|
||||
|
||||
$client = ApiClient::oAuth1($baseUri, $creds);
|
||||
|
||||
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
|
||||
$this->assertEquals('oauth', $client->getConfig('auth'));
|
||||
}
|
||||
|
||||
public function test_basic_auth(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'username' => 'testuser',
|
||||
'password' => 'testpass',
|
||||
];
|
||||
|
||||
$client = ApiClient::basicAuth($baseUri, $creds);
|
||||
|
||||
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
|
||||
$this->assertEquals($creds, $client->getConfig('auth'));
|
||||
}
|
||||
|
||||
public function test_digest(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'username' => 'testuser',
|
||||
'password' => 'testpass',
|
||||
'digest' => 'testdigest',
|
||||
];
|
||||
|
||||
$client = ApiClient::digest($baseUri, $creds);
|
||||
|
||||
$config = $client->getConfig();
|
||||
$this->assertEquals('http://test.com', $config[1]['base_uri']);
|
||||
$this->assertEquals($creds, $config[1]['auth']);
|
||||
}
|
||||
|
||||
public function test_ntlm(): void
|
||||
{
|
||||
$baseUri = 'http://test.com';
|
||||
$creds = [
|
||||
'username' => 'testuser',
|
||||
'password' => 'testpass',
|
||||
'ntlm' => 'testntlm',
|
||||
];
|
||||
|
||||
$client = ApiClient::ntlm($baseUri, $creds);
|
||||
|
||||
$this->assertEquals('http://test.com', $client->getConfig('base_uri'));
|
||||
$this->assertEquals($creds, $client->getConfig('auth'));
|
||||
}
|
||||
}
|
||||
109
tests/Unit/app/Core/ApplicationUrlTest.php
Normal file
109
tests/Unit/app/Core/ApplicationUrlTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Core;
|
||||
|
||||
use Leantime\Core\Application;
|
||||
use Leantime\Core\Bootstrap\LoadConfig;
|
||||
use Leantime\Core\Bootstrap\SetRequestForConsole;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
|
||||
class ApplicationUrlTest extends \Unit\TestCase
|
||||
{
|
||||
protected $app;
|
||||
|
||||
protected $config;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
}
|
||||
|
||||
protected function bootstrapApplication()
|
||||
{
|
||||
|
||||
$this->app = new Application(APP_ROOT);
|
||||
|
||||
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
|
||||
$this->app->boot();
|
||||
|
||||
$this->config = $this->app['config'];
|
||||
}
|
||||
|
||||
public function test_base_url_is_set_correctly_from_config(): void
|
||||
{
|
||||
// BASE_URL constant is set from LEAN_APP_URL in the environment.
|
||||
// Verify the config matches whatever BASE_URL was resolved to.
|
||||
$this->assertEquals(BASE_URL, $this->config->get('app.url'));
|
||||
|
||||
// Test with LEAN_APP_URL set to a known value
|
||||
putenv('LEAN_APP_URL=https://example.com');
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com';
|
||||
|
||||
// Reinitialize application to test new environment
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
public function test_base_url_handles_trailing_slash(): void
|
||||
{
|
||||
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com/';
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
public function test_base_url_handles_subdirectory(): void
|
||||
{
|
||||
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com/leantime';
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com/leantime', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com/leantime', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
public function test_base_url_handles_port(): void
|
||||
{
|
||||
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com:8443';
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com:8443', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com:8443', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
public function test_base_url_handles_reverse_proxy(): void
|
||||
{
|
||||
// Simulate reverse proxy headers
|
||||
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
|
||||
$_SERVER['HTTP_X_FORWARDED_HOST'] = 'example.com';
|
||||
|
||||
$_ENV['LEAN_APP_URL'] = 'https://example.com';
|
||||
|
||||
$this->bootstrapApplication();
|
||||
|
||||
$this->assertEquals('https://example.com', $this->config->get('app.url'));
|
||||
$this->assertEquals('https://example.com', $this->config->get('appUrl'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
// Clean up environment
|
||||
putenv('LEAN_APP_URL');
|
||||
unset($_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||
unset($_SERVER['HTTP_X_FORWARDED_HOST']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Core\Auth\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\DefaultRolePermissions;
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
|
||||
/**
|
||||
* Locks the built-in role -> permission matrix against a representative catalog so a change
|
||||
* to DefaultRolePermissions that would over- or under-grant a role fails loudly. This is the
|
||||
* grant-equivalence guard for the pilot: it proves the seeded grants match the documented
|
||||
* role capabilities (readonly view-only; commenter comment/upload; editor content CRUD;
|
||||
* manager moderation + all project perms; admin everything-but-company-settings; owner all).
|
||||
*/
|
||||
class DefaultRolePermissionsTest extends \Unit\TestCase
|
||||
{
|
||||
/** @return array<int, Permission> */
|
||||
private function catalog(): array
|
||||
{
|
||||
return [
|
||||
new Permission('tickets.view', 'View', true),
|
||||
new Permission('tickets.comment', 'Comment', true),
|
||||
new Permission('tickets.upload', 'Upload', true),
|
||||
new Permission('tickets.create', 'Create', true),
|
||||
new Permission('tickets.edit', 'Edit', true),
|
||||
new Permission('tickets.delete', 'Delete', true),
|
||||
new Permission('sprints.view', 'View', true),
|
||||
new Permission('sprints.create', 'Create', true),
|
||||
new Permission('sprints.edit', 'Edit', true),
|
||||
new Permission('sprints.delete', 'Delete', true),
|
||||
new Permission('wiki.view', 'View', true),
|
||||
new Permission('wiki.create', 'Create', true),
|
||||
new Permission('wiki.edit', 'Edit', true),
|
||||
new Permission('wiki.delete', 'Delete', true),
|
||||
new Permission('ideas.view', 'View', true),
|
||||
new Permission('ideas.create', 'Create', true),
|
||||
new Permission('ideas.edit', 'Edit', true),
|
||||
new Permission('ideas.delete', 'Delete', true),
|
||||
new Permission('blueprints.view', 'View', true),
|
||||
new Permission('blueprints.create', 'Create', true),
|
||||
new Permission('blueprints.edit', 'Edit', true),
|
||||
new Permission('blueprints.delete', 'Delete', true),
|
||||
new Permission('goals.view', 'View', true),
|
||||
new Permission('goals.create', 'Create', true),
|
||||
new Permission('goals.edit', 'Edit', true),
|
||||
new Permission('goals.delete', 'Delete', true),
|
||||
new Permission('files.view', 'View', true),
|
||||
new Permission('files.upload', 'Upload', true),
|
||||
new Permission('files.delete', 'Delete', true),
|
||||
new Permission('reports.view', 'View', true),
|
||||
// Calendar: project-scoped capability verbs (view→readonly+, create/edit/delete→editor+)
|
||||
// + a GLOBAL manage verb (admin+ cross-user override; managers do NOT get it).
|
||||
new Permission('calendar.view', 'View', true),
|
||||
new Permission('calendar.create', 'Create', true),
|
||||
new Permission('calendar.edit', 'Edit', true),
|
||||
new Permission('calendar.delete', 'Delete', true),
|
||||
new Permission('calendar.manage', 'Manage any calendar', false),
|
||||
new Permission('comments.view', 'View', true),
|
||||
new Permission('comments.create', 'Create', true),
|
||||
new Permission('comments.moderate', 'Moderate', true),
|
||||
// Company-wide (not project-scoped):
|
||||
new Permission('users.view', 'View users', false),
|
||||
new Permission('users.create', 'Invite/create users', false),
|
||||
new Permission('users.edit', 'Edit users', false),
|
||||
new Permission('users.delete', 'Delete users', false),
|
||||
new Permission('users.import', 'Import users', false),
|
||||
new Permission('clients.view', 'View clients', false),
|
||||
new Permission('clients.create', 'Create clients', false),
|
||||
new Permission('clients.edit', 'Edit clients', false),
|
||||
new Permission('clients.delete', 'Delete clients', false),
|
||||
new Permission('company.settings.view', 'View company settings', false),
|
||||
new Permission('company.settings.edit', 'Edit company settings', false),
|
||||
// Timesheets are company-wide (global): editor gets own-time view/create/edit/delete,
|
||||
// manager+ gets manage (cross-user invoicing/reports).
|
||||
new Permission('timesheets.view', 'View timesheets', false),
|
||||
new Permission('timesheets.create', 'Log time', false),
|
||||
new Permission('timesheets.edit', 'Edit timesheets', false),
|
||||
new Permission('timesheets.delete', 'Delete timesheets', false),
|
||||
new Permission('timesheets.manage', 'Manage timesheets', false),
|
||||
// Project-scoped (rename a project's ticket/idea state labels — manager+ in project):
|
||||
new Permission('projectsettings.labels.manage', 'Rename project labels', true),
|
||||
// Projects: view is project-scoped (readonly+ data read); create/edit/delete are GLOBAL
|
||||
// company actions (manager+; editors do NOT get them since global perms aren't matched
|
||||
// by the editor project-verb rule — same shape as the timesheets globals).
|
||||
new Permission('projects.view', 'View a project', true),
|
||||
new Permission('projects.create', 'Create projects', false),
|
||||
new Permission('projects.edit', 'Edit projects', false),
|
||||
new Permission('projects.delete', 'Delete projects', false),
|
||||
];
|
||||
}
|
||||
|
||||
private function grantsFor(string $role): array
|
||||
{
|
||||
return DefaultRolePermissions::grantsFor($role, $this->catalog());
|
||||
}
|
||||
|
||||
public function test_readonly_can_only_view_project_content(): void
|
||||
{
|
||||
$this->assertEqualsCanonicalizing(['tickets.view', 'comments.view', 'sprints.view', 'wiki.view', 'ideas.view', 'blueprints.view', 'goals.view', 'files.view', 'reports.view', 'calendar.view', 'projects.view'], $this->grantsFor('readonly'));
|
||||
}
|
||||
|
||||
public function test_commenter_adds_comment_upload_and_can_create_comments(): void
|
||||
{
|
||||
$grants = $this->grantsFor('commenter');
|
||||
|
||||
$this->assertContains('tickets.view', $grants); // inherited
|
||||
$this->assertContains('tickets.comment', $grants);
|
||||
$this->assertContains('tickets.upload', $grants);
|
||||
$this->assertContains('comments.create', $grants); // explicit commenter grant
|
||||
$this->assertNotContains('tickets.create', $grants);
|
||||
$this->assertNotContains('tickets.delete', $grants);
|
||||
$this->assertNotContains('sprints.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('sprints.view', $grants); // inherited from readonly
|
||||
$this->assertNotContains('wiki.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('wiki.view', $grants); // inherited from readonly
|
||||
$this->assertNotContains('ideas.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('ideas.view', $grants); // inherited from readonly
|
||||
$this->assertNotContains('blueprints.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('blueprints.view', $grants); // inherited from readonly
|
||||
$this->assertNotContains('goals.create', $grants); // commenter views but cannot create
|
||||
$this->assertContains('goals.view', $grants); // inherited from readonly
|
||||
// Files: a commenter inherits view and gains the standard upload verb (attachments), but
|
||||
// cannot delete (editor+).
|
||||
$this->assertContains('files.view', $grants); // inherited from readonly
|
||||
$this->assertContains('files.upload', $grants); // commenter upload verb
|
||||
$this->assertNotContains('files.delete', $grants); // editor+
|
||||
// Reports: view-only feature, inherited from readonly (maintainer-approved loosening of
|
||||
// the legacy editor+ page gate — it only aggregates readonly-visible data).
|
||||
$this->assertContains('reports.view', $grants);
|
||||
// Timesheets are editor+ (global); a commenter logs no time.
|
||||
$this->assertNotContains('timesheets.view', $grants);
|
||||
$this->assertNotContains('timesheets.create', $grants);
|
||||
$this->assertNotContains('comments.moderate', $grants);
|
||||
}
|
||||
|
||||
public function test_editor_gets_content_crud_but_not_moderation_or_company(): void
|
||||
{
|
||||
$grants = $this->grantsFor('editor');
|
||||
|
||||
$this->assertContains('tickets.create', $grants);
|
||||
$this->assertContains('tickets.edit', $grants);
|
||||
$this->assertContains('tickets.delete', $grants);
|
||||
// Sprints uses the same standard project verbs, so editor auto-gets create/edit/delete.
|
||||
$this->assertContains('sprints.create', $grants);
|
||||
$this->assertContains('sprints.edit', $grants);
|
||||
$this->assertContains('sprints.delete', $grants);
|
||||
// Wiki uses the same standard project verbs, so editor auto-gets create/edit/delete.
|
||||
$this->assertContains('wiki.create', $grants);
|
||||
$this->assertContains('wiki.edit', $grants);
|
||||
$this->assertContains('wiki.delete', $grants);
|
||||
// Ideas uses the same standard project verbs, so editor auto-gets create/edit/delete.
|
||||
$this->assertContains('ideas.create', $grants);
|
||||
$this->assertContains('ideas.edit', $grants);
|
||||
$this->assertContains('ideas.delete', $grants);
|
||||
// Blueprints (canvas) uses the same standard project verbs, so editor auto-gets create/edit/delete.
|
||||
$this->assertContains('blueprints.create', $grants);
|
||||
$this->assertContains('blueprints.edit', $grants);
|
||||
$this->assertContains('blueprints.delete', $grants);
|
||||
$this->assertContains('goals.create', $grants);
|
||||
$this->assertContains('goals.edit', $grants);
|
||||
$this->assertContains('goals.delete', $grants);
|
||||
// Files uses standard project verbs, so editor auto-gets upload + delete (and view).
|
||||
$this->assertContains('files.view', $grants);
|
||||
$this->assertContains('files.upload', $grants);
|
||||
$this->assertContains('files.delete', $grants);
|
||||
// Timesheets are GLOBAL-scoped, so the project verb rule does NOT match them — editor gets
|
||||
// its own-time keys explicitly (view/create/edit/delete) but NOT the manager-only `manage`.
|
||||
$this->assertContains('timesheets.view', $grants);
|
||||
$this->assertContains('timesheets.create', $grants);
|
||||
$this->assertContains('timesheets.edit', $grants);
|
||||
$this->assertContains('timesheets.delete', $grants);
|
||||
$this->assertNotContains('timesheets.manage', $grants);
|
||||
// Calendar uses standard PROJECT verbs, so editor auto-gets view/create/edit/delete; the
|
||||
// GLOBAL manage verb (cross-user override) stays admin+.
|
||||
$this->assertContains('calendar.view', $grants);
|
||||
$this->assertContains('calendar.create', $grants);
|
||||
$this->assertContains('calendar.edit', $grants);
|
||||
$this->assertContains('calendar.delete', $grants);
|
||||
$this->assertNotContains('calendar.manage', $grants);
|
||||
// Projects: editor can VIEW projects (inherited from readonly) but project create/edit/delete
|
||||
// are GLOBAL company actions reserved for manager+ (editors do NOT manage projects).
|
||||
$this->assertContains('projects.view', $grants);
|
||||
$this->assertNotContains('projects.create', $grants);
|
||||
$this->assertNotContains('projects.edit', $grants);
|
||||
$this->assertNotContains('projects.delete', $grants);
|
||||
$this->assertContains('comments.create', $grants); // inherited
|
||||
$this->assertNotContains('comments.moderate', $grants); // manager+ only
|
||||
$this->assertNotContains('users.view', $grants); // company-wide, admin+
|
||||
$this->assertNotContains('users.create', $grants); // company-wide, manager+
|
||||
$this->assertNotContains('clients.view', $grants); // company-wide, admin+
|
||||
$this->assertNotContains('company.settings.view', $grants);
|
||||
// Label renaming uses the 'manage' verb (not 'edit'), so it stays manager+ and does NOT
|
||||
// leak to editor via the project create/edit/delete grant.
|
||||
$this->assertNotContains('projectsettings.labels.manage', $grants);
|
||||
$this->assertNotContains('company.settings.edit', $grants);
|
||||
}
|
||||
|
||||
public function test_manager_moderates_and_holds_all_project_perms_but_no_company(): void
|
||||
{
|
||||
$grants = $this->grantsFor('manager');
|
||||
|
||||
$this->assertContains('comments.moderate', $grants);
|
||||
$this->assertContains('tickets.delete', $grants);
|
||||
// Timesheets: manager gets the company-wide manage verb AND inherits editor's own-time keys.
|
||||
$this->assertContains('timesheets.manage', $grants);
|
||||
$this->assertContains('timesheets.view', $grants);
|
||||
$this->assertContains('timesheets.edit', $grants);
|
||||
// Calendar: manager holds all four project capability verbs (project '*' rule) but NOT the
|
||||
// cross-user override — calendar.manage is GLOBAL-scoped and admin-only (legacy override was
|
||||
// Auth::userIsAtLeast(admin)).
|
||||
$this->assertContains('calendar.view', $grants);
|
||||
$this->assertContains('calendar.create', $grants);
|
||||
$this->assertContains('calendar.edit', $grants);
|
||||
$this->assertContains('calendar.delete', $grants);
|
||||
$this->assertNotContains('calendar.manage', $grants);
|
||||
// Projects: manager gets the GLOBAL project-management keys (the matrix edit) + inherits view.
|
||||
$this->assertContains('projects.view', $grants);
|
||||
$this->assertContains('projects.create', $grants);
|
||||
$this->assertContains('projects.edit', $grants);
|
||||
$this->assertContains('projects.delete', $grants);
|
||||
// Managers may INVITE users (within their own client — scoped in the controller), but
|
||||
// cannot view the roster, edit, delete, or import accounts (those stay admin+).
|
||||
$this->assertContains('users.create', $grants);
|
||||
$this->assertNotContains('users.view', $grants);
|
||||
$this->assertNotContains('users.edit', $grants);
|
||||
$this->assertNotContains('users.delete', $grants);
|
||||
$this->assertNotContains('users.import', $grants);
|
||||
// Client management stays admin+ (managers have no real client access today — ShowAll
|
||||
// redirects them and ShowClient 403s them), so a manager gets NO clients.* —
|
||||
// grant-equivalent with the current behavior, not the aspirational target matrix.
|
||||
$this->assertNotContains('clients.view', $grants);
|
||||
$this->assertNotContains('clients.create', $grants);
|
||||
$this->assertNotContains('clients.edit', $grants);
|
||||
$this->assertNotContains('clients.delete', $grants);
|
||||
// Renaming a project's labels is a manager-in-project capability (project '*' grant).
|
||||
$this->assertContains('projectsettings.labels.manage', $grants);
|
||||
$this->assertNotContains('company.settings.view', $grants);
|
||||
$this->assertNotContains('company.settings.edit', $grants);
|
||||
}
|
||||
|
||||
public function test_admin_gets_company_wide_including_company_settings(): void
|
||||
{
|
||||
$grants = $this->grantsFor('admin');
|
||||
|
||||
$this->assertContains('users.view', $grants);
|
||||
$this->assertContains('users.create', $grants);
|
||||
$this->assertContains('users.edit', $grants);
|
||||
$this->assertContains('users.delete', $grants);
|
||||
$this->assertContains('users.import', $grants); // full user management
|
||||
$this->assertContains('clients.view', $grants);
|
||||
$this->assertContains('clients.create', $grants);
|
||||
$this->assertContains('clients.edit', $grants);
|
||||
$this->assertContains('clients.delete', $grants); // full client management
|
||||
$this->assertContains('projectsettings.labels.manage', $grants);
|
||||
$this->assertContains('comments.moderate', $grants);
|
||||
$this->assertContains('tickets.delete', $grants);
|
||||
$this->assertContains('calendar.manage', $grants); // cross-user calendar override (admin+)
|
||||
// Per policy (admin views + edits company settings), admins hold both company.settings
|
||||
// keys via an explicit grant alongside the wildcard-with-exclude rule.
|
||||
$this->assertContains('company.settings.view', $grants);
|
||||
$this->assertContains('company.settings.edit', $grants);
|
||||
}
|
||||
|
||||
public function test_owner_gets_everything_including_company_settings(): void
|
||||
{
|
||||
$grants = $this->grantsFor('owner');
|
||||
|
||||
$this->assertContains('company.settings.view', $grants);
|
||||
$this->assertContains('company.settings.edit', $grants);
|
||||
$this->assertContains('projectsettings.labels.manage', $grants);
|
||||
$this->assertContains('clients.delete', $grants);
|
||||
$this->assertContains('users.view', $grants);
|
||||
$this->assertContains('comments.moderate', $grants);
|
||||
$this->assertContains('tickets.delete', $grants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: a rule that combines an explicit `keys` allow-list with an `exclude` list must
|
||||
* still honor the exclude. matches() previously returned early for `keys` rules and bypassed
|
||||
* the exclude entirely, which could over-grant an excluded permission.
|
||||
*/
|
||||
public function test_keys_rule_still_honors_exclude(): void
|
||||
{
|
||||
$matches = new \ReflectionMethod(DefaultRolePermissions::class, 'matches');
|
||||
$matches->setAccessible(true);
|
||||
|
||||
$rule = [
|
||||
'scope' => 'global',
|
||||
'keys' => ['company.settings.view', 'company.settings.edit'],
|
||||
'exclude' => ['company.settings.edit'],
|
||||
];
|
||||
|
||||
$included = new Permission('company.settings.view', 'View', false);
|
||||
$excluded = new Permission('company.settings.edit', 'Edit', false);
|
||||
|
||||
$this->assertTrue($matches->invoke(null, $included, $rule), 'A keys-listed, non-excluded permission still matches');
|
||||
$this->assertFalse($matches->invoke(null, $excluded, $rule), 'A keys-listed permission that is also excluded must NOT match');
|
||||
}
|
||||
}
|
||||
206
tests/Unit/app/Core/Auth/Permissions/PermissionEnforcerTest.php
Normal file
206
tests/Unit/app/Core/Auth/Permissions/PermissionEnforcerTest.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Core\Auth\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
|
||||
/**
|
||||
* Verifies the PermissionEnforcer resolves the project scope correctly for each
|
||||
* RequiresPermission mode: entityScoped defers (the method self-authorizes its loaded entity),
|
||||
* global checks the company-wide role, projectIdParam reads the named request param, and the
|
||||
* default falls back to the session project. A method with no attribute is a complete no-op.
|
||||
*/
|
||||
class PermissionEnforcerTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Build an enforcer whose engine records every currentUserCan(...) call into $calls and
|
||||
* answers $allow, so we can assert exactly what the enforcer asked the engine.
|
||||
*
|
||||
* @param array<int, array{key: string, projectId: ?int, forceGlobal: bool}> $calls
|
||||
*/
|
||||
private function spyEnforcer(array &$calls, bool $allow = true): PermissionEnforcer
|
||||
{
|
||||
$permissions = $this->make(PermissionService::class, [
|
||||
'currentUserCan' => function (string $key, ?int $projectId = null, ?bool $forceGlobal = false) use (&$calls, $allow): bool {
|
||||
$calls[] = ['key' => $key, 'projectId' => $projectId, 'forceGlobal' => (bool) $forceGlobal];
|
||||
|
||||
return $allow;
|
||||
},
|
||||
]);
|
||||
|
||||
return new PermissionEnforcer($permissions);
|
||||
}
|
||||
|
||||
public function test_entity_scoped_defers_and_never_calls_the_engine(): void
|
||||
{
|
||||
// entityScoped methods authorize their loaded entity's project in their own body, so the
|
||||
// enforcer must not run a check here (it can't see the entity, would use the wrong
|
||||
// project). Even a denying engine must produce no call and no throw.
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: false);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'entityScopedAction', ['id' => 5]);
|
||||
|
||||
$this->assertSame([], $calls, 'entityScoped should defer to the in-method authorize()');
|
||||
}
|
||||
|
||||
public function test_global_checks_company_role_not_a_project(): void
|
||||
{
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'globalAction', []);
|
||||
|
||||
$this->assertSame([['key' => 'users.create', 'projectId' => null, 'forceGlobal' => true]], $calls);
|
||||
}
|
||||
|
||||
public function test_project_id_param_is_read_from_the_named_argument(): void
|
||||
{
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', ['projectId' => 42]);
|
||||
|
||||
$this->assertSame([['key' => 'tickets.view', 'projectId' => 42, 'forceGlobal' => false]], $calls);
|
||||
}
|
||||
|
||||
public function test_default_falls_back_to_the_session_project(): void
|
||||
{
|
||||
session(['currentProject' => 7]);
|
||||
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'sessionAction', []);
|
||||
|
||||
$this->assertSame([['key' => 'tickets.view', 'projectId' => 7, 'forceGlobal' => false]], $calls);
|
||||
}
|
||||
|
||||
public function test_unannotated_method_is_a_noop(): void
|
||||
{
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: false);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'plainAction', []);
|
||||
|
||||
$this->assertSame([], $calls);
|
||||
}
|
||||
|
||||
public function test_mandatory_project_param_absent_fails_closed(): void
|
||||
{
|
||||
// paramAction declares projectIdParam:'projectId' and types it `int` (no default) — the
|
||||
// project is mandatory. With it absent, the enforcer must NOT fall back to the session
|
||||
// project (which would authorize the wrong project); it denies without consulting the
|
||||
// engine. allow:true proves the denial comes from the unresolved-project path, not a
|
||||
// negative engine answer.
|
||||
config(['permissions.enforce' => true]);
|
||||
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: true);
|
||||
|
||||
$threw = false;
|
||||
try {
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', []);
|
||||
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
|
||||
$threw = true;
|
||||
}
|
||||
|
||||
$this->assertTrue($threw, 'an unresolvable mandatory project param must deny');
|
||||
$this->assertSame([], $calls, 'the engine must not be consulted when the project is unresolvable');
|
||||
}
|
||||
|
||||
public function test_mandatory_project_param_explicit_null_fails_closed(): void
|
||||
{
|
||||
// isset() was the original bug: it is false for an explicit null, so a null projectId
|
||||
// silently fell through to the session project. A mandatory param passed null now denies.
|
||||
config(['permissions.enforce' => true]);
|
||||
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: true);
|
||||
|
||||
$threw = false;
|
||||
try {
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', ['projectId' => null]);
|
||||
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
|
||||
$threw = true;
|
||||
}
|
||||
|
||||
$this->assertTrue($threw, 'an explicit-null mandatory project param must deny');
|
||||
$this->assertSame([], $calls);
|
||||
}
|
||||
|
||||
public function test_invalid_project_param_is_treated_as_unresolved(): void
|
||||
{
|
||||
// A bare (int) cast would mis-resolve every one of these: [7] (array) → 1, '-5' → -5,
|
||||
// '7abc' → 7, and an out-of-range digit string → PHP_INT_MAX. None name a real project,
|
||||
// so each must be unresolved → deny for a mandatory param, never silently coerced.
|
||||
config(['permissions.enforce' => true]);
|
||||
|
||||
$invalid = [
|
||||
['projectId' => [7]], // non-scalar
|
||||
['projectId' => '-5'], // negative
|
||||
['projectId' => '7abc'], // non-numeric
|
||||
['projectId' => '999999999999999999999999'], // overflows the platform int range
|
||||
];
|
||||
|
||||
foreach ($invalid as $params) {
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls, allow: true);
|
||||
|
||||
$threw = false;
|
||||
try {
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'paramAction', $params);
|
||||
} catch (\Leantime\Core\Exceptions\AuthorizationException) {
|
||||
$threw = true;
|
||||
}
|
||||
|
||||
$this->assertTrue($threw, 'non-positive-integer project param must be unresolved → deny: '.json_encode($params));
|
||||
$this->assertSame([], $calls);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_optional_project_param_keeps_the_session_fallback(): void
|
||||
{
|
||||
// optionalParamAction defaults projectId to null ("current project"), so an absent value
|
||||
// is legitimate — the enforcer authorizes against the session project, exactly as the
|
||||
// method itself will operate. This is what makes the poll/dashboard endpoints keep working.
|
||||
session(['currentProject' => 7]);
|
||||
|
||||
$calls = [];
|
||||
$enforcer = $this->spyEnforcer($calls);
|
||||
|
||||
$enforcer->enforce(PermissionEnforcerFixture::class, 'optionalParamAction', []);
|
||||
|
||||
$this->assertSame([['key' => 'tickets.view', 'projectId' => 7, 'forceGlobal' => false]], $calls);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture exercising each RequiresPermission resolution mode. Bodies are intentionally empty —
|
||||
* only the attributes matter to the enforcer.
|
||||
*/
|
||||
class PermissionEnforcerFixture
|
||||
{
|
||||
#[RequiresPermission('tickets.edit', entityScoped: true)]
|
||||
public function entityScopedAction(int $id): void {}
|
||||
|
||||
#[RequiresPermission('users.create', global: true)]
|
||||
public function globalAction(): void {}
|
||||
|
||||
#[RequiresPermission('tickets.view', projectIdParam: 'projectId')]
|
||||
public function paramAction(int $projectId): void {}
|
||||
|
||||
// Same attribute, but the project param is OPTIONAL (defaults to null) — "current project"
|
||||
// semantics. An absent/null value must keep the session fallback, not deny.
|
||||
#[RequiresPermission('tickets.view', projectIdParam: 'projectId')]
|
||||
public function optionalParamAction(?int $projectId = null): void {}
|
||||
|
||||
#[RequiresPermission('tickets.view')]
|
||||
public function sessionAction(): void {}
|
||||
|
||||
public function plainAction(): void {}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Controller;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for stale route-cache entries. Routes resolved by the
|
||||
* Frontcontroller are cached across requests in the installation store and can
|
||||
* outlive a deploy: a controller whose run() was replaced by get()/post() left
|
||||
* a cached ['method' => 'run'] entry behind, and callAction('run') then hit
|
||||
* __call() and produced a 500 (seen in production on /calendar/showMyCalendar
|
||||
* and /timesheets/showMy). A cached entry must only be trusted if its class and
|
||||
* method still exist; otherwise it gets dropped and the route re-resolved.
|
||||
*/
|
||||
class FrontcontrollerRouteCacheTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// The cached-route read path is only taken when debug is off (writes happen
|
||||
// regardless), so the validation under test requires debug to be disabled.
|
||||
config(['debug' => false]);
|
||||
}
|
||||
|
||||
private function frontcontroller(): Frontcontroller
|
||||
{
|
||||
// Built by hand: container resolution would pull in the real
|
||||
// PermissionEnforcer, which needs a database connection.
|
||||
return new Frontcontroller(
|
||||
IncomingRequest::create('/calendar/showMyCalendar', 'GET'),
|
||||
$this->createMock(PermissionEnforcer::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function cacheKey(string $module, string $action, string $method): string
|
||||
{
|
||||
return 'routes.'.$module.'.Controllers.'.$action.'.'.$method;
|
||||
}
|
||||
|
||||
public function test_stale_cached_method_is_dropped_and_route_reresolved(): void
|
||||
{
|
||||
// Simulate a pre-deploy cache entry pointing at the removed run() method.
|
||||
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
|
||||
Cache::store('installation')->set($key, [
|
||||
'class' => \Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class,
|
||||
'method' => 'run',
|
||||
]);
|
||||
|
||||
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
|
||||
|
||||
$this->assertSame('get', $result['method']);
|
||||
$this->assertSame(\Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class, $result['class']);
|
||||
|
||||
// The stale entry must have been replaced with the fresh resolution.
|
||||
$this->assertSame($result, Cache::store('installation')->get($key));
|
||||
}
|
||||
|
||||
public function test_cached_entry_with_missing_class_is_dropped(): void
|
||||
{
|
||||
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
|
||||
Cache::store('installation')->set($key, [
|
||||
'class' => 'Leantime\\Domain\\Calendar\\Controllers\\NoLongerExists',
|
||||
'method' => 'get',
|
||||
]);
|
||||
|
||||
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
|
||||
|
||||
$this->assertSame(\Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class, $result['class']);
|
||||
$this->assertSame('get', $result['method']);
|
||||
}
|
||||
|
||||
public function test_valid_cached_entry_is_returned_as_is(): void
|
||||
{
|
||||
$key = $this->cacheKey('Calendar', 'ShowMyCalendar', 'get');
|
||||
$cached = [
|
||||
'class' => \Leantime\Domain\Calendar\Controllers\ShowMyCalendar::class,
|
||||
'method' => 'get',
|
||||
];
|
||||
Cache::store('installation')->set($key, $cached);
|
||||
|
||||
$result = $this->frontcontroller()->getValidControllerCall('calendar', 'showMyCalendar', 'get', 'Controllers');
|
||||
|
||||
$this->assertSame($cached, $result);
|
||||
}
|
||||
}
|
||||
395
tests/Unit/app/Core/Events/ClassEventDispatchTest.php
Normal file
395
tests/Unit/app/Core/Events/ClassEventDispatchTest.php
Normal file
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Leantime\Core\Events\Concerns\InteractsWithEvents;
|
||||
use Leantime\Core\Events\Concerns\InteractsWithFilters;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
use Leantime\Core\Events\Contracts\LeantimeFilter;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Fixture event mirroring a migrated domain event: typed payload plus the
|
||||
* `legacyHook: __FUNCTION__` discriminator pattern — each dispatch rebuilds the single
|
||||
* historical name of its emit site (never a static list of all sites).
|
||||
*/
|
||||
class FixtureThingUpdated implements LeantimeEvent
|
||||
{
|
||||
use InteractsWithEvents;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $thingId,
|
||||
private readonly ?string $legacyHook = null,
|
||||
) {}
|
||||
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
if ($this->legacyHook === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['leantime.domain.things.services.things.'.$this->legacyHook.'.thing_updated'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture event without legacy hooks (an event introduced after the class-based system).
|
||||
*/
|
||||
class FixtureThingCreated implements LeantimeEvent
|
||||
{
|
||||
use InteractsWithEvents;
|
||||
|
||||
public function __construct(public readonly int $thingId) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture class-based listener (resolved through the container, handle() receives the
|
||||
* typed event object).
|
||||
*/
|
||||
class FixtureThingListener
|
||||
{
|
||||
public static array $received = [];
|
||||
|
||||
public function handle(FixtureThingUpdated $event): void
|
||||
{
|
||||
self::$received[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture invokable listener (no handle() method) to cover the __invoke fallback for
|
||||
* array-form registrations like [FixtureInvokableListener::class].
|
||||
*/
|
||||
class FixtureInvokableListener
|
||||
{
|
||||
public static ?object $received = null;
|
||||
|
||||
public function __invoke(FixtureThingCreated $event): void
|
||||
{
|
||||
self::$received = $event;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture filter mirroring a migrated domain filter: payload plus typed context.
|
||||
*/
|
||||
class FixtureThingsFilter implements LeantimeFilter
|
||||
{
|
||||
use InteractsWithFilters;
|
||||
|
||||
public function __construct(public array $things, public readonly int $userId) {}
|
||||
|
||||
public function payload(): mixed
|
||||
{
|
||||
return $this->things;
|
||||
}
|
||||
|
||||
public function legacyHooks(): array
|
||||
{
|
||||
return [
|
||||
'leantime.domain.things.services.things.getThings.filterThings',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ClassEventDispatchTest extends TestCase
|
||||
{
|
||||
private array $staticSnapshot = [];
|
||||
|
||||
private const STATIC_PROPS = [
|
||||
'eventRegistry',
|
||||
'filterRegistry',
|
||||
'available_hooks',
|
||||
'patternMatchCache',
|
||||
'compiledPatternCache',
|
||||
'eventRegistryVersion',
|
||||
'filterRegistryVersion',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach (self::STATIC_PROPS as $prop) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$this->staticSnapshot[$prop] = $property->getValue();
|
||||
}
|
||||
|
||||
FixtureThingListener::$received = [];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach ($this->staticSnapshot as $prop => $value) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$property->setValue(null, $value);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* A closure listener registered on the FQCN receives the bare typed event object.
|
||||
*/
|
||||
public function test_fqcn_closure_listener_receives_typed_event_object(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingUpdated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 42);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingUpdated::class, $received);
|
||||
$this->assertSame(42, $received->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A class-string listener registered on the FQCN is container-resolved and its
|
||||
* handle() method receives the typed event object. This is the cacheable
|
||||
* registration style new code should use (no closures).
|
||||
*/
|
||||
public function test_fqcn_class_listener_handle_receives_typed_event_object(): void
|
||||
{
|
||||
EventDispatcher::add_event_listener(FixtureThingUpdated::class, FixtureThingListener::class);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 7);
|
||||
|
||||
$this->assertCount(1, FixtureThingListener::$received);
|
||||
$this->assertSame(7, FixtureThingListener::$received[0]->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* An invokable listener registered in array form ([Class::class], no handle()
|
||||
* method) falls back to __invoke() — same as the string registration form.
|
||||
*/
|
||||
public function test_array_form_invokable_listener_falls_back_to_invoke(): void
|
||||
{
|
||||
FixtureInvokableListener::$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, [FixtureInvokableListener::class]);
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 11);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, FixtureInvokableListener::$received);
|
||||
$this->assertSame(11, FixtureInvokableListener::$received->thingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: a listener registered on the exact historical string name
|
||||
* fires and receives today's array payload (event properties + current_route +
|
||||
* currentEvent) — NOT the event object. Existing plugins keep working unchanged.
|
||||
*/
|
||||
public function test_legacy_string_listener_receives_legacy_array_payload(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(
|
||||
'leantime.domain.things.services.things.updateThing.thing_updated',
|
||||
function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
}
|
||||
);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 42, legacyHook: 'updateThing');
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertSame(42, $received['thingId']);
|
||||
$this->assertSame(
|
||||
'leantime.domain.things.services.things.updateThing.thing_updated',
|
||||
$received['currentEvent']
|
||||
);
|
||||
$this->assertArrayHasKey('current_route', $received);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: plugin wildcard subscriptions (leantime.domain.*.services.*)
|
||||
* match the legacy name of a class-based event — exactly ONCE per dispatch, because
|
||||
* each emit site contributes only its own historical name via the legacyHook
|
||||
* discriminator. Both historical names stay reachable from their respective sites.
|
||||
*/
|
||||
public function test_wildcard_listener_fires_once_per_dispatch_for_legacy_hook(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
|
||||
$this->assertSame(1, $called);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
|
||||
$this->assertSame(2, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: an exact subscriber to one historical site's name does
|
||||
* NOT fire when a different site emits the same logical event — per-site semantics
|
||||
* are preserved through the migration window.
|
||||
*/
|
||||
public function test_exact_legacy_listener_keeps_per_site_semantics(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener(
|
||||
'leantime.domain.things.services.things.patchThing.thing_updated',
|
||||
function () use (&$called) {
|
||||
$called++;
|
||||
}
|
||||
);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
|
||||
$this->assertSame(0, $called);
|
||||
|
||||
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
|
||||
$this->assertSame(1, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wildcard string listeners do NOT accidentally match the FQCN (backslashes and
|
||||
* case don't fit the dotted lowercase patterns).
|
||||
*/
|
||||
public function test_wildcard_listener_does_not_match_fqcn(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 1);
|
||||
|
||||
$this->assertSame(0, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* An event with no legacy hooks only reaches FQCN listeners.
|
||||
*/
|
||||
public function test_event_without_legacy_hooks_fires_fqcn_listener_only(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 9);
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, $received);
|
||||
$this->assertContains(FixtureThingCreated::class, EventDispatcher::get_available_hooks()['events']);
|
||||
}
|
||||
|
||||
/**
|
||||
* FQCN listeners run in priority order, lower number first.
|
||||
*/
|
||||
public function test_fqcn_listeners_run_in_priority_order(): void
|
||||
{
|
||||
$order = [];
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
|
||||
$order[] = 30;
|
||||
}, 30);
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
|
||||
$order[] = 10;
|
||||
}, 10);
|
||||
|
||||
FixtureThingCreated::dispatch(thingId: 1);
|
||||
|
||||
$this->assertSame([10, 30], $order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Class filter: FQCN listeners thread the payload and receive the filter object as
|
||||
* typed context; the final payload is returned.
|
||||
*/
|
||||
public function test_class_filter_threads_payload_through_fqcn_listeners(): void
|
||||
{
|
||||
$receivedFilter = null;
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) use (&$receivedFilter) {
|
||||
$receivedFilter = $filter;
|
||||
$things[] = 'added-by-listener';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
|
||||
|
||||
$this->assertSame(['original', 'added-by-listener'], $result);
|
||||
$this->assertInstanceOf(FixtureThingsFilter::class, $receivedFilter);
|
||||
$this->assertSame(5, $receivedFilter->userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* BACKWARDS COMPATIBILITY: a filter listener on the historical string name receives
|
||||
* today's ($payload, $availableParams) signature — params include the filter's
|
||||
* public properties plus current_route/currentEvent — and its return value threads
|
||||
* into the final result, after FQCN listeners.
|
||||
*/
|
||||
public function test_class_filter_threads_payload_through_legacy_listeners(): void
|
||||
{
|
||||
$receivedParams = null;
|
||||
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) {
|
||||
$things[] = 'fqcn';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
EventDispatcher::add_filter_listener(
|
||||
'leantime.domain.things.services.things.getThings.filterThings',
|
||||
function ($things, $params) use (&$receivedParams) {
|
||||
$receivedParams = $params;
|
||||
$things[] = 'legacy';
|
||||
|
||||
return $things;
|
||||
}
|
||||
);
|
||||
|
||||
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
|
||||
|
||||
// FQCN group runs first, then the legacy group threads its output.
|
||||
$this->assertSame(['original', 'fqcn', 'legacy'], $result);
|
||||
$this->assertSame(5, $receivedParams['userId']);
|
||||
$this->assertArrayHasKey('current_route', $receivedParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter with no listeners at all returns the payload unchanged.
|
||||
*/
|
||||
public function test_class_filter_without_listeners_returns_payload_unchanged(): void
|
||||
{
|
||||
$result = FixtureThingsFilter::dispatch(things: ['untouched'], userId: 1);
|
||||
|
||||
$this->assertSame(['untouched'], $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance apply() ergonomic returns the filtered payload too.
|
||||
*/
|
||||
public function test_class_filter_apply_instance_method(): void
|
||||
{
|
||||
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things) {
|
||||
$things[] = 'applied';
|
||||
|
||||
return $things;
|
||||
});
|
||||
|
||||
$filter = new FixtureThingsFilter(things: ['a'], userId: 2);
|
||||
|
||||
$this->assertSame(['a', 'applied'], $filter->apply());
|
||||
}
|
||||
|
||||
/**
|
||||
* Class events route correctly through Laravel's event() helper / the instance
|
||||
* dispatch() of the Dispatcher interface as well.
|
||||
*/
|
||||
public function test_class_event_routes_through_laravel_event_helper(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
|
||||
$received = $event;
|
||||
});
|
||||
|
||||
event(new FixtureThingCreated(thingId: 3));
|
||||
|
||||
$this->assertInstanceOf(FixtureThingCreated::class, $received);
|
||||
$this->assertSame(3, $received->thingId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Leantime\Core\WorkStructure\Events\StructureRegistered;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Fixture emitter that dispatches through the DispatchesEvents trait exactly like a
|
||||
* domain service does, so the auto-generated event names (lowercased FQCN + method +
|
||||
* raw hook) match the real runtime format.
|
||||
*/
|
||||
class CharacterizationEmitter
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
public function updateThing(): void
|
||||
{
|
||||
self::dispatchEvent('thing_updated', ['thingId' => 7]);
|
||||
}
|
||||
|
||||
public function filterThing(int $payload): mixed
|
||||
{
|
||||
return self::dispatchFilter('thing_filter', $payload, ['mode' => 'strict']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Characterization tests locking the CURRENT EventDispatcher behavior before the
|
||||
* class-based event bridge is added. These tests document the string-event contract
|
||||
* that existing plugins rely on; they must keep passing unchanged.
|
||||
*/
|
||||
class EventDispatcherCharacterizationTest extends TestCase
|
||||
{
|
||||
private array $staticSnapshot = [];
|
||||
|
||||
private const STATIC_PROPS = [
|
||||
'eventRegistry',
|
||||
'filterRegistry',
|
||||
'available_hooks',
|
||||
'patternMatchCache',
|
||||
'compiledPatternCache',
|
||||
'eventRegistryVersion',
|
||||
'filterRegistryVersion',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach (self::STATIC_PROPS as $prop) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$this->staticSnapshot[$prop] = $property->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(EventDispatcher::class);
|
||||
foreach ($this->staticSnapshot as $prop => $value) {
|
||||
$property = $reflection->getProperty($prop);
|
||||
$property->setValue(null, $value);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* The DispatchesEvents trait builds the full event name as
|
||||
* strtolower(FQCN with \ -> .) + '.' + emitting method + '.' + raw hook.
|
||||
* Plugins subscribe to exactly these strings — the format must not drift.
|
||||
*/
|
||||
public function test_trait_builds_full_event_name_from_class_and_method(): void
|
||||
{
|
||||
(new CharacterizationEmitter)->updateThing();
|
||||
|
||||
$this->assertContains(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
EventDispatcher::get_available_hooks()['events']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A listener registered on the full string name receives a SINGLE array argument:
|
||||
* the dispatched payload merged with current_route and currentEvent.
|
||||
*/
|
||||
public function test_string_event_listener_receives_define_params_array(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
}
|
||||
);
|
||||
|
||||
(new CharacterizationEmitter)->updateThing();
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertSame(7, $received['thingId']);
|
||||
$this->assertSame(
|
||||
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
|
||||
$received['currentEvent']
|
||||
);
|
||||
$this->assertArrayHasKey('current_route', $received);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter listeners receive ($payload, $availableParams) where availableParams is the
|
||||
* emitter-provided context merged with current_route/currentEvent, and the payload is
|
||||
* threaded through listeners in priority order (lower priority number runs first).
|
||||
*/
|
||||
public function test_filter_threads_payload_in_priority_order_and_passes_params(): void
|
||||
{
|
||||
$fullName = 'unit.app.core.events.characterizationemitter.filterThing.thing_filter';
|
||||
$receivedParams = null;
|
||||
|
||||
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) use (&$receivedParams) {
|
||||
$receivedParams = $params;
|
||||
|
||||
return $payload + 1;
|
||||
}, 20);
|
||||
|
||||
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) {
|
||||
return $payload * 2;
|
||||
}, 10);
|
||||
|
||||
$result = (new CharacterizationEmitter)->filterThing(5);
|
||||
|
||||
// priority 10 runs first: 5 * 2 = 10, then priority 20: 10 + 1 = 11
|
||||
$this->assertSame(11, $result);
|
||||
$this->assertSame('strict', $receivedParams['mode']);
|
||||
$this->assertSame($fullName, $receivedParams['currentEvent']);
|
||||
$this->assertArrayHasKey('current_route', $receivedParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins rely on wildcard subscriptions (e.g. leantime.domain.*.services.*) matching
|
||||
* the auto-generated full names. The * wildcard must keep matching.
|
||||
*/
|
||||
public function test_wildcard_listener_matches_full_event_name(): void
|
||||
{
|
||||
$called = 0;
|
||||
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
|
||||
$called++;
|
||||
});
|
||||
|
||||
EventDispatcher::dispatch_event('leantime.domain.faux.services.faux.doIt.did_it', ['x' => 1], '');
|
||||
|
||||
$this->assertSame(1, $called);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listeners for one hook run in priority order, lower number first.
|
||||
*/
|
||||
public function test_event_listeners_run_in_priority_order(): void
|
||||
{
|
||||
$order = [];
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 30;
|
||||
}, 30);
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 10;
|
||||
}, 10);
|
||||
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
|
||||
$order[] = 20;
|
||||
}, 20);
|
||||
|
||||
EventDispatcher::dispatch_event('char.priority.event', [], '');
|
||||
|
||||
$this->assertSame([10, 20, 30], $order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current behavior for plain object events (Laravel Dispatchable path, e.g. the
|
||||
* WorkStructure events): the object resolves to its FQCN as the listener name and a
|
||||
* 'leantime' source listener receives the defineParams array with the object at [0].
|
||||
*/
|
||||
public function test_plain_object_event_fires_fqcn_string_listener(): void
|
||||
{
|
||||
$received = null;
|
||||
EventDispatcher::add_event_listener(StructureRegistered::class, function ($params) use (&$received) {
|
||||
$received = $params;
|
||||
});
|
||||
|
||||
StructureRegistered::dispatch(1, 'My Structure', 'system');
|
||||
|
||||
$this->assertIsArray($received);
|
||||
$this->assertInstanceOf(StructureRegistered::class, $received[0]);
|
||||
$this->assertSame(1, $received[0]->structureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pattern-match cache is invalidated when a listener is added (version counter),
|
||||
* so listeners registered after a first dispatch still fire on later dispatches.
|
||||
*/
|
||||
public function test_pattern_cache_busts_when_listener_added_after_dispatch(): void
|
||||
{
|
||||
$first = 0;
|
||||
$second = 0;
|
||||
|
||||
EventDispatcher::add_event_listener('char.cache.*', function () use (&$first) {
|
||||
$first++;
|
||||
});
|
||||
EventDispatcher::dispatch_event('char.cache.bust', [], '');
|
||||
|
||||
EventDispatcher::add_event_listener('char.cache.*', function () use (&$second) {
|
||||
$second++;
|
||||
});
|
||||
EventDispatcher::dispatch_event('char.cache.bust', [], '');
|
||||
|
||||
$this->assertSame(2, $first);
|
||||
$this->assertSame(1, $second);
|
||||
}
|
||||
}
|
||||
104
tests/Unit/app/Core/Events/EventVocabularyTest.php
Normal file
104
tests/Unit/app/Core/Events/EventVocabularyTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Codeception\Test\Unit;
|
||||
use Leantime\Core\Events\Contracts\LeantimeEvent;
|
||||
use Leantime\Core\Events\Contracts\LeantimeFilter;
|
||||
use Leantime\Core\Events\EventVerb;
|
||||
|
||||
/**
|
||||
* Enforces the shared event vocabulary across all domains:
|
||||
*
|
||||
* - event classes are named {Entity}{Verb} with the verb from the central EventVerb
|
||||
* enum (TicketCreated, MilestoneDeleted — never TicketChanged/TicketEdited)
|
||||
* - filter classes are named {Thing}Filter (TodoWidgetTasksFilter)
|
||||
*
|
||||
* Scans every class in app/Domain/* /Events and app/Core/* /Events that implements
|
||||
* LeantimeEvent or LeantimeFilter. Failing this test means a synonym crept in — use an
|
||||
* existing verb or (rarely) add one to EventVerb.
|
||||
*/
|
||||
class EventVocabularyTest extends Unit
|
||||
{
|
||||
public function test_event_class_names_end_with_central_vocabulary_verb(): void
|
||||
{
|
||||
$discovered = $this->discoverEventClasses();
|
||||
|
||||
// Guard against a vacuous pass: if discovery silently finds nothing (e.g. a
|
||||
// broken base path), the loop below would assert nothing. The pilot ships ten
|
||||
// contract classes in Tickets, so discovery must find them.
|
||||
$this->assertContains(
|
||||
\Leantime\Domain\Tickets\Events\TicketUpdated::class,
|
||||
$discovered,
|
||||
'Event class discovery found nothing — the vocabulary check would pass vacuously.'
|
||||
);
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($discovered as $class) {
|
||||
$implements = class_implements($class);
|
||||
$shortName = substr($class, strrpos($class, '\\') + 1);
|
||||
|
||||
if (in_array(LeantimeFilter::class, $implements, true)) {
|
||||
if (! str_ends_with($shortName, 'Filter')) {
|
||||
$violations[] = "$class — filter classes must be named {Thing}Filter";
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array(LeantimeEvent::class, $implements, true)) {
|
||||
$endsWithVerb = false;
|
||||
foreach (EventVerb::cases() as $verb) {
|
||||
if (str_ends_with($shortName, $verb->name)) {
|
||||
$endsWithVerb = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $endsWithVerb) {
|
||||
$violations[] = "$class — event classes must be named {Entity}{Verb} with a verb from EventVerb";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $violations, "Event vocabulary violations:\n".implode("\n", $violations));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all classes in Domain and Core Events/ folders that implement one of the
|
||||
* class-based hook contracts.
|
||||
*
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
private function discoverEventClasses(): array
|
||||
{
|
||||
// Anchor on the canonical app-root constant rather than a brittle relative
|
||||
// dirname() hop, so the scan can't silently miss the Events folders.
|
||||
$appRoot = defined('APP_ROOT') ? APP_ROOT : dirname(__DIR__, 5);
|
||||
|
||||
$files = array_merge(
|
||||
glob($appRoot.'/app/Domain/*/Events/*.php') ?: [],
|
||||
glob($appRoot.'/app/Core/*/Events/*.php') ?: [],
|
||||
);
|
||||
|
||||
$classes = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$relative = str_replace([$appRoot.'/app/', '/', '.php'], ['', '\\', ''], $file);
|
||||
$class = 'Leantime\\'.$relative;
|
||||
|
||||
if (! class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$implements = class_implements($class) ?: [];
|
||||
if (in_array(LeantimeEvent::class, $implements, true)
|
||||
|| in_array(LeantimeFilter::class, $implements, true)) {
|
||||
$classes[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
return $classes;
|
||||
}
|
||||
}
|
||||
69
tests/Unit/app/Core/Events/EventsTest.php
Normal file
69
tests/Unit/app/Core/Events/EventsTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Events;
|
||||
|
||||
use Codeception\Test\Unit;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
|
||||
class EventsTest extends Unit
|
||||
{
|
||||
/**
|
||||
* This test will check the dispatch_event method of the EventDispatcher class.
|
||||
* It will dispatch an event and assert if it is added to the available_hooks array.
|
||||
*/
|
||||
public function test_dispatch_event()
|
||||
{
|
||||
$eventName = 'test.event.name';
|
||||
$payload = ['testKey' => 'testValue'];
|
||||
$context = 'testContext';
|
||||
|
||||
// Dispatch event
|
||||
EventDispatcher::dispatch_event($eventName, $payload, $context);
|
||||
|
||||
// Get all available hooks
|
||||
$available_hooks = EventDispatcher::get_available_hooks();
|
||||
|
||||
// Test that the dispatched event has been registered in available_hooks
|
||||
$this->assertContains("$context.$eventName", $available_hooks['events']);
|
||||
}
|
||||
|
||||
/**
|
||||
* This test will check the findEventListeners method of the EventDispatcher class.
|
||||
*/
|
||||
public function test_find_event_listeners()
|
||||
{
|
||||
$eventName = 'test.event.name';
|
||||
$listenerName = 'test.listener';
|
||||
$payload = ['testKey' => 'testValue'];
|
||||
$context = 'testContext';
|
||||
$eventListeners = [$listenerName => [$payload]];
|
||||
|
||||
EventDispatcher::add_event_listener($listenerName, function () {}, 10);
|
||||
// Test that the event listener has been found
|
||||
$this->assertEquals([$payload], EventDispatcher::findEventListeners($listenerName, $eventListeners));
|
||||
}
|
||||
|
||||
/**
|
||||
* This test will check the get_registries method of the EventDispatcher class.
|
||||
* It will add new event listener and a new filter listener and check both listeners
|
||||
* are in the registry arrays.
|
||||
*/
|
||||
public function test_get_registries()
|
||||
{
|
||||
$eventName = 'event.test.name';
|
||||
$filterName = 'filter.test.name';
|
||||
|
||||
// Add an event listener
|
||||
EventDispatcher::add_event_listener($eventName, function () {}, 10);
|
||||
|
||||
// Add a filter listener
|
||||
EventDispatcher::add_filter_listener($filterName, function () {}, 10);
|
||||
|
||||
// Get registries
|
||||
$registries = EventDispatcher::get_registries();
|
||||
|
||||
// Check registries
|
||||
$this->assertContains($eventName, $registries['events']);
|
||||
$this->assertContains($filterName, $registries['filters']);
|
||||
}
|
||||
}
|
||||
22
tests/Unit/app/Core/ExampleTest.php
Normal file
22
tests/Unit/app/Core/ExampleTest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Test\Unit;
|
||||
|
||||
class ExampleTest extends \Unit\TestCase
|
||||
{
|
||||
public function test_example(): void
|
||||
{
|
||||
// A simple test to demonstrate the testing process
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_string_operations(): void
|
||||
{
|
||||
// A slightly more complex test
|
||||
$string = 'Hello, Leantime!';
|
||||
$this->assertEquals('Hello, Leantime!', $string);
|
||||
$this->assertStringContainsString('Leantime', $string);
|
||||
$this->assertStringStartsWith('Hello', $string);
|
||||
$this->assertStringEndsWith('!', $string);
|
||||
}
|
||||
}
|
||||
109
tests/Unit/app/Core/Exceptions/LeantimeExceptionTest.php
Normal file
109
tests/Unit/app/Core/Exceptions/LeantimeExceptionTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Exceptions;
|
||||
|
||||
use Leantime\Core\Exceptions\AuthException;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Exceptions\Contracts\LeantimeExceptionInterface;
|
||||
use Leantime\Core\Exceptions\EntityExistsException;
|
||||
use Leantime\Core\Exceptions\InvalidArgumentException;
|
||||
use Leantime\Core\Exceptions\MissingParameterException;
|
||||
use Leantime\Core\Exceptions\NotFoundException;
|
||||
use Leantime\Core\Exceptions\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* The typed exception hierarchy: each carries its own HTTP status (honored by the global
|
||||
* ExceptionHandler because it implements HttpExceptionInterface) and JSON-RPC error code
|
||||
* (read by JsonRpcErrorResponse). Auth + validation are modeled as exceptions per the design.
|
||||
*/
|
||||
class LeantimeExceptionTest extends TestCase
|
||||
{
|
||||
public function test_authorization_exception_carries_403_and_rpc_auth_code(): void
|
||||
{
|
||||
$e = new AuthorizationException;
|
||||
|
||||
$this->assertInstanceOf(LeantimeExceptionInterface::class, $e);
|
||||
$this->assertInstanceOf(HttpExceptionInterface::class, $e);
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
$this->assertSame(-32001, $e->getRpcCode());
|
||||
$this->assertSame([], $e->getErrorData());
|
||||
$this->assertNotSame('', $e->getClientMessage());
|
||||
}
|
||||
|
||||
public function test_not_found_exception_carries_404(): void
|
||||
{
|
||||
$e = new NotFoundException;
|
||||
|
||||
$this->assertSame(404, $e->getStatusCode());
|
||||
$this->assertSame(-32002, $e->getRpcCode());
|
||||
}
|
||||
|
||||
public function test_validation_exception_carries_422_field_errors_and_invalid_params_code(): void
|
||||
{
|
||||
$errors = ['headline' => ['The headline is required.']];
|
||||
$e = ValidationException::withMessages($errors);
|
||||
|
||||
$this->assertSame(422, $e->getStatusCode());
|
||||
$this->assertSame(-32602, $e->getRpcCode());
|
||||
$this->assertSame($errors, $e->getErrorData());
|
||||
}
|
||||
|
||||
public function test_validate_bridge_returns_validated_data_on_success(): void
|
||||
{
|
||||
$validated = ValidationException::validate(
|
||||
['name' => 'Acme', 'extra' => 'ignored'],
|
||||
['name' => 'required|string'],
|
||||
);
|
||||
|
||||
// validated() returns only the validated keys.
|
||||
$this->assertSame(['name' => 'Acme'], $validated);
|
||||
}
|
||||
|
||||
public function test_validate_bridge_throws_leantime_type_with_field_errors_on_failure(): void
|
||||
{
|
||||
try {
|
||||
ValidationException::validate(['name' => ''], ['name' => 'required']);
|
||||
$this->fail('Expected a ValidationException to be thrown.');
|
||||
} catch (ValidationException $e) {
|
||||
$this->assertArrayHasKey('name', $e->getErrorData());
|
||||
$this->assertSame(-32602, $e->getRpcCode());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_retrofitted_exceptions_expose_http_status_and_rpc_code(): void
|
||||
{
|
||||
$this->assertSame(409, (new EntityExistsException)->getStatusCode());
|
||||
$this->assertSame(-32005, (new EntityExistsException)->getRpcCode());
|
||||
|
||||
$this->assertSame(422, (new InvalidArgumentException)->getStatusCode());
|
||||
$this->assertSame(-32602, (new InvalidArgumentException)->getRpcCode());
|
||||
|
||||
$missing = new MissingParameterException('x missing');
|
||||
$this->assertSame(422, $missing->getStatusCode());
|
||||
$this->assertSame(-32602, $missing->getRpcCode());
|
||||
$this->assertInstanceOf(LeantimeExceptionInterface::class, $missing);
|
||||
}
|
||||
|
||||
public function test_retrofitted_exception_preserves_legacy_get_code(): void
|
||||
{
|
||||
// BC: the HTTP status historically lived in getCode(); keep it readable there too.
|
||||
$this->assertSame(409, (new EntityExistsException('dupe'))->getCode());
|
||||
}
|
||||
|
||||
public function test_auth_exception_is_a_deprecated_authorization_alias(): void
|
||||
{
|
||||
$e = new AuthException('Invalid domain user');
|
||||
|
||||
// It IS an AuthorizationException (a deprecated alias, not a second auth exception),
|
||||
// so it keeps the 403 status + -32001 rpc code while the AdvancedAuth plugin and
|
||||
// external installs that still throw the old class name keep working.
|
||||
$this->assertInstanceOf(AuthorizationException::class, $e);
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
$this->assertSame(-32001, $e->getRpcCode());
|
||||
// Legacy ($message, $code) signature preserved, including getCode().
|
||||
$this->assertSame('Invalid domain user', $e->getMessage());
|
||||
$this->assertSame(403, $e->getCode());
|
||||
}
|
||||
}
|
||||
408
tests/Unit/app/Core/Files/FileManagerTest.php
Normal file
408
tests/Unit/app/Core/Files/FileManagerTest.php
Normal file
@@ -0,0 +1,408 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Core\Files;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Files\Exceptions\FileValidationException;
|
||||
use Leantime\Core\Files\FileManager;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Unit\TestCase;
|
||||
|
||||
class FileManagerTest extends TestCase
|
||||
{
|
||||
private $filesystemManager;
|
||||
|
||||
private $config;
|
||||
|
||||
private $fileManager;
|
||||
|
||||
private $storage;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Set up session values needed for DateTimeHelper (used by dtHelper())
|
||||
session(['usersettings.timezone' => 'UTC']);
|
||||
session(['usersettings.language' => 'en-US']);
|
||||
session(['usersettings.date_format' => 'Y-m-d']);
|
||||
session(['usersettings.time_format' => 'H:i']);
|
||||
|
||||
// Mock Environment and bind to container for dtHelper()
|
||||
$envMock = $this->createMock(Environment::class);
|
||||
$envMock->defaultTimezone = 'UTC';
|
||||
$envMock->language = 'en-US';
|
||||
app()->instance(Environment::class, $envMock);
|
||||
|
||||
// Mock Language and bind to container
|
||||
$langMock = $this->createMock(Language::class);
|
||||
$langMock->method('__')->willReturnCallback(function ($index) {
|
||||
$map = [
|
||||
'language.dateformat' => 'Y-m-d',
|
||||
'language.timeformat' => 'H:i',
|
||||
];
|
||||
|
||||
return $map[$index] ?? $index;
|
||||
});
|
||||
app()->instance(Language::class, $langMock);
|
||||
|
||||
// Register CarbonMacros for date parsing
|
||||
CarbonImmutable::mixin(new CarbonMacros('UTC', 'en-US', 'Y-m-d', 'H:i'));
|
||||
|
||||
// Mock the FilesystemManager
|
||||
$this->filesystemManager = $this->createMock(FilesystemManager::class);
|
||||
|
||||
// Mock the Environment
|
||||
$this->config = $this->createMock(Environment::class);
|
||||
|
||||
// Mock the storage disk
|
||||
$this->storage = $this->createMock(FilesystemAdapter::class);
|
||||
|
||||
// Setup the FileManager with mocked dependencies
|
||||
$this->fileManager = new FileManager(
|
||||
$this->filesystemManager,
|
||||
$this->config
|
||||
);
|
||||
|
||||
// Create a test file in userfiles directory
|
||||
$testDir = base_path('userfiles/test');
|
||||
if (! is_dir($testDir)) {
|
||||
mkdir($testDir, 0777, true);
|
||||
}
|
||||
file_put_contents($testDir.'/test.txt', 'test content');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up test file
|
||||
@unlink(base_path('userfiles/test/test.txt'));
|
||||
@rmdir(base_path('userfiles/test'));
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_upload_file_successfully()
|
||||
{
|
||||
// Mock session data
|
||||
session(['userdata.id' => 123]);
|
||||
|
||||
// Create a mock uploaded file
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(true);
|
||||
$file->method('getError')->willReturn(0);
|
||||
$file->method('getSize')->willReturn(1000); // 1KB
|
||||
$file->method('getClientOriginalName')->willReturn('test-file.txt');
|
||||
$file->method('getClientOriginalExtension')->willReturn('txt');
|
||||
$file->method('getRealPath')->willReturn(base_path('userfiles/test/test.txt'));
|
||||
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
$this->storage->method('mimeType')->willReturn('text/plain');
|
||||
|
||||
// Setup storage to successfully store the file
|
||||
$this->storage->method('put')->willReturn(true);
|
||||
|
||||
// Mock the PHP stream functions
|
||||
$this->storage->method('put')
|
||||
->with($this->anything(), $this->anything(), $this->anything())
|
||||
->willReturn(true);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->upload($file);
|
||||
|
||||
// Assert the result is an array with expected keys
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('fileName', $result);
|
||||
$this->assertArrayHasKey('realName', $result);
|
||||
$this->assertArrayHasKey('extension', $result);
|
||||
$this->assertEquals('test-file.txt', $result['realName']);
|
||||
$this->assertEquals('txt', $result['extension']);
|
||||
}
|
||||
|
||||
public function test_upload_file_with_invalid_file()
|
||||
{
|
||||
// Create a mock uploaded file that is invalid
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(false);
|
||||
$file->method('getErrorMessage')->willReturn('Test error message');
|
||||
|
||||
// // Mock the Log facade
|
||||
// Log::shouldReceive('error')
|
||||
// ->once()
|
||||
// ->with('File upload failed: Invalid file upload attempt: Test error message', ['exception' => new FileValidationException('test'), 'file'=> '']);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->upload($file);
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_upload_file_with_file_too_large()
|
||||
{
|
||||
// Create a mock uploaded file that is too large
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(true);
|
||||
$file->method('getError')->willReturn(0);
|
||||
$file->method('getSize')->willReturn(PHP_INT_MAX); // Very large file
|
||||
|
||||
// // Mock the Log facade
|
||||
// Log::shouldReceive('error')
|
||||
// ->once()
|
||||
// ->with('File upload failed: File size exceeds the maximum allowed size of', ['exception' => new FileValidationException('test'), 'file'=> '']);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->upload($file);
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_get_file_successfully()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to successfully find and read the file
|
||||
$this->storage->expects($this->once())->method('exists')->willReturn(true);
|
||||
$this->storage->method('mimeType')->willReturn('text/plain');
|
||||
$this->storage->method('get')->willReturn('file content');
|
||||
$this->storage->method('size')->willReturn(12);
|
||||
$this->storage->method('lastModified')->willReturn(1700000000);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFile('test.txt', 'original-name.txt');
|
||||
|
||||
// Assert the result is a Response with correct headers
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
$this->assertEquals('file content', $result->getContent());
|
||||
$this->assertEquals('text/plain', $result->headers->get('Content-Type'));
|
||||
$this->assertEquals('12', $result->headers->get('Content-Length'));
|
||||
$this->assertStringContainsString('original-name.txt', $result->headers->get('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function test_get_file_not_found()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to not find the file
|
||||
$this->storage->method('exists')->willReturn(false);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFile('non-existent-file.txt', 'original-name.txt');
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_get_file_url_local_storage()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
$this->storage->method('mimeType')->willReturn('text/plain');
|
||||
|
||||
// Setup storage to successfully find the file and return a URL
|
||||
$this->storage->method('exists')->willReturn(true);
|
||||
$this->storage->method('url')->willReturn('http://example.com/files/test.txt');
|
||||
|
||||
// Configure cache behavior
|
||||
$this->config->method('get')->willReturn(true);
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->andReturn('http://example.com/files/test.txt');
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFileUrl('test.txt');
|
||||
|
||||
// Assert the result is the expected URL
|
||||
$this->assertEquals('http://example.com/files/test.txt', $result);
|
||||
}
|
||||
|
||||
public function test_get_file_url_s3_storage()
|
||||
{
|
||||
// Configure environment to use S3
|
||||
$this->config->useS3 = true;
|
||||
$this->config->method('get')->willReturn(60);
|
||||
|
||||
// Setup storage to return mime type
|
||||
$this->storage->method('mimeType')->willReturn('text/plain');
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('disk')->with('s3')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to successfully find the file and return a temporary URL
|
||||
$this->storage->method('exists')->willReturn(true);
|
||||
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
|
||||
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
|
||||
$this->storage->method('temporaryUrl')->willReturn('https://s3.example.com/files/test.txt?signature=abc123');
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFileUrl('test.txt', 's3');
|
||||
|
||||
// Assert the result is the expected URL
|
||||
$this->assertEquals('https://s3.example.com/files/test.txt?signature=abc123', $result);
|
||||
}
|
||||
|
||||
public function test_get_file_url_file_not_found()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to not find the file
|
||||
$this->storage->method('exists')->willReturn(false);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->getFileUrl('non-existent-file.txt');
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_delete_file_successfully()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to successfully find and delete the file
|
||||
$this->storage->method('exists')->willReturn(true);
|
||||
$this->storage->method('delete')->willReturn(true);
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->deleteFile('test.txt');
|
||||
|
||||
// Assert the result is true
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function test_delete_file_not_found()
|
||||
{
|
||||
// Setup filesystem manager to return our mocked storage
|
||||
$this->filesystemManager->method('getDefaultDriver')->willReturn('local');
|
||||
$this->filesystemManager->method('disk')->with('local')->willReturn($this->storage);
|
||||
|
||||
// Setup storage to not find the file
|
||||
$this->storage->method('exists')->willReturn(false);
|
||||
|
||||
// Mock the Log facade
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('File not found for deletion: test.txt on disk local');
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->deleteFile('test.txt');
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_delete_file_with_empty_filename()
|
||||
{
|
||||
// Mock the Log facade
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('Attempted to delete a file with empty filename');
|
||||
|
||||
// Execute the method under test
|
||||
$result = $this->fileManager->deleteFile('');
|
||||
|
||||
// Assert the result is false
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function test_get_maximum_file_upload_size()
|
||||
{
|
||||
// Test the static method
|
||||
$result = FileManager::getMaximumFileUploadSize();
|
||||
|
||||
// Assert the result is an integer
|
||||
$this->assertIsInt($result);
|
||||
|
||||
// The result should be the minimum of post_max_size and upload_max_filesize
|
||||
$expected = min(
|
||||
$this->convertPHPSizeToBytes(ini_get('post_max_size')),
|
||||
$this->convertPHPSizeToBytes(ini_get('upload_max_filesize'))
|
||||
);
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to convert PHP size strings to bytes
|
||||
*/
|
||||
private function convertPHPSizeToBytes(string $sSize): int
|
||||
{
|
||||
$sSuffix = strtoupper(substr($sSize, -1));
|
||||
if (! in_array($sSuffix, ['P', 'T', 'G', 'M', 'K'])) {
|
||||
return (int) $sSize;
|
||||
}
|
||||
$iValue = substr($sSize, 0, -1);
|
||||
switch ($sSuffix) {
|
||||
case 'P':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
case 'T':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
case 'G':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
case 'M':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
case 'K':
|
||||
$iValue *= 1024;
|
||||
break;
|
||||
}
|
||||
|
||||
return (int) $iValue;
|
||||
}
|
||||
|
||||
public function test_sanitize_filename()
|
||||
{
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass(FileManager::class);
|
||||
$method = $reflection->getMethod('sanitizeFilename');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Test with a normal filename
|
||||
$result = $method->invoke($this->fileManager, 'test.txt');
|
||||
$this->assertEquals('test.txt', $result);
|
||||
|
||||
// Test with a path
|
||||
$result = $method->invoke($this->fileManager, '/path/to/test.txt');
|
||||
$this->assertEquals('test.txt', $result);
|
||||
|
||||
// Test with special characters
|
||||
$result = $method->invoke($this->fileManager, 'test@file#$.txt');
|
||||
$this->assertEquals('test-file--.txt', $result);
|
||||
|
||||
// Allow chinese characters
|
||||
$result = $method->invoke($this->fileManager, '测试文件.txt');
|
||||
$this->assertEquals('测试文件.txt', $result);
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_cache_hit()
|
||||
{
|
||||
// We already have a test file at userfiles/test/test.txt
|
||||
$testFile = base_path('userfiles/test/test.txt');
|
||||
|
||||
// Verify the test file exists
|
||||
$this->assertFileExists($testFile);
|
||||
|
||||
// Verify content
|
||||
$this->assertEquals('test content', file_get_contents($testFile));
|
||||
}
|
||||
}
|
||||
45
tests/Unit/app/Core/Http/Responses/ImageResponseTest.php
Normal file
45
tests/Unit/app/Core/Http/Responses/ImageResponseTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Http\Responses;
|
||||
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use Leantime\Core\Http\Responses\ImageResponse;
|
||||
use SVG\SVG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the ImageResponse response type used by the domain image controllers
|
||||
* (Users\Controllers\ProfileImage, Projects\Controllers\ProjectImage). It is returned
|
||||
* directly from controllers and converted by Laravel's router via the Responsable contract.
|
||||
*/
|
||||
class ImageResponseTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
public function test_it_is_a_leantime_response(): void
|
||||
{
|
||||
$this->assertInstanceOf(LeantimeResponseInterface::class, new ImageResponse('/tmp/x'));
|
||||
}
|
||||
|
||||
public function test_to_response_renders_svg_with_cache_headers(): void
|
||||
{
|
||||
$svg = $this->make(SVG::class, [
|
||||
'toXMLString' => fn () => '<svg></svg>',
|
||||
]);
|
||||
|
||||
$response = (new ImageResponse($svg))->toResponse(null);
|
||||
|
||||
$this->assertSame('<svg></svg>', $response->getContent());
|
||||
$this->assertSame('image/svg+xml', $response->headers->get('Content-type'));
|
||||
$this->assertStringContainsString('max-age=86400', $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
public function test_to_response_passes_through_an_existing_response(): void
|
||||
{
|
||||
$existing = new Response('already built');
|
||||
|
||||
// An uploaded file is already a built Response; it must be returned untouched.
|
||||
$this->assertSame($existing, (new ImageResponse($existing))->toResponse(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Http\Responses;
|
||||
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Exceptions\ValidationException;
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use Leantime\Core\Http\Responses\JsonRpcErrorResponse;
|
||||
use RuntimeException;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* The JSON-RPC 2.0 error envelope response type and its fromException() bridge — the single
|
||||
* place a thrown exception becomes a client-facing error. Typed Leantime exceptions expose
|
||||
* their own code/message/data; any other throwable is collapsed to a generic server error so
|
||||
* internal detail is never leaked.
|
||||
*/
|
||||
class JsonRpcErrorResponseTest extends TestCase
|
||||
{
|
||||
public function test_it_is_a_leantime_response(): void
|
||||
{
|
||||
$this->assertInstanceOf(LeantimeResponseInterface::class, new JsonRpcErrorResponse(-32000, 'x'));
|
||||
}
|
||||
|
||||
public function test_error_envelope(): void
|
||||
{
|
||||
$response = (new JsonRpcErrorResponse(-32602, 'Invalid params', ['field' => ['bad']], 3))->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame('2.0', $body['jsonrpc']);
|
||||
$this->assertSame(-32602, $body['error']['code']);
|
||||
$this->assertSame('Invalid params', $body['error']['message']);
|
||||
$this->assertSame(['field' => ['bad']], $body['error']['data']);
|
||||
$this->assertSame(3, $body['id']);
|
||||
}
|
||||
|
||||
public function test_from_validation_exception_maps_code_and_field_errors(): void
|
||||
{
|
||||
$errors = ['name' => ['Name is required.']];
|
||||
|
||||
$response = JsonRpcErrorResponse::fromException(ValidationException::withMessages($errors), 5)->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame(-32602, $body['error']['code']);
|
||||
$this->assertSame($errors, $body['error']['data']);
|
||||
$this->assertSame(5, $body['id']);
|
||||
}
|
||||
|
||||
public function test_from_authorization_exception_uses_auth_code(): void
|
||||
{
|
||||
$response = JsonRpcErrorResponse::fromException(new AuthorizationException, 1)->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame(-32001, $body['error']['code']);
|
||||
}
|
||||
|
||||
public function test_unknown_throwable_is_generic_and_does_not_leak(): void
|
||||
{
|
||||
$secret = 'internal-db-dsn-with-password';
|
||||
|
||||
$response = JsonRpcErrorResponse::fromException(new RuntimeException($secret), 9)->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame(-32000, $body['error']['code']);
|
||||
$this->assertSame('Server error', $body['error']['message']);
|
||||
$this->assertNull($body['error']['data']);
|
||||
$this->assertStringNotContainsString($secret, $response->getContent());
|
||||
}
|
||||
}
|
||||
47
tests/Unit/app/Core/Http/Responses/JsonRpcResponseTest.php
Normal file
47
tests/Unit/app/Core/Http/Responses/JsonRpcResponseTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Http\Responses;
|
||||
|
||||
use Leantime\Core\Http\Responses\Contracts\LeantimeResponseInterface;
|
||||
use Leantime\Core\Http\Responses\JsonRpcResponse;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* The JSON-RPC 2.0 success envelope response type. Centralizes the `{jsonrpc, result, id}`
|
||||
* wire format previously inlined in the Jsonrpc controller.
|
||||
*/
|
||||
class JsonRpcResponseTest extends TestCase
|
||||
{
|
||||
public function test_it_is_a_leantime_response(): void
|
||||
{
|
||||
$this->assertInstanceOf(LeantimeResponseInterface::class, new JsonRpcResponse('x', 1));
|
||||
}
|
||||
|
||||
public function test_success_envelope(): void
|
||||
{
|
||||
$response = (new JsonRpcResponse(['ok' => true], 7))->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame('2.0', $body['jsonrpc']);
|
||||
$this->assertSame(['ok' => true], $body['result']);
|
||||
$this->assertSame(7, $body['id']);
|
||||
}
|
||||
|
||||
public function test_scalar_result_and_string_id_pass_through(): void
|
||||
{
|
||||
$response = (new JsonRpcResponse(42, 'abc'))->toResponse(null);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
$this->assertSame(42, $body['result']);
|
||||
$this->assertSame('abc', $body['id']);
|
||||
}
|
||||
|
||||
public function test_notification_without_id_returns_empty_200(): void
|
||||
{
|
||||
// A JSON-RPC notification (no id) MUST NOT be responded to.
|
||||
$response = (new JsonRpcResponse('ignored', null))->toResponse(null);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertSame('', $response->getContent());
|
||||
}
|
||||
}
|
||||
118
tests/Unit/app/Core/Middleware/AuthCheckTest.php
Normal file
118
tests/Unit/app/Core/Middleware/AuthCheckTest.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Middleware\AuthCheck;
|
||||
use Leantime\Domain\Api\Services\Api;
|
||||
use Leantime\Domain\Users\Services\Users;
|
||||
|
||||
/**
|
||||
* Guards the Bearer-auth regression (3.9.0): the permission engine reads the user's id + role from
|
||||
* session('userdata'), which the x-api-key guard establishes as a side effect of getAPIKeyUser()
|
||||
* but the Sanctum (Bearer) guard never did — so every gated @api method denied Bearer requests.
|
||||
* establishApiUserSession() makes the API auth path uniform: any guard that resolves a user has the
|
||||
* same userdata built from the canonical user row, through the same setApiUserSession() builder.
|
||||
*
|
||||
* This tests the middleware's responsibility — resolve the user id, fetch the canonical row, and
|
||||
* hand it to the session builder, idempotently. The builder itself is covered by ApiServiceTest.
|
||||
*/
|
||||
class AuthCheckTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* A request whose user() resolver returns an object with the given id — i.e. a guard (Sanctum
|
||||
* or x-api-key) has authenticated, but userdata has not been established yet.
|
||||
*/
|
||||
private function apiRequestForUser(int $userId): IncomingRequest
|
||||
{
|
||||
$request = IncomingRequest::create('/api/jsonrpc', 'POST');
|
||||
$request->setUserResolver(fn () => (object) ['id' => $userId]);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/** Invoke the protected establishApiUserSession() on a constructor-less AuthCheck. */
|
||||
private function establish(IncomingRequest $request): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
(fn () => $this->establishApiUserSession($request))->call($authCheck);
|
||||
}
|
||||
|
||||
public function test_establishes_userdata_from_the_canonical_row_when_missing(): void
|
||||
{
|
||||
session()->forget('userdata');
|
||||
|
||||
$row = ['id' => 42, 'firstname' => 'Gloria', 'role' => 20];
|
||||
|
||||
app()->instance(Users::class, $this->make(Users::class, [
|
||||
'getUser' => fn ($id = null) => (int) $id === 42 ? $row : false,
|
||||
]));
|
||||
|
||||
$captured = null;
|
||||
app()->instance(Api::class, $this->make(Api::class, [
|
||||
'setApiUserSession' => function (array $user, bool $isExternalAuth = false) use (&$captured) {
|
||||
$captured = ['user' => $user, 'external' => $isExternalAuth];
|
||||
},
|
||||
]));
|
||||
|
||||
$this->establish($this->apiRequestForUser(42));
|
||||
|
||||
$this->assertSame($row, $captured['user'] ?? null, 'the canonical row must be handed to the session builder');
|
||||
$this->assertTrue($captured['external'] ?? false, 'API sessions are external auth');
|
||||
}
|
||||
|
||||
public function test_is_idempotent_when_userdata_already_exists(): void
|
||||
{
|
||||
// x-api-key (and stateful web) already populated userdata before this runs — leave it,
|
||||
// and never re-resolve the user.
|
||||
session(['userdata' => ['id' => 7, 'role' => 'admin']]);
|
||||
|
||||
app()->instance(Users::class, $this->make(Users::class, [
|
||||
'getUser' => function ($id = null) {
|
||||
$this->fail('must not re-resolve the user when userdata already exists');
|
||||
},
|
||||
]));
|
||||
|
||||
$called = false;
|
||||
app()->instance(Api::class, $this->make(Api::class, [
|
||||
'setApiUserSession' => function (array $user, bool $isExternalAuth = false) use (&$called) {
|
||||
$called = true;
|
||||
},
|
||||
]));
|
||||
|
||||
$this->establish($this->apiRequestForUser(42));
|
||||
|
||||
$this->assertFalse($called, 'must not rebuild an already-established session');
|
||||
$this->assertSame(7, session('userdata.id'), 'existing userdata must be left untouched');
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile SSO exchange (/oidc/mobile/exchange) arrives with no session
|
||||
* cookie — the validated one-time code + PKCE verifier are the authorization —
|
||||
* so it must be allow-listed as public. Guards that allow-list from regressing.
|
||||
*/
|
||||
public function test_oidc_mobile_exchange_is_a_public_route(): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
|
||||
$this->assertTrue(
|
||||
$authCheck->isPublicController('oidc.mobile.exchange'),
|
||||
'the mobile exchange endpoint must be public (no session at exchange time)'
|
||||
);
|
||||
|
||||
// Negative control: an oidc sub-route that is NOT allow-listed stays private.
|
||||
$this->assertFalse($authCheck->isPublicController('oidc.settings.save'));
|
||||
}
|
||||
|
||||
public function test_status_discovery_is_a_public_route(): void
|
||||
{
|
||||
$authCheck = $this->make(AuthCheck::class);
|
||||
|
||||
// The mobile app hits /status unauthenticated at connect time to discover
|
||||
// login methods, so the route must be public.
|
||||
$this->assertTrue($authCheck->isPublicController('status.index'));
|
||||
$this->assertTrue($authCheck->isPublicController('status'));
|
||||
}
|
||||
}
|
||||
137
tests/Unit/app/Core/Middleware/SessionMergeTest.php
Normal file
137
tests/Unit/app/Core/Middleware/SessionMergeTest.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Illuminate\Session\Store;
|
||||
use Leantime\Core\Middleware\StartSession;
|
||||
use ReflectionMethod;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for the optimistic session-concurrency strategy in
|
||||
* StartSession. The original blanket-locking existed because a no-lock version
|
||||
* lost session writes: two concurrent requests would each overwrite the whole
|
||||
* session blob, clobbering each other (e.g. a project switch reverted by a
|
||||
* background widget). The merge-on-write strategy must persist ONLY the keys a
|
||||
* request actually changed, re-reading the freshest state first, so a concurrent
|
||||
* writer's keys survive.
|
||||
*/
|
||||
class SessionMergeTest extends TestCase
|
||||
{
|
||||
private function middleware(): StartSession
|
||||
{
|
||||
return new StartSession(app('session'));
|
||||
}
|
||||
|
||||
private function invokeDiff(array $initial, array $current): array
|
||||
{
|
||||
$method = new ReflectionMethod(StartSession::class, 'diffSession');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($this->middleware(), $initial, $current);
|
||||
}
|
||||
|
||||
private function invokeMerge(Store $session, array $changed, array $removed): void
|
||||
{
|
||||
$method = new ReflectionMethod(StartSession::class, 'mergeSessionChanges');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->middleware(), $session, $changed, $removed);
|
||||
}
|
||||
|
||||
public function test_diff_detects_added_changed_and_removed_keys(): void
|
||||
{
|
||||
[$changed, $removed] = $this->invokeDiff(
|
||||
['currentProject' => 1, 'keep' => 'same', 'goingAway' => 'x'],
|
||||
['currentProject' => 2, 'keep' => 'same', 'brandNew' => 'y'],
|
||||
);
|
||||
|
||||
$this->assertSame(['currentProject' => 2, 'brandNew' => 'y'], $changed);
|
||||
$this->assertSame(['goingAway'], $removed);
|
||||
}
|
||||
|
||||
public function test_pure_read_produces_no_diff(): void
|
||||
{
|
||||
[$changed, $removed] = $this->invokeDiff(
|
||||
['currentProject' => 1, 'nested' => ['a' => 1]],
|
||||
['currentProject' => 1, 'nested' => ['a' => 1]],
|
||||
);
|
||||
|
||||
$this->assertSame([], $changed);
|
||||
$this->assertSame([], $removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The core race: request B loads the session, request A switches the project
|
||||
* and commits first, then B persists. B only changed `lastPage`, so the merge
|
||||
* must keep A's `currentProject = 2` rather than reverting it to the value B
|
||||
* originally loaded.
|
||||
*/
|
||||
public function test_merge_preserves_a_concurrent_writers_key(): void
|
||||
{
|
||||
$handler = new ArraySessionHandler(120);
|
||||
$name = 'leantime_session';
|
||||
// Store::setId() rejects ids that aren't 40-char alphanumeric and
|
||||
// generates a random one instead, so the id must be a valid session id
|
||||
// for the three stores to share state through the handler.
|
||||
$id = str_repeat('a', 40);
|
||||
|
||||
// Seed the persisted session.
|
||||
$seed = new Store($name, $handler, $id);
|
||||
$seed->start();
|
||||
$seed->put('currentProject', 1);
|
||||
$seed->put('userdata.id', 99);
|
||||
$seed->save();
|
||||
|
||||
// Request B starts and loads the current state.
|
||||
$requestB = new Store($name, $handler, $id);
|
||||
$requestB->start();
|
||||
$bInitial = $requestB->all();
|
||||
$requestB->put('lastPage', '/dashboard/home'); // B's only change
|
||||
|
||||
// Request A switches the project and commits BEFORE B persists.
|
||||
$requestA = new Store($name, $handler, $id);
|
||||
$requestA->start();
|
||||
$requestA->put('currentProject', 2);
|
||||
$requestA->save();
|
||||
|
||||
// B persists via the merge strategy (diff of B's change against B's snapshot).
|
||||
[$changed, $removed] = $this->invokeDiff($bInitial, $requestB->all());
|
||||
$this->invokeMerge($requestB, $changed, $removed);
|
||||
|
||||
// Read the final persisted state.
|
||||
$verify = new Store($name, $handler, $id);
|
||||
$verify->start();
|
||||
|
||||
$this->assertSame(2, $verify->get('currentProject'), 'concurrent project switch was clobbered');
|
||||
$this->assertSame('/dashboard/home', $verify->get('lastPage'), 'B\'s own write was lost');
|
||||
$this->assertSame(99, $verify->get('userdata.id'), 'untouched key was dropped');
|
||||
}
|
||||
|
||||
public function test_merge_applies_removed_keys(): void
|
||||
{
|
||||
$handler = new ArraySessionHandler(120);
|
||||
$name = 'leantime_session';
|
||||
$id = str_repeat('b', 40);
|
||||
|
||||
$seed = new Store($name, $handler, $id);
|
||||
$seed->start();
|
||||
$seed->put('currentIdeaCanvas', 5);
|
||||
$seed->put('currentProject', 3);
|
||||
$seed->save();
|
||||
|
||||
$request = new Store($name, $handler, $id);
|
||||
$request->start();
|
||||
$initial = $request->all();
|
||||
$request->forget('currentIdeaCanvas');
|
||||
|
||||
[$changed, $removed] = $this->invokeDiff($initial, $request->all());
|
||||
$this->invokeMerge($request, $changed, $removed);
|
||||
|
||||
$verify = new Store($name, $handler, $id);
|
||||
$verify->start();
|
||||
|
||||
$this->assertFalse($verify->has('currentIdeaCanvas'), 'removed key should not be persisted');
|
||||
$this->assertSame(3, $verify->get('currentProject'));
|
||||
}
|
||||
}
|
||||
117
tests/Unit/app/Core/Middleware/UpdatedTest.php
Normal file
117
tests/Unit/app/Core/Middleware/UpdatedTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Middleware;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Middleware\Updated;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
|
||||
/**
|
||||
* Guards the stale-session redirect loop: the Updated middleware caches the
|
||||
* db-version in the session and (before the fix) never re-read the database
|
||||
* once a value was cached. After an admin ran an update in THEIR session,
|
||||
* every other live session kept the old cached version, concluded "not
|
||||
* updated", and bounced between every page and /install/update until the
|
||||
* user's cookies were cleared.
|
||||
*
|
||||
* The fix self-heals: when a CACHED value would trigger the redirect, the
|
||||
* middleware re-reads the real version from the database first — one extra
|
||||
* query, only on the would-redirect path.
|
||||
*/
|
||||
class UpdatedTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private function appSettings(string $codeVersion): void
|
||||
{
|
||||
$settings = new AppSettings;
|
||||
$settings->dbVersion = $codeVersion;
|
||||
app()->instance(AppSettings::class, $settings);
|
||||
}
|
||||
|
||||
/** @param array<int, string|false> $dbVersions consecutive getSetting('db-version') results */
|
||||
private function settingRepo(array $dbVersions, ?int &$reads = null): void
|
||||
{
|
||||
$reads = 0;
|
||||
app()->instance(SettingRepository::class, $this->make(SettingRepository::class, [
|
||||
// Mirrors the real signature so forwarded arguments can't ever
|
||||
// make the stub brittle.
|
||||
'getSetting' => function (string $type = 'db-version') use (&$reads, $dbVersions) {
|
||||
$value = $dbVersions[min($reads, count($dbVersions) - 1)];
|
||||
$reads++;
|
||||
|
||||
return $value;
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
/** Run the middleware; returns [response, nextWasCalled]. */
|
||||
private function handleRequest(): array
|
||||
{
|
||||
// The redirect path resolves Frontcontroller from the container; its
|
||||
// real constructor needs the full HTTP stack, so bind a bare instance
|
||||
// (its redirect()/getCurrentRoute() members are static and work as-is).
|
||||
app()->instance(
|
||||
\Leantime\Core\Controller\Frontcontroller::class,
|
||||
$this->make(\Leantime\Core\Controller\Frontcontroller::class)
|
||||
);
|
||||
|
||||
$called = false;
|
||||
$response = (new Updated)->handle(
|
||||
IncomingRequest::create('/dashboard/home', 'GET'),
|
||||
function () use (&$called) {
|
||||
$called = true;
|
||||
|
||||
return new \Symfony\Component\HttpFoundation\Response('ok');
|
||||
}
|
||||
);
|
||||
|
||||
return [$response, $called];
|
||||
}
|
||||
|
||||
public function test_stale_session_cache_self_heals_after_an_update_ran_elsewhere(): void
|
||||
{
|
||||
// Session still remembers 3.5.25 from before the admin upgraded; the
|
||||
// DATABASE already says 3.5.26 (matching the code). The middleware
|
||||
// must re-read and pass through — not redirect-loop the user.
|
||||
session(['dbVersion' => '3.5.25', 'isUpdated' => false]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.26'], $reads);
|
||||
|
||||
[, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertTrue($nextCalled, 'a session whose cache is stale but whose DB is current must pass through');
|
||||
$this->assertSame(1, $reads, 'the DB is consulted exactly once to heal the cache');
|
||||
$this->assertSame('3.5.26', session('dbVersion'), 'the healed version is re-cached');
|
||||
$this->assertTrue(session('isUpdated'));
|
||||
}
|
||||
|
||||
public function test_current_session_cache_passes_through_without_touching_the_db(): void
|
||||
{
|
||||
session(['dbVersion' => '3.5.26', 'isUpdated' => true]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.26'], $reads);
|
||||
|
||||
[, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertTrue($nextCalled);
|
||||
$this->assertSame(0, $reads, 'an up-to-date cached version costs zero settings reads');
|
||||
}
|
||||
|
||||
public function test_genuinely_outdated_install_still_redirects_to_update(): void
|
||||
{
|
||||
// Both the cache AND the database are behind the code: the redirect is
|
||||
// correct. The self-heal costs one confirming read, then redirects.
|
||||
session(['dbVersion' => '3.5.25', 'isUpdated' => false]);
|
||||
$this->appSettings('3.5.26');
|
||||
$this->settingRepo(['3.5.25'], $reads);
|
||||
|
||||
[$response, $nextCalled] = $this->handleRequest();
|
||||
|
||||
$this->assertFalse($nextCalled, 'a genuinely outdated install must not pass through');
|
||||
$this->assertSame(1, $reads);
|
||||
$this->assertStringContainsString('/install/update', $response->headers->get('Location') ?? '', 'the redirect still points at the updater');
|
||||
$this->assertFalse(session('isUpdated'));
|
||||
}
|
||||
}
|
||||
164
tests/Unit/app/Core/Resources/Models/ResourceSummaryTest.php
Normal file
164
tests/Unit/app/Core/Resources/Models/ResourceSummaryTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Core\Resources\Models;
|
||||
|
||||
use Leantime\Core\Resources\Models\BudgetLine;
|
||||
use Leantime\Core\Resources\Models\Dependency;
|
||||
use Leantime\Core\Resources\Models\PersonAllocation;
|
||||
use Leantime\Core\Resources\Models\ResourceSummary;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* ResourceSummary value-object arithmetic tests. Utilization math is
|
||||
* consumed directly by the report tiles, so its edge cases (zero-divide,
|
||||
* empty aggregation) need to be locked in.
|
||||
*/
|
||||
class ResourceSummaryTest extends TestCase
|
||||
{
|
||||
public function test_empty_returns_a_summary_with_zero_totals_and_marks_is_empty(): void
|
||||
{
|
||||
$summary = ResourceSummary::empty([1, 2, 3]);
|
||||
|
||||
$this->assertSame([1, 2, 3], $summary->projectIds);
|
||||
$this->assertSame([], $summary->people);
|
||||
$this->assertSame([], $summary->budget);
|
||||
$this->assertSame([], $summary->dependencies);
|
||||
$this->assertSame(0.0, $summary->totalCapacity);
|
||||
$this->assertSame(0.0, $summary->totalAllocated);
|
||||
$this->assertTrue($summary->isEmpty());
|
||||
}
|
||||
|
||||
public function test_capacity_utilization_is_zero_when_no_capacity_declared(): void
|
||||
{
|
||||
$summary = ResourceSummary::empty([1]);
|
||||
|
||||
// Divide-by-zero must not occur — the report tile calls this
|
||||
// unconditionally.
|
||||
$this->assertSame(0.0, $summary->capacityUtilization());
|
||||
}
|
||||
|
||||
public function test_capacity_utilization_returns_percent_when_capacity_declared(): void
|
||||
{
|
||||
$summary = new ResourceSummary(
|
||||
projectIds: [1],
|
||||
people: [$this->makePerson(40, [1 => 30])],
|
||||
budget: [],
|
||||
dependencies: [],
|
||||
totalCapacity: 40.0,
|
||||
totalAllocated: 30.0,
|
||||
totalBudgeted: 0.0,
|
||||
totalSpent: 0.0,
|
||||
);
|
||||
|
||||
$this->assertSame(75.0, $summary->capacityUtilization());
|
||||
}
|
||||
|
||||
public function test_budget_utilization_edge_cases(): void
|
||||
{
|
||||
$noBudget = ResourceSummary::empty([1]);
|
||||
$this->assertSame(0.0, $noBudget->budgetUtilization());
|
||||
|
||||
$withBudget = new ResourceSummary(
|
||||
projectIds: [1],
|
||||
people: [],
|
||||
budget: [$this->makeBudget(1000.0, 250.0)],
|
||||
dependencies: [],
|
||||
totalCapacity: 0.0,
|
||||
totalAllocated: 0.0,
|
||||
totalBudgeted: 1000.0,
|
||||
totalSpent: 250.0,
|
||||
);
|
||||
$this->assertSame(25.0, $withBudget->budgetUtilization());
|
||||
}
|
||||
|
||||
public function test_person_allocation_totals_and_availability(): void
|
||||
{
|
||||
$person = $this->makePerson(40, [1 => 20, 2 => 15]);
|
||||
|
||||
$this->assertSame(35.0, $person->totalAllocated());
|
||||
$this->assertSame(5.0, $person->available());
|
||||
}
|
||||
|
||||
public function test_person_over_allocation_reports_zero_available(): void
|
||||
{
|
||||
// available() clamps at 0; over-allocation is a real product state
|
||||
// callers detect by comparing totalAllocated() > capacity directly.
|
||||
$person = $this->makePerson(40, [1 => 45]);
|
||||
|
||||
$this->assertSame(45.0, $person->totalAllocated());
|
||||
$this->assertSame(0.0, $person->available());
|
||||
$this->assertGreaterThan($person->capacity, $person->totalAllocated());
|
||||
}
|
||||
|
||||
public function test_is_empty_true_only_when_all_three_sections_empty(): void
|
||||
{
|
||||
$onlyPeople = new ResourceSummary(
|
||||
projectIds: [1],
|
||||
people: [$this->makePerson(40, [])],
|
||||
budget: [],
|
||||
dependencies: [],
|
||||
totalCapacity: 40.0,
|
||||
totalAllocated: 0.0,
|
||||
totalBudgeted: 0.0,
|
||||
totalSpent: 0.0,
|
||||
);
|
||||
|
||||
$this->assertFalse($onlyPeople->isEmpty());
|
||||
}
|
||||
|
||||
public function test_is_empty_false_when_only_dependencies_present(): void
|
||||
{
|
||||
// A partnership-heavy program can have zero people and zero budget
|
||||
// authored but still be non-empty; the section must render.
|
||||
$onlyDeps = new ResourceSummary(
|
||||
projectIds: [1],
|
||||
people: [],
|
||||
budget: [],
|
||||
dependencies: [$this->makeDependency()],
|
||||
totalCapacity: 0.0,
|
||||
totalAllocated: 0.0,
|
||||
totalBudgeted: 0.0,
|
||||
totalSpent: 0.0,
|
||||
);
|
||||
|
||||
$this->assertFalse($onlyDeps->isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $allocations
|
||||
*/
|
||||
private function makePerson(float $capacity, array $allocations): PersonAllocation
|
||||
{
|
||||
return new PersonAllocation(
|
||||
itemId: 1,
|
||||
userId: null,
|
||||
displayName: 'Test',
|
||||
capacity: $capacity,
|
||||
allocations: $allocations,
|
||||
);
|
||||
}
|
||||
|
||||
private function makeBudget(float $budgeted, float $spent): BudgetLine
|
||||
{
|
||||
return new BudgetLine(
|
||||
itemId: 1,
|
||||
projectId: 1,
|
||||
label: 'Test',
|
||||
budgeted: $budgeted,
|
||||
spent: $spent,
|
||||
color: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function makeDependency(): Dependency
|
||||
{
|
||||
return new Dependency(
|
||||
itemId: 1,
|
||||
partnerName: 'Test',
|
||||
type: 'partner',
|
||||
confirmed: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
117
tests/Unit/app/Core/Resources/Services/ResourcesRegistryTest.php
Normal file
117
tests/Unit/app/Core/Resources/Services/ResourcesRegistryTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Core\Resources\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Resources\Contracts\ResourcesGateway;
|
||||
use Leantime\Core\Resources\Models\ResourceSummary;
|
||||
use Leantime\Core\Resources\Services\ResourcesRegistry;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for ResourcesRegistry — the one-plugin-owns-it contract.
|
||||
*
|
||||
* Behaviors under test:
|
||||
* - null on read when no provider is registered (honest "not installed")
|
||||
* - a registered provider is returned by resolve()
|
||||
* - re-registering the SAME provider class REPLACES the stored instance
|
||||
* (last write wins; safe because two instances of the same class are
|
||||
* functionally interchangeable — deliberately NOT idempotent)
|
||||
* - a DIFFERENT provider class trying to register is refused (first wins)
|
||||
*/
|
||||
class ResourcesRegistryTest extends TestCase
|
||||
{
|
||||
public function test_resolve_returns_null_when_no_provider_registered(): void
|
||||
{
|
||||
$registry = new ResourcesRegistry;
|
||||
|
||||
$this->assertNull($registry->resolve());
|
||||
$this->assertFalse($registry->hasProvider());
|
||||
}
|
||||
|
||||
public function test_resolve_returns_registered_gateway(): void
|
||||
{
|
||||
$registry = new ResourcesRegistry;
|
||||
$gateway = $this->makeGateway();
|
||||
|
||||
$registry->register($gateway);
|
||||
|
||||
$this->assertSame($gateway, $registry->resolve());
|
||||
$this->assertTrue($registry->hasProvider());
|
||||
}
|
||||
|
||||
public function test_reregistering_same_provider_class_replaces_instance(): void
|
||||
{
|
||||
$registry = new ResourcesRegistry;
|
||||
$first = $this->makeGateway();
|
||||
$second = $this->makeGateway(); // same anonymous class
|
||||
|
||||
$registry->register($first);
|
||||
$registry->register($second);
|
||||
|
||||
// Second registration replaces first because it's the same class.
|
||||
// (No warning is emitted — this is the "plugin re-registered on
|
||||
// hot-reload" case, not a conflict.) NOT idempotent in the strict
|
||||
// sense: state changes to point at the newer instance.
|
||||
$this->assertSame($second, $registry->resolve());
|
||||
}
|
||||
|
||||
public function test_different_provider_class_registration_is_refused(): void
|
||||
{
|
||||
Log::spy();
|
||||
|
||||
$registry = new ResourcesRegistry;
|
||||
$first = $this->makeGateway();
|
||||
$second = $this->makeOtherGateway();
|
||||
|
||||
$registry->register($first);
|
||||
$registry->register($second);
|
||||
|
||||
$this->assertSame(
|
||||
$first,
|
||||
$registry->resolve(),
|
||||
'First registration must win when a different class tries to register',
|
||||
);
|
||||
|
||||
// Logging the refused registration is part of the contract — a silent
|
||||
// refusal would let double-installs go unnoticed.
|
||||
Log::shouldHaveReceived('warning')->once()->withArgs(
|
||||
fn (string $message): bool => str_contains($message, 'ResourcesRegistry')
|
||||
&& str_contains($message, 'already registered')
|
||||
);
|
||||
}
|
||||
|
||||
private function makeGateway(): ResourcesGateway
|
||||
{
|
||||
return new class implements ResourcesGateway
|
||||
{
|
||||
public function getForProjects(array $projectIds, ?string $actualsFrom = null, ?string $actualsTo = null): ResourceSummary
|
||||
{
|
||||
return ResourceSummary::empty($projectIds);
|
||||
}
|
||||
|
||||
public function getForProgram(int $programId): ResourceSummary
|
||||
{
|
||||
return ResourceSummary::empty([$programId]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function makeOtherGateway(): ResourcesGateway
|
||||
{
|
||||
return new class implements ResourcesGateway
|
||||
{
|
||||
public function getForProjects(array $projectIds, ?string $actualsFrom = null, ?string $actualsTo = null): ResourceSummary
|
||||
{
|
||||
return ResourceSummary::empty($projectIds);
|
||||
}
|
||||
|
||||
public function getForProgram(int $programId): ResourceSummary
|
||||
{
|
||||
return ResourceSummary::empty([$programId]);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
147
tests/Unit/app/Core/Support/AvatarcreatorTest.php
Normal file
147
tests/Unit/app/Core/Support/AvatarcreatorTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\app\Core\Support;
|
||||
|
||||
use LasseRafn\InitialAvatarGenerator\InitialAvatar;
|
||||
use LasseRafn\Initials\Initials;
|
||||
use Leantime\Core\Support\Avatarcreator;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use SVG\SVG;
|
||||
use Unit\TestCase;
|
||||
|
||||
class AvatarcreatorTest extends TestCase
|
||||
{
|
||||
private $avatarGenerator;
|
||||
|
||||
private $initials;
|
||||
|
||||
private $theme;
|
||||
|
||||
private $avatarCreator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->avatarGenerator = $this->createMock(InitialAvatar::class);
|
||||
$this->avatarGenerator->method('background')->willReturn($this->avatarGenerator);
|
||||
$this->avatarGenerator->method('font')->willReturn($this->avatarGenerator);
|
||||
$this->avatarGenerator->method('color')->willReturn($this->avatarGenerator);
|
||||
$this->avatarGenerator->method('generateSvg')->willReturn(SVG::fromString('<svg></svg>'));
|
||||
|
||||
$this->initials = $this->createMock(Initials::class);
|
||||
$this->theme = $this->createMock(Theme::class);
|
||||
|
||||
$this->avatarCreator = new Avatarcreator(
|
||||
$this->avatarGenerator,
|
||||
$this->initials,
|
||||
$this->theme
|
||||
);
|
||||
}
|
||||
|
||||
public function test_set_background_color()
|
||||
{
|
||||
$this->avatarGenerator->expects($this->once())
|
||||
->method('background')
|
||||
->with('#ffffff');
|
||||
|
||||
$this->avatarCreator->setBackground('#ffffff');
|
||||
}
|
||||
|
||||
public function test_set_file_prefix()
|
||||
{
|
||||
$this->avatarCreator->setFilePrefix('test-prefix');
|
||||
$this->assertEquals('test-prefix', $this->avatarCreator->getFilePrefix());
|
||||
}
|
||||
|
||||
public function test_set_initials_with_valid_name()
|
||||
{
|
||||
$this->initials->expects($this->once())
|
||||
->method('name')
|
||||
->with('john-doe');
|
||||
|
||||
$this->avatarGenerator->expects($this->once())
|
||||
->method('name')
|
||||
->with('john-doe');
|
||||
|
||||
$this->avatarCreator->setInitials('John Doe');
|
||||
}
|
||||
|
||||
public function test_set_initials_with_empty_name()
|
||||
{
|
||||
$this->initials->expects($this->once())
|
||||
->method('name')
|
||||
->with('👻');
|
||||
|
||||
$this->avatarCreator->setInitials('');
|
||||
}
|
||||
|
||||
public function test_get_initials()
|
||||
{
|
||||
$this->initials->expects($this->once())
|
||||
->method('getInitials')
|
||||
->willReturn('JD');
|
||||
|
||||
$this->assertEquals('JD', $this->avatarCreator->getInitials());
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_cache_hit()
|
||||
{
|
||||
$this->initials->method('getInitials')->willReturn('JD');
|
||||
|
||||
// Create test file
|
||||
$cacheDir = storage_path('framework/cache/avatars');
|
||||
if (! is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0777, true);
|
||||
}
|
||||
$testFile = $cacheDir.'/user-jd.svg';
|
||||
file_put_contents($testFile, '<svg>test</svg>');
|
||||
|
||||
$result = $this->avatarCreator->getAvatar('John Doe');
|
||||
|
||||
$this->assertEquals(SVG::fromString('<svg>test</svg>'), $result);
|
||||
unlink($testFile);
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_cache_miss()
|
||||
{
|
||||
$this->initials->method('getInitials')->willReturn('JD');
|
||||
$this->avatarGenerator->method('generateSvg')
|
||||
->willReturn(SVG::fromString('<svg></svg>'));
|
||||
|
||||
$result = $this->avatarCreator->getAvatar('John Doe');
|
||||
|
||||
$cacheDir = storage_path('framework/cache/avatars');
|
||||
$testFile = $cacheDir.'/user-jd.svg';
|
||||
|
||||
$this->assertFileExists($testFile);
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_special_characters()
|
||||
{
|
||||
$this->initials->method('getInitials')->willReturn('JD');
|
||||
$this->avatarGenerator->method('generateSvg')
|
||||
->willReturn(SVG::fromString('<svg></svg>'));
|
||||
|
||||
$result = $this->avatarCreator->getAvatar('John@Doe#$%');
|
||||
|
||||
$cacheDir = storage_path('framework/cache/avatars');
|
||||
$testFile = $cacheDir.'/user-jd.svg';
|
||||
|
||||
$this->assertFileExists($testFile);
|
||||
}
|
||||
|
||||
public function test_get_avatar_with_non_latin_characters()
|
||||
{
|
||||
$this->initials->method('getInitials')->willReturn('李王');
|
||||
$this->avatarGenerator->method('generateSvg')
|
||||
->willReturn(SVG::fromString('<svg></svg>'));
|
||||
|
||||
$result = $this->avatarCreator->getAvatar('李王');
|
||||
|
||||
$cacheDir = storage_path('framework/cache/avatars');
|
||||
$testFile = $cacheDir.'/user-李王.svg';
|
||||
|
||||
$this->assertFileExists($testFile);
|
||||
}
|
||||
}
|
||||
122
tests/Unit/app/Core/Support/CarbonMacrosTest.php
Normal file
122
tests/Unit/app/Core/Support/CarbonMacrosTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Core\Support;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Unit\TestCase;
|
||||
|
||||
class CarbonMacrosTest extends TestCase
|
||||
{
|
||||
private CarbonMacros $carbonMacros;
|
||||
|
||||
private Language $languageMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->languageMock = $this->createMock(Language::class);
|
||||
$this->languageMock->method('__')
|
||||
->willReturnCallback(function ($key) {
|
||||
return match ($key) {
|
||||
'language.dateformat' => 'm/d/Y',
|
||||
'language.timeformat' => 'h:i A',
|
||||
'language.dayNamesShort' => 'zo,ma,di,wo,do,vr,za',
|
||||
'language.dayNamesMin' => 'zo,ma,di,wo,do,vr,za',
|
||||
'language.monthNamesShort' => 'jan,feb,mrt,apr,mei,jun,jul,aug,sep,okt,nov,dec',
|
||||
default => $key
|
||||
};
|
||||
});
|
||||
|
||||
app()->instance(Language::class, $this->languageMock);
|
||||
|
||||
// Initialize with test values
|
||||
$this->carbonMacros = new CarbonMacros(
|
||||
userTimezone: 'America/Los_Angeles',
|
||||
userLanguage: 'en_US',
|
||||
userDateFormat: 'm/d/Y',
|
||||
userTimeFormat: 'h:i A',
|
||||
dbFormat: 'Y-m-d H:i:s',
|
||||
dbTimezone: 'UTC'
|
||||
);
|
||||
|
||||
// Mix in the macros to CarbonImmutable
|
||||
CarbonImmutable::mixin($this->carbonMacros);
|
||||
}
|
||||
|
||||
public function test_format_date_for_user(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$formatted = $date->formatDateForUser();
|
||||
|
||||
// Should be formatted according to user's timezone (PST) and format (m/d/Y)
|
||||
$this->assertEquals('12/25/2023', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_time_for_user(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$formatted = $date->formatTimeForUser();
|
||||
|
||||
// UTC 14:30 is 06:30 AM in PST
|
||||
$this->assertEquals('06:30 AM', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_24h_time_for_user(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$formatted = $date->format24HTimeForUser();
|
||||
|
||||
// UTC 14:30 is 06:30 in PST
|
||||
$this->assertEquals('06:30', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_date_time_for_db(): void
|
||||
{
|
||||
// Create a date in user's timezone
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 6, 30, 0, 'America/Los_Angeles');
|
||||
$formatted = $date->formatDateTimeForDb();
|
||||
|
||||
// Should be converted to UTC and formatted as Y-m-d H:i:s
|
||||
$this->assertEquals('2023-12-25 14:30:00', $formatted);
|
||||
}
|
||||
|
||||
public function test_set_to_user_timezone(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$converted = $date->setToUserTimezone();
|
||||
|
||||
$this->assertEquals('America/Los_Angeles', $converted->timezone->getName());
|
||||
$this->assertEquals('06:30', $converted->format('H:i'));
|
||||
}
|
||||
|
||||
public function test_set_to_db_timezone(): void
|
||||
{
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 6, 30, 0, 'America/Los_Angeles');
|
||||
$converted = $date->setToDbTimezone();
|
||||
|
||||
$this->assertEquals('UTC', $converted->timezone->getName());
|
||||
$this->assertEquals('14:30', $converted->format('H:i'));
|
||||
}
|
||||
|
||||
public function test_dutch_language_support(): void
|
||||
{
|
||||
$macros = new CarbonMacros(
|
||||
userTimezone: 'Europe/Amsterdam',
|
||||
userLanguage: 'nl_NL',
|
||||
userDateFormat: 'd-m-Y',
|
||||
userTimeFormat: 'H:i',
|
||||
dbFormat: 'Y-m-d H:i:s',
|
||||
dbTimezone: 'UTC'
|
||||
);
|
||||
|
||||
CarbonImmutable::mixin($macros);
|
||||
|
||||
$date = CarbonImmutable::create(2023, 12, 25, 14, 30, 0, 'UTC');
|
||||
$formatted = $date->formatDateForUser();
|
||||
|
||||
$this->assertEquals('25-12-2023', $formatted);
|
||||
}
|
||||
}
|
||||
290
tests/Unit/app/Core/Support/DateTimeHelperTest.php
Normal file
290
tests/Unit/app/Core/Support/DateTimeHelperTest.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\App\Core\Support;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\Exceptions\InvalidDateException;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\DateTimeHelper;
|
||||
use Unit\TestCase;
|
||||
|
||||
class DateTimeHelperTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
private DateTimeHelper $dateTimeHelper;
|
||||
|
||||
private Environment $environmentMock;
|
||||
|
||||
private Language $languageMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Mock the Environment class
|
||||
$this->environmentMock = $this->make(Environment::class, [
|
||||
'defaultTimezone' => 'UTC',
|
||||
'language' => 'en-US',
|
||||
]);
|
||||
app()->instance(Environment::class, $this->environmentMock);
|
||||
|
||||
$this->languageMock = $this->createMock(Language::class);
|
||||
$this->languageMock->method('__')->willReturnCallback(function ($index) {
|
||||
$map = [
|
||||
'language.dateformat' => 'm/d/Y',
|
||||
'language.timeformat' => 'h:i A',
|
||||
];
|
||||
|
||||
return $map[$index] ?? null;
|
||||
});
|
||||
app()->instance(\Leantime\Core\Language::class, $this->languageMock);
|
||||
|
||||
// Register mocks with the application container
|
||||
//
|
||||
// app()->instance(Language::class, $this->languageMock);
|
||||
|
||||
// America Los_Angeles is UTC - 8 so all db times need to come back from UTC - 8 hours
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
'America/Los_Angeles',
|
||||
'en-US',
|
||||
'm/d/Y',
|
||||
'h:i A'
|
||||
));
|
||||
|
||||
// Create the DateTimeHelper instance
|
||||
$this->dateTimeHelper = new DateTimeHelper;
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_timezone_offset_midnight(): void
|
||||
{
|
||||
// Test ISO 8601 with timezone offset (2025-04-16T00:00:00-04:00)
|
||||
$dateString = '2025-04-16T00:00:00-04:00';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('00', $parsedDate->format('H'));
|
||||
$this->assertEquals('00', $parsedDate->format('i'));
|
||||
$this->assertEquals('00', $parsedDate->format('s'));
|
||||
$this->assertEquals('-04:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_timezone_offset(): void
|
||||
{
|
||||
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-04:00)
|
||||
$dateString = '2025-04-16T23:59:59-04:00';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
$this->assertEquals('-04:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_timezone_offset_hhmm(): void
|
||||
{
|
||||
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-0400)
|
||||
$dateString = '2025-04-16T23:59:59-0400';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
$this->assertEquals('-04:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_timezone_offset_hh(): void
|
||||
{
|
||||
// Test ISO 8601 with timezone offset (2025-04-16T23:59:59-04)
|
||||
$dateString = '2025-04-16T23:59:59-04';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
$this->assertEquals('-04:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_with_zulu_time(): void
|
||||
{
|
||||
// Test ISO 8601 with Z/Zulu time (2025-04-16T23:59:59Z)
|
||||
$dateString = '2025-04-16T23:59:59Z';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
// Z time should be parsed as UTC
|
||||
$this->assertEquals('+00:00', $parsedDate->format('P'));
|
||||
}
|
||||
|
||||
public function test_parse_iso8601_without_timezone(): void
|
||||
{
|
||||
// Test ISO 8601 without timezone (2025-04-16T23:59:59)
|
||||
$dateString = '2025-04-16T23:59:59';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
}
|
||||
|
||||
public function test_parse_user_date_format(): void
|
||||
{
|
||||
// Test parsing date in user format (m/d/Y)
|
||||
$dateString = '04/16/2025';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
}
|
||||
|
||||
public function test_parse_user_date_and_time_format(): void
|
||||
{
|
||||
// Test parsing date and time in user format (m/d/Y h:i A)
|
||||
$dateString = '04/16/2025';
|
||||
$timeString = '11:59 PM';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, $timeString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
}
|
||||
|
||||
public function test_parse_user_date_with_start_of_day(): void
|
||||
{
|
||||
// Test parsing date with start of day
|
||||
$dateString = '04/16/2025';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, 'start');
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('00', $parsedDate->format('H'));
|
||||
$this->assertEquals('00', $parsedDate->format('i'));
|
||||
$this->assertEquals('00', $parsedDate->format('s'));
|
||||
}
|
||||
|
||||
public function test_parse_user_date_with_end_of_day(): void
|
||||
{
|
||||
// Test parsing date with end of day
|
||||
$dateString = '04/16/2025';
|
||||
$parsedDate = $this->dateTimeHelper->parseUserDateTime($dateString, 'end');
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
}
|
||||
|
||||
public function test_invalid_date_string(): void
|
||||
{
|
||||
// Test with invalid date string
|
||||
$this->expectException(InvalidFormatException::class);
|
||||
$this->dateTimeHelper->parseUserDateTime('not-a-date');
|
||||
}
|
||||
|
||||
public function test_empty_date_string(): void
|
||||
{
|
||||
// Test with empty date string
|
||||
$this->expectException(InvalidDateException::class);
|
||||
$this->dateTimeHelper->parseUserDateTime('');
|
||||
}
|
||||
|
||||
public function test_parse_db_date_time(): void
|
||||
{
|
||||
// Test parsing DB date time
|
||||
$dbDate = '2025-04-16 23:59:59';
|
||||
$parsedDate = $this->dateTimeHelper->parseDbDateTime($dbDate);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedDate);
|
||||
$this->assertEquals('2025', $parsedDate->format('Y'));
|
||||
$this->assertEquals('04', $parsedDate->format('m'));
|
||||
$this->assertEquals('16', $parsedDate->format('d'));
|
||||
$this->assertEquals('23', $parsedDate->format('H'));
|
||||
$this->assertEquals('59', $parsedDate->format('i'));
|
||||
$this->assertEquals('59', $parsedDate->format('s'));
|
||||
}
|
||||
|
||||
public function test_parse_user_24h_time(): void
|
||||
{
|
||||
// Test parsing 24h time
|
||||
$timeString = '23:59';
|
||||
$parsedTime = $this->dateTimeHelper->parseUser24hTime($timeString);
|
||||
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $parsedTime);
|
||||
$this->assertEquals('23', $parsedTime->format('H'));
|
||||
$this->assertEquals('59', $parsedTime->format('i'));
|
||||
}
|
||||
|
||||
public function test_user_now(): void
|
||||
{
|
||||
// Test user now returns current time
|
||||
$now = $this->dateTimeHelper->userNow();
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $now);
|
||||
|
||||
// Should be within a few seconds of now
|
||||
$this->assertLessThan(5, abs(time() - $now->timestamp));
|
||||
}
|
||||
|
||||
public function test_db_now(): void
|
||||
{
|
||||
// Test db now returns current time in UTC
|
||||
$now = $this->dateTimeHelper->dbNow();
|
||||
$this->assertInstanceOf(CarbonImmutable::class, $now);
|
||||
|
||||
// Should be within a few seconds of now
|
||||
$this->assertLessThan(5, abs(time() - $now->timestamp));
|
||||
|
||||
// Should be in UTC timezone
|
||||
$this->assertEquals('UTC', $now->timezone->getName());
|
||||
}
|
||||
|
||||
public function test_is_valid_date_string(): void
|
||||
{
|
||||
// Test valid date strings
|
||||
$this->assertTrue($this->dateTimeHelper->isValidDateString('2025-04-16 23:59:59'));
|
||||
$this->assertTrue($this->dateTimeHelper->isValidDateString('2025-04-16T23:59:59-04:00'));
|
||||
|
||||
// Test invalid date strings
|
||||
$this->assertFalse($this->dateTimeHelper->isValidDateString(''));
|
||||
$this->assertFalse($this->dateTimeHelper->isValidDateString(null));
|
||||
$this->assertFalse($this->dateTimeHelper->isValidDateString('1969-12-31 00:00:00'));
|
||||
$this->assertFalse($this->dateTimeHelper->isValidDateString('0000-00-00 00:00:00'));
|
||||
}
|
||||
}
|
||||
73
tests/Unit/app/Core/Support/FormatTest.php
Normal file
73
tests/Unit/app/Core/Support/FormatTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Support;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Core\Support\Format;
|
||||
use Tests\DateTimeHelper;
|
||||
use Tests\Language;
|
||||
use Tests\MockObject;
|
||||
use Unit\TestCase;
|
||||
|
||||
class FormatTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var DateTimeHelper|MockObject
|
||||
*/
|
||||
private $carbonMacrosMock;
|
||||
|
||||
/**
|
||||
* @var Language|MockObject
|
||||
*/
|
||||
private $languageMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
||||
parent::setUp();
|
||||
|
||||
$this->languageMock = $this->createMock(\Leantime\Core\Language::class);
|
||||
app()->instance(\Leantime\Core\Support\CarbonMacros::class, $this->carbonMacrosMock);
|
||||
app()->instance(\Leantime\Core\Language::class, $this->languageMock);
|
||||
|
||||
// America Los_Angeles is UTC - 8 so all db times need to come back from UTC - 8 hours
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
'America/Los_Angeles',
|
||||
'en-US',
|
||||
'm/d/Y',
|
||||
'h:i A'
|
||||
));
|
||||
|
||||
}
|
||||
|
||||
public function test_date(): void
|
||||
{
|
||||
$formattedDateString = '12/31/2021';
|
||||
$dbDate = '2022-01-01 00:00:00';
|
||||
$format = new Format($dbDate, '');
|
||||
|
||||
$this->assertSame($formattedDateString, $format->date());
|
||||
}
|
||||
|
||||
public function test_time(): void
|
||||
{
|
||||
$formattedTimeString = '04:00 PM';
|
||||
$dbDate = '2022-01-01 00:00:00';
|
||||
$format = new Format($dbDate, '');
|
||||
|
||||
$this->assertSame($formattedTimeString, $format->time());
|
||||
}
|
||||
|
||||
public function test_time24(): void
|
||||
{
|
||||
$formattedTimeString = '16:00';
|
||||
$dbDate = '2022-01-01 00:00:00';
|
||||
$format = new Format($dbDate, '');
|
||||
|
||||
$this->assertSame($formattedTimeString, $format->time24());
|
||||
|
||||
}
|
||||
|
||||
// Similarly you can add tests for other 'Format' class methods.
|
||||
}
|
||||
61
tests/Unit/app/Core/Support/NameSanitizerTest.php
Normal file
61
tests/Unit/app/Core/Support/NameSanitizerTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Support;
|
||||
|
||||
use Leantime\Core\Support\NameSanitizer;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression tests for the invite-spam abuse fix: person names were stored and
|
||||
* emailed raw, letting spammers use the firstname field as an email payload.
|
||||
* The sanitizer must strip abuse vectors (contact numbers, URLs, emails, bidi
|
||||
* tricks) while letting legitimate names in any script through unchanged.
|
||||
*/
|
||||
class NameSanitizerTest extends TestCase
|
||||
{
|
||||
public function test_legitimate_names_pass_unchanged(): void
|
||||
{
|
||||
$this->assertSame('Marcel', NameSanitizer::clean('Marcel'));
|
||||
$this->assertSame('María José', NameSanitizer::clean('María José'));
|
||||
$this->assertSame('汪小明', NameSanitizer::clean('汪小明'));
|
||||
$this->assertSame('محمد علي', NameSanitizer::clean('محمد علي'));
|
||||
$this->assertSame("O'Connor-Smith", NameSanitizer::clean("O'Connor-Smith"));
|
||||
}
|
||||
|
||||
public function test_strips_contact_number_from_spam_payload(): void
|
||||
{
|
||||
// The actual payload from the 2026-07 abuse reports
|
||||
$this->assertStringNotContainsString('992600898', NameSanitizer::clean('+汪汪992600898-ن颂58嗏،Virtual'));
|
||||
}
|
||||
|
||||
public function test_strips_html(): void
|
||||
{
|
||||
$this->assertSame('alert(1)', NameSanitizer::clean('<script>alert(1)</script>'));
|
||||
}
|
||||
|
||||
public function test_strips_urls_and_emails(): void
|
||||
{
|
||||
$this->assertSame('Buy cheap', NameSanitizer::clean('Buy http://spam.example.com cheap'));
|
||||
$this->assertSame('Visit now', NameSanitizer::clean('Visit www.spam.example now'));
|
||||
$this->assertSame('mail me', NameSanitizer::clean('mail spam@evil.example me'));
|
||||
}
|
||||
|
||||
public function test_strips_control_and_bidi_characters(): void
|
||||
{
|
||||
$this->assertSame('JohnSmith', NameSanitizer::clean("John\u{202E}Smith"));
|
||||
$this->assertSame('AB', NameSanitizer::clean("A\u{200B}\u{0000}B"));
|
||||
}
|
||||
|
||||
public function test_caps_length_and_handles_non_strings(): void
|
||||
{
|
||||
$this->assertSame(50, mb_strlen(NameSanitizer::clean(str_repeat('A', 200))));
|
||||
$this->assertSame('', NameSanitizer::clean(null));
|
||||
$this->assertSame('', NameSanitizer::clean(12345));
|
||||
$this->assertSame('', NameSanitizer::clean(['array']));
|
||||
}
|
||||
|
||||
public function test_collapses_whitespace(): void
|
||||
{
|
||||
$this->assertSame('John Smith', NameSanitizer::clean(" John Smith \n"));
|
||||
}
|
||||
}
|
||||
84
tests/Unit/app/Core/Support/OutboundUrlGuardTest.php
Normal file
84
tests/Unit/app/Core/Support/OutboundUrlGuardTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\Support;
|
||||
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use GuzzleHttp\Psr7\Uri;
|
||||
use Leantime\Core\Support\OutboundUrlGuard;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Covers the SSRF guard's address classification and redirect re-validation using IP literals and
|
||||
* direct calls, so nothing here depends on live DNS or the network.
|
||||
*/
|
||||
class OutboundUrlGuardTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider ipProvider
|
||||
*/
|
||||
public function test_is_ip_allowed(string $ip, bool $expected): void
|
||||
{
|
||||
$this->assertSame($expected, OutboundUrlGuard::isIpAllowed($ip));
|
||||
}
|
||||
|
||||
public static function ipProvider(): array
|
||||
{
|
||||
return [
|
||||
'loopback v4' => ['127.0.0.1', false],
|
||||
'private 10/8' => ['10.1.2.3', false],
|
||||
'private 172.16/12' => ['172.16.5.5', false],
|
||||
'private 192.168/16' => ['192.168.1.1', false],
|
||||
'cgnat 100.64/10' => ['100.64.0.1', false],
|
||||
'link-local metadata' => ['169.254.169.254', false],
|
||||
'reserved 0.0.0.0/8' => ['0.0.0.0', false],
|
||||
'public v4 (google dns)' => ['8.8.8.8', true],
|
||||
'public v4 (cloudflare)' => ['1.1.1.1', true],
|
||||
'loopback v6' => ['::1', false],
|
||||
'public v6 (cloudflare)' => ['2606:4700:4700::1111', true],
|
||||
'ipv4-mapped loopback' => ['::ffff:127.0.0.1', false],
|
||||
'ipv4-mapped cgnat' => ['::ffff:100.64.0.1', false],
|
||||
'ipv4-mapped public' => ['::ffff:8.8.8.8', true],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider urlProvider
|
||||
*/
|
||||
public function test_is_allowed_url(string $url, bool $expected): void
|
||||
{
|
||||
$this->assertSame($expected, OutboundUrlGuard::isAllowedUrl($url));
|
||||
}
|
||||
|
||||
public static function urlProvider(): array
|
||||
{
|
||||
return [
|
||||
'loopback literal' => ['http://127.0.0.1/feed.ics', false],
|
||||
'cgnat literal' => ['http://100.64.0.1/', false],
|
||||
'metadata literal' => ['http://169.254.169.254/latest/meta-data/', false],
|
||||
'public literal' => ['https://8.8.8.8/', true],
|
||||
'non-http scheme' => ['ftp://8.8.8.8/', false],
|
||||
'file scheme' => ['file:///etc/passwd', false],
|
||||
'garbage' => ['not-a-url', false],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_redirect_options_block_disallowed_hop(): void
|
||||
{
|
||||
$onRedirect = OutboundUrlGuard::redirectOptions()['on_redirect'];
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$onRedirect(new Request('GET', 'https://8.8.8.8/'), new Response(302), new Uri('http://169.254.169.254/'));
|
||||
}
|
||||
|
||||
public function test_redirect_options_allow_public_hop(): void
|
||||
{
|
||||
$onRedirect = OutboundUrlGuard::redirectOptions()['on_redirect'];
|
||||
|
||||
// A public → public redirect must not throw.
|
||||
$onRedirect(new Request('GET', 'https://8.8.8.8/'), new Response(302), new Uri('https://1.1.1.1/'));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
61
tests/Unit/app/Core/UI/TemplateEscapeTest.php
Normal file
61
tests/Unit/app/Core/UI/TemplateEscapeTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\UI;
|
||||
|
||||
use Leantime\Core\UI\Template;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression tests for Template::escape() (#3636).
|
||||
*
|
||||
* escape() used htmlentities(), which converts non-ASCII to named entities on top of the
|
||||
* XSS-relevant characters. Nearly every call site renders through {{ }}, which escapes the
|
||||
* resulting ampersand a second time, so users with non-English data saw a literal
|
||||
* "Müller" in dropdowns, filters and Ideas.
|
||||
*
|
||||
* These pin both halves of the contract: non-ASCII survives, and the escaping is still as
|
||||
* strong as it was for the call sites that render through {!! !!}.
|
||||
*/
|
||||
class TemplateEscapeTest extends TestCase
|
||||
{
|
||||
private function escape(?string $value): string
|
||||
{
|
||||
// Built without the constructor: escape() only reaches convertRelativePaths(), which
|
||||
// depends on the BASE_URL constant rather than any instance state, so none of
|
||||
// Template's collaborators (session, db, theme) need to exist here.
|
||||
$template = (new \ReflectionClass(Template::class))->newInstanceWithoutConstructor();
|
||||
|
||||
return $template->escape($value);
|
||||
}
|
||||
|
||||
public function test_non_ascii_is_left_alone(): void
|
||||
{
|
||||
$this->assertSame('Müller', $this->escape('Müller'));
|
||||
$this->assertSame('Ä Ö Ü ä ö ü ß', $this->escape('Ä Ö Ü ä ö ü ß'));
|
||||
$this->assertStringNotContainsString(
|
||||
'ü',
|
||||
$this->escape('Müller'),
|
||||
'Umlauts must not be turned into named entities (#3636)'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_xss_relevant_characters_are_still_escaped(): void
|
||||
{
|
||||
$this->assertSame('<script>alert(1)</script>', $this->escape('<script>alert(1)</script>'));
|
||||
$this->assertSame('" onerror="alert(1)', $this->escape('" onerror="alert(1)'));
|
||||
$this->assertSame('' onmouseover='x', $this->escape("' onmouseover='x"));
|
||||
$this->assertSame('a < b & c > d', $this->escape('a < b & c > d'));
|
||||
}
|
||||
|
||||
public function test_ampersand_is_escaped_exactly_once(): void
|
||||
{
|
||||
// The double-escape the user actually saw came from & being encoded here and again
|
||||
// by Blade. One pass here must produce exactly one &.
|
||||
$this->assertSame('Müller & Söhne', $this->escape('Müller & Söhne'));
|
||||
}
|
||||
|
||||
public function test_null_is_an_empty_string(): void
|
||||
{
|
||||
$this->assertSame('', $this->escape(null));
|
||||
}
|
||||
}
|
||||
112
tests/Unit/app/Core/UI/ThemeTest.php
Normal file
112
tests/Unit/app/Core/UI/ThemeTest.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Core\UI;
|
||||
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Files\FileManager;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
|
||||
class ThemeTest extends \Unit\TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* The test object
|
||||
*
|
||||
* @var Theme
|
||||
*/
|
||||
protected $theme;
|
||||
|
||||
protected $settingsRepoMock;
|
||||
|
||||
protected $languageMock;
|
||||
|
||||
protected $configMock;
|
||||
|
||||
protected $appSettingsMock;
|
||||
|
||||
protected $fileManagerMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
||||
parent::setUp();
|
||||
|
||||
if (! defined('BASE_URL')) {
|
||||
define('BASE_URL', 'http://localhost');
|
||||
}
|
||||
|
||||
$this->settingsRepoMock = $this->make(Setting::class, [
|
||||
|
||||
]);
|
||||
$this->languageMock = $this->make(Language::class, [
|
||||
|
||||
]);
|
||||
|
||||
$this->fileManagerMock = $this->make(FileManager::class, [
|
||||
|
||||
]);
|
||||
|
||||
$this->configMock = $this->make(Environment::class, [
|
||||
'primarycolor' => '#123',
|
||||
'secondarycolor' => '#123',
|
||||
|
||||
]);
|
||||
|
||||
$this->appSettingsMock = $this->make(AppSettings::class, [
|
||||
'appVersion' => '123',
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
protected function _after()
|
||||
{
|
||||
$this->theme = null;
|
||||
}
|
||||
|
||||
// Write tests below
|
||||
|
||||
/**
|
||||
* Test GetMenuTypes method
|
||||
*/
|
||||
public function test_get_default_color_scheme_with_color_env_set()
|
||||
{
|
||||
|
||||
// Load class to be tested
|
||||
$this->theme = new Theme(
|
||||
settingsRepo: $this->settingsRepoMock,
|
||||
language: $this->languageMock,
|
||||
config: $this->configMock,
|
||||
appSettings: $this->appSettingsMock,
|
||||
fileManager: $this->fileManagerMock
|
||||
);
|
||||
|
||||
$colorScheme = $this->theme->getColorScheme();
|
||||
$this->assertEquals('companyColors', $colorScheme);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GetMenuTypes method
|
||||
*/
|
||||
public function test_get_default_color_scheme_without_env()
|
||||
{
|
||||
|
||||
$configMock = $this->make(Environment::class, []);
|
||||
|
||||
$theme = new Theme(
|
||||
settingsRepo: $this->settingsRepoMock,
|
||||
language: $this->languageMock,
|
||||
config: $configMock,
|
||||
appSettings: $this->appSettingsMock,
|
||||
fileManager: $this->fileManagerMock
|
||||
);
|
||||
|
||||
$colorScheme = $theme->getColorScheme();
|
||||
$this->assertEquals('themeDefault', $colorScheme);
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user