From 3589a40cdeadcac0590b4defb937a47ec69bc567 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 19 May 2026 13:35:07 +0200 Subject: [PATCH] test(keycloak): HTTPAdapter exercised against an in-process stub KC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds internal/keycloak/client_test.go: a minimal stubKC built on httptest.Server that responds to /token + /admin/serverinfo + the Admin API paths the adapter actually calls. Coverage on the keycloak package jumps from ~5% → ~50%; total project line coverage from 60% → 71.6%, back above the 70% gate. Workflow updated to include internal/keycloak/... in the test command (was missing — only server + config were enumerated). Tests added: Health success GET /admin/serverinfo with bearer CreateOrgAndInvite full flow POST org + user + member + email, assert call counts and ID parsing from the Location header Conflict surfacing POST /organizations → 409 → ErrOrgConflict Empty admin email rejected before any HTTP call Token unavailable connection refused → errors.Is(err, ErrUnavailable) Token unauthorized 401 on /token → ErrUnauthorized SyncClaims PUT /users/:id with attributes Token caching 3 Health() calls produce ONE /token fetch — the lock + expiry check works as designed Refs: M4.3 --- .gitea/workflows/ci.yaml | 2 +- internal/keycloak/client_test.go | 243 +++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 internal/keycloak/client_test.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f293c38..ad675d6 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -83,7 +83,7 @@ jobs: # own test binary — and including it triggers a covdata-tool error # on packages with no _test.go files. -coverpkg makes the server's # exercise of store/* count toward coverage. - run: go test -race -coverpkg=./internal/... -coverprofile=cover.out ./internal/server/... ./internal/config/... + run: go test -race -coverpkg=./internal/... -coverprofile=cover.out ./internal/server/... ./internal/config/... ./internal/keycloak/... - name: coverage gate run: | diff --git a/internal/keycloak/client_test.go b/internal/keycloak/client_test.go new file mode 100644 index 0000000..129dff6 --- /dev/null +++ b/internal/keycloak/client_test.go @@ -0,0 +1,243 @@ +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}`)) +}