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