From 5951b335d11f4346ff96097a85f162695bf794a1 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 7 Oct 2026 15:29:50 +0200 Subject: [PATCH] feat(management): search users by email in any case GET /api/v2/users with q=email:"..." answers like Auth0's user search: the email matches in any letter case, unlike users-by-email, and the answer has the include_totals shape the Go SDK asks for by default. Any other query is refused with 400 rather than answered wrong. Goodfeed's backend checks whether an address is taken with this search before it lets a user change their email. --- cmd/service/service.go | 1 + handlers/management.go | 43 ++++++++++++++++++++++++ handlers/management_test.go | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 handlers/management_test.go diff --git a/cmd/service/service.go b/cmd/service/service.go index c5fa006..e05d307 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -102,6 +102,7 @@ func main() { // Management API endpoints mux.HandleFunc("GET /api/v2/users-by-email", managementHandler.GetUsersByEmail) + mux.HandleFunc("GET /api/v2/users", managementHandler.SearchUsers) mux.HandleFunc("POST /api/v2/users", managementHandler.CreateUser) mux.HandleFunc("PATCH /api/v2/users/", managementHandler.UpdateUser) mux.HandleFunc("POST /api/v2/tickets/password-change", managementHandler.PasswordChangeTicket) diff --git a/handlers/management.go b/handlers/management.go index c512440..2d401a3 100644 --- a/handlers/management.go +++ b/handlers/management.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "regexp" "strings" "git.unbound.se/unboundsoftware/auth0mock/store" @@ -152,3 +153,45 @@ func (h *ManagementHandler) PasswordChangeTicket(w http.ResponseWriter, r *http. "ticket": "https://some-url", }) } + +// emailQuery matches the one Lucene query this mock answers: an email phrase, +// email:"...", with \" and \\ escaped inside it. +var emailQuery = regexp.MustCompile(`^email:"((?:[^"\\]|\\.)*)"$`) + +// SearchUsers handles GET /api/v2/users. Like Auth0's user search it matches +// email in any letter case (users-by-email does not), and it answers in the +// shape the Go SDK asks for by default (include_totals=true). Only an email +// phrase is understood; any other query is refused rather than answered wrong. +func (h *ManagementHandler) SearchUsers(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + match := emailQuery.FindStringSubmatch(q) + if match == nil { + http.Error(w, "only q=email:\"...\" is supported", http.StatusBadRequest) + return + } + email := strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(match[1]) + + h.logger.Debug("searching users", "email", email) + + users := []UserResponse{} + for _, user := range h.userStore.List() { + if strings.EqualFold(user.Email, email) { + users = append(users, UserResponse{ + Email: user.Email, + GivenName: user.GivenName, + FamilyName: user.FamilyName, + UserID: fmt.Sprintf("auth0|%s", user.UserID), + Picture: user.Picture, + }) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "start": 0, + "limit": len(users), + "length": len(users), + "total": len(users), + "users": users, + }) +} diff --git a/handlers/management_test.go b/handlers/management_test.go new file mode 100644 index 0000000..f64270f --- /dev/null +++ b/handlers/management_test.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "git.unbound.se/unboundsoftware/auth0mock/store" +) + +func searchUsers(t *testing.T, h *ManagementHandler, q string) (int, []UserResponse) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v2/users?search_engine=v3&include_totals=true&q="+url.QueryEscape(q), nil) + rec := httptest.NewRecorder() + h.SearchUsers(rec, req) + if rec.Code != http.StatusOK { + return rec.Code, nil + } + var body struct { + Total int `json:"total"` + Users []UserResponse `json:"users"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Total != len(body.Users) { + t.Fatalf("total %d, users %d", body.Total, len(body.Users)) + } + return rec.Code, body.Users +} + +// The search matches email in any letter case, as Auth0's does: a backend that +// checks whether an address is taken must find it however it was typed. +func TestSearchUsersMatchesEmailInAnyCase(t *testing.T) { + users := store.NewUserStore() + users.Create("anna@kpmg.se", &store.User{GivenName: "Anna"}) + users.Create("bob@acme.se", &store.User{GivenName: "Bob"}) + h := NewManagementHandler(users, slog.New(slog.DiscardHandler)) + + for _, q := range []string{`email:"anna@kpmg.se"`, `email:"ANNA@KPMG.SE"`, `email:"Anna@Kpmg.se"`} { + code, found := searchUsers(t, h, q) + if code != http.StatusOK || len(found) != 1 || found[0].UserID != "auth0|anna@kpmg.se" { + t.Fatalf("%s: code %d, found %+v", q, code, found) + } + } + + if _, found := searchUsers(t, h, `email:"nobody@acme.se"`); len(found) != 0 { + t.Fatalf("an unknown address found %+v", found) + } + // A quoted * is part of the address, not a wildcard. + if _, found := searchUsers(t, h, `email:"*@kpmg.se"`); len(found) != 0 { + t.Fatalf("a quoted * matched %+v", found) + } +} + +func TestSearchUsersRefusesOtherQueries(t *testing.T) { + h := NewManagementHandler(store.NewUserStore(), slog.New(slog.DiscardHandler)) + for _, q := range []string{"", "name:anna", `email:"a@b.se" OR name:x`} { + if code, _ := searchUsers(t, h, q); code != http.StatusBadRequest { + t.Fatalf("%q: code %d, want 400", q, code) + } + } +} -- 2.54.0