TEN-05: backend-api + dev-server + next.js tenant-verwaltungsoberflaeche
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
// Package tenantadmin implementiert Core TEN-05: das Backend-API fuer die
|
||||
// Tenant-Verwaltungsoberflaeche. Enthaelt KEINE eigene Provisioning-/
|
||||
// Lifecycle-/Settings-Logik, sondern ist ein duenner Vermittler ueber
|
||||
// internal/tenant (TEN-01/TEN-04), internal/tenantsettings (TEN-03) und
|
||||
// internal/user.SuperadminStore (Berechtigungspruefung) — Ticket-Vorgabe:
|
||||
// "Verwaltungsoberflaeche im Stil einer schlanken Zitadel-Console".
|
||||
package tenantadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
// ErrNotSuperadmin wird geliefert, wenn der Aufrufer keine aktive
|
||||
// Superadmin-Identitaet hat (Akzeptanzkriterium 1 / Pruefung 1: die
|
||||
// Oberflaeche zeigt Mandanten nur berechtigten Superadmins).
|
||||
var ErrNotSuperadmin = errors.New("tenantadmin: aufrufer ist kein aktiver superadmin")
|
||||
|
||||
// ErrMissingDisplayName wird geliefert, wenn ein Einstellungs-Update ohne
|
||||
// das Pflichtfeld Anzeigename versucht wird (Akzeptanzkriterium 2 / Pruefung 2).
|
||||
var ErrMissingDisplayName = errors.New("tenantadmin: anzeigename ist ein pflichtfeld")
|
||||
|
||||
type Handler struct {
|
||||
registry *tenant.Registry
|
||||
lifecycle *tenant.Lifecycle
|
||||
settings *tenantsettings.Store
|
||||
superadmins *user.SuperadminStore
|
||||
}
|
||||
|
||||
func NewHandler(registry *tenant.Registry, lifecycle *tenant.Lifecycle, settings *tenantsettings.Store, superadmins *user.SuperadminStore) *Handler {
|
||||
return &Handler{registry: registry, lifecycle: lifecycle, settings: settings, superadmins: superadmins}
|
||||
}
|
||||
|
||||
// requireSuperadmin prueft, dass der Aufrufer ein EXISTIERENDER, AKTIVER
|
||||
// Superadmin ist — es gibt (bewusst, siehe internal/user.SuperadminStore)
|
||||
// keine Tenant-Scoping-Dimension fuer Superadmins: wer ueberhaupt Zugriff
|
||||
// hat, sieht alle Mandanten. Alles andere wird abgelehnt, bevor irgendeine
|
||||
// Mandantendatei gelesen wird (Fail-Safe-Default).
|
||||
func (h *Handler) requireSuperadmin(ctx context.Context, superadminID string) error {
|
||||
if superadminID == "" {
|
||||
return ErrNotSuperadmin
|
||||
}
|
||||
admin, err := h.superadmins.Get(ctx, superadminID)
|
||||
if err != nil {
|
||||
return ErrNotSuperadmin
|
||||
}
|
||||
if admin.Status != user.StatusActive {
|
||||
return ErrNotSuperadmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TenantListItem ist die fuer die Uebersichtsliste relevante Projektion
|
||||
// (Akzeptanzkriterium 1).
|
||||
type TenantListItem struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ListTenants liefert alle Mandanten, optional gefiltert nach Suchbegriff
|
||||
// (Slug/Name, Teilstring, case-insensitive) und Status — beides serverseitig,
|
||||
// damit die Oberflaeche nicht selbst ueber unautorisierte Datensaetze
|
||||
// filtern muss (Akzeptanzkriterium 1: Suche und Filter).
|
||||
func (h *Handler) ListTenants(ctx context.Context, superadminID, search, statusFilter string) ([]TenantListItem, error) {
|
||||
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
all, err := h.registry.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mandanten auflisten: %w", err)
|
||||
}
|
||||
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
out := make([]TenantListItem, 0, len(all))
|
||||
for _, t := range all {
|
||||
if statusFilter != "" && string(t.Status) != statusFilter {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(t.Slug), search) && !strings.Contains(strings.ToLower(t.Name), search) {
|
||||
continue
|
||||
}
|
||||
out = append(out, TenantListItem{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TenantDetail buendelt Stammdaten und Einstellungen fuer die Detailansicht.
|
||||
type TenantDetail struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Settings tenantsettings.Settings `json:"settings"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetTenantDetail(ctx context.Context, superadminID, slug string) (TenantDetail, error) {
|
||||
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
||||
return TenantDetail{}, err
|
||||
}
|
||||
t, err := h.registry.GetBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return TenantDetail{}, fmt.Errorf("mandant laden: %w", err)
|
||||
}
|
||||
s, err := h.settings.Get(ctx, t.ID)
|
||||
if err != nil {
|
||||
return TenantDetail{}, fmt.Errorf("einstellungen laden: %w", err)
|
||||
}
|
||||
return TenantDetail{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status), Settings: s}, nil
|
||||
}
|
||||
|
||||
// SettingsPatch ist die vom Formular gesendete Aenderung. DisplayName ist
|
||||
// KEIN Zeiger, weil es Pflichtfeld ist (Akzeptanzkriterium 2) — die anderen
|
||||
// Felder bleiben optional (Zeiger = "unveraendert lassen", siehe
|
||||
// tenantsettings.Patch).
|
||||
type SettingsPatch struct {
|
||||
DisplayName string
|
||||
LogoURL *string
|
||||
ColorScheme *string
|
||||
Timezone *string
|
||||
Language *string
|
||||
}
|
||||
|
||||
// UpdateSettings validiert das Pflichtfeld Anzeigename, BEVOR irgendein
|
||||
// Schreibzugriff erfolgt (Akzeptanzkriterium 2 / Pruefung 2: unvollstaendige
|
||||
// Pflichtfelder werden serverseitig verhindert, nicht nur clientseitig).
|
||||
func (h *Handler) UpdateSettings(ctx context.Context, superadminID, slug string, patch SettingsPatch) (tenantsettings.Settings, error) {
|
||||
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
||||
return tenantsettings.Settings{}, err
|
||||
}
|
||||
if strings.TrimSpace(patch.DisplayName) == "" {
|
||||
return tenantsettings.Settings{}, ErrMissingDisplayName
|
||||
}
|
||||
|
||||
t, err := h.registry.GetBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return tenantsettings.Settings{}, fmt.Errorf("mandant laden: %w", err)
|
||||
}
|
||||
|
||||
displayName := patch.DisplayName
|
||||
return h.settings.Update(ctx, t.ID, tenantsettings.Patch{
|
||||
DisplayName: &displayName,
|
||||
LogoURL: patch.LogoURL,
|
||||
ColorScheme: patch.ColorScheme,
|
||||
Timezone: patch.Timezone,
|
||||
Language: patch.Language,
|
||||
})
|
||||
}
|
||||
|
||||
// LifecycleAction sind die von der Oberflaeche ausloesbaren Aktionen
|
||||
// (Akzeptanzkriterium 3) — je EIN Wort pro Aktion, damit ein Bestaetigungs-
|
||||
// dialog im Frontend darauf verzweigen kann, ohne HTTP-Interna zu kennen.
|
||||
type LifecycleAction string
|
||||
|
||||
const (
|
||||
ActionSuspend LifecycleAction = "suspend"
|
||||
ActionReactivate LifecycleAction = "reactivate"
|
||||
ActionScheduleDeletion LifecycleAction = "schedule_deletion"
|
||||
ActionCancelDeletion LifecycleAction = "cancel_deletion"
|
||||
)
|
||||
|
||||
var ErrUnknownAction = errors.New("tenantadmin: unbekannte lifecycle-aktion")
|
||||
|
||||
// DefaultDeletionGracePeriod ist die Karenzzeit, die die Oberflaeche beim
|
||||
// Ausloesen von ActionScheduleDeletion verwendet — Konfiguration dieses
|
||||
// Pakets, nicht von internal/tenant (das lifecycle.go generisch mit einer
|
||||
// uebergebenen Dauer arbeitet, siehe TEN-04).
|
||||
const DefaultDeletionGracePeriod = 30 * 24 * time.Hour
|
||||
|
||||
// PerformLifecycleAction fuehrt EINEN der vier Uebergaenge aus. Ungueltige
|
||||
// Zustandsuebergaenge (z.B. "suspend" auf einen bereits geloeschten Tenant)
|
||||
// werden von internal/tenant.Lifecycle selbst mit ErrInvalidTransition
|
||||
// abgelehnt (siehe TEN-04) — dieses Paket dupliziert diese Pruefung nicht.
|
||||
func (h *Handler) PerformLifecycleAction(ctx context.Context, superadminID, slug string, action LifecycleAction) (tenant.Tenant, error) {
|
||||
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
||||
return tenant.Tenant{}, err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case ActionSuspend:
|
||||
return h.registry.Suspend(ctx, slug)
|
||||
case ActionReactivate:
|
||||
return h.registry.Reactivate(ctx, slug)
|
||||
case ActionScheduleDeletion:
|
||||
return h.registry.ScheduleDeletion(ctx, slug, DefaultDeletionGracePeriod)
|
||||
case ActionCancelDeletion:
|
||||
return h.registry.CancelDeletion(ctx, slug)
|
||||
default:
|
||||
return tenant.Tenant{}, ErrUnknownAction
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP-Bindung ---
|
||||
//
|
||||
// Der Aufrufer wird bewusst als expliziter Query-/Body-Parameter
|
||||
// "superadmin" statt aus einem Auth-Header gelesen — Session-/Token-basierte
|
||||
// Authentifizierung ist Sache von IAM-01/IAM-02 und wird hier NICHT
|
||||
// dupliziert (Kein Umbau angrenzender Bereiche); dieser Handler ist ein
|
||||
// duenner Entwicklungs-/Testzugang, der genau die in requireSuperadmin
|
||||
// beschriebene Berechtigungspruefung durchsetzt.
|
||||
|
||||
func (h *Handler) ListTenantsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
items, err := h.ListTenants(r.Context(), q.Get("superadmin"), q.Get("search"), q.Get("status"))
|
||||
writeResult(w, items, err)
|
||||
}
|
||||
|
||||
func (h *Handler) TenantDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
detail, err := h.GetTenantDetail(r.Context(), q.Get("superadmin"), q.Get("slug"))
|
||||
writeResult(w, detail, err)
|
||||
}
|
||||
|
||||
type settingsRequest struct {
|
||||
Superadmin string `json:"superadmin"`
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LogoURL *string `json:"logo_url"`
|
||||
ColorScheme *string `json:"color_scheme"`
|
||||
Timezone *string `json:"timezone"`
|
||||
Language *string `json:"language"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req settingsRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
result, err := h.UpdateSettings(r.Context(), req.Superadmin, req.Slug, SettingsPatch{
|
||||
DisplayName: req.DisplayName,
|
||||
LogoURL: req.LogoURL,
|
||||
ColorScheme: req.ColorScheme,
|
||||
Timezone: req.Timezone,
|
||||
Language: req.Language,
|
||||
})
|
||||
writeResult(w, result, err)
|
||||
}
|
||||
|
||||
type lifecycleRequest struct {
|
||||
Superadmin string `json:"superadmin"`
|
||||
Slug string `json:"slug"`
|
||||
Action LifecycleAction `json:"action"`
|
||||
}
|
||||
|
||||
func (h *Handler) LifecycleActionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req lifecycleRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
result, err := h.PerformLifecycleAction(r.Context(), req.Superadmin, req.Slug, req.Action)
|
||||
writeResult(w, result, err)
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeResult(w http.ResponseWriter, body any, err error) {
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrNotSuperadmin):
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, ErrMissingDisplayName):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, ErrUnknownAction), errors.Is(err, tenant.ErrInvalidTransition):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, tenant.ErrTenantNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package tenantadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
type testEnv struct {
|
||||
handler *Handler
|
||||
superadminID string
|
||||
slug string
|
||||
adminPool *pgxpool.Pool
|
||||
registry *tenant.Registry
|
||||
}
|
||||
|
||||
func setupTest(t *testing.T) (testEnv, func()) {
|
||||
t.Helper()
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
if adminDSN == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("registry pool: %v", err)
|
||||
}
|
||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("admin pool: %v", err)
|
||||
}
|
||||
|
||||
if _, err := registryPool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), previous_status TEXT, deletion_scheduled_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tenant_settings (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), display_name TEXT, logo_url TEXT,
|
||||
color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tenant_settings_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, display_name TEXT,
|
||||
logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS superadmins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
registry := tenant.NewRegistry(registryPool)
|
||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
||||
lifecycle := tenant.NewLifecycle(registry, adminPool)
|
||||
settingsStore := tenantsettings.NewStore(registryPool)
|
||||
superadmins := user.NewSuperadminStore(registryPool)
|
||||
|
||||
slug := fmt.Sprintf("tadm_%d", time.Now().UnixNano()%1_000_000_000)
|
||||
if _, err := provisioner.Provision(ctx, slug, "Test Mandant "+slug); err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
|
||||
admin, err := superadmins.Create(ctx, fmt.Sprintf("admin-%d@example.com", time.Now().UnixNano()), "Test Superadmin")
|
||||
if err != nil {
|
||||
t.Fatalf("superadmin anlegen: %v", err)
|
||||
}
|
||||
|
||||
handler := NewHandler(registry, lifecycle, settingsStore, superadmins)
|
||||
|
||||
cleanup := func() {
|
||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug)
|
||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug)
|
||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
||||
_, _ = registryPool.Exec(ctx, `DELETE FROM superadmins WHERE id = $1`, admin.ID)
|
||||
registryPool.Close()
|
||||
adminPool.Close()
|
||||
}
|
||||
return testEnv{handler: handler, superadminID: admin.ID, slug: slug, adminPool: adminPool, registry: registry}, cleanup
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: nur berechtigte (aktive) Superadmins
|
||||
// sehen die Mandantenliste ueberhaupt.
|
||||
func TestListTenants_RejectsNonSuperadmin(t *testing.T) {
|
||||
env, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := env.handler.ListTenants(ctx, "irgendeine-nicht-existierende-id", "", "")
|
||||
if !errors.Is(err, ErrNotSuperadmin) {
|
||||
t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTenants_ShowsSearchAndStatusFilterResults(t *testing.T) {
|
||||
env, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
all, err := env.handler.ListTenants(ctx, env.superadminID, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, item := range all {
|
||||
if item.Slug == env.slug {
|
||||
found = true
|
||||
if item.Status != "active" {
|
||||
t.Fatalf("status = %q, want active", item.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("erwartet test-tenant in ungefilterter liste")
|
||||
}
|
||||
|
||||
bySearch, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list mit suche: %v", err)
|
||||
}
|
||||
if len(bySearch) != 1 || bySearch[0].Slug != env.slug {
|
||||
t.Fatalf("suche nach slug lieferte unerwartetes ergebnis: %+v", bySearch)
|
||||
}
|
||||
|
||||
byWrongStatus, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "suspended")
|
||||
if err != nil {
|
||||
t.Fatalf("list mit statusfilter: %v", err)
|
||||
}
|
||||
if len(byWrongStatus) != 0 {
|
||||
t.Fatalf("statusfilter haette test-tenant (status=active) ausfiltern muessen, habe: %+v", byWrongStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2: fehlender Pflichtwert (Anzeigename)
|
||||
// wird serverseitig abgelehnt, bevor etwas gespeichert wird.
|
||||
func TestUpdateSettings_RejectsMissingDisplayName(t *testing.T) {
|
||||
env, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: " "})
|
||||
if !errors.Is(err, ErrMissingDisplayName) {
|
||||
t.Fatalf("erwartet ErrMissingDisplayName, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings_AppliesValidPatch(t *testing.T) {
|
||||
env, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
updated, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: "Neuer Name"})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.DisplayName != "Neuer Name" {
|
||||
t.Fatalf("displayname = %q, want 'Neuer Name'", updated.DisplayName)
|
||||
}
|
||||
|
||||
detail, err := env.handler.GetTenantDetail(ctx, env.superadminID, env.slug)
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
if detail.Settings.DisplayName != "Neuer Name" {
|
||||
t.Fatalf("detail zeigt nicht den aktualisierten namen: %+v", detail.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3: Lifecycle-Aktionen sind ausloesbar und wirken sich
|
||||
// auf den tatsaechlichen Mandantenstatus aus.
|
||||
func TestPerformLifecycleAction_SuspendAndReactivate(t *testing.T) {
|
||||
env, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
suspended, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionSuspend)
|
||||
if err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
if suspended.Status != tenant.StatusSuspended {
|
||||
t.Fatalf("status = %q, want suspended", suspended.Status)
|
||||
}
|
||||
|
||||
reactivated, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate)
|
||||
if err != nil {
|
||||
t.Fatalf("reactivate: %v", err)
|
||||
}
|
||||
if reactivated.Status != tenant.StatusActive {
|
||||
t.Fatalf("status = %q, want active", reactivated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformLifecycleAction_RejectsInvalidTransition(t *testing.T) {
|
||||
env, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Reaktivieren eines bereits aktiven Tenants ist kein gueltiger Uebergang.
|
||||
_, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate)
|
||||
if !errors.Is(err, tenant.ErrInvalidTransition) {
|
||||
t.Fatalf("erwartet ErrInvalidTransition, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformLifecycleAction_RejectsNonSuperadmin(t *testing.T) {
|
||||
env, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := env.handler.PerformLifecycleAction(ctx, "keine-berechtigung", env.slug, ActionSuspend)
|
||||
if !errors.Is(err, ErrNotSuperadmin) {
|
||||
t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user