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(SystemAuditService::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(SystemAuditService::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(SystemAuditService::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(SystemAuditService::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(SystemAuditService::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, ?SystemAuditService $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(SystemAuditService::class), $sessionStore ?? $this->createMock(SessionStoreInterface::class) ); } }