package main // A fake Keycloak: the token endpoint, accepting one password that a test may change, and the admin // routes this module touches on one realm's clients, answering with the status codes and shapes // Keycloak gives. import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" ) var ctx = context.Background() type fakeKeycloak struct { mu sync.Mutex srv *httptest.Server realm string password string // what the admin's login accepts down bool // answer 503, as a starting server does logins int clients map[string]Rep calls []string } func newFakeKeycloak(t *testing.T, realm, password string) *fakeKeycloak { f := &fakeKeycloak{realm: realm, password: password, clients: map[string]Rep{}} f.srv = httptest.NewServer(http.HandlerFunc(f.serve)) t.Cleanup(f.srv.Close) return f } func uuid() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } func send(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if v != nil { _ = json.NewEncoder(w).Encode(v) } } func (f *fakeKeycloak) set(fn func()) { f.mu.Lock() defer f.mu.Unlock() fn() } func (f *fakeKeycloak) serve(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, r.Method+" "+r.URL.Path) if f.down { send(w, 503, nil) return } if r.URL.Path == "/realms/master/protocol/openid-connect/token" { _ = r.ParseForm() f.logins++ if r.PostForm.Get("password") != f.password { send(w, 401, map[string]string{"error": "invalid_grant", "error_description": "Invalid user credentials"}) return } send(w, 200, map[string]any{"access_token": "t", "expires_in": 300}) return } base := "/admin/realms/" + f.realm + "/clients" if !strings.HasPrefix(r.URL.Path, base) { send(w, 404, map[string]string{"error": "Realm not found."}) return } var rest []string for _, p := range strings.Split(strings.TrimPrefix(r.URL.Path, base), "/") { if p != "" { rest = append(rest, p) } } body := func() Rep { raw, _ := io.ReadAll(r.Body) var v Rep _ = json.Unmarshal(raw, &v) return v } if len(rest) == 0 && r.Method == "GET" { want := r.URL.Query().Get("clientId") out := []Rep{} for _, c := range f.clients { if want == "" || c["clientId"] == want { out = append(out, c) } } send(w, 200, out) return } if len(rest) == 0 && r.Method == "POST" { rep := body() for _, c := range f.clients { if c["clientId"] == rep["clientId"] { send(w, 409, map[string]string{"errorMessage": "exists"}) return } } id := uuid() mappers := []any{} if list, ok := rep["protocolMappers"].([]any); ok { for _, m := range list { mm := m.(map[string]any) mm["id"] = uuid() mappers = append(mappers, mm) } } rep["id"], rep["protocolMappers"] = id, mappers f.clients[id] = rep send(w, 201, nil) return } c := f.clients[rest[0]] if c == nil { send(w, 404, map[string]string{"error": "Could not find client"}) return } switch { case len(rest) == 1 && r.Method == "PUT": // Keycloak ignores protocolMappers on a client update: they have their own endpoints. rep := body() rep["id"], rep["protocolMappers"] = c["id"], c["protocolMappers"] f.clients[rest[0]] = rep send(w, 204, nil) case len(rest) == 1 && r.Method == "DELETE": delete(f.clients, rest[0]) send(w, 204, nil) case rest[1] == "client-secret" && r.Method == "GET": send(w, 200, map[string]any{"type": "secret", "value": c["secret"]}) case rest[1] == "protocol-mappers" && r.Method == "GET": send(w, 200, c["protocolMappers"]) case rest[1] == "protocol-mappers" && r.Method == "POST": m := body() m["id"] = uuid() list, _ := c["protocolMappers"].([]any) c["protocolMappers"] = append(list, m) send(w, 201, nil) case rest[1] == "protocol-mappers" && r.Method == "PUT": m := body() list, _ := c["protocolMappers"].([]any) for i, x := range list { if x.(map[string]any)["id"] == rest[4] { list[i] = m } } send(w, 204, nil) default: send(w, 405, nil) } } func (f *fakeKeycloak) client(password string) *Client { return NewClient(f.srv.URL, "admin", func() (string, error) { return password, nil }, "master") }