Closes the tenant-anchor divergence that blocked the Auth-5 sdk/backend flips. The registry is the authority for tenant identity (model B2), but nothing ever told the product about a new tenant. The anchors drifted: the registry held acme/matrix-acme while the product database held only the legacy seed tenant 9282a473. With the SDK gate enabled its TenantResolver does GetTenantBySlug and would 403 EVERY authenticated request, because no real user's org slug existed locally. New internal/product port, mirroring keycloak.Adapter: handlers depend on the interface, main wires HTTPProvisioner when PRODUCT_API_URL is set and NoopProvisioner otherwise, so an unconfigured deployment still creates tenants. Tenant creation now also provisions the product tenant with OUR uuid, using the same best-effort contract as Keycloak provisioning: a failure does not roll the tenant back, it emits a product.provision_failed audit event so the divergence is traceable. Success emits product.tenant_provisioned. A 409 from the product counts as success — onboarding may retry and the product's insert is idempotent on the primary key (compliance#217). Server.productProvisioner() guarantees the documented never-nil invariant; tests construct Server directly and would otherwise panic mid-tenant-creation. 5 tests incl. the point of the whole port (the registry UUID is what gets sent). Full suite green with -race, coverage 71.4% (gate 70). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
89 lines
2.7 KiB
Go
89 lines
2.7 KiB
Go
package product
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The whole point of this port: the product must be created with the
|
|
// REGISTRY's uuid, so both systems anchor to the same tenant identity.
|
|
func TestProvisionTenant_sendsTheRegistryUUID(t *testing.T) {
|
|
var got Tenant
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&got)
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := NewHTTPProvisioner(srv.URL, "/sdk/v1/tenants", nil, 5*time.Second)
|
|
err := p.ProvisionTenant(context.Background(), Tenant{
|
|
ID: "2f875d6a-1d94-433a-b2ec-8529451a2d89", Name: "Acme", Slug: "acme",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("provision: %v", err)
|
|
}
|
|
if got.ID != "2f875d6a-1d94-433a-b2ec-8529451a2d89" {
|
|
t.Errorf("registry uuid not sent: %+v", got)
|
|
}
|
|
if got.Slug != "acme" || got.Name != "Acme" {
|
|
t.Errorf("payload wrong: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Re-provisioning must be safe: onboarding may retry, and the product's
|
|
// insert is idempotent on the primary key.
|
|
func TestProvisionTenant_conflictIsSuccess(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusConflict)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := NewHTTPProvisioner(srv.URL, "", nil, 5*time.Second)
|
|
if err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"}); err != nil {
|
|
t.Fatalf("409 should be treated as already-provisioned, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProvisionTenant_serverErrorIsUnavailable(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := NewHTTPProvisioner(srv.URL, "", nil, 5*time.Second)
|
|
err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"})
|
|
if !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("want ErrUnavailable, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProvisionTenant_attachesAuthHeaderWhenSupplied(t *testing.T) {
|
|
var seen string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := NewHTTPProvisioner(srv.URL, "", func(context.Context) (string, error) {
|
|
return "Bearer tok-123", nil
|
|
}, 5*time.Second)
|
|
if err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if seen != "Bearer tok-123" {
|
|
t.Errorf("auth header = %q", seen)
|
|
}
|
|
}
|
|
|
|
func TestNoopProvisioner(t *testing.T) {
|
|
if err := (NoopProvisioner{}).ProvisionTenant(context.Background(), Tenant{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|