1
0

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:
2026-03-22 23:29:07 +01:00
parent 48cb7c9e08
commit 07fbb96246
9 changed files with 5116 additions and 0 deletions

View 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, []);
}
}

View 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)
);
}
}

View 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()
);
}
}

View 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)
);
}
}

View 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
);
}
}

View 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
);
}
}

View 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
);
}
}

View 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),
);
}
}

View 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),
);
}
}