diff --git a/tests/Service/Audit/UserLifecycleAuditServiceTest.php b/tests/Service/Audit/UserLifecycleAuditServiceTest.php new file mode 100644 index 0000000..e3deb4d --- /dev/null +++ b/tests/Service/Audit/UserLifecycleAuditServiceTest.php @@ -0,0 +1,365 @@ +createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row): bool { + return $row['action'] === 'deactivate' + && $row['trigger_type'] === 'manual' + && $row['status'] === 'success' + && $row['run_uuid'] === 'run-1' + && $row['target_user_id'] === 5 + && $row['target_user_uuid'] === 'user-uuid-5' + && $row['target_user_email'] === 'test@example.com' + && $row['actor_user_id'] === 99 + && $row['policy_deactivate_days'] === 30 + && $row['policy_delete_days'] === 60 + && $row['snapshot_enc'] === null + && $row['snapshot_version'] === 1; + })) + ->willReturn(1); + + $service = new UserLifecycleAuditService($repo); + $result = $service->logDeactivate( + 'run-1', + 'manual', + ['deactivate_days' => 30, 'delete_days' => 60], + 99, + ['id' => 5, 'uuid' => 'user-uuid-5', 'email' => 'test@example.com'], + 'success', + null + ); + + $this->assertTrue($result); + } + + public function testLogDeactivateReturnsFalseOnRepoException(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->method('create')->willThrowException(new \RuntimeException('db error')); + + $service = new UserLifecycleAuditService($repo); + $result = $service->logDeactivate('run-1', 'manual', [], null, []); + + $this->assertFalse($result); + } + + public function testLogDeactivateReturnsFalseWhenRepoReturnsFalse(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->method('create')->willReturn(false); + + $service = new UserLifecycleAuditService($repo); + $result = $service->logDeactivate('run-1', 'manual', [], null, []); + + $this->assertFalse($result); + } + + // ── logDeleteWithSnapshot ─────────────────────────────────────────── + + public function testLogDeleteWithSnapshotCreatesEncryptedSnapshot(): void + { + if (!self::$cryptoAvailable) { + $this->markTestSkipped('Crypto not configured'); + } + + $targetUser = []; + foreach (self::SNAPSHOT_FIELDS as $field) { + $targetUser[$field] = 'val_' . $field; + } + $targetUser['id'] = 5; + $targetUser['extra_field'] = 'should_be_excluded'; + + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row): bool { + return $row['action'] === 'delete' + && $row['status'] === 'success' + && $row['snapshot_version'] === 1 + && is_string($row['snapshot_enc']) + && $row['snapshot_enc'] !== ''; + })) + ->willReturn(7); + + $service = new UserLifecycleAuditService($repo); + $result = $service->logDeleteWithSnapshot('run-2', 'cron', ['deactivate_days' => 30, 'delete_days' => 90], null, $targetUser); + + $this->assertSame(7, $result); + } + + public function testLogDeleteWithSnapshotContainsOnlySnapshotFields(): void + { + if (!self::$cryptoAvailable) { + $this->markTestSkipped('Crypto not configured'); + } + + $targetUser = ['id' => 5, 'uuid' => 'u5', 'email' => 'e@example.com', 'extra' => 'no']; + + $capturedRow = null; + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row) use (&$capturedRow): bool { + $capturedRow = $row; + return true; + })) + ->willReturn(1); + + $service = new UserLifecycleAuditService($repo); + $service->logDeleteWithSnapshot('run-3', 'manual', [], null, $targetUser); + + $this->assertNotNull($capturedRow); + $decryptedJson = Crypto::decryptString($capturedRow['snapshot_enc']); + $snapshot = json_decode($decryptedJson, true); + $this->assertIsArray($snapshot); + $this->assertSame(self::SNAPSHOT_FIELDS, array_keys($snapshot)); + $this->assertArrayNotHasKey('extra', $snapshot); + $this->assertArrayNotHasKey('id', $snapshot); + } + + public function testLogDeleteWithSnapshotReturnsFalseOnCryptoFailure(): void + { + // Simulate a scenario where Crypto throws by passing a target user that will + // cause the try/catch to fire. We cannot easily override static Crypto, but we + // know that if APP_CRYPTO_KEY were missing, encryptString would throw. + // Since we defined a valid key, let's test the repo exception path instead. + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->method('create')->willThrowException(new \RuntimeException('db error')); + + $service = new UserLifecycleAuditService($repo); + $result = $service->logDeleteWithSnapshot('run-4', 'manual', [], null, ['id' => 1, 'uuid' => 'u']); + + $this->assertFalse($result); + } + + // ── logRestore ────────────────────────────────────────────────────── + + public function testLogRestoreCallsRepoWithRestoreAction(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row): bool { + return $row['action'] === 'restore' + && $row['trigger_type'] === 'manual' + && $row['status'] === 'success'; + })) + ->willReturn(10); + + $service = new UserLifecycleAuditService($repo); + $result = $service->logRestore('run-5', 'manual', [], 99, ['id' => 5, 'uuid' => 'u5', 'email' => 'e@x.com']); + + $this->assertTrue($result); + } + + public function testLogRestoreReturnsFalseOnException(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->method('create')->willThrowException(new \RuntimeException('fail')); + + $service = new UserLifecycleAuditService($repo); + $result = $service->logRestore('run-5', 'manual', [], null, []); + + $this->assertFalse($result); + } + + // ── decryptSnapshot ───────────────────────────────────────────────── + + public function testDecryptSnapshotWithValidPayload(): void + { + if (!self::$cryptoAvailable) { + $this->markTestSkipped('Crypto not configured'); + } + + $original = ['uuid' => 'test-uuid', 'email' => 'test@example.com']; + $encrypted = Crypto::encryptString(json_encode($original)); + + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $service = new UserLifecycleAuditService($repo); + + $result = $service->decryptSnapshot(['snapshot_enc' => $encrypted]); + $this->assertSame($original, $result); + } + + public function testDecryptSnapshotReturnsNullForEmptyPayload(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $service = new UserLifecycleAuditService($repo); + + $this->assertNull($service->decryptSnapshot([])); + $this->assertNull($service->decryptSnapshot(['snapshot_enc' => ''])); + $this->assertNull($service->decryptSnapshot(['snapshot_enc' => ' '])); + } + + public function testDecryptSnapshotReturnsNullForInvalidPayload(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $service = new UserLifecycleAuditService($repo); + + $result = $service->decryptSnapshot(['snapshot_enc' => 'not-valid-encrypted-data']); + $this->assertNull($result); + } + + // ── markDeleteEventRestored ───────────────────────────────────────── + + public function testMarkDeleteEventRestoredDelegatesToRepo(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('markRestored') + ->with(1, 99, 200) + ->willReturn(true); + + $service = new UserLifecycleAuditService($repo); + $this->assertTrue($service->markDeleteEventRestored(1, 99, 200)); + } + + public function testMarkDeleteEventRestoredReturnsFalseOnException(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->method('markRestored')->willThrowException(new \RuntimeException('fail')); + + $service = new UserLifecycleAuditService($repo); + $this->assertFalse($service->markDeleteEventRestored(1, 99, 200)); + } + + // ── purgeExpired ──────────────────────────────────────────────────── + + public function testPurgeExpiredDelegatesToRepoWith365Days(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('purgeOlderThanDays') + ->with(365) + ->willReturn(5); + + $service = new UserLifecycleAuditService($repo); + $this->assertSame(5, $service->purgeExpired()); + } + + // ── buildBaseRow enum normalization ────────────────────────────────── + + public function testBuildBaseRowNormalizesInvalidTriggerTypeToDefault(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row): bool { + return $row['trigger_type'] === 'system'; // fallback for invalid trigger + })) + ->willReturn(1); + + $service = new UserLifecycleAuditService($repo); + $service->logDeactivate('run-x', 'INVALID_TRIGGER', [], null, []); + } + + public function testBuildBaseRowNormalizesInvalidStatusToFailed(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row): bool { + return $row['status'] === 'failed'; // fallback for invalid status + })) + ->willReturn(1); + + $service = new UserLifecycleAuditService($repo); + $service->logDeactivate('run-x', 'manual', [], null, [], 'INVALID_STATUS'); + } + + public function testBuildBaseRowNormalizesNullActorToNull(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row): bool { + return $row['actor_user_id'] === null; + })) + ->willReturn(1); + + $service = new UserLifecycleAuditService($repo); + $service->logDeactivate('run-x', 'manual', [], null, []); + } + + public function testBuildBaseRowNormalizesZeroActorToNull(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row): bool { + return $row['actor_user_id'] === null; + })) + ->willReturn(1); + + $service = new UserLifecycleAuditService($repo); + $service->logDeactivate('run-x', 'manual', [], 0, []); + } + + public function testBuildBaseRowSetsNegativePolicyDaysToZero(): void + { + $repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class); + $repo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $row): bool { + return $row['policy_deactivate_days'] === 0 + && $row['policy_delete_days'] === 0; + })) + ->willReturn(1); + + $service = new UserLifecycleAuditService($repo); + $service->logDeactivate('run-x', 'manual', ['deactivate_days' => -10, 'delete_days' => -5], null, []); + } +} diff --git a/tests/Service/Auth/ApiTokenEndpointServiceTest.php b/tests/Service/Auth/ApiTokenEndpointServiceTest.php new file mode 100644 index 0000000..482c6e5 --- /dev/null +++ b/tests/Service/Auth/ApiTokenEndpointServiceTest.php @@ -0,0 +1,542 @@ +newService(); + + $this->assertSame(25, $service->normalizeLimit(25)); + } + + public function testNormalizeLimitBelowMinReturnsDefault(): void + { + $service = $this->newService(); + + $this->assertSame(50, $service->normalizeLimit(0)); + } + + public function testNormalizeLimitAboveMaxReturnsCappedMax(): void + { + $service = $this->newService(); + + $this->assertSame(100, $service->normalizeLimit(999)); + } + + public function testNormalizeLimitNonNumericReturnsDefault(): void + { + $service = $this->newService(); + + $this->assertSame(50, $service->normalizeLimit('abc')); + } + + public function testNormalizeLimitAtExactMinReturnsValue(): void + { + $service = $this->newService(); + + $this->assertSame(1, $service->normalizeLimit(1)); + } + + public function testNormalizeLimitAtExactMaxReturnsValue(): void + { + $service = $this->newService(); + + $this->assertSame(100, $service->normalizeLimit(100)); + } + + public function testNormalizeLimitNegativeReturnsDefault(): void + { + $service = $this->newService(); + + $this->assertSame(50, $service->normalizeLimit(-5)); + } + + // --------------------------------------------------------------- + // listSelfTokens() + // --------------------------------------------------------------- + + public function testListSelfTokensReturnsEmptyWhenUserIdIsZero(): void + { + $service = $this->newService(); + + $this->assertSame([], $service->listSelfTokens(0, 50, null, null)); + } + + public function testListSelfTokensReturnsEmptyWhenUserIdIsNegative(): void + { + $service = $this->newService(); + + $this->assertSame([], $service->listSelfTokens(-1, 50, null, null)); + } + + public function testListSelfTokensReturnsFormattedTokensWithoutHash(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->expects($this->any())->method('listByUserId')->with(5, 50)->willReturn([ + [ + 'id' => 10, + 'uuid' => 'uuid-a', + 'name' => 'Token A', + 'selector' => 'abcdef1234567890abcdefgh', + 'token_hash' => 'should-not-appear', + 'tenant_id' => null, + 'last_used_at' => '2025-01-01 10:00:00', + 'last_ip' => '10.0.0.1', + 'expires_at' => '2025-12-31 23:59:59', + 'revoked_at' => null, + 'created' => '2025-01-01 00:00:00', + ], + ]); + + $tenantRepo = $this->createMock(TenantRepository::class); + $tenantRepo->method('listByIds')->willReturn([]); + + $service = $this->newService(apiTokenRepository: $tokenRepo, tenantRepository: $tenantRepo); + + $result = $service->listSelfTokens(5, 50, null, null); + + $this->assertCount(1, $result); + $this->assertSame('uuid-a', $result[0]['uuid']); + $this->assertSame('Token A', $result[0]['name']); + $this->assertSame('abcdef12', $result[0]['selector_prefix']); + $this->assertArrayNotHasKey('token_hash', $result[0]); + $this->assertFalse($result[0]['is_current_token']); + } + + public function testListSelfTokensMarksCurrentToken(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->method('listByUserId')->willReturn([ + [ + 'id' => 10, + 'uuid' => 'uuid-a', + 'name' => 'Token A', + 'selector' => 'abcdef1234567890', + 'tenant_id' => null, + 'last_used_at' => null, + 'last_ip' => '', + 'expires_at' => null, + 'revoked_at' => null, + 'created' => null, + ], + [ + 'id' => 20, + 'uuid' => 'uuid-b', + 'name' => 'Token B', + 'selector' => 'zyxwvuts87654321', + 'tenant_id' => null, + 'last_used_at' => null, + 'last_ip' => '', + 'expires_at' => null, + 'revoked_at' => null, + 'created' => null, + ], + ]); + + $tenantRepo = $this->createMock(TenantRepository::class); + $tenantRepo->method('listByIds')->willReturn([]); + + $service = $this->newService(apiTokenRepository: $tokenRepo, tenantRepository: $tenantRepo); + + $result = $service->listSelfTokens(5, 50, null, 20); + + $this->assertCount(2, $result); + $this->assertFalse($result[0]['is_current_token']); + $this->assertTrue($result[1]['is_current_token']); + } + + public function testListSelfTokensFiltersByScopedTenant(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->method('listByUserId')->willReturn([ + [ + 'id' => 10, + 'uuid' => 'uuid-a', + 'name' => 'Token A', + 'selector' => 'abcdef1234567890', + 'tenant_id' => 1, + 'last_used_at' => null, + 'last_ip' => '', + 'expires_at' => null, + 'revoked_at' => null, + 'created' => null, + ], + [ + 'id' => 20, + 'uuid' => 'uuid-b', + 'name' => 'Token B', + 'selector' => 'zyxwvuts87654321', + 'tenant_id' => 2, + 'last_used_at' => null, + 'last_ip' => '', + 'expires_at' => null, + 'revoked_at' => null, + 'created' => null, + ], + ]); + + $tenantRepo = $this->createMock(TenantRepository::class); + $tenantRepo->method('listByIds')->willReturn([ + ['id' => 1, 'uuid' => 'tenant-uuid-1'], + ]); + + $service = $this->newService(apiTokenRepository: $tokenRepo, tenantRepository: $tenantRepo); + + // Only tokens with tenant_id=1 should be returned + $result = $service->listSelfTokens(5, 50, 1, null); + + $this->assertCount(1, $result); + $this->assertSame('uuid-a', $result[0]['uuid']); + } + + // --------------------------------------------------------------- + // findSelfToken() + // --------------------------------------------------------------- + + public function testFindSelfTokenReturnsNullForInvalidUuid(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->expects($this->any())->method('isUuid')->with('not-valid')->willReturn(false); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertNull($service->findSelfToken('not-valid', 5, null, null)); + } + + public function testFindSelfTokenReturnsNullWhenUserIdIsZero(): void + { + $service = $this->newService(); + + $this->assertNull($service->findSelfToken('some-uuid', 0, null, null)); + } + + public function testFindSelfTokenReturnsNullWhenTokenNotFound(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->method('isUuid')->willReturn(true); + $tokenRepo->method('findByUuidForUser')->willReturn(null); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertNull($service->findSelfToken('valid-uuid', 5, null, null)); + } + + public function testFindSelfTokenReturnsNullWhenScopedTenantDoesNotMatch(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->method('isUuid')->willReturn(true); + $tokenRepo->method('findByUuidForUser')->willReturn([ + 'id' => 10, + 'uuid' => 'valid-uuid', + 'name' => 'Token', + 'selector' => 'abcdef12', + 'tenant_id' => 2, + 'last_used_at' => null, + 'last_ip' => '', + 'expires_at' => null, + 'revoked_at' => null, + 'created' => null, + ]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + // Scoped to tenant 1 but token is for tenant 2 + $this->assertNull($service->findSelfToken('valid-uuid', 5, 1, null)); + } + + public function testFindSelfTokenSuccess(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->method('isUuid')->willReturn(true); + $tokenRepo->method('findByUuidForUser')->willReturn([ + 'id' => 10, + 'uuid' => 'valid-uuid', + 'name' => 'My Token', + 'selector' => 'abcdef1234567890', + 'tenant_id' => null, + 'last_used_at' => '2025-01-01 00:00:00', + 'last_ip' => '10.0.0.1', + 'expires_at' => '2026-01-01 00:00:00', + 'revoked_at' => null, + 'created' => '2025-01-01 00:00:00', + ]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->findSelfToken('valid-uuid', 5, null, 10); + + $this->assertNotNull($result); + $this->assertSame('valid-uuid', $result['uuid']); + $this->assertSame('My Token', $result['name']); + $this->assertSame('abcdef12', $result['selector_prefix']); + $this->assertTrue($result['is_current_token']); + $this->assertArrayNotHasKey('token_hash', $result); + } + + // --------------------------------------------------------------- + // revokeSelfToken() + // --------------------------------------------------------------- + + public function testRevokeSelfTokenReturnsFalseForInvalidUuid(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->method('isUuid')->willReturn(false); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertFalse($service->revokeSelfToken('bad-uuid', 5)); + } + + public function testRevokeSelfTokenReturnsFalseWhenUserIdIsZero(): void + { + $service = $this->newService(); + + $this->assertFalse($service->revokeSelfToken('some-uuid', 0)); + } + + public function testRevokeSelfTokenSuccessReturnsTrue(): void + { + $tokenRepo = $this->createMock(ApiTokenRepository::class); + $tokenRepo->method('isUuid')->willReturn(true); + $tokenRepo->expects($this->once()) + ->method('revokeByUuidForUser') + ->with('valid-uuid', 5) + ->willReturn(true); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertTrue($service->revokeSelfToken('valid-uuid', 5)); + } + + // --------------------------------------------------------------- + // resolveSelfCreateTenant() + // --------------------------------------------------------------- + + public function testResolveSelfCreateTenantSuccessWithEmptyInput(): void + { + $service = $this->newService(); + + $result = $service->resolveSelfCreateTenant(5, null, ''); + + $this->assertTrue($result['ok']); + $this->assertNull($result['tenant_id']); + $this->assertNull($result['tenant_uuid']); + } + + public function testResolveSelfCreateTenantReturnsInvalidTenantUuid(): void + { + $tenantRepo = $this->createMock(TenantRepository::class); + $tenantRepo->expects($this->any())->method('findByUuid')->with('bad-uuid')->willReturn(null); + + $service = $this->newService(tenantRepository: $tenantRepo); + + $result = $service->resolveSelfCreateTenant(5, null, 'bad-uuid'); + + $this->assertFalse($result['ok']); + $this->assertSame(422, $result['status']); + $this->assertSame('invalid_tenant_uuid', $result['error']); + } + + public function testResolveSelfCreateTenantReturnsTenantScopedForbiddenWhenScopeResolveFails(): void + { + $tenantRepo = $this->createMock(TenantRepository::class); + // resolveTenantUuidById calls find() to resolve the scoped tenant + $tenantRepo->method('find')->willReturn(null); + + $service = $this->newService(tenantRepository: $tenantRepo); + + // scopedTenantId is set but its UUID cannot be resolved + $result = $service->resolveSelfCreateTenant(5, 99, ''); + + $this->assertFalse($result['ok']); + $this->assertSame(403, $result['status']); + $this->assertSame('tenant_scoped_token_forbidden', $result['error']); + } + + public function testResolveSelfCreateTenantReturnsTenantScopedForbiddenWhenTenantMismatch(): void + { + $tenantRepo = $this->createMock(TenantRepository::class); + $tenantRepo->expects($this->any())->method('findByUuid')->with('tenant-uuid-2')->willReturn(['id' => 2, 'uuid' => 'tenant-uuid-2']); + $tenantRepo->expects($this->any())->method('find')->with(1)->willReturn(['id' => 1, 'uuid' => 'tenant-uuid-1']); + + $service = $this->newService(tenantRepository: $tenantRepo); + + // Scoped to tenant 1, but requesting tenant 2 + $result = $service->resolveSelfCreateTenant(5, 1, 'tenant-uuid-2'); + + $this->assertFalse($result['ok']); + $this->assertSame(403, $result['status']); + $this->assertSame('tenant_scoped_token_forbidden', $result['error']); + } + + public function testResolveSelfCreateTenantReturnsTenantNotAssigned(): void + { + $tenantRepo = $this->createMock(TenantRepository::class); + $tenantRepo->expects($this->any())->method('findByUuid')->with('tenant-uuid-3')->willReturn(['id' => 3, 'uuid' => 'tenant-uuid-3']); + + $scopeService = $this->createMock(TenantScopeService::class); + $scopeService->expects($this->any())->method('getUserTenantIds')->with(5)->willReturn([1, 2]); + + $service = $this->newService( + tenantRepository: $tenantRepo, + authScopeGateway: $scopeService + ); + + $result = $service->resolveSelfCreateTenant(5, null, 'tenant-uuid-3'); + + $this->assertFalse($result['ok']); + $this->assertSame(403, $result['status']); + $this->assertSame('tenant_not_assigned', $result['error']); + } + + public function testResolveSelfCreateTenantSuccessWithValidTenant(): void + { + $tenantRepo = $this->createMock(TenantRepository::class); + $tenantRepo->expects($this->any())->method('findByUuid')->with('tenant-uuid-1')->willReturn(['id' => 1, 'uuid' => 'tenant-uuid-1']); + + $scopeService = $this->createMock(TenantScopeService::class); + $scopeService->expects($this->any())->method('getUserTenantIds')->with(5)->willReturn([1, 2]); + + $service = $this->newService( + tenantRepository: $tenantRepo, + authScopeGateway: $scopeService + ); + + $result = $service->resolveSelfCreateTenant(5, null, 'tenant-uuid-1'); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['tenant_id']); + $this->assertSame('tenant-uuid-1', $result['tenant_uuid']); + } + + // --------------------------------------------------------------- + // parseExpiresAt() + // --------------------------------------------------------------- + + public function testParseExpiresAtEmptyReturnsNull(): void + { + $service = $this->newService(); + + $result = $service->parseExpiresAt(''); + + $this->assertTrue($result['ok']); + $this->assertNull($result['expires_at']); + } + + public function testParseExpiresAtValidFutureDate(): void + { + $service = $this->newService(); + $futureDate = gmdate('Y-m-d H:i:s', time() + 86400 * 30); + + $result = $service->parseExpiresAt($futureDate); + + $this->assertTrue($result['ok']); + $this->assertNotEmpty($result['expires_at']); + } + + public function testParseExpiresAtPastDateReturnsMustBeInFuture(): void + { + $service = $this->newService(); + + $result = $service->parseExpiresAt('2020-01-01 00:00:00'); + + $this->assertFalse($result['ok']); + $this->assertSame('must_be_in_future', $result['error']); + } + + public function testParseExpiresAtInvalidFormatReturnsInvalidDatetime(): void + { + $service = $this->newService(); + + $result = $service->parseExpiresAt('not-a-date-at-all!!!'); + + $this->assertFalse($result['ok']); + $this->assertSame('invalid_datetime', $result['error']); + } + + // --------------------------------------------------------------- + // capExpiryToParent() + // --------------------------------------------------------------- + + public function testCapExpiryKeepsRequestedWhenBeforeParent(): void + { + $service = $this->newService(); + + $result = $service->capExpiryToParent('2025-06-01 00:00:00', '2025-12-31 00:00:00'); + + $this->assertSame('2025-06-01 00:00:00', $result); + } + + public function testCapExpiryCapsToParentWhenRequestedIsAfter(): void + { + $service = $this->newService(); + + $result = $service->capExpiryToParent('2026-06-01 00:00:00', '2025-12-31 00:00:00'); + + $this->assertSame('2025-12-31 00:00:00', $result); + } + + public function testCapExpiryKeepsRequestedWhenParentIsNull(): void + { + $service = $this->newService(); + + $result = $service->capExpiryToParent('2026-06-01 00:00:00', null); + + $this->assertSame('2026-06-01 00:00:00', $result); + } + + public function testCapExpiryKeepsRequestedWhenParentIsEmpty(): void + { + $service = $this->newService(); + + $result = $service->capExpiryToParent('2026-06-01 00:00:00', ''); + + $this->assertSame('2026-06-01 00:00:00', $result); + } + + public function testCapExpiryReturnsParentWhenRequestedIsNull(): void + { + $service = $this->newService(); + + $result = $service->capExpiryToParent(null, '2025-12-31 00:00:00'); + + $this->assertSame('2025-12-31 00:00:00', $result); + } + + public function testCapExpiryReturnsNullWhenBothAreNull(): void + { + $service = $this->newService(); + + $result = $service->capExpiryToParent(null, null); + + $this->assertNull($result); + } + + // --------------------------------------------------------------- + // Factory helper + // --------------------------------------------------------------- + + private function newService( + ?ApiTokenRepository $apiTokenRepository = null, + ?TenantRepository $tenantRepository = null, + ?TenantScopeService $authScopeGateway = null + ): ApiTokenEndpointService { + return new ApiTokenEndpointService( + $apiTokenRepository ?? $this->createMock(ApiTokenRepository::class), + $tenantRepository ?? $this->createMock(TenantRepository::class), + $authScopeGateway ?? $this->createMock(TenantScopeService::class) + ); + } +} diff --git a/tests/Service/Auth/ApiTokenServiceTest.php b/tests/Service/Auth/ApiTokenServiceTest.php new file mode 100644 index 0000000..bc8b687 --- /dev/null +++ b/tests/Service/Auth/ApiTokenServiceTest.php @@ -0,0 +1,819 @@ +newService(); + + $result = $service->create(1, ' '); + + $this->assertFalse($result['ok']); + $this->assertSame('name_required', $result['error']); + } + + public function testCreateReturnsInvalidUserWhenUserIdIsZero(): void + { + $service = $this->newService(); + + $result = $service->create(0, 'My Token'); + + $this->assertFalse($result['ok']); + $this->assertSame('invalid_user', $result['error']); + } + + public function testCreateReturnsInvalidUserWhenUserIdIsNegative(): void + { + $service = $this->newService(); + + $result = $service->create(-5, 'My Token'); + + $this->assertFalse($result['ok']); + $this->assertSame('invalid_user', $result['error']); + } + + public function testCreateReturnsUserInactiveWhenUserNotFound(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->expects($this->any())->method('find')->with(42)->willReturn(null); + + $service = $this->newService(userReadRepository: $userRepo); + + $result = $service->create(42, 'Token'); + + $this->assertFalse($result['ok']); + $this->assertSame('user_inactive', $result['error']); + } + + public function testCreateReturnsUserInactiveWhenUserIsInactive(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->expects($this->any())->method('find')->with(42)->willReturn(['id' => 42, 'active' => 0]); + + $service = $this->newService(userReadRepository: $userRepo); + + $result = $service->create(42, 'Token'); + + $this->assertFalse($result['ok']); + $this->assertSame('user_inactive', $result['error']); + } + + public function testCreateReturnsMaxTokensReachedWhenLimitHit(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->expects($this->any())->method('countActiveForUser')->with(1)->willReturn(10); + + $service = $this->newService(userReadRepository: $userRepo, apiTokenRepository: $tokenRepo); + + $result = $service->create(1, 'Token'); + + $this->assertFalse($result['ok']); + $this->assertSame('max_tokens_reached', $result['error']); + } + + public function testCreateReturnsTenantNotAssignedWhenUserLacksTenant(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('countActiveForUser')->willReturn(0); + + $scopeGateway = $this->createMock(TenantScopeService::class); + $scopeGateway->expects($this->any())->method('getUserTenantIds')->with(1)->willReturn([2, 3]); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + scopeGateway: $scopeGateway + ); + + $result = $service->create(1, 'Token', tenantId: 99); + + $this->assertFalse($result['ok']); + $this->assertSame('tenant_not_assigned', $result['error']); + } + + public function testCreateReturnsInvalidExpiresAtForPastDate(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('countActiveForUser')->willReturn(0); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(90); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + settingsGateway: $settingsGateway + ); + + $result = $service->create(1, 'Token', expiresAt: '2020-01-01 00:00:00'); + + $this->assertFalse($result['ok']); + $this->assertSame('invalid_expires_at', $result['error']); + } + + public function testCreateReturnsCreateFailedWhenRepositoryReturnsNull(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('countActiveForUser')->willReturn(0); + $tokenRepo->method('create')->willReturn(null); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + settingsGateway: $settingsGateway + ); + + $result = $service->create(1, 'Token'); + + $this->assertFalse($result['ok']); + $this->assertSame('create_failed', $result['error']); + } + + public function testCreateReturnsCreateFailedWhenFindAfterInsertFails(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('countActiveForUser')->willReturn(0); + $tokenRepo->method('create')->willReturn(55); + $tokenRepo->expects($this->any())->method('find')->with(55)->willReturn(null); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + settingsGateway: $settingsGateway + ); + + $result = $service->create(1, 'Token'); + + $this->assertFalse($result['ok']); + $this->assertSame('create_failed', $result['error']); + } + + public function testCreateSuccessReturnsTokenAndId(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('countActiveForUser')->willReturn(0); + $tokenRepo->method('create')->willReturn(99); + $tokenRepo->expects($this->any())->method('find')->with(99)->willReturn(['id' => 99, 'uuid' => 'abc-uuid-123']); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + settingsGateway: $settingsGateway + ); + + $result = $service->create(1, 'My API Token'); + + $this->assertTrue($result['ok']); + $this->assertSame(99, $result['id']); + $this->assertSame('abc-uuid-123', $result['uuid']); + $this->assertStringContainsString(':', $result['token']); + $this->assertNotEmpty($result['expires_at']); + + // Selector is 24-char hex, plain token is 64-char hex + [$selector, $plain] = explode(':', $result['token'], 2); + $this->assertSame(24, strlen($selector)); + $this->assertSame(64, strlen($plain)); + } + + public function testCreateWithTenantSucceedsWhenUserHasTenant(): void + { + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('countActiveForUser')->willReturn(0); + $tokenRepo->method('create')->willReturn(100); + $tokenRepo->expects($this->any())->method('find')->with(100)->willReturn(['id' => 100, 'uuid' => 'uuid-t']); + + $scopeGateway = $this->createMock(TenantScopeService::class); + $scopeGateway->expects($this->any())->method('getUserTenantIds')->with(1)->willReturn([5, 10]); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + settingsGateway: $settingsGateway, + scopeGateway: $scopeGateway + ); + + $result = $service->create(1, 'Tenant Token', tenantId: 5); + + $this->assertTrue($result['ok']); + } + + // --------------------------------------------------------------- + // validate() + // --------------------------------------------------------------- + + public function testValidateReturnsNullForEmptyToken(): void + { + $service = $this->newService(); + + $this->assertNull($service->validate('')); + } + + public function testValidateReturnsNullForMalformedBearerWithoutColon(): void + { + $service = $this->newService(); + + $this->assertNull($service->validate('no-colon-here')); + } + + public function testValidateReturnsNullWhenSelectorIsEmpty(): void + { + $service = $this->newService(); + + $this->assertNull($service->validate(':plaintoken')); + } + + public function testValidateReturnsNullWhenPlainTokenIsEmpty(): void + { + $service = $this->newService(); + + $this->assertNull($service->validate('selector:')); + } + + public function testValidateReturnsNullWhenSelectorNotFoundPerformsDummyHash(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->expects($this->any())->method('findBySelector')->with('abc123')->willReturn(null); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertNull($service->validate('abc123:sometoken')); + } + + public function testValidateReturnsNullWhenTokenIsRevoked(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('findBySelector')->willReturn([ + 'id' => 1, + 'user_id' => 5, + 'revoked_at' => '2024-01-01 00:00:00', + 'expires_at' => null, + 'token_hash' => hash('sha256', 'secret'), + ]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertNull($service->validate('sel:secret')); + } + + public function testValidateReturnsNullWhenTokenIsExpired(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('findBySelector')->willReturn([ + 'id' => 1, + 'user_id' => 5, + 'revoked_at' => null, + 'expires_at' => '2020-01-01 00:00:00', + 'token_hash' => hash('sha256', 'secret'), + ]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertNull($service->validate('sel:secret')); + } + + public function testValidateReturnsNullWhenHashDoesNotMatch(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('findBySelector')->willReturn([ + 'id' => 1, + 'user_id' => 5, + 'revoked_at' => null, + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600), + 'token_hash' => hash('sha256', 'correct-secret'), + ]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertNull($service->validate('sel:wrong-secret')); + } + + public function testValidateReturnsNullWhenUserIsInactive(): void + { + $plainToken = 'mytesttoken123'; + $tokenHash = hash('sha256', $plainToken); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('findBySelector')->willReturn([ + 'id' => 1, + 'user_id' => 5, + 'revoked_at' => null, + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600), + 'token_hash' => $tokenHash, + ]); + + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->expects($this->any())->method('find')->with(5)->willReturn(['id' => 5, 'active' => 0]); + + $service = $this->newService(userReadRepository: $userRepo, apiTokenRepository: $tokenRepo); + + $this->assertNull($service->validate('sel:' . $plainToken)); + } + + public function testValidateReturnsNullWhenUserNotFound(): void + { + $plainToken = 'mytesttoken456'; + $tokenHash = hash('sha256', $plainToken); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('findBySelector')->willReturn([ + 'id' => 1, + 'user_id' => 5, + 'revoked_at' => null, + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600), + 'token_hash' => $tokenHash, + ]); + + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->expects($this->any())->method('find')->with(5)->willReturn(null); + + $service = $this->newService(userReadRepository: $userRepo, apiTokenRepository: $tokenRepo); + + $this->assertNull($service->validate('sel:' . $plainToken)); + } + + public function testValidateSuccessReturnsUserAndTokenAndUpdatesLastUsed(): void + { + $plainToken = 'validplaintoken789'; + $tokenHash = hash('sha256', $plainToken); + + $tokenRecord = [ + 'id' => 10, + 'user_id' => 7, + 'revoked_at' => null, + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400), + 'token_hash' => $tokenHash, + 'tenant_id' => 3, + ]; + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->expects($this->any())->method('findBySelector')->with('mysel')->willReturn($tokenRecord); + $tokenRepo->expects($this->once()) + ->method('updateLastUsed') + ->with(10, '192.168.1.1'); + + $userRecord = ['id' => 7, 'active' => 1, 'email' => 'user@test.com']; + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->expects($this->any())->method('find')->with(7)->willReturn($userRecord); + + $requestRuntime = $this->makeRequestRuntime('192.168.1.1'); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + requestRuntime: $requestRuntime + ); + + $result = $service->validate('mysel:' . $plainToken); + + $this->assertNotNull($result); + $this->assertSame($userRecord, $result['user']); + $this->assertSame($tokenRecord, $result['token_record']); + $this->assertSame(3, $result['tenant_id']); + } + + public function testValidateSuccessWithNullTenantId(): void + { + $plainToken = 'notenant123'; + $tokenHash = hash('sha256', $plainToken); + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('findBySelector')->willReturn([ + 'id' => 11, + 'user_id' => 8, + 'revoked_at' => null, + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400), + 'token_hash' => $tokenHash, + 'tenant_id' => null, + ]); + $tokenRepo->expects($this->once())->method('updateLastUsed'); + + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->expects($this->any())->method('find')->with(8)->willReturn(['id' => 8, 'active' => 1]); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + requestRuntime: $this->makeRequestRuntime('10.0.0.1') + ); + + $result = $service->validate('sel:' . $plainToken); + + $this->assertNotNull($result); + $this->assertNull($result['tenant_id']); + } + + // --------------------------------------------------------------- + // rotateCurrentToken() + // --------------------------------------------------------------- + + public function testRotateReturnsNotFoundWhenUserIdIsZero(): void + { + $service = $this->newService(); + + $result = $service->rotateCurrentToken(0, 1); + + $this->assertFalse($result['ok']); + $this->assertSame('current_token_not_found', $result['error']); + } + + public function testRotateReturnsNotFoundWhenTokenIdIsZero(): void + { + $service = $this->newService(); + + $result = $service->rotateCurrentToken(1, 0); + + $this->assertFalse($result['ok']); + $this->assertSame('current_token_not_found', $result['error']); + } + + public function testRotateReturnsNotFoundWhenTokenDoesNotExist(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('find')->willReturn(null); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->rotateCurrentToken(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('current_token_not_found', $result['error']); + } + + public function testRotateReturnsNotFoundWhenTokenBelongsToDifferentUser(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('find')->willReturn(['id' => 5, 'user_id' => 99, 'revoked_at' => null, 'expires_at' => null]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->rotateCurrentToken(1, 5); + + $this->assertFalse($result['ok']); + $this->assertSame('current_token_not_found', $result['error']); + } + + public function testRotateReturnsInvalidWhenTokenIsRevoked(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('find')->willReturn([ + 'id' => 5, + 'user_id' => 1, + 'revoked_at' => '2024-01-01 00:00:00', + 'expires_at' => null, + 'name' => 'Test', + ]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->rotateCurrentToken(1, 5); + + $this->assertFalse($result['ok']); + $this->assertSame('current_token_invalid', $result['error']); + } + + public function testRotateReturnsInvalidWhenTokenIsExpired(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('find')->willReturn([ + 'id' => 5, + 'user_id' => 1, + 'revoked_at' => null, + 'expires_at' => '2020-01-01 00:00:00', + 'name' => 'Test', + ]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->rotateCurrentToken(1, 5); + + $this->assertFalse($result['ok']); + $this->assertSame('current_token_invalid', $result['error']); + } + + public function testRotateSuccessRevokesOldAndCreatesNew(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('find')->willReturnCallback(function (int $id) { + if ($id === 5) { + return [ + 'id' => 5, + 'user_id' => 1, + 'revoked_at' => null, + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400), + 'name' => 'Rotate Me', + 'tenant_id' => 2, + ]; + } + // The newly created token + return ['id' => $id, 'uuid' => 'new-uuid']; + }); + $tokenRepo->method('countActiveForUserExcludingId')->willReturn(0); + $tokenRepo->method('countActiveForUser')->willReturn(0); + $tokenRepo->expects($this->any())->method('revoke')->with(5)->willReturn(true); + $tokenRepo->method('create')->willReturn(50); + + $userRepo = $this->createMock(UserReadRepositoryInterface::class); + $userRepo->method('find')->willReturn(['id' => 1, 'active' => 1]); + + $scopeGateway = $this->createMock(TenantScopeService::class); + $scopeGateway->method('getUserTenantIds')->willReturn([2]); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $settingsGateway->method('getApiTokenDefaultTtlDays')->willReturn(30); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->once())->method('commitTransaction'); + $dbSession->expects($this->never())->method('rollbackTransaction'); + + $service = $this->newService( + userReadRepository: $userRepo, + apiTokenRepository: $tokenRepo, + settingsGateway: $settingsGateway, + scopeGateway: $scopeGateway, + databaseSessionRepository: $dbSession + ); + + $result = $service->rotateCurrentToken(1, 5); + + $this->assertTrue($result['ok']); + $this->assertStringContainsString(':', $result['token']); + $this->assertSame(2, $result['tenant_id']); + } + + public function testRotateRollsBackWhenRevokeFails(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('find')->willReturn([ + 'id' => 5, + 'user_id' => 1, + 'revoked_at' => null, + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400), + 'name' => 'Test', + 'tenant_id' => null, + ]); + $tokenRepo->method('countActiveForUserExcludingId')->willReturn(0); + $tokenRepo->method('revoke')->willReturn(false); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->once())->method('rollbackTransaction'); + $dbSession->expects($this->never())->method('commitTransaction'); + + $service = $this->newService( + apiTokenRepository: $tokenRepo, + databaseSessionRepository: $dbSession + ); + + $result = $service->rotateCurrentToken(1, 5); + + $this->assertFalse($result['ok']); + $this->assertSame('rotate_failed', $result['error']); + } + + public function testRotateRollsBackOnException(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('find')->willReturn([ + 'id' => 5, + 'user_id' => 1, + 'revoked_at' => null, + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400), + 'name' => 'Test', + 'tenant_id' => null, + ]); + $tokenRepo->method('countActiveForUserExcludingId')->willReturn(0); + $tokenRepo->method('revoke')->willThrowException(new \RuntimeException('DB down')); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + apiTokenRepository: $tokenRepo, + databaseSessionRepository: $dbSession + ); + + $result = $service->rotateCurrentToken(1, 5); + + $this->assertFalse($result['ok']); + $this->assertSame('rotate_failed', $result['error']); + } + + // --------------------------------------------------------------- + // revoke() + // --------------------------------------------------------------- + + public function testRevokeReturnsNotFoundWhenTokenDoesNotExist(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('find')->willReturn(null); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->revoke(999); + + $this->assertFalse($result['ok']); + $this->assertSame(404, $result['status']); + $this->assertSame('not_found', $result['error']); + } + + public function testRevokeSuccessReturnsOk(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->expects($this->any())->method('find')->with(10)->willReturn(['id' => 10]); + $tokenRepo->expects($this->once())->method('revoke')->with(10); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->revoke(10); + + $this->assertTrue($result['ok']); + } + + // --------------------------------------------------------------- + // revokeAllForUser() + // --------------------------------------------------------------- + + public function testRevokeAllForUserReturnsCount(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->expects($this->once()) + ->method('revokeAllForUser') + ->with(5, null) + ->willReturn(3); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->revokeAllForUser(5); + + $this->assertTrue($result['ok']); + $this->assertSame(3, $result['count']); + } + + public function testRevokeAllForUserWithTenantReturnsCount(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->expects($this->once()) + ->method('revokeAllForUser') + ->with(5, 2) + ->willReturn(1); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $result = $service->revokeAllForUser(5, 2); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['count']); + } + + // --------------------------------------------------------------- + // listForUser() + // --------------------------------------------------------------- + + public function testListForUserReturnsArray(): void + { + $expected = [ + ['id' => 1, 'name' => 'Token A'], + ['id' => 2, 'name' => 'Token B'], + ]; + + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->expects($this->once()) + ->method('listByUserId') + ->with(7) + ->willReturn($expected); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertSame($expected, $service->listForUser(7)); + } + + public function testListForUserReturnsEmptyArray(): void + { + $tokenRepo = $this->createMock(ApiTokenRepositoryInterface::class); + $tokenRepo->method('listByUserId')->willReturn([]); + + $service = $this->newService(apiTokenRepository: $tokenRepo); + + $this->assertSame([], $service->listForUser(1)); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * RequestRuntimeInterface has a method named "method()" which PHPUnit 12 + * cannot mock (reserved name). Use a simple anonymous-class stub instead. + */ + private function makeRequestRuntime(string $ip = '127.0.0.1'): RequestRuntimeInterface + { + return new class ($ip) implements RequestRuntimeInterface { + public function __construct(private readonly string $stubIp) + { + } + + public function method(): string + { + return 'GET'; + } + + public function path(): string + { + return '/'; + } + + public function ip(): string + { + return $this->stubIp; + } + + public function userAgent(): string + { + return 'test'; + } + + public function queryParams(): array + { + return []; + } + + public function isSecure(): bool + { + return false; + } + }; + } + + private function newService( + ?UserReadRepositoryInterface $userReadRepository = null, + ?ApiTokenRepositoryInterface $apiTokenRepository = null, + ?AuthSettingsGateway $settingsGateway = null, + ?TenantScopeService $scopeGateway = null, + ?DatabaseSessionRepository $databaseSessionRepository = null, + ?RequestRuntimeInterface $requestRuntime = null + ): ApiTokenService { + return new ApiTokenService( + $userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class), + $apiTokenRepository ?? $this->createMock(ApiTokenRepositoryInterface::class), + $settingsGateway ?? $this->createMock(AuthSettingsGateway::class), + $scopeGateway ?? $this->createMock(TenantScopeService::class), + $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class), + $requestRuntime ?? $this->makeRequestRuntime() + ); + } +} diff --git a/tests/Service/Auth/SsoUserLinkServiceTest.php b/tests/Service/Auth/SsoUserLinkServiceTest.php new file mode 100644 index 0000000..c5d6f8e --- /dev/null +++ b/tests/Service/Auth/SsoUserLinkServiceTest.php @@ -0,0 +1,832 @@ + 'ms-tenant-id', + 'oid' => 'ms-object-id', + 'issuer' => 'https://login.microsoftonline.com/tid/v2.0', + 'subject' => 'subject-123', + 'email' => 'user@example.com', + 'given_name' => 'Ada', + 'family_name' => 'Lovelace', + 'name' => 'Ada Lovelace', + ]; + + private const BASE_TENANT = ['id' => 1]; + + // --------------------------------------------------------------- + // Identity validation errors + // --------------------------------------------------------------- + + public function testIdentityInvalidWhenTidIsMissing(): void + { + $service = $this->newService(); + $claims = self::BASE_CLAIMS; + $claims['tid'] = ''; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertFalse($result['ok']); + $this->assertSame('identity_invalid', $result['error']); + } + + public function testIdentityInvalidWhenOidIsMissing(): void + { + $service = $this->newService(); + $claims = self::BASE_CLAIMS; + $claims['oid'] = ''; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertFalse($result['ok']); + $this->assertSame('identity_invalid', $result['error']); + } + + public function testIdentityInvalidWhenIssuerIsMissing(): void + { + $service = $this->newService(); + $claims = self::BASE_CLAIMS; + $claims['issuer'] = ''; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertFalse($result['ok']); + $this->assertSame('identity_invalid', $result['error']); + } + + public function testIdentityInvalidWhenSubjectIsMissing(): void + { + $service = $this->newService(); + $claims = self::BASE_CLAIMS; + $claims['subject'] = ''; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertFalse($result['ok']); + $this->assertSame('identity_invalid', $result['error']); + } + + public function testIdentityInvalidWhenTenantIdIsZero(): void + { + $service = $this->newService(); + + $result = $service->linkOrProvisionMicrosoftUser(['id' => 0], self::BASE_CLAIMS); + + $this->assertFalse($result['ok']); + $this->assertSame('identity_invalid', $result['error']); + } + + // --------------------------------------------------------------- + // Phase 1: OID identity match + // --------------------------------------------------------------- + + public function testPhase1OidMatchReturnsExistingUserAndSyncsProfile(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 42]); + $identityGateway->expects($this->never())->method('findByProviderIssuerSubject'); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('find')->with(42)->willReturn(['id' => 42, 'active' => 1]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->expects($this->any())->method('listTenantIdsByUserId')->with(42)->willReturn([1, 2]); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userTenantRepository: $userTenantRepo, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertTrue($result['ok']); + $this->assertSame(42, $result['user_id']); + $this->assertFalse($result['created']); + } + + // --------------------------------------------------------------- + // Phase 2: Issuer/Subject match + // --------------------------------------------------------------- + + public function testPhase2IssuerSubjectMatchReturnsExistingUser(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(['user_id' => 43]); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('find')->with(43)->willReturn(['id' => 43, 'active' => 1]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->expects($this->any())->method('listTenantIdsByUserId')->with(43)->willReturn([1]); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userTenantRepository: $userTenantRepo, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertTrue($result['ok']); + $this->assertSame(43, $result['user_id']); + $this->assertFalse($result['created']); + } + + // --------------------------------------------------------------- + // Identity found but user inactive / tenant not assigned + // --------------------------------------------------------------- + + public function testIdentityFoundButUserInactive(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 44]); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('find')->with(44)->willReturn(['id' => 44, 'active' => 0]); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertFalse($result['ok']); + $this->assertSame('user_inactive', $result['error']); + } + + public function testIdentityFoundButUserNotFound(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 45]); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('find')->with(45)->willReturn(null); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertFalse($result['ok']); + $this->assertSame('user_inactive', $result['error']); + } + + public function testIdentityFoundButTenantNotAssigned(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 46]); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('find')->with(46)->willReturn(['id' => 46, 'active' => 1]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->expects($this->any())->method('listTenantIdsByUserId')->with(46)->willReturn([99, 100]); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userTenantRepository: $userTenantRepo, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertFalse($result['ok']); + $this->assertSame('tenant_not_assigned', $result['error']); + } + + // --------------------------------------------------------------- + // Email linking: email exists, user active + // --------------------------------------------------------------- + + public function testEmailLinkingCreatesIdentityLink(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + $identityGateway->expects($this->once()) + ->method('createLink') + ->with($this->callback(function (array $data): bool { + return ($data['user_id'] ?? 0) === 50 + && ($data['provider'] ?? '') === 'microsoft' + && ($data['oid'] ?? '') === 'ms-object-id'; + })); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('findByEmail')->with('user@example.com')->willReturn([ + 'id' => 50, + 'active' => 1, + 'email' => 'user@example.com', + ]); + // find() is called by syncProfileFromMicrosoft + $userReadRepo->method('find')->willReturn(['id' => 50, 'active' => 1, 'uuid' => 'u-uuid']); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userTenantRepository: $userTenantRepo, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertTrue($result['ok']); + $this->assertSame(50, $result['user_id']); + $this->assertFalse($result['created']); + } + + public function testEmailLinkingReturnsInactiveWhenUserIsInactive(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByEmail')->willReturn([ + 'id' => 51, + 'active' => 0, + ]); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertFalse($result['ok']); + $this->assertSame('user_inactive', $result['error']); + } + + // --------------------------------------------------------------- + // Email linking ensures tenant assignment + // --------------------------------------------------------------- + + public function testEmailLinkingEnsuresTenantAssignment(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + $identityGateway->method('createLink')->willReturn(true); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByEmail')->willReturn(['id' => 52, 'active' => 1]); + $userReadRepo->method('find')->willReturn(['id' => 52, 'active' => 1, 'uuid' => 'u-uuid']); + + // User is NOT assigned to tenant 1 yet + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('listTenantIdsByUserId')->willReturn([99]); + + $userAssignmentService = $this->createMock(UserAssignmentService::class); + $userAssignmentService->expects($this->once()) + ->method('syncTenants') + ->with(52, $this->callback(function (array $ids): bool { + return in_array(1, $ids, true) && in_array(99, $ids, true); + })); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userAssignmentService: $userAssignmentService, + userTenantRepository: $userTenantRepo, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertTrue($result['ok']); + } + + // --------------------------------------------------------------- + // Email missing: no identity, no email + // --------------------------------------------------------------- + + public function testEmailMissingWhenNoIdentityAndNoEmail(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $claims = self::BASE_CLAIMS; + $claims['email'] = ''; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertFalse($result['ok']); + $this->assertSame('email_missing', $result['error']); + } + + public function testEmailMissingWhenInvalidEmailFormat(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $claims = self::BASE_CLAIMS; + $claims['email'] = 'not-an-email'; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertFalse($result['ok']); + $this->assertSame('email_missing', $result['error']); + } + + // --------------------------------------------------------------- + // Full provisioning: creates new user + // --------------------------------------------------------------- + + public function testFullProvisioningCreatesNewUser(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + $identityGateway->expects($this->once())->method('createLink'); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByEmail')->willReturn(null); + $userReadRepo->method('find')->willReturn(['id' => 100, 'uuid' => 'new-uuid', 'active' => 1]); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once()) + ->method('create') + ->with($this->callback(function (array $data): bool { + return ($data['first_name'] ?? '') === 'Ada' + && ($data['last_name'] ?? '') === 'Lovelace' + && ($data['email'] ?? '') === 'user@example.com' + && str_starts_with($data['password'] ?? '', 'sso-') + && str_ends_with($data['password'] ?? '', '-A1!') + && ($data['primary_tenant_id'] ?? 0) === 1 + && ($data['current_tenant_id'] ?? 0) === 1 + && ($data['active'] ?? 0) === 1; + })) + ->willReturn(100); + $userWriteRepo->expects($this->once())->method('setEmailVerified')->with(100); + + $userAssignmentService = $this->createMock(UserAssignmentService::class); + $userAssignmentService->expects($this->once())->method('syncTenants')->with(100, [1]); + $userAssignmentService->expects($this->once())->method('syncRoles')->with(100, [5]); + $userAssignmentService->expects($this->once())->method('syncDepartments')->with(100, [3]); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getDefaultRoleId')->willReturn(5); + $settingsGateway->method('getDefaultDepartmentId')->willReturn(3); + $settingsGateway->method('getAppTheme')->willReturn('auto'); + $settingsGateway->expects($this->any())->method('normalizeTheme')->with('auto')->willReturn('auto'); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userAssignmentService: $userAssignmentService, + settingsGateway: $settingsGateway, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertTrue($result['ok']); + $this->assertSame(100, $result['user_id']); + $this->assertTrue($result['created']); + } + + public function testFullProvisioningParsesFullNameWhenGivenNameMissing(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + $identityGateway->method('createLink')->willReturn(true); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByEmail')->willReturn(null); + $userReadRepo->method('find')->willReturn(['id' => 101, 'uuid' => 'u-uuid', 'active' => 1]); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once()) + ->method('create') + ->with($this->callback(function (array $data): bool { + return ($data['first_name'] ?? '') === 'Marie' + && ($data['last_name'] ?? '') === 'Von Curie'; + })) + ->willReturn(101); + $userWriteRepo->method('setEmailVerified')->willReturn(true); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getDefaultRoleId')->willReturn(0); + $settingsGateway->method('getDefaultDepartmentId')->willReturn(0); + $settingsGateway->method('getAppTheme')->willReturn('light'); + $settingsGateway->method('normalizeTheme')->willReturn('light'); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + settingsGateway: $settingsGateway, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $claims = self::BASE_CLAIMS; + $claims['given_name'] = ''; + $claims['family_name'] = ''; + $claims['name'] = 'Marie Von Curie'; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertTrue($result['ok']); + $this->assertTrue($result['created']); + } + + public function testFullProvisioningUsesEmailPrefixWhenNoNameAvailable(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + $identityGateway->method('createLink')->willReturn(true); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByEmail')->willReturn(null); + $userReadRepo->method('find')->willReturn(['id' => 102, 'uuid' => 'u-uuid', 'active' => 1]); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once()) + ->method('create') + ->with($this->callback(function (array $data): bool { + // first_name should be "User" (ucfirst of email prefix) + return ($data['first_name'] ?? '') === 'User' + && ($data['last_name'] ?? '') === ''; + })) + ->willReturn(102); + $userWriteRepo->method('setEmailVerified')->willReturn(true); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getDefaultRoleId')->willReturn(0); + $settingsGateway->method('getDefaultDepartmentId')->willReturn(0); + $settingsGateway->method('getAppTheme')->willReturn('auto'); + $settingsGateway->method('normalizeTheme')->willReturn('auto'); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + settingsGateway: $settingsGateway, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $claims = self::BASE_CLAIMS; + $claims['given_name'] = ''; + $claims['family_name'] = ''; + $claims['name'] = ''; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertTrue($result['ok']); + } + + public function testFullProvisioningSkipsRoleAndDeptWhenDefaultsAreZero(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + $identityGateway->method('createLink')->willReturn(true); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByEmail')->willReturn(null); + $userReadRepo->method('find')->willReturn(['id' => 103, 'uuid' => 'u-uuid', 'active' => 1]); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->method('create')->willReturn(103); + $userWriteRepo->method('setEmailVerified')->willReturn(true); + + $userAssignmentService = $this->createMock(UserAssignmentService::class); + $userAssignmentService->expects($this->once())->method('syncTenants'); + $userAssignmentService->expects($this->never())->method('syncRoles'); + $userAssignmentService->expects($this->never())->method('syncDepartments'); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getDefaultRoleId')->willReturn(0); + $settingsGateway->method('getDefaultDepartmentId')->willReturn(0); + $settingsGateway->method('getAppTheme')->willReturn('auto'); + $settingsGateway->method('normalizeTheme')->willReturn('auto'); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userAssignmentService: $userAssignmentService, + settingsGateway: $settingsGateway, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertTrue($result['ok']); + } + + // --------------------------------------------------------------- + // user_create_failed + // --------------------------------------------------------------- + + public function testUserCreateFailedWhenRepositoryReturnsFalse(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByEmail')->willReturn(null); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->method('create')->willReturn(false); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getAppTheme')->willReturn('auto'); + $settingsGateway->method('normalizeTheme')->willReturn('auto'); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + settingsGateway: $settingsGateway, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertFalse($result['ok']); + $this->assertSame('user_create_failed', $result['error']); + } + + public function testUserCreateFailedWhenRepositoryReturnsZero(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(null); + $identityGateway->method('findByProviderIssuerSubject')->willReturn(null); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByEmail')->willReturn(null); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->method('create')->willReturn(0); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getAppTheme')->willReturn('auto'); + $settingsGateway->method('normalizeTheme')->willReturn('auto'); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + settingsGateway: $settingsGateway, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertFalse($result['ok']); + $this->assertSame('user_create_failed', $result['error']); + } + + // --------------------------------------------------------------- + // Avatar sync + // --------------------------------------------------------------- + + public function testAvatarSyncProcessesValidJpegBase64(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 60]); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('find')->with(60)->willReturn(['id' => 60, 'active' => 1, 'uuid' => 'user-uuid-60']); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once()) + ->method('updateProfileFieldsFromSso') + ->with(60, $this->isType('array')) + ->willReturn(true); + + $avatarService = $this->createMock(UserAvatarService::class); + $avatarService->expects($this->any())->method('isValidUuid')->with('user-uuid-60')->willReturn(true); + $avatarService->expects($this->once()) + ->method('saveBinary') + ->with('user-uuid-60', $this->isType('string'), 'image/jpeg') + ->willReturn(['ok' => true]); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + $tenantSsoService->method('normalizeProfileSyncFields') + ->willReturn(['first_name', 'last_name', 'avatar']); + $tenantSsoService->method('defaultProfileSyncFields') + ->willReturn(['first_name', 'last_name']); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userTenantRepository: $userTenantRepo, + avatarService: $avatarService, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $claims = self::BASE_CLAIMS; + $claims['sync_profile_on_login'] = true; + $claims['sync_profile_fields'] = ['first_name', 'last_name', 'avatar']; + $claims['graph_avatar_data_base64'] = base64_encode('fake-jpeg-binary-data'); + $claims['graph_avatar_mime'] = 'image/jpeg'; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertTrue($result['ok']); + } + + public function testAvatarSyncSkipsInvalidMime(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 61]); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('find')->with(61)->willReturn(['id' => 61, 'active' => 1, 'uuid' => 'user-uuid-61']); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once())->method('updateProfileFieldsFromSso'); + + $avatarService = $this->createMock(UserAvatarService::class); + $avatarService->expects($this->never())->method('saveBinary'); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + $tenantSsoService->method('normalizeProfileSyncFields') + ->willReturn(['first_name', 'avatar']); + $tenantSsoService->method('defaultProfileSyncFields') + ->willReturn(['first_name', 'last_name']); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userTenantRepository: $userTenantRepo, + avatarService: $avatarService, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + $claims = self::BASE_CLAIMS; + $claims['sync_profile_on_login'] = true; + $claims['sync_profile_fields'] = ['first_name', 'avatar']; + $claims['graph_avatar_data_base64'] = base64_encode('some-data'); + $claims['graph_avatar_mime'] = 'application/pdf'; + + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, $claims); + + $this->assertTrue($result['ok']); + } + + public function testProfileSyncSkippedWhenFlagNotSet(): void + { + $identityGateway = $this->createMock(AuthExternalIdentityGateway::class); + $identityGateway->method('findByProviderTidOid')->willReturn(['user_id' => 62]); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->any())->method('find')->with(62)->willReturn(['id' => 62, 'active' => 1]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->never())->method('updateProfileFieldsFromSso'); + + $avatarService = $this->createMock(UserAvatarService::class); + $avatarService->expects($this->never())->method('saveBinary'); + + $tenantSsoService = $this->createMock(TenantSsoService::class); + $tenantSsoService->method('microsoftProviderKey')->willReturn('microsoft'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userTenantRepository: $userTenantRepo, + avatarService: $avatarService, + tenantSsoService: $tenantSsoService, + externalIdentityGateway: $identityGateway + ); + + // No sync_profile_on_login key + $result = $service->linkOrProvisionMicrosoftUser(self::BASE_TENANT, self::BASE_CLAIMS); + + $this->assertTrue($result['ok']); + } + + // --------------------------------------------------------------- + // Factory helper + // --------------------------------------------------------------- + + private function newService( + ?UserReadRepositoryInterface $userReadRepository = null, + ?UserWriteRepositoryInterface $userWriteRepository = null, + ?UserAssignmentService $userAssignmentService = null, + ?UserTenantRepositoryInterface $userTenantRepository = null, + ?AuthSettingsGateway $settingsGateway = null, + ?TenantSsoService $tenantSsoService = null, + ?UserAvatarService $avatarService = null, + ?AuthExternalIdentityGateway $externalIdentityGateway = null + ): SsoUserLinkService { + return new SsoUserLinkService( + $userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class), + $userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class), + $userAssignmentService ?? $this->createMock(UserAssignmentService::class), + $userTenantRepository ?? $this->createMock(UserTenantRepositoryInterface::class), + $settingsGateway ?? $this->createMock(AuthSettingsGateway::class), + $tenantSsoService ?? $this->createMock(TenantSsoService::class), + $avatarService ?? $this->createMock(UserAvatarService::class), + $externalIdentityGateway ?? $this->createMock(AuthExternalIdentityGateway::class) + ); + } +} diff --git a/tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php b/tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php new file mode 100644 index 0000000..671ff39 --- /dev/null +++ b/tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php @@ -0,0 +1,489 @@ +createMock(SettingsApiPolicyGateway::class); + $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); + $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); + $apiPolicyGateway->expects($this->once()) + ->method('setApiTokenMaxTtlDays') + ->with(180) + ->willReturn(true); + $apiPolicyGateway->method('setApiTokenDefaultTtlDays')->willReturn(true); + $apiPolicyGateway->method('setApiCorsAllowedOrigins')->willReturn(true); + + $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'api_token_max_ttl_days' => '180', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidApiTokenMaxTtl(): void + { + $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); + $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); + $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); + $apiPolicyGateway->expects($this->once()) + ->method('setApiTokenMaxTtlDays') + ->willReturn(false); + $apiPolicyGateway->method('setApiTokenDefaultTtlDays')->willReturn(true); + $apiPolicyGateway->method('setApiCorsAllowedOrigins')->willReturn(true); + + $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'api_token_max_ttl_days' => '99999', + ])); + + $this->assertContains('api_token_max_ttl_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // API token default TTL + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidApiTokenDefaultTtl(): void + { + $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); + $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); + $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); + $apiPolicyGateway->method('setApiTokenMaxTtlDays')->willReturn(true); + $apiPolicyGateway->expects($this->once()) + ->method('setApiTokenDefaultTtlDays') + ->with(60) + ->willReturn(true); + $apiPolicyGateway->method('setApiCorsAllowedOrigins')->willReturn(true); + + $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'api_token_default_ttl_days' => '60', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidApiTokenDefaultTtl(): void + { + $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); + $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); + $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); + $apiPolicyGateway->method('setApiTokenMaxTtlDays')->willReturn(true); + $apiPolicyGateway->expects($this->once()) + ->method('setApiTokenDefaultTtlDays') + ->willReturn(false); + $apiPolicyGateway->method('setApiCorsAllowedOrigins')->willReturn(true); + + $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'api_token_default_ttl_days' => '9999', + ])); + + $this->assertContains('api_token_default_ttl_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // CORS allowed origins + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidCorsOrigins(): void + { + $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); + $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); + $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); + $apiPolicyGateway->method('setApiTokenMaxTtlDays')->willReturn(true); + $apiPolicyGateway->method('setApiTokenDefaultTtlDays')->willReturn(true); + $apiPolicyGateway->expects($this->once()) + ->method('setApiCorsAllowedOrigins') + ->with('https://example.com') + ->willReturn(true); + + $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'api_cors_allowed_origins' => 'https://example.com', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidCorsOrigins(): void + { + $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); + $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); + $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); + $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); + $apiPolicyGateway->method('setApiTokenMaxTtlDays')->willReturn(true); + $apiPolicyGateway->method('setApiTokenDefaultTtlDays')->willReturn(true); + $apiPolicyGateway->expects($this->once()) + ->method('setApiCorsAllowedOrigins') + ->willReturn(false); + + $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'api_cors_allowed_origins' => 'not-a-valid-origin', + ])); + + $this->assertContains('api_cors_allowed_origins_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // User inactivity deactivate days + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidDeactivateDays(): void + { + $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); + $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(180); + $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(365); + $lifecycleGateway->expects($this->once()) + ->method('setUserInactivityDeactivateDays') + ->with(90) + ->willReturn(true); + $lifecycleGateway->expects($this->once()) + ->method('setUserInactivityDeleteDays') + ->with(365) + ->willReturn(true); + + $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'user_inactivity_deactivate_days' => '90', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidDeactivateDays(): void + { + $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); + $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(180); + $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(365); + $lifecycleGateway->expects($this->once()) + ->method('setUserInactivityDeactivateDays') + ->willReturn(false); + $lifecycleGateway->method('setUserInactivityDeleteDays')->willReturn(true); + + $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'user_inactivity_deactivate_days' => '99999', + ])); + + $this->assertContains('user_inactivity_deactivate_days_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // User inactivity delete days + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidDeleteDays(): void + { + $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); + $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(180); + $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(365); + $lifecycleGateway->expects($this->once()) + ->method('setUserInactivityDeactivateDays') + ->with(180) + ->willReturn(true); + $lifecycleGateway->expects($this->once()) + ->method('setUserInactivityDeleteDays') + ->with(730) + ->willReturn(true); + + $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'user_inactivity_delete_days' => '730', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidDeleteDays(): void + { + $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); + $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(180); + $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(365); + $lifecycleGateway->method('setUserInactivityDeactivateDays')->willReturn(true); + $lifecycleGateway->expects($this->once()) + ->method('setUserInactivityDeleteDays') + ->willReturn(false); + + $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'user_inactivity_delete_days' => '99999', + ])); + + $this->assertContains('user_inactivity_delete_days_invalid', $this->extractErrorKeys($result)); + } + + public function testUpdateFromAdminReturnsErrorWhenDeleteDaysSetWithDeactivateDisabled(): void + { + $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); + $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(0); + $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(0); + $lifecycleGateway->method('setUserInactivityDeactivateDays')->willReturn(true); + $lifecycleGateway->expects($this->once()) + ->method('setUserInactivityDeleteDays') + ->with(0); + + $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'user_inactivity_deactivate_days' => '0', + 'user_inactivity_delete_days' => '365', + ])); + + $this->assertContains('user_inactivity_delete_requires_deactivate', $this->extractErrorKeys($result)); + } + + public function testUpdateFromAdminForcesDeleteDaysToZeroWhenDeactivateDisabled(): void + { + $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); + $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(0); + $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(0); + $lifecycleGateway->method('setUserInactivityDeactivateDays')->willReturn(true); + $lifecycleGateway->expects($this->once()) + ->method('setUserInactivityDeleteDays') + ->with(0); + + $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); + $service->updateFromAdmin($this->validPostData([ + 'user_inactivity_deactivate_days' => '0', + 'user_inactivity_delete_days' => '500', + ])); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** @return list */ + private function extractErrorKeys(array $result): array + { + $errors = is_array($result['errors'] ?? null) ? $result['errors'] : []; + $errorKeys = []; + foreach ($errors as $error) { + if (is_array($error) && isset($error['key'])) { + $errorKeys[] = (string) $error['key']; + } + } + return $errorKeys; + } + + /** + * @param array $overrides + * @return array + */ + private function validPostData(array $overrides = []): array + { + $base = [ + 'default_tenant_id' => '0', + 'default_role_id' => '0', + 'default_department_id' => '0', + 'app_title' => 'Demo', + 'app_locale' => 'de', + 'app_theme' => 'light', + 'app_theme_user' => '1', + 'app_registration' => '1', + 'api_token_default_ttl_days' => '90', + 'api_token_max_ttl_days' => '365', + 'user_inactivity_deactivate_days' => '180', + 'user_inactivity_delete_days' => '365', + 'session_idle_timeout_minutes' => '30', + 'session_absolute_timeout_hours' => '8', + 'api_cors_allowed_origins' => '', + 'system_audit_enabled' => '1', + 'system_audit_retention_days' => '365', + 'frontend_telemetry_enabled' => '1', + 'frontend_telemetry_sample_rate' => '0.2', + 'frontend_telemetry_allowed_events' => ['warn_once'], + 'app_primary_color' => '#2FA4A4', + 'smtp_host' => '', + 'smtp_port' => '', + 'smtp_user' => '', + 'smtp_password' => '', + 'smtp_secure' => '', + 'smtp_from' => '', + 'smtp_from_name' => '', + 'microsoft_shared_client_id' => '', + 'microsoft_shared_client_secret' => '', + 'microsoft_authority' => 'https://login.microsoftonline.com/common/v2.0', + ]; + + return array_replace($base, $overrides); + } + + private function newService( + ?SettingsApiPolicyGateway $settingsApiPolicyGateway = null, + ?SettingsUserLifecycleGateway $settingsUserLifecycleGateway = null + ): AdminSettingsService { + $settingsDefaultsGateway = $this->createConfiguredMock(SettingsDefaultsGateway::class, [ + 'getDefaultTenantId' => null, + 'getDefaultRoleId' => null, + 'getDefaultDepartmentId' => null, + 'setDefaultTenantId' => true, + 'setDefaultRoleId' => true, + 'setDefaultDepartmentId' => true, + ]); + + $settingsAppGateway = $this->createConfiguredMock(SettingsAppGateway::class, [ + 'getAppLocale' => 'de', + 'getAppTheme' => 'light', + 'isUserThemeAllowed' => true, + 'isRegistrationEnabled' => true, + 'getAppPrimaryColor' => '#2FA4A4', + 'setAppTitle' => true, + 'setAppLocale' => true, + 'setAppTheme' => true, + 'setUserThemeAllowed' => true, + 'setRegistrationEnabled' => true, + 'setAppPrimaryColor' => true, + ]); + + $settingsApiPolicyGateway = $settingsApiPolicyGateway ?? $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ + 'getApiTokenDefaultTtlDays' => 90, + 'getApiTokenMaxTtlDays' => 365, + 'getApiCorsAllowedOriginsText' => '', + 'setApiTokenDefaultTtlDays' => true, + 'setApiTokenMaxTtlDays' => true, + 'setApiCorsAllowedOrigins' => true, + ]); + + $settingsUserLifecycleGateway = $settingsUserLifecycleGateway ?? $this->createConfiguredMock(SettingsUserLifecycleGateway::class, [ + 'getUserInactivityDeactivateDays' => 180, + 'getUserInactivityDeleteDays' => 365, + 'setUserInactivityDeactivateDays' => true, + 'setUserInactivityDeleteDays' => true, + ]); + + $settingsSessionGateway = $this->createConfiguredMock(SettingsSessionGateway::class, [ + 'getSessionIdleTimeoutMinutes' => 30, + 'getSessionAbsoluteTimeoutHours' => 8, + 'setSessionIdleTimeoutMinutes' => true, + 'setSessionAbsoluteTimeoutHours' => true, + ]); + + $settingsSystemAuditGateway = $this->createConfiguredMock(SettingsSystemAuditGateway::class, [ + 'isSystemAuditEnabled' => true, + 'getSystemAuditRetentionDays' => 365, + 'setSystemAuditEnabled' => true, + 'setSystemAuditRetentionDays' => true, + ]); + + $settingsFrontendTelemetryGateway = $this->createConfiguredMock(SettingsFrontendTelemetryGateway::class, [ + 'isFrontendTelemetryEnabled' => true, + 'getFrontendTelemetrySampleRate' => 0.2, + 'getFrontendTelemetryAllowedEvents' => ['warn_once'], + 'setFrontendTelemetryEnabled' => true, + 'setFrontendTelemetrySampleRate' => true, + 'setFrontendTelemetryAllowedEvents' => true, + ]); + + $settingsMicrosoftGateway = $this->createConfiguredMock(SettingsMicrosoftGateway::class, [ + 'getMicrosoftAuthority' => 'https://login.microsoftonline.com/common/v2.0', + 'setMicrosoftSharedClientId' => true, + 'setMicrosoftSharedClientSecret' => true, + 'setMicrosoftAuthority' => true, + ]); + + $settingsSmtpGateway = $this->createConfiguredMock(SettingsSmtpGateway::class, [ + 'getSmtpSecure' => '', + 'setSmtpHost' => true, + 'setSmtpPort' => true, + 'setSmtpUser' => true, + 'setSmtpPassword' => true, + 'setSmtpSecure' => true, + 'setSmtpFrom' => true, + 'setSmtpFromName' => true, + ]); + + $loginPersistenceGateway = $this->createConfiguredMock(SettingsLoginPersistenceGateway::class, [ + 'isMicrosoftAutoRememberDefault' => false, + 'getRememberTokenLifetimeDays' => 30, + 'setMicrosoftAutoRememberDefault' => true, + 'setRememberTokenLifetimeDays' => true, + ]); + + $settingsMetadataGateway = $this->createMock(SettingsMetadataGateway::class); + $settingsMetadataGateway->method('getValue')->willReturn(null); + $settingsMetadataGateway->method('get')->willReturnCallback( + static fn (string $key): array => ['key' => $key, 'description' => 'setting.default'] + ); + + $settingCacheService = $this->createConfiguredMock(SettingCacheService::class, [ + 'update' => true, + ]); + + $tenantService = $this->createConfiguredMock(TenantService::class, [ + 'list' => [], + ]); + $roleService = $this->createConfiguredMock(RoleService::class, [ + 'listActive' => [], + ]); + $departmentService = $this->createConfiguredMock(DepartmentService::class, [ + 'list' => [], + ]); + + $rememberTokenRepository = $this->createConfiguredMock(RememberTokenRepository::class, [ + 'countActive' => 0, + ]); + $apiTokenRepository = $this->createConfiguredMock(ApiTokenRepository::class, [ + 'countActive' => 0, + ]); + $systemAuditService = $this->createConfiguredMock(SystemAuditService::class, [ + 'record' => null, + ]); + + return new AdminSettingsService( + $settingsDefaultsGateway, + $settingsAppGateway, + $settingsApiPolicyGateway, + $settingsUserLifecycleGateway, + $settingsSessionGateway, + $settingsSystemAuditGateway, + $settingsFrontendTelemetryGateway, + $settingsMicrosoftGateway, + $settingsSmtpGateway, + $loginPersistenceGateway, + $settingsMetadataGateway, + $settingCacheService, + $tenantService, + $roleService, + $departmentService, + $rememberTokenRepository, + $apiTokenRepository, + $systemAuditService + ); + } +} diff --git a/tests/Service/Settings/AdminSettingsServiceAppTest.php b/tests/Service/Settings/AdminSettingsServiceAppTest.php new file mode 100644 index 0000000..0f7dd1e --- /dev/null +++ b/tests/Service/Settings/AdminSettingsServiceAppTest.php @@ -0,0 +1,454 @@ +createMock(SettingsAppGateway::class); + $appGateway->method('getAppLocale')->willReturn('de'); + $appGateway->method('getAppTheme')->willReturn('light'); + $appGateway->method('isUserThemeAllowed')->willReturn(true); + $appGateway->method('isRegistrationEnabled')->willReturn(true); + $appGateway->method('getAppPrimaryColor')->willReturn('#2FA4A4'); + $appGateway->method('setAppTitle')->willReturn(true); + $appGateway->method('setAppLocale')->willReturn(true); + $appGateway->method('setAppTheme')->willReturn(true); + $appGateway->method('setUserThemeAllowed')->willReturn(true); + $appGateway->method('setRegistrationEnabled')->willReturn(true); + $appGateway->expects($this->once()) + ->method('setAppPrimaryColor') + ->with('#2FA4A4') + ->willReturn(true); + + $service = $this->newService(settingsAppGateway: $appGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'app_primary_color' => '#2FA4A4', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidPrimaryColor(): void + { + $appGateway = $this->createMock(SettingsAppGateway::class); + $appGateway->method('getAppLocale')->willReturn('de'); + $appGateway->method('getAppTheme')->willReturn('light'); + $appGateway->method('isUserThemeAllowed')->willReturn(true); + $appGateway->method('isRegistrationEnabled')->willReturn(true); + $appGateway->method('getAppPrimaryColor')->willReturn('#2FA4A4'); + $appGateway->method('setAppTitle')->willReturn(true); + $appGateway->method('setAppLocale')->willReturn(true); + $appGateway->method('setAppTheme')->willReturn(true); + $appGateway->method('setUserThemeAllowed')->willReturn(true); + $appGateway->method('setRegistrationEnabled')->willReturn(true); + $appGateway->expects($this->once()) + ->method('setAppPrimaryColor') + ->with('not-a-color') + ->willReturn(false); + + $service = $this->newService(settingsAppGateway: $appGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'app_primary_color' => 'not-a-color', + ])); + + $this->assertContains('app_primary_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // buildPageData: sorted tenants/roles/departments + // --------------------------------------------------------------- + + public function testBuildPageDataReturnsSortedTenantsByDescription(): void + { + $tenantService = $this->createConfiguredMock(TenantService::class, [ + 'list' => [ + ['id' => 2, 'description' => 'Zeta Corp'], + ['id' => 1, 'description' => 'Alpha Inc'], + ['id' => 3, 'description' => 'Mu Ltd'], + ], + ]); + $roleService = $this->createConfiguredMock(RoleService::class, [ + 'listActive' => [ + ['id' => 2, 'description' => 'Viewer'], + ['id' => 1, 'description' => 'Admin'], + ], + ]); + $departmentService = $this->createConfiguredMock(DepartmentService::class, [ + 'list' => [ + ['id' => 3, 'description' => 'Sales'], + ['id' => 1, 'description' => 'Engineering'], + ['id' => 2, 'description' => 'Marketing'], + ], + ]); + + $service = $this->newService( + tenantService: $tenantService, + roleService: $roleService, + departmentService: $departmentService + ); + $data = $service->buildPageData(); + + $tenantDescs = array_column($data['tenants'], 'description'); + $this->assertSame(['Alpha Inc', 'Mu Ltd', 'Zeta Corp'], $tenantDescs); + + $roleDescs = array_column($data['roles'], 'description'); + $this->assertSame(['Admin', 'Viewer'], $roleDescs); + + $deptDescs = array_column($data['departments'], 'description'); + $this->assertSame(['Engineering', 'Marketing', 'Sales'], $deptDescs); + } + + // --------------------------------------------------------------- + // buildPageData: active token counts + // --------------------------------------------------------------- + + public function testBuildPageDataIncludesActiveTokenCounts(): void + { + $rememberTokenRepository = $this->createConfiguredMock(RememberTokenRepository::class, [ + 'countActive' => 42, + ]); + $apiTokenRepository = $this->createConfiguredMock(ApiTokenRepository::class, [ + 'countActive' => 7, + ]); + + $service = $this->newService( + rememberTokenRepository: $rememberTokenRepository, + apiTokenRepository: $apiTokenRepository + ); + $data = $service->buildPageData(); + + $this->assertSame(42, $data['active_login_tokens']); + $this->assertSame(7, $data['active_api_tokens']); + } + + // --------------------------------------------------------------- + // buildPageData: values array contains expected keys + // --------------------------------------------------------------- + + public function testBuildPageDataValuesContainExpectedKeys(): void + { + $service = $this->newService(); + $data = $service->buildPageData(); + + $values = is_array($data['values'] ?? null) ? $data['values'] : []; + + $expectedKeys = [ + 'default_tenant_id', + 'default_role_id', + 'default_department_id', + 'app_title', + 'app_locale', + 'app_theme', + 'app_theme_user', + 'app_registration', + 'app_primary_color', + 'api_token_default_ttl_days', + 'api_token_max_ttl_days', + 'user_inactivity_deactivate_days', + 'user_inactivity_delete_days', + 'session_idle_timeout_minutes', + 'session_absolute_timeout_hours', + 'microsoft_auto_remember_default', + 'remember_token_lifetime_days', + 'api_cors_allowed_origins', + 'system_audit_enabled', + 'system_audit_retention_days', + 'frontend_telemetry_enabled', + 'frontend_telemetry_sample_rate', + 'frontend_telemetry_allowed_events', + 'microsoft_shared_client_id', + 'microsoft_authority', + 'smtp_host', + 'smtp_port', + 'smtp_user', + 'smtp_secure', + 'smtp_from', + 'smtp_from_name', + ]; + + foreach ($expectedKeys as $key) { + $this->assertArrayHasKey($key, $values, "Expected key '$key' missing from values array"); + } + } + + // --------------------------------------------------------------- + // SMTP settings: pass-through (gateway setters called) + // --------------------------------------------------------------- + + public function testUpdateFromAdminCallsSmtpGatewaySetters(): void + { + $smtpGateway = $this->createMock(SettingsSmtpGateway::class); + $smtpGateway->method('getSmtpSecure')->willReturn(''); + $smtpGateway->expects($this->once())->method('setSmtpHost')->with('mail.example.com'); + $smtpGateway->expects($this->once())->method('setSmtpPort')->with(587); + $smtpGateway->expects($this->once())->method('setSmtpUser')->with('user@example.com'); + $smtpGateway->expects($this->once())->method('setSmtpPassword')->with('secret'); + $smtpGateway->expects($this->once())->method('setSmtpSecure')->with('tls'); + $smtpGateway->expects($this->once())->method('setSmtpFrom')->with('noreply@example.com'); + $smtpGateway->expects($this->once())->method('setSmtpFromName')->with('CoreCore'); + + $service = $this->newService(settingsSmtpGateway: $smtpGateway); + $service->updateFromAdmin($this->validPostData([ + 'smtp_host' => 'mail.example.com', + 'smtp_port' => '587', + 'smtp_user' => 'user@example.com', + 'smtp_password' => 'secret', + 'smtp_secure' => 'tls', + 'smtp_from' => 'noreply@example.com', + 'smtp_from_name' => 'CoreCore', + ])); + } + + public function testUpdateFromAdminSkipsSmtpPasswordWhenEmpty(): void + { + $smtpGateway = $this->createMock(SettingsSmtpGateway::class); + $smtpGateway->method('getSmtpSecure')->willReturn(''); + $smtpGateway->method('setSmtpHost'); + $smtpGateway->method('setSmtpPort'); + $smtpGateway->method('setSmtpUser'); + $smtpGateway->expects($this->never())->method('setSmtpPassword'); + $smtpGateway->method('setSmtpSecure'); + $smtpGateway->method('setSmtpFrom'); + $smtpGateway->method('setSmtpFromName'); + + $service = $this->newService(settingsSmtpGateway: $smtpGateway); + $service->updateFromAdmin($this->validPostData([ + 'smtp_password' => '', + ])); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** @return list */ + private function extractErrorKeys(array $result): array + { + $errors = is_array($result['errors'] ?? null) ? $result['errors'] : []; + $errorKeys = []; + foreach ($errors as $error) { + if (is_array($error) && isset($error['key'])) { + $errorKeys[] = (string) $error['key']; + } + } + return $errorKeys; + } + + /** + * @param array $overrides + * @return array + */ + private function validPostData(array $overrides = []): array + { + $base = [ + 'default_tenant_id' => '0', + 'default_role_id' => '0', + 'default_department_id' => '0', + 'app_title' => 'Demo', + 'app_locale' => 'de', + 'app_theme' => 'light', + 'app_theme_user' => '1', + 'app_registration' => '1', + 'api_token_default_ttl_days' => '90', + 'api_token_max_ttl_days' => '365', + 'user_inactivity_deactivate_days' => '180', + 'user_inactivity_delete_days' => '365', + 'session_idle_timeout_minutes' => '30', + 'session_absolute_timeout_hours' => '8', + 'api_cors_allowed_origins' => '', + 'system_audit_enabled' => '1', + 'system_audit_retention_days' => '365', + 'frontend_telemetry_enabled' => '1', + 'frontend_telemetry_sample_rate' => '0.2', + 'frontend_telemetry_allowed_events' => ['warn_once'], + 'app_primary_color' => '#2FA4A4', + 'smtp_host' => '', + 'smtp_port' => '', + 'smtp_user' => '', + 'smtp_password' => '', + 'smtp_secure' => '', + 'smtp_from' => '', + 'smtp_from_name' => '', + 'microsoft_shared_client_id' => '', + 'microsoft_shared_client_secret' => '', + 'microsoft_authority' => 'https://login.microsoftonline.com/common/v2.0', + ]; + + return array_replace($base, $overrides); + } + + private function newService( + ?SettingsAppGateway $settingsAppGateway = null, + ?SettingsSmtpGateway $settingsSmtpGateway = null, + ?TenantService $tenantService = null, + ?RoleService $roleService = null, + ?DepartmentService $departmentService = null, + ?RememberTokenRepository $rememberTokenRepository = null, + ?ApiTokenRepository $apiTokenRepository = null + ): AdminSettingsService { + $settingsDefaultsGateway = $this->createConfiguredMock(SettingsDefaultsGateway::class, [ + 'getDefaultTenantId' => null, + 'getDefaultRoleId' => null, + 'getDefaultDepartmentId' => null, + 'setDefaultTenantId' => true, + 'setDefaultRoleId' => true, + 'setDefaultDepartmentId' => true, + ]); + + $settingsAppGateway = $settingsAppGateway ?? $this->createConfiguredMock(SettingsAppGateway::class, [ + 'getAppLocale' => 'de', + 'getAppTheme' => 'light', + 'isUserThemeAllowed' => true, + 'isRegistrationEnabled' => true, + 'getAppPrimaryColor' => '#2FA4A4', + 'setAppTitle' => true, + 'setAppLocale' => true, + 'setAppTheme' => true, + 'setUserThemeAllowed' => true, + 'setRegistrationEnabled' => true, + 'setAppPrimaryColor' => true, + ]); + + $settingsApiPolicyGateway = $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ + 'getApiTokenDefaultTtlDays' => 90, + 'getApiTokenMaxTtlDays' => 365, + 'getApiCorsAllowedOriginsText' => '', + 'setApiTokenDefaultTtlDays' => true, + 'setApiTokenMaxTtlDays' => true, + 'setApiCorsAllowedOrigins' => true, + ]); + + $settingsUserLifecycleGateway = $this->createConfiguredMock(SettingsUserLifecycleGateway::class, [ + 'getUserInactivityDeactivateDays' => 180, + 'getUserInactivityDeleteDays' => 365, + 'setUserInactivityDeactivateDays' => true, + 'setUserInactivityDeleteDays' => true, + ]); + + $settingsSessionGateway = $this->createConfiguredMock(SettingsSessionGateway::class, [ + 'getSessionIdleTimeoutMinutes' => 30, + 'getSessionAbsoluteTimeoutHours' => 8, + 'setSessionIdleTimeoutMinutes' => true, + 'setSessionAbsoluteTimeoutHours' => true, + ]); + + $settingsSystemAuditGateway = $this->createConfiguredMock(SettingsSystemAuditGateway::class, [ + 'isSystemAuditEnabled' => true, + 'getSystemAuditRetentionDays' => 365, + 'setSystemAuditEnabled' => true, + 'setSystemAuditRetentionDays' => true, + ]); + + $settingsFrontendTelemetryGateway = $this->createConfiguredMock(SettingsFrontendTelemetryGateway::class, [ + 'isFrontendTelemetryEnabled' => true, + 'getFrontendTelemetrySampleRate' => 0.2, + 'getFrontendTelemetryAllowedEvents' => ['warn_once'], + 'setFrontendTelemetryEnabled' => true, + 'setFrontendTelemetrySampleRate' => true, + 'setFrontendTelemetryAllowedEvents' => true, + ]); + + $settingsMicrosoftGateway = $this->createConfiguredMock(SettingsMicrosoftGateway::class, [ + 'getMicrosoftAuthority' => 'https://login.microsoftonline.com/common/v2.0', + 'setMicrosoftSharedClientId' => true, + 'setMicrosoftSharedClientSecret' => true, + 'setMicrosoftAuthority' => true, + ]); + + $settingsSmtpGateway = $settingsSmtpGateway ?? $this->createConfiguredMock(SettingsSmtpGateway::class, [ + 'getSmtpSecure' => '', + 'setSmtpHost' => true, + 'setSmtpPort' => true, + 'setSmtpUser' => true, + 'setSmtpPassword' => true, + 'setSmtpSecure' => true, + 'setSmtpFrom' => true, + 'setSmtpFromName' => true, + ]); + + $loginPersistenceGateway = $this->createConfiguredMock(SettingsLoginPersistenceGateway::class, [ + 'isMicrosoftAutoRememberDefault' => false, + 'getRememberTokenLifetimeDays' => 30, + 'setMicrosoftAutoRememberDefault' => true, + 'setRememberTokenLifetimeDays' => true, + ]); + + $settingsMetadataGateway = $this->createMock(SettingsMetadataGateway::class); + $settingsMetadataGateway->method('getValue')->willReturn(null); + $settingsMetadataGateway->method('get')->willReturnCallback( + static fn (string $key): array => ['key' => $key, 'description' => 'setting.default'] + ); + + $settingCacheService = $this->createConfiguredMock(SettingCacheService::class, [ + 'update' => true, + ]); + + $tenantService = $tenantService ?? $this->createConfiguredMock(TenantService::class, [ + 'list' => [], + ]); + $roleService = $roleService ?? $this->createConfiguredMock(RoleService::class, [ + 'listActive' => [], + ]); + $departmentService = $departmentService ?? $this->createConfiguredMock(DepartmentService::class, [ + 'list' => [], + ]); + + $rememberTokenRepository = $rememberTokenRepository ?? $this->createConfiguredMock(RememberTokenRepository::class, [ + 'countActive' => 0, + ]); + $apiTokenRepository = $apiTokenRepository ?? $this->createConfiguredMock(ApiTokenRepository::class, [ + 'countActive' => 0, + ]); + $systemAuditService = $this->createConfiguredMock(SystemAuditService::class, [ + 'record' => null, + ]); + + return new AdminSettingsService( + $settingsDefaultsGateway, + $settingsAppGateway, + $settingsApiPolicyGateway, + $settingsUserLifecycleGateway, + $settingsSessionGateway, + $settingsSystemAuditGateway, + $settingsFrontendTelemetryGateway, + $settingsMicrosoftGateway, + $settingsSmtpGateway, + $loginPersistenceGateway, + $settingsMetadataGateway, + $settingCacheService, + $tenantService, + $roleService, + $departmentService, + $rememberTokenRepository, + $apiTokenRepository, + $systemAuditService + ); + } +} diff --git a/tests/Service/Settings/AdminSettingsServiceSecurityTest.php b/tests/Service/Settings/AdminSettingsServiceSecurityTest.php new file mode 100644 index 0000000..3130bc2 --- /dev/null +++ b/tests/Service/Settings/AdminSettingsServiceSecurityTest.php @@ -0,0 +1,573 @@ +createMock(SettingsMicrosoftGateway::class); + $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); + $microsoftGateway->method('setMicrosoftSharedClientId')->willReturn(true); + $microsoftGateway->method('setMicrosoftSharedClientSecret')->willReturn(true); + $microsoftGateway->expects($this->once()) + ->method('setMicrosoftAuthority') + ->with('https://login.microsoftonline.com/tenants/v2.0') + ->willReturn(true); + + $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'microsoft_authority' => 'https://login.microsoftonline.com/tenants/v2.0', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidMicrosoftAuthority(): void + { + $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); + $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); + $microsoftGateway->method('setMicrosoftSharedClientId')->willReturn(true); + $microsoftGateway->expects($this->once()) + ->method('setMicrosoftAuthority') + ->with('http://not-https.example.com') + ->willReturn(false); + + $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'microsoft_authority' => 'http://not-https.example.com', + ])); + + $this->assertContains('microsoft_authority_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // Microsoft client secret + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidMicrosoftClientSecret(): void + { + $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); + $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); + $microsoftGateway->method('setMicrosoftSharedClientId')->willReturn(true); + $microsoftGateway->method('setMicrosoftAuthority')->willReturn(true); + $microsoftGateway->expects($this->once()) + ->method('setMicrosoftSharedClientSecret') + ->with('my-secret-value') + ->willReturn(true); + + $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'microsoft_shared_client_secret' => 'my-secret-value', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorWhenClientSecretEncryptionFails(): void + { + $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); + $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); + $microsoftGateway->method('setMicrosoftSharedClientId')->willReturn(true); + $microsoftGateway->method('setMicrosoftAuthority')->willReturn(true); + $microsoftGateway->expects($this->once()) + ->method('setMicrosoftSharedClientSecret') + ->with('fail-secret') + ->willReturn(false); + + $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'microsoft_shared_client_secret' => 'fail-secret', + ])); + + $this->assertContains('microsoft_client_secret_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // Microsoft client ID + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidMicrosoftClientId(): void + { + $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); + $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); + $microsoftGateway->method('setMicrosoftAuthority')->willReturn(true); + $microsoftGateway->method('setMicrosoftSharedClientSecret')->willReturn(true); + $microsoftGateway->expects($this->once()) + ->method('setMicrosoftSharedClientId') + ->with('some-client-id') + ->willReturn(true); + + $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'microsoft_shared_client_id' => 'some-client-id', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidMicrosoftClientId(): void + { + $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); + $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); + $microsoftGateway->method('setMicrosoftAuthority')->willReturn(true); + $microsoftGateway->expects($this->once()) + ->method('setMicrosoftSharedClientId') + ->willReturn(false); + + $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'microsoft_shared_client_id' => 'bad-id', + ])); + + $this->assertContains('microsoft_client_id_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // System audit enabled + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsSystemAuditEnabled(): void + { + $auditGateway = $this->createMock(SettingsSystemAuditGateway::class); + $auditGateway->method('isSystemAuditEnabled')->willReturn(false); + $auditGateway->method('getSystemAuditRetentionDays')->willReturn(365); + $auditGateway->expects($this->once()) + ->method('setSystemAuditEnabled') + ->with(true) + ->willReturn(true); + $auditGateway->method('setSystemAuditRetentionDays')->willReturn(true); + + $service = $this->newService(settingsSystemAuditGateway: $auditGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'system_audit_enabled' => '1', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminPersistsSystemAuditDisabled(): void + { + $auditGateway = $this->createMock(SettingsSystemAuditGateway::class); + $auditGateway->method('isSystemAuditEnabled')->willReturn(true); + $auditGateway->method('getSystemAuditRetentionDays')->willReturn(365); + $auditGateway->expects($this->once()) + ->method('setSystemAuditEnabled') + ->with(false) + ->willReturn(true); + $auditGateway->method('setSystemAuditRetentionDays')->willReturn(true); + + $service = $this->newService(settingsSystemAuditGateway: $auditGateway); + $post = $this->validPostData(); + unset($post['system_audit_enabled']); + $result = $service->updateFromAdmin($post); + + $this->assertSame([], $result['errors'] ?? []); + } + + // --------------------------------------------------------------- + // System audit retention + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidSystemAuditRetention(): void + { + $auditGateway = $this->createMock(SettingsSystemAuditGateway::class); + $auditGateway->method('isSystemAuditEnabled')->willReturn(true); + $auditGateway->method('getSystemAuditRetentionDays')->willReturn(365); + $auditGateway->method('setSystemAuditEnabled')->willReturn(true); + $auditGateway->expects($this->once()) + ->method('setSystemAuditRetentionDays') + ->with(90) + ->willReturn(true); + + $service = $this->newService(settingsSystemAuditGateway: $auditGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'system_audit_retention_days' => '90', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidSystemAuditRetention(): void + { + $auditGateway = $this->createMock(SettingsSystemAuditGateway::class); + $auditGateway->method('isSystemAuditEnabled')->willReturn(true); + $auditGateway->method('getSystemAuditRetentionDays')->willReturn(365); + $auditGateway->method('setSystemAuditEnabled')->willReturn(true); + $auditGateway->expects($this->once()) + ->method('setSystemAuditRetentionDays') + ->willReturn(false); + + $service = $this->newService(settingsSystemAuditGateway: $auditGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'system_audit_retention_days' => '99999', + ])); + + $this->assertContains('system_audit_retention_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // Frontend telemetry enabled + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsFrontendTelemetryEnabled(): void + { + $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); + $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(false); + $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); + $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); + $telemetryGateway->expects($this->once()) + ->method('setFrontendTelemetryEnabled') + ->with(true) + ->willReturn(true); + $telemetryGateway->method('setFrontendTelemetrySampleRate')->willReturn(true); + $telemetryGateway->method('setFrontendTelemetryAllowedEvents')->willReturn(true); + + $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'frontend_telemetry_enabled' => '1', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminPersistsFrontendTelemetryDisabled(): void + { + $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); + $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); + $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); + $telemetryGateway->expects($this->once()) + ->method('setFrontendTelemetryEnabled') + ->with(false) + ->willReturn(true); + $telemetryGateway->method('setFrontendTelemetrySampleRate')->willReturn(true); + $telemetryGateway->method('setFrontendTelemetryAllowedEvents')->willReturn(true); + + $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); + $post = $this->validPostData(); + unset($post['frontend_telemetry_enabled']); + $result = $service->updateFromAdmin($post); + + $this->assertSame([], $result['errors'] ?? []); + } + + // --------------------------------------------------------------- + // Frontend telemetry sample rate + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidFrontendTelemetrySampleRate(): void + { + $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); + $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); + $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); + $telemetryGateway->method('setFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->expects($this->once()) + ->method('setFrontendTelemetrySampleRate') + ->with(0.2) + ->willReturn(true); + $telemetryGateway->method('setFrontendTelemetryAllowedEvents')->willReturn(true); + + $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'frontend_telemetry_sample_rate' => '0.2', + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForNonNumericSampleRate(): void + { + $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); + $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); + $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); + $telemetryGateway->method('setFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->expects($this->once()) + ->method('setFrontendTelemetrySampleRate') + ->willReturn(false); + $telemetryGateway->method('setFrontendTelemetryAllowedEvents')->willReturn(true); + + $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'frontend_telemetry_sample_rate' => 'not-a-number', + ])); + + $this->assertContains('frontend_telemetry_sample_rate_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // Frontend telemetry allowed events + // --------------------------------------------------------------- + + public function testUpdateFromAdminPersistsValidFrontendTelemetryAllowedEvents(): void + { + $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); + $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); + $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); + $telemetryGateway->method('setFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->method('setFrontendTelemetrySampleRate')->willReturn(true); + $telemetryGateway->expects($this->once()) + ->method('setFrontendTelemetryAllowedEvents') + ->with(['warn_once', 'error']) + ->willReturn(true); + + $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'frontend_telemetry_allowed_events' => ['warn_once', 'error'], + ])); + + $this->assertSame([], $result['errors'] ?? []); + } + + public function testUpdateFromAdminReturnsErrorForInvalidFrontendTelemetryAllowedEvents(): void + { + $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); + $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); + $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); + $telemetryGateway->method('setFrontendTelemetryEnabled')->willReturn(true); + $telemetryGateway->method('setFrontendTelemetrySampleRate')->willReturn(true); + $telemetryGateway->expects($this->once()) + ->method('setFrontendTelemetryAllowedEvents') + ->willReturn(false); + + $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); + $result = $service->updateFromAdmin($this->validPostData([ + 'frontend_telemetry_allowed_events' => ['invalid_event_type'], + ])); + + $this->assertContains('frontend_telemetry_allowed_events_invalid', $this->extractErrorKeys($result)); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** @return list */ + private function extractErrorKeys(array $result): array + { + $errors = is_array($result['errors'] ?? null) ? $result['errors'] : []; + $errorKeys = []; + foreach ($errors as $error) { + if (is_array($error) && isset($error['key'])) { + $errorKeys[] = (string) $error['key']; + } + } + return $errorKeys; + } + + /** + * @param array $overrides + * @return array + */ + private function validPostData(array $overrides = []): array + { + $base = [ + 'default_tenant_id' => '0', + 'default_role_id' => '0', + 'default_department_id' => '0', + 'app_title' => 'Demo', + 'app_locale' => 'de', + 'app_theme' => 'light', + 'app_theme_user' => '1', + 'app_registration' => '1', + 'api_token_default_ttl_days' => '90', + 'api_token_max_ttl_days' => '365', + 'user_inactivity_deactivate_days' => '180', + 'user_inactivity_delete_days' => '365', + 'session_idle_timeout_minutes' => '30', + 'session_absolute_timeout_hours' => '8', + 'api_cors_allowed_origins' => '', + 'system_audit_enabled' => '1', + 'system_audit_retention_days' => '365', + 'frontend_telemetry_enabled' => '1', + 'frontend_telemetry_sample_rate' => '0.2', + 'frontend_telemetry_allowed_events' => ['warn_once'], + 'app_primary_color' => '#2FA4A4', + 'smtp_host' => '', + 'smtp_port' => '', + 'smtp_user' => '', + 'smtp_password' => '', + 'smtp_secure' => '', + 'smtp_from' => '', + 'smtp_from_name' => '', + 'microsoft_shared_client_id' => '', + 'microsoft_shared_client_secret' => '', + 'microsoft_authority' => 'https://login.microsoftonline.com/common/v2.0', + ]; + + return array_replace($base, $overrides); + } + + private function newService( + ?SettingsMicrosoftGateway $settingsMicrosoftGateway = null, + ?SettingsSystemAuditGateway $settingsSystemAuditGateway = null, + ?SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway = null + ): AdminSettingsService { + $settingsDefaultsGateway = $this->createConfiguredMock(SettingsDefaultsGateway::class, [ + 'getDefaultTenantId' => null, + 'getDefaultRoleId' => null, + 'getDefaultDepartmentId' => null, + 'setDefaultTenantId' => true, + 'setDefaultRoleId' => true, + 'setDefaultDepartmentId' => true, + ]); + + $settingsAppGateway = $this->createConfiguredMock(SettingsAppGateway::class, [ + 'getAppLocale' => 'de', + 'getAppTheme' => 'light', + 'isUserThemeAllowed' => true, + 'isRegistrationEnabled' => true, + 'getAppPrimaryColor' => '#2FA4A4', + 'setAppTitle' => true, + 'setAppLocale' => true, + 'setAppTheme' => true, + 'setUserThemeAllowed' => true, + 'setRegistrationEnabled' => true, + 'setAppPrimaryColor' => true, + ]); + + $settingsApiPolicyGateway = $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ + 'getApiTokenDefaultTtlDays' => 90, + 'getApiTokenMaxTtlDays' => 365, + 'getApiCorsAllowedOriginsText' => '', + 'setApiTokenDefaultTtlDays' => true, + 'setApiTokenMaxTtlDays' => true, + 'setApiCorsAllowedOrigins' => true, + ]); + + $settingsUserLifecycleGateway = $this->createConfiguredMock(SettingsUserLifecycleGateway::class, [ + 'getUserInactivityDeactivateDays' => 180, + 'getUserInactivityDeleteDays' => 365, + 'setUserInactivityDeactivateDays' => true, + 'setUserInactivityDeleteDays' => true, + ]); + + $settingsSessionGateway = $this->createConfiguredMock(SettingsSessionGateway::class, [ + 'getSessionIdleTimeoutMinutes' => 30, + 'getSessionAbsoluteTimeoutHours' => 8, + 'setSessionIdleTimeoutMinutes' => true, + 'setSessionAbsoluteTimeoutHours' => true, + ]); + + $settingsSystemAuditGateway = $settingsSystemAuditGateway ?? $this->createConfiguredMock(SettingsSystemAuditGateway::class, [ + 'isSystemAuditEnabled' => true, + 'getSystemAuditRetentionDays' => 365, + 'setSystemAuditEnabled' => true, + 'setSystemAuditRetentionDays' => true, + ]); + + $settingsFrontendTelemetryGateway = $settingsFrontendTelemetryGateway ?? $this->createConfiguredMock(SettingsFrontendTelemetryGateway::class, [ + 'isFrontendTelemetryEnabled' => true, + 'getFrontendTelemetrySampleRate' => 0.2, + 'getFrontendTelemetryAllowedEvents' => ['warn_once'], + 'setFrontendTelemetryEnabled' => true, + 'setFrontendTelemetrySampleRate' => true, + 'setFrontendTelemetryAllowedEvents' => true, + ]); + + $settingsMicrosoftGateway = $settingsMicrosoftGateway ?? $this->createConfiguredMock(SettingsMicrosoftGateway::class, [ + 'getMicrosoftAuthority' => 'https://login.microsoftonline.com/common/v2.0', + 'setMicrosoftSharedClientId' => true, + 'setMicrosoftSharedClientSecret' => true, + 'setMicrosoftAuthority' => true, + ]); + + $settingsSmtpGateway = $this->createConfiguredMock(SettingsSmtpGateway::class, [ + 'getSmtpSecure' => '', + 'setSmtpHost' => true, + 'setSmtpPort' => true, + 'setSmtpUser' => true, + 'setSmtpPassword' => true, + 'setSmtpSecure' => true, + 'setSmtpFrom' => true, + 'setSmtpFromName' => true, + ]); + + $loginPersistenceGateway = $this->createConfiguredMock(SettingsLoginPersistenceGateway::class, [ + 'isMicrosoftAutoRememberDefault' => false, + 'getRememberTokenLifetimeDays' => 30, + 'setMicrosoftAutoRememberDefault' => true, + 'setRememberTokenLifetimeDays' => true, + ]); + + $settingsMetadataGateway = $this->createMock(SettingsMetadataGateway::class); + $settingsMetadataGateway->method('getValue')->willReturn(null); + $settingsMetadataGateway->method('get')->willReturnCallback( + static fn (string $key): array => ['key' => $key, 'description' => 'setting.default'] + ); + + $settingCacheService = $this->createConfiguredMock(SettingCacheService::class, [ + 'update' => true, + ]); + + $tenantService = $this->createConfiguredMock(TenantService::class, [ + 'list' => [], + ]); + $roleService = $this->createConfiguredMock(RoleService::class, [ + 'listActive' => [], + ]); + $departmentService = $this->createConfiguredMock(DepartmentService::class, [ + 'list' => [], + ]); + + $rememberTokenRepository = $this->createConfiguredMock(RememberTokenRepository::class, [ + 'countActive' => 0, + ]); + $apiTokenRepository = $this->createConfiguredMock(ApiTokenRepository::class, [ + 'countActive' => 0, + ]); + $systemAuditService = $this->createConfiguredMock(SystemAuditService::class, [ + 'record' => null, + ]); + + return new AdminSettingsService( + $settingsDefaultsGateway, + $settingsAppGateway, + $settingsApiPolicyGateway, + $settingsUserLifecycleGateway, + $settingsSessionGateway, + $settingsSystemAuditGateway, + $settingsFrontendTelemetryGateway, + $settingsMicrosoftGateway, + $settingsSmtpGateway, + $loginPersistenceGateway, + $settingsMetadataGateway, + $settingCacheService, + $tenantService, + $roleService, + $departmentService, + $rememberTokenRepository, + $apiTokenRepository, + $systemAuditService + ); + } +} diff --git a/tests/Service/User/UserAssignmentServiceTest.php b/tests/Service/User/UserAssignmentServiceTest.php new file mode 100644 index 0000000..54c3a4a --- /dev/null +++ b/tests/Service/User/UserAssignmentServiceTest.php @@ -0,0 +1,557 @@ +newService(); + $result = $service->buildAssignmentsForUser(0); + + $this->assertSame([], $result['tenants']); + $this->assertSame([], $result['departments']); + $this->assertSame([], $result['roles']); + } + + public function testBuildAssignmentsForUserReturnsEmptyStructureWhenUserIdIsNegative(): void + { + $service = $this->newService(); + $result = $service->buildAssignmentsForUser(-5); + + $this->assertSame([], $result['tenants']); + $this->assertSame([], $result['departments']); + $this->assertSame([], $result['roles']); + } + + public function testBuildAssignmentsForUserReturnsTenantsDepartmentsAndRoles(): void + { + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([1, 2]); + + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->expects($this->once())->method('listDepartmentIdsByUserId')->with(10)->willReturn([100]); + + $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepo->expects($this->once())->method('listRoleIdsByUserId')->with(10)->willReturn([50]); + + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->expects($this->once())->method('listTenantsByIds')->with([1, 2])->willReturn([ + ['id' => 1, 'uuid' => 'tenant-1', 'description' => 'Tenant 1', 'status' => 'active'], + ['id' => 2, 'uuid' => 'tenant-2', 'description' => 'Tenant 2', 'status' => 'active'], + ]); + $directoryGateway->expects($this->once())->method('listDepartmentsByIds')->with([100], true)->willReturn([ + ['id' => 100, 'uuid' => 'dept-100', 'description' => 'Dept 100', 'active' => 1, 'tenant_id' => 1], + ]); + $directoryGateway->expects($this->once())->method('listRolesByIds')->with([50])->willReturn([ + ['id' => 50, 'uuid' => 'role-50', 'description' => 'Role 50', 'active' => 1], + ]); + + $service = $this->newService( + userTenantRepository: $userTenantRepo, + userDepartmentRepository: $userDeptRepo, + userRoleRepository: $userRoleRepo, + directoryGateway: $directoryGateway + ); + + $result = $service->buildAssignmentsForUser(10); + + $this->assertCount(2, $result['tenants']); + $this->assertSame(1, $result['tenants'][0]['id']); + $this->assertSame(2, $result['tenants'][1]['id']); + $this->assertCount(1, $result['departments']); + $this->assertSame(100, $result['departments'][0]['id']); + $this->assertSame('tenant-1', $result['departments'][0]['tenant_uuid']); + $this->assertCount(1, $result['roles']); + $this->assertSame(50, $result['roles'][0]['id']); + } + + // ── syncTenants ───────────────────────────────────────────────────── + + public function testSyncTenantsNormalizesAndCallsRepository(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->expects($this->once())->method('listTenantIds')->willReturn([1, 2, 3]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->expects($this->once()) + ->method('replaceForUser') + ->with(10, $this->callback(static fn (array $ids): bool => $ids === [1, 2])) + ->willReturn(true); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); + + $service = $this->newService( + userWriteRepository: $userWriteRepo, + userTenantRepository: $userTenantRepo, + directoryGateway: $directoryGateway + ); + + $result = $service->syncTenants(10, [1, 2]); + $this->assertTrue($result); + } + + public function testSyncTenantsDoesNotBumpAuthzWhenFlagIsFalse(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listTenantIds')->willReturn([1]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('replaceForUser')->willReturn(true); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->never())->method('bumpAuthzVersion'); + + $service = $this->newService( + userWriteRepository: $userWriteRepo, + userTenantRepository: $userTenantRepo, + directoryGateway: $directoryGateway + ); + + $result = $service->syncTenants(10, [1], false); + $this->assertTrue($result); + } + + // ── syncRoles ─────────────────────────────────────────────────────── + + public function testSyncRolesDeduplicatesAndFiltersAgainstActiveRoles(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->expects($this->once())->method('listActiveRoleIds')->willReturn([1, 2, 3]); + + $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepo->expects($this->once()) + ->method('replaceForUser') + ->with(10, $this->callback(static fn (array $ids): bool => $ids === [1, 3])) + ->willReturn(true); + + $permissionService = $this->createMock(PermissionService::class); + $permissionService->expects($this->once())->method('clearUserCache')->with(10); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); + + $service = $this->newService( + userWriteRepository: $userWriteRepo, + userRoleRepository: $userRoleRepo, + directoryGateway: $directoryGateway, + permissionService: $permissionService + ); + + // Pass duplicates and an inactive role (99) + $result = $service->syncRoles(10, [1, 3, 3, 99]); + $this->assertTrue($result); + } + + public function testSyncRolesWithActorEnforcesAssignableRoleFreeze(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listActiveRoleIds')->willReturn([1, 2, 3, 4]); + + $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepo->expects($this->once())->method('listRoleIdsByUserId')->with(10)->willReturn([2, 4]); + $userRoleRepo->expects($this->once()) + ->method('replaceForUser') + ->with(10, [4, 1]) // frozen(4) + touched(1) + ->willReturn(true); + + $assignableRoleService = $this->createMock(AssignableRoleService::class); + $assignableRoleService->expects($this->once()) + ->method('computeEffectiveRoleIds') + ->with(99, [1, 3], [2, 4]) + ->willReturn([4, 1]); // role 4 frozen, role 1 submitted & assignable + + $permissionService = $this->createMock(PermissionService::class); + $permissionService->expects($this->once())->method('clearUserCache')->with(10); + + $service = $this->newService( + userRoleRepository: $userRoleRepo, + directoryGateway: $directoryGateway, + permissionService: $permissionService, + assignableRoleService: $assignableRoleService + ); + + $result = $service->syncRoles(10, [1, 3], true, 99); + $this->assertTrue($result); + } + + public function testSyncRolesClearsPermissionCacheEvenOnFailure(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listActiveRoleIds')->willReturn([1]); + + $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepo->expects($this->once())->method('replaceForUser')->willReturn(false); + + $permissionService = $this->createMock(PermissionService::class); + $permissionService->expects($this->once())->method('clearUserCache')->with(10); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->never())->method('bumpAuthzVersion'); + + $service = $this->newService( + userWriteRepository: $userWriteRepo, + userRoleRepository: $userRoleRepo, + directoryGateway: $directoryGateway, + permissionService: $permissionService + ); + + $result = $service->syncRoles(10, [1]); + $this->assertFalse($result); + } + + // ── syncDepartments ───────────────────────────────────────────────── + + public function testSyncDepartmentsValidatesDepartmentsBelongToUserTenants(): void + { + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([1]); + + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->expects($this->once()) + ->method('listActiveDepartmentIdsByTenantIds') + ->with([1]) + ->willReturn([100, 101]); + + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->expects($this->once()) + ->method('replaceForUser') + ->with(10, [100]) // 200 filtered out because not in allowed list + ->willReturn(true); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); + + $service = $this->newService( + userWriteRepository: $userWriteRepo, + userTenantRepository: $userTenantRepo, + userDepartmentRepository: $userDeptRepo, + directoryGateway: $directoryGateway + ); + + $result = $service->syncDepartments(10, [100, 200]); + $this->assertTrue($result); + } + + public function testSyncDepartmentsRejectsAllWhenUserHasNoTenants(): void + { + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([]); + + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->expects($this->never())->method('listActiveDepartmentIdsByTenantIds'); + + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->expects($this->once()) + ->method('replaceForUser') + ->with(10, []) + ->willReturn(true); + + $service = $this->newService( + userTenantRepository: $userTenantRepo, + userDepartmentRepository: $userDeptRepo, + directoryGateway: $directoryGateway + ); + + $result = $service->syncDepartments(10, [100]); + $this->assertTrue($result); + } + + public function testSyncDepartmentsRejectsAllWhenNoActiveDepartmentsInTenants(): void + { + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); + + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listActiveDepartmentIdsByTenantIds')->willReturn([]); + + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->expects($this->once()) + ->method('replaceForUser') + ->with(10, []) + ->willReturn(true); + + $service = $this->newService( + userTenantRepository: $userTenantRepo, + userDepartmentRepository: $userDeptRepo, + directoryGateway: $directoryGateway + ); + + $result = $service->syncDepartments(10, [100]); + $this->assertTrue($result); + } + + // ── syncAllAssignments ────────────────────────────────────────────── + + public function testSyncAllAssignmentsReturnsFalseWhenUserIdIsZero(): void + { + $service = $this->newService(); + $this->assertFalse($service->syncAllAssignments(0, [], [], [])); + } + + public function testSyncAllAssignmentsCommitsTransactionOnSuccess(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listTenantIds')->willReturn([1]); + $directoryGateway->method('listActiveRoleIds')->willReturn([2]); + $directoryGateway->method('listActiveDepartmentIdsByTenantIds')->willReturn([3]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('replaceForUser')->willReturn(true); + $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); + + $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepo->method('replaceForUser')->willReturn(true); + + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->method('replaceForUser')->willReturn(true); + + $permissionService = $this->createMock(PermissionService::class); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->once())->method('commitTransaction'); + $dbSession->expects($this->never())->method('rollbackTransaction'); + + $service = $this->newService( + userWriteRepository: $userWriteRepo, + userTenantRepository: $userTenantRepo, + userRoleRepository: $userRoleRepo, + userDepartmentRepository: $userDeptRepo, + directoryGateway: $directoryGateway, + permissionService: $permissionService, + databaseSessionRepository: $dbSession + ); + + $result = $service->syncAllAssignments(10, [1], [2], [3]); + $this->assertTrue($result); + } + + public function testSyncAllAssignmentsRollsBackOnSyncFailure(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listTenantIds')->willReturn([1]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('replaceForUser')->willReturn(false); // tenant sync fails + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->never())->method('commitTransaction'); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userTenantRepository: $userTenantRepo, + directoryGateway: $directoryGateway, + databaseSessionRepository: $dbSession + ); + + $result = $service->syncAllAssignments(10, [1], [2], [3]); + $this->assertFalse($result); + } + + public function testSyncAllAssignmentsRollsBackOnException(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listTenantIds') + ->willThrowException(new \RuntimeException('db error')); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->never())->method('commitTransaction'); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userTenantRepository: $userTenantRepo, + directoryGateway: $directoryGateway, + databaseSessionRepository: $dbSession + ); + + $result = $service->syncAllAssignments(10, [1], [], []); + $this->assertFalse($result); + } + + public function testSyncAllAssignmentsBumpsAuthzOnlyOnce(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listTenantIds')->willReturn([1]); + $directoryGateway->method('listActiveRoleIds')->willReturn([2]); + $directoryGateway->method('listActiveDepartmentIdsByTenantIds')->willReturn([3]); + + $userTenantRepo = $this->createMock(UserTenantRepositoryInterface::class); + $userTenantRepo->method('replaceForUser')->willReturn(true); + $userTenantRepo->method('listTenantIdsByUserId')->willReturn([1]); + + $userRoleRepo = $this->createMock(UserRoleRepositoryInterface::class); + $userRoleRepo->method('replaceForUser')->willReturn(true); + + $userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class); + $userDeptRepo->method('replaceForUser')->willReturn(true); + + $permissionService = $this->createMock(PermissionService::class); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + // bumpAuthzVersion should be called exactly once (not three times) + $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(10); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + + $service = $this->newService( + userWriteRepository: $userWriteRepo, + userTenantRepository: $userTenantRepo, + userRoleRepository: $userRoleRepo, + userDepartmentRepository: $userDeptRepo, + directoryGateway: $directoryGateway, + permissionService: $permissionService, + databaseSessionRepository: $dbSession + ); + + $service->syncAllAssignments(10, [1], [2], [3]); + } + + // ── bumpAuthzVersion ──────────────────────────────────────────────── + + public function testBumpAuthzVersionReturnsEarlyWhenUserIdIsZero(): void + { + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->never())->method('bumpAuthzVersion'); + + $service = $this->newService(userWriteRepository: $userWriteRepo); + $service->bumpAuthzVersion(0); + } + + public function testBumpAuthzVersionReturnsEarlyWhenUserIdIsNegative(): void + { + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->never())->method('bumpAuthzVersion'); + + $service = $this->newService(userWriteRepository: $userWriteRepo); + $service->bumpAuthzVersion(-1); + } + + public function testBumpAuthzVersionCallsRepository(): void + { + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once())->method('bumpAuthzVersion')->with(42); + + $service = $this->newService(userWriteRepository: $userWriteRepo); + $service->bumpAuthzVersion(42); + } + + // ── normalizeIdInput ──────────────────────────────────────────────── + + public function testNormalizeIdInputWithScalarInt(): void + { + $service = $this->newService(); + $this->assertSame([5], $service->normalizeIdInput(5)); + } + + public function testNormalizeIdInputWithArrayOfInts(): void + { + $service = $this->newService(); + $this->assertSame([1, 2, 3], $service->normalizeIdInput([1, 2, 3])); + } + + public function testNormalizeIdInputWithCommaSeparatedString(): void + { + $service = $this->newService(); + $this->assertSame([1, 2, 3], $service->normalizeIdInput('1,2,3')); + } + + public function testNormalizeIdInputWithNestedArrays(): void + { + $service = $this->newService(); + $this->assertSame([1, 2, 3], $service->normalizeIdInput([[1, [2]], 3])); + } + + public function testNormalizeIdInputFiltersNegativeValues(): void + { + $service = $this->newService(); + $this->assertSame([1, 3], $service->normalizeIdInput([1, -2, 3, 0])); + } + + public function testNormalizeIdInputRemovesDuplicates(): void + { + $service = $this->newService(); + $this->assertSame([1, 2], $service->normalizeIdInput([1, 2, 1, 2])); + } + + public function testNormalizeIdInputHandlesEmptyString(): void + { + $service = $this->newService(); + $this->assertSame([], $service->normalizeIdInput('')); + } + + public function testNormalizeIdInputHandlesEmptyArray(): void + { + $service = $this->newService(); + $this->assertSame([], $service->normalizeIdInput([])); + } + + // ── normalizeTenantIds ────────────────────────────────────────────── + + public function testNormalizeTenantIdsFiltersAgainstValidTenantIds(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->expects($this->once())->method('listTenantIds')->willReturn([1, 2, 3]); + + $service = $this->newService(directoryGateway: $directoryGateway); + $result = $service->normalizeTenantIds([1, 99, 2]); + + $this->assertSame([1, 2], $result); + } + + public function testNormalizeTenantIdsFiltersNegativeAndZeroIds(): void + { + $directoryGateway = $this->createMock(UserDirectoryGateway::class); + $directoryGateway->method('listTenantIds')->willReturn([1, 2]); + + $service = $this->newService(directoryGateway: $directoryGateway); + $result = $service->normalizeTenantIds([0, -1, 1]); + + $this->assertSame([1], $result); + } + + // ── Helper ────────────────────────────────────────────────────────── + + private function newService( + ?UserWriteRepositoryInterface $userWriteRepository = null, + ?UserTenantRepositoryInterface $userTenantRepository = null, + ?UserRoleRepositoryInterface $userRoleRepository = null, + ?UserDepartmentRepositoryInterface $userDepartmentRepository = null, + ?UserDirectoryGateway $directoryGateway = null, + ?PermissionService $permissionService = null, + ?DatabaseSessionRepository $databaseSessionRepository = null, + ?AssignableRoleService $assignableRoleService = null, + ): UserAssignmentService { + return new UserAssignmentService( + $userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class), + $userTenantRepository ?? $this->createMock(UserTenantRepositoryInterface::class), + $userRoleRepository ?? $this->createMock(UserRoleRepositoryInterface::class), + $userDepartmentRepository ?? $this->createMock(UserDepartmentRepositoryInterface::class), + $directoryGateway ?? $this->createMock(UserDirectoryGateway::class), + $permissionService ?? $this->createMock(PermissionService::class), + $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class), + $assignableRoleService ?? $this->createMock(AssignableRoleService::class), + ); + } +} diff --git a/tests/Service/User/UserLifecycleRestoreServiceTest.php b/tests/Service/User/UserLifecycleRestoreServiceTest.php new file mode 100644 index 0000000..30f40a6 --- /dev/null +++ b/tests/Service/User/UserLifecycleRestoreServiceTest.php @@ -0,0 +1,485 @@ + 1, + 'restored_at' => null, + 'snapshot_enc' => 'enc-data', + 'policy_deactivate_days' => 30, + 'policy_delete_days' => 90, + ]; + + $snapshot = [ + 'uuid' => 'restored-uuid', + 'email' => 'restored@example.com', + 'first_name' => 'Max', + 'last_name' => 'Mustermann', + 'theme' => 'dark', + ]; + + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->expects($this->once()) + ->method('findDeleteEventForRestore') + ->with(1, true) + ->willReturn($event); + $auditService->expects($this->once()) + ->method('decryptSnapshot') + ->with($event) + ->willReturn($snapshot); + $auditService->expects($this->once()) + ->method('markDeleteEventRestored') + ->with(1, 99, 77) + ->willReturn(true); + $auditService->expects($this->once()) + ->method('logRestore') + ->with( + $this->isString(), + 'manual', + $this->callback(static fn (array $policy): bool => $policy['deactivate_days'] === 30 && $policy['delete_days'] === 90), + 99, + $this->callback(static fn (array $user): bool => $user['id'] === 77 && $user['uuid'] === 'restored-uuid' && $user['email'] === 'restored@example.com'), + 'success', + null + ); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->once())->method('findByUuid')->with('restored-uuid')->willReturn(null); + $userReadRepo->expects($this->once())->method('findByEmail')->with('restored@example.com')->willReturn(null); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once()) + ->method('create') + ->with($this->callback(static function (array $data): bool { + return $data['uuid'] === 'restored-uuid' + && $data['email'] === 'restored@example.com' + && $data['first_name'] === 'Max' + && $data['last_name'] === 'Mustermann' + && $data['active'] === 0 + && $data['created_by'] === 99 + && $data['primary_tenant_id'] === null + && $data['current_tenant_id'] === null + && $data['totp_secret'] === ''; + })) + ->willReturn(77); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->once())->method('commitTransaction'); + $dbSession->expects($this->never())->method('rollbackTransaction'); + + $settingsGateway = $this->createMock(UserSettingsGateway::class); + $settingsGateway->method('normalizeTheme')->willReturn('dark'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession, + userSettingsGateway: $settingsGateway + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertTrue($result['ok']); + $this->assertSame(77, $result['restored_user_id']); + $this->assertSame('restored-uuid', $result['restored_user_uuid']); + $this->assertSame(1, $result['audit_id']); + } + + // ── invalid_request ───────────────────────────────────────────────── + + public function testRestoreReturnsInvalidRequestWhenAuditIdIsZero(): void + { + $service = $this->newService(); + $result = $service->restoreFromLifecycleAudit(0, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('invalid_request', $result['error']); + } + + public function testRestoreReturnsInvalidRequestWhenAuditIdIsNegative(): void + { + $service = $this->newService(); + $result = $service->restoreFromLifecycleAudit(-1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('invalid_request', $result['error']); + } + + public function testRestoreReturnsInvalidRequestWhenActorUserIdIsZero(): void + { + $service = $this->newService(); + $result = $service->restoreFromLifecycleAudit(1, 0); + + $this->assertFalse($result['ok']); + $this->assertSame('invalid_request', $result['error']); + } + + public function testRestoreReturnsInvalidRequestWhenActorUserIdIsNegative(): void + { + $service = $this->newService(); + $result = $service->restoreFromLifecycleAudit(1, -5); + + $this->assertFalse($result['ok']); + $this->assertSame('invalid_request', $result['error']); + } + + // ── audit_event_not_found ─────────────────────────────────────────── + + public function testRestoreReturnsAuditEventNotFoundWhenEventIsNull(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->expects($this->once()) + ->method('findDeleteEventForRestore') + ->with(1, true) + ->willReturn(null); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->never())->method('commitTransaction'); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('audit_event_not_found', $result['error']); + } + + // ── audit_event_already_restored ──────────────────────────────────── + + public function testRestoreReturnsAlreadyRestoredWhenEventHasRestoredAt(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn([ + 'id' => 1, + 'restored_at' => '2025-01-01 00:00:00', + ]); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('audit_event_already_restored', $result['error']); + } + + // ── snapshot_unavailable ──────────────────────────────────────────── + + public function testRestoreReturnsSnapshotUnavailableWhenDecryptReturnsNull(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn([ + 'id' => 1, + 'restored_at' => null, + ]); + $auditService->method('decryptSnapshot')->willReturn(null); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('snapshot_unavailable', $result['error']); + } + + // ── snapshot_invalid ──────────────────────────────────────────────── + + public function testRestoreReturnsSnapshotInvalidWhenUuidIsMissing(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); + $auditService->method('decryptSnapshot')->willReturn(['uuid' => '', 'email' => 'a@b.com']); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('snapshot_invalid', $result['error']); + } + + public function testRestoreReturnsSnapshotInvalidWhenEmailIsMissing(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); + $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'u1', 'email' => '']); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('snapshot_invalid', $result['error']); + } + + // ── restore_uuid_exists ───────────────────────────────────────────── + + public function testRestoreReturnsUuidExistsWhenUserWithUuidAlreadyExists(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); + $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'existing-uuid', 'email' => 'new@example.com']); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->expects($this->once())->method('findByUuid')->with('existing-uuid') + ->willReturn(['id' => 50, 'uuid' => 'existing-uuid']); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('restore_uuid_exists', $result['error']); + } + + // ── restore_email_exists ──────────────────────────────────────────── + + public function testRestoreReturnsEmailExistsWhenUserWithEmailAlreadyExists(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); + $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'new-uuid', 'email' => 'existing@example.com']); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByUuid')->willReturn(null); + $userReadRepo->expects($this->once())->method('findByEmail')->with('existing@example.com') + ->willReturn(['id' => 60, 'email' => 'existing@example.com']); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('restore_email_exists', $result['error']); + } + + // ── restore_create_failed ─────────────────────────────────────────── + + public function testRestoreReturnsCreateFailedWhenWriteRepoReturnsZero(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); + $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'u1', 'email' => 'e@x.com']); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByUuid')->willReturn(null); + $userReadRepo->method('findByEmail')->willReturn(null); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once())->method('create')->willReturn(0); + + $settingsGateway = $this->createMock(UserSettingsGateway::class); + $settingsGateway->method('normalizeTheme')->willReturn('light'); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession, + userSettingsGateway: $settingsGateway + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('restore_create_failed', $result['error']); + } + + public function testRestoreReturnsCreateFailedWhenWriteRepoReturnsFalse(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); + $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'u1', 'email' => 'e@x.com']); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByUuid')->willReturn(null); + $userReadRepo->method('findByEmail')->willReturn(null); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->expects($this->once())->method('create')->willReturn(false); + + $settingsGateway = $this->createMock(UserSettingsGateway::class); + $settingsGateway->method('normalizeTheme')->willReturn('light'); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession, + userSettingsGateway: $settingsGateway + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('restore_create_failed', $result['error']); + } + + // ── restore_mark_failed ───────────────────────────────────────────── + + public function testRestoreReturnsMarkFailedWhenMarkRestoredReturnsFalse(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); + $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'u1', 'email' => 'e@x.com']); + $auditService->expects($this->once()) + ->method('markDeleteEventRestored') + ->with(1, 99, 77) + ->willReturn(false); + + $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); + $userReadRepo->method('findByUuid')->willReturn(null); + $userReadRepo->method('findByEmail')->willReturn(null); + + $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); + $userWriteRepo->method('create')->willReturn(77); + + $settingsGateway = $this->createMock(UserSettingsGateway::class); + $settingsGateway->method('normalizeTheme')->willReturn('light'); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('rollbackTransaction'); + $dbSession->expects($this->never())->method('commitTransaction'); + + $service = $this->newService( + userReadRepository: $userReadRepo, + userWriteRepository: $userWriteRepo, + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession, + userSettingsGateway: $settingsGateway + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('restore_mark_failed', $result['error']); + } + + // ── Transaction rollback on exception ─────────────────────────────── + + public function testRestoreRollsBackOnUnexpectedException(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore') + ->willThrowException(new \RuntimeException('unexpected')); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->expects($this->once())->method('beginTransaction'); + $dbSession->expects($this->never())->method('commitTransaction'); + $dbSession->expects($this->once())->method('rollbackTransaction'); + + $service = $this->newService( + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('restore_unexpected_error', $result['error']); + } + + public function testRestoreRollbackQuietlySwallowsRollbackException(): void + { + $auditService = $this->createMock(UserLifecycleAuditService::class); + $auditService->method('findDeleteEventForRestore') + ->willThrowException(new \RuntimeException('db error')); + + $dbSession = $this->createMock(DatabaseSessionRepository::class); + $dbSession->method('rollbackTransaction') + ->willThrowException(new \RuntimeException('rollback also failed')); + + $service = $this->newService( + userLifecycleAuditService: $auditService, + databaseSessionRepository: $dbSession + ); + + // Should not throw, even though rollback itself throws + $result = $service->restoreFromLifecycleAudit(1, 99); + + $this->assertFalse($result['ok']); + $this->assertSame('restore_unexpected_error', $result['error']); + } + + // ── Helper ────────────────────────────────────────────────────────── + + private function newService( + ?UserReadRepositoryInterface $userReadRepository = null, + ?UserWriteRepositoryInterface $userWriteRepository = null, + ?UserLifecycleAuditService $userLifecycleAuditService = null, + ?DatabaseSessionRepository $databaseSessionRepository = null, + ?UserSettingsGateway $userSettingsGateway = null, + ): UserLifecycleRestoreService { + return new UserLifecycleRestoreService( + $userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class), + $userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class), + $userLifecycleAuditService ?? $this->createMock(UserLifecycleAuditService::class), + $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class), + $userSettingsGateway ?? $this->createMock(UserSettingsGateway::class), + ); + } +}