Skip to content

Commit

Permalink
Re-add Selenium tests for the UI
Browse files Browse the repository at this point in the history
Simplify the Selenium tests and exercise the create token UI.
Add some id and class attributes to the frontend so that Selenium
can find the necessary elements.  Change the GitHub Actions
workflow to install the JavaScript dependencies and build the UI
before running tests.
  • Loading branch information
rra committed Jan 4, 2021
1 parent ef65bb1 commit b394ba5
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 86 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
run: npm ci
working-directory: ./ui

- name: Build the UI
run: make ui

- name: Install tox
run: pip install tox

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ update: update-deps init
.PHONY: ui
ui:
cd ui && npm run lint:fix
cd ui && gatsby build --prefix-paths
cd ui && node_modules/.bin/gatsby build --prefix-paths
14 changes: 9 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import pytest

from gafaelfawr.dependencies.config import config_dependency
from tests.support.constants import TEST_HOSTNAME
from tests.support.selenium import run_app, selenium_driver
from tests.support.settings import build_settings
Expand All @@ -18,6 +19,8 @@
from pytest_httpx import HTTPXMock
from seleniumwire import webdriver

from tests.support.selenium import SeleniumConfig


@pytest.fixture(scope="session")
def driver() -> Iterator[webdriver.Chrome]:
Expand All @@ -42,22 +45,23 @@ def non_mocked_hosts() -> List[str]:


@pytest.fixture
def selenium_server_url(tmp_path: Path) -> Iterable[str]:
def selenium_config(tmp_path: Path) -> Iterable[SeleniumConfig]:
"""Start a server for Selenium tests.
The server will be automatically stopped at the end of the test.
Returns
-------
server_url : `str`
The URL to use to contact that server.
config : `tests.support.selenium.SeleniumConfig`
Configuration information for the server.
"""
database_url = "sqlite:///" + str(tmp_path / "gafaelfawr.sqlite")
settings_path = build_settings(
tmp_path, "selenium", database_url=database_url
)
with run_app(tmp_path, settings_path) as server_url:
yield server_url
config_dependency.set_settings_path(str(settings_path))
with run_app(tmp_path, settings_path) as config:
yield config


@pytest.fixture
Expand Down
1 change: 0 additions & 1 deletion tests/handlers/auth_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ async def test_no_auth(setup: SetupTest) -> None:
@pytest.mark.asyncio
async def test_invalid(setup: SetupTest) -> None:
token = await setup.create_session_token()
print(token.token)
r = await setup.client.get(
"/auth", headers={"Authorization": f"bearer {token.token}"}
)
Expand Down
8 changes: 8 additions & 0 deletions tests/pages/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ def find_elements_by_class_name(self, name: str) -> List[WebElement]:
def find_element_by_id(self, id_: str) -> WebElement:
return self.root.find_element_by_id(id_)

def find_element_by_tag_name(self, tag: str) -> WebElement:
return self.root.find_element_by_tag_name(tag)


class BasePage(BaseFinder):
def __init__(self, root: webdriver.Chrome) -> None:
Expand All @@ -34,6 +37,11 @@ def page_source(self) -> str:
return self.root.page_source


class BaseModal(BaseFinder):
def __init__(self, root: WebElement) -> None:
self.root = root


class BaseElement(BaseFinder):
def __init__(self, root: WebElement) -> None:
self.root = root
97 changes: 44 additions & 53 deletions tests/pages/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,74 @@

from __future__ import annotations

import re
from typing import TYPE_CHECKING

from tests.pages.base import BaseElement, BasePage
from selenium.common.exceptions import NoSuchElementException

from gafaelfawr.models.token import TokenType
from tests.pages.base import BaseElement, BaseModal, BasePage
from tests.support.selenium import run

if TYPE_CHECKING:
from typing import List, Optional
from typing import List

from selenium.webdriver.remote.webelement import WebElement


class NewTokenPage(BasePage):
@property
def form(self) -> WebElement:
return self.find_element_by_id("create-token")

@property
def scopes(self) -> List[ScopeRow]:
class TokensPage(BasePage):
async def click_create_token(self) -> CreateTokenModal:
button = self.find_element_by_id("qa-create-token")
await run(button.click)
element = self.find_element_by_id("qa-create-modal")
return CreateTokenModal(element)

def get_new_token_modal(self) -> NewTokenModal:
element = self.find_element_by_id("qa-new-token-modal")
return NewTokenModal(element)

def get_tokens(self, token_type: TokenType) -> List[TokenRow]:
try:
table = self.find_element_by_id(f"tokens-{token_type.value}")
except NoSuchElementException:
return []
return [
ScopeRow(e)
for e in self.find_elements_by_class_name("qa-token-scope")
TokenRow(e)
for e in table.find_elements_by_class_name("qa-token-row")
]

async def submit(self) -> None:
button = self.form.find_element_by_id("submit")
await run(button.click)


class TokensPage(BasePage):
@property
def new_token(self) -> Optional[str]:
alert = self.find_elements_by_class_name("alert")
if not alert:
return None
match = re.search("Token: ([^ ]+)", alert[0].text)
if match:
return match.group(1)
else:
return None

class CreateTokenModal(BaseModal):
@property
def tokens(self) -> List[TokenRow]:
return [
TokenRow(e)
for e in self.find_elements_by_class_name("qa-token-row")
]
def form(self) -> WebElement:
return self.find_element_by_tag_name("form")

async def click_create_token(self) -> None:
button = self.find_element_by_id("new-token")
await run(button.click)
def set_token_name(self, token_name: str) -> None:
field = self.form.find_element_by_id("create-token-name")
field.send_keys(token_name)

async def submit(self) -> None:
await run(self.form.submit)

class ScopeRow(BaseElement):
@property
def checkbox(self) -> WebElement:
return self.find_element_by_class_name("form-check-input")

class NewTokenModal(BaseModal):
@property
def description(self) -> str:
return self.find_element_by_class_name("qa-scope-description").text
def token(self) -> str:
return self.find_element_by_id("qa-new-token").text

@property
def label(self) -> str:
return self.find_element_by_class_name("form-check-label").text
def dismiss(self) -> None:
button = self.find_element_by_id("token-accept")
button.click()


class TokenRow(BaseElement):
@property
def key(self) -> str:
return self.find_element_by_class_name("token-link").text
def name(self) -> str:
return self.find_element_by_class_name("qa-token-name").text

@property
def link(self) -> str:
token_link = self.find_element_by_class_name("token-link")
return token_link.find_element_by_tag_name("a").get_attribute("href")
def token(self) -> str:
return self.find_element_by_class_name("qa-token").text

@property
def scope(self) -> str:
return self.find_element_by_class_name("qa-token-scope").text
async def click_delete_token(self) -> None:
button = self.find_element_by_class_name("qa-token-delete")
await run(button.click)
53 changes: 53 additions & 0 deletions tests/selenium/tokens_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Selenium tests for ``/auth/tokens``."""

from __future__ import annotations

from typing import TYPE_CHECKING
from urllib.parse import urljoin

import pytest

from gafaelfawr.constants import COOKIE_NAME
from gafaelfawr.models.state import State
from gafaelfawr.models.token import TokenType
from tests.pages.tokens import TokensPage
from tests.support.selenium import run

if TYPE_CHECKING:
from seleniumwire import webdriver

from tests.support.selenium import SeleniumConfig


@pytest.mark.asyncio
async def test_create_token(
driver: webdriver.Chrome, selenium_config: SeleniumConfig
) -> None:
cookie = State(token=selenium_config.token).as_cookie()
driver.header_overrides = {"Cookie": f"{COOKIE_NAME}={cookie}"}

tokens_url = urljoin(selenium_config.url, "/auth/tokens")
await run(lambda: driver.get(tokens_url))

tokens_page = TokensPage(driver)
assert tokens_page.get_tokens(TokenType.user) == []
session_tokens = tokens_page.get_tokens(TokenType.session)
assert len(session_tokens) == 1
assert session_tokens[0].token == selenium_config.token.key

# Drop our cookie in favor of the one the browser is now sending, since
# the browser one contains a CSRF token that will be required for token
# creation.
del driver.header_overrides

create_modal = await tokens_page.click_create_token()
create_modal.set_token_name("test token")
await create_modal.submit()

new_token_modal = tokens_page.get_new_token_modal()
assert new_token_modal.token.startswith("gt-")
new_token_modal.dismiss()

user_tokens = tokens_page.get_tokens(TokenType.user)
assert len(user_tokens) == 1
assert user_tokens[0].name == "test token"
49 changes: 45 additions & 4 deletions tests/support/selenium.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
import subprocess
import time
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING

from seleniumwire import webdriver

from gafaelfawr.database import initialize_database
from gafaelfawr.dependencies.config import config_dependency
from gafaelfawr.models.token import Token

if TYPE_CHECKING:
from pathlib import Path
Expand All @@ -24,15 +26,43 @@
T = TypeVar("T")

APP_TEMPLATE = """
from unittest.mock import MagicMock
from gafaelfawr.dependencies.config import config_dependency
from gafaelfawr.dependencies.redis import redis_dependency
from gafaelfawr.factory import ComponentFactory
from gafaelfawr.main import app
from gafaelfawr.models.token import TokenUserInfo
config_dependency.set_settings_path("{settings_path}")
redis_dependency.is_mocked = True
@app.on_event("startup")
async def startup_event() -> None:
factory = ComponentFactory(
config=config_dependency(),
redis=await redis_dependency(),
http_client=MagicMock(),
)
token_service = factory.create_token_service()
user_info = TokenUserInfo(username="testuser", name="Test User", uid=1000)
token = await token_service.create_session_token(user_info)
with open("{token_path}", "w") as f:
f.write(str(token))
"""


@dataclass
class SeleniumConfig:
"""Information about the running server at which Selenium can point."""

token: Token
"""A valid authentication token for the running server."""

url: str
"""The URL at which to contact the server."""


async def run(f: Callable[[], T]) -> T:
"""Run a function async.
Expand Down Expand Up @@ -97,7 +127,9 @@ def selenium_driver() -> webdriver.Chrome:
# accesses. See https://github.com/wkeeling/selenium-wire/issues/157.
options.add_argument("proxy-bypass-list=<-loopback>")

return webdriver.Chrome(options=options)
driver = webdriver.Chrome(options=options)
driver.implicitly_wait(1)
return driver


def _wait_for_server(port: int, timeout: float = 5.0) -> None:
Expand All @@ -123,7 +155,7 @@ def _wait_for_server(port: int, timeout: float = 5.0) -> None:


@contextmanager
def run_app(tmp_path: Path, settings_path: Path) -> Iterator[str]:
def run_app(tmp_path: Path, settings_path: Path) -> Iterator[SeleniumConfig]:
"""Run the application as a separate process for Selenium access.
Parameters
Expand All @@ -137,9 +169,14 @@ def run_app(tmp_path: Path, settings_path: Path) -> Iterator[str]:
config = config_dependency()
initialize_database(config)

token_path = tmp_path / "token"
app_source = APP_TEMPLATE.format(
settings_path=str(settings_path),
token_path=str(token_path),
)
app_path = tmp_path / "testing.py"
with app_path.open("w") as f:
f.write(APP_TEMPLATE.format(settings_path=str(settings_path)))
f.write(app_source)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
Expand All @@ -154,6 +191,10 @@ def run_app(tmp_path: Path, settings_path: Path) -> Iterator[str]:
_wait_for_server(port)

try:
yield f"http://localhost:{port}"
selenium_config = SeleniumConfig(
token=Token.from_str(token_path.read_text()),
url=f"http://localhost:{port}",
)
yield selenium_config
finally:
p.terminate()
Loading

0 comments on commit b394ba5

Please sign in to comment.