ldaptest

package
v1.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package ldaptest is an in-memory LDAP directory for tests.

It speaks enough of the protocol to be worth testing against: real filter parsing, real scopes, real bind semantics — including the one that matters most, where a bind with an empty password succeeds as an unauthenticated bind exactly as a conforming server would. A fake that quietly rejected the empty password would make the test that proves this library never sends one pass for the wrong reason.

It is not a server. Nothing here listens on a socket; a Directory hands out an ldapauth.Dialer whose connections talk to the map directly. For tests against a real directory, see the integration suite.

What it will tell you

dir.Binds()          every bind, in order, with the password it carried
dir.Filters()        every search filter, as it went out
dir.Searches()       the full search requests
dir.Conns()          how many connections were opened and closed
dir.PagedSearches()  how many used a paged results control

Directory.Binds records the password on purpose: the point of recording it is to let a test assert that an empty one was never sent.

Making things go wrong

dir.SetDown(url, true)      dialling fails, and live connections break
dir.DialHook = ...          fail, count, or delay a dial
dir.BindHook = ...          the same for binds
dir.SearchHook = ...        and for searches
dir.ReferralHook = ...      answer a search with referrals, as a forest does
dir.BindControlHook = ...   answer a bind with the password policy control

Directory.SetDown also breaks connections that were already open, which is what a server going down actually does and what a connection pool has to survive.

What it is not

It is not a validator. It will not check schemas, enforce access controls, or complain about an entry with no objectClass. Those are exactly the differences that make something work in tests and fail in production, which is what the integration suite is for.

dir := ldaptest.New(
    ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
        With("uid", "alice").
        With("mail", "alice@example.com"),
)

auth, err := ldapauth.New(
    ldapauth.WithURL("ldap://memory"),
    ldapauth.WithDialer(dir.Dialer()),
    ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
)

Index

Examples

Constants

View Source
const ChainOID = "1.2.840.113556.1.4.1941"

ChainOID is LDAP_MATCHING_RULE_IN_CHAIN, the Active Directory matching rule for transitive group membership.

Variables

This section is empty.

Functions

This section is empty.

Types

type Bind

type Bind struct {
	// DN is the distinguished name the bind was attempted as. It is empty
	// for an anonymous bind.
	DN string

	// Password is what was presented. It is recorded on purpose: the
	// point of recording it is to let a test assert that an empty one was
	// never sent.
	Password string

	// URL is the server the connection was opened to.
	URL string
}

A Bind is one recorded bind attempt. The password is recorded because the point of recording it is to let a test assert that an empty one was never sent.

type Directory

type Directory struct {

	// BindHook, if set, runs before a bind is evaluated. Returning an
	// error makes the bind fail with that error.
	BindHook func(dn, password string) error

	// SearchHook, if set, runs before a search is evaluated.
	SearchHook func(request *ldap.SearchRequest) error

	// DialHook, if set, runs before a connection is created.
	DialHook func(url string) error

	// BindControlHook, if set, returns the controls a bind should be
	// answered with.
	//
	// It is how a test produces the password policy control that
	// OpenLDAP and 389 Directory Server use to say *why* a credential
	// was refused — an expired password, a locked account, one that must
	// be changed after a reset. Active Directory says the same thing in
	// the error text and needs nothing here.
	BindControlHook func(dn string) []ldap.Control

	// TimeoutHook, if set, is called every time an operation timeout is
	// set on a connection. It is how a test sees the timeout the
	// authenticator narrowed a connection to, which is otherwise
	// invisible and is exactly where one request's deadline can leak
	// into the next.
	TimeoutHook func(timeout time.Duration)

	// ReferralHook, if set, returns the referral URLs a search should be
	// answered with. Return none for an ordinary search.
	//
	// A referral is what a multi-domain Active Directory forest answers
	// when the base DN names a partition that does not hold the object,
	// and it is the failure mode most likely to be mistaken for "no such
	// user". A fake with no way to produce one is a fake that cannot test
	// the handling.
	ReferralHook func(request *ldap.SearchRequest) []string
	// contains filtered or unexported fields
}

Directory is an in-memory directory. The zero value is not usable; call New. A Directory is safe for concurrent use.

func New

func New(entries ...*Entry) *Directory

New builds a directory holding the given entries.

Example

A directory in a map, with no server anywhere.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

func main() {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson"),
	)

	auth, err := ldapauth.New(
		ldapauth.WithURL("ldap://in-memory"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
		ldapauth.WithEmailAttribute("mail"),
	)
	if err != nil {
		fmt.Println(err)

		return
	}
	defer auth.Close()

	identity, err := auth.Authenticate(context.Background(), "alice", "s3cret")
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(identity.GetEmail())
}
Output:
alice@example.com

func (*Directory) Add

func (d *Directory) Add(entries ...*Entry) *Directory

Add adds entries, replacing any that already exist under the same DN.

Replacing rather than appending is what a directory does, and it is the behaviour a test wants: appending a second entry with the same DN would leave the first one shadowing it in every search, and the test would fail for a reason that has nothing to do with what it was testing.

func (*Directory) Binds

func (d *Directory) Binds() []Bind

Binds returns every bind the directory has seen, in order.

Example

The empty-password bind is modelled faithfully: a conforming server answers it with success, which is why a library that sends one is a library that authenticates anybody.

package main

import (
	"context"
	"errors"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

func main() {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("objectClass", "inetOrgPerson"),
	)

	auth, err := ldapauth.New(
		ldapauth.WithURL("ldap://in-memory"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
	)
	if err != nil {
		fmt.Println(err)

		return
	}
	defer auth.Close()

	_, err = auth.Authenticate(context.Background(), "alice", "")

	fmt.Println(errors.Is(err, ldapauth.ErrEmptyPassword))
	fmt.Println("binds reaching the directory:", len(dir.Binds()))
}
Output:
true
binds reaching the directory: 0

func (*Directory) Conns

func (d *Directory) Conns() (opened, closed int)

Conns reports how many connections have been opened and closed. A test that wants to prove the pool is pooling asserts on the first; one that wants to prove it is not leaking asserts they are equal at the end.

func (*Directory) Dialer

func (d *Directory) Dialer() ldapauth.Dialer

Dialer returns a dialer that connects to this directory whatever URL it is given, unless that URL has been marked down with SetDown.

func (*Directory) Filters

func (d *Directory) Filters() []string

Filters returns the filter string of every search, which is usually the only part a test cares about.

Example

What went to the directory, which is usually the thing a security test wants to assert on.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

func main() {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("objectClass", "inetOrgPerson"),
		ldaptest.User("cn=svc,dc=example,dc=com", "svc-password").
			With("objectClass", "person"),
	)

	auth, err := ldapauth.New(
		ldapauth.WithURL("ldap://in-memory"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithServiceAccount("cn=svc,dc=example,dc=com", "svc-password"),
		ldapauth.WithUserSearch("ou=people,dc=example,dc=com", "(uid={{username}})"),
	)
	if err != nil {
		fmt.Println(err)

		return
	}
	defer auth.Close()

	// A username that is trying something.
	_, _ = auth.Authenticate(context.Background(), "*)(uid=*", "anything")

	fmt.Println(dir.Filters()[0])
}
Output:
(uid=\2a\29\28uid=\2a)

func (*Directory) PagedSearches

func (d *Directory) PagedSearches() int

PagedSearches reports how many searches were issued with a paged results control, for a test that wants to know whether WithPagingSize reached the wire.

func (*Directory) Reset

func (d *Directory) Reset()

Reset clears the recorded binds and searches, leaving the entries alone.

func (*Directory) Searches

func (d *Directory) Searches() []*ldap.SearchRequest

Searches returns every search request the directory has seen, in order.

func (*Directory) SetDown

func (d *Directory) SetDown(url string, down bool)

SetDown makes dialling a URL fail, or stop failing. Use it to take a server out from under a running authenticator.

Example

Taking a server away, which is what a failover test needs. Connections already open to it break, as they would.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

func main() {
	dir := ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("objectClass", "inetOrgPerson"),
	)

	dir.SetDown("ldap://dc1", true)

	auth, err := ldapauth.New(
		ldapauth.WithURLs("ldap://dc1", "ldap://dc2"),
		ldapauth.WithDialer(dir.Dialer()),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
	)
	if err != nil {
		fmt.Println(err)

		return
	}
	defer auth.Close()

	if _, err := auth.Authenticate(context.Background(), "alice", "s3cret"); err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println("bound on", dir.Binds()[0].URL)
}
Output:
bound on ldap://dc2

type Entry

type Entry struct {
	// DN is the entry's distinguished name.
	DN string

	// Password is what a simple bind as this entry must present. An entry
	// with no password cannot be bound as.
	Password string

	// Attributes are the entry's attributes, keyed by name.
	Attributes map[string][]string
}

Entry is one directory entry.

func Group

func Group(dn string) *Entry

Group builds an entry with no password, for a group or any other non-bindable object.

func User

func User(dn, password string) *Entry

User builds an entry with a password.

func (*Entry) Values

func (e *Entry) Values(name string) []string

Values returns the values of an attribute, matched case-insensitively as a directory would.

func (*Entry) With

func (e *Entry) With(name string, values ...string) *Entry

With adds attribute values and returns the entry, for chaining.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL