package handlers import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "git.netcell-it.de/projekte/edgeguard-native/internal/database" oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc" "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) // mockAuth erfüllt oidcsvc.Authenticator und liefert vorgegebene Claims — // kein echter IdP nötig. type mockAuth struct { claims *oidcsvc.Claims err error } func (m *mockAuth) AuthCodeURL(_ context.Context, _, state, _, _ string) (string, error) { return "https://idp.example/authorize?state=" + state, nil } func (m *mockAuth) Exchange(_ context.Context, _, _, _ string) (*oidcsvc.Claims, error) { return m.claims, m.err } func oidcTestSetup(t *testing.T) (*usersvc.Repo, *pgxpool.Pool, *session.Signer) { t.Helper() dsn := os.Getenv("EG_FWTEST_DSN") if dsn == "" { t.Skip("set EG_FWTEST_DSN to run the oidc handler test") } ctx := context.Background() // Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere // guarded Test-Pakete dieselbe frische DB parallel migrieren. var mErr error for i := 0; i < 3; i++ { if mErr = database.Migrate(ctx, dsn); mErr == nil { break } time.Sleep(700 * time.Millisecond) } if mErr != nil { t.Fatalf("migrate: %v", mErr) } pool, err := database.Open(ctx, dsn) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(pool.Close) return usersvc.New(pool), pool, session.NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, 0) } func seedUser(t *testing.T, repo *usersvc.Repo, pool *pgxpool.Pool, email, role string, active bool) { t.Helper() ctx := context.Background() _, _ = pool.Exec(ctx, `DELETE FROM users WHERE email=$1`, email) if _, err := repo.Create(ctx, email, "Sup3rSecret-pw-123", role, active); err != nil { t.Fatalf("seed user: %v", err) } } func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code string) *httptest.ResponseRecorder { t.Helper() gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) blob, _ := json.Marshal(flow) signed, err := h.Signer.SignBlob(blob, oidcFlowTTL) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil) req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant c.Request = req h.Callback(c) return rec } func sessionCookie(rec *httptest.ResponseRecorder) string { for _, ck := range rec.Result().Cookies() { if ck.Name == cookieName && ck.Value != "" && ck.MaxAge >= 0 { return ck.Value } } return "" } func TestCallback_KnownActiveUser_RoleFromDB(t *testing.T) { users, pool, signer := oidcTestSetup(t) seedUser(t, users, pool, "sso-viewer@test.local", "viewer", true) h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ Email: "sso-viewer@test.local", EmailVerified: true, Subject: "sub-1", Nonce: "N", }}, users, signer, nil) rec := runCallback(t, h, oidcFlow{State: "S", Verifier: "v", Nonce: "N"}, "S", "code") if loc := rec.Header().Get("Location"); loc != "/dashboard" { t.Fatalf("expected redirect to /dashboard, got %q (body proves failure path)", loc) } raw := sessionCookie(rec) if raw == "" { t.Fatal("expected a session cookie to be set") } tok, err := signer.Verify(raw) if err != nil { t.Fatalf("session token invalid: %v", err) } // Kernbeweis: Rolle kommt aus der DB-Row (viewer), nicht aus Claims. if tok.Role != "viewer" { t.Errorf("token role = %q, want viewer (role must come from DB)", tok.Role) } if tok.Actor != "sso-viewer@test.local" { t.Errorf("token actor = %q", tok.Actor) } } func TestCallback_UnknownEmail_Rejected(t *testing.T) { users, pool, signer := oidcTestSetup(t) _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE email=$1`, "ghost@test.local") h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ Email: "ghost@test.local", EmailVerified: true, Subject: "x", Nonce: "N", }}, users, signer, nil) rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code") if !strings.Contains(rec.Header().Get("Location"), "sso_error=no_account") { t.Fatalf("expected sso_error=no_account, got %q", rec.Header().Get("Location")) } if sessionCookie(rec) != "" { t.Fatal("no session cookie expected for unknown user") } } func TestCallback_InactiveUser_Rejected(t *testing.T) { users, pool, signer := oidcTestSetup(t) seedUser(t, users, pool, "sso-disabled@test.local", "admin", false) h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ Email: "sso-disabled@test.local", EmailVerified: true, Subject: "x", Nonce: "N", }}, users, signer, nil) rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code") if !strings.Contains(rec.Header().Get("Location"), "sso_error=disabled") { t.Fatalf("expected sso_error=disabled, got %q", rec.Header().Get("Location")) } if sessionCookie(rec) != "" { t.Fatal("no session cookie expected for inactive user") } } func TestCallback_EmailUnverified_Rejected(t *testing.T) { users, pool, signer := oidcTestSetup(t) seedUser(t, users, pool, "sso-unverified@test.local", "admin", true) h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ Email: "sso-unverified@test.local", EmailVerified: false, Subject: "x", Nonce: "N", }}, users, signer, nil) rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code") if !strings.Contains(rec.Header().Get("Location"), "sso_error=unverified") { t.Fatalf("expected sso_error=unverified, got %q", rec.Header().Get("Location")) } if sessionCookie(rec) != "" { t.Fatal("no session cookie expected for unverified email") } } func TestCallback_NonceMismatch_Rejected(t *testing.T) { users, pool, signer := oidcTestSetup(t) seedUser(t, users, pool, "sso-nonce@test.local", "admin", true) h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{ Email: "sso-nonce@test.local", EmailVerified: true, Subject: "x", Nonce: "WRONG", }}, users, signer, nil) rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code") if !strings.Contains(rec.Header().Get("Location"), "sso_error=nonce") { t.Fatalf("expected sso_error=nonce, got %q", rec.Header().Get("Location")) } } func TestCallback_StateMismatch_Rejected(t *testing.T) { users, _, signer := oidcTestSetup(t) h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{}}, users, signer, nil) rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "WRONG", "code") if !strings.Contains(rec.Header().Get("Location"), "sso_error=state") { t.Fatalf("expected sso_error=state, got %q", rec.Header().Get("Location")) } }