Files
breadcrumb-the-shire/tests/Service/Auth/AuthServiceTest.php

483 lines
19 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Auth\AuthService;
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
use MintyPHP\Service\Auth\EmailVerificationService;
use MintyPHP\Service\Auth\RememberMeService;
use MintyPHP\Service\Auth\TenantSsoService;
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserTenantContextService;
use PHPUnit\Framework\TestCase;
class AuthServiceTest extends TestCase
{
2026-03-19 20:35:15 +01:00
private array $previousSession = [];
protected function setUp(): void
{
parent::setUp();
$this->previousSession = $_SESSION ?? [];
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$_SESSION = [];
}
protected function tearDown(): void
{
$_SESSION = $this->previousSession;
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
parent::tearDown();
}
// ---------------------------------------------------------------
// canLoginToTenant
// ---------------------------------------------------------------
public function testCanLoginToTenantReturnsFalseForInvalidUserId(): void
{
$service = $this->newService();
$this->assertFalse($service->canLoginToTenant(0, 5));
}
public function testCanLoginToTenantReturnsFalseForInvalidTenantId(): void
{
$service = $this->newService();
$this->assertFalse($service->canLoginToTenant(1, 0));
}
public function testCanLoginToTenantReturnsTrueWhenTenantIsAssigned(): void
{
$tenantCtx = $this->createMock(UserTenantContextService::class);
$tenantCtx->method('getAvailableTenants')->willReturn([
['id' => 3],
['id' => 7],
]);
$service = $this->newService(userTenantContextService: $tenantCtx);
$this->assertTrue($service->canLoginToTenant(1, 7));
}
public function testCanLoginToTenantReturnsFalseWhenTenantNotAssigned(): void
{
$tenantCtx = $this->createMock(UserTenantContextService::class);
$tenantCtx->method('getAvailableTenants')->willReturn([
['id' => 3],
]);
$service = $this->newService(userTenantContextService: $tenantCtx);
$this->assertFalse($service->canLoginToTenant(1, 99));
}
// ---------------------------------------------------------------
// refreshSessionAuthState
// ---------------------------------------------------------------
public function testRefreshSessionRequiresLogoutWhenUserIdIsZero(): void
{
$service = $this->newService();
$result = $service->refreshSessionAuthState(0);
$this->assertFalse($result['ok']);
$this->assertTrue($result['logout_required']);
$this->assertSame('user_missing', $result['reason']);
}
public function testRefreshSessionRequiresLogoutWhenUserNotFound(): void
{
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('findAuthzSnapshot')->willReturn(null);
$service = $this->newService(userReadRepository: $userRead);
$result = $service->refreshSessionAuthState(5);
$this->assertFalse($result['ok']);
$this->assertTrue($result['logout_required']);
$this->assertSame('user_not_found', $result['reason']);
}
public function testRefreshSessionRequiresLogoutWhenUserIsInactive(): void
{
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('findAuthzSnapshot')->willReturn(['active' => 0, 'authz_version' => 1]);
$service = $this->newService(userReadRepository: $userRead);
$result = $service->refreshSessionAuthState(5);
$this->assertFalse($result['ok']);
$this->assertTrue($result['logout_required']);
$this->assertSame('inactive', $result['reason']);
}
public function testRefreshSessionReturnsOkWhenVersionMatchesAndTenantActive(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], ['id' => 5, 'authz_version' => 2]],
['current_tenant', [], ['id' => 10]],
['no_active_tenant', false, false],
]);
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('findAuthzSnapshot')->willReturn(['active' => 1, 'authz_version' => 2]);
$service = $this->newService(userReadRepository: $userRead, sessionStore: $session);
$result = $service->refreshSessionAuthState(5);
$this->assertTrue($result['ok']);
$this->assertFalse($result['logout_required']);
$this->assertFalse($result['refreshed']);
}
public function testRefreshSessionRefreshesWhenVersionMismatch(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnCallback(function (string $key, mixed $default) {
if ($key === 'user') {
return ['id' => 5, 'authz_version' => 1];
}
if ($key === 'no_active_tenant') {
return false;
}
return $default;
});
$session->expects($this->atLeastOnce())->method('set');
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('findAuthzSnapshot')->willReturn(['active' => 1, 'authz_version' => 3]);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 1, 'authz_version' => 3]);
$permService = $this->createMock(PermissionService::class);
$permService->expects($this->once())->method('getUserPermissions')->with(5, true);
$tenantCtxService = $this->createMock(AuthSessionTenantContextService::class);
$tenantCtxService->expects($this->once())->method('hydrateForUser')->with(5);
$service = $this->newService(
userReadRepository: $userRead,
permissionService: $permService,
authSessionTenantContextService: $tenantCtxService,
sessionStore: $session
);
$result = $service->refreshSessionAuthState(5);
$this->assertTrue($result['ok']);
$this->assertFalse($result['logout_required']);
$this->assertTrue($result['refreshed']);
}
public function testRefreshSessionRequiresLogoutWhenNoActiveTenantAfterRefresh(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnCallback(function (string $key, mixed $default) {
if ($key === 'user') {
return ['id' => 5, 'authz_version' => 1];
}
if ($key === 'no_active_tenant') {
return true; // no active tenant
}
return $default;
});
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('findAuthzSnapshot')->willReturn(['active' => 1, 'authz_version' => 2]);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 1, 'authz_version' => 2]);
$service = $this->newService(userReadRepository: $userRead, sessionStore: $session);
$result = $service->refreshSessionAuthState(5);
$this->assertFalse($result['ok']);
$this->assertTrue($result['logout_required']);
$this->assertSame('no_active_tenant', $result['reason']);
}
// ---------------------------------------------------------------
// loginUserById
// ---------------------------------------------------------------
public function testLoginUserByIdReturnsErrorForZeroId(): void
{
$service = $this->newService();
$result = $service->loginUserById(0);
$this->assertFalse($result['ok']);
$this->assertSame('user_not_found', $result['error']);
}
public function testLoginUserByIdReturnsErrorWhenUserNotFound(): void
{
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(null);
$service = $this->newService(userReadRepository: $userRead);
$result = $service->loginUserById(5);
$this->assertFalse($result['ok']);
$this->assertSame('user_not_found', $result['error']);
}
public function testLoginUserByIdReturnsErrorWhenUserInactive(): void
{
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 0]);
$service = $this->newService(userReadRepository: $userRead);
$result = $service->loginUserById(5);
$this->assertFalse($result['ok']);
$this->assertSame('user_inactive', $result['error']);
}
public function testLoginUserByIdSucceedsWithActiveTenant(): void
{
$_SESSION = [];
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 1]);
$userWrite = $this->createMock(UserWriteRepositoryInterface::class);
$userWrite->expects($this->once())->method('updateLastLogin')->with(5, 'microsoft');
$session = $this->createMock(SessionStoreInterface::class);
$session->expects($this->atLeastOnce())->method('set');
$session->method('get')->willReturnMap([
['user', [], ['id' => 5, 'active' => 1]],
['current_tenant', [], ['id' => 10]],
['no_active_tenant', false, false],
]);
$permService = $this->createMock(PermissionService::class);
$permService->expects($this->once())->method('getUserPermissions')->with(5, true);
$tenantCtxService = $this->createMock(AuthSessionTenantContextService::class);
$tenantCtxService->expects($this->once())->method('hydrateForUser')->with(5);
$audit = $this->createMock(AuditRecorderInterface::class);
$audit->expects($this->once())->method('record')
->with('auth.login.success', 'success', $this->anything());
$service = $this->newService(
userReadRepository: $userRead,
userWriteRepository: $userWrite,
permissionService: $permService,
authSessionTenantContextService: $tenantCtxService,
systemAuditService: $audit,
sessionStore: $session
);
$result = $service->loginUserById(5, null, 'microsoft');
$this->assertTrue($result['ok']);
$this->assertSame(5, (int) ($result['user']['id'] ?? 0));
}
public function testLoginUserByIdFailsWhenNoActiveTenant(): void
{
$_SESSION = [];
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 1]);
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnCallback(function (string $key, mixed $default) {
if ($key === 'user') {
return ['id' => 5, 'active' => 1];
}
if ($key === 'no_active_tenant') {
return true;
}
return $default;
});
$audit = $this->createMock(AuditRecorderInterface::class);
$audit->expects($this->once())->method('record')
->with('auth.login.failed', 'failed', $this->anything());
$service = $this->newService(
userReadRepository: $userRead,
systemAuditService: $audit,
sessionStore: $session
);
$result = $service->loginUserById(5);
$this->assertFalse($result['ok']);
$this->assertSame('login_no_active_tenant', $result['error']);
}
public function testLoginUserByIdSetsPreferredTenantWhenProvided(): void
{
$_SESSION = [];
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 1]);
$tenantCtx = $this->createMock(UserTenantContextService::class);
$tenantCtx->expects($this->once())
->method('setCurrentTenant')
->with(5, 42)
->willReturn(['ok' => true]);
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], ['id' => 5, 'active' => 1]],
['current_tenant', [], ['id' => 42]],
['no_active_tenant', false, false],
]);
$session->expects($this->atLeastOnce())->method('set');
$service = $this->newService(
userReadRepository: $userRead,
userTenantContextService: $tenantCtx,
sessionStore: $session
);
$result = $service->loginUserById(5, 42, 'local');
$this->assertTrue($result['ok']);
}
// ---------------------------------------------------------------
// login — email not verified branch
// ---------------------------------------------------------------
public function testLoginReturnsNeedsVerificationWhenEmailNotVerified(): void
{
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('findByEmail')->willReturn([
'id' => 5,
'email' => 'test@example.com',
'email_verified_at' => null,
]);
$audit = $this->createMock(AuditRecorderInterface::class);
$audit->expects($this->once())->method('record')
->with('auth.login.failed', 'failed', $this->callback(function (array $ctx): bool {
return ($ctx['error_code'] ?? '') === 'email_not_verified';
}));
$service = $this->newService(userReadRepository: $userRead, systemAuditService: $audit);
$result = $service->login('test@example.com', 'password');
$this->assertFalse($result['ok']);
$this->assertTrue($result['needs_verification'] ?? false);
$this->assertSame('login_not_verified', $result['flash_key']);
}
// ---------------------------------------------------------------
// logout behavior
// ---------------------------------------------------------------
public function testLogoutDueToSessionTimeoutKeepsRememberTokenAndClearsSessionTimers(): void
{
$_SESSION = [];
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$removedKeys = [];
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], ['id' => 5]],
['current_tenant', [], ['id' => 9]],
]);
$session->method('remove')->willReturnCallback(function (string $key) use (&$removedKeys): void {
$removedKeys[] = $key;
});
$rememberMeService = $this->createMock(RememberMeService::class);
$rememberMeService->expects($this->never())->method('forgetCurrentUser');
$audit = $this->createMock(AuditRecorderInterface::class);
$audit->expects($this->once())->method('record')
->with('auth.session.timeout', 'success', $this->callback(function (array $context): bool {
return (int) ($context['actor_user_id'] ?? 0) === 5
&& (int) ($context['actor_tenant_id'] ?? 0) === 9;
}));
$service = $this->newService(
rememberMeService: $rememberMeService,
systemAuditService: $audit,
sessionStore: $session
);
$service->logoutDueToSessionTimeout();
sort($removedKeys);
$this->assertSame(['session_last_activity', 'session_started_at'], $removedKeys);
}
public function testLogoutStillForgetsRememberTokenOnManualLogout(): void
{
$_SESSION = [];
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$rememberMeService = $this->createMock(RememberMeService::class);
$rememberMeService->expects($this->once())->method('forgetCurrentUser');
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], ['id' => 5]],
['current_tenant', [], ['id' => 11]],
]);
$audit = $this->createMock(AuditRecorderInterface::class);
$audit->expects($this->once())->method('record')
->with('auth.logout', 'success', $this->callback(function (array $context): bool {
return (int) ($context['actor_user_id'] ?? 0) === 5
&& (int) ($context['actor_tenant_id'] ?? 0) === 11;
}));
$service = $this->newService(
rememberMeService: $rememberMeService,
systemAuditService: $audit,
sessionStore: $session
);
$service->logout();
}
// ---------------------------------------------------------------
// Helper
// ---------------------------------------------------------------
private function newService(
?UserReadRepositoryInterface $userReadRepository = null,
?UserWriteRepositoryInterface $userWriteRepository = null,
?UserAccountService $userAccountService = null,
?UserTenantContextService $userTenantContextService = null,
?RememberMeService $rememberMeService = null,
?EmailVerificationService $emailVerificationService = null,
?PermissionService $permissionService = null,
?TenantSsoService $tenantSsoService = null,
?AuthSessionTenantContextService $authSessionTenantContextService = null,
?AuditRecorderInterface $systemAuditService = null,
?SessionStoreInterface $sessionStore = null
): AuthService {
return new AuthService(
$userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class),
$userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class),
$userAccountService ?? $this->createMock(UserAccountService::class),
$userTenantContextService ?? $this->createMock(UserTenantContextService::class),
$rememberMeService ?? $this->createMock(RememberMeService::class),
$emailVerificationService ?? $this->createMock(EmailVerificationService::class),
$permissionService ?? $this->createMock(PermissionService::class),
$tenantSsoService ?? $this->createMock(TenantSsoService::class),
$authSessionTenantContextService ?? $this->createMock(AuthSessionTenantContextService::class),
$systemAuditService ?? $this->createMock(AuditRecorderInterface::class),
$sessionStore ?? $this->createMock(SessionStoreInterface::class)
);
}
}