feat(management): search users by email in any case #443
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user