From d06df56c49289670c7e7b5937c83eea6870aa533 Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 22 Apr 2026 18:57:43 +0200 Subject: [PATCH] test(security): cover tenant-scope, settings authz allow paths, auth gateways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster 1 der Testabdeckungs-Initiative (.agents/runs/TEST-SEC-COVERAGE-001). +38 Tests / +233 Assertions, nur tests/** veraendert. - SettingsAuthorizationPolicyTest: Allow-Pfade fuer UPDATE/BRANDING_UPDATE/ TOKENS_REVOKE/USER_LIFECYCLE_RUN plus Unknown-Ability — schliesst die Halb-Test-Luecke, die zuvor nur Deny-Pfade fuer 4 von 5 Abilities pruefte. - AssignableRoleServiceTest: neu (GR-TEST-001, vorher nur transitiv via UserAssignmentServiceTest gedeckt). - AuthCryptoGatewayTest: neu (Roundtrip + Fehlerpfad, GR-SEC-005). - AuthSettingsGatewayTest: neu (Delegation zu 5 Settings-Sub-Gateways). - UserTenantContextServiceTest: neu, 18 Tests, Tenant-Scope-Logik (GR-SEC-009) — current/primary/fallback, inactive-filter, assign-checks. LdapConnectionGateway und OidcHttpGateway bleiben bewusst ohne Direkttests: beide sind architektonische Test-Seams (dokumentiert im Docblock bzw. reiner statischer Curl-Delegator). Ihre Logik ist ueber Konsumenten-Tests (LdapAuthServiceTest, MicrosoftOidcServiceTest) bereits gedeckt. Gates: QG-001 (1945 Tests) / QG-002 (PHPStan L5) / QG-003 (Architecture) / QG-006 (php-cs-fixer) pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Access/AssignableRoleServiceTest.php | 222 +++++++++++++ .../SettingsAuthorizationPolicyTest.php | 69 ++++ tests/Service/Auth/AuthCryptoGatewayTest.php | 52 ++++ .../Service/Auth/AuthSettingsGatewayTest.php | 108 +++++++ .../User/UserTenantContextServiceTest.php | 294 ++++++++++++++++++ 5 files changed, 745 insertions(+) create mode 100644 tests/Service/Access/AssignableRoleServiceTest.php create mode 100644 tests/Service/Auth/AuthCryptoGatewayTest.php create mode 100644 tests/Service/Auth/AuthSettingsGatewayTest.php create mode 100644 tests/Service/User/UserTenantContextServiceTest.php diff --git a/tests/Service/Access/AssignableRoleServiceTest.php b/tests/Service/Access/AssignableRoleServiceTest.php new file mode 100644 index 0000000..f752cc3 --- /dev/null +++ b/tests/Service/Access/AssignableRoleServiceTest.php @@ -0,0 +1,222 @@ +makeService(); + + $this->assertSame([], $service->listAssignableRoleIdsForActor(0)); + $this->assertSame([], $service->listAssignableRoleIdsForActor(-5)); + } + + public function testListReturnsAllActiveRolesWhenActorHasAssignAllPermission(): void + { + $permissionService = $this->createMock(PermissionService::class); + $permissionService->expects($this->once()) + ->method('userHas') + ->with(42, PermissionService::ROLES_ASSIGN_ALL) + ->willReturn(true); + + $roleRepository = $this->createMock(RoleRepositoryInterface::class); + $roleRepository->expects($this->once()) + ->method('listActiveIds') + ->willReturn([1, 2, 3, 4]); + + $service = $this->makeService( + permissionService: $permissionService, + roleRepository: $roleRepository, + ); + + $this->assertSame([1, 2, 3, 4], $service->listAssignableRoleIdsForActor(42)); + } + + public function testListReturnsEmptyWhenActorHasNoRoles(): void + { + $permissionService = $this->createMock(PermissionService::class); + $permissionService->method('userHas')->willReturn(false); + + $userRoleRepository = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepository->expects($this->once()) + ->method('listRoleIdsByUserId') + ->with(42) + ->willReturn([]); + + $service = $this->makeService( + permissionService: $permissionService, + userRoleRepository: $userRoleRepository, + ); + + $this->assertSame([], $service->listAssignableRoleIdsForActor(42)); + } + + public function testListReturnsUnionOfAssignableRoleIdsFromActorRoles(): void + { + $permissionService = $this->createMock(PermissionService::class); + $permissionService->method('userHas')->willReturn(false); + + $userRoleRepository = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepository->method('listRoleIdsByUserId')->willReturn([10, 11]); + + $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); + $assignableRepo->expects($this->once()) + ->method('listAssignableRoleIdsByRoleIds') + ->with([10, 11]) + ->willReturn([20, 21, 22]); + + $service = $this->makeService( + permissionService: $permissionService, + userRoleRepository: $userRoleRepository, + assignableRoleRepository: $assignableRepo, + ); + + $this->assertSame([20, 21, 22], $service->listAssignableRoleIdsForActor(42)); + } + + public function testComputeEffectiveKeepsFrozenRolesAndAppliesSubmittedAssignableRoles(): void + { + $permissionService = $this->createMock(PermissionService::class); + $permissionService->method('userHas')->willReturn(false); + + $userRoleRepository = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepository->method('listRoleIdsByUserId')->willReturn([10]); + + $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); + $assignableRepo->method('listAssignableRoleIdsByRoleIds')->willReturn([20, 21]); + + $service = $this->makeService( + permissionService: $permissionService, + userRoleRepository: $userRoleRepository, + assignableRoleRepository: $assignableRepo, + ); + + // currentIds contains a non-assignable role 99 (frozen) plus assignable 20 (touchable). + // submittedIds drops 20, adds 21, attempts to add 99 again and a forbidden 55. + $result = $service->computeEffectiveRoleIds( + actorUserId: 42, + submittedIds: [21, 99, 55], + currentIds: [99, 20], + ); + + // Frozen: 99. Touched: 21. 55 is not assignable so dropped. 20 removed because actor chose not to keep it. + $this->assertSame([99, 21], $result); + } + + public function testComputeEffectiveDropsNonAssignableSubmittedIds(): void + { + $permissionService = $this->createMock(PermissionService::class); + $permissionService->method('userHas')->willReturn(true); + + $roleRepository = $this->createMock(RoleRepositoryInterface::class); + $roleRepository->method('listActiveIds')->willReturn([1, 2, 3]); + + $service = $this->makeService( + permissionService: $permissionService, + roleRepository: $roleRepository, + ); + + $result = $service->computeEffectiveRoleIds(42, [1, 999], []); + + $this->assertSame([1], $result); + } + + public function testReplaceAssignableRolesRecordsAuditOnChange(): void + { + $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); + $assignableRepo->method('listAssignableRoleIdsByRoleId') + ->willReturnOnConsecutiveCalls([1, 2], [1, 2, 3]); + $assignableRepo->expects($this->once()) + ->method('replaceForRole') + ->with(10, [1, 2, 3]) + ->willReturn(true); + + $audit = $this->createMock(AuditRecorderInterface::class); + $audit->expects($this->once()) + ->method('record') + ->with( + 'admin.roles.assignable_roles.update', + 'success', + $this->callback(static function (array $metadata): bool { + return ($metadata['actor_user_id'] ?? null) === 42 + && ($metadata['target_id'] ?? null) === 10 + && ($metadata['before']['assignable_role_ids'] ?? null) === [1, 2] + && ($metadata['after']['assignable_role_ids'] ?? null) === [1, 2, 3]; + }) + ); + + $service = $this->makeService( + assignableRoleRepository: $assignableRepo, + systemAuditService: $audit, + ); + + $this->assertTrue($service->replaceAssignableRoles(10, [1, 2, 3], 42)); + } + + public function testReplaceAssignableRolesSkipsAuditWhenNothingChanged(): void + { + $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); + $assignableRepo->method('listAssignableRoleIdsByRoleId') + ->willReturnOnConsecutiveCalls([1, 2], [1, 2]); + $assignableRepo->method('replaceForRole')->willReturn(true); + + $audit = $this->createMock(AuditRecorderInterface::class); + $audit->expects($this->never())->method('record'); + + $service = $this->makeService( + assignableRoleRepository: $assignableRepo, + systemAuditService: $audit, + ); + + $this->assertTrue($service->replaceAssignableRoles(10, [1, 2], 42)); + } + + public function testReplaceAssignableRolesNormalizesNonPositiveActorIdInAudit(): void + { + $assignableRepo = $this->createMock(RoleAssignableRoleRepositoryInterface::class); + $assignableRepo->method('listAssignableRoleIdsByRoleId') + ->willReturnOnConsecutiveCalls([], [5]); + $assignableRepo->method('replaceForRole')->willReturn(true); + + $audit = $this->createMock(AuditRecorderInterface::class); + $audit->expects($this->once()) + ->method('record') + ->with( + 'admin.roles.assignable_roles.update', + 'success', + $this->callback(static fn (array $m): bool => array_key_exists('actor_user_id', $m) && $m['actor_user_id'] === null) + ); + + $service = $this->makeService( + assignableRoleRepository: $assignableRepo, + systemAuditService: $audit, + ); + + $service->replaceAssignableRoles(10, [5], 0); + } + + private function makeService( + ?RoleAssignableRoleRepositoryInterface $assignableRoleRepository = null, + ?UserRoleRepositoryInterface $userRoleRepository = null, + ?PermissionService $permissionService = null, + ?RoleRepositoryInterface $roleRepository = null, + ?AuditRecorderInterface $systemAuditService = null, + ): AssignableRoleService { + return new AssignableRoleService( + $assignableRoleRepository ?? $this->createMock(RoleAssignableRoleRepositoryInterface::class), + $userRoleRepository ?? $this->createMock(UserRoleRepositoryInterface::class), + $permissionService ?? $this->createMock(PermissionService::class), + $roleRepository ?? $this->createMock(RoleRepositoryInterface::class), + $systemAuditService ?? $this->createMock(AuditRecorderInterface::class), + ); + } +} diff --git a/tests/Service/Access/SettingsAuthorizationPolicyTest.php b/tests/Service/Access/SettingsAuthorizationPolicyTest.php index 506784d..e49488f 100644 --- a/tests/Service/Access/SettingsAuthorizationPolicyTest.php +++ b/tests/Service/Access/SettingsAuthorizationPolicyTest.php @@ -104,4 +104,73 @@ class SettingsAuthorizationPolicyTest extends TestCase $this->assertDeniedDecision($decision, 403, 'forbidden'); } + + public function testUpdateAllowsWhenPermissionGranted(): void + { + $permissionService = $this->permissionGatewayAllowing([ + 8 => [PermissionService::SETTINGS_UPDATE], + ]); + + $policy = new SettingsAuthorizationPolicy($permissionService); + $decision = $policy->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE, [ + 'actor_user_id' => 8, + ]); + + $this->assertTrue($decision->isAllowed()); + } + + public function testBrandingUpdateAllowsWhenPermissionGranted(): void + { + $permissionService = $this->permissionGatewayAllowing([ + 8 => [PermissionService::SETTINGS_UPDATE], + ]); + + $policy = new SettingsAuthorizationPolicy($permissionService); + $decision = $policy->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE, [ + 'actor_user_id' => 8, + ]); + + $this->assertTrue($decision->isAllowed()); + } + + public function testTokenRevokeAllowsWhenPermissionGranted(): void + { + $permissionService = $this->permissionGatewayAllowing([ + 8 => [PermissionService::SETTINGS_UPDATE], + ]); + + $policy = new SettingsAuthorizationPolicy($permissionService); + $decision = $policy->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE, [ + 'actor_user_id' => 8, + ]); + + $this->assertTrue($decision->isAllowed()); + } + + public function testUserLifecycleRunAllowsWhenPermissionGranted(): void + { + $permissionService = $this->permissionGatewayAllowing([ + 8 => [PermissionService::SETTINGS_UPDATE], + ]); + + $policy = new SettingsAuthorizationPolicy($permissionService); + $decision = $policy->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN, [ + 'actor_user_id' => 8, + ]); + + $this->assertTrue($decision->isAllowed()); + } + + public function testUnknownAbilityIsDeniedWithServerError(): void + { + $permissionService = $this->createMock(PermissionService::class); + $permissionService->expects($this->never())->method('userHas'); + + $policy = new SettingsAuthorizationPolicy($permissionService); + $decision = $policy->authorize('admin.settings.nonexistent', [ + 'actor_user_id' => 8, + ]); + + $this->assertDeniedDecision($decision, 500, 'authorization_ability_not_supported'); + } } diff --git a/tests/Service/Auth/AuthCryptoGatewayTest.php b/tests/Service/Auth/AuthCryptoGatewayTest.php new file mode 100644 index 0000000..2ad135f --- /dev/null +++ b/tests/Service/Auth/AuthCryptoGatewayTest.php @@ -0,0 +1,52 @@ +assertTrue($gateway->isConfigured()); + } + + public function testEncryptDecryptRoundTripThroughGateway(): void + { + $gateway = new AuthCryptoGateway(); + $plaintext = 'sso-client-secret'; + + $encrypted = $gateway->encryptString($plaintext); + + $this->assertNotSame($plaintext, $encrypted); + $this->assertSame($plaintext, $gateway->decryptString($encrypted)); + } + + public function testDecryptStringThrowsOnMalformedInput(): void + { + $gateway = new AuthCryptoGateway(); + + $this->expectException(\RuntimeException::class); + $gateway->decryptString('not-a-valid-payload'); + } + + public function testEncryptProducesDifferentCiphertextEachCall(): void + { + $gateway = new AuthCryptoGateway(); + + $this->assertNotSame( + $gateway->encryptString('same-value'), + $gateway->encryptString('same-value'), + ); + } +} diff --git a/tests/Service/Auth/AuthSettingsGatewayTest.php b/tests/Service/Auth/AuthSettingsGatewayTest.php new file mode 100644 index 0000000..1187e90 --- /dev/null +++ b/tests/Service/Auth/AuthSettingsGatewayTest.php @@ -0,0 +1,108 @@ +createMock(SettingsMicrosoftGateway::class); + $microsoft->method('getMicrosoftSharedClientId')->willReturn('client-id'); + $microsoft->method('getMicrosoftSharedClientSecret')->willReturn('secret'); + $microsoft->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/tenant'); + + $gateway = $this->makeGateway(microsoft: $microsoft); + + $this->assertSame('client-id', $gateway->getMicrosoftSharedClientId()); + $this->assertSame('secret', $gateway->getMicrosoftSharedClientSecret()); + $this->assertSame('https://login.microsoftonline.com/tenant', $gateway->getMicrosoftAuthority()); + } + + public function testMicrosoftAccessorsReturnEmptyStringWhenUnconfigured(): void + { + $microsoft = $this->createMock(SettingsMicrosoftGateway::class); + $microsoft->method('getMicrosoftSharedClientId')->willReturn(''); + $microsoft->method('getMicrosoftSharedClientSecret')->willReturn(''); + $microsoft->method('getMicrosoftAuthority')->willReturn(''); + + $gateway = $this->makeGateway(microsoft: $microsoft); + + $this->assertSame('', $gateway->getMicrosoftSharedClientId()); + $this->assertSame('', $gateway->getMicrosoftSharedClientSecret()); + $this->assertSame('', $gateway->getMicrosoftAuthority()); + } + + public function testApiTokenTtlAccessorsDelegate(): void + { + $apiPolicy = $this->createMock(SettingsApiPolicyGateway::class); + $apiPolicy->method('getApiTokenMaxTtlDays')->willReturn(90); + $apiPolicy->method('getApiTokenDefaultTtlDays')->willReturn(30); + + $gateway = $this->makeGateway(apiPolicy: $apiPolicy); + + $this->assertSame(90, $gateway->getApiTokenMaxTtlDays()); + $this->assertSame(30, $gateway->getApiTokenDefaultTtlDays()); + } + + public function testAppThemeAccessorsDelegate(): void + { + $app = $this->createMock(SettingsAppGateway::class); + $app->method('getAppTheme')->willReturn('dark'); + $app->method('resolveDefaultTheme')->willReturn('light'); + $app->expects($this->once())->method('normalizeTheme')->with('DARK')->willReturn('dark'); + + $gateway = $this->makeGateway(app: $app); + + $this->assertSame('dark', $gateway->getAppTheme()); + $this->assertSame('light', $gateway->defaultTheme()); + $this->assertSame('dark', $gateway->normalizeTheme('DARK')); + } + + public function testDefaultsAccessorsCastToInt(): void + { + $defaults = $this->createMock(SettingsDefaultsGateway::class); + $defaults->method('getDefaultRoleId')->willReturn(5); + $defaults->method('getDefaultDepartmentId')->willReturn(12); + + $gateway = $this->makeGateway(defaults: $defaults); + + $this->assertSame(5, $gateway->getDefaultRoleId()); + $this->assertSame(12, $gateway->getDefaultDepartmentId()); + } + + public function testLoginPersistenceAccessorsDelegate(): void + { + $login = $this->createMock(SettingsLoginPersistenceGateway::class); + $login->method('isMicrosoftAutoRememberDefault')->willReturn(true); + $login->method('getRememberTokenLifetimeDays')->willReturn(14); + + $gateway = $this->makeGateway(login: $login); + + $this->assertTrue($gateway->isMicrosoftAutoRememberDefault()); + $this->assertSame(14, $gateway->getRememberTokenLifetimeDays()); + } + + private function makeGateway( + ?SettingsMicrosoftGateway $microsoft = null, + ?SettingsApiPolicyGateway $apiPolicy = null, + ?SettingsDefaultsGateway $defaults = null, + ?SettingsAppGateway $app = null, + ?SettingsLoginPersistenceGateway $login = null, + ): AuthSettingsGateway { + return new AuthSettingsGateway( + $microsoft ?? $this->createMock(SettingsMicrosoftGateway::class), + $apiPolicy ?? $this->createMock(SettingsApiPolicyGateway::class), + $defaults ?? $this->createMock(SettingsDefaultsGateway::class), + $app ?? $this->createMock(SettingsAppGateway::class), + $login ?? $this->createMock(SettingsLoginPersistenceGateway::class), + ); + } +} diff --git a/tests/Service/User/UserTenantContextServiceTest.php b/tests/Service/User/UserTenantContextServiceTest.php new file mode 100644 index 0000000..58fe80b --- /dev/null +++ b/tests/Service/User/UserTenantContextServiceTest.php @@ -0,0 +1,294 @@ +createMock(UserReadRepositoryInterface::class); + $userRead->method('find')->willReturn(null); + + $service = $this->makeService(userRead: $userRead); + + $this->assertNull($service->getCurrentTenantId(42)); + } + + public function testGetCurrentTenantIdReturnsNullWhenUserHasNoAccessibleTenants(): void + { + $userRead = $this->createMock(UserReadRepositoryInterface::class); + $userRead->method('find')->willReturn(['current_tenant_id' => 5, 'primary_tenant_id' => 5]); + + $scope = $this->createMock(TenantScopeService::class); + $scope->method('getUserTenantIds')->willReturn([]); + + $service = $this->makeService(userRead: $userRead, scope: $scope); + + $this->assertNull($service->getCurrentTenantId(42)); + } + + public function testGetCurrentTenantIdPrefersCurrentWhenInAccessibleSet(): void + { + $userRead = $this->createMock(UserReadRepositoryInterface::class); + $userRead->method('find')->willReturn([ + 'current_tenant_id' => 7, + 'primary_tenant_id' => 3, + ]); + + $scope = $this->createMock(TenantScopeService::class); + $scope->method('getUserTenantIds')->willReturn([3, 7, 9]); + + $service = $this->makeService(userRead: $userRead, scope: $scope); + + $this->assertSame(7, $service->getCurrentTenantId(42)); + } + + public function testGetCurrentTenantIdFallsBackToPrimaryWhenCurrentNotAccessible(): void + { + $userRead = $this->createMock(UserReadRepositoryInterface::class); + $userRead->method('find')->willReturn([ + 'current_tenant_id' => 999, + 'primary_tenant_id' => 3, + ]); + + $scope = $this->createMock(TenantScopeService::class); + $scope->method('getUserTenantIds')->willReturn([3, 9]); + + $service = $this->makeService(userRead: $userRead, scope: $scope); + + $this->assertSame(3, $service->getCurrentTenantId(42)); + } + + public function testGetCurrentTenantIdFallsBackToFirstAccessibleWhenNeitherCurrentNorPrimaryMatch(): void + { + $userRead = $this->createMock(UserReadRepositoryInterface::class); + $userRead->method('find')->willReturn([ + 'current_tenant_id' => 0, + 'primary_tenant_id' => 999, + ]); + + $scope = $this->createMock(TenantScopeService::class); + $scope->method('getUserTenantIds')->willReturn([9, 11]); + + $service = $this->makeService(userRead: $userRead, scope: $scope); + + $this->assertSame(9, $service->getCurrentTenantId(42)); + } + + public function testGetCurrentTenantReturnsNullWhenNoCurrentTenantResolvable(): void + { + $userRead = $this->createMock(UserReadRepositoryInterface::class); + $userRead->method('find')->willReturn(null); + + $directory = $this->createMock(UserDirectoryGateway::class); + $directory->expects($this->never())->method('findTenant'); + + $service = $this->makeService(userRead: $userRead, directory: $directory); + + $this->assertNull($service->getCurrentTenant(42)); + } + + public function testGetCurrentTenantLoadsTenantRecord(): void + { + $userRead = $this->createMock(UserReadRepositoryInterface::class); + $userRead->method('find')->willReturn(['current_tenant_id' => 7, 'primary_tenant_id' => 7]); + + $scope = $this->createMock(TenantScopeService::class); + $scope->method('getUserTenantIds')->willReturn([7]); + + $directory = $this->createMock(UserDirectoryGateway::class); + $directory->expects($this->once()) + ->method('findTenant') + ->with(7) + ->willReturn(['id' => 7, 'description' => 'Acme']); + + $service = $this->makeService(userRead: $userRead, scope: $scope, directory: $directory); + + $this->assertSame(['id' => 7, 'description' => 'Acme'], $service->getCurrentTenant(42)); + } + + public function testGetAvailableTenantsReturnsEmptyWhenNoneAccessible(): void + { + $scope = $this->createMock(TenantScopeService::class); + $scope->method('getUserTenantIds')->willReturn([]); + + $service = $this->makeService(scope: $scope); + + $this->assertSame([], $service->getAvailableTenants(42)); + } + + public function testGetAvailableTenantsFiltersInactiveAndSortsByDescription(): void + { + $scope = $this->createMock(TenantScopeService::class); + $scope->method('getUserTenantIds')->willReturn([1, 2, 3]); + + $directory = $this->createMock(UserDirectoryGateway::class); + $directory->method('findTenant')->willReturnCallback(static function (int $id): ?array { + return match ($id) { + 1 => ['id' => 1, 'description' => 'Charlie', 'status' => 'active'], + 2 => ['id' => 2, 'description' => 'Alpha', 'status' => 'inactive'], + 3 => ['id' => 3, 'description' => 'Bravo', 'status' => 'active'], + default => null, + }; + }); + + $service = $this->makeService(scope: $scope, directory: $directory); + $result = $service->getAvailableTenants(42); + + $this->assertCount(2, $result); + $this->assertSame('Bravo', $result[0]['description']); + $this->assertSame('Charlie', $result[1]['description']); + } + + public function testGetAssignedActiveTenantsReturnsEmptyForInvalidUserId(): void + { + $service = $this->makeService(); + + $this->assertSame([], $service->getAssignedActiveTenants(0)); + $this->assertSame([], $service->getAssignedActiveTenants(-1)); + } + + public function testGetAssignedActiveTenantsFiltersInactiveAndPreservesAssignedOrder(): void + { + $userTenant = $this->createMock(UserTenantRepositoryInterface::class); + $userTenant->method('listTenantIdsByUserId')->willReturn([3, 1, 2, 3, 0]); + + $directory = $this->createMock(UserDirectoryGateway::class); + $directory->expects($this->once()) + ->method('listActiveTenantIdsByIds') + ->with([3, 1, 2]) + ->willReturn([1, 3]); + $directory->expects($this->once()) + ->method('listTenantsByIds') + ->with([1, 3]) + ->willReturn([ + ['id' => 3, 'description' => 'Zulu'], + ['id' => 1, 'description' => 'Alpha'], + ]); + + $service = $this->makeService(userTenant: $userTenant, directory: $directory); + $result = $service->getAssignedActiveTenants(42); + + $this->assertCount(2, $result); + $this->assertSame(1, $result[0]['id']); + $this->assertSame(3, $result[1]['id']); + } + + public function testGetAssignedActiveTenantsReturnsEmptyWhenNoneActive(): void + { + $userTenant = $this->createMock(UserTenantRepositoryInterface::class); + $userTenant->method('listTenantIdsByUserId')->willReturn([1, 2]); + + $directory = $this->createMock(UserDirectoryGateway::class); + $directory->method('listActiveTenantIdsByIds')->willReturn([]); + $directory->expects($this->never())->method('listTenantsByIds'); + + $service = $this->makeService(userTenant: $userTenant, directory: $directory); + + $this->assertSame([], $service->getAssignedActiveTenants(42)); + } + + public function testSetCurrentTenantRejectsInvalidTenantId(): void + { + $service = $this->makeService(); + + $this->assertSame(['ok' => false, 'error' => 'invalid_tenant'], $service->setCurrentTenant(42, 0)); + $this->assertSame(['ok' => false, 'error' => 'invalid_tenant'], $service->setCurrentTenant(42, -1)); + } + + public function testSetCurrentTenantRejectsUnassignedTenant(): void + { + $userTenant = $this->createMock(UserTenantRepositoryInterface::class); + $userTenant->method('listTenantIdsByUserId')->willReturn([1, 2]); + + $service = $this->makeService(userTenant: $userTenant); + + $this->assertSame(['ok' => false, 'error' => 'tenant_not_assigned'], $service->setCurrentTenant(42, 999)); + } + + public function testSetCurrentTenantRejectsMissingTenant(): void + { + $userTenant = $this->createMock(UserTenantRepositoryInterface::class); + $userTenant->method('listTenantIdsByUserId')->willReturn([7]); + + $directory = $this->createMock(UserDirectoryGateway::class); + $directory->method('findTenant')->willReturn(null); + + $service = $this->makeService(userTenant: $userTenant, directory: $directory); + + $this->assertSame(['ok' => false, 'error' => 'tenant_not_found'], $service->setCurrentTenant(42, 7)); + } + + public function testSetCurrentTenantRejectsInactiveTenant(): void + { + $userTenant = $this->createMock(UserTenantRepositoryInterface::class); + $userTenant->method('listTenantIdsByUserId')->willReturn([7]); + + $directory = $this->createMock(UserDirectoryGateway::class); + $directory->method('findTenant')->willReturn(['id' => 7, 'status' => 'inactive']); + + $service = $this->makeService(userTenant: $userTenant, directory: $directory); + + $this->assertSame(['ok' => false, 'error' => 'tenant_inactive'], $service->setCurrentTenant(42, 7)); + } + + public function testSetCurrentTenantReturnsUpdateFailedWhenPersistFails(): void + { + $userTenant = $this->createMock(UserTenantRepositoryInterface::class); + $userTenant->method('listTenantIdsByUserId')->willReturn([7]); + + $directory = $this->createMock(UserDirectoryGateway::class); + $directory->method('findTenant')->willReturn(['id' => 7, 'status' => 'active']); + + $userWrite = $this->createMock(UserWriteRepositoryInterface::class); + $userWrite->method('setCurrentTenant')->willReturn(false); + + $service = $this->makeService(userTenant: $userTenant, userWrite: $userWrite, directory: $directory); + + $this->assertSame(['ok' => false, 'error' => 'update_failed'], $service->setCurrentTenant(42, 7)); + } + + public function testSetCurrentTenantPersistsAndReturnsTenantOnSuccess(): void + { + $userTenant = $this->createMock(UserTenantRepositoryInterface::class); + $userTenant->method('listTenantIdsByUserId')->willReturn([7]); + + $directory = $this->createMock(UserDirectoryGateway::class); + $tenant = ['id' => 7, 'status' => 'active', 'description' => 'Acme']; + $directory->method('findTenant')->willReturn($tenant); + + $userWrite = $this->createMock(UserWriteRepositoryInterface::class); + $userWrite->expects($this->once()) + ->method('setCurrentTenant') + ->with(42, 7) + ->willReturn(true); + + $service = $this->makeService(userTenant: $userTenant, userWrite: $userWrite, directory: $directory); + + $this->assertSame(['ok' => true, 'tenant' => $tenant], $service->setCurrentTenant(42, 7)); + } + + private function makeService( + ?UserReadRepositoryInterface $userRead = null, + ?UserWriteRepositoryInterface $userWrite = null, + ?UserTenantRepositoryInterface $userTenant = null, + ?TenantScopeService $scope = null, + ?UserDirectoryGateway $directory = null, + ): UserTenantContextService { + return new UserTenantContextService( + $userRead ?? $this->createMock(UserReadRepositoryInterface::class), + $userWrite ?? $this->createMock(UserWriteRepositoryInterface::class), + $userTenant ?? $this->createMock(UserTenantRepositoryInterface::class), + $scope ?? $this->createMock(TenantScopeService::class), + $directory ?? $this->createMock(UserDirectoryGateway::class), + ); + } +}