256 lines
8.6 KiB
Go
256 lines
8.6 KiB
Go
package tenant
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool, func()) {
|
|
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)
|
|
}
|
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("registry 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,
|
|
retention_block_reason TEXT,
|
|
retention_checked_at TIMESTAMPTZ
|
|
)`); err != nil {
|
|
t.Fatalf("registry-schema: %v", err)
|
|
}
|
|
|
|
registry := NewRegistry(registryPool)
|
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
lifecycle := NewLifecycle(registry, adminPool)
|
|
|
|
cleanup := func() {
|
|
registryPool.Close()
|
|
adminPool.Close()
|
|
}
|
|
_ = provisioner
|
|
return registry, lifecycle, adminPool, cleanup
|
|
}
|
|
|
|
func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) {
|
|
t.Helper()
|
|
dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1)
|
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
|
|
t.Fatalf("provision %s: %v", slug, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx := context.Background()
|
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
|
_, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
|
})
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge).
|
|
func TestLifecycle_SuspendAndReactivate(t *testing.T) {
|
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
defer cleanup()
|
|
provisionTestTenant(t, registry, adminPool, "lc_suspend")
|
|
ctx := context.Background()
|
|
|
|
suspended, err := registry.Suspend(ctx, "lc_suspend")
|
|
if err != nil {
|
|
t.Fatalf("suspend: %v", err)
|
|
}
|
|
if suspended.Status != StatusSuspended {
|
|
t.Fatalf("status = %q, want suspended", suspended.Status)
|
|
}
|
|
|
|
reactivated, err := registry.Reactivate(ctx, "lc_suspend")
|
|
if err != nil {
|
|
t.Fatalf("reactivate: %v", err)
|
|
}
|
|
if reactivated.Status != StatusActive {
|
|
t.Fatalf("status = %q, want active", reactivated.Status)
|
|
}
|
|
}
|
|
|
|
// Pruefung 1: ungueltige Uebergaenge werden abgewiesen.
|
|
func TestLifecycle_RejectsInvalidTransitions(t *testing.T) {
|
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
defer cleanup()
|
|
provisionTestTenant(t, registry, adminPool, "lc_invalid")
|
|
ctx := context.Background()
|
|
|
|
// Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang.
|
|
if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
}
|
|
|
|
if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil {
|
|
t.Fatalf("suspend: %v", err)
|
|
}
|
|
// Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig.
|
|
if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
}
|
|
|
|
// CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig.
|
|
if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
}
|
|
|
|
if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) {
|
|
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der
|
|
// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus,
|
|
// mit exakter Wiederherstellung des jeweiligen Vorzustands.
|
|
func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) {
|
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
defer cleanup()
|
|
provisionTestTenant(t, registry, adminPool, "lc_cancel_active")
|
|
provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended")
|
|
ctx := context.Background()
|
|
|
|
// Fall 1: aus 'active' heraus vorgemerkt und widerrufen.
|
|
scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("schedule deletion: %v", err)
|
|
}
|
|
if scheduled.Status != StatusPendingDeletion {
|
|
t.Fatalf("status = %q, want pending_deletion", scheduled.Status)
|
|
}
|
|
if scheduled.DeletionScheduledAt == nil {
|
|
t.Fatal("erwartet gesetzte deletion_scheduled_at")
|
|
}
|
|
|
|
restored, err := registry.CancelDeletion(ctx, "lc_cancel_active")
|
|
if err != nil {
|
|
t.Fatalf("cancel deletion: %v", err)
|
|
}
|
|
if restored.Status != StatusActive {
|
|
t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status)
|
|
}
|
|
|
|
// Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu
|
|
// 'suspended' zurueckkehren, NICHT zu 'active'.
|
|
if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil {
|
|
t.Fatalf("suspend: %v", err)
|
|
}
|
|
if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil {
|
|
t.Fatalf("schedule deletion: %v", err)
|
|
}
|
|
restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended")
|
|
if err != nil {
|
|
t.Fatalf("cancel deletion: %v", err)
|
|
}
|
|
if restoredSuspended.Status != StatusSuspended {
|
|
t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem
|
|
// Zugriffsversuch einen klaren Fehler.
|
|
func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) {
|
|
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
defer cleanup()
|
|
provisionTestTenant(t, registry, adminPool, "lc_checkactive")
|
|
ctx := context.Background()
|
|
|
|
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil {
|
|
t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err)
|
|
}
|
|
|
|
if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil {
|
|
t.Fatalf("suspend: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 3; i++ {
|
|
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) {
|
|
t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err)
|
|
}
|
|
}
|
|
|
|
if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) {
|
|
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit
|
|
// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions,
|
|
// das RunSweeper periodisch aufruft).
|
|
func TestLifecycle_ProcessDueDeletions(t *testing.T) {
|
|
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
defer cleanup()
|
|
provisionTestTenant(t, registry, adminPool, "lc_due")
|
|
provisionTestTenant(t, registry, adminPool, "lc_not_due")
|
|
ctx := context.Background()
|
|
|
|
// lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig.
|
|
if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil {
|
|
t.Fatalf("schedule deletion (due): %v", err)
|
|
}
|
|
// lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig.
|
|
if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil {
|
|
t.Fatalf("schedule deletion (not due): %v", err)
|
|
}
|
|
|
|
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
|
if err != nil {
|
|
t.Fatalf("process due deletions: %v", err)
|
|
}
|
|
if processed != 1 {
|
|
t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed)
|
|
}
|
|
|
|
due, err := registry.GetBySlug(ctx, "lc_due")
|
|
if err != nil {
|
|
t.Fatalf("get lc_due: %v", err)
|
|
}
|
|
if due.Status != StatusDeleted {
|
|
t.Fatalf("lc_due status = %q, want deleted", due.Status)
|
|
}
|
|
|
|
notDue, err := registry.GetBySlug(ctx, "lc_not_due")
|
|
if err != nil {
|
|
t.Fatalf("get lc_not_due: %v", err)
|
|
}
|
|
if notDue.Status != StatusPendingDeletion {
|
|
t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status)
|
|
}
|
|
|
|
// Datenbank von lc_due wurde tatsaechlich physisch entfernt.
|
|
var exists bool
|
|
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
|
dbNameForSlug("lc_due")).Scan(&exists); err != nil {
|
|
t.Fatalf("pg_database pruefen: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde")
|
|
}
|
|
}
|