package authtoken import ( "bytes" "context" "errors" "fmt" "log/slog" "os" "strings" "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 password_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), token_hash BYTEA NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('reset','invite')), expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } userStore := user.NewTenantUserStore(pool) email := fmt.Sprintf("reset-test-%d@example.com", time.Now().UnixNano()) u, err := userStore.Create(ctx, email, "Reset Test") if err != nil { t.Fatalf("testuser anlegen: %v", err) } cleanup := func() { pool.Close() } return NewStore(pool), userStore, u.ID, cleanup } // Akzeptanzkriterium 1 + Pruefung 1: Wiederverwendung eines bereits // eingeloesten Tokens wird abgewiesen. func TestConsume_RejectsReuse(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() token, err := store.Create(ctx, userID, PurposeReset, time.Hour) if err != nil { t.Fatalf("create: %v", err) } gotUserID, err := store.Consume(ctx, token, PurposeReset) if err != nil { t.Fatalf("erste einloesung: %v", err) } if gotUserID != userID { t.Fatalf("user id = %q, want %q", gotUserID, userID) } if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) { t.Fatalf("erwartet ErrInvalidToken bei zweiter einloesung, habe %v", err) } } // Akzeptanzkriterium 1 + 3 + Pruefung 2: Ablaufzeit wird serverseitig // durchgesetzt. func TestConsume_RejectsExpiredToken(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() token, err := store.Create(ctx, userID, PurposeReset, -1*time.Second) // sofort abgelaufen if err != nil { t.Fatalf("create: %v", err) } if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) { t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe %v", err) } } func TestConsume_RejectsUnknownToken(t *testing.T) { store, _, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if _, err := store.Consume(ctx, "niemals-ausgestelltes-token", PurposeReset); !errors.Is(err, ErrInvalidToken) { t.Fatalf("erwartet ErrInvalidToken, habe %v", err) } } func TestConsume_RejectsWrongPurpose(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() token, err := store.Create(ctx, userID, PurposeInvite, time.Hour) if err != nil { t.Fatalf("create: %v", err) } if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) { t.Fatalf("erwartet ErrInvalidToken bei falschem purpose, habe %v", err) } } // Akzeptanzkriterium 2: Einladungs-Link setzt das Erstpasswort ohne // bestehende Session — der Ablauf braucht keinerlei Auth-Middleware/Cookie. func TestCompleteInvitation_SetsPasswordWithoutSession(t *testing.T) { store, userStore, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() token, err := store.Create(ctx, userID, PurposeInvite, time.Hour) if err != nil { t.Fatalf("create: %v", err) } if err := CompleteInvitation(ctx, store, userStore, token, "neues-erstpasswort-123"); err != nil { t.Fatalf("complete invitation: %v", err) } u, err := userStore.Get(ctx, userID) if err != nil { t.Fatalf("user lesen: %v", err) } creds, err := userStore.GetByEmailForAuth(ctx, u.Email) if err != nil { t.Fatalf("credentials lesen: %v", err) } if !auth.VerifyPassword(creds.PasswordHash, "neues-erstpasswort-123") { t.Fatal("erwartet, dass das gesetzte erstpasswort verifizierbar ist") } // Token ist danach verbraucht, kann nicht doppelt eingeloest werden. if err := CompleteInvitation(ctx, store, userStore, token, "irgendwas"); !errors.Is(err, ErrInvalidToken) { t.Fatalf("erwartet ErrInvalidToken bei erneuter einloesung, habe %v", err) } } // Akzeptanzkriterium 3 + Pruefung 3: Token werden nicht im Klartext geloggt. func TestCreateAndConsume_NeverLogTokenPlaintext(t *testing.T) { store, _, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() var buf bytes.Buffer prevLogger := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) defer slog.SetDefault(prevLogger) token, err := store.Create(ctx, userID, PurposeReset, time.Hour) if err != nil { t.Fatalf("create: %v", err) } if _, err := store.Consume(ctx, "falsches-token-zum-testen-des-log-pfads", PurposeReset); !errors.Is(err, ErrInvalidToken) { t.Fatalf("consume (ungueltig): %v", err) } if _, err := store.Consume(ctx, token, PurposeReset); err != nil { t.Fatalf("consume (gueltig): %v", err) } logOutput := buf.String() if strings.Contains(logOutput, token) { t.Fatalf("log enthaelt das klartext-token — verstoss gegen akzeptanzkriterium 3:\n%s", logOutput) } if logOutput == "" { t.Fatal("erwartet ueberhaupt log-ausgabe fuer diesen stichprobentest") } }