OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
316
tests/Unit/app/Domain/Auth/Services/AuthServiceTest.php
Normal file
316
tests/Unit/app/Domain/Auth/Services/AuthServiceTest.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Auth\Services;
|
||||
|
||||
use Illuminate\Session\SessionManager;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
|
||||
use Leantime\Domain\Auth\Repositories\Auth as AuthRepository;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the pure/business logic extracted into the Auth service during
|
||||
* the thin-controller refactor (resolveSafeRedirect, shouldHideLoginForm,
|
||||
* checkPasswordStrength, resetPassword).
|
||||
*/
|
||||
class AuthServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Auth service with mocked dependencies. The Environment and
|
||||
* Setting repository can be overridden so config/setting driven behavior can
|
||||
* be exercised.
|
||||
*/
|
||||
private function makeService(
|
||||
?EnvironmentCore $config = null,
|
||||
?SettingRepository $settingsRepo = null,
|
||||
?AuthRepository $authRepo = null
|
||||
): AuthService {
|
||||
return new AuthService(
|
||||
$config ?? $this->make(EnvironmentCore::class),
|
||||
$this->make(SessionManager::class),
|
||||
$this->make(LanguageCore::class),
|
||||
$settingsRepo ?? $this->make(SettingRepository::class),
|
||||
$authRepo ?? $this->make(AuthRepository::class),
|
||||
$this->make(UserRepository::class),
|
||||
$this->make(AccessTokenRepository::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_defaults_to_dashboard(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(null));
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(''));
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect('/'));
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_allows_internal_path(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(BASE_URL.'/tickets/showAll', $service->resolveSafeRedirect('tickets/showAll'));
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_blocks_external_url(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// An absolute external URL is a valid URL, so it is rejected and the
|
||||
// default dashboard target is returned instead.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('https://evil.example.com')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_allows_same_origin_absolute_url(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Same-origin absolute URL — must be treated the same as a relative
|
||||
// path by stripping the BASE_URL prefix. This is the exact scenario
|
||||
// the maintainer flagged: the login form often submits a full
|
||||
// absolute URL in the redirectUrl hidden field.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect(BASE_URL.'/dashboard/home')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_allows_same_origin_absolute_url_with_deep_path(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame(
|
||||
BASE_URL.'/tickets/showAll',
|
||||
$service->resolveSafeRedirect(BASE_URL.'/tickets/showAll')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_allows_url_encoded_same_origin_absolute_url(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// URL-encoded same-origin absolute URL — rawurldecode is called first,
|
||||
// then BASE_URL is stripped.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/tickets/showAll',
|
||||
$service->resolveSafeRedirect(urlencode(BASE_URL.'/tickets/showAll'))
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_rejects_external_url_disguised_with_base_url_prefix(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// An external URL whose path happens to start with the same characters
|
||||
// as BASE_URL — str_starts_with won't match because the scheme+host
|
||||
// differ. This gets rejected as external.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('https://evil.example.com/'.BASE_URL.'/dashboard/home')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_rejects_host_prefix_without_a_boundary(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// The real prefix hazard: a host that merely *begins* with our host, e.g.
|
||||
// BASE_URL https://host vs https://hostile.example.com. A bare
|
||||
// str_starts_with($url, BASE_URL) strips the prefix and rewrites this into the
|
||||
// bogus internal path /ile.example.com/pwn instead of rejecting it outright.
|
||||
// Stripping only on a boundary (end, '/', '?', '#') keeps it external.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect(BASE_URL.'ile.example.com/pwn')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_returns_dashboard_for_base_url_itself(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Exactly BASE_URL (with and without a trailing slash) has no path to go to —
|
||||
// it must fall back to the dashboard rather than the bare app root.
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(BASE_URL));
|
||||
$this->assertSame(BASE_URL.'/dashboard/home', $service->resolveSafeRedirect(BASE_URL.'/'));
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_blocks_logout_including_variants(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Redirecting to logout right after login is a forced-logout loop. An exact
|
||||
// string match on '/auth/logout' is walkable with a trailing slash, a query
|
||||
// string or different casing, so the normalized path is what gets compared.
|
||||
foreach ([
|
||||
'/auth/logout',
|
||||
'/auth/logout/',
|
||||
'auth/logout',
|
||||
'/auth/logout?next=/dashboard/home',
|
||||
'/auth/logout#x',
|
||||
'/AUTH/logout',
|
||||
BASE_URL.'/auth/logout',
|
||||
] as $variant) {
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect($variant),
|
||||
sprintf('logout variant "%s" must not be an accepted redirect target', $variant)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_strips_control_characters(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Encoded CR/LF must never reach the Location header, and leading whitespace
|
||||
// must not be usable to pad a protocol-relative URL past the '//' guard.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('%09//evil.example.com')
|
||||
);
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect(' //evil.example.com')
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
"\r",
|
||||
$service->resolveSafeRedirect('tickets/showAll%0d%0aSet-Cookie:x')
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
"\n",
|
||||
$service->resolveSafeRedirect('tickets/showAll%0d%0aSet-Cookie:x')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_preserves_plus_in_query_strings(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// rawurldecode (not urldecode) is used precisely so a '+' in a query string
|
||||
// survives instead of silently becoming a space.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/tickets/showAll?searchTerm=a+b',
|
||||
$service->resolveSafeRedirect('tickets/showAll?searchTerm=a+b')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_rejects_protocol_relative_url(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Protocol-relative URL (//attacker.com) — FILTER_VALIDATE_URL
|
||||
// treats these as valid URLs, so they are correctly rejected
|
||||
// and the default dashboard redirect is returned.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('//attacker.com')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_safe_redirect_rejects_backslash_protocol_trick(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
// Backslash variant (\/\/attacker.com) — some parsers treat
|
||||
// this as a protocol-relative URL. Verify it is rejected.
|
||||
$this->assertSame(
|
||||
BASE_URL.'/dashboard/home',
|
||||
$service->resolveSafeRedirect('\/\/attacker.com')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_check_password_strength_rejects_weak_and_accepts_strong(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertFalse($service->checkPasswordStrength('weak'));
|
||||
$this->assertFalse($service->checkPasswordStrength('alllowercase1!'));
|
||||
$this->assertFalse($service->checkPasswordStrength('NoNumber!!'));
|
||||
$this->assertFalse($service->checkPasswordStrength('NoSpecial123'));
|
||||
$this->assertFalse($service->checkPasswordStrength('Aa1!aaa')); // 7 chars
|
||||
$this->assertTrue($service->checkPasswordStrength('StrongPass1!'));
|
||||
}
|
||||
|
||||
public function test_reset_password_reports_mismatch(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame('mismatch', $service->resetPassword('', '', 'hash'));
|
||||
$this->assertSame('mismatch', $service->resetPassword('StrongPass1!', 'Different1!', 'hash'));
|
||||
}
|
||||
|
||||
public function test_reset_password_reports_weak(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$this->assertSame('weak', $service->resetPassword('weak', 'weak', 'hash'));
|
||||
}
|
||||
|
||||
public function test_reset_password_success_and_error_map_to_repository(): void
|
||||
{
|
||||
$successRepo = $this->make(AuthRepository::class, [
|
||||
'changePW' => fn () => true,
|
||||
]);
|
||||
$this->assertSame('success', $this->makeService(null, null, $successRepo)
|
||||
->resetPassword('StrongPass1!', 'StrongPass1!', 'hash'));
|
||||
|
||||
$failRepo = $this->make(AuthRepository::class, [
|
||||
'changePW' => fn () => false,
|
||||
]);
|
||||
$this->assertSame('error', $this->makeService(null, null, $failRepo)
|
||||
->resetPassword('StrongPass1!', 'StrongPass1!', 'hash'));
|
||||
}
|
||||
|
||||
public function test_should_hide_login_form_when_setting_on(): void
|
||||
{
|
||||
$settingsRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => 'on',
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->makeService(null, $settingsRepo)->shouldHideLoginForm());
|
||||
}
|
||||
|
||||
public function test_should_hide_login_form_falls_back_to_config(): void
|
||||
{
|
||||
$config = new EnvironmentCore;
|
||||
$config->set('disableLoginForm', true);
|
||||
|
||||
$settingsRepo = $this->make(SettingRepository::class, [
|
||||
'getSetting' => fn () => false,
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->makeService($config, $settingsRepo)->shouldHideLoginForm());
|
||||
|
||||
$config2 = new EnvironmentCore;
|
||||
$config2->set('disableLoginForm', false);
|
||||
|
||||
$this->assertFalse($this->makeService($config2, $settingsRepo)->shouldHideLoginForm());
|
||||
}
|
||||
|
||||
public function test_login_input_placeholder_depends_on_ldap(): void
|
||||
{
|
||||
$ldapConfig = new EnvironmentCore;
|
||||
$ldapConfig->set('useLdap', true);
|
||||
$this->assertSame(
|
||||
'input.placeholders.enter_email_or_username',
|
||||
$this->makeService($ldapConfig)->getLoginInputPlaceholder()
|
||||
);
|
||||
|
||||
$noLdapConfig = new EnvironmentCore;
|
||||
$noLdapConfig->set('useLdap', false);
|
||||
$this->assertSame(
|
||||
'input.placeholders.enter_email',
|
||||
$this->makeService($noLdapConfig)->getLoginInputPlaceholder()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Auth\Services;
|
||||
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Auth\Services\AuthUser;
|
||||
|
||||
/**
|
||||
* Regression guard for the 3.9.x Bearer-auth role bug.
|
||||
*
|
||||
* AuthUser is the userdata builder on the Sanctum (Bearer) guard path: AccessToken::findToken ->
|
||||
* AuthUser::setUser -> setUserSession. It stored the RAW DB role int ("50") in session('userdata'),
|
||||
* while the permission engine's Auth::getRoleToCheck() validates the session role against
|
||||
* Roles::getRoles() (the role-NAME list). So "50" resolved to false and the engine denied every
|
||||
* #[RequiresPermission] @api method with -32001 — for every Bearer/Sanctum integrator, on any
|
||||
* server that exposes the Authorization header (production). CI missed it because its Apache hid
|
||||
* the header, routing Bearer through the fallback path (which builds userdata via
|
||||
* Api::setApiUserSession, and that one DOES convert the role).
|
||||
*
|
||||
* The fix: AuthUser::setUserSession must store the role NAME string, matching the other two
|
||||
* userdata builders. This asserts the resulting session role is engine-valid for every built-in
|
||||
* role — it FAILS on the raw-int bug and PASSES on the fix, independent of web server config.
|
||||
*/
|
||||
class AuthUserSessionRoleTest extends \Unit\TestCase
|
||||
{
|
||||
private function userRow(int $role): array
|
||||
{
|
||||
return [
|
||||
'id' => 1,
|
||||
'firstname' => 'Test',
|
||||
'username' => 'test@leantime.io',
|
||||
'profileId' => 0,
|
||||
'clientId' => 0,
|
||||
'role' => $role,
|
||||
'settings' => '',
|
||||
'twoFAEnabled' => false,
|
||||
'twoFASecret' => '',
|
||||
'createdOn' => '2026-01-01 00:00:00',
|
||||
'modified' => '2026-01-01 00:00:00',
|
||||
];
|
||||
}
|
||||
|
||||
public function test_sanctum_guard_session_role_is_engine_valid_for_every_builtin_role(): void
|
||||
{
|
||||
// setUserSession touches no instance state, so a constructor-less instance avoids the DB.
|
||||
$authUser = (new \ReflectionClass(AuthUser::class))->newInstanceWithoutConstructor();
|
||||
$setUserSession = new \ReflectionMethod(AuthUser::class, 'setUserSession');
|
||||
$setUserSession->setAccessible(true);
|
||||
|
||||
foreach (array_keys(Roles::getRoles()) as $roleInt) {
|
||||
session()->forget('userdata');
|
||||
|
||||
$setUserSession->invoke($authUser, $this->userRow((int) $roleInt));
|
||||
|
||||
$sessionRole = session('userdata.role');
|
||||
|
||||
$this->assertContains(
|
||||
$sessionRole,
|
||||
Roles::getRoles(),
|
||||
"AuthUser stored an engine-invalid role for DB int $roleInt: ".var_export($sessionRole, true)
|
||||
);
|
||||
$this->assertNotFalse(
|
||||
Auth::getRoleToCheck(false),
|
||||
"getRoleToCheck() rejected the Sanctum-guard session role for DB int $roleInt"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
161
tests/Unit/app/Domain/Auth/Services/OnboardingServiceTest.php
Normal file
161
tests/Unit/app/Domain/Auth/Services/OnboardingServiceTest.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Auth\Services;
|
||||
|
||||
use Leantime\Core\Language as LanguageCore;
|
||||
use Leantime\Core\UI\Theme;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Auth\Services\Onboarding as OnboardingService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for the onboarding/invite business logic extracted from the
|
||||
* UserInvite controller during the thin-controller refactor.
|
||||
*/
|
||||
class OnboardingServiceTest extends TestCase
|
||||
{
|
||||
use \Codeception\Test\Feature\Stub;
|
||||
|
||||
/**
|
||||
* Builds a real Onboarding service with mocked dependencies.
|
||||
*/
|
||||
private function makeService(
|
||||
?UserService $userService = null,
|
||||
?SettingService $settingService = null,
|
||||
?Theme $theme = null,
|
||||
?AuthService $authService = null
|
||||
): OnboardingService {
|
||||
return new OnboardingService(
|
||||
$authService ?? $this->make(AuthService::class),
|
||||
$userService ?? $this->make(UserService::class),
|
||||
$settingService ?? $this->make(SettingService::class),
|
||||
$theme ?? $this->make(Theme::class),
|
||||
$this->make(LanguageCore::class),
|
||||
);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
session()->forget('tempPassword');
|
||||
}
|
||||
|
||||
public function test_save_account_rejects_weak_password(): void
|
||||
{
|
||||
$userService = $this->make(UserService::class, [
|
||||
'checkPasswordStrength' => fn () => false,
|
||||
'editUser' => function () {
|
||||
$this->fail('editUser must not be called for a weak password');
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($userService)->saveAccount(
|
||||
['id' => 5, 'username' => 'jane@example.com'],
|
||||
'Jane Doe',
|
||||
'Engineer',
|
||||
'weak'
|
||||
);
|
||||
|
||||
$this->assertSame('weak', $result);
|
||||
$this->assertNull(session('tempPassword'));
|
||||
}
|
||||
|
||||
public function test_save_account_splits_name_and_persists(): void
|
||||
{
|
||||
$captured = null;
|
||||
$userService = $this->make(UserService::class, [
|
||||
'checkPasswordStrength' => fn () => true,
|
||||
'editUser' => function ($values, $id) use (&$captured) {
|
||||
$captured = ['values' => $values, 'id' => $id];
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $this->makeService($userService)->saveAccount(
|
||||
['id' => 5, 'username' => 'jane@example.com'],
|
||||
'Jane Doe',
|
||||
'Engineer',
|
||||
'StrongPass1!'
|
||||
);
|
||||
|
||||
$this->assertSame('saved', $result);
|
||||
$this->assertSame(5, $captured['id']);
|
||||
$this->assertSame('Jane', $captured['values']['firstname']);
|
||||
$this->assertSame('Doe', $captured['values']['lastname']);
|
||||
$this->assertSame('Engineer', $captured['values']['jobTitle']);
|
||||
$this->assertSame('i', $captured['values']['status']);
|
||||
$this->assertSame('jane@example.com', $captured['values']['user']);
|
||||
$this->assertSame('StrongPass1!', $captured['values']['password']);
|
||||
// Temp password is stored so the user can be auto-logged-in later.
|
||||
$this->assertSame('StrongPass1!', session('tempPassword'));
|
||||
}
|
||||
|
||||
public function test_save_account_handles_single_word_name(): void
|
||||
{
|
||||
$captured = null;
|
||||
$userService = $this->make(UserService::class, [
|
||||
'checkPasswordStrength' => fn () => true,
|
||||
'editUser' => function ($values) use (&$captured) {
|
||||
$captured = $values;
|
||||
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
$this->makeService($userService)->saveAccount(
|
||||
['id' => 9, 'username' => 'mono@example.com'],
|
||||
'Cher',
|
||||
'',
|
||||
'StrongPass1!'
|
||||
);
|
||||
|
||||
$this->assertSame('Cher', $captured['firstname']);
|
||||
$this->assertSame('', $captured['lastname']);
|
||||
}
|
||||
|
||||
public function test_save_account_reports_error_when_persist_fails(): void
|
||||
{
|
||||
$userService = $this->make(UserService::class, [
|
||||
'checkPasswordStrength' => fn () => true,
|
||||
'editUser' => fn () => false,
|
||||
]);
|
||||
|
||||
$result = $this->makeService($userService)->saveAccount(
|
||||
['id' => 5, 'username' => 'jane@example.com'],
|
||||
'Jane Doe',
|
||||
'Engineer',
|
||||
'StrongPass1!'
|
||||
);
|
||||
|
||||
$this->assertSame('error', $result);
|
||||
}
|
||||
|
||||
public function test_get_invite_settings_applies_defaults_when_unset(): void
|
||||
{
|
||||
$settingService = $this->make(SettingService::class, [
|
||||
'getSetting' => fn () => false,
|
||||
]);
|
||||
|
||||
$theme = $this->make(Theme::class, [
|
||||
'getAvailableColorSchemes' => fn () => ['companyColors'],
|
||||
'getAvailableFonts' => fn () => ['Roboto'],
|
||||
'getAll' => fn () => ['default'],
|
||||
]);
|
||||
|
||||
$settings = $this->makeService(null, $settingService, $theme)
|
||||
->getInviteSettings(['id' => 7]);
|
||||
|
||||
$this->assertSame('default', $settings['userTheme']);
|
||||
$this->assertSame('light', $settings['userColorMode']);
|
||||
$this->assertSame('companyColors', $settings['userColorScheme']);
|
||||
$this->assertSame('Roboto', $settings['themeFont']);
|
||||
$this->assertSame($this->makeService()->getDefaultWorkdays(), $settings['workdays']);
|
||||
$this->assertSame($this->makeService()->getDefaultDaySchedule(), $settings['daySchedule']);
|
||||
$this->assertArrayHasKey('dayHourOptions', $settings);
|
||||
$this->assertArrayHasKey('dateTimeValues', $settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Unit\app\Domain\Auth\Services;
|
||||
|
||||
use Leantime\Domain\Api\Services\Api;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\AuthUser;
|
||||
use Leantime\Domain\Auth\Services\UserSessionBuilder;
|
||||
|
||||
/**
|
||||
* Guards the userdata-builder bug family (3.9.x Bearer regression + the twoFAVerified twin).
|
||||
*
|
||||
* Every auth path now builds session('userdata') through UserSessionBuilder, so a field can't
|
||||
* silently drift between paths. These tests pin the two invariants that historically broke:
|
||||
* - role is ALWAYS the engine-valid NAME string (never the raw DB int), for every built-in role;
|
||||
* - the two token paths (Sanctum/Bearer via AuthUser, x-api-key via Api) agree on role +
|
||||
* twoFAVerified.
|
||||
*/
|
||||
class UserSessionBuilderTest extends \Unit\TestCase
|
||||
{
|
||||
private function userRow(int $role): array
|
||||
{
|
||||
return [
|
||||
'id' => 1,
|
||||
'firstname' => 'Test',
|
||||
'username' => 'test@leantime.io',
|
||||
'profileId' => 0,
|
||||
'clientId' => 0,
|
||||
'role' => $role,
|
||||
'settings' => '',
|
||||
'twoFAEnabled' => false,
|
||||
'twoFASecret' => '',
|
||||
'createdOn' => '2026-01-01 00:00:00',
|
||||
'modified' => '2026-01-01 00:00:00',
|
||||
];
|
||||
}
|
||||
|
||||
public function test_role_is_engine_valid_name_string_for_every_builtin_role(): void
|
||||
{
|
||||
foreach (array_keys(Roles::getRoles()) as $roleInt) {
|
||||
$userdata = UserSessionBuilder::build($this->userRow((int) $roleInt));
|
||||
|
||||
$this->assertSame(Roles::getRoleString((int) $roleInt), $userdata['role']);
|
||||
$this->assertContains(
|
||||
$userdata['role'],
|
||||
Roles::getRoles(),
|
||||
"Factory produced an engine-invalid role for DB int $roleInt: ".var_export($userdata['role'], true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_flags_are_honored(): void
|
||||
{
|
||||
$tokenSession = UserSessionBuilder::build($this->userRow(50), isExternalAuth: true, twoFAVerified: true);
|
||||
$this->assertTrue($tokenSession['isExternalAuth']);
|
||||
$this->assertTrue($tokenSession['twoFAVerified']);
|
||||
|
||||
$default = UserSessionBuilder::build($this->userRow(50));
|
||||
$this->assertFalse($default['isExternalAuth']);
|
||||
$this->assertFalse($default['twoFAVerified']);
|
||||
}
|
||||
|
||||
public function test_both_token_paths_build_consistent_role_and_twofa(): void
|
||||
{
|
||||
// The Sanctum/Bearer path (AuthUser) and the x-api-key path (Api) are both token auth and
|
||||
// must produce the same role + twoFAVerified — these are the exact two fields that drifted.
|
||||
// setUserSession/setApiUserSession touch no instance state, so construct without the DB.
|
||||
$row = $this->userRow(50);
|
||||
|
||||
session()->forget('userdata');
|
||||
$authUser = (new \ReflectionClass(AuthUser::class))->newInstanceWithoutConstructor();
|
||||
$m = new \ReflectionMethod(AuthUser::class, 'setUserSession');
|
||||
$m->setAccessible(true);
|
||||
$m->invoke($authUser, $row);
|
||||
$guardSession = session('userdata');
|
||||
|
||||
session()->forget('userdata');
|
||||
$api = (new \ReflectionClass(Api::class))->newInstanceWithoutConstructor();
|
||||
$api->setApiUserSession($row, false);
|
||||
$apiKeySession = session('userdata');
|
||||
|
||||
$this->assertSame($guardSession['role'], $apiKeySession['role'], 'token paths disagree on role');
|
||||
$this->assertSame($guardSession['twoFAVerified'], $apiKeySession['twoFAVerified'], 'token paths disagree on twoFAVerified');
|
||||
$this->assertContains($guardSession['role'], Roles::getRoles());
|
||||
$this->assertTrue($guardSession['twoFAVerified'], 'token sessions should be 2FA-verified');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user