test(keycloak): HTTPAdapter exercised against an in-process stub KC
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
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
Reference in New Issue
Block a user