ldap-upstream/internal/ldap/handler_test.go
Michael Aldridge d74308280b internal/ldap: Reply with the result type a request expects
An LDAP client only accepts the result that pairs with the request it
sent, and silently ignores anything else. Unrouted operations were
answered with a generic result, which encodes with the wrong tag, so a
search with an unexpected base DN (for example the empty base that
ldapsearch uses when -b is omitted) left the client waiting forever
instead of reporting an error.

Every failure path now terminates the request with the matching result
type, panics included, so a single wrapper covers all operations rather
than one responder per operation. NetAuth calls carry a 30s deadline
because the gRPC client dials lazily and applies none of its own, and
refused operations and failed authentication are logged, which is what
an operator needs when a client appears to hang.

💘 Generated with Crush

Assisted-by: Crush:qwen38_flash_next_mtp
2026-11-02 19:59:04 -06:00

157 lines
4.8 KiB
Go

package ldap
import (
"reflect"
"testing"
"github.com/hashicorp/go-hclog"
"github.com/vjeantet/goldap/message"
ldap "github.com/vjeantet/ldapserver"
)
// recordingWriter captures everything written to it so tests can
// assert on the exact sequence of responses a handler produces.
type recordingWriter struct {
responses []message.ProtocolOp
}
func (w *recordingWriter) Write(po message.ProtocolOp) {
w.responses = append(w.responses, po)
}
func testLogger() hclog.Logger { return hclog.NewNullLogger() }
func TestGuardRecoversPanic(t *testing.T) {
s := &server{l: testLogger()}
h := s.guard(func(w ldap.ResponseWriter, m *ldap.Message) {
panic("boom")
})
w := &recordingWriter{}
h(w, mustDecode(t, berSearchRequest("ou=entities,dc=netauth,dc=example,dc=com")))
if len(w.responses) != 1 {
t.Fatalf("got %d responses, want 1", len(w.responses))
}
if _, ok := w.responses[0].(message.SearchResultDone); !ok {
t.Errorf("response is %T, want message.SearchResultDone", w.responses[0])
}
if code := resultCode(t, w.responses[0]); code != ldap.LDAPResultOperationsError {
t.Errorf("result code is %d, want %d", code, ldap.LDAPResultOperationsError)
}
}
func TestGuardSilentAfterPartialWrite(t *testing.T) {
s := &server{l: testLogger()}
h := s.guard(func(w ldap.ResponseWriter, m *ldap.Message) {
w.Write(ldap.NewSearchResultEntry("cn=test"))
panic("boom")
})
w := &recordingWriter{}
h(w, mustDecode(t, berSearchRequest("ou=entities,dc=netauth,dc=example,dc=com")))
if len(w.responses) != 1 {
t.Errorf("got %d responses, want 1", len(w.responses))
}
if _, ok := w.responses[0].(message.SearchResultEntry); !ok {
t.Errorf("response is %T, want the partial entry only", w.responses[0])
}
}
func TestGuardPassesThrough(t *testing.T) {
s := &server{l: testLogger()}
h := s.guard(func(w ldap.ResponseWriter, m *ldap.Message) {
w.Write(ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess))
})
w := &recordingWriter{}
h(w, &ldap.Message{})
if len(w.responses) != 1 {
t.Fatalf("got %d responses, want 1", len(w.responses))
}
if _, ok := w.responses[0].(message.SearchResultDone); !ok {
t.Errorf("response is %T, want message.SearchResultDone", w.responses[0])
}
}
// TestResultForRequestPairing is the regression test for the client
// hang: an LDAP client only accepts the result type that pairs with
// the request it sent and silently discards anything else, so a
// mismatched type leaves it waiting for a done message forever.
func TestResultForRequestPairing(t *testing.T) {
tests := []struct {
name string
op message.ProtocolOp
want string
}{
{"search", message.SearchRequest{}, "SearchResultDone"},
{"bind", message.BindRequest{}, "BindResponse"},
{"extended", message.ExtendedRequest{}, "ExtendedResponse"},
{"compare", message.CompareRequest{}, "CompareResponse"},
{"add", message.AddRequest{}, "AddResponse"},
{"delete", message.DelRequest("cn=x,dc=example,dc=com"), "DelResponse"},
{"modify", message.ModifyRequest{}, "ModifyResponse"},
{"modifyDN", message.ModifyDNRequest{}, "ModifyDNResponse"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
res, ok := resultForRequest(tc.op, ldap.LDAPResultUnwillingToPerform, "nope")
if !ok {
t.Fatal("no result produced for a request that needs one")
}
if got := reflect.TypeOf(res).Name(); got != tc.want {
t.Errorf("result type is %s, want %s", got, tc.want)
}
})
}
// Abandon is never answered, so it must produce nothing rather
// than an unsolicited result.
if res, ok := resultForRequest(message.AbandonRequest(1), ldap.LDAPResultSuccess, ""); ok {
t.Errorf("abandon produced %T, want no response", res)
}
}
// TestSearchResultDoneWireTag pins the search reply to the
// searchResDone application tag (5). A reply carrying any other tag,
// including the bare LDAPResult universal SEQUENCE, is ignored by
// clients and hangs them.
func TestSearchResultDoneWireTag(t *testing.T) {
res, ok := resultForRequest(message.SearchRequest{}, ldap.LDAPResultUnwillingToPerform, "nope")
if !ok {
t.Fatal("no result produced for a search request")
}
m := message.NewLDAPMessageWithProtocolOp(res)
m.SetMessageID(2)
b, err := m.Write()
if err != nil {
t.Fatalf("encoding response: %s", err)
}
raw := b.Bytes()
// Descend from the LDAPMessage SEQUENCE into the protocolOp.
off := 0
if _, off, err = message.ParseTagAndLength(raw, off); err != nil {
t.Fatalf("message tag: %s", err)
}
var id message.TagAndLength
if id, off, err = message.ParseTagAndLength(raw, off); err != nil {
t.Fatalf("messageID tag: %s", err)
}
off += id.Length
op, _, err := message.ParseTagAndLength(raw, off)
if err != nil {
t.Fatalf("protocolOp tag: %s", err)
}
if op.Class != 1 || op.Tag != 5 {
t.Errorf("protocolOp is class %d tag %d, want APPLICATION (1) tag 5", op.Class, op.Tag)
}
}