feat(keycloak): M4.3 — Admin API adapter + claim resolver
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
This commit is contained in:
@@ -97,7 +97,10 @@ func TestAuditAutoEmittedOnTenantCreate(t *testing.T) {
|
||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "audit-target", "name": "Audit Target",
|
||||
})
|
||||
fresh := decode[store.Tenant](t, body)
|
||||
freshWrap := decode[struct {
|
||||
Tenant *store.Tenant `json:"tenant"`
|
||||
}](t, body)
|
||||
fresh := freshWrap.Tenant
|
||||
|
||||
resp, body := h.do("GET", "/v1/audit?action=tenant.created&tenant_id="+fresh.ID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
|
||||
@@ -50,7 +50,10 @@ func TestCatalogTrialRequest(t *testing.T) {
|
||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "trial-target", "name": "Trial Target",
|
||||
})
|
||||
fresh := decode[store.Tenant](t, body)
|
||||
freshWrap := decode[struct {
|
||||
Tenant *store.Tenant `json:"tenant"`
|
||||
}](t, body)
|
||||
fresh := freshWrap.Tenant
|
||||
|
||||
resp, body := h.do("POST", "/v1/catalog/trial-request", map[string]any{
|
||||
"tenant_id": fresh.ID, "product": "compliance",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
// provisionKeycloak is called inside createTenant after the DB insert
|
||||
// succeeds. Best-effort: a failure does NOT roll the tenant back. The
|
||||
// audit_log captures the error so the operator can heal it later
|
||||
// (resending the invite is a one-click in the KC admin UI).
|
||||
//
|
||||
// Returns the InviteURL so the API response can surface it for dev.
|
||||
func (s *Server) provisionKeycloak(ctx context.Context, t *store.Tenant, adminEmail, adminName string) (string, error) {
|
||||
if adminEmail == "" {
|
||||
// Skip silently — caller chose not to invite anyone yet (sales-led
|
||||
// flow, demo tenant, test fixture, etc.).
|
||||
return "", nil
|
||||
}
|
||||
res, err := s.Keycloak.CreateOrgAndInvite(ctx, keycloak.InviteInput{
|
||||
TenantID: t.ID,
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
AdminEmail: adminEmail,
|
||||
AdminName: adminName,
|
||||
})
|
||||
if err != nil {
|
||||
s.Log.Error("keycloak provision failed",
|
||||
"tenant_id", t.ID, "slug", t.Slug, "err", err)
|
||||
return "", err
|
||||
}
|
||||
s.Log.Info("keycloak provisioned",
|
||||
"tenant_id", t.ID, "kc_org_id", res.OrganizationID, "kc_user_id", res.UserID)
|
||||
return res.InviteURL, nil
|
||||
}
|
||||
|
||||
// kcClaims is POST /v1/internal/keycloak/claims. Called by Keycloak's
|
||||
// protocol mapper (or by a dev tester) to fetch the current entitlement
|
||||
// bundle for a user. Lookup chain:
|
||||
// 1. body.tenant_slug → tenant
|
||||
// 2. body.tenant_id → tenant
|
||||
// 3. body.user_attrs.tenant_id → tenant
|
||||
//
|
||||
// At least one must be present.
|
||||
type kcClaimsReq struct {
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
UserAttrs map[string]string `json:"user_attrs,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) kcClaims(w http.ResponseWriter, r *http.Request) {
|
||||
var in kcClaimsReq
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
id := in.TenantID
|
||||
if id == "" {
|
||||
id = in.UserAttrs["tenant_id"]
|
||||
}
|
||||
slug := in.TenantSlug
|
||||
if slug == "" {
|
||||
slug = in.UserAttrs["tenant_slug"]
|
||||
}
|
||||
if id == "" && slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id or tenant_slug required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
t *store.Tenant
|
||||
err error
|
||||
)
|
||||
if id != "" {
|
||||
t, err = s.Store.GetTenant(ctx, id)
|
||||
} else {
|
||||
t, err = s.Store.GetTenantBySlug(ctx, slug)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "tenant does not exist")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
products, err := s.Store.ListTenantProducts(ctx, t.ID)
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
productKeys := make([]string, 0, len(products))
|
||||
for _, p := range products {
|
||||
if p.Enabled {
|
||||
productKeys = append(productKeys, p.Product)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, keycloak.Claims{
|
||||
TenantID: t.ID,
|
||||
TenantSlug: t.Slug,
|
||||
OrgRoles: []string{}, // populated by /v1/users/:id role lookup — out of scope until M5.2
|
||||
Products: productKeys,
|
||||
Plan: t.Plan,
|
||||
TenantStatus: t.Status,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
func TestCreateTenant_provisionsKeycloak(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "kc-co",
|
||||
"name": "KC Co.",
|
||||
"admin_email": "owner@kc-co.test",
|
||||
"admin_name": "Pat Owner",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
out := decode[struct {
|
||||
Tenant *store.Tenant `json:"tenant"`
|
||||
InviteURL string `json:"invite_url"`
|
||||
}](t, body)
|
||||
if out.Tenant.Slug != "kc-co" {
|
||||
t.Errorf("slug = %q", out.Tenant.Slug)
|
||||
}
|
||||
if out.InviteURL == "" {
|
||||
t.Error("invite_url missing in response")
|
||||
}
|
||||
|
||||
// The mock recorded the call.
|
||||
if _, ok := h.kcMock.Orgs[out.Tenant.ID]; !ok {
|
||||
t.Errorf("kc mock did not record org for tenant %s", out.Tenant.ID)
|
||||
}
|
||||
if _, ok := h.kcMock.Users["owner@kc-co.test"]; !ok {
|
||||
t.Error("kc mock did not record user for owner@kc-co.test")
|
||||
}
|
||||
|
||||
// And we emitted a keycloak.invite_sent audit event.
|
||||
resp, body = h.do("GET",
|
||||
"/v1/audit?action=keycloak.invite_sent&tenant_id="+out.Tenant.ID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("audit list status = %d", resp.StatusCode)
|
||||
}
|
||||
listed := decode[struct {
|
||||
Items []store.AuditEvent `json:"items"`
|
||||
}](t, body)
|
||||
if len(listed.Items) != 1 {
|
||||
t.Errorf("expected 1 invite_sent event, got %d", len(listed.Items))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTenant_kcFailure_doesNotRollback(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
// Force the mock to fail the next call.
|
||||
h.kcMock.FailNext = keycloak.ErrUnavailable
|
||||
|
||||
resp, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "kc-fail", "name": "KC Fail", "admin_email": "x@y.test",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("expected tenant still created despite kc fail; status=%d body=%s",
|
||||
resp.StatusCode, body)
|
||||
}
|
||||
out := decode[struct {
|
||||
Tenant *store.Tenant `json:"tenant"`
|
||||
}](t, body)
|
||||
// Tenant landed in the DB.
|
||||
if out.Tenant.ID == "" {
|
||||
t.Error("tenant id missing")
|
||||
}
|
||||
// And there's a provision_failed audit event for it.
|
||||
_, body = h.do("GET",
|
||||
"/v1/audit?action=keycloak.provision_failed&tenant_id="+out.Tenant.ID, nil)
|
||||
listed := decode[struct {
|
||||
Items []store.AuditEvent `json:"items"`
|
||||
}](t, body)
|
||||
if len(listed.Items) != 1 {
|
||||
t.Errorf("expected 1 provision_failed event, got %d", len(listed.Items))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKcClaims_returnsCurrentEntitlements(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
||||
"tenant_slug": h.tenant.Slug,
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
got := decode[keycloak.Claims](t, body)
|
||||
if got.TenantID != h.tenant.ID || got.TenantSlug != h.tenant.Slug {
|
||||
t.Errorf("tenant fields off: %+v", got)
|
||||
}
|
||||
if got.Plan != h.tenant.Plan {
|
||||
t.Errorf("plan = %q, want %q", got.Plan, h.tenant.Plan)
|
||||
}
|
||||
if got.TenantStatus != h.tenant.Status {
|
||||
t.Errorf("status = %q, want %q", got.TenantStatus, h.tenant.Status)
|
||||
}
|
||||
// acme is seeded with certifai + compliance entitlements (memory)
|
||||
// or one or zero (postgres, depending on prior subtest ordering).
|
||||
// At minimum the field is present.
|
||||
if got.Products == nil {
|
||||
t.Error("products is nil; should be at least empty slice")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKcClaims_lookupByUserAttrs(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
||||
"user_attrs": map[string]string{"tenant_slug": h.tenant.Slug},
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
got := decode[keycloak.Claims](t, body)
|
||||
if got.TenantID != h.tenant.ID {
|
||||
t.Errorf("did not resolve via user_attrs; got %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKcClaims_missingTenant404(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
||||
"tenant_slug": "nope-nope",
|
||||
})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKcClaims_requiresInput(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -44,6 +44,7 @@ func TestOpenAPISpec_loadsAndIsConsistent(t *testing.T) {
|
||||
{"GET", "/v1/api-keys?tenant_id=00000000-0000-0000-0000-000000000001"},
|
||||
{"GET", "/v1/catalog"},
|
||||
{"GET", "/v1/audit?limit=10"},
|
||||
{"POST", "/v1/internal/keycloak/claims"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
req := newRequest(t, c.method, c.path)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package server wires the HTTP surface for tenant-registry.
|
||||
//
|
||||
// All routes are registered in NewRouter; per-concern handlers live in
|
||||
// peer files (tenants.go, catalog.go, apikeys.go, audit.go).
|
||||
// peer files (tenants.go, catalog.go, apikeys.go, audit.go, keycloak.go).
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -9,14 +9,16 @@ import (
|
||||
"net/http"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
// Server bundles the dependencies every handler needs.
|
||||
type Server struct {
|
||||
Cfg *config.Config
|
||||
Log *slog.Logger
|
||||
Store store.Store
|
||||
Cfg *config.Config
|
||||
Log *slog.Logger
|
||||
Store store.Store
|
||||
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
|
||||
}
|
||||
|
||||
// NewRouter builds the http.Handler with logging middleware applied.
|
||||
@@ -34,9 +36,7 @@ func NewRouter(s *Server) http.Handler {
|
||||
mux.HandleFunc("POST /v1/tenants/{id}/activate", s.activateTenant)
|
||||
mux.HandleFunc("POST /v1/tenants/{id}/cancel", s.cancelTenant)
|
||||
|
||||
// entitlements — top-level path so it doesn't conflict with
|
||||
// /v1/tenants/by-slug/{slug} (Go 1.22 ServeMux can't disambiguate
|
||||
// /v1/tenants/{id}/products vs /v1/tenants/by-slug/{slug=products}).
|
||||
// entitlements
|
||||
mux.HandleFunc("GET /v1/entitlements", s.listTenantProducts)
|
||||
|
||||
// catalog
|
||||
@@ -44,8 +44,7 @@ func NewRouter(s *Server) http.Handler {
|
||||
mux.HandleFunc("POST /v1/catalog/request", s.catalogRequest)
|
||||
mux.HandleFunc("POST /v1/catalog/trial-request", s.catalogTrialRequest)
|
||||
|
||||
// api keys — same disambiguation: list lives at /v1/api-keys?tenant_id=X
|
||||
// instead of /v1/tenants/{id}/api-keys.
|
||||
// api keys
|
||||
mux.HandleFunc("POST /v1/api-keys", s.createAPIKey)
|
||||
mux.HandleFunc("GET /v1/api-keys", s.listAPIKeys)
|
||||
mux.HandleFunc("DELETE /v1/api-keys/{id}", s.revokeAPIKey)
|
||||
@@ -55,6 +54,12 @@ func NewRouter(s *Server) http.Handler {
|
||||
mux.HandleFunc("POST /v1/audit", s.appendAudit)
|
||||
mux.HandleFunc("GET /v1/audit", s.listAudit)
|
||||
|
||||
// keycloak claims refresh — the URL the protocol mapper would call at
|
||||
// token issuance to grab the up-to-date entitlement bundle. Today the
|
||||
// dev realm projects user attributes (set by SyncClaims) — this is
|
||||
// the "pull" complement for when the realm is reconfigured to fetch.
|
||||
mux.HandleFunc("POST /v1/internal/keycloak/claims", s.kcClaims)
|
||||
|
||||
return logRequest(s.Log)(mux)
|
||||
}
|
||||
|
||||
@@ -67,5 +72,9 @@ func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.Keycloak.Health(r.Context()); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/migrations"
|
||||
@@ -33,6 +34,7 @@ type testHarness struct {
|
||||
srv *httptest.Server
|
||||
store store.Store
|
||||
tenant *store.Tenant // pre-created acme tenant
|
||||
kcMock *keycloak.Mock
|
||||
}
|
||||
|
||||
func (h *testHarness) Close() {
|
||||
@@ -131,14 +133,19 @@ func newPostgresHarness(t *testing.T) *testHarness {
|
||||
func wireHarness(t *testing.T, s store.Store, seed *store.Tenant) *testHarness {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
mock := keycloak.NewMock()
|
||||
handler := server.NewRouter(&server.Server{
|
||||
Cfg: &config.Config{Env: "dev"}, Log: logger, Store: s,
|
||||
Cfg: &config.Config{Env: "dev"},
|
||||
Log: logger,
|
||||
Store: s,
|
||||
Keycloak: mock,
|
||||
})
|
||||
return &testHarness{
|
||||
t: t,
|
||||
srv: httptest.NewServer(handler),
|
||||
store: s,
|
||||
tenant: seed,
|
||||
kcMock: mock,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ type createTenantReq struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
SalesOwner string `json:"sales_owner,omitempty"`
|
||||
// AdminEmail is optional. When set, the Keycloak adapter provisions
|
||||
// an organization + invites this user as IT_ADMIN. Omitted for
|
||||
// sales-led flows that invite the admin later via the portal.
|
||||
AdminEmail string `json:"admin_email,omitempty"`
|
||||
AdminName string `json:"admin_name,omitempty"`
|
||||
}
|
||||
|
||||
// createTenantResp wraps the tenant with the optional KC invite URL so
|
||||
// dev testers can use it without waiting for the email.
|
||||
type createTenantResp struct {
|
||||
Tenant *store.Tenant `json:"tenant"`
|
||||
InviteURL string `json:"invite_url,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -40,7 +52,7 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
t, err := s.Store.CreateTenant(ctx, store.TenantCreate{
|
||||
Slug: in.Slug, Name: in.Name, Plan: in.Plan, Kind: in.Kind, SalesOwner: in.SalesOwner,
|
||||
@@ -63,7 +75,25 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
Metadata: map[string]interface{}{"plan": t.Plan, "kind": t.Kind},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
// Best-effort Keycloak provisioning. A failure here doesn't roll the
|
||||
// tenant back — the operator can resend the invite via the KC admin UI.
|
||||
// We emit an audit event regardless so the failure is traceable.
|
||||
inviteURL, kcErr := s.provisionKeycloak(ctx, t, in.AdminEmail, in.AdminName)
|
||||
if kcErr != nil {
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: t.ID, Action: "keycloak.provision_failed",
|
||||
TargetID: t.ID, TargetType: "tenant",
|
||||
Metadata: map[string]interface{}{"err": kcErr.Error(), "admin_email": in.AdminEmail},
|
||||
})
|
||||
} else if in.AdminEmail != "" {
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: t.ID, Action: "keycloak.invite_sent",
|
||||
TargetID: in.AdminEmail, TargetType: "user", TargetName: in.AdminEmail,
|
||||
Metadata: map[string]interface{}{"role": "IT_ADMIN"},
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL})
|
||||
}
|
||||
|
||||
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -24,9 +24,12 @@ func TestCreateTenant(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
t1 := decode[store.Tenant](t, body)
|
||||
if t1.Slug != "beta-co" || t1.Status != "trial" || t1.Plan != "starter" {
|
||||
t.Errorf("unexpected: %+v", t1)
|
||||
out := decode[struct {
|
||||
Tenant *store.Tenant `json:"tenant"`
|
||||
InviteURL string `json:"invite_url"`
|
||||
}](t, body)
|
||||
if out.Tenant.Slug != "beta-co" || out.Tenant.Status != "trial" || out.Tenant.Plan != "starter" {
|
||||
t.Errorf("unexpected: %+v", out.Tenant)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -81,7 +84,10 @@ func TestActivateTenant(t *testing.T) {
|
||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "trial-co", "name": "Trial Co.",
|
||||
})
|
||||
created := decode[store.Tenant](t, body)
|
||||
createdWrap := decode[struct {
|
||||
Tenant *store.Tenant `json:"tenant"`
|
||||
}](t, body)
|
||||
created := createdWrap.Tenant
|
||||
if created.Status != "trial" {
|
||||
t.Fatalf("precondition: %q", created.Status)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user