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")