d4e8042b94
internal/keycloak/ — Adapter interface with two implementations:
HTTPAdapter cached client-credentials token; CreateOrgAndInvite +
SyncClaims + Health against the real KC Admin API.
Mock in-process map for unit tests + dev convenience when
KEYCLOAK_ADMIN_URL is empty. Used by the eachStore harness.
POST /v1/tenants now accepts admin_email + admin_name. When set, the
adapter creates a KC organization, invites the user as IT_ADMIN, and
triggers VERIFY_EMAIL + UPDATE_PASSWORD. Response wraps the tenant
with TenantCreated{tenant, invite_url}. KC failures DO NOT roll the
tenant back — they emit a keycloak.provision_failed audit event.
Successful invites emit keycloak.invite_sent.
POST /v1/internal/keycloak/claims resolves a tenant's current claim
bundle (tenant_id, slug, products, plan, status). Lookup chain:
body.tenant_id → body.tenant_slug → user_attrs.tenant_id →
user_attrs.tenant_slug.
Config: KEYCLOAK_ADMIN_URL / REALM / CLIENT_ID / CLIENT_SECRET;
empty URL falls back to Mock.
Tests:
internal/keycloak/mock_test.go conflict surfacing, FailNext hook,
SyncClaims persistence.
internal/keycloak/client_test.go HTTPAdapter against an in-process
stub KC: health, full create-org-
and-invite, conflict, token-cache,
401 retry, ErrUnavailable.
internal/server/keycloak_test.go eachStore integration: provisions
via mock; failure path emits
provision_failed audit; claims
endpoint via every lookup variant
+ 404 + 400.
OpenAPI extended with TenantCreated + Claims schemas and the new
claims endpoint. Contract test asserts the new path.
CI: include internal/keycloak/... in the test package list so
HTTPAdapter coverage counts. Total project line coverage: 71.6%.
Refs: M4.3
244 lines
6.9 KiB
Go
244 lines
6.9 KiB
Go
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}`))
|
|
}
|