feat: comprehensive test suite + SAML auth fixes + mock SAML IdP
Tests (198 unit + 70 e2e = 268 total): - Add test_api_deps.py: Bearer token auth, get_current_api_user, require_admin - Add test_wireguard_extended.py: ensure_interface, set_private_key, set_listen_port - Add test_firewall_extended.py: _nft/_nft_batch errors, jump rules, policies - Add test_mfa_login.py: MFA redirect, TOTP verify, invalid code, cancel - Add test_magic_link_page.py: page render, submit, empty email, back to login - Add test_admin_devices.py: list, filter, create, edit, delete, config dialog - Add test_admin_rules.py: list, create, edit, delete (all DB-verified) - Add test_admin_settings.py: defaults, security, OIDC/SAML providers - Add test_saml_login.py: button visible, redirect, metadata, full login flow Bug fixes: - Fix SAML callback to use /auth/complete bridge (same fix as OIDC) - Fix missing get_settings import in admin settings page - Add SAML provider buttons to login page - Make SAML strict mode configurable per-provider Infrastructure: - Add mock SimpleSAMLphp IdP to compose.yml with SP config - Add mock-saml service to CI workflows (release + dev)
This commit is contained in:
parent
25cff5e4d9
commit
06b5a3dc12
18 changed files with 1768 additions and 47 deletions
281
tests/e2e/test_admin_settings.py
Normal file
281
tests/e2e/test_admin_settings.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""E2E tests for admin settings page — client defaults, security, OIDC/SAML providers."""
|
||||
|
||||
import pytest_asyncio
|
||||
from playwright.async_api import Page, expect
|
||||
from sqlmodel import select
|
||||
|
||||
from wiregui.db import async_session
|
||||
from wiregui.models.configuration import Configuration
|
||||
from wiregui.models.user import User
|
||||
from tests.e2e.conftest import TEST_APP_BASE, login
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def reset_config(app_server):
|
||||
"""Snapshot config before test, restore after."""
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||
if not c:
|
||||
yield
|
||||
return
|
||||
snap = {
|
||||
"default_client_endpoint": c.default_client_endpoint,
|
||||
"default_client_dns": list(c.default_client_dns),
|
||||
"default_client_mtu": c.default_client_mtu,
|
||||
"default_client_persistent_keepalive": c.default_client_persistent_keepalive,
|
||||
"default_client_allowed_ips": list(c.default_client_allowed_ips),
|
||||
"vpn_session_duration": c.vpn_session_duration,
|
||||
"local_auth_enabled": c.local_auth_enabled,
|
||||
"allow_unprivileged_device_management": c.allow_unprivileged_device_management,
|
||||
"allow_unprivileged_device_configuration": c.allow_unprivileged_device_configuration,
|
||||
"openid_connect_providers": list(c.openid_connect_providers or []),
|
||||
"saml_identity_providers": list(c.saml_identity_providers or []),
|
||||
}
|
||||
cid = c.id
|
||||
|
||||
yield
|
||||
|
||||
async with async_session() as session:
|
||||
c = await session.get(Configuration, cid)
|
||||
if c:
|
||||
for k, v in snap.items():
|
||||
setattr(c, k, v)
|
||||
session.add(c)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _go_to_settings(page: Page):
|
||||
await login(page)
|
||||
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||
await page.goto(f"{TEST_APP_BASE}/admin/settings")
|
||||
await expect(page.get_by_text("Default Client Configuration")).to_be_visible(timeout=10_000)
|
||||
|
||||
|
||||
# --- Client Defaults ---
|
||||
|
||||
|
||||
async def test_save_client_defaults(page: Page, test_user: User):
|
||||
"""Save endpoint, DNS, MTU, keepalive, allowed IPs — verify persists in DB."""
|
||||
await _go_to_settings(page)
|
||||
|
||||
endpoint = page.locator("input[aria-label='Endpoint']")
|
||||
await endpoint.clear()
|
||||
await endpoint.fill("vpn.test.local")
|
||||
|
||||
dns = page.locator("input[aria-label='DNS Servers']")
|
||||
await dns.clear()
|
||||
await dns.fill("9.9.9.9, 149.112.112.112")
|
||||
|
||||
mtu = page.locator("input[aria-label='MTU']")
|
||||
await mtu.clear()
|
||||
await mtu.fill("1420")
|
||||
|
||||
keepalive = page.locator("input[aria-label='Persistent Keepalive']")
|
||||
await keepalive.clear()
|
||||
await keepalive.fill("30")
|
||||
|
||||
allowed = page.locator("input[aria-label='Allowed IPs']")
|
||||
await allowed.clear()
|
||||
await allowed.fill("10.0.0.0/8, 192.168.0.0/16")
|
||||
|
||||
await page.get_by_role("button", name="Save Defaults").click()
|
||||
await expect(page.get_by_text("Client defaults saved")).to_be_visible(timeout=5_000)
|
||||
|
||||
# Verify in DB
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
assert c.default_client_endpoint == "vpn.test.local"
|
||||
assert c.default_client_dns == ["9.9.9.9", "149.112.112.112"]
|
||||
assert c.default_client_mtu == 1420
|
||||
assert c.default_client_persistent_keepalive == 30
|
||||
assert c.default_client_allowed_ips == ["10.0.0.0/8", "192.168.0.0/16"]
|
||||
|
||||
|
||||
async def test_client_defaults_persist_on_reload(page: Page, test_user: User):
|
||||
"""Saved defaults are reflected after page reload."""
|
||||
# Set values via DB
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
c.default_client_endpoint = "reload-test.vpn"
|
||||
c.default_client_dns = ["8.8.8.8"]
|
||||
c.default_client_mtu = 1500
|
||||
c.default_client_persistent_keepalive = 15
|
||||
c.default_client_allowed_ips = ["172.16.0.0/12"]
|
||||
session.add(c)
|
||||
await session.commit()
|
||||
|
||||
await _go_to_settings(page)
|
||||
|
||||
await expect(page.locator("input[aria-label='Endpoint']")).to_have_value("reload-test.vpn")
|
||||
await expect(page.locator("input[aria-label='DNS Servers']")).to_have_value("8.8.8.8")
|
||||
await expect(page.locator("input[aria-label='MTU']")).to_have_value("1500")
|
||||
await expect(page.locator("input[aria-label='Persistent Keepalive']")).to_have_value("15")
|
||||
await expect(page.locator("input[aria-label='Allowed IPs']")).to_have_value("172.16.0.0/12")
|
||||
|
||||
|
||||
# --- Security ---
|
||||
|
||||
|
||||
async def test_save_security_local_auth_toggle(page: Page, test_user: User):
|
||||
"""Toggle local auth off — verify in DB."""
|
||||
await _go_to_settings(page)
|
||||
|
||||
# Find the local auth switch and toggle it off
|
||||
switch = page.locator(".q-toggle", has_text="Local Authentication")
|
||||
await switch.click()
|
||||
|
||||
await page.get_by_role("button", name="Save Security Settings").click()
|
||||
await expect(page.get_by_text("Security settings saved")).to_be_visible(timeout=5_000)
|
||||
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
assert c.local_auth_enabled is False
|
||||
|
||||
|
||||
async def test_save_vpn_session_duration(page: Page, test_user: User):
|
||||
"""Change VPN session duration — verify in DB."""
|
||||
await _go_to_settings(page)
|
||||
|
||||
await page.locator("label:has-text('VPN Session Duration')").click()
|
||||
await page.get_by_role("option", name="Every Day").click()
|
||||
|
||||
await page.get_by_role("button", name="Save Security Settings").click()
|
||||
await expect(page.get_by_text("Security settings saved")).to_be_visible(timeout=5_000)
|
||||
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
assert c.vpn_session_duration == 86400
|
||||
|
||||
|
||||
async def test_save_unprivileged_toggles(page: Page, test_user: User):
|
||||
"""Toggle unprivileged device management/configuration — verify in DB."""
|
||||
await _go_to_settings(page)
|
||||
|
||||
await page.locator(".q-toggle", has_text="Allow Unprivileged Device Management").click()
|
||||
await page.locator(".q-toggle", has_text="Allow Unprivileged Device Configuration").click()
|
||||
|
||||
await page.get_by_role("button", name="Save Security Settings").click()
|
||||
await expect(page.get_by_text("Security settings saved")).to_be_visible(timeout=5_000)
|
||||
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
# Toggled from default (True) to False
|
||||
assert c.allow_unprivileged_device_management is False
|
||||
assert c.allow_unprivileged_device_configuration is False
|
||||
|
||||
|
||||
# --- OIDC Providers ---
|
||||
|
||||
|
||||
async def test_add_oidc_provider(page: Page, test_user: User):
|
||||
"""Add an OIDC provider — appears in table and DB."""
|
||||
await _go_to_settings(page)
|
||||
|
||||
await page.get_by_role("button", name="Add OIDC Provider").click()
|
||||
await expect(page.get_by_text("OIDC Provider", exact=True)).to_be_visible(timeout=5_000)
|
||||
|
||||
await page.locator(".q-dialog input[aria-label='Config ID']").fill("e2e-test-oidc")
|
||||
await page.locator(".q-dialog input[aria-label='Label']").fill("E2E Test IdP")
|
||||
await page.locator(".q-dialog input[aria-label='Client ID']").fill("test-client-id")
|
||||
await page.locator(".q-dialog input[aria-label='Client Secret']").fill("test-client-secret")
|
||||
await page.locator(".q-dialog input[aria-label='Discovery Document URI']").fill("https://idp.test/.well-known/openid-configuration")
|
||||
|
||||
await page.locator(".q-dialog").get_by_role("button", name="Save").click()
|
||||
await expect(page.get_by_text("OIDC provider 'E2E Test IdP' saved")).to_be_visible(timeout=5_000)
|
||||
|
||||
await expect(page.get_by_role("cell", name="e2e-test-oidc")).to_be_visible(timeout=5_000)
|
||||
|
||||
# Verify in DB
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
provider = next((p for p in c.openid_connect_providers if p["id"] == "e2e-test-oidc"), None)
|
||||
assert provider is not None
|
||||
assert provider["label"] == "E2E Test IdP"
|
||||
assert provider["client_id"] == "test-client-id"
|
||||
|
||||
|
||||
async def test_delete_oidc_provider(page: Page, test_user: User):
|
||||
"""Delete an OIDC provider — removed from table and DB."""
|
||||
# Seed a provider
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
providers = list(c.openid_connect_providers or [])
|
||||
providers.append({
|
||||
"id": "delete-me-oidc", "label": "Delete Me", "scope": "openid",
|
||||
"client_id": "x", "client_secret": "x",
|
||||
"discovery_document_uri": "https://x/.well-known/openid-configuration",
|
||||
})
|
||||
c.openid_connect_providers = providers
|
||||
session.add(c)
|
||||
await session.commit()
|
||||
|
||||
await _go_to_settings(page)
|
||||
await expect(page.get_by_role("cell", name="delete-me-oidc")).to_be_visible(timeout=5_000)
|
||||
|
||||
row = page.locator("tr", has_text="delete-me-oidc")
|
||||
await row.locator(".q-btn").first.click()
|
||||
|
||||
await expect(page.get_by_text("OIDC provider deleted")).to_be_visible(timeout=5_000)
|
||||
await page.wait_for_timeout(500)
|
||||
await expect(page.get_by_role("cell", name="delete-me-oidc")).not_to_be_visible()
|
||||
|
||||
# Verify in DB
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
assert not any(p["id"] == "delete-me-oidc" for p in c.openid_connect_providers)
|
||||
|
||||
|
||||
# --- SAML Providers ---
|
||||
|
||||
|
||||
async def test_add_saml_provider(page: Page, test_user: User):
|
||||
"""Add a SAML provider — appears in table and DB."""
|
||||
await _go_to_settings(page)
|
||||
|
||||
await page.get_by_role("button", name="Add SAML Provider").click()
|
||||
await expect(page.get_by_text("SAML Identity Provider", exact=True)).to_be_visible(timeout=5_000)
|
||||
|
||||
await page.locator(".q-dialog input[aria-label='Config ID']").fill("e2e-test-saml")
|
||||
await page.locator(".q-dialog input[aria-label='Label']").fill("E2E SAML IdP")
|
||||
await page.locator(".q-dialog textarea").fill("<EntityDescriptor>test</EntityDescriptor>")
|
||||
|
||||
await page.locator(".q-dialog").get_by_role("button", name="Save").click()
|
||||
await expect(page.get_by_text("SAML provider 'E2E SAML IdP' saved")).to_be_visible(timeout=5_000)
|
||||
|
||||
await expect(page.get_by_role("cell", name="e2e-test-saml")).to_be_visible(timeout=5_000)
|
||||
|
||||
# Verify in DB
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
provider = next((p for p in c.saml_identity_providers if p["id"] == "e2e-test-saml"), None)
|
||||
assert provider is not None
|
||||
assert provider["label"] == "E2E SAML IdP"
|
||||
|
||||
|
||||
async def test_delete_saml_provider(page: Page, test_user: User):
|
||||
"""Delete a SAML provider — removed from table and DB."""
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
providers = list(c.saml_identity_providers or [])
|
||||
providers.append({
|
||||
"id": "delete-me-saml", "label": "Delete Me SAML",
|
||||
"metadata": "<EntityDescriptor/>",
|
||||
})
|
||||
c.saml_identity_providers = providers
|
||||
session.add(c)
|
||||
await session.commit()
|
||||
|
||||
await _go_to_settings(page)
|
||||
await expect(page.get_by_role("cell", name="delete-me-saml")).to_be_visible(timeout=5_000)
|
||||
|
||||
row = page.locator("tr", has_text="delete-me-saml")
|
||||
await row.locator(".q-btn").first.click()
|
||||
|
||||
await expect(page.get_by_text("SAML provider deleted")).to_be_visible(timeout=5_000)
|
||||
await page.wait_for_timeout(500)
|
||||
await expect(page.get_by_role("cell", name="delete-me-saml")).not_to_be_visible()
|
||||
|
||||
# Verify in DB
|
||||
async with async_session() as session:
|
||||
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||
assert not any(p["id"] == "delete-me-saml" for p in c.saml_identity_providers)
|
||||
Loading…
Add table
Add a link
Reference in a new issue