OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user