Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/keycloak-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ jobs:
python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
import json
s = json.load(open("report.json")).get("summary", {})
for k in ("total", "passed", "failed", "skipped", "error"):
for k in ("total", "passed", "failed", "xfailed", "skipped", "error"):
if k in s:
print(f"- **{k}**: {s[k]}")
PY
Expand Down
34 changes: 34 additions & 0 deletions locators/consumer/keycloak_auth_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,29 @@
from selenium.webdriver.common.by import By


class KeycloakShellLocators:
"""Chrome shared by every page CustomTemplate renders."""

HEADING = (By.CSS_SELECTOR, ".civic-auth-header h1")
SUBTITLE = (By.CSS_SELECTOR, ".civic-auth-header .auth-subtitle")
CARD = (By.CSS_SELECTOR, ".card-pf")
BACK_TO_HOME = (By.CSS_SELECTOR, ".civic-form-column > a.back-home")
LEGAL_LINKS = (By.CSS_SELECTOR, ".civic-legal-links a")
ALERT = (By.CSS_SELECTOR, ".pf-c-alert")
FIELD_ERROR = (By.CSS_SELECTOR, ".civic-field-error")
ANY_LINK = (By.TAG_NAME, "a")


class KeycloakLoginLocators:
USERNAME = (By.ID, "username")
PASSWORD = (By.ID, "password")
SUBMIT = (By.CSS_SELECTOR, "input[type=submit], button[type=submit]")
USERNAME_LABEL = (By.CSS_SELECTOR, "label[for=username]")
PASSWORD_LABEL = (By.CSS_SELECTOR, "label[for=password]")
PASSWORD_TOGGLE = (By.CSS_SELECTOR, "button[aria-controls=password]")
FORGOT_LINK = (By.CSS_SELECTOR, "a[href*='login-actions/reset-credentials']")
REGISTER_PROMPT = (By.ID, "kc-registration")
GOOGLE_ICON = (By.CSS_SELECTOR, "a[href*='/broker/google/login'] svg")

# "Continue With Google" - an <a> to the broker endpoint, not a form post.
GOOGLE_BROKER_LINK = (By.CSS_SELECTOR, "a[href*='/broker/google/login']")
Expand All @@ -21,10 +40,25 @@ class KeycloakLoginLocators:
PRIVACY_LINK = (By.CSS_SELECTOR, "a[href*='/privacy']")


class KeycloakResetLocators:
USERNAME = (By.ID, "username")
SUBMIT = (By.CSS_SELECTOR, "#kc-reset-password-form input[type=submit]")
BACK_TO_SIGN_IN = (By.CSS_SELECTOR, "#kc-registration a")


class KeycloakErrorLocators:
TRY_AGAIN = (By.ID, "kc-try-again")


class KeycloakRegisterLocators:
EMAIL = (By.ID, "email")
FIRST_NAME = (By.ID, "firstName")
LAST_NAME = (By.ID, "lastName")
PASSWORD = (By.ID, "password")
SIGN_IN_PROMPT = (By.ID, "kc-registration")
GOOGLE_BROKER_LINK = (By.CSS_SELECTOR, "a[href*='/broker/google/login']")
TERMS_ROW = (By.CSS_SELECTOR, ".civic-terms")
TERMS_ERROR = (By.ID, "input-error-termsAccepted")

# The privacy consent checkbox. Note it carries required=false in the DOM,
# so nothing about the markup guarantees consent is enforced - the tests
Expand Down
256 changes: 230 additions & 26 deletions pages/consumer/keycloak_auth_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
from selenium.webdriver.support.ui import WebDriverWait

from locators.consumer.keycloak_auth_locators import (
KeycloakErrorLocators,
KeycloakLoginLocators,
KeycloakRegisterLocators,
KeycloakResetLocators,
KeycloakShellLocators,
)
from pages.base_page import BasePage

Expand All @@ -28,17 +31,103 @@ def _kc_settings():
)


def _auth_url(endpoint, app_base_url):
kc_url, realm, client_id = _kc_settings()
redirect = f"{app_base_url.rstrip('/')}{CALLBACK_PATH}"
def _auth_url(endpoint, app_base_url, redirect_uri=None, client_id=None):
kc_url, realm, default_client = _kc_settings()
redirect = redirect_uri or f"{app_base_url.rstrip('/')}{CALLBACK_PATH}"
return (
f"{kc_url}/realms/{realm}/protocol/openid-connect/{endpoint}"
f"?client_id={client_id}&response_type=code&scope=openid"
f"?client_id={client_id or default_client}&response_type=code&scope=openid"
f"&state=qa&nonce=qa&redirect_uri={redirect}"
)


class KeycloakLoginPage(BasePage):
def _squash(text):
return " ".join((text or "").split())


class _KeycloakPage(BasePage):
"""Helpers for any page rendered inside the theme's CustomTemplate."""

def heading(self):
self._await(KeycloakShellLocators.HEADING)
return self._text(KeycloakShellLocators.HEADING)

def subtitle(self):
return self._text(KeycloakShellLocators.SUBTITLE)

def back_to_home_href(self):
el = self._find(KeycloakShellLocators.BACK_TO_HOME)
return el.get_attribute("href") if el else None

def legal_link_texts(self):
return [_squash(e.text) for e in self.driver.find_elements(*KeycloakShellLocators.LEGAL_LINKS)]

def visible_legal_link_texts(self):
"""Privacy/Terms/Legal links a user can actually see at this viewport."""
return [
_squash(e.text)
for e in self.driver.find_elements(*KeycloakShellLocators.ANY_LINK)
if e.is_displayed() and _squash(e.text).lower() in ("privacy", "terms", "legal")
]

def field_errors(self):
return [
_squash(e.text)
for e in self.driver.find_elements(*KeycloakShellLocators.FIELD_ERROR)
if _squash(e.text)
]

def alert_text(self):
return self._text(KeycloakShellLocators.ALERT)

def card_text(self):
return self._text(KeycloakShellLocators.CARD)

def use_mobile_viewport(self, width=390, height=844):
self.driver.set_window_size(width, height)
return self

def has_horizontal_scroll(self):
return self.driver.execute_script(
"return document.documentElement.scrollWidth > window.innerWidth"
)

def mark_document(self):
"""Tag the live document so a later check can tell if it was replaced.

A server round trip loads a new document and drops the tag; client-side
validation leaves it in place.
"""
self.driver.execute_script("window.__qaMarker = true;")

def document_was_replaced(self):
return not self.driver.execute_script("return window.__qaMarker === true;")

def _wait_ready(self, timeout=20):
WebDriverWait(self.driver, timeout).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)

def _text(self, locator):
el = self._find(locator)
return _squash(el.text) if el else ""

def _await(self, locator, timeout=20):
WebDriverWait(self.driver, timeout).until(EC.presence_of_element_located(locator))

def _exists(self, locator, timeout=15):
try:
self._await(locator, timeout)
return True
except Exception:
return False

def _find(self, locator):
els = self.driver.find_elements(*locator)
return els[0] if els else None


class KeycloakLoginPage(_KeycloakPage):
"""The Keycloak sign-in page users reach from the app."""

def load(self, app_base_url):
Expand All @@ -53,28 +142,129 @@ def google_login_href(self):
el = self._find(KeycloakLoginLocators.GOOGLE_BROKER_LINK)
return el.get_attribute("href") if el else None

def google_button_text(self):
return self._text(KeycloakLoginLocators.GOOGLE_BROKER_LINK)

def google_button_has_icon(self):
return self._find(KeycloakLoginLocators.GOOGLE_ICON) is not None

def has_register_link(self):
return self._exists(KeycloakLoginLocators.REGISTER_LINK)

def register_prompt_text(self):
return self._text(KeycloakLoginLocators.REGISTER_PROMPT)

def privacy_hrefs(self):
return [e.get_attribute("href") for e in self.driver.find_elements(*KeycloakLoginLocators.PRIVACY_LINK)]

def _await(self, locator, timeout=20):
WebDriverWait(self.driver, timeout).until(EC.presence_of_element_located(locator))
def label_texts(self):
return (
self._text(KeycloakLoginLocators.USERNAME_LABEL),
self._text(KeycloakLoginLocators.PASSWORD_LABEL),
)

def _exists(self, locator, timeout=15):
def placeholders(self):
return (
self._find(KeycloakLoginLocators.USERNAME).get_attribute("placeholder"),
self._find(KeycloakLoginLocators.PASSWORD).get_attribute("placeholder"),
)

def forgot_link_text(self):
return self._text(KeycloakLoginLocators.FORGOT_LINK)

def password_state(self):
"""(input type, toggle aria-label) for the password field."""
toggle = self._find(KeycloakLoginLocators.PASSWORD_TOGGLE)
return (
self._find(KeycloakLoginLocators.PASSWORD).get_attribute("type"),
toggle.get_attribute("aria-label") if toggle else None,
)

def toggle_password(self):
self._find(KeycloakLoginLocators.PASSWORD_TOGGLE).click()
return self

def type_email(self, email):
el = self._find(KeycloakLoginLocators.USERNAME)
el.clear()
el.send_keys(email)
return self

def sign_in(self, email, password):
"""Submit credentials. Only ever called with an address that has no account."""
self.type_email(email)
self._find(KeycloakLoginLocators.PASSWORD).send_keys(password)
return self.submit()

def submit(self):
self.mark_document()
self._find(KeycloakLoginLocators.SUBMIT).click()
self._wait_ready()
self._await(KeycloakLoginLocators.USERNAME)
return self

def go_to_forgot_password(self):
self._find(KeycloakLoginLocators.FORGOT_LINK).click()
WebDriverWait(self.driver, 20).until(lambda d: "reset-credentials" in d.current_url)
return KeycloakResetPage(self.driver).wait_loaded()

def follow_google(self, timeout=20):
"""Click the Google button and return the host it lands on. Never signs in."""
kc_host = _kc_settings()[0].split("/")[2]
self._find(KeycloakLoginLocators.GOOGLE_BROKER_LINK).click()
try:
self._await(locator, timeout)
return True
except Exception:
return False
WebDriverWait(self.driver, timeout).until(
lambda d: d.current_url.split("/")[2] != kc_host
)
except TimeoutException:
pass
return self.driver.current_url.split("/")[2]

def _find(self, locator):
els = self.driver.find_elements(*locator)
return els[0] if els else None

class KeycloakResetPage(_KeycloakPage):
""""Reset your password" - reached from the sign-in page's forgot link."""

def wait_loaded(self):
self._await(KeycloakResetLocators.USERNAME)
return self

class KeycloakRegisterPage(BasePage):
def email_value(self):
return self._find(KeycloakResetLocators.USERNAME).get_attribute("value")

def submit_label(self):
return self._find(KeycloakResetLocators.SUBMIT).get_attribute("value")

def back_to_sign_in_text(self):
return self._text(KeycloakResetLocators.BACK_TO_SIGN_IN)

def request_reset(self, email):
"""Submit the form. Only ever called with an address that has no account."""
el = self._find(KeycloakResetLocators.USERNAME)
el.clear()
if email:
el.send_keys(email)
self.mark_document()
self._find(KeycloakResetLocators.SUBMIT).click()
self._wait_ready()
self._await(KeycloakShellLocators.HEADING)
return self


class KeycloakErrorPage(_KeycloakPage):
"""error.ftl, reached by starting a login the realm refuses."""

def load_with_redirect(self, app_base_url, redirect_uri):
self.driver.get(_auth_url("auth", app_base_url, redirect_uri=redirect_uri))
self._await(KeycloakErrorLocators.TRY_AGAIN)
return self

def load_with_client(self, app_base_url, client_id):
self.driver.get(_auth_url("auth", app_base_url, client_id=client_id))
self._await(KeycloakErrorLocators.TRY_AGAIN)
return self


class KeycloakRegisterPage(_KeycloakPage):
"""The Keycloak registration page, including the privacy consent checkbox."""

def load(self, app_base_url):
Expand Down Expand Up @@ -169,16 +359,30 @@ def body_text(self, timeout=15):
pass
return self.driver.find_element("tag name", "body").text

def _await(self, locator, timeout=20):
WebDriverWait(self.driver, timeout).until(EC.presence_of_element_located(locator))
def sign_in_prompt_text(self):
return self._text(KeycloakRegisterLocators.SIGN_IN_PROMPT)

def _exists(self, locator, timeout=15):
try:
self._await(locator, timeout)
return True
except Exception:
def has_google_button(self):
return self._exists(KeycloakRegisterLocators.GOOGLE_BROKER_LINK, timeout=5)

def terms_text(self):
return self._text(KeycloakRegisterLocators.TERMS_ROW)

def terms_error_text(self):
return self._text(KeycloakRegisterLocators.TERMS_ERROR)

def type_password(self, text):
"""Type into the password field. False when the realm renders none."""
el = self._find(KeycloakRegisterLocators.PASSWORD)
if el is None:
return False
el.click()
el.send_keys(text)
return True

def _find(self, locator):
els = self.driver.find_elements(*locator)
return els[0] if els else None
def submit_without_consent(self):
"""Click Create Account with consent unticked; the theme should block it."""
self.mark_document()
self._find(KeycloakRegisterLocators.SUBMIT).click()
self._wait_ready()
return self
Loading
Loading