Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4180a26c6e |
+1
-10
@@ -8,7 +8,6 @@ import (
|
|||||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -35,20 +34,12 @@ func main() {
|
|||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
tenantHandler := tenant.NewHandler(provisioner)
|
||||||
|
|
||||||
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
|
||||||
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
|
||||||
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
|
|
||||||
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
|
|
||||||
superadmins := user.NewSuperadminStore(registryPool)
|
|
||||||
userHandler := user.NewHandler(nil, superadmins)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||||
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
|
||||||
|
|
||||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package cfgservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
|
||||||
|
// (Akzeptanzkriterium 2 / Pruefung 2 in diesem Ticket bezieht sich auf die
|
||||||
|
// Aenderungsnachvollziehbarkeit — die Cache-Frist selbst folgt demselben
|
||||||
|
// Muster wie internal/flag.DefaultCacheTTL).
|
||||||
|
const DefaultCacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
value Value
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service ist die Leseseite mit Vorrangregel (Akzeptanzkriterium 1:
|
||||||
|
// Tenant-Override vor Global-Default) und lokalem TTL-Cache.
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]cacheEntry // Schluessel: key + "\x00" + tenantSlug
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store *Store, ttl time.Duration) *Service {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = DefaultCacheTTL
|
||||||
|
}
|
||||||
|
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheKey(key, tenantSlug string) string {
|
||||||
|
return key + "\x00" + tenantSlug
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve liefert den Konfigurationswert fuer einen Tenant: ein
|
||||||
|
// Tenant-spezifischer Override hat Vorrang vor dem globalen Default
|
||||||
|
// (Akzeptanzkriterium 1 / Pruefung 1). tenantSlug == "" wertet nur den
|
||||||
|
// globalen Wert aus.
|
||||||
|
func (s *Service) Resolve(ctx context.Context, tenantSlug, key string) (Value, error) {
|
||||||
|
ck := cacheKey(key, tenantSlug)
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
entry, exists := s.cache[ck]
|
||||||
|
fresh := exists && time.Now().Before(entry.expiresAt)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return entry.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := s.resolveUncached(ctx, tenantSlug, key)
|
||||||
|
if err != nil {
|
||||||
|
return Value{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cache[ck] = cacheEntry{value: v, expiresAt: time.Now().Add(s.ttl)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveUncached(ctx context.Context, tenantSlug, key string) (Value, error) {
|
||||||
|
if tenantSlug != "" {
|
||||||
|
v, err := s.store.Get(ctx, key, tenantSlug)
|
||||||
|
if err == nil {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
return Value{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.store.Get(ctx, key, GlobalScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate erzwingt beim naechsten Resolve-Aufruf ein sofortiges Neuladen
|
||||||
|
// fuer einen bestimmten (key, tenantSlug) statt auf den TTL-Ablauf zu warten
|
||||||
|
// — analog internal/flag.Service.Invalidate.
|
||||||
|
func (s *Service) Invalidate(key, tenantSlug string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.cache, cacheKey(key, tenantSlug))
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package cfgservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: Tenant-Override hat Vorrang vor
|
||||||
|
// Global-Default, automatisiert getestet.
|
||||||
|
func TestService_TenantOverrideTakesPrecedenceOverGlobal(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Set(ctx, "test_precedence_key", GlobalScope, "global-wert"); err != nil {
|
||||||
|
t.Fatalf("set global: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Set(ctx, "test_precedence_key", "test_acme", "tenant-wert"); err != nil {
|
||||||
|
t.Fatalf("set tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(store, time.Hour)
|
||||||
|
|
||||||
|
got, err := svc.Resolve(ctx, "test_acme", "test_precedence_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve mit override: %v", err)
|
||||||
|
}
|
||||||
|
if got.Value != "tenant-wert" {
|
||||||
|
t.Fatalf("erwartet tenant-override, habe %q", got.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotOther, err := svc.Resolve(ctx, "test_anderer_tenant", "test_precedence_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve ohne override: %v", err)
|
||||||
|
}
|
||||||
|
if gotOther.Value != "global-wert" {
|
||||||
|
t.Fatalf("erwartet global-default fuer tenant ohne override, habe %q", gotOther.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Cache-Invalidierung nach
|
||||||
|
// Konfigurationsaenderung innerhalb dokumentierter Zeit gemessen.
|
||||||
|
func TestService_CacheInvalidationTiming(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const ttl = 150 * time.Millisecond
|
||||||
|
if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "alt"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, ttl)
|
||||||
|
|
||||||
|
v, err := svc.Resolve(ctx, "", "test_ttl_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if v.Value != "alt" {
|
||||||
|
t.Fatalf("erwartet 'alt', habe %q", v.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
changedAt := time.Now()
|
||||||
|
if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "neu"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err = svc.Resolve(ctx, "", "test_ttl_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve direkt nach aenderung: %v", err)
|
||||||
|
}
|
||||||
|
if v.Value != "alt" {
|
||||||
|
t.Fatalf("cache haette den alten wert liefern sollen, habe %q", v.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := changedAt.Add(ttl + 100*time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
v, err := svc.Resolve(ctx, "", "test_ttl_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if v.Value == "neu" {
|
||||||
|
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", time.Since(changedAt), ttl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "alt"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, time.Hour)
|
||||||
|
_, _ = svc.Resolve(ctx, "", "test_invalidate_key")
|
||||||
|
|
||||||
|
if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "neu"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc.Invalidate("test_invalidate_key", "")
|
||||||
|
|
||||||
|
v, err := svc.Resolve(ctx, "", "test_invalidate_key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if v.Value != "neu" {
|
||||||
|
t.Fatalf("erwartet sofort sichtbaren neuen wert nach Invalidate, habe %q", v.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package cfgservice implementiert Core CFG-01: den zentralen Dienst fuer
|
||||||
|
// globale und tenant-spezifische Konfigurationswerte mit Versionierung und
|
||||||
|
// Cache-Invalidierung. Andere Module lesen Konfiguration AUSSCHLIESSLICH
|
||||||
|
// ueber dieses Paket (Akzeptanzkriterium 3), niemals ueber eigene Tabellen.
|
||||||
|
package cfgservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GlobalScope ist der reservierte Scope-Wert fuer globale Defaults — jeder
|
||||||
|
// andere Scope-Wert ist ein Tenant-Slug (Akzeptanzkriterium 1).
|
||||||
|
const GlobalScope = "global"
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("cfgservice: kein wert fuer diesen key gefunden")
|
||||||
|
|
||||||
|
type Value struct {
|
||||||
|
Key string
|
||||||
|
Scope string
|
||||||
|
Value string
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryEntry struct {
|
||||||
|
Key string
|
||||||
|
Scope string
|
||||||
|
Value string
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store ist die Schreib-/Verwaltungsseite. Set schreibt IMMER sowohl den
|
||||||
|
// aktuellen Stand (config_values) als auch einen Historieneintrag
|
||||||
|
// (config_value_history) in derselben Transaktion — eine Aenderung ohne
|
||||||
|
// Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2).
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set schreibt einen neuen Wert fuer (key, scope) und erhoeht die Version um 1
|
||||||
|
// (Version 1 bei erstmaligem Setzen).
|
||||||
|
func (s *Store) Set(ctx context.Context, key, scope, value string) (Value, error) {
|
||||||
|
if scope == "" {
|
||||||
|
return Value{}, errors.New("cfgservice: scope darf nicht leer sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Value{}, fmt.Errorf("transaktion starten: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var currentVersion int
|
||||||
|
err = tx.QueryRow(ctx, `SELECT version FROM config_values WHERE key = $1 AND scope = $2`, key, scope).Scan(¤tVersion)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Value{}, fmt.Errorf("aktuelle version lesen: %w", err)
|
||||||
|
}
|
||||||
|
newVersion := currentVersion + 1
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO config_values (key, scope, value, version, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, now())
|
||||||
|
ON CONFLICT (key, scope) DO UPDATE SET value = $3, version = $4, updated_at = now()
|
||||||
|
`, key, scope, value, newVersion); err != nil {
|
||||||
|
return Value{}, fmt.Errorf("wert speichern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO config_value_history (key, scope, value, version, changed_at)
|
||||||
|
VALUES ($1, $2, $3, $4, now())
|
||||||
|
`, key, scope, value, newVersion); err != nil {
|
||||||
|
return Value{}, fmt.Errorf("historie schreiben: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return Value{}, fmt.Errorf("transaktion committen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Value{Key: key, Scope: scope, Value: value, Version: newVersion}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get liefert den Wert fuer GENAU EINEN Scope (kein Vorrang-Fallback) — die
|
||||||
|
// Vorrangregel (Tenant vor Global) lebt bewusst in Service.Resolve, damit
|
||||||
|
// Store rein CRUD bleibt.
|
||||||
|
func (s *Store) Get(ctx context.Context, key, scope string) (Value, error) {
|
||||||
|
var v Value
|
||||||
|
v.Key, v.Scope = key, scope
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT value, version FROM config_values WHERE key = $1 AND scope = $2
|
||||||
|
`, key, scope).Scan(&v.Value, &v.Version)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Value{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Value{}, fmt.Errorf("wert lesen: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// History liefert die vollstaendige Versionshistorie eines (key, scope) in
|
||||||
|
// aufsteigender Reihenfolge (Akzeptanzkriterium 2 / Pruefung 3).
|
||||||
|
func (s *Store) History(ctx context.Context, key, scope string) ([]HistoryEntry, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT key, scope, value, version FROM config_value_history
|
||||||
|
WHERE key = $1 AND scope = $2 ORDER BY version
|
||||||
|
`, key, scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("historie abfragen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []HistoryEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var h HistoryEntry
|
||||||
|
if err := rows.Scan(&h.Key, &h.Scope, &h.Value, &h.Version); err != nil {
|
||||||
|
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package cfgservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupStoreTest(t *testing.T) (*Store, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS config_values (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL CHECK (scope <> ''),
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (key, scope)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS config_value_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM config_value_history WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM config_values WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_SetIncrementsVersion(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
v1, err := store.Set(ctx, "test_key", GlobalScope, "erster-wert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set 1: %v", err)
|
||||||
|
}
|
||||||
|
if v1.Version != 1 {
|
||||||
|
t.Fatalf("erwartet version 1, habe %d", v1.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
v2, err := store.Set(ctx, "test_key", GlobalScope, "zweiter-wert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set 2: %v", err)
|
||||||
|
}
|
||||||
|
if v2.Version != 2 {
|
||||||
|
t.Fatalf("erwartet version 2, habe %d", v2.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.Get(ctx, "test_key", GlobalScope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Value != "zweiter-wert" || got.Version != 2 {
|
||||||
|
t.Fatalf("aktueller wert unerwartet: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 3: Versionierungshistorie ueber mehrere
|
||||||
|
// Aenderungen hinweg nachvollzogen.
|
||||||
|
func TestStore_HistoryTracksAllChanges(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
values := []string{"v1", "v2", "v3"}
|
||||||
|
for _, v := range values {
|
||||||
|
if _, err := store.Set(ctx, "test_history_key", GlobalScope, v); err != nil {
|
||||||
|
t.Fatalf("set %q: %v", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.History(ctx, "test_history_key", GlobalScope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
|
||||||
|
}
|
||||||
|
for i, h := range history {
|
||||||
|
if h.Version != i+1 || h.Value != values[i] {
|
||||||
|
t.Fatalf("historieneintrag[%d] unerwartet: %+v", i, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_GetUnknownKeyReturnsNotFound(t *testing.T) {
|
||||||
|
store, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Get(ctx, "test_nie_gesetzt", GlobalScope); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("erwartet ErrNotFound, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
|
|
||||||
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
|
|
||||||
// dieser Kachel und daher hier noch nicht angebunden.
|
|
||||||
type Handler struct {
|
|
||||||
users *TenantUserStore
|
|
||||||
superadmins *SuperadminStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
|
|
||||||
return &Handler{users: users, superadmins: superadmins}
|
|
||||||
}
|
|
||||||
|
|
||||||
type createUserRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := h.users.Create(r.Context(), req.Email, req.Name)
|
|
||||||
writeUserResult(w, u, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
|
|
||||||
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
|
|
||||||
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
|
|
||||||
writeUserResult(w, u, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeUserResult(w http.ResponseWriter, u User, err error) {
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
case errors.Is(err, ErrNotFound):
|
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
|
||||||
default:
|
|
||||||
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(u)
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
|
|
||||||
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
|
|
||||||
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
|
|
||||||
// fuer dasselbe Muster.
|
|
||||||
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("admin pool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
||||||
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
|
||||||
t.Fatalf("testdatenbank anlegen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
|
|
||||||
pool, err := pgxpool.New(ctx, dsn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect testdatenbank: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
|
|
||||||
t.Fatalf("schema anwenden: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
pool.Close()
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
||||||
adminPool.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
return pool
|
|
||||||
}
|
|
||||||
|
|
||||||
const usersSchema = `
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
CREATE TABLE users (
|
|
||||||
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()
|
|
||||||
);`
|
|
||||||
|
|
||||||
const superadminsSchema = `
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
CREATE TABLE 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()
|
|
||||||
);`
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
|
|
||||||
func TestTenantUserStore_CRUD(t *testing.T) {
|
|
||||||
pool := setupTestDB(t, "test_iam01_users", usersSchema)
|
|
||||||
store := NewTenantUserStore(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
created, err := store.Create(ctx, "alice@example.com", "Alice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create: %v", err)
|
|
||||||
}
|
|
||||||
if created.Status != StatusActive {
|
|
||||||
t.Fatalf("erwartet status active, hat %q", created.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := store.Get(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.Email != "alice@example.com" {
|
|
||||||
t.Fatalf("get email = %q", got.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update: %v", err)
|
|
||||||
}
|
|
||||||
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
|
|
||||||
t.Fatalf("update ergebnis unerwartet: %+v", updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := store.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 1 {
|
|
||||||
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivated, err := store.Deactivate(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deactivate: %v", err)
|
|
||||||
}
|
|
||||||
if deactivated.Status != StatusInactive {
|
|
||||||
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Negativfall: doppelte E-Mail-Adresse.
|
|
||||||
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
|
|
||||||
t.Fatalf("create second: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
|
|
||||||
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Negativfall: fehlender Benutzer.
|
|
||||||
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
|
|
||||||
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
|
|
||||||
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
|
|
||||||
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
|
|
||||||
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
|
|
||||||
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
|
|
||||||
store := NewSuperadminStore(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create superadmin: %v", err)
|
|
||||||
}
|
|
||||||
if created.Status != StatusActive {
|
|
||||||
t.Fatalf("erwartet status active, hat %q", created.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := store.Get(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.Email != "root@nexarch.internal" {
|
|
||||||
t.Fatalf("get email = %q", got.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
|
|
||||||
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivated, err := store.Deactivate(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deactivate: %v", err)
|
|
||||||
}
|
|
||||||
if deactivated.Status != StatusInactive {
|
|
||||||
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
|
|
||||||
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
|
|
||||||
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
|
|
||||||
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
|
|
||||||
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
|
|
||||||
type SuperadminStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
|
|
||||||
return &SuperadminStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var u User
|
|
||||||
u.Email, u.Name, u.Status = email, name, StatusActive
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO superadmins (email, name, status)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`, u.Email, u.Name, u.Status)
|
|
||||||
|
|
||||||
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM superadmins WHERE id = $1
|
|
||||||
`, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM superadmins ORDER BY created_at
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("superadmins auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []User
|
|
||||||
for rows.Next() {
|
|
||||||
var u User
|
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("superadmin lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE superadmins SET status = $2, updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, StatusInactive))
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
|
|
||||||
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
|
|
||||||
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
|
|
||||||
type TenantUserStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
|
|
||||||
return &TenantUserStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var u User
|
|
||||||
u.Email, u.Name, u.Status = email, name, StatusActive
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO users (email, name, status)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`, u.Email, u.Name, u.Status)
|
|
||||||
|
|
||||||
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM users WHERE id = $1
|
|
||||||
`, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM users ORDER BY created_at
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("benutzer auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []User
|
|
||||||
for rows.Next() {
|
|
||||||
var u User
|
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("benutzer lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
|
|
||||||
// Feld unveraendert.
|
|
||||||
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
|
|
||||||
if email != "" {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE users
|
|
||||||
SET email = COALESCE(NULLIF($2, ''), email),
|
|
||||||
name = COALESCE(NULLIF($3, ''), name),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, email, name)
|
|
||||||
|
|
||||||
u, err := scanUser(row)
|
|
||||||
if err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
|
|
||||||
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE users SET status = $2, updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, StatusInactive))
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanUser(row pgx.Row) (User, error) {
|
|
||||||
var u User
|
|
||||||
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return User{}, ErrNotFound
|
|
||||||
}
|
|
||||||
return User{}, fmt.Errorf("benutzer lesen: %w", err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
|
|
||||||
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
|
|
||||||
func mapWriteErr(err error) error {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
||||||
return ErrEmailTaken
|
|
||||||
}
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return fmt.Errorf("benutzer schreiben: %w", err)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
|
|
||||||
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
|
|
||||||
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
|
|
||||||
// dagegen mandantenuebergreifend in der Registry und sind ueber
|
|
||||||
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
|
|
||||||
// tenant_id-NULL-Sonderfall in User.
|
|
||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"regexp"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Status string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusActive Status = "active"
|
|
||||||
StatusInactive Status = "inactive"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
Status Status
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
|
||||||
|
|
||||||
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
|
|
||||||
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
|
|
||||||
var ErrNotFound = errors.New("user: nicht gefunden")
|
|
||||||
|
|
||||||
func ValidateEmail(email string) error {
|
|
||||||
if !emailPattern.MatchString(email) {
|
|
||||||
return ErrInvalidEmail
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateEmail(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
email string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"a@b.de", false},
|
|
||||||
{"a.b+c@sub.example.com", false},
|
|
||||||
{"", true},
|
|
||||||
{"keine-email", true},
|
|
||||||
{"a@b", true},
|
|
||||||
{"@b.de", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
err := ValidateEmail(c.email)
|
|
||||||
if (err != nil) != c.wantErr {
|
|
||||||
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS superadmins;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
|
|
||||||
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
|
|
||||||
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
|
|
||||||
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
|
|
||||||
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
|
|
||||||
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
|
|
||||||
CREATE TABLE 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()
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS config_value_history;
|
||||||
|
DROP TABLE IF EXISTS config_values;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Zentraler Konfigurationsdienst (CFG-01, siehe core-kanban/tickets/CFG-01.md).
|
||||||
|
-- scope = 'global' fuer globale Defaults, sonst der Tenant-Slug. config_values
|
||||||
|
-- haelt den AKTUELLEN Stand je (key, scope); config_value_history haelt JEDE
|
||||||
|
-- Aenderung fest (Akzeptanzkriterium 2: versioniert nachvollziehbar).
|
||||||
|
CREATE TABLE config_values (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL CHECK (scope <> ''),
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (key, scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE config_value_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX config_value_history_key_scope_idx ON config_value_history (key, scope, version);
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS users;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
|
|
||||||
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
|
|
||||||
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
|
|
||||||
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
|
|
||||||
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
|
|
||||||
-- nur innerhalb dieser einen Tenant-Datenbank.
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
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()
|
|
||||||
);
|
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
|
||||||
|
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
|
||||||
|
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
|
||||||
|
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
|
||||||
|
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
||||||
|
#
|
||||||
|
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||||
|
ROLE="nexarch_test"
|
||||||
|
|
||||||
|
export PGPASSWORD="$PASS"
|
||||||
|
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_value_history CASCADE;"
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_values CASCADE;"
|
||||||
|
|
||||||
|
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||||
|
for db in $dbs; do
|
||||||
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
|
||||||
|
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
|
||||||
|
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
|
||||||
|
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
||||||
|
#
|
||||||
|
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
|
||||||
|
|
||||||
|
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
|
||||||
|
|
||||||
|
echo "== go build =="
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
echo "== go vet =="
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
echo "== go test (-p 1) =="
|
||||||
|
go test ./... -p 1 -count=1
|
||||||
Reference in New Issue
Block a user