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