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) + } + } +}