package session import ( "context" "errors" "fmt" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/auth" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, 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 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() ); CREATE TABLE IF NOT EXISTS sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), token_hash BYTEA NOT NULL UNIQUE, device_info TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ ); `); err != nil { t.Fatalf("schema: %v", err) } userStore := user.NewTenantUserStore(pool) email := fmt.Sprintf("session-test-%d@example.com", time.Now().UnixNano()) u, err := userStore.Create(ctx, email, "Session Test") if err != nil { t.Fatalf("create user: %v", err) } cleanup := func() { pool.Close() } return NewStore(pool), userStore, u.ID, cleanup } // Akzeptanzkriterium 1 + Pruefung 1: zwei Sitzungen angelegt, beide in der // Uebersicht sichtbar. func TestListForUser_ShowsAllActiveSessions(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if _, _, err := store.Create(ctx, userID, "Chrome auf Windows", "10.0.0.1"); err != nil { t.Fatalf("create session 1: %v", err) } if _, _, err := store.Create(ctx, userID, "Safari auf iPhone", "10.0.0.2"); err != nil { t.Fatalf("create session 2: %v", err) } sessions, err := store.ListForUser(ctx, userID) if err != nil { t.Fatalf("list: %v", err) } if len(sessions) != 2 { t.Fatalf("erwartet 2 sitzungen, habe %d", len(sessions)) } } // Akzeptanzkriterium 2 + Pruefung 2: Widerruf macht das Token sofort ungueltig. func TestRevoke_InvalidatesTokenImmediately(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() sessionID, token, err := store.Create(ctx, userID, "Firefox", "10.0.0.3") if err != nil { t.Fatalf("create: %v", err) } if _, err := store.Validate(ctx, token); err != nil { t.Fatalf("validate vor widerruf: %v", err) } if err := store.Revoke(ctx, sessionID); err != nil { t.Fatalf("revoke: %v", err) } if _, err := store.Validate(ctx, token); !errors.Is(err, ErrSessionInvalid) { t.Fatalf("erwartet ErrSessionInvalid sofort nach widerruf, habe %v", err) } } // Akzeptanzkriterium 3 + Pruefung 3: "alle anderen beenden" funktioniert, // aktuelle Sitzung bleibt aktiv. func TestRevokeAllExcept_KeepsCurrentSessionActive(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() currentID, currentToken, err := store.Create(ctx, userID, "aktuelles geraet", "10.0.0.4") if err != nil { t.Fatalf("create current: %v", err) } _, otherToken1, err := store.Create(ctx, userID, "anderes geraet 1", "10.0.0.5") if err != nil { t.Fatalf("create other 1: %v", err) } _, otherToken2, err := store.Create(ctx, userID, "anderes geraet 2", "10.0.0.6") if err != nil { t.Fatalf("create other 2: %v", err) } if err := store.RevokeAllExcept(ctx, userID, currentID); err != nil { t.Fatalf("revoke all except: %v", err) } if _, err := store.Validate(ctx, currentToken); err != nil { t.Fatalf("aktuelle sitzung sollte weiterhin gueltig sein: %v", err) } if _, err := store.Validate(ctx, otherToken1); !errors.Is(err, ErrSessionInvalid) { t.Fatalf("andere sitzung 1 sollte widerrufen sein, habe %v", err) } if _, err := store.Validate(ctx, otherToken2); !errors.Is(err, ErrSessionInvalid) { t.Fatalf("andere sitzung 2 sollte widerrufen sein, habe %v", err) } remaining, err := store.ListForUser(ctx, userID) if err != nil { t.Fatalf("list: %v", err) } if len(remaining) != 1 || remaining[0].ID != currentID { t.Fatalf("erwartet genau die aktuelle sitzung uebrig, habe %+v", remaining) } } func TestLoginAndCreateSession(t *testing.T) { store, userStore, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() u, err := userStore.Get(ctx, userID) if err != nil { t.Fatalf("get user: %v", err) } hash, err := auth.HashPassword("korrektes-passwort") if err != nil { t.Fatalf("hash: %v", err) } if err := userStore.SetPasswordHash(ctx, userID, hash); err != nil { t.Fatalf("set password: %v", err) } token, err := LoginAndCreateSession(ctx, userStore, store, u.Email, "korrektes-passwort", "Test-Client", "127.0.0.1") if err != nil { t.Fatalf("login and create session: %v", err) } if _, err := store.Validate(ctx, token); err != nil { t.Fatalf("validate: %v", err) } if _, err := LoginAndCreateSession(ctx, userStore, store, u.Email, "falsches-passwort", "Test-Client", "127.0.0.1"); err == nil { t.Fatal("erwartet fehler bei falschem passwort") } }