diff --git a/.coveragerc b/.coveragerc index 5a23f4d..6edb2cf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -7,6 +7,7 @@ source = packages/sshecret-sshd/src/sshecret_sshd omit = + packages/sshecret-backend/src/sshecret_backend/frontend/* */__init__.py */types.py */testing.py @@ -17,7 +18,7 @@ omit = */test_*.py */conftest.py */site-packages/* -concurrency = multiprocessing +concurrency = thread [report] show_missing = True diff --git a/pyproject.toml b/pyproject.toml index 51aba30..1faa105 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ all = [ {ref="fmt"}, {ref="lint"}, {ref="check"}, {ref="test"} ] "ci:fmt" = "ruff format --check ${PWD}" # fail if not formatted "ci:lint" = "ruff check ${PWD}" [tool.poe.tasks.coverage] -cmd = "pytest --cov-config=${PWD}/.coveragerc --cov --cov-report=html --cov-report=term-missing" +cmd = "pytest --ignore tests/integration/frontend --cov-config=${PWD}/.coveragerc --cov --cov-report=html --cov-report=term-missing --alluredir allure-results" cwd = "${POE_PWD}" @@ -32,6 +32,7 @@ dependencies = [ "pykeepass>=4.1.1.post1", "pytest-asyncio>=0.26.0", "pytest-cov>=6.1.1", + "pytest-selenium>=4.1.0", "python-dotenv>=1.0.1", "python-json-logger>=3.3.0", ] @@ -65,9 +66,13 @@ dev = [ "python-dotenv>=1.0.1", ] test = [ + "allure-pytest>=2.14.2", "coverage>=7.8.0", "pytest>=8.3.5", "pytest-asyncio>=0.26.0", "pytest-cov>=6.1.1", + "pytest-selenium>=4.1.0", + "requests>=2.32.3", "robotframework>=7.2.2", + "selenium>=4.32.0", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6b4d2e4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Capture screenshots on failure.""" +import allure +import base64 + +from allure_commons.types import AttachmentType + +def pytest_selenium_capture_debug(item, report, extra): + for log_type in extra: + if log_type["name"] == "Screenshot": + content = base64.b64decode(log_type["content"].encode("utf-8")) + allure.attach( + content, + name="Screenshot on failure", + attachment_type=AttachmentType.PNG, + ) diff --git a/tests/frontend/__init__.py b/tests/frontend/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/frontend/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/frontend/conftest.py b/tests/frontend/conftest.py new file mode 100644 index 0000000..27dcfb5 --- /dev/null +++ b/tests/frontend/conftest.py @@ -0,0 +1,129 @@ +"""Test fixtures for the frontend.""" + +import asyncio +import secrets +import tempfile +import threading +import time +from pathlib import Path + +import pytest +import requests +import uvicorn + + +from sshecret_admin.core.app import create_admin_app +from sshecret_admin.core.settings import AdminServerSettings +from sshecret_backend.app import create_backend_app +from sshecret_backend.settings import BackendSettings +from sshecret_backend.testing import create_test_token + +from tests.helpers import create_test_admin_user, in_tempdir +from tests.types import PortFactory, TestPorts + + +@pytest.fixture(name="ui_test_ports", scope="session") +def generate_test_ports(unused_tcp_port_factory: PortFactory) -> TestPorts: + """Generate the test ports.""" + test_ports = TestPorts( + backend=unused_tcp_port_factory(), + admin=unused_tcp_port_factory(), + sshd=unused_tcp_port_factory(), + ) + print(f"{test_ports=!r}") + return test_ports + + +@pytest.fixture(scope="function", name="ui_backend_server") +def run_backend_server(ui_test_ports: TestPorts): + """Run the backend server in a thread.""" + port = ui_test_ports.backend + + with tempfile.TemporaryDirectory() as tmp_dir: + backend_work_path = Path(tmp_dir) + db_file = backend_work_path / "backend.db" + backend_settings = BackendSettings(database=str(db_file.absolute())) + backend_app = create_backend_app(backend_settings) + token = create_test_token(backend_settings) + + config = uvicorn.Config( + app=backend_app, port=port, host="127.0.0.1", log_level="warning" + ) + server = uvicorn.Server(config) + + def run(): + asyncio.run(server.serve()) + + thread = threading.Thread(target=run) + thread.start() + + backend_url = f"http://127.0.0.1:{port}" + for _ in range(30): + try: + r = requests.get(backend_url) + if r.status_code < 500: + break + except Exception: + pass + time.sleep(1) + else: + raise RuntimeError("Backend server did not start in time") + + yield backend_url, token + + server.should_exit = True + thread.join() + + +@pytest.fixture(scope="function", name="ui_admin_server") +def run_admin_server(ui_test_ports: TestPorts, ui_backend_server: tuple[str, str]): + """Run the admin server in a thread.""" + backend_url, backend_token = ui_backend_server + port = ui_test_ports.admin + secret_key = secrets.token_urlsafe(32) + + with in_tempdir() as admin_work_path: + admin_db = admin_work_path / "ssh_admin.db" + admin_settings = AdminServerSettings.model_validate( + { + "sshecret_backend_url": backend_url, + "backend_token": backend_token, + "secret_key": secret_key, + "listen_address": "127.0.0.1", + "port": port, + "database": str(admin_db.absolute()), + "password_manager_directory": str(admin_work_path.absolute()), + } + ) + + admin_app = create_admin_app(admin_settings) + config = uvicorn.Config( + app=admin_app, port=port, host="127.0.0.1", log_level="warning" + ) + server = uvicorn.Server(config) + + def run(): + asyncio.run(server.serve()) + + thread = threading.Thread(target=run) + thread.start() + + admin_url = f"http://127.0.0.1:{port}" + admin_password = secrets.token_urlsafe(10) + create_test_admin_user(admin_settings, "test", admin_password) + + for _ in range(30): + try: + r = requests.get(admin_url) + if r.status_code < 500: + break + except Exception: + pass + time.sleep(1) + else: + raise RuntimeError("Admin server did not start in time") + + yield admin_url, ("test", admin_password) + + server.should_exit = True + thread.join() diff --git a/tests/frontend/helpers/__init__.py b/tests/frontend/helpers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/frontend/helpers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/frontend/helpers/auth.py b/tests/frontend/helpers/auth.py new file mode 100644 index 0000000..4e40ae0 --- /dev/null +++ b/tests/frontend/helpers/auth.py @@ -0,0 +1,30 @@ +"""Auth helpers.""" + +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from tests.integration.types import AdminServer +from .wait_helpers import wait_until_url_contains + +def login(ui_admin_server: AdminServer, driver: WebDriver) -> WebDriver: + """Log in.""" + admin_url, credentials = ui_admin_server + username, password = credentials + + driver.get(admin_url + "/login") + username_input = driver.find_element(By.NAME, "username") + password_input = driver.find_element(By.NAME, "password") + submit_button = driver.find_element(By.XPATH, "//button[@type='submit']") + assert username_input is not None + assert password_input is not None + assert submit_button.text.lower() == "sign in" + + username_input.clear() + username_input.send_keys(username) + password_input.send_keys(password) + + + submit_button.click() + + wait_until_url_contains(driver, "/dashboard") + + return driver diff --git a/tests/frontend/helpers/db.py b/tests/frontend/helpers/db.py new file mode 100644 index 0000000..1c15107 --- /dev/null +++ b/tests/frontend/helpers/db.py @@ -0,0 +1,66 @@ +"""Database helpers. + +Allows pre-loading database for tests. +""" + +from collections.abc import Iterator +from contextlib import contextmanager +import httpx +from sshecret.crypto import generate_private_key, generate_public_key_string + + +class DatabasePreloader: + """Database preloader class.""" + + def __init__(self, admin_url: str, username: str, password: str) -> None: + """Instantiate class to populate database.""" + self.admin_url: str = admin_url + self.username: str = username + self.password: str = password + + @contextmanager + def login(self) -> Iterator[httpx.Client]: + """Login and yield client.""" + login_client = httpx.Client(base_url=self.admin_url) + resp = login_client.post( + "api/v1/token", + data={"username": self.username, "password": self.password} + ) + assert resp.status_code == 200 + data = resp.json() + token = data["access_token"] + headers = {"Authorization": f"Bearer {token}"} + with httpx.Client(base_url=self.admin_url, headers=headers) as client: + yield client + + def create_client(self, *names: str) -> None: + """Create one or more clients.""" + with self.login() as http_client: + for name in names: + private_key = generate_private_key() + public_key = generate_public_key_string(private_key.public_key()) + data = { + "name": name, + "description": "Test client", + "public_key": public_key, + "sources": ["0.0.0.0/0", "::/0"], + } + resp = http_client.post("api/v1/clients/", json=data) + resp.raise_for_status() + + def create_secret(self, *secrets: tuple[str, list[str]]) -> None: + """Create secret. + + Argument format is (secret_name, [client1, client2, ...]) + + Clients must exist. + """ + with self.login() as http_client: + for name, clients in secrets: + data = { + "name": name, + "clients": clients, + "value": {"auto_generate": True, "length": 32} + } + + http_client.post("api/v1/secrets/", json=data) diff --git a/tests/frontend/helpers/wait_helpers.py b/tests/frontend/helpers/wait_helpers.py new file mode 100644 index 0000000..61e8e17 --- /dev/null +++ b/tests/frontend/helpers/wait_helpers.py @@ -0,0 +1,63 @@ +""" +Collection of waiting statements. +""" + +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.by import By + + +def wait_for_url_change(driver: WebDriver, old_url: str, timeout: int = 10) -> None: + WebDriverWait(driver, timeout).until(lambda d: d.current_url != old_url) + + +def wait_until_url_contains(driver: WebDriver, text: str, timeout: int = 10) -> None: + WebDriverWait(driver, timeout).until(lambda d: text in d.current_url) + + +def wait_for_element(driver: WebDriver, by: str, value: str, timeout: int = 10): + return WebDriverWait(driver, timeout).until( + EC.presence_of_element_located((by, value)) + ) + + +def wait_for_element_with_text( + driver: WebDriver, tag: str, text: str, timeout: int = 10 +): + return WebDriverWait(driver, timeout).until( + EC.text_to_be_present_in_element((By.TAG_NAME, tag), text) + ) + + +def wait_for_clickable(driver: WebDriver, by: str, value: str, timeout: int = 10): + return WebDriverWait(driver, timeout).until(EC.element_to_be_clickable((by, value))) + + +def wait_for_element_to_be_visisble(driver: WebDriver, id: str, timeout: int = 10): + return WebDriverWait(driver, timeout).until( + EC.visibility_of_element_located((By.ID, id)) + ) + + +def wait_for_element_to_be_disabled( + driver: WebDriver, by: str, value: str, timeout: int = 10 +): + """Wait for an element to be disabled.""" + return WebDriverWait(driver, timeout).until( + EC.none_of(EC.element_to_be_clickable((by, value))) + ) + + +def wait_for_alert(driver: WebDriver, timeout: int = 10): + """Wait for an alert.""" + return WebDriverWait(driver, timeout).until(lambda d: d.switch_to.alert) + + +def wait_for_element_to_disappear( + driver: WebDriver, by: str, value: str, timeout: int = 10 +): + """Wait for an element to disappear.""" + return WebDriverWait(driver, timeout).until( + EC.none_of(EC.presence_of_element_located((by, value))) + ) diff --git a/tests/frontend/test_clients.py b/tests/frontend/test_clients.py new file mode 100644 index 0000000..9cea055 --- /dev/null +++ b/tests/frontend/test_clients.py @@ -0,0 +1,217 @@ +"""Tests for the client page.""" + +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +import pytest +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from sshecret.crypto import generate_private_key, generate_public_key_string + +from tests.integration.types import AdminServer + +from .helpers.auth import login +from .helpers import wait_helpers + +@pytest.fixture +def client_page(ui_admin_server: AdminServer, driver: WebDriver) -> WebDriver: + """Log in and show the client page.""" + admin_url = ui_admin_server[0] + driver = login(ui_admin_server, driver) + driver.get(admin_url + "/clients") + return driver + + +class TestClientPage: + """Test client page.""" + + def test_client_page_loaded(self, client_page: WebDriver) -> None: + """Test that the client page loads.""" + # Ensure that the create client button is present + client_page.refresh() + create_client_button = client_page.find_element(By.ID, "createClientButton") + assert create_client_button is not None + # Ensure that the table is loaded + client_table = client_page.find_element(By.ID, "clientListTable") + assert client_table is not None + + def test_create_client_button(self, client_page: WebDriver) -> None: + """Test that the Create Client button works.""" + client_page.refresh() + create_client_button = client_page.find_element(By.ID, "createClientButton") + assert create_client_button is not None + + create_client_button.click() + + wait_helpers.wait_for_element_to_be_visisble(client_page, "drawer-create-client-default") + + add_client_button = client_page.find_element(By.XPATH, "//button[@type='submit']") + assert add_client_button.text.lower() == "add client" + + def test_create_client(self, client_page: WebDriver) -> None: + """Test create clients.""" + client_page.refresh() + private_key = generate_private_key() + public_key = generate_public_key_string(private_key.public_key()) + + create_client_button = client_page.find_element(By.ID, "createClientButton") + assert create_client_button is not None + + create_client_button.click() + + wait_helpers.wait_for_element_to_be_visisble(client_page, "drawer-create-client-default") + + drawer = client_page.find_element(By.ID, "drawer-create-client-default") + assert drawer is not None + name_input = drawer.find_element(By.NAME, "name") + assert name_input is not None + description_input = drawer.find_element(By.NAME, "description") + assert description_input is not None + sources_input = drawer.find_element(By.NAME, "sources") + assert sources_input is not None + public_key_input = drawer.find_element(By.NAME, "public_key") + assert public_key_input is not None + + + name_input.send_keys("testuser") + description_input.send_keys("Test") + sources_input.clear() + sources_input.send_keys("0.0.0.0/0, ::/0") + public_key_input.send_keys(public_key) + validation_field = drawer.find_element(By.ID, "clientPublicKeyValidation") + assert validation_field is not None + error_message = validation_field.find_elements(By.TAG_NAME, "p") + assert len(error_message) == 0 + + # Submit the request + + add_client_button = drawer.find_element(By.XPATH, "//button[@type='submit']") + assert add_client_button.text.lower() == "add client" + + add_client_button.click() + + client_appeared = wait_helpers.wait_for_element_with_text(client_page, "td", "testuser") + assert client_appeared is not False + + def test_delete_client(self, client_page: WebDriver) -> None: + """Test deletion of a test client.""" + self.test_create_client(client_page) + client_page.refresh() + client_field = client_page.find_element(By.XPATH, "//tr/td[contains(text(), 'testuser')]") + assert client_field is not None + row = client_field.find_element(By.XPATH, "./..") + assert row is not None + assert row.tag_name == "tr" + row_id = row.get_attribute("id") + assert row_id is not None + print(row_id) + client_id = row_id[7:] + + wait_helpers.wait_for_element_to_be_visisble(client_page, row_id) + + delete_button = client_page.find_element(By.ID, f"deleteClientButton-{client_id}") + assert delete_button is not None + + delete_button.click() + + drawer_name = f"drawer-delete-{row_id}" + wait_helpers.wait_for_element_to_be_visisble(client_page, drawer_name) + drawer = client_page.find_element(By.ID, drawer_name) + + assert drawer is not None + + confirm_button = drawer.find_element(By.XPATH, "//button[contains(text(), 'Yes, delete the client')]") + assert confirm_button is not None + confirm_button.click() + + WebDriverWait(client_page, 10).until( + EC.none_of( + EC.presence_of_element_located((By.ID, row_id)) + ) + ) + + def test_update_client(self, client_page: WebDriver) -> None: + """Test updating a client.""" + self.test_create_client(client_page) + + client_page.refresh() + client_field = client_page.find_element(By.XPATH, "//tr/td[contains(text(), 'testuser')]") + assert client_field is not None + row = client_field.find_element(By.XPATH, "./..") + assert row is not None + assert row.tag_name == "tr" + row_id = row.get_attribute("id") + assert row_id is not None + print(row_id) + client_id = row_id[7:] + + wait_helpers.wait_for_element_to_be_visisble(client_page, row_id) + update_button = client_page.find_element(By.ID, f"updateClientButton-{client_id}") + assert update_button is not None + + update_button.click() + + drawer_name = f"drawer-update-{row_id}" + + wait_helpers.wait_for_element_to_be_visisble(client_page, drawer_name) + + drawer = client_page.find_element(By.ID, drawer_name) + description_input = drawer.find_element(By.NAME, "description") + + description_input.clear() + + description_input.send_keys("New Description") + + confirm_button = drawer.find_element(By.XPATH, "//button[contains(text(), 'Update')]") + assert confirm_button is not None + confirm_button.click() + + WebDriverWait(client_page, 10).until( + EC.text_to_be_present_in_element((By.XPATH, f"//tr[@id='{row_id}']/td[3]"), "New Description") + ) + + + def test_delete_from_update_client(self, client_page: WebDriver) -> None: + """Test updating a client.""" + self.test_create_client(client_page) + + client_page.refresh() + client_field = client_page.find_element(By.XPATH, "//tr/td[contains(text(), 'testuser')]") + assert client_field is not None + row = client_field.find_element(By.XPATH, "./..") + assert row is not None + assert row.tag_name == "tr" + row_id = row.get_attribute("id") + assert row_id is not None + print(row_id) + client_id = row_id[7:] + + wait_helpers.wait_for_element_to_be_visisble(client_page, row_id) + update_button = client_page.find_element(By.ID, f"updateClientButton-{client_id}") + assert update_button is not None + + update_button.click() + + drawer_name = f"drawer-update-{row_id}" + + wait_helpers.wait_for_element_to_be_visisble(client_page, drawer_name) + + drawer = client_page.find_element(By.ID, drawer_name) + description_input = drawer.find_element(By.NAME, "description") + + description_input.clear() + + description_input.send_keys("New Description") + + delete_button = drawer.find_element(By.ID, f"delete-button-{client_id}") + assert delete_button is not None + delete_button.click() + + alert = WebDriverWait(client_page, 10).until(lambda d: d.switch_to.alert) + assert alert.text == "Are you sure?" + alert.accept() + + WebDriverWait(client_page, 10).until( + EC.none_of( + EC.presence_of_element_located((By.ID, row_id)) + ) + ) diff --git a/tests/frontend/test_dashboard.py b/tests/frontend/test_dashboard.py new file mode 100644 index 0000000..0417f4e --- /dev/null +++ b/tests/frontend/test_dashboard.py @@ -0,0 +1,79 @@ +"""Test of the dashboard landing page.""" + +import time +import allure +import pytest +from selenium.webdriver import ActionChains +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support.select import Select + +from tests.integration.types import AdminServer + +from .helpers.auth import login +from .helpers.db import DatabasePreloader +from .helpers.wait_helpers import ( + wait_for_element_to_be_visisble, +) + + +class TestDashboardPage: + """Test dashboard page.""" + + @pytest.fixture(autouse=True) + def login(self, ui_admin_server: AdminServer, driver: WebDriver) -> None: + """Login.""" + login(ui_admin_server, driver) + + @pytest.fixture(autouse=True) + def create_testdata(self, ui_admin_server: AdminServer) -> None: + """Preload some test data.""" + test_clients = ["client1", "client2", "client3"] + admin_url, (username, password) = ui_admin_server + db = DatabasePreloader(admin_url, username, password) + db.create_client(*test_clients) + secrets = [ + ("secret1", ["client1", "client2"]), + ("secret2", ["client1"]), + ("secret3", ["client3"]), + ("secret4", ["client2"]), + ] + db.create_secret(*secrets) + + @allure.title("Test Dashboard view") + def test_dashboard_elements(self, driver: WebDriver) -> None: + """Test elements on the dashboard.""" + wait_for_element_to_be_visisble(driver, "dashboard-stats-panel") + stats_clients = driver.find_element(By.ID, "stats-client-count") + assert stats_clients.text == "3" + stats_secrets = driver.find_element(By.ID, "stats-secret-count") + assert stats_secrets.text == "4" + stats_audit = driver.find_element(By.ID, "stats-audit-count") + assert stats_audit.text.isdecimal() + assert int(stats_audit.text) > 0 + + # Check that there is at least one row in each audit table + login_table = driver.find_element(By.ID, "last-login-events") + login_table_rows = login_table.find_elements(By.XPATH, ".//tr") + assert len(login_table_rows) > 1 + + audit_table = driver.find_element(By.ID, "last-audit-events") + audit_table_rows = audit_table.find_elements(By.XPATH, ".//tr") + assert len(audit_table_rows) > 1 + + # Find a questionmark hover + login_info_btn = login_table_rows[-1].find_element(By.XPATH, "./td[1]//button") + login_info_target = login_info_btn.get_attribute("data-popover-target") + assert login_info_target is not None + + ActionChains(driver).move_to_element(login_info_btn).perform() + + wait_for_element_to_be_visisble(driver, login_info_target) + + audit_info_btn = audit_table_rows[-1].find_element(By.XPATH, "./td[1]//button") + + audit_info_target = audit_info_btn.get_attribute("data-popover-target") + assert audit_info_target is not None + + ActionChains(driver).move_to_element(audit_info_btn).perform() + wait_for_element_to_be_visisble(driver, audit_info_target) diff --git a/tests/frontend/test_login.py b/tests/frontend/test_login.py new file mode 100644 index 0000000..595fdc0 --- /dev/null +++ b/tests/frontend/test_login.py @@ -0,0 +1,23 @@ +"""Tests for the login.""" + +from selenium.webdriver.remote.webdriver import WebDriver +from tests.integration.types import AdminServer + +from .helpers.auth import login +from .helpers import wait_helpers + +def test_login(ui_admin_server: AdminServer, driver: WebDriver) -> None: + """Test login.""" + driver = login(ui_admin_server, driver) + print(driver.current_url) + assert driver.current_url.endswith("/dashboard") + + +def test_logout(ui_admin_server: AdminServer, driver: WebDriver) -> None: + """Test logout function.""" + admin_url = ui_admin_server[0] + driver = login(ui_admin_server, driver) + assert driver.current_url.endswith("/dashboard") + driver.get(admin_url + "/logout") + wait_helpers.wait_until_url_contains(driver, "/login") + assert driver.current_url.endswith("/login") diff --git a/tests/frontend/test_secrets.py b/tests/frontend/test_secrets.py new file mode 100644 index 0000000..98f820f --- /dev/null +++ b/tests/frontend/test_secrets.py @@ -0,0 +1,189 @@ +"""Tests for the secrets page.""" + +import allure +import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support.select import Select +from tests.integration.types import AdminServer + +from .helpers.auth import login +from .helpers.db import DatabasePreloader +from .helpers.wait_helpers import ( + wait_for_alert, + wait_for_element, + wait_for_element_to_be_disabled, + wait_for_element_to_be_visisble, + wait_for_element_to_disappear, + wait_for_element_with_text, +) + + +class TestSecretsPage: + """Test secrets page.""" + + @pytest.fixture(autouse=True) + def login(self, ui_admin_server: AdminServer, driver: WebDriver) -> None: + """Log in and navigate to secrets page..""" + admin_url = ui_admin_server[0] + driver = login(ui_admin_server, driver) + driver.get(admin_url + "/secrets") + + @pytest.fixture(autouse=True) + def create_testdata(self, ui_admin_server: AdminServer) -> None: + """Preload some test data.""" + test_clients = ["client1", "client2", "client3"] + admin_url, (username, password) = ui_admin_server + db = DatabasePreloader(admin_url, username, password) + db.create_client(*test_clients) + + @allure.title("Test secret creation") + @allure.description("Verify that secrets can be created") + def test_create_secret(self, driver: WebDriver) -> None: + """Test creation of secrets.""" + driver.refresh() + create_secret_button = driver.find_element(By.ID, "createSecretButton") + assert create_secret_button is not None + create_secret_button.click() + wait_for_element_to_be_visisble(driver, "drawer-create-secret-default") + + drawer = driver.find_element(By.ID, "drawer-create-secret-default") + assert drawer is not None + name_input = drawer.find_element(By.NAME, "name") + value_input = drawer.find_element(By.NAME, "value") + client_select = drawer.find_element(By.NAME, "clients") + client_select_node = Select(client_select) + + name_input.send_keys("testsecret") + value_input.send_keys("secret") + client_select_node.select_by_visible_text("client1") + + add_secret_button = drawer.find_element(By.XPATH, "//button[@type='submit']") + add_secret_button.click() + + client_appeared = wait_for_element_with_text(driver, "td", "testsecret") + + assert client_appeared is not False + + secret_name_field = driver.find_element( + By.XPATH, "//tr/td[contains(text(), 'testsecret')]" + ) + secret_row = secret_name_field.find_element(By.XPATH, "./..") + client_field = secret_row.find_element(By.CLASS_NAME, "secret-client-list") + secret_client = client_field.find_element( + By.XPATH, "//span[contains(text(), 'client1')]" + ) + assert secret_client is not None + + @allure.title("Test auto-generating secrets") + @allure.description("Test creation of a secret with automatic value") + def test_auto_secret_creation(self, driver: WebDriver) -> None: + """Test creation of secret with automatic value.""" + driver.refresh() + create_secret_button = driver.find_element(By.ID, "createSecretButton") + assert create_secret_button is not None + create_secret_button.click() + wait_for_element_to_be_visisble(driver, "drawer-create-secret-default") + + drawer = driver.find_element(By.ID, "drawer-create-secret-default") + assert drawer is not None + name_input = drawer.find_element(By.NAME, "name") + value_input = drawer.find_element(By.NAME, "value") + + # The auto generate checkbox is obscured by a dynamic div. + # We find the label and its nested div + + auto_generate_label = drawer.find_element(By.ID, "autoGenerateCheckboxLabel") + # find the first div + checkbox_div = auto_generate_label.find_element(By.TAG_NAME, "div") + checkbox_div.click() + wait_for_element_to_be_disabled(driver, By.NAME, "value") + client_select = drawer.find_element(By.NAME, "clients") + client_select_node = Select(client_select) + + name_input.send_keys("autosecret") + client_select_node.deselect_all() + client_select_node.select_by_visible_text("client1") + client_select_node.select_by_visible_text("client2") + + add_secret_button = drawer.find_element(By.XPATH, "//button[@type='submit']") + add_secret_button.click() + + client_appeared = wait_for_element_with_text(driver, "td", "autosecret") + + secret_name_field = driver.find_element( + By.XPATH, "//tr/td[contains(text(), 'autosecret')]" + ) + secret_row = secret_name_field.find_element(By.XPATH, "./..") + client_field = secret_row.find_element(By.CLASS_NAME, "secret-client-list") + secret_client1 = client_field.find_element( + By.XPATH, "//span[contains(text(), 'client1')]" + ) + assert secret_client1 is not None + + secret_client2 = client_field.find_element( + By.XPATH, "//span[contains(text(), 'client2')]" + ) + assert secret_client2 is not None + + @allure.title("Test manage client access") + def test_manage_client_access(self, driver: WebDriver) -> None: + """Test the manage client access button.""" + # Use the previous step to create a secret assigned to two clients. + self.test_auto_secret_creation(driver) + driver.refresh() + # Find the manage client access button + # btn_id = "client-secret-modal-autosecret" + manage_btn = driver.find_element(By.ID, "manage-client-access-btn-autosecret") + assert manage_btn is not None + manage_btn.click() + wait_for_element_to_be_visisble(driver, "client-secret-modal-autosecret") + modal = driver.find_element(By.ID, "client-secret-modal-autosecret") + assert modal is not None + client_pills = modal.find_elements(By.CLASS_NAME, "pill-client-secret") + assert len(client_pills) == 2 + + # Remove client1 + remove_pill_btn_id = "btn-remove-client-client1-secret-autosecret" + remove_btn = modal.find_element(By.ID, remove_pill_btn_id) + assert remove_btn is not None + remove_btn.click() + alert = wait_for_alert(driver) + alert.accept() + + # Wait for the client pill to disappear. + wait_for_element_to_disappear( + driver, + By.XPATH, + "//td[@id='secret-client-list-autosecret']/span[contains(text(), 'client1')]", + ) + + # Add a different client. + client_select_field = modal.find_element(By.NAME, "client") + assert client_select_field is not None + assert client_select_field.tag_name == "select" + + client_select = Select(client_select_field) + client_select.select_by_visible_text("client3") + + give_access = modal.find_element(By.XPATH, "//button[@type='submit']") + assert "give access" in give_access.text.lower() + give_access.click() + wait_for_element(driver, By.ID, "client-secret-autosecret-pill-client3") + + @allure.title("Test secret deletion") + def test_delete_secret(self, driver: WebDriver) -> None: + """Test deleting a secret.""" + self.test_auto_secret_creation(driver) + driver.refresh() + delete_btn_id = "delete-secret-btn-autosecret" + delete_btn = driver.find_element(By.ID, delete_btn_id) + + delete_btn.click() + + alert = wait_for_alert(driver) + alert.accept() + + wait_for_element_to_disappear( + driver, By.XPATH, "//td[contains(text(), 'autosecret')]" + ) diff --git a/tests/integration/helpers.py b/tests/helpers.py similarity index 100% rename from tests/integration/helpers.py rename to tests/helpers.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 33f5ed7..0cc9c2c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -34,8 +34,8 @@ from sshecret_sshd.settings import ServerSettings from sshecret_sshd.ssh_server import start_sshecret_sshd from .clients import ClientData -from .helpers import create_sshd_server_key, create_test_admin_user, in_tempdir -from .types import PortFactory, TestPorts +from tests.helpers import create_sshd_server_key, create_test_admin_user, in_tempdir +from tests.types import PortFactory, TestPorts TEST_SCOPE = "function" LOOP_SCOPE = "function" @@ -92,7 +92,7 @@ async def run_admin_server(test_ports: TestPorts, backend_server: tuple[str, str "sshecret_backend_url": backend_url, "backend_token": backend_token, "secret_key": secret_key, - "listen_address": "127.0.0.1", + "listen_address": "0.0.0.0", "port": port, "database": str(admin_db.absolute()), "password_manager_directory": str(admin_work_path.absolute()), diff --git a/tests/integration/test_admin_api.py b/tests/integration/test_admin_api.py index d7bc005..b299427 100644 --- a/tests/integration/test_admin_api.py +++ b/tests/integration/test_admin_api.py @@ -2,10 +2,13 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +import allure import pytest import httpx +from allure_commons.types import Severity + from sshecret.backend import Client from sshecret.crypto import generate_private_key, generate_public_key_string @@ -70,9 +73,12 @@ class BaseAdminTests: return client +@allure.title("Admin API") class TestAdminAPI(BaseAdminTests): """Tests of the Admin REST API.""" + @allure.title("Test health test endpoint") + @allure.severity(Severity.TRIVIAL) @pytest.mark.asyncio async def test_health_check( self, admin_server: tuple[str, tuple[str, str]] @@ -82,6 +88,8 @@ class TestAdminAPI(BaseAdminTests): resp = await client.get("/health") assert resp.status_code == 200 + @allure.title("Test login over API") + @allure.severity(Severity.BLOCKER) @pytest.mark.asyncio async def test_admin_login(self, admin_server: AdminServer) -> None: """Test admin login.""" @@ -95,9 +103,12 @@ class TestAdminAPI(BaseAdminTests): assert resp.status_code == 200 +@allure.title("Admin API Client API") class TestAdminApiClients(BaseAdminTests): """Test client routes.""" + @allure.title("Test creating a client") + @allure.description("Ensure we can create a new client.") @pytest.mark.asyncio async def test_create_client(self, admin_server: AdminServer) -> None: """Test create_client.""" @@ -106,6 +117,8 @@ class TestAdminApiClients(BaseAdminTests): assert client.id is not None assert client.name == "testclient" + @allure.title("Test reading clients") + @allure.description("Ensure we can retrieve a list of current clients.") @pytest.mark.asyncio async def test_get_clients(self, admin_server: AdminServer) -> None: """Test get_clients.""" @@ -124,6 +137,8 @@ class TestAdminApiClients(BaseAdminTests): client_name = entry.get("name") assert client_name in client_names + @allure.title("Test client deletion") + @allure.description("Ensure we can delete a client.") @pytest.mark.asyncio async def test_delete_client(self, admin_server: AdminServer) -> None: """Test delete_client.""" @@ -146,9 +161,12 @@ class TestAdminApiClients(BaseAdminTests): assert len(data) == 0 +@allure.title("Test secret management") class TestAdminApiSecrets(BaseAdminTests): """Test secret management.""" + @allure.title("Test adding a secret") + @allure.description("Ensure that we can add a secret to a client.") @pytest.mark.asyncio async def test_add_secret(self, admin_server: AdminServer) -> None: """Test add_secret.""" @@ -162,6 +180,8 @@ class TestAdminApiSecrets(BaseAdminTests): resp = await http_client.post("api/v1/secrets/", json=data) assert resp.status_code == 200 + @allure.title("Test read a secret") + @allure.description("Ensure that we can retrieve a secret we have stored.") @pytest.mark.asyncio async def test_get_secret(self, admin_server: AdminServer) -> None: """Test get_secret.""" @@ -175,6 +195,8 @@ class TestAdminApiSecrets(BaseAdminTests): assert data["secret"] == "secretstring" assert "testclient" in data["clients"] + @allure.title("Test adding a secret with automatic value") + @allure.description("Test that we can add a secret where we let the system come up with the value of a given length.") @pytest.mark.asyncio async def test_add_secret_auto(self, admin_server: AdminServer) -> None: """Test adding a secret with an auto-generated value.""" @@ -195,6 +217,8 @@ class TestAdminApiSecrets(BaseAdminTests): assert len(data["secret"]) == 17 assert "testclient" in data["clients"] + @allure.title("Test updating a secret") + @allure.description("Test that we can update the value of a stored secret.") @pytest.mark.asyncio async def test_update_secret(self, admin_server: AdminServer) -> None: """Test updating secrets.""" diff --git a/tests/integration/types.py b/tests/integration/types.py index 311c0bd..29a2df8 100644 --- a/tests/integration/types.py +++ b/tests/integration/types.py @@ -2,25 +2,13 @@ import asyncssh from typing import Any, AsyncContextManager, Protocol -from dataclasses import dataclass from collections.abc import Callable, Awaitable from .clients import ClientData -PortFactory = Callable[[], int] - AdminServer = tuple[str, tuple[str, str]] -@dataclass -class TestPorts: - """Test port dataclass.""" - - backend: int - admin: int - sshd: int - - CommandRunner = Callable[[ClientData, str], Awaitable[asyncssh.SSHCompletedProcess]] class ProcessRunner(Protocol): diff --git a/tests/types.py b/tests/types.py new file mode 100644 index 0000000..a5b5a62 --- /dev/null +++ b/tests/types.py @@ -0,0 +1,16 @@ +"""Typings.""" + +from dataclasses import dataclass +from collections.abc import Callable + +PortFactory = Callable[[], int] + + +@dataclass +class TestPorts: + """Test port dataclass.""" + + backend: int + admin: int + sshd: int + diff --git a/uv.lock b/uv.lock index d417894..ec9ab58 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/18/d89a443ed1ab9bcda16264716f809c663866d4ca8de218aa78fd50b38ead/alembic-1.15.2-py3-none-any.whl", hash = "sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53", size = 231911 }, ] +[[package]] +name = "allure-pytest" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "allure-python-commons" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/f4/59d3d3ca7cbcdb5efae990072f6b4aafebff524237fa277c14daac8b84f8/allure_pytest-2.14.2.tar.gz", hash = "sha256:d387492178d27805863d95350bdc38b7feca3ed7165841997630fd4073cc9101", size = 17153 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/bb/d6b28ee5cced087a171715945e578bd6f2c0a32e6bbbd3578fe701f7ae4c/allure_pytest-2.14.2-py3-none-any.whl", hash = "sha256:18f3baa9ebd1b6148223cfa898bacfc2794bb9446221adac1be71deeb26ed79a", size = 11673 }, +] + +[[package]] +name = "allure-python-commons" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/79/53fe62ff56fa1f972b9fcbc46d04687786907cecc40b412421c4554d3734/allure_python_commons-2.14.2.tar.gz", hash = "sha256:7acdc4fe3efbe709604895e2393f082b2659d8e5653e77ff6367682e6e4a41bc", size = 15186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/b7/41b4323f65911b216a616087c37a8fd337d2cd92f7b8fa89bc35cb91cd1d/allure_python_commons-2.14.2-py3-none-any.whl", hash = "sha256:ad50385a4c601ec31c86eed773d8ccfdcc687fecbb6535c9768af3bf03b50a19", size = 16191 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -91,6 +117,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/56/db25216aa7f385ec71fdc489af80812171515cddbe68c0e515e98a291390/asyncssh-2.21.0-py3-none-any.whl", hash = "sha256:cf7f3dfa52b2cb4ad31f0d77ff0d0a8fdd850203da84a0e72e62c36fdd4daf4b", size = 374919 }, ] +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -172,6 +207,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + [[package]] name = "click" version = "8.2.0" @@ -574,6 +631,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, +] + [[package]] name = "packaging" version = "25.0" @@ -755,6 +824,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376 }, ] +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, +] + [[package]] name = "pytailwindcss" version = "0.2.0" @@ -791,6 +869,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694 }, ] +[[package]] +name = "pytest-base-url" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302 }, +] + [[package]] name = "pytest-cov" version = "6.1.1" @@ -804,6 +895,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 }, ] +[[package]] +name = "pytest-html" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pytest" }, + { name = "pytest-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ab/4862dcb5a8a514bd87747e06b8d55483c0c9e987e1b66972336946e49b49/pytest_html-4.1.1.tar.gz", hash = "sha256:70a01e8ae5800f4a074b56a4cb1025c8f4f9b038bba5fe31e3c98eb996686f07", size = 150773 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl", hash = "sha256:c8152cea03bd4e9bee6d525573b67bbc6622967b72b9628dda0ea3e2a0b5dd71", size = 23491 }, +] + +[[package]] +name = "pytest-metadata" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428 }, +] + +[[package]] +name = "pytest-selenium" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "pytest-base-url" }, + { name = "pytest-html" }, + { name = "pytest-variables" }, + { name = "requests" }, + { name = "selenium" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/c275839e461fdde9ccaaf7ec46f67ad08dbd28bfebc5f8480f883d9da690/pytest_selenium-4.1.0.tar.gz", hash = "sha256:b0a4e1f27750cde631c513c87ae4863dcf9e180e5a1d680a66077da8a669156c", size = 41059 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/27/bac432528c2b20a382ef2423731c1bb8c6292ea5328e3522eccfa9bf0687/pytest_selenium-4.1.0-py3-none-any.whl", hash = "sha256:c6f2c18e91596d3ef360d74c450953767a15193879d3971296498151d1843c01", size = 24131 }, +] + +[[package]] +name = "pytest-variables" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/52/d756c9704a80119f2e8a84e418290f8bd1b7e1415c3417a1b9c13fcd2a87/pytest_variables-3.1.0.tar.gz", hash = "sha256:4719b07f0f6e5d07829b19284a99d9159543a2e0336311f7bc4ee3b1617f595d", size = 7420 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/fe/30dbeccfeafa242b3c9577db059019022cd96db20942c4a74ef9361c5b3c/pytest_variables-3.1.0-py3-none-any.whl", hash = "sha256:4c864d2b7093f9053a2bed61e4b1d027bb26456924e637fcef2d1455d32732b1", size = 6070 }, +] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -848,6 +995,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + [[package]] name = "rich" version = "14.0.0" @@ -884,6 +1046,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/9a/7498a0a40a32ba522840da2b8c0ecb4794114ec332992fda09a0733c25a0/robotframework-7.2.2-py3-none-any.whl", hash = "sha256:1cb4ec69d52aae515bf6037cee66a2a2d8dc3256368081c0f4b3d4578d40904e", size = 777676 }, ] +[[package]] +name = "selenium" +version = "4.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/2d/fafffe946099033ccf22bf89e12eede14c1d3c5936110c5f6f2b9830722c/selenium-4.32.0.tar.gz", hash = "sha256:b9509bef4056f4083772abb1ae19ff57247d617a29255384b26be6956615b206", size = 870997 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/37/d07ed9d13e571b2115d4ed6956d156c66816ceec0b03b2e463e80d09f572/selenium-4.32.0-py3-none-any.whl", hash = "sha256:c4d9613f8a45693d61530c9660560fadb52db7d730237bc788ddedf442391f97", size = 9369668 }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -902,6 +1081,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + [[package]] name = "sqlalchemy" version = "2.0.40" @@ -952,6 +1140,7 @@ dependencies = [ { name = "pykeepass" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-selenium" }, { name = "python-dotenv" }, { name = "python-json-logger" }, ] @@ -964,11 +1153,15 @@ dev = [ { name = "python-dotenv" }, ] test = [ + { name = "allure-pytest" }, { name = "coverage" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-selenium" }, + { name = "requests" }, { name = "robotframework" }, + { name = "selenium" }, ] [package.metadata] @@ -984,6 +1177,7 @@ requires-dist = [ { name = "pykeepass", specifier = ">=4.1.1.post1" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-selenium", specifier = ">=4.1.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-json-logger", specifier = ">=3.3.0" }, ] @@ -996,11 +1190,15 @@ dev = [ { name = "python-dotenv", specifier = ">=1.0.1" }, ] test = [ + { name = "allure-pytest", specifier = ">=2.14.2" }, { name = "coverage", specifier = ">=7.8.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-selenium", specifier = ">=4.1.0" }, + { name = "requests", specifier = ">=2.32.3" }, { name = "robotframework", specifier = ">=7.2.2" }, + { name = "selenium", specifier = ">=4.32.0" }, ] [[package]] @@ -1110,6 +1308,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, ] +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248 }, +] + +[[package]] +name = "trio" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/c1/68d582b4d3a1c1f8118e18042464bb12a7c1b75d64d75111b297687041e3/trio-0.30.0.tar.gz", hash = "sha256:0781c857c0c81f8f51e0089929a26b5bb63d57f927728a5586f7e36171f064df", size = 593776 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/8e/3f6dfda475ecd940e786defe6df6c500734e686c9cd0a0f8ef6821e9b2f2/trio-0.30.0-py3-none-any.whl", hash = "sha256:3bf4f06b8decf8d3cf00af85f40a89824669e2d033bb32469d34840edcfc22a5", size = 499194 }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221 }, +] + [[package]] name = "typer" version = "0.15.3" @@ -1167,6 +1405,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, ] +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + [[package]] name = "uvicorn" version = "0.34.2" @@ -1228,6 +1480,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087 }, ] +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -1247,3 +1508,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, ] + +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 }, +]