diff --git a/internal/cluster/orchestrator_service_auth.go b/internal/cluster/orchestrator_service_auth.go index 537055e..81f7845 100644 --- a/internal/cluster/orchestrator_service_auth.go +++ b/internal/cluster/orchestrator_service_auth.go @@ -6,10 +6,12 @@ import ( "encoding/base64" "encoding/json" "fmt" + "html" "io" "net/http" "net/http/cookiejar" neturl "net/url" + "regexp" "strings" "time" @@ -32,15 +34,14 @@ type kubernetesSecret struct { Data map[string]string `json:"data"` } +var keycloakLoginFormActionPattern = regexp.MustCompile(`(?is)
`, + )) + return true + case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"): + http.Redirect(w, r, "/admin/master/console/", http.StatusFound) + return true + case strings.Contains(r.URL.Path, "/admin/master/console/"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("console ok")) + return true + default: + return false + } +} + // TestHookServiceAuthChecklistSuccess runs one orchestration or CLI step. // Signature: TestHookServiceAuthChecklistSuccess(t *testing.T). // Why: validates full robotuser-authenticated checklist flow with final URL and @@ -86,6 +106,21 @@ func TestHookServiceAuthChecklistSuccess(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(fmt.Sprintf(`{"redirect":"%s/session/bootstrap"}`, appServer.URL))) }) + kcMux.HandleFunc("/realms/master/protocol/openid-connect/auth", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) + kcMux.HandleFunc("/realms/master/login-actions/authenticate", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) + kcMux.HandleFunc("/admin/master/console/", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) kcServer := httptest.NewTLSServer(kcMux) defer kcServer.Close() @@ -343,6 +378,122 @@ func TestHookServiceAuthHTTPErrorBranches(t *testing.T) { } } +// TestHookKeycloakAdminBrowserLoginAndParserBranches runs one orchestration or CLI step. +// Signature: TestHookKeycloakAdminBrowserLoginAndParserBranches(t *testing.T). +// Why: covers admin-browser bootstrap and form-action parser branches that +// drive robotuser-authenticated startup checks. +func TestHookKeycloakAdminBrowserLoginAndParserBranches(t *testing.T) { + cfg := lifecycleConfig(t) + orch, _ := newHookOrchestrator(t, cfg, nil, nil) + client := &http.Client{Timeout: 2 * time.Second} + + t.Run("invalid base url build failure", func(t *testing.T) { + auth := authSettings("://bad-url") + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, auth, "admin", "pw"); err == nil { + t.Fatalf("expected browser login request-build failure") + } + }) + + t.Run("auth page non-2xx", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth") { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("bad gateway")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err == nil { + t.Fatalf("expected non-2xx auth page failure") + } + }) + + t.Run("login submit request failure", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err == nil { + t.Fatalf("expected login submit request failure") + } + }) + + t.Run("build login request failure", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err == nil { + t.Fatalf("expected login request-build failure") + } + }) + + t.Run("login submit returns 5xx", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"): + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err == nil { + t.Fatalf("expected login 5xx branch") + } + }) + + t.Run("login success path", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + _, _ = w.Write([]byte(``)) + case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"): + http.Redirect(w, r, "/admin/master/console/", http.StatusFound) + case strings.Contains(r.URL.Path, "/admin/master/console/"): + _, _ = w.Write([]byte("console ok")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + if err := orch.TestHookKeycloakAdminBrowserLogin(context.Background(), client, authSettings(kc.URL), "admin", "pw"); err != nil { + t.Fatalf("expected browser login success, got %v", err) + } + }) + + t.Run("form action parser absolute and invalid", func(t *testing.T) { + action, err := cluster.TestHookKeycloakLoginFormAction( + ``, + "https://sso.bstein.dev", + ) + if err != nil || action != "https://example.test/login" { + t.Fatalf("expected absolute action parse success, action=%q err=%v", action, err) + } + if _, err := cluster.TestHookKeycloakLoginFormAction( + ``, + "https://sso.bstein.dev", + ); err == nil { + t.Fatalf("expected unsupported action format failure") + } + }) +} + // TestHookServiceChecklistProbeBranches runs one orchestration or CLI step. // Signature: TestHookServiceChecklistProbeBranches(t *testing.T). // Why: exercises redirect + final-url probe branches, including robot-auth @@ -403,6 +554,9 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Run("robot-user lookup failure", func(t *testing.T) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } switch { case strings.Contains(r.URL.Path, "/token"): _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) @@ -424,6 +578,9 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Run("impersonation failure", func(t *testing.T) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } switch { case strings.Contains(r.URL.Path, "/token"): _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) @@ -447,6 +604,9 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Run("redirect url build failure", func(t *testing.T) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } switch { case strings.Contains(r.URL.Path, "/token"): _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) @@ -469,6 +629,9 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Run("redirect request failure", func(t *testing.T) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } switch { case strings.Contains(r.URL.Path, "/token"): _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) @@ -488,6 +651,79 @@ func TestHookAuthenticateRobotChecklistSessionFailureStages(t *testing.T) { t.Fatalf("expected redirect request failure branch") } }) + + t.Run("admin login page missing form action", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("no form")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + cfg := lifecycleConfig(t) + cfg.Startup.ServiceChecklistAuth = authSettings(kc.URL) + orch, _ := newHookOrchestrator(t, cfg, secretRun, secretRun) + if err := orch.TestHookAuthenticateRobotChecklistSession(context.Background(), client); err == nil { + t.Fatalf("expected admin login form parse failure branch") + } + }) + + t.Run("admin login remains on login form", func(t *testing.T) { + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte( + ``, + )) + case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + cfg := lifecycleConfig(t) + cfg.Startup.ServiceChecklistAuth = authSettings(kc.URL) + orch, _ := newHookOrchestrator(t, cfg, secretRun, secretRun) + if err := orch.TestHookAuthenticateRobotChecklistSession(context.Background(), client); err == nil { + t.Fatalf("expected admin login failure branch when login form persists") + } + }) + + t.Run("robot bootstrap ends on keycloak login flow", func(t *testing.T) { + baseURL := "" + kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + switch { + case strings.Contains(r.URL.Path, "/token"): + _, _ = w.Write([]byte(`{"access_token":"admin-token"}`)) + case strings.Contains(r.URL.Path, "/users"): + _, _ = w.Write([]byte(`[{"id":"robot-id"}]`)) + case strings.Contains(r.URL.Path, "/impersonation"): + _, _ = w.Write([]byte(fmt.Sprintf(`{"redirect":"%s/realms/atlas/protocol/openid-connect/auth?client_id=logs"}`, baseURL))) + case strings.Contains(r.URL.Path, "/realms/atlas/protocol/openid-connect/auth"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer kc.Close() + baseURL = kc.URL + cfg := lifecycleConfig(t) + cfg.Startup.ServiceChecklistAuth = authSettings(kc.URL) + orch, _ := newHookOrchestrator(t, cfg, secretRun, secretRun) + if err := orch.TestHookAuthenticateRobotChecklistSession(context.Background(), client); err == nil { + t.Fatalf("expected robot bootstrap final-url auth-flow failure") + } + }) } // TestHookServiceAuthFallbackRedirect runs one orchestration or CLI step. @@ -512,6 +748,21 @@ func TestHookServiceAuthFallbackRedirect(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("account ok")) }) + kcMux.HandleFunc("/realms/master/protocol/openid-connect/auth", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) + kcMux.HandleFunc("/realms/master/login-actions/authenticate", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) + kcMux.HandleFunc("/admin/master/console/", func(w http.ResponseWriter, r *http.Request) { + if serveMockKeycloakAdminBrowserFlow(w, r) { + return + } + }) kcServer := httptest.NewTLSServer(kcMux) defer kcServer.Close()