OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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()
);
}
}

View File

@@ -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"
);
}
}
}

View 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);
}
}

View File

@@ -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');
}
}