createMock(LdapConnectionGateway::class) ); } private function baseConfig(): array { return [ 'host' => 'ldap.example.com', 'port' => 389, 'encryption_mode' => 'none', 'verify_tls_certificate' => false, 'base_dn' => 'DC=example,DC=com', 'bind_dn' => 'CN=svc,DC=example,DC=com', 'bind_password' => 'secret', 'user_search_filter' => '(&(objectClass=user)(sAMAccountName=%s))', 'user_search_scope' => 'sub', 'bind_method' => 'search_then_bind', 'bind_username_format' => '', 'unique_id_attribute' => 'objectGUID', 'attribute_map' => '{"first_name":"givenName","last_name":"sn","email":"mail"}', 'network_timeout' => 5, ]; } public function testAuthenticateReturnsCredentialsEmptyForEmptyUsername(): void { $service = $this->newService(); $result = $service->authenticate($this->baseConfig(), '', 'password'); $this->assertFalse($result['ok']); $this->assertSame('credentials_empty', $result['error']); } public function testAuthenticateReturnsCredentialsEmptyForEmptyPassword(): void { $service = $this->newService(); $result = $service->authenticate($this->baseConfig(), 'user', ''); $this->assertFalse($result['ok']); $this->assertSame('credentials_empty', $result['error']); } public function testAuthenticateReturnsConnectionFailedWhenConnectFails(): void { $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn(false); $service = $this->newService($ldap); $result = $service->authenticate($this->baseConfig(), 'user', 'pass'); $this->assertFalse($result['ok']); $this->assertSame('connection_failed', $result['error']); } public function testAuthenticateReturnsConnectionFailedForEmptyHost(): void { $config = $this->baseConfig(); $config['host'] = ''; $service = $this->newService(); $result = $service->authenticate($config, 'user', 'pass'); $this->assertFalse($result['ok']); $this->assertSame('connection_failed', $result['error']); } public function testAuthenticateSearchThenBindServiceBindFails(): void { $conn = $this->createMockConnection(); $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('bind')->willReturn(false); $ldap->method('unbind')->willReturn(true); $service = $this->newService($ldap); $result = $service->authenticate($this->baseConfig(), 'user', 'pass'); $this->assertFalse($result['ok']); $this->assertSame('service_bind_failed', $result['error']); } public function testAuthenticateSearchThenBindUserNotFound(): void { $conn = $this->createMockConnection(); $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('bind')->willReturn(true); $ldap->method('search')->willReturn(false); $ldap->method('unbind')->willReturn(true); $service = $this->newService($ldap); $result = $service->authenticate($this->baseConfig(), 'user', 'pass'); $this->assertFalse($result['ok']); $this->assertSame('user_not_found', $result['error']); } public function testAuthenticateSearchThenBindInvalidPassword(): void { $conn = $this->createMockConnection(); $searchResult = $this->createMockSearchResult(); $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('escape')->willReturnCallback(fn ($v) => $v); $ldap->method('search')->willReturn($searchResult); $ldap->method('getEntries')->willReturn([ 'count' => 1, 0 => [ 'dn' => 'CN=Test User,DC=example,DC=com', 'givenname' => ['count' => 1, 0 => 'Test'], 'sn' => ['count' => 1, 0 => 'User'], 'mail' => ['count' => 1, 0 => 'test@example.com'], ], ]); $ldap->method('unbind')->willReturn(true); $bindCallCount = 0; $ldap->method('bind')->willReturnCallback(function () use (&$bindCallCount) { $bindCallCount++; return $bindCallCount <= 1; }); $service = $this->newService($ldap); $result = $service->authenticate($this->baseConfig(), 'testuser', 'wrongpass'); $this->assertFalse($result['ok']); $this->assertSame('invalid_credentials', $result['error']); } public function testAuthenticateSearchThenBindSuccess(): void { $conn = $this->createMockConnection(); $searchResult = $this->createMockSearchResult(); $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('bind')->willReturn(true); $ldap->method('escape')->willReturnCallback(fn ($v) => $v); $ldap->method('search')->willReturn($searchResult); $ldap->method('getEntries')->willReturn([ 'count' => 1, 0 => [ 'dn' => 'CN=Test User,DC=example,DC=com', 'givenname' => ['count' => 1, 0 => 'Test'], 'sn' => ['count' => 1, 0 => 'User'], 'mail' => ['count' => 1, 0 => 'test@example.com'], 'objectguid' => ['count' => 1, 0 => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'], ], ]); $ldap->method('unbind')->willReturn(true); $service = $this->newService($ldap); $result = $service->authenticate($this->baseConfig(), 'testuser', 'correctpass'); $this->assertTrue($result['ok']); $this->assertSame('CN=Test User,DC=example,DC=com', $result['dn']); $this->assertSame('Test', $result['attributes']['first_name']); $this->assertSame('User', $result['attributes']['last_name']); $this->assertSame('test@example.com', $result['attributes']['email']); } public function testAuthenticateDirectBindSuccess(): void { $config = $this->baseConfig(); $config['bind_method'] = 'direct_bind'; $config['bind_username_format'] = '%s@example.com'; $conn = $this->createMockConnection(); $searchResult = $this->createMockSearchResult(); $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('bind')->willReturn(true); $ldap->method('escape')->willReturnCallback(fn ($v) => $v); $ldap->method('search')->willReturn($searchResult); $ldap->method('getEntries')->willReturn([ 'count' => 1, 0 => [ 'dn' => 'CN=Test User,DC=example,DC=com', 'givenname' => ['count' => 1, 0 => 'Direct'], 'sn' => ['count' => 1, 0 => 'User'], 'mail' => ['count' => 1, 0 => 'direct@example.com'], ], ]); $ldap->method('unbind')->willReturn(true); $service = $this->newService($ldap); $result = $service->authenticate($config, 'testuser', 'pass'); $this->assertTrue($result['ok']); $this->assertSame('Direct', $result['attributes']['first_name']); } public function testLdapInjectionIsEscaped(): void { $ldap = $this->createMock(LdapConnectionGateway::class); $escapedValue = ''; $ldap->method('escape')->willReturnCallback(function (string $v) use (&$escapedValue) { $escapedValue = $v; return 'escaped_' . $v; }); $conn = $this->createMockConnection(); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('bind')->willReturn(true); $ldap->method('search')->willReturn(false); $ldap->method('unbind')->willReturn(true); $service = $this->newService($ldap); $result = $service->authenticate($this->baseConfig(), 'user*)(|(uid=*)', 'pass'); $this->assertSame('user*)(|(uid=*)', $escapedValue); $this->assertFalse($result['ok']); $this->assertSame('user_not_found', $result['error']); } public function testBinaryGuidToUuid(): void { $binary = hex2bin('a1a2a3a4b1b2c1c2d1d2e1e2e3e4e5e6'); $uuid = LdapAuthService::binaryGuidToUuid($binary); $this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $uuid); } public function testBinaryGuidToUuidNon16Bytes(): void { $result = LdapAuthService::binaryGuidToUuid('short'); $this->assertSame(bin2hex('short'), $result); } public function testTestConnectionSuccess(): void { $conn = $this->createMockConnection(); $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('bind')->willReturn(true); $ldap->method('unbind')->willReturn(true); $service = $this->newService($ldap); $result = $service->testConnection($this->baseConfig()); $this->assertTrue($result['ok']); } public function testTestConnectionBindFailure(): void { $conn = $this->createMockConnection(); $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('bind')->willReturn(false); $ldap->method('error')->willReturn('Invalid credentials'); $ldap->method('unbind')->willReturn(true); $service = $this->newService($ldap); $result = $service->testConnection($this->baseConfig()); $this->assertFalse($result['ok']); $this->assertStringContainsString('Service account bind failed', $result['error']); } public function testStarttlsFailureReturnsConnectionFailed(): void { $config = $this->baseConfig(); $config['encryption_mode'] = 'starttls'; $conn = $this->createMockConnection(); $ldap = $this->createMock(LdapConnectionGateway::class); $ldap->method('connect')->willReturn($conn); $ldap->method('setOption')->willReturn(true); $ldap->method('startTls')->willReturn(false); $service = $this->newService($ldap); $result = $service->authenticate($config, 'user', 'pass'); $this->assertFalse($result['ok']); $this->assertSame('connection_failed', $result['error']); } private function createMockConnection(): \LDAP\Connection { // We need a real LDAP\Connection object for type hints. // Since we can't create one without ldap_connect, use reflection. // For tests where ldap extension may not be available, skip. if (!extension_loaded('ldap')) { $this->markTestSkipped('LDAP extension not available'); } // ldap_connect with a URI doesn't actually connect in PHP 8.1+ $conn = ldap_connect('ldap://127.0.0.1:1'); if ($conn === false) { $this->markTestSkipped('Could not create LDAP connection resource'); } return $conn; } private function createMockSearchResult(): \LDAP\Result { // LDAP\Result can't be mocked directly, but since our gateway wraps // all ldap_* calls, the actual result object is opaque to LdapAuthService. // The gateway mock returns it, and we just need a type-compatible value. // Use a real connection + search if ldap is available; otherwise skip. if (!extension_loaded('ldap')) { $this->markTestSkipped('LDAP extension not available'); } // Create a real but unused Result object via reflection workaround. // Since we mock getEntries(), the actual result content doesn't matter. // But LDAP\Result has no public constructor, so we use a trick: // We accept that the gateway returns LDAP\Result|false and our mocks // already handle this. For this test helper, return a mock-compatible value. // Since LdapConnectionGateway::search returns \LDAP\Result|false, // and we mock the gateway, we can use createMock on a stdClass and // rely on the mock returning it. But PHPUnit mock of LdapConnectionGateway // allows any return value when configured via willReturn(). // So we just need any object - the gateway mock will return it to the service, // and the service passes it back to gateway->getEntries(). $conn = ldap_connect('ldap://127.0.0.1:1'); if ($conn === false) { $this->markTestSkipped('Could not create LDAP connection'); } // We can't get a real LDAP\Result without a real server, but since // the service only passes it to the mocked gateway, we can create // a test double. PHPUnit allows returning any value from willReturn(). // Let's use a simple approach: the gateway mock already handles this. // For the type system, we use an anonymous class that extends nothing // but the mock system handles the actual passing. // Actually, since we mock LdapConnectionGateway::search() to return // this value, and LdapConnectionGateway::getEntries() is also mocked, // the value just needs to be truthy (not false). // We can use createStub to get a compatible value. return $this->createStub(\LDAP\Result::class); } }