From 5adb0c86cea399621f6aa6813314fdc7bb95885d Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Mon, 30 Mar 2026 22:26:15 -0500 Subject: [PATCH] feat: add E2E tests for device creation and account management 10 E2E tests using NiceGUI's User fixture: - Device creation flow and name validation - Password change (success, wrong current, mismatch, too short) - API token creation, TOTP registration, invalid code rejection - Account deletion with email confirmation Tests live in tests/e2e/ with a separate conftest that loads the NiceGUI testing plugin. CI runs unit and E2E tests as separate steps. --- .forgejo/workflows/release.yml | 7 +- TODO.md | 8 +- pyproject.toml | 1 + tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 69 +++++++++++++++ tests/e2e/test_account.py | 149 +++++++++++++++++++++++++++++++++ tests/e2e/test_devices.py | 54 ++++++++++++ 7 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_account.py create mode 100644 tests/e2e/test_devices.py diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7917fdf..a7018fe 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -40,8 +40,11 @@ jobs: - name: Install dependencies run: uv sync - - name: Run tests - run: uv run pytest -v --tb=short + - name: Run unit tests + run: uv run pytest tests/ --ignore=tests/e2e -v --tb=short + + - name: Run E2E tests + run: uv run pytest tests/e2e/ -v --tb=short release: needs: test diff --git a/TODO.md b/TODO.md index 120e7d9..42483a0 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI. Source: `/home/stefanob/PycharmProjects/personal/wirezone` -**Test count: 164 (163 passing, 1 skipped) | Coverage: 35%** +**Test count: 174 (173 passing, 1 skipped) | Coverage: 35%** --- @@ -181,8 +181,10 @@ Source: `/home/stefanob/PycharmProjects/personal/wirezone` - [ ] `wiregui/auth/saml.py` (0%) — needs mock SAML IdP metadata + response parsing - [ ] `wiregui/auth/webauthn.py` — test verify_registration, verify_authentication with mock credential data -**Pages (0% — requires E2E testing):** -- [ ] Consider Playwright or NiceGUI's testing utilities for E2E page tests +**E2E page tests (via NiceGUI `User` fixture in `tests/e2e/`):** +- [x] `tests/e2e/test_devices.py` (2 tests) — add device full flow, name validation +- [x] `tests/e2e/test_account.py` (8 tests) — change password (success/wrong/mismatch/short), create API token, TOTP registration + invalid code, account deletion +- [ ] E2E tests for admin pages (users, devices, rules, settings) ### Logging (done) - [x] Loguru configured (wiregui/logging.py), no print statements diff --git a/pyproject.toml b/pyproject.toml index 0094cfd..74ed08b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,3 +47,4 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" testpaths = ["tests"] +main_file = "wiregui/main.py" diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..c872d97 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,69 @@ +"""E2E test configuration — loads NiceGUI testing plugin and app.""" + +import pytest +from sqlmodel import select + +from wiregui.auth.passwords import hash_password +from wiregui.db import async_session +from wiregui.models.api_token import ApiToken +from wiregui.models.configuration import Configuration +from wiregui.models.device import Device +from wiregui.models.mfa_method import MFAMethod +from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.rule import Rule +from wiregui.models.user import User + +pytest_plugins = ["nicegui.testing.user_plugin"] + +FAKE_SERVER_KEY = "SFake0ServerPubKey0000000000000000000000000w=" +TEST_EMAIL = "e2e-test@example.com" +TEST_PASSWORD = "testpass123" + +RELATED_MODELS = (Device, Rule, MFAMethod, ApiToken, OIDCConnection) + + +async def _delete_user_cascade(session, user_id): + """Delete a user and all related objects.""" + for model in RELATED_MODELS: + for obj in (await session.execute(select(model).where(model.user_id == user_id))).scalars().all(): + await session.delete(obj) + u = await session.get(User, user_id) + if u: + await session.delete(u) + + +@pytest.fixture +async def test_user(): + """Create a test user and ensure server config has a public key.""" + async with async_session() as session: + # Clean up any leftover from a previous failed run + existing = (await session.execute(select(User).where(User.email == TEST_EMAIL))).scalar_one_or_none() + if existing: + await _delete_user_cascade(session, existing.id) + await session.commit() + + # Ensure a Configuration with a server key exists + config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none() + if config: + if not config.server_public_key: + config.server_public_key = FAKE_SERVER_KEY + session.add(config) + else: + config = Configuration(server_public_key=FAKE_SERVER_KEY) + session.add(config) + + user = User( + email=TEST_EMAIL, + password_hash=hash_password(TEST_PASSWORD), + role="admin", + ) + session.add(user) + await session.commit() + await session.refresh(user) + + yield user + + # Teardown + async with async_session() as session: + await _delete_user_cascade(session, user.id) + await session.commit() diff --git a/tests/e2e/test_account.py b/tests/e2e/test_account.py new file mode 100644 index 0000000..f8744e3 --- /dev/null +++ b/tests/e2e/test_account.py @@ -0,0 +1,149 @@ +"""End-to-end tests for account page — password, TOTP, API tokens, deletion.""" + +from unittest.mock import patch + +import pytest +from nicegui import ui +from nicegui.testing import User + +from wiregui.models.user import User as UserModel +from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD + + +async def _login(user: User): + """Log in and navigate to account page.""" + await user.open("/login") + user.find("Email").type(TEST_EMAIL) + user.find("Password").type(TEST_PASSWORD) + user.find("Sign in").click() + await user.should_see("My Devices") + await user.open("/account") + await user.should_see("Account Settings") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_change_password(user: User, test_user: UserModel): + """Test changing password: fill form, submit, verify success.""" + await _login(user) + + user.find("Current Password").type(TEST_PASSWORD) + user.find("New Password").type("newpass12345") + user.find("Confirm Password").type("newpass12345") + user.find("Update Password").click() + await user.should_see("Password changed") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_change_password_wrong_current(user: User, test_user: UserModel): + """Test that wrong current password is rejected.""" + await _login(user) + + user.find("Current Password").type("wrongpassword") + user.find("New Password").type("newpass12345") + user.find("Confirm Password").type("newpass12345") + user.find("Update Password").click() + await user.should_see("Wrong current password") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_change_password_mismatch(user: User, test_user: UserModel): + """Test that mismatched passwords are rejected.""" + await _login(user) + + user.find("Current Password").type(TEST_PASSWORD) + user.find("New Password").type("newpass12345") + user.find("Confirm Password").type("differentpass") + user.find("Update Password").click() + await user.should_see("Passwords don't match") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_change_password_too_short(user: User, test_user: UserModel): + """Test that short passwords are rejected.""" + await _login(user) + + user.find("Current Password").type(TEST_PASSWORD) + user.find("New Password").type("short") + user.find("Confirm Password").type("short") + user.find("Update Password").click() + await user.should_see("Min 8 characters") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_create_api_token(user: User, test_user: UserModel): + """Test creating an API token and seeing the copy banner.""" + await _login(user) + + await user.should_see("No API tokens.") + user.find("Add API Token").click() + await user.should_see("Copy now") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_totp_registration_flow(user: User, test_user: UserModel): + """Test starting TOTP registration shows QR and verify form.""" + with patch("wiregui.pages.account.generate_totp_secret", return_value="JBSWY3DPEHPK3PXP"), \ + patch("wiregui.pages.account.generate_totp_qr_svg", return_value=''), \ + patch("wiregui.pages.account.get_totp_uri", return_value="otpauth://totp/WireGUI:test?secret=JBSWY3DPEHPK3PXP"): + + await _login(user) + + await user.should_see("No MFA methods configured.") + user.find("Add TOTP Method").click() + await user.should_see("Register TOTP Authenticator") + await user.should_see("JBSWY3DPEHPK3PXP") + await user.should_see("Verify & Save") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_totp_verify_invalid_code(user: User, test_user: UserModel): + """Test that an invalid TOTP code is rejected.""" + with patch("wiregui.pages.account.generate_totp_secret", return_value="JBSWY3DPEHPK3PXP"), \ + patch("wiregui.pages.account.generate_totp_qr_svg", return_value=''), \ + patch("wiregui.pages.account.get_totp_uri", return_value="otpauth://totp/WireGUI:test?secret=JBSWY3DPEHPK3PXP"): + + await _login(user) + + user.find("Add TOTP Method").click() + await user.should_see("Register TOTP Authenticator") + + user.find("6-digit verification code").type("000000") + user.find("Verify & Save").click() + await user.should_see("Invalid code") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_delete_account(user: User, test_user: UserModel): + """Test account deletion flow with email confirmation.""" + # Create a second admin first so deletion is allowed + from wiregui.db import async_session + from wiregui.auth.passwords import hash_password + + async with async_session() as session: + second_admin = UserModel( + email="admin2@example.com", + password_hash=hash_password("admin2pass"), + role="admin", + ) + session.add(second_admin) + await session.commit() + + try: + await _login(user) + + user.find("Delete Your Account").click() + await user.should_see("Delete Your Account?") + + user.find(ui.input).type(TEST_EMAIL) + user.find("Delete My Account").click() + + # Should redirect to login + await user.should_see("Sign in") + finally: + # Clean up second admin + async with async_session() as session: + from sqlmodel import select + a2 = (await session.execute(select(UserModel).where(UserModel.email == "admin2@example.com"))).scalar_one_or_none() + if a2: + await session.delete(a2) + await session.commit() diff --git a/tests/e2e/test_devices.py b/tests/e2e/test_devices.py new file mode 100644 index 0000000..4e23cbe --- /dev/null +++ b/tests/e2e/test_devices.py @@ -0,0 +1,54 @@ +"""End-to-end tests for device management UI using NiceGUI's User fixture.""" + +from unittest.mock import patch + +import pytest +from nicegui.testing import User + +from wiregui.models.user import User as UserModel +from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD + +# Fake WG keys for testing (valid base64, 32 bytes) +FAKE_PRIVATE_KEY = "YFake0PrivateKey00000000000000000000000000w=" +FAKE_PUBLIC_KEY = "ZFake0PublicKey000000000000000000000000000w=" + + +async def _login(user: User): + """Helper to log in via the UI.""" + await user.open("/login") + user.find("Email").type(TEST_EMAIL) + user.find("Password").type(TEST_PASSWORD) + user.find("Sign in").click() + await user.should_see("My Devices") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_add_device_via_ui(user: User, test_user: UserModel): + """Test the full flow: login → devices → add device → see it in table.""" + with patch("wiregui.pages.devices.generate_keypair", return_value=(FAKE_PRIVATE_KEY, FAKE_PUBLIC_KEY)), \ + patch("wiregui.pages.devices.generate_preshared_key", return_value="cHJlc2hhcmVkMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="): + + await _login(user) + + # Open create dialog + user.find("Add Device").click() + await user.should_see("New Device") + + # Fill device name and submit + user.find("Device Name").type("Test Laptop") + user.find("Create").click() + + # Should see config dialog with the device config + await user.should_see("Test Laptop") + + +@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) +async def test_add_device_requires_name(user: User, test_user: UserModel): + """Test that creating a device without a name shows an error.""" + await _login(user) + + # Open create dialog and submit without name + user.find("Add Device").click() + await user.should_see("New Device") + user.find("Create").click() + await user.should_see("Device name is required")