wiregui/tests/test_api_routes.py

326 lines
12 KiB
Python
Raw Normal View History

feat: initial WireGUI implementation — full VPN management platform Complete Python/NiceGUI rewrite of the Wirezone (Elixir/Phoenix) VPN management platform. All 10 implementation phases delivered. Core stack: - NiceGUI reactive UI with SQLModel ORM on PostgreSQL (asyncpg) - Alembic migrations, Valkey/Redis cache, pydantic-settings config - WireGuard management via subprocess (wg/ip/nft CLIs) - 164 tests passing, 35% code coverage Features: - User/device/rule CRUD with admin and unprivileged roles - Full device config form with per-device WG overrides - WireGuard client config generation with QR codes - REST API (v0) with Bearer token auth for all resources - TOTP MFA with QR registration and challenge flow - OIDC SSO with authlib (provider registry, auto-create users) - Magic link passwordless sign-in via email - SAML SP-initiated SSO with IdP metadata parsing - WebAuthn/FIDO2 security key registration - nftables firewall with per-user chains and masquerade - Background tasks: WG stats polling, VPN session expiry, OIDC token refresh, WAN connectivity checks - Startup reconciliation (DB ↔ WireGuard state sync) - In-memory notification system with header badge - Admin UI: users, devices, rules, settings (3 tabs), diagnostics - Loguru logging with optional timestamped file output Deployment: - Multi-stage Dockerfile (python:3.13-slim) - Docker Compose prod stack (bridge networking, NET_ADMIN, nftables) - Forgejo CI: tests → semantic versioning → Docker registry push - Health endpoint at /api/health
2026-03-30 16:53:46 -05:00
"""Tests for REST API routes via httpx AsyncClient against the FastAPI app."""
import hashlib
from uuid import UUID, uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from sqlmodel import select
from wiregui.api.deps import get_current_api_user, get_db, require_admin
from wiregui.api.v0 import router as api_router
from wiregui.auth.api_token import generate_api_token
from wiregui.auth.passwords import hash_password
from wiregui.models.api_token import ApiToken
from wiregui.models.configuration import Configuration
from wiregui.models.device import Device
from wiregui.models.rule import Rule
from wiregui.models.user import User
def _build_app(session, admin_user=None, regular_user=None):
"""Build a test FastAPI app with overridden dependencies."""
test_app = FastAPI()
test_app.include_router(api_router, prefix="/api")
async def override_get_db():
yield session
test_app.dependency_overrides[get_db] = override_get_db
if admin_user:
test_app.dependency_overrides[get_current_api_user] = lambda: admin_user
test_app.dependency_overrides[require_admin] = lambda: admin_user
return test_app
async def _make_admin(session) -> User:
user = User(email="api-admin@test.com", password_hash=hash_password("pw"), role="admin")
session.add(user)
await session.flush()
return user
async def _make_user(session, email="api-user@test.com") -> User:
user = User(email=email, password_hash=hash_password("pw"), role="unprivileged")
session.add(user)
await session.flush()
return user
# ========== Users API ==========
async def test_list_users(session):
admin = await _make_admin(session)
await _make_user(session, "user1@test.com")
await _make_user(session, "user2@test.com")
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/v0/users/")
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 3 # admin + 2 users
async def test_get_user(session):
admin = await _make_admin(session)
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(f"/api/v0/users/{admin.id}")
assert resp.status_code == 200
assert resp.json()["email"] == "api-admin@test.com"
async def test_get_user_not_found(session):
admin = await _make_admin(session)
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(f"/api/v0/users/{uuid4()}")
assert resp.status_code == 404
async def test_create_user(session):
admin = await _make_admin(session)
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/api/v0/users/", json={
"email": "new-api-user@test.com",
"password": "secret123",
"role": "unprivileged",
})
assert resp.status_code == 201
data = resp.json()
assert data["email"] == "new-api-user@test.com"
assert data["role"] == "unprivileged"
assert "id" in data
async def test_update_user(session):
admin = await _make_admin(session)
user = await _make_user(session)
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.put(f"/api/v0/users/{user.id}", json={
"role": "admin",
})
assert resp.status_code == 200
assert resp.json()["role"] == "admin"
async def test_update_user_password(session):
admin = await _make_admin(session)
user = await _make_user(session)
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.put(f"/api/v0/users/{user.id}", json={
"password": "new-password-123",
})
assert resp.status_code == 200
from wiregui.auth.passwords import verify_password
refreshed = await session.get(User, user.id)
assert verify_password("new-password-123", refreshed.password_hash)
async def test_delete_user(session):
admin = await _make_admin(session)
user = await _make_user(session)
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.delete(f"/api/v0/users/{user.id}")
assert resp.status_code == 204
assert await session.get(User, user.id) is None
# ========== Devices API ==========
async def test_list_devices_admin_sees_all(session):
admin = await _make_admin(session)
user = await _make_user(session)
session.add(Device(name="d1", public_key="pk-api-d1", user_id=admin.id))
session.add(Device(name="d2", public_key="pk-api-d2", user_id=user.id))
await session.flush()
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/v0/devices/")
assert resp.status_code == 200
assert len(resp.json()) >= 2
async def test_list_devices_user_sees_own(session):
admin = await _make_admin(session)
user = await _make_user(session, "own-devices@test.com")
session.add(Device(name="mine", public_key="pk-api-mine", user_id=user.id))
session.add(Device(name="not-mine", public_key="pk-api-notmine", user_id=admin.id))
await session.flush()
# Override to be the regular user
test_app = _build_app(session)
test_app.dependency_overrides[get_current_api_user] = lambda: user
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://test") as client:
resp = await client.get("/api/v0/devices/")
assert resp.status_code == 200
names = [d["name"] for d in resp.json()]
assert "mine" in names
assert "not-mine" not in names
async def test_get_device(session):
admin = await _make_admin(session)
device = Device(name="detail", public_key="pk-api-detail", user_id=admin.id, ipv4="10.0.0.5")
session.add(device)
await session.flush()
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get(f"/api/v0/devices/{device.id}")
assert resp.status_code == 200
assert resp.json()["name"] == "detail"
assert resp.json()["ipv4"] == "10.0.0.5"
async def test_get_device_forbidden_for_other_user(session):
admin = await _make_admin(session)
user = await _make_user(session, "other-dev@test.com")
device = Device(name="admin-dev", public_key="pk-api-forbid", user_id=admin.id)
session.add(device)
await session.flush()
test_app = _build_app(session)
test_app.dependency_overrides[get_current_api_user] = lambda: user
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://test") as client:
resp = await client.get(f"/api/v0/devices/{device.id}")
assert resp.status_code == 403
async def test_update_device(session):
admin = await _make_admin(session)
device = Device(name="old-name", public_key="pk-api-update", user_id=admin.id)
session.add(device)
await session.flush()
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.put(f"/api/v0/devices/{device.id}", json={"name": "new-name"})
assert resp.status_code == 200
assert resp.json()["name"] == "new-name"
async def test_delete_device(session):
admin = await _make_admin(session)
device = Device(name="to-delete", public_key="pk-api-del", user_id=admin.id)
session.add(device)
await session.flush()
did = device.id
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.delete(f"/api/v0/devices/{did}")
assert resp.status_code == 204
assert await session.get(Device, did) is None
# ========== Rules API ==========
async def test_list_rules(session):
admin = await _make_admin(session)
session.add(Rule(action="accept", destination="10.0.0.0/8"))
session.add(Rule(action="drop", destination="192.168.0.0/16", user_id=admin.id))
await session.flush()
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/v0/rules/")
assert resp.status_code == 200
assert len(resp.json()) >= 2
async def test_create_rule(session):
admin = await _make_admin(session)
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/api/v0/rules/", json={
"action": "accept",
"destination": "172.16.0.0/12",
"port_type": "tcp",
"port_range": "443",
})
assert resp.status_code == 201
data = resp.json()
assert data["action"] == "accept"
assert data["destination"] == "172.16.0.0/12"
assert data["port_type"] == "tcp"
assert data["port_range"] == "443"
async def test_update_rule(session):
admin = await _make_admin(session)
rule = Rule(action="accept", destination="10.0.0.0/8")
session.add(rule)
await session.flush()
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.put(f"/api/v0/rules/{rule.id}", json={"action": "drop"})
assert resp.status_code == 200
assert resp.json()["action"] == "drop"
async def test_delete_rule(session):
admin = await _make_admin(session)
rule = Rule(action="drop", destination="0.0.0.0/0")
session.add(rule)
await session.flush()
rid = rule.id
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.delete(f"/api/v0/rules/{rid}")
assert resp.status_code == 204
assert await session.get(Rule, rid) is None
# ========== Configuration API ==========
async def test_get_configuration_auto_creates(session):
admin = await _make_admin(session)
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/api/v0/configuration/")
assert resp.status_code == 200
data = resp.json()
assert data["default_client_mtu"] == 1280
assert data["local_auth_enabled"] is True
async def test_update_configuration(session):
admin = await _make_admin(session)
# Pre-create config
config = Configuration()
session.add(config)
await session.flush()
app = _build_app(session, admin_user=admin)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.put("/api/v0/configuration/", json={
"default_client_mtu": 1400,
"vpn_session_duration": 3600,
"default_client_dns": ["8.8.8.8"],
})
assert resp.status_code == 200
data = resp.json()
assert data["default_client_mtu"] == 1400
assert data["vpn_session_duration"] == 3600
assert data["default_client_dns"] == ["8.8.8.8"]