forked from fa/breadcrumb-the-shire
test: add PHPUnit coverage for 7 critical Tier 1 services (GR-TEST-001)
199 new tests across 9 files covering auth, user lifecycle, and settings: - ApiTokenService: create/validate/rotate/revoke, timing-safe hash, tenant scope - ApiTokenEndpointService: pagination, scope filtering, tenant resolution, expiry - SsoUserLinkService: 2-phase identity lookup, provisioning, profile+avatar sync - UserAssignmentService: sync methods, assignable-role freeze, transactions - UserLifecycleAuditService: encrypted snapshots, enum normalization, retention - UserLifecycleRestoreService: full restore flow, 9 error exits, rollback - AdminSettingsService: API/lifecycle, Microsoft/telemetry, color/SMTP validation Workflow: TEST-TIER1-001 (Analyst → Planner → Executor → Code Review → Security Review → Acceptance → Finalizer — all pass) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
365
tests/Service/Audit/UserLifecycleAuditServiceTest.php
Normal file
365
tests/Service/Audit/UserLifecycleAuditServiceTest.php
Normal file
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Audit;
|
||||
|
||||
use MintyPHP\Repository\Audit\UserLifecycleAuditRepositoryInterface;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditService;
|
||||
use MintyPHP\Support\Crypto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class UserLifecycleAuditServiceTest extends TestCase
|
||||
{
|
||||
private const SNAPSHOT_FIELDS = [
|
||||
'uuid',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'display_name',
|
||||
'email',
|
||||
'profile_description',
|
||||
'job_title',
|
||||
'phone',
|
||||
'mobile',
|
||||
'short_dial',
|
||||
'address',
|
||||
'postal_code',
|
||||
'city',
|
||||
'region',
|
||||
'country',
|
||||
'hire_date',
|
||||
'locale',
|
||||
'theme',
|
||||
'email_verified_at',
|
||||
'last_login_at',
|
||||
'last_login_provider',
|
||||
'primary_tenant_id',
|
||||
'current_tenant_id',
|
||||
'created',
|
||||
'modified',
|
||||
'active_changed_at',
|
||||
];
|
||||
|
||||
private static bool $cryptoAvailable = false;
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
if (!defined('APP_CRYPTO_KEY')) {
|
||||
define('APP_CRYPTO_KEY', str_repeat('ab', 32));
|
||||
}
|
||||
self::$cryptoAvailable = Crypto::isConfigured();
|
||||
}
|
||||
|
||||
// ── logDeactivate ───────────────────────────────────────────────────
|
||||
|
||||
public function testLogDeactivateCallsRepoCreateWithCorrectRow(): void
|
||||
{
|
||||
$repo = $this->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, []);
|
||||
}
|
||||
}
|
||||
542
tests/Service/Auth/ApiTokenEndpointServiceTest.php
Normal file
542
tests/Service/Auth/ApiTokenEndpointServiceTest.php
Normal file
@@ -0,0 +1,542 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Auth\ApiTokenEndpointService;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiTokenEndpointServiceTest extends TestCase
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// normalizeLimit()
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function testNormalizeLimitWithinRange(): void
|
||||
{
|
||||
$service = $this->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)
|
||||
);
|
||||
}
|
||||
}
|
||||
819
tests/Service/Auth/ApiTokenServiceTest.php
Normal file
819
tests/Service/Auth/ApiTokenServiceTest.php
Normal file
@@ -0,0 +1,819 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepositoryInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Service\Auth\ApiTokenService;
|
||||
use MintyPHP\Service\Auth\AuthSettingsGateway;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiTokenServiceTest extends TestCase
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// create()
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function testCreateReturnsNameRequiredWhenNameIsBlank(): void
|
||||
{
|
||||
$service = $this->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()
|
||||
);
|
||||
}
|
||||
}
|
||||
832
tests/Service/Auth/SsoUserLinkServiceTest.php
Normal file
832
tests/Service/Auth/SsoUserLinkServiceTest.php
Normal file
@@ -0,0 +1,832 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\Auth\AuthExternalIdentityGateway;
|
||||
use MintyPHP\Service\Auth\AuthSettingsGateway;
|
||||
use MintyPHP\Service\Auth\SsoUserLinkService;
|
||||
use MintyPHP\Service\Auth\TenantSsoService;
|
||||
use MintyPHP\Service\User\UserAssignmentService;
|
||||
use MintyPHP\Service\User\UserAvatarService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SsoUserLinkServiceTest extends TestCase
|
||||
{
|
||||
private const BASE_CLAIMS = [
|
||||
'tid' => '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)
|
||||
);
|
||||
}
|
||||
}
|
||||
489
tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php
Normal file
489
tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php
Normal file
@@ -0,0 +1,489 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Settings;
|
||||
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Audit\SystemAuditService;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Settings\AdminSettingsService;
|
||||
use MintyPHP\Service\Settings\SettingCacheService;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSessionGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AdminSettingsServiceApiLifecycleTest extends TestCase
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// API token max TTL
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function testUpdateFromAdminPersistsValidApiTokenMaxTtl(): 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')
|
||||
->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<string> */
|
||||
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<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
454
tests/Service/Settings/AdminSettingsServiceAppTest.php
Normal file
454
tests/Service/Settings/AdminSettingsServiceAppTest.php
Normal file
@@ -0,0 +1,454 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Settings;
|
||||
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Audit\SystemAuditService;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Settings\AdminSettingsService;
|
||||
use MintyPHP\Service\Settings\SettingCacheService;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSessionGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AdminSettingsServiceAppTest extends TestCase
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// Primary color
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function testUpdateFromAdminPersistsValidPrimaryColor(): 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('#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<string> */
|
||||
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<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
573
tests/Service/Settings/AdminSettingsServiceSecurityTest.php
Normal file
573
tests/Service/Settings/AdminSettingsServiceSecurityTest.php
Normal file
@@ -0,0 +1,573 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Settings;
|
||||
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Audit\SystemAuditService;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Settings\AdminSettingsService;
|
||||
use MintyPHP\Service\Settings\SettingCacheService;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSessionGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AdminSettingsServiceSecurityTest extends TestCase
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// Microsoft authority
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function testUpdateFromAdminPersistsValidMicrosoftAuthority(): void
|
||||
{
|
||||
$microsoftGateway = $this->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<string> */
|
||||
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<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
557
tests/Service/User/UserAssignmentServiceTest.php
Normal file
557
tests/Service/User/UserAssignmentServiceTest.php
Normal file
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\User;
|
||||
|
||||
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
|
||||
use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\Access\AssignableRoleService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\User\UserAssignmentService;
|
||||
use MintyPHP\Service\User\UserDirectoryGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class UserAssignmentServiceTest extends TestCase
|
||||
{
|
||||
// ── buildAssignmentsForUser ──────────────────────────────────────────
|
||||
|
||||
public function testBuildAssignmentsForUserReturnsEmptyStructureWhenUserIdIsZero(): void
|
||||
{
|
||||
$service = $this->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),
|
||||
);
|
||||
}
|
||||
}
|
||||
485
tests/Service/User/UserLifecycleRestoreServiceTest.php
Normal file
485
tests/Service/User/UserLifecycleRestoreServiceTest.php
Normal file
@@ -0,0 +1,485 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\User;
|
||||
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditService;
|
||||
use MintyPHP\Service\User\UserLifecycleRestoreService;
|
||||
use MintyPHP\Service\User\UserSettingsGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class UserLifecycleRestoreServiceTest extends TestCase
|
||||
{
|
||||
// ── Full success path ───────────────────────────────────────────────
|
||||
|
||||
public function testRestoreFullSuccessPath(): void
|
||||
{
|
||||
$event = [
|
||||
'id' => 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user