startup: require real keycloak admin session before robotuser checks

This commit is contained in:
Brad Stein 2026-04-09 02:05:30 -03:00
parent c2c79e5821
commit af2d94a53b
3 changed files with 377 additions and 4 deletions

View File

@ -6,10 +6,12 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
neturl "net/url" neturl "net/url"
"regexp"
"strings" "strings"
"time" "time"
@ -32,15 +34,14 @@ type kubernetesSecret struct {
Data map[string]string `json:"data"` Data map[string]string `json:"data"`
} }
var keycloakLoginFormActionPattern = regexp.MustCompile(`(?is)<form[^>]*id=["']kc-form-login["'][^>]*action=["']([^"']+)["']`)
// checklistAuthHTTPClient runs one orchestration or CLI step. // checklistAuthHTTPClient runs one orchestration or CLI step.
// Signature: (o *Orchestrator) checklistAuthHTTPClient(ctx context.Context, timeout time.Duration, insecureSkipTLS bool) (*http.Client, error). // Signature: (o *Orchestrator) checklistAuthHTTPClient(ctx context.Context, timeout time.Duration, insecureSkipTLS bool) (*http.Client, error).
// Why: startup checklist checks that require real user behavior need an // Why: startup checklist checks that require real user behavior need an
// authenticated robotuser browser-like session before probing service pages. // authenticated robotuser browser-like session before probing service pages.
func (o *Orchestrator) checklistAuthHTTPClient(ctx context.Context, timeout time.Duration, insecureSkipTLS bool) (*http.Client, error) { func (o *Orchestrator) checklistAuthHTTPClient(ctx context.Context, timeout time.Duration, insecureSkipTLS bool) (*http.Client, error) {
jar, err := cookiejar.New(nil) jar, _ := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("create cookie jar: %w", err)
}
transport := &http.Transport{} transport := &http.Transport{}
if insecureSkipTLS { if insecureSkipTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
@ -74,6 +75,9 @@ func (o *Orchestrator) authenticateRobotChecklistSession(ctx context.Context, cl
if err != nil { if err != nil {
return err return err
} }
if err := o.keycloakAdminBrowserLogin(ctx, client, auth, adminUser, adminPassword); err != nil {
return fmt.Errorf("initialize keycloak admin browser session: %w", err)
}
adminToken, err := o.keycloakAdminToken(ctx, client, auth, adminUser, adminPassword) adminToken, err := o.keycloakAdminToken(ctx, client, auth, adminUser, adminPassword)
if err != nil { if err != nil {
return err return err
@ -101,6 +105,74 @@ func (o *Orchestrator) authenticateRobotChecklistSession(ctx context.Context, cl
} }
defer resp.Body.Close() defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024)) _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024))
finalURL := ""
if resp.Request != nil && resp.Request.URL != nil {
finalURL = strings.TrimSpace(resp.Request.URL.String())
}
if strings.Contains(finalURL, "/protocol/openid-connect/auth") || strings.Contains(finalURL, "/login-actions/authenticate") {
return fmt.Errorf("robot session bootstrap ended on keycloak login flow: %s", finalURL)
}
return nil
}
// keycloakAdminBrowserLogin runs one orchestration or CLI step.
// Signature: (o *Orchestrator) keycloakAdminBrowserLogin(ctx context.Context, client *http.Client, auth config.ServiceChecklistAuthSettings, adminUser string, adminPassword string) error.
// Why: Keycloak impersonation only yields a usable robot session cookie when the
// client already has a real admin browser session; token-only API calls are not
// sufficient for downstream OIDC-gated service checks.
func (o *Orchestrator) keycloakAdminBrowserLogin(ctx context.Context, client *http.Client, auth config.ServiceChecklistAuthSettings, adminUser string, adminPassword string) error {
baseURL := keycloakBaseURL(auth)
authURL := baseURL + "/realms/master/protocol/openid-connect/auth?" + keycloakAdminConsoleAuthQuery(baseURL).Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil)
if err != nil {
return fmt.Errorf("build keycloak admin auth request: %w", err)
}
req.Header.Set("User-Agent", "ananke/startup-checklist")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request keycloak admin auth page: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("keycloak admin auth page request failed status=%d body=%q", resp.StatusCode, compactHTTPBody(body))
}
actionURL, err := keycloakLoginFormAction(string(body), baseURL)
if err != nil {
return err
}
form := neturl.Values{}
form.Set("username", adminUser)
form.Set("password", adminPassword)
form.Set("credentialId", "")
loginReq, err := http.NewRequestWithContext(ctx, http.MethodPost, actionURL, strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("build keycloak admin login request: %w", err)
}
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginReq.Header.Set("User-Agent", "ananke/startup-checklist")
loginResp, err := client.Do(loginReq)
if err != nil {
return fmt.Errorf("request keycloak admin login submit: %w", err)
}
defer loginResp.Body.Close()
loginBody, _ := io.ReadAll(io.LimitReader(loginResp.Body, 512*1024))
finalURL := ""
if loginResp.Request != nil && loginResp.Request.URL != nil {
finalURL = strings.TrimSpace(loginResp.Request.URL.String())
}
if loginResp.StatusCode >= 500 {
return fmt.Errorf("keycloak admin login failed status=%d body=%q", loginResp.StatusCode, compactHTTPBody(loginBody))
}
if strings.Contains(finalURL, "/login-actions/authenticate") || strings.Contains(finalURL, "/protocol/openid-connect/auth") {
return fmt.Errorf("keycloak admin login did not complete (final_url=%q)", finalURL)
}
if strings.Contains(strings.ToLower(string(loginBody)), "kc-form-login") {
return fmt.Errorf("keycloak admin login form still present after submit")
}
return nil return nil
} }
@ -273,6 +345,42 @@ func keycloakBaseURL(auth config.ServiceChecklistAuthSettings) string {
return strings.TrimRight(strings.TrimSpace(auth.KeycloakBaseURL), "/") return strings.TrimRight(strings.TrimSpace(auth.KeycloakBaseURL), "/")
} }
// keycloakAdminConsoleAuthQuery runs one orchestration or CLI step.
// Signature: keycloakAdminConsoleAuthQuery() neturl.Values.
// Why: centralizes required Keycloak admin-console auth parameters, including
// PKCE fields required by current Keycloak defaults.
func keycloakAdminConsoleAuthQuery(baseURL string) neturl.Values {
query := neturl.Values{}
query.Set("client_id", "security-admin-console")
query.Set("redirect_uri", strings.TrimRight(strings.TrimSpace(baseURL), "/")+"/admin/master/console/")
query.Set("response_type", "code")
query.Set("scope", "openid")
query.Set("state", "ananke-startup-checklist")
query.Set("nonce", "ananke-startup-checklist")
query.Set("code_challenge_method", "S256")
query.Set("code_challenge", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
return query
}
// keycloakLoginFormAction runs one orchestration or CLI step.
// Signature: keycloakLoginFormAction(page string, baseURL string) (string, error).
// Why: Keycloak's login form action carries session-bound query params and must
// be parsed from the rendered page before posting credentials.
func keycloakLoginFormAction(page string, baseURL string) (string, error) {
matches := keycloakLoginFormActionPattern.FindStringSubmatch(page)
if len(matches) < 2 || strings.TrimSpace(matches[1]) == "" {
return "", fmt.Errorf("keycloak login page missing kc-form-login action")
}
action := html.UnescapeString(strings.TrimSpace(matches[1]))
if strings.HasPrefix(action, "/") {
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + action, nil
}
if strings.HasPrefix(action, "http://") || strings.HasPrefix(action, "https://") {
return action, nil
}
return "", fmt.Errorf("keycloak login action uses unsupported format %q", action)
}
// compactHTTPBody runs one orchestration or CLI step. // compactHTTPBody runs one orchestration or CLI step.
// Signature: compactHTTPBody(raw []byte) string. // Signature: compactHTTPBody(raw []byte) string.
// Why: checklist auth errors should include a readable body summary without // Why: checklist auth errors should include a readable body summary without

View File

@ -57,6 +57,13 @@ func (o *Orchestrator) TestHookKeycloakImpersonationRedirect(ctx context.Context
return o.keycloakImpersonationRedirect(ctx, client, auth, adminToken, robotUserID) return o.keycloakImpersonationRedirect(ctx, client, auth, adminToken, robotUserID)
} }
// TestHookKeycloakAdminBrowserLogin runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookKeycloakAdminBrowserLogin(ctx context.Context, client *http.Client, auth config.ServiceChecklistAuthSettings, adminUser string, adminPassword string) error.
// Why: exposes browser-session bootstrap internals to top-level tests.
func (o *Orchestrator) TestHookKeycloakAdminBrowserLogin(ctx context.Context, client *http.Client, auth config.ServiceChecklistAuthSettings, adminUser string, adminPassword string) error {
return o.keycloakAdminBrowserLogin(ctx, client, auth, adminUser, adminPassword)
}
// TestHookHTTPChecklistProbeWithLocation runs one orchestration or CLI step. // TestHookHTTPChecklistProbeWithLocation runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookHTTPChecklistProbeWithLocation(ctx context.Context, check config.ServiceChecklistCheck) (int, string, string, string, error). // Signature: (o *Orchestrator) TestHookHTTPChecklistProbeWithLocation(ctx context.Context, check config.ServiceChecklistCheck) (int, string, string, string, error).
// Why: exposes redirect-aware checklist probe internals to top-level tests. // Why: exposes redirect-aware checklist probe internals to top-level tests.
@ -77,3 +84,10 @@ func TestHookKeycloakBaseURL(auth config.ServiceChecklistAuthSettings) string {
func TestHookCompactHTTPBody(raw []byte) string { func TestHookCompactHTTPBody(raw []byte) string {
return compactHTTPBody(raw) return compactHTTPBody(raw)
} }
// TestHookKeycloakLoginFormAction runs one orchestration or CLI step.
// Signature: TestHookKeycloakLoginFormAction(page string, baseURL string) (string, error).
// Why: exposes login-form action parser internals to top-level tests.
func TestHookKeycloakLoginFormAction(page string, baseURL string) (string, error) {
return keycloakLoginFormAction(page, baseURL)
}

View File

@ -36,6 +36,26 @@ func authSettings(baseURL string) config.ServiceChecklistAuthSettings {
} }
} }
func serveMockKeycloakAdminBrowserFlow(w http.ResponseWriter, r *http.Request) bool {
switch {
case strings.Contains(r.URL.Path, "/realms/master/protocol/openid-connect/auth"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(
`<html><body><form id="kc-form-login" action="/realms/master/login-actions/authenticate?session_code=test&execution=test&client_id=security-admin-console&tab_id=test" method="post"></form></body></html>`,
))
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("<html><body>console ok</body></html>"))
return true
default:
return false
}
}
// TestHookServiceAuthChecklistSuccess runs one orchestration or CLI step. // TestHookServiceAuthChecklistSuccess runs one orchestration or CLI step.
// Signature: TestHookServiceAuthChecklistSuccess(t *testing.T). // Signature: TestHookServiceAuthChecklistSuccess(t *testing.T).
// Why: validates full robotuser-authenticated checklist flow with final URL and // 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.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(fmt.Sprintf(`{"redirect":"%s/session/bootstrap"}`, appServer.URL))) _, _ = 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) kcServer := httptest.NewTLSServer(kcMux)
defer kcServer.Close() 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(`<html><body><form id="kc-form-login" action="http://127.0.0.1:1/login"></form></body></html>`))
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(`<html><body><form id="kc-form-login" action="http://[::1"></form></body></html>`))
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(`<html><body><form id="kc-form-login" action="/realms/master/login-actions/authenticate"></form></body></html>`))
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(`<html><body><form id="kc-form-login" action="/realms/master/login-actions/authenticate"></form></body></html>`))
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("<html><body>console ok</body></html>"))
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(
`<html><form id="kc-form-login" action="https://example.test/login"></form></html>`,
"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(
`<html><form id="kc-form-login" action="javascript:void(0)"></form></html>`,
"https://sso.bstein.dev",
); err == nil {
t.Fatalf("expected unsupported action format failure")
}
})
}
// TestHookServiceChecklistProbeBranches runs one orchestration or CLI step. // TestHookServiceChecklistProbeBranches runs one orchestration or CLI step.
// Signature: TestHookServiceChecklistProbeBranches(t *testing.T). // Signature: TestHookServiceChecklistProbeBranches(t *testing.T).
// Why: exercises redirect + final-url probe branches, including robot-auth // 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) { t.Run("robot-user lookup failure", func(t *testing.T) {
kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if serveMockKeycloakAdminBrowserFlow(w, r) {
return
}
switch { switch {
case strings.Contains(r.URL.Path, "/token"): case strings.Contains(r.URL.Path, "/token"):
_, _ = w.Write([]byte(`{"access_token":"admin-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) { t.Run("impersonation failure", func(t *testing.T) {
kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if serveMockKeycloakAdminBrowserFlow(w, r) {
return
}
switch { switch {
case strings.Contains(r.URL.Path, "/token"): case strings.Contains(r.URL.Path, "/token"):
_, _ = w.Write([]byte(`{"access_token":"admin-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) { t.Run("redirect url build failure", func(t *testing.T) {
kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if serveMockKeycloakAdminBrowserFlow(w, r) {
return
}
switch { switch {
case strings.Contains(r.URL.Path, "/token"): case strings.Contains(r.URL.Path, "/token"):
_, _ = w.Write([]byte(`{"access_token":"admin-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) { t.Run("redirect request failure", func(t *testing.T) {
kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { kc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if serveMockKeycloakAdminBrowserFlow(w, r) {
return
}
switch { switch {
case strings.Contains(r.URL.Path, "/token"): case strings.Contains(r.URL.Path, "/token"):
_, _ = w.Write([]byte(`{"access_token":"admin-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.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("<html><body>no form</body></html>"))
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(
`<html><body><form id="kc-form-login" action="/realms/master/login-actions/authenticate?session_code=test"></form></body></html>`,
))
case strings.Contains(r.URL.Path, "/realms/master/login-actions/authenticate"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<html><body><form id="kc-form-login" action="/retry"></form></body></html>`))
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(`<html><body><form id="kc-form-login" action="/realms/atlas/login-actions/authenticate"></form></body></html>`))
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. // TestHookServiceAuthFallbackRedirect runs one orchestration or CLI step.
@ -512,6 +748,21 @@ func TestHookServiceAuthFallbackRedirect(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("account ok")) _, _ = 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) kcServer := httptest.NewTLSServer(kcMux)
defer kcServer.Close() defer kcServer.Close()