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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user