package keycloak import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // stubKC builds a minimal KC look-alike: token endpoint + the Admin API // paths the HTTPAdapter actually calls. Each path is a single handler that // asserts the request shape and returns the bare-minimum valid response. type stubKC struct { srv *httptest.Server tokenCalls atomic.Int32 orgCalls atomic.Int32 userCalls atomic.Int32 memberCalls atomic.Int32 emailCalls atomic.Int32 healthCalls atomic.Int32 syncCalls atomic.Int32 tokenFails atomic.Bool // when true, /token returns 401 once } func newStubKC(t *testing.T) *stubKC { t.Helper() s := &stubKC{} mux := http.NewServeMux() mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) { s.tokenCalls.Add(1) if s.tokenFails.Swap(false) { w.WriteHeader(http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "test-token", "expires_in": 60, }) }) mux.HandleFunc("/admin/serverinfo", func(w http.ResponseWriter, r *http.Request) { s.healthCalls.Add(1) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"systemInfo":{"version":"26.0.0"}}`)) }) mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) { s.orgCalls.Add(1) if r.Method == http.MethodPost { w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz") w.WriteHeader(http.StatusCreated) return } http.Error(w, "method", http.StatusMethodNotAllowed) }) mux.HandleFunc("/admin/realms/test-realm/organizations/org-xyz/members", func(w http.ResponseWriter, r *http.Request) { s.memberCalls.Add(1) w.WriteHeader(http.StatusCreated) }) mux.HandleFunc("/admin/realms/test-realm/users", func(w http.ResponseWriter, r *http.Request) { s.userCalls.Add(1) if r.Method == http.MethodPost { w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/users/user-abc") w.WriteHeader(http.StatusCreated) return } }) mux.HandleFunc("/admin/realms/test-realm/users/user-abc/execute-actions-email", func(w http.ResponseWriter, r *http.Request) { s.emailCalls.Add(1) w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/admin/realms/test-realm/users/user-abc", func(w http.ResponseWriter, r *http.Request) { s.syncCalls.Add(1) if r.Method == http.MethodPut { w.WriteHeader(http.StatusNoContent) return } }) s.srv = httptest.NewServer(mux) return s } func (s *stubKC) close() { s.srv.Close() } func newTestAdapter(srv *httptest.Server) *HTTPAdapter { return NewHTTPAdapter(HTTPConfig{ BaseURL: srv.URL, Realm: "test-realm", ClientID: "test-client", ClientSecret: "test-secret", Timeout: 5 * time.Second, }) } func TestHTTPAdapter_health(t *testing.T) { s := newStubKC(t) defer s.close() a := newTestAdapter(s.srv) if err := a.Health(context.Background()); err != nil { t.Fatal(err) } if s.healthCalls.Load() != 1 { t.Errorf("health calls = %d", s.healthCalls.Load()) } } func TestHTTPAdapter_createOrgAndInvite(t *testing.T) { s := newStubKC(t) defer s.close() a := newTestAdapter(s.srv) res, err := a.CreateOrgAndInvite(context.Background(), InviteInput{ TenantID: "t1", Slug: "acme", Name: "Acme Inc.", AdminEmail: "owner@acme.test", AdminName: "Alice Owner", }) if err != nil { t.Fatal(err) } if res.OrganizationID != "org-xyz" || res.UserID != "user-abc" { t.Errorf("unexpected ids: %+v", res) } if s.orgCalls.Load() != 1 || s.userCalls.Load() != 1 || s.memberCalls.Load() != 1 || s.emailCalls.Load() != 1 { t.Errorf("call counts: org=%d user=%d member=%d email=%d", s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load()) } } func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) { s := newStubKC(t) defer s.close() a := newTestAdapter(s.srv) _, err := a.CreateOrgAndInvite(context.Background(), InviteInput{ TenantID: "t1", Slug: "x", Name: "X", }) if err == nil { t.Fatal("expected error for empty admin email") } } func TestHTTPAdapter_orgConflict(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", tokenOK) mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) }) srv := httptest.NewServer(mux) defer srv.Close() a := newTestAdapter(srv) _, err := a.CreateOrgAndInvite(context.Background(), InviteInput{ TenantID: "t1", Slug: "x", Name: "X", AdminEmail: "a@b.test", }) if !errors.Is(err, ErrOrgConflict) { t.Errorf("err = %v, want ErrOrgConflict", err) } } func TestHTTPAdapter_tokenUnavailable(t *testing.T) { // No KC server at all — adapter should surface ErrUnavailable. a := NewHTTPAdapter(HTTPConfig{ BaseURL: "http://127.0.0.1:1", Realm: "test", ClientID: "x", ClientSecret: "y", Timeout: 1 * time.Second, }) err := a.Health(context.Background()) if !errors.Is(err, ErrUnavailable) { t.Errorf("err = %v, want ErrUnavailable", err) } } func TestHTTPAdapter_tokenUnauthorized(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }) srv := httptest.NewServer(mux) defer srv.Close() a := newTestAdapter(srv) err := a.Health(context.Background()) if !errors.Is(err, ErrUnauthorized) { t.Errorf("err = %v, want ErrUnauthorized", err) } } func TestHTTPAdapter_syncClaims(t *testing.T) { s := newStubKC(t) defer s.close() a := newTestAdapter(s.srv) err := a.SyncClaims(context.Background(), "user-abc", Claims{ TenantID: "t1", TenantSlug: "acme", Plan: "professional", Products: []string{"certifai"}, TenantStatus: "active", }) if err != nil { t.Fatal(err) } if s.syncCalls.Load() != 1 { t.Errorf("sync calls = %d", s.syncCalls.Load()) } } func TestHTTPAdapter_tokenIsCached(t *testing.T) { s := newStubKC(t) defer s.close() a := newTestAdapter(s.srv) // Three Health calls should produce ONE token fetch (cached). for i := 0; i < 3; i++ { if err := a.Health(context.Background()); err != nil { t.Fatal(err) } } if s.tokenCalls.Load() != 1 { t.Errorf("token fetches = %d, want 1 (cache miss)", s.tokenCalls.Load()) } } // tokenOK is a reusable handler that always returns a working token. func tokenOK(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } b := make([]byte, r.ContentLength) _, _ = r.Body.Read(b) if !strings.Contains(string(b), "client_credentials") { http.Error(w, "grant_type", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"access_token":"t","expires_in":60}`)) }