package auth import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) 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', password_hash TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );` func setupTenantDB(t *testing.T, dbName 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, usersSchema); 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 } func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User { t.Helper() ctx := context.Background() u, err := store.Create(ctx, email, "Test User") if err != nil { t.Fatalf("create user: %v", err) } hash, err := HashPassword(password) if err != nil { t.Fatalf("hash password: %v", err) } if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil { t.Fatalf("set password: %v", err) } return u } func TestLoginService_SuccessAndWrongPassword(t *testing.T) { pool := setupTenantDB(t, "test_iam02_login") store := user.NewTenantUserStore(pool) createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort") issuer := NewTokenIssuer("test-secret-nur-fuer-tests") login := NewLoginService(store, issuer, "acme") token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort") if err != nil { t.Fatalf("login: %v", err) } if token == "" { t.Fatal("erwartet nicht-leeres token") } if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) { t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err) } if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) { t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err) } } // Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in // zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern // existiert. func TestLoginService_NoCrossTenantLogin(t *testing.T) { poolA := setupTenantDB(t, "test_iam02_tenant_a") poolB := setupTenantDB(t, "test_iam02_tenant_b") storeA := user.NewTenantUserStore(poolA) storeB := user.NewTenantUserStore(poolB) createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a") createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b") issuer := NewTokenIssuer("test-secret-nur-fuer-tests") loginA := NewLoginService(storeA, issuer, "tenant-a") // Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen, // obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt // strukturell nur die Zeilen ihrer eigenen Datenbank. if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) { t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err) } token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a") if err != nil { t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err) } claims, err := issuer.Verify(token) if err != nil { t.Fatalf("verify: %v", err) } if claims.TenantSlug != "tenant-a" { t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug) } } // Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar. func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) { issuer := NewTokenIssuer("test-secret-nur-fuer-tests") protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) // Kein Cookie. req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) rec := httptest.NewRecorder() protected(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("ohne cookie: status = %d, want 401", rec.Code) } // Manipuliertes Cookie. req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"}) rec = httptest.NewRecorder() protected(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code) } // Gueltiges Token. token, err := issuer.Issue("user-1", "acme") if err != nil { t.Fatalf("issue: %v", err) } req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil) req.AddCookie(&http.Cookie{Name: CookieName, Value: token}) rec = httptest.NewRecorder() protected(rec, req) if rec.Code != http.StatusOK { t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code) } }