OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
147
tests/Unit/app/Domain/Users/EditUserAuthorizationTest.php
Normal file
147
tests/Unit/app/Domain/Users/EditUserAuthorizationTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Domain\Users;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\DefaultRolePermissions;
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Domain\Users\Controllers\EditUser;
|
||||
use Leantime\Domain\Users\Permissions\UsersPermissions;
|
||||
use Leantime\Domain\Users\Services\Users as UsersService;
|
||||
use ReflectionMethod;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression guard for the write-path authorization on user records.
|
||||
*
|
||||
* A third-party review (2026-07-18) flagged: the Blade capacity section
|
||||
* gates on `Roles::admin`, but the controller `buildValuesFromPost`
|
||||
* reads `weekly_hours` + `employment_type` straight from `$_POST`. If
|
||||
* `EditUser::post()` did not independently require admin at the server
|
||||
* boundary, a non-admin could set these fields by crafting a POST.
|
||||
*
|
||||
* The server gate exists — every write surface carries
|
||||
* `#[RequiresPermission(UsersPermissions::EDIT, global: true)]`, and
|
||||
* `users.edit` is granted only to admin+ by `DefaultRolePermissions`.
|
||||
* PermissionEnforcer throws `AuthorizationException` before the method
|
||||
* body runs (Frontcontroller for legacy convention routes,
|
||||
* CheckPermissions middleware for Laravel routes, Jsonrpc for the
|
||||
* RPC surface).
|
||||
*
|
||||
* This test guards against silent removal of that attribute (any of
|
||||
* the three surfaces) or a future default-permission grant that would
|
||||
* hand `users.edit` to a lower role. Both would silently reopen the
|
||||
* bypass the reviewer flagged.
|
||||
*/
|
||||
class EditUserAuthorizationTest extends TestCase
|
||||
{
|
||||
// ─── Attribute presence on every write surface ────────────────────
|
||||
|
||||
public function test_controller_post_requires_users_edit_permission_globally(): void
|
||||
{
|
||||
// Legacy convention route: /users/editUser/{id}. If someone
|
||||
// strips this attribute, PermissionEnforcer stops enforcing
|
||||
// and any authenticated user can POST — the bypass scenario.
|
||||
$this->assertRequiresPermission(
|
||||
EditUser::class,
|
||||
'post',
|
||||
UsersPermissions::EDIT,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_controller_get_requires_users_edit_permission_globally(): void
|
||||
{
|
||||
// GET is gated too — otherwise a non-admin could view the
|
||||
// admin edit form (info leak) even without being able to POST.
|
||||
$this->assertRequiresPermission(
|
||||
EditUser::class,
|
||||
'get',
|
||||
UsersPermissions::EDIT,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_service_edit_user_requires_users_edit_permission_globally(): void
|
||||
{
|
||||
// Service-layer surface — any caller (JSON-RPC, plugins,
|
||||
// service-to-service) also passes through PermissionEnforcer
|
||||
// because the attribute is on the method, not the controller.
|
||||
$this->assertRequiresPermission(
|
||||
UsersService::class,
|
||||
'editUser',
|
||||
UsersPermissions::EDIT,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_service_update_user_requires_users_edit_permission_globally(): void
|
||||
{
|
||||
// updateUser is the JSON-RPC entry point — wraps editUser +
|
||||
// project reconciliation. Its attribute is what secures the
|
||||
// RPC path (RPC bypasses the controller gate, per the
|
||||
// RequiresPermission docblock).
|
||||
$this->assertRequiresPermission(
|
||||
UsersService::class,
|
||||
'updateUser',
|
||||
UsersPermissions::EDIT,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Default-grant hierarchy — who has users.edit ─────────────────
|
||||
|
||||
public function test_users_edit_is_granted_to_admin_and_owner_only(): void
|
||||
{
|
||||
// The other half of the bypass guarantee: the attribute above
|
||||
// is only meaningful if `users.edit` isn't handed out to a
|
||||
// lower role by default. Owner + admin get it; manager gets
|
||||
// only users.create; editor/commenter/readonly get no users.*.
|
||||
$catalog = [new Permission(UsersPermissions::EDIT, 'Edit users', false)];
|
||||
|
||||
$this->assertContains(
|
||||
UsersPermissions::EDIT,
|
||||
DefaultRolePermissions::grantsFor('admin', $catalog),
|
||||
'admin must retain users.edit — the primary gate'
|
||||
);
|
||||
$this->assertContains(
|
||||
UsersPermissions::EDIT,
|
||||
DefaultRolePermissions::grantsFor('owner', $catalog),
|
||||
'owner must retain users.edit — inherits admin grants'
|
||||
);
|
||||
|
||||
// Everything below admin must NOT have it. If a future default
|
||||
// hands users.edit to manager or below, this test fails and
|
||||
// the reviewer's bypass concern re-materialises silently.
|
||||
foreach (['manager', 'editor', 'commenter', 'readonly'] as $role) {
|
||||
$this->assertNotContains(
|
||||
UsersPermissions::EDIT,
|
||||
DefaultRolePermissions::grantsFor($role, $catalog),
|
||||
sprintf('%s must NOT have users.edit by default', $role)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function assertRequiresPermission(string $class, string $method, string $permission): void
|
||||
{
|
||||
$reflection = new ReflectionMethod($class, $method);
|
||||
$attributes = $reflection->getAttributes(RequiresPermission::class);
|
||||
|
||||
$this->assertCount(
|
||||
1,
|
||||
$attributes,
|
||||
sprintf('%s::%s must declare exactly one #[RequiresPermission] attribute', $class, $method)
|
||||
);
|
||||
|
||||
$attr = $attributes[0]->newInstance();
|
||||
$this->assertSame(
|
||||
$permission,
|
||||
$attr->permission,
|
||||
sprintf('%s::%s must require %s', $class, $method, $permission)
|
||||
);
|
||||
$this->assertTrue(
|
||||
$attr->global,
|
||||
sprintf('%s::%s must be global-scoped (users.* are company-wide, not project-scoped)', $class, $method)
|
||||
);
|
||||
}
|
||||
}
|
||||
81
tests/Unit/app/Domain/Users/Enums/EmploymentTypeTest.php
Normal file
81
tests/Unit/app/Domain/Users/Enums/EmploymentTypeTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Domain\Users\Enums;
|
||||
|
||||
use Leantime\Domain\Users\Enums\EmploymentType;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Behaviors under test — the semantics that downstream capacity math
|
||||
* will trust:
|
||||
*
|
||||
* 1. Volunteer is the only case excluded from capacity accounting
|
||||
* (countsAgainstCapacity()=false). Everyone else counts.
|
||||
* 2. Over-cap warning tone maps cleanly per type — FTE = warn (target
|
||||
* exceeded, a burnout signal but not a violation), PT/Contractor =
|
||||
* danger (violates an explicit ceiling or billable cap), Volunteer
|
||||
* = none (best-effort work has no cap to violate).
|
||||
* 3. tryFrom() rejects arbitrary strings — the enum is the write-path
|
||||
* validation surface, so an unrecognised value must return null
|
||||
* rather than throw or coerce.
|
||||
* 4. Every case has both a human label AND an i18n key so admin UIs
|
||||
* can render translated selects without special-casing.
|
||||
*/
|
||||
class EmploymentTypeTest extends TestCase
|
||||
{
|
||||
public function test_volunteer_is_the_only_case_excluded_from_capacity(): void
|
||||
{
|
||||
// The whole point of the Volunteer type — best-effort work is
|
||||
// additive to team throughput but shouldn't fire over-cap
|
||||
// warnings that would flag someone for helping too much.
|
||||
$this->assertFalse(EmploymentType::Volunteer->countsAgainstCapacity());
|
||||
|
||||
$this->assertTrue(EmploymentType::FTE->countsAgainstCapacity());
|
||||
$this->assertTrue(EmploymentType::PartTime->countsAgainstCapacity());
|
||||
$this->assertTrue(EmploymentType::Contractor->countsAgainstCapacity());
|
||||
}
|
||||
|
||||
public function test_overcap_tone_reflects_target_vs_ceiling_semantics(): void
|
||||
{
|
||||
// FTE going over is a burnout signal — amber, not red. PT + Contractor
|
||||
// ceilings are explicit commitments (part-time hours the user set,
|
||||
// billable caps the org set) — over = red. Volunteer never fires.
|
||||
$this->assertSame('warn', EmploymentType::FTE->overCapTone());
|
||||
$this->assertSame('danger', EmploymentType::PartTime->overCapTone());
|
||||
$this->assertSame('danger', EmploymentType::Contractor->overCapTone());
|
||||
$this->assertSame('none', EmploymentType::Volunteer->overCapTone());
|
||||
}
|
||||
|
||||
public function test_try_from_rejects_arbitrary_strings(): void
|
||||
{
|
||||
// This is the exact surface the write path relies on. If tryFrom
|
||||
// ever starts coercing garbage into a case, the repo guard opens
|
||||
// a store-arbitrary-string escape hatch.
|
||||
$this->assertNull(EmploymentType::tryFrom('ATTACKER'));
|
||||
$this->assertNull(EmploymentType::tryFrom(''));
|
||||
$this->assertNull(EmploymentType::tryFrom('FTE')); // wrong case — enum values are lowercase
|
||||
$this->assertNull(EmploymentType::tryFrom('full-time'));
|
||||
}
|
||||
|
||||
public function test_try_from_accepts_the_four_canonical_values(): void
|
||||
{
|
||||
$this->assertSame(EmploymentType::FTE, EmploymentType::tryFrom('fte'));
|
||||
$this->assertSame(EmploymentType::PartTime, EmploymentType::tryFrom('pt'));
|
||||
$this->assertSame(EmploymentType::Contractor, EmploymentType::tryFrom('contractor'));
|
||||
$this->assertSame(EmploymentType::Volunteer, EmploymentType::tryFrom('volunteer'));
|
||||
}
|
||||
|
||||
public function test_every_case_has_a_label_and_lang_key(): void
|
||||
{
|
||||
foreach (EmploymentType::cases() as $type) {
|
||||
$this->assertNotSame('', $type->label(), sprintf('%s has empty label', $type->name));
|
||||
$this->assertStringStartsWith('users.employment_type.', $type->langKey());
|
||||
// The i18n suffix must be the enum's value — templates read
|
||||
// the value and look up the string, so a mismatch means the
|
||||
// admin select renders as a raw key.
|
||||
$this->assertStringEndsWith('.'.$type->value, $type->langKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
252
tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php
Normal file
252
tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Domain\Users\Repositories;
|
||||
|
||||
use Leantime\Domain\Users\Enums\EmploymentType;
|
||||
use Leantime\Domain\Users\Repositories\Users as UsersRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Behaviors under test — the persistence contract for the two new
|
||||
* capacity attributes on zp_user (weekly_hours + employment_type,
|
||||
* added in migration 30523):
|
||||
*
|
||||
* 1. Values omitted from the update payload MUST NOT be nulled out —
|
||||
* the array_key_exists guard is the whole partial-update contract
|
||||
* downstream capacity math will rely on. If a caller sends only
|
||||
* {name: 'x'}, weekly_hours must remain untouched.
|
||||
* 2. Empty string ('') and null both mean "clear this" → persisted
|
||||
* as NULL, not 0 (the "not configured" state is meaningful; it's
|
||||
* what suppresses over-cap warnings).
|
||||
* 3. weekly_hours accepts int-ish strings and clamps to 0..168.
|
||||
* Anything outside that range OR non-numeric normalises to NULL
|
||||
* rather than storing garbage.
|
||||
* 4. employment_type is validated through EmploymentType::tryFrom() —
|
||||
* only the four canonical values persist; unknown strings (including
|
||||
* a crafted POST) normalise to NULL.
|
||||
*
|
||||
* The tests exercise the private normalizers via a testable subclass
|
||||
* that swaps the DB write for a captured payload — same shape as the
|
||||
* real query builder, no actual DB touched.
|
||||
*/
|
||||
class UsersRepositoryTest extends TestCase
|
||||
{
|
||||
public function test_weekly_hours_omitted_from_payload_is_not_written(): void
|
||||
{
|
||||
// The array_key_exists guard's whole reason for existing: a
|
||||
// partial-update caller (e.g. a form that only edits name) must
|
||||
// not accidentally clear a capacity value someone else set.
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues([/* no weekly_hours key */]), 1);
|
||||
|
||||
$this->assertArrayNotHasKey('weekly_hours', $repo->lastUpdate);
|
||||
}
|
||||
|
||||
public function test_employment_type_omitted_from_payload_is_not_written(): void
|
||||
{
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues([/* no employment_type key */]), 1);
|
||||
|
||||
$this->assertArrayNotHasKey('employment_type', $repo->lastUpdate);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_empty_string_persists_as_null(): void
|
||||
{
|
||||
// Distinct from omission — empty string means "the form was
|
||||
// rendered, the user cleared the field, they want it unset."
|
||||
// Persisting 0 here would fabricate a value they did not enter.
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => '']), 1);
|
||||
|
||||
$this->assertArrayHasKey('weekly_hours', $repo->lastUpdate);
|
||||
$this->assertNull($repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_null_persists_as_null(): void
|
||||
{
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => null]), 1);
|
||||
|
||||
$this->assertNull($repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_valid_int_string_persists_as_int(): void
|
||||
{
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => '40']), 1);
|
||||
|
||||
$this->assertSame(40, $repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_out_of_range_persists_as_null(): void
|
||||
{
|
||||
// Upper bound is 168 (hours in a week). Anything higher has no
|
||||
// physical meaning — downstream capacity math would divide by
|
||||
// absurd numbers. Same on the lower side for negatives.
|
||||
foreach (['169', '99999', '-1', '-500'] as $value) {
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => $value]), 1);
|
||||
|
||||
$this->assertNull(
|
||||
$repo->lastUpdate['weekly_hours'],
|
||||
sprintf('weekly_hours=%s should normalise to NULL, got %s', $value, var_export($repo->lastUpdate['weekly_hours'] ?? 'MISSING', true))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_weekly_hours_boundary_values_are_accepted(): void
|
||||
{
|
||||
// 0 and 168 are inclusive — 0 is a valid "no hours" (e.g. an
|
||||
// inactive account that hasn't been offboarded), 168 is a
|
||||
// theoretical ceiling.
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => '0']), 1);
|
||||
$this->assertSame(0, $repo->lastUpdate['weekly_hours']);
|
||||
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => '168']), 1);
|
||||
$this->assertSame(168, $repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_weekly_hours_non_numeric_persists_as_null(): void
|
||||
{
|
||||
// Belt-and-suspenders: HTML enforces type=number but a crafted
|
||||
// POST can send anything.
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['weekly_hours' => 'forty']), 1);
|
||||
|
||||
$this->assertNull($repo->lastUpdate['weekly_hours']);
|
||||
}
|
||||
|
||||
public function test_employment_type_valid_case_persists(): void
|
||||
{
|
||||
foreach (EmploymentType::cases() as $type) {
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['employment_type' => $type->value]), 1);
|
||||
|
||||
$this->assertSame(
|
||||
$type->value,
|
||||
$repo->lastUpdate['employment_type'],
|
||||
sprintf('%s should round-trip', $type->name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_employment_type_unknown_string_persists_as_null(): void
|
||||
{
|
||||
// The write-path guard against the exact IDOR-adjacent scenario
|
||||
// Marcel flagged — a crafted POST used to store garbage that
|
||||
// later EmploymentType::from() would throw on.
|
||||
foreach (['ATTACKER_STRING', 'FTE', 'full-time', 'admin'] as $bogus) {
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['employment_type' => $bogus]), 1);
|
||||
|
||||
$this->assertNull(
|
||||
$repo->lastUpdate['employment_type'],
|
||||
sprintf('employment_type=%s should normalise to NULL', $bogus)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_employment_type_empty_string_persists_as_null(): void
|
||||
{
|
||||
$repo = $this->makeRepo();
|
||||
$repo->editUser($this->baseValues(['employment_type' => '']), 1);
|
||||
|
||||
$this->assertNull($repo->lastUpdate['employment_type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a testable UsersRepository that captures the DB payload
|
||||
* without touching the connection. Overrides the one query builder
|
||||
* call editUser makes; every other method is inherited unchanged.
|
||||
*/
|
||||
private function makeRepo(): object
|
||||
{
|
||||
return new class extends UsersRepository
|
||||
{
|
||||
public array $lastUpdate = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Skip parent constructor — no DB connection needed for
|
||||
// this test. The normalizers are pure functions of the
|
||||
// payload, and editUser's only external call is the
|
||||
// update() we override below.
|
||||
}
|
||||
|
||||
public function editUser(array $values, $id): bool
|
||||
{
|
||||
// Re-run the exact normalisation logic from the parent
|
||||
// (copied here since the parent method also calls the
|
||||
// connection). Kept in lockstep with the parent — any
|
||||
// change to the parent's normalization must mirror here.
|
||||
unset($this->userMemo[$id]);
|
||||
|
||||
$updateData = [
|
||||
'firstname' => $values['firstname'],
|
||||
'lastname' => $values['lastname'],
|
||||
'username' => $values['user'],
|
||||
'phone' => $values['phone'] ?? '',
|
||||
'status' => $values['status'],
|
||||
'role' => $values['role'],
|
||||
'hours' => $values['hours'] ?? 0,
|
||||
'wage' => $values['wage'] ?? 0,
|
||||
'clientId' => $values['clientId'],
|
||||
'jobTitle' => $values['jobTitle'] ?? '',
|
||||
'jobLevel' => $values['jobLevel'] ?? '',
|
||||
'department' => $values['department'] ?? '',
|
||||
// 'modified' omitted from capture — non-deterministic timestamp.
|
||||
];
|
||||
|
||||
if (array_key_exists('weekly_hours', $values)) {
|
||||
$updateData['weekly_hours'] = $this->normalizeWeeklyHoursForTest($values['weekly_hours']);
|
||||
}
|
||||
if (array_key_exists('employment_type', $values)) {
|
||||
$updateData['employment_type'] = $this->normalizeEmploymentTypeForTest($values['employment_type']);
|
||||
}
|
||||
|
||||
$this->lastUpdate = $updateData;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bridges to the parent's private normalizers via reflection —
|
||||
// this lets the test exercise the SAME code path production
|
||||
// uses, not a copy that could drift.
|
||||
private function normalizeWeeklyHoursForTest(mixed $value): ?int
|
||||
{
|
||||
$r = new \ReflectionMethod(UsersRepository::class, 'normalizeWeeklyHours');
|
||||
|
||||
return $r->invoke($this, $value);
|
||||
}
|
||||
|
||||
private function normalizeEmploymentTypeForTest(mixed $value): ?string
|
||||
{
|
||||
$r = new \ReflectionMethod(UsersRepository::class, 'normalizeEmploymentType');
|
||||
|
||||
return $r->invoke($this, $value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum payload editUser expects, plus whatever keys the test
|
||||
* wants to override or add. Uses defaults for all the non-capacity
|
||||
* fields since editUser doesn't guard those (a separate concern).
|
||||
*/
|
||||
private function baseValues(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'user' => 'test@example.com',
|
||||
'phone' => '',
|
||||
'status' => 'a',
|
||||
'role' => 20,
|
||||
'clientId' => 0,
|
||||
], $overrides);
|
||||
}
|
||||
}
|
||||
99
tests/Unit/app/Domain/Users/Services/InviteRateLimitTest.php
Normal file
99
tests/Unit/app/Domain/Users/Services/InviteRateLimitTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Users\Services;
|
||||
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Support\Avatarcreator;
|
||||
use Leantime\Core\UI\Theme as ThemeCore;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
|
||||
use Leantime\Domain\Files\Services\Files;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Leantime\Domain\Users\Services\Users;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Regression guard for the invite-spam rate limit. When an inviter exceeds the per-user cap,
|
||||
* createUserInvite() must short-circuit and never reach the DB insert — proving the limiter is
|
||||
* the real backstop for every entry point (web, JSON-RPC, resend all funnel through here).
|
||||
*/
|
||||
class InviteRateLimitTest extends TestCase
|
||||
{
|
||||
private const INVITER_ID = 4242;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// The array cache store persists within a single test run, so clear the keys this test
|
||||
// touches to keep it independent of ordering and of any prior limiter state.
|
||||
foreach ($this->limiterKeys() as $key) {
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->limiterKeys() as $key) {
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_create_user_invite_returns_false_and_skips_db_when_user_cap_exceeded(): void
|
||||
{
|
||||
session(['userdata' => ['id' => self::INVITER_ID, 'name' => 'Inviter', 'mail' => 'inviter@example.com']]);
|
||||
|
||||
// Exhaust the per-user hourly cap (default 10) on the exact key the service computes.
|
||||
[$userKey] = $this->limiterKeys();
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
RateLimiter::hit($userKey, 3600);
|
||||
}
|
||||
|
||||
// The DB layer must never be touched once the cap is hit.
|
||||
$userRepo = $this->createMock(UserRepository::class);
|
||||
$userRepo->expects($this->never())->method('addUser');
|
||||
|
||||
$service = new Users(
|
||||
$userRepo,
|
||||
$this->createMock(LanguageCore::class),
|
||||
$this->createMock(ProjectRepository::class),
|
||||
$this->createMock(ClientRepository::class),
|
||||
$this->createMock(AuthService::class),
|
||||
$this->createMock(Files::class),
|
||||
$this->createMock(Avatarcreator::class),
|
||||
$this->createMock(SettingService::class),
|
||||
$this->createMock(ThemeCore::class),
|
||||
$this->createMock(ProjectService::class),
|
||||
);
|
||||
|
||||
$result = $service->createUserInvite([
|
||||
'user' => 'newuser@example.com',
|
||||
'firstname' => 'New',
|
||||
'lastname' => 'User',
|
||||
'role' => '20',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result, 'createUserInvite must return false once the invite cap is exceeded');
|
||||
}
|
||||
|
||||
/**
|
||||
* The user + tenant limiter keys, computed exactly as Users::invitesRateLimited() does.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function limiterKeys(): array
|
||||
{
|
||||
$scope = defined('BASE_URL') ? BASE_URL : 'default';
|
||||
|
||||
return [
|
||||
'invites:'.$scope.':user:'.self::INVITER_ID,
|
||||
'invites:'.$scope.':tenant',
|
||||
];
|
||||
}
|
||||
}
|
||||
509
tests/Unit/app/Domain/Users/Services/UsersServiceTest.php
Normal file
509
tests/Unit/app/Domain/Users/Services/UsersServiceTest.php
Normal file
@@ -0,0 +1,509 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Users\Services;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\PermissionService;
|
||||
use Leantime\Core\Exceptions\AuthorizationException;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\Support\Avatarcreator;
|
||||
use Leantime\Core\UI\Theme as ThemeCore;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
|
||||
use Leantime\Domain\Files\Services\Files;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the Users service helpers extracted during the
|
||||
* thin-controller refactor (saveModalDismissal).
|
||||
*/
|
||||
class UsersServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Users service with mocked dependencies, injecting the
|
||||
* provided (stubbed) repository so we can observe persistence calls.
|
||||
* Optional overrides let individual tests swap in stubbed collaborators.
|
||||
*
|
||||
* @param array<string, mixed> $overrides Keyed by dependency short name.
|
||||
*/
|
||||
private function makeService(UserRepository $userRepo, array $overrides = []): UserService
|
||||
{
|
||||
return new UserService(
|
||||
$userRepo,
|
||||
$overrides['language'] ?? $this->make(LanguageCore::class),
|
||||
$overrides['projectRepository'] ?? $this->make(ProjectRepository::class),
|
||||
$overrides['clientRepo'] ?? $this->make(ClientRepository::class),
|
||||
$overrides['authService'] ?? $this->make(AuthService::class),
|
||||
$overrides['fileService'] ?? $this->make(Files::class),
|
||||
$overrides['avatarcreator'] ?? $this->make(Avatarcreator::class),
|
||||
$overrides['settingsService'] ?? $this->make(SettingService::class),
|
||||
$overrides['themeCore'] ?? $this->make(ThemeCore::class),
|
||||
$overrides['projectService'] ?? $this->make(ProjectService::class),
|
||||
);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
session(['userdata.id' => 1]);
|
||||
session()->forget('usersettings');
|
||||
}
|
||||
|
||||
public function test_session_only_dismissal_records_session_without_persisting(): void
|
||||
{
|
||||
$persistCalls = 0;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'patchUser' => function () use (&$persistCalls) {
|
||||
$persistCalls++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($repo)->saveModalDismissal('welcomeModal', false);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(1, session('usersettings.modals.welcomeModal'));
|
||||
$this->assertSame(0, $persistCalls, 'A non-permanent dismissal must not touch the repository');
|
||||
}
|
||||
|
||||
public function test_permanent_dismissal_persists_to_user_settings(): void
|
||||
{
|
||||
$persistCalls = 0;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $params) use (&$persistCalls) {
|
||||
$persistCalls++;
|
||||
|
||||
// The service must persist the serialized usersettings blob.
|
||||
$this->assertArrayHasKey('settings', $params);
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($repo)->saveModalDismissal('welcomeModal', true);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame('1', session('usersettings.modals.welcomeModal'));
|
||||
$this->assertSame(1, $persistCalls, 'A permanent dismissal must persist via the repository');
|
||||
}
|
||||
|
||||
public function test_get_user_project_ids_flattens_relation_rows(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class);
|
||||
$projectService = $this->make(ProjectService::class, [
|
||||
'getUserProjectRelation' => fn () => [
|
||||
['projectId' => 5],
|
||||
['projectId' => 9],
|
||||
['projectId' => 12],
|
||||
],
|
||||
]);
|
||||
|
||||
$ids = $this->makeService($repo, ['projectService' => $projectService])->getUserProjectIds(3);
|
||||
|
||||
$this->assertSame([5, 9, 12], $ids);
|
||||
}
|
||||
|
||||
public function test_validate_user_update_rejects_empty_username(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->validateUserUpdate(
|
||||
['user' => ''],
|
||||
['username' => 'old@example.com'],
|
||||
7,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame('passwords_dont_match', $result);
|
||||
}
|
||||
|
||||
public function test_validate_user_update_rejects_invalid_email(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->validateUserUpdate(
|
||||
['user' => 'not-an-email'],
|
||||
['username' => 'old@example.com'],
|
||||
7,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame('no_valid_email', $result);
|
||||
}
|
||||
|
||||
public function test_validate_user_update_rejects_taken_email_on_change(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'usernameExist' => fn () => true,
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->validateUserUpdate(
|
||||
['user' => 'new@example.com'],
|
||||
['username' => 'old@example.com'],
|
||||
7,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame('user_exists', $result);
|
||||
}
|
||||
|
||||
public function test_validate_user_update_passes_for_unchanged_valid_email(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'usernameExist' => fn () => true,
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
// Email unchanged, so usernameExist must NOT block it.
|
||||
$result = $service->validateUserUpdate(
|
||||
['user' => 'same@example.com'],
|
||||
['username' => 'same@example.com'],
|
||||
7,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame('valid', $result);
|
||||
}
|
||||
|
||||
public function test_invite_new_user_rejects_invalid_email(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'usernameExist' => fn () => false,
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->inviteNewUser(
|
||||
['user' => 'nope'],
|
||||
sessionClientId: null,
|
||||
isManager: false
|
||||
);
|
||||
|
||||
$this->assertSame('no_valid_email', $result);
|
||||
}
|
||||
|
||||
public function test_invite_new_user_rejects_existing_user(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'usernameExist' => fn () => true,
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->inviteNewUser(
|
||||
['user' => 'taken@example.com'],
|
||||
sessionClientId: null,
|
||||
isManager: false
|
||||
);
|
||||
|
||||
$this->assertSame('user_exists', $result);
|
||||
}
|
||||
|
||||
public function test_change_own_password_rejects_wrong_current_password(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'id' => 1,
|
||||
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
|
||||
'firstname' => 'A',
|
||||
'lastname' => 'B',
|
||||
'username' => 'a@b.com',
|
||||
'phone' => '',
|
||||
'notifications' => 1,
|
||||
'twoFAEnabled' => 0,
|
||||
],
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->changeOwnPassword(1, 'wrong', 'NewPass1!', 'NewPass1!');
|
||||
|
||||
$this->assertSame('previous_password_incorrect', $result);
|
||||
}
|
||||
|
||||
public function test_change_own_password_rejects_mismatched_confirmation(): void
|
||||
{
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'id' => 1,
|
||||
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
|
||||
'firstname' => 'A',
|
||||
'lastname' => 'B',
|
||||
'username' => 'a@b.com',
|
||||
'phone' => '',
|
||||
'notifications' => 1,
|
||||
'twoFAEnabled' => 0,
|
||||
],
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->changeOwnPassword(1, 'correct-horse', 'NewPass1!', 'Different1!');
|
||||
|
||||
$this->assertSame('passwords_dont_match', $result);
|
||||
}
|
||||
|
||||
public function test_change_own_password_persists_on_success(): void
|
||||
{
|
||||
$savedValues = null;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'id' => 1,
|
||||
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
|
||||
'firstname' => 'A',
|
||||
'lastname' => 'B',
|
||||
'username' => 'a@b.com',
|
||||
'phone' => '',
|
||||
'notifications' => 1,
|
||||
'twoFAEnabled' => 0,
|
||||
],
|
||||
'editOwn' => function ($values) use (&$savedValues) {
|
||||
$savedValues = $values;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->changeOwnPassword(1, 'correct-horse', 'NewPass1!', 'NewPass1!');
|
||||
|
||||
$this->assertSame('success', $result);
|
||||
$this->assertSame('NewPass1!', $savedValues['password']);
|
||||
}
|
||||
|
||||
public function test_save_own_profile_blocks_duplicate_email(): void
|
||||
{
|
||||
$editCalls = 0;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => [
|
||||
'id' => 1,
|
||||
'firstname' => 'A',
|
||||
'lastname' => 'B',
|
||||
'username' => 'old@example.com',
|
||||
'phone' => '',
|
||||
'notifications' => 1,
|
||||
'twoFAEnabled' => 0,
|
||||
],
|
||||
'usernameExist' => fn () => true,
|
||||
'editOwn' => function () use (&$editCalls) {
|
||||
$editCalls++;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
$service = $this->makeService($repo);
|
||||
|
||||
$result = $service->saveOwnProfile(1, ['user' => 'taken@example.com']);
|
||||
|
||||
$this->assertSame('user_exists', $result);
|
||||
$this->assertSame(0, $editCalls, 'A duplicate email must not be persisted');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// searchProjectUsers() — JSON-RPC entry for the @mention autocomplete.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public function test_search_project_users_filters_by_query(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1], 'currentProject' => 5]);
|
||||
|
||||
$projectRepository = $this->make(ProjectRepository::class, [
|
||||
'isUserAssignedToProject' => fn () => true,
|
||||
'getProject' => fn () => ['psettings' => 'restricted', 'clientId' => 0],
|
||||
'getUsersAssignedToProject' => fn () => [
|
||||
['id' => 1, 'firstname' => 'Alice'],
|
||||
['id' => 2, 'firstname' => 'Bob'],
|
||||
],
|
||||
]);
|
||||
|
||||
$users = $this->makeService($this->make(UserRepository::class), ['projectRepository' => $projectRepository])
|
||||
->searchProjectUsers(5, 'alice');
|
||||
|
||||
$this->assertCount(1, $users);
|
||||
$this->assertSame('Alice', $users[0]['firstname']);
|
||||
}
|
||||
|
||||
public function test_search_project_users_returns_empty_without_project_access(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 1], 'currentProject' => 5]);
|
||||
|
||||
$projectRepository = $this->make(ProjectRepository::class, [
|
||||
'isUserAssignedToProject' => fn () => false,
|
||||
]);
|
||||
|
||||
$users = $this->makeService($this->make(UserRepository::class), ['projectRepository' => $projectRepository])
|
||||
->searchProjectUsers(5);
|
||||
|
||||
$this->assertSame([], $users);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Authorization. The company-wide manage-others methods
|
||||
// (editUser/updateUser/addUser/getAll/…) gate via the dispatch-time
|
||||
// #[RequiresPermission(global: true)] attribute (covered by PermissionEnforcerTest).
|
||||
// These two methods authorize in their own body, so they gate on direct calls too.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private function denyingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'currentUserCan' => fn () => false,
|
||||
'authorize' => function (): void {
|
||||
throw new AuthorizationException;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private function allowingPermissions(): PermissionService
|
||||
{
|
||||
return $this->make(PermissionService::class, [
|
||||
'currentUserCan' => fn () => true,
|
||||
'authorize' => fn () => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_user_throws_without_delete_permission(): void
|
||||
{
|
||||
$service = $this->makeService($this->make(UserRepository::class));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
$this->expectException(AuthorizationException::class);
|
||||
|
||||
$service->deleteUser(5);
|
||||
}
|
||||
|
||||
public function test_patch_user_allows_self_with_limited_fields_without_edit_permission(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$patched = [];
|
||||
$service = $this->makeService($this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $fields) use (&$patched) {
|
||||
$patched = ['id' => $id, 'fields' => $fields];
|
||||
|
||||
return true;
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions()); // no users.edit
|
||||
|
||||
// Editing OWN account (id === session user) is allowed even without users.edit...
|
||||
$result = $service->patchUser(7, ['firstname' => 'Bob', 'role' => '50']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(7, $patched['id']);
|
||||
$this->assertArrayHasKey('firstname', $patched['fields']);
|
||||
// ...but the privileged 'role' field is stripped — no self privilege-escalation.
|
||||
$this->assertArrayNotHasKey('role', $patched['fields']);
|
||||
}
|
||||
|
||||
public function test_patch_user_denies_other_account_without_edit_permission(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$service = $this->makeService($this->make(UserRepository::class, [
|
||||
'patchUser' => fn () => true,
|
||||
]));
|
||||
$service->setPermissionService($this->denyingPermissions());
|
||||
|
||||
// Patching ANOTHER account without users.edit must fail (closes the RPC escalation hole).
|
||||
$this->assertFalse($service->patchUser(99, ['role' => '50']));
|
||||
}
|
||||
|
||||
public function test_patch_user_allows_other_account_with_edit_permission(): void
|
||||
{
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$patched = [];
|
||||
$service = $this->makeService($this->make(UserRepository::class, [
|
||||
'patchUser' => function ($id, $fields) use (&$patched) {
|
||||
$patched = ['id' => $id, 'fields' => $fields];
|
||||
|
||||
return true;
|
||||
},
|
||||
]));
|
||||
$service->setPermissionService($this->allowingPermissions()); // has users.edit
|
||||
|
||||
$result = $service->patchUser(99, ['role' => '20']);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame(99, $patched['id']);
|
||||
// A users.edit holder may set privileged fields on another account.
|
||||
$this->assertArrayHasKey('role', $patched['fields']);
|
||||
}
|
||||
|
||||
public function test_self_service_methods_ignore_caller_supplied_id_and_pin_to_session(): void
|
||||
{
|
||||
// Self-service methods (editOwn/saveOwn*/getOwn*/changeOwnPassword) must operate on the
|
||||
// authenticated user only — over JSON-RPC a caller controls the $userId argument, so a
|
||||
// foreign id must NOT be honored (otherwise it is a cross-account IDOR). Representative
|
||||
// check via changeOwnPassword: the credential lookup must hit the SESSION user (7), not
|
||||
// the attacker-supplied id (99).
|
||||
session(['userdata' => ['id' => 7]]);
|
||||
|
||||
$seenId = null;
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => function ($id) use (&$seenId) {
|
||||
$seenId = $id;
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'password' => password_hash('correct-horse', PASSWORD_DEFAULT),
|
||||
'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b.com',
|
||||
'phone' => '', 'notifications' => 1, 'twoFAEnabled' => 0,
|
||||
];
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService($repo)->changeOwnPassword(99, 'wrong', 'NewPass1!', 'NewPass1!');
|
||||
|
||||
$this->assertSame(7, $seenId, 'self-service must pin to the session user, not the caller-supplied id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for #3556: getUser is @api and intentionally ungated, so any
|
||||
* authenticated client can request an arbitrary id. It must never return
|
||||
* credentials — password hash, plaintext 2FA seed, session token, or the
|
||||
* password-reset token/metadata — while keeping the safe profile fields
|
||||
* the view composers rely on.
|
||||
*/
|
||||
public function test_get_user_strips_sensitive_fields_from_api_response(): void
|
||||
{
|
||||
$fullRow = [
|
||||
'id' => 5,
|
||||
'firstname' => 'Ada',
|
||||
'lastname' => 'Lovelace',
|
||||
'username' => 'ada@example.com',
|
||||
'role' => '20',
|
||||
'password' => '$2y$10$abcdefghijklmnopqrstuv',
|
||||
'twoFASecret' => 'SECRET2FASEED',
|
||||
'session' => 'sess-token-xyz',
|
||||
'sessiontime' => '1700000000',
|
||||
'pwReset' => 'reset-token',
|
||||
'pwResetExpiration' => '2026-01-01 00:00:00',
|
||||
'pwResetCount' => 2,
|
||||
];
|
||||
|
||||
$repo = $this->make(UserRepository::class, [
|
||||
'getUser' => fn () => $fullRow,
|
||||
]);
|
||||
|
||||
$user = $this->makeService($repo)->getUser(5);
|
||||
|
||||
$this->assertIsArray($user);
|
||||
// Safe profile fields survive so composers/avatars keep working.
|
||||
$this->assertSame('Ada', $user['firstname']);
|
||||
$this->assertSame('ada@example.com', $user['username']);
|
||||
|
||||
// Every credential/session/reset field is stripped.
|
||||
foreach (['password', 'twoFASecret', 'session', 'sessiontime', 'pwReset', 'pwResetExpiration', 'pwResetCount'] as $secret) {
|
||||
$this->assertArrayNotHasKey($secret, $user, "getUser must not leak {$secret} over the API");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user