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