diff --git a/.gitignore b/.gitignore index 5da8c0b..74e4d49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea +.venv/ +.ruff_cache/ +.vscode/ .env -.venv -.ruff_cache \ No newline at end of file +__pycache__/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0e1e0a5..da2043d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,13 @@ select = ["E", "F", "I", "B", "UP"] quote-style = "double" indent-style = "space" +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["app"] +addopts = "-q" + [dependency-groups] dev = [ + "pytest>=9.0.3", "ruff>=0.15.10", ] diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..a3d400f --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,112 @@ +import importlib +from datetime import UTC, datetime, timedelta + +from core.security import AuthContext, get_auth_context +from models.auth import AccessTokenPayload, AuthUserDocument + +auth_router_module = importlib.import_module("api.auth.router") + + +def test_login_success_returns_bearer_token(client, monkeypatch: object): + user = AuthUserDocument(username="admin", password_hash="stored-hash") + + async def fake_get_user_by_username(username: str) -> AuthUserDocument | None: + assert username == "admin" + return user + + def fake_verify_password(plain_password: str, password_hash: str) -> bool: + return plain_password == "secret" and password_hash == "stored-hash" + + def fake_create_access_token(*, subject: str) -> str: + assert subject == "admin" + return "token-123" + + monkeypatch.setattr( + auth_router_module, + "get_user_by_username", + fake_get_user_by_username, + ) + monkeypatch.setattr(auth_router_module, "verify_password", fake_verify_password) + monkeypatch.setattr( + auth_router_module, + "create_access_token", + fake_create_access_token, + ) + + response = client.post( + "/auth/login", + json={"username": "admin", "password": "secret"}, + ) + + assert response.status_code == 200 + assert response.json() == {"access_token": "token-123", "token_type": "bearer"} + + +def test_login_invalid_credentials_returns_401(client, monkeypatch: object): + async def fake_get_user_by_username(_: str) -> None: + return None + + monkeypatch.setattr( + auth_router_module, + "get_user_by_username", + fake_get_user_by_username, + ) + + response = client.post( + "/auth/login", + json={"username": "admin", "password": "wrong"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid username or password" + assert response.headers["www-authenticate"] == "Bearer" + + +def test_logout_revokes_active_token(client, monkeypatch: object): + captured: dict[str, object] = {} + + async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None: + captured["jti"] = jti + captured["expires_at"] = expires_at + + monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token) + + response = client.post("/auth/logout") + + assert response.status_code == 204 + assert captured["jti"] == "test-jti" + assert isinstance(captured["expires_at"], datetime) + + +def test_logout_skips_revocation_when_token_is_expired( + client, + fake_user: AuthUserDocument, + monkeypatch: object, +): + state = {"called": False} + + async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None: + state["called"] = True + + monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token) + + expired_payload = AccessTokenPayload( + sub=fake_user.username, + jti="expired-jti", + exp=int((datetime.now(UTC) - timedelta(minutes=1)).timestamp()), + ) + expired_context = AuthContext( + token="expired-token", + user=fake_user, + payload=expired_payload, + ) + + async def override_auth_context() -> AuthContext: + return expired_context + + client.app.dependency_overrides[get_auth_context] = override_auth_context + + response = client.post("/auth/logout") + + assert response.status_code == 204 + assert state["called"] is False diff --git a/tests/api/test_proxy.py b/tests/api/test_proxy.py new file mode 100644 index 0000000..d521a8f --- /dev/null +++ b/tests/api/test_proxy.py @@ -0,0 +1,147 @@ +import importlib + +from core.security import get_auth_context, require_authenticated_user +from models.proxy import ProxyDocument +from models.service import ServiceEndpointDocument + +proxy_router_module = importlib.import_module("api.proxy.router") + + +def make_proxy_document( + *, + proxy_port: int, + name: str = "svc", + code: str = "proxy-code", + address: str = "127.0.0.1", + port: int = 9000, +) -> ProxyDocument: + return ProxyDocument( + proxy_port=proxy_port, + name=name, + code=code, + service=ServiceEndpointDocument(address=address, port=port), + ) + + +def test_proxy_requires_auth_without_dependency_overrides(client): + client.app.dependency_overrides.pop(require_authenticated_user, None) + client.app.dependency_overrides.pop(get_auth_context, None) + + response = client.get("/proxy") + + assert response.status_code == 401 + assert response.json()["detail"] == "Could not validate credentials" + + +def test_list_proxies_returns_documents(client, monkeypatch: object): + docs = [ + make_proxy_document(proxy_port=8080, name="api"), + make_proxy_document(proxy_port=8081, name="worker"), + ] + + async def fake_list_proxies() -> list[ProxyDocument]: + return docs + + monkeypatch.setattr(proxy_router_module, "list_proxy_documents", fake_list_proxies) + + response = client.get("/proxy") + + assert response.status_code == 200 + body = response.json() + assert [item["proxy_port"] for item in body] == [8080, 8081] + + +def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object): + async def fake_get_proxy_by_port(proxy_port: int) -> None: + return None + + monkeypatch.setattr( + proxy_router_module, + "get_proxy_by_port", + fake_get_proxy_by_port, + ) + + response = client.get("/proxy/9999") + + assert response.status_code == 404 + assert response.json()["detail"] == "Proxy not found" + + +def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object): + existing = make_proxy_document(proxy_port=3671) + + async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None: + assert proxy_port == 3671 + return existing + + monkeypatch.setattr( + proxy_router_module, + "get_proxy_by_port", + fake_get_proxy_by_port, + ) + + response = client.post( + "/proxy", + json={ + "proxy_port": 3671, + "name": "new", + "code": "abc", + "service": {"address": "localhost", "port": 8080}, + }, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "Proxy already exists on port 3671" + + +def test_create_proxy_returns_created_document(client, monkeypatch: object): + captured: dict[str, object] = {} + + async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None: + assert proxy_port == 4040 + return None + + async def fake_create_proxy( + *, + proxy_port: int, + name: str, + service: dict[str, object], + code: str, + ) -> ProxyDocument: + captured["proxy_port"] = proxy_port + captured["name"] = name + captured["service"] = service + captured["code"] = code + return make_proxy_document( + proxy_port=proxy_port, + name=name, + code=code, + address=str(service["address"]), + port=int(service["port"]), + ) + + monkeypatch.setattr( + proxy_router_module, + "get_proxy_by_port", + fake_get_proxy_by_port, + ) + monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy) + + response = client.post( + "/proxy", + json={ + "proxy_port": 4040, + "name": "edge", + "code": "edge-code", + "service": {"address": "service.local", "port": 8181}, + }, + ) + + assert response.status_code == 201 + assert response.json()["proxy_port"] == 4040 + assert captured == { + "proxy_port": 4040, + "name": "edge", + "service": {"address": "service.local", "port": 8181}, + "code": "edge-code", + } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5a84984 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,68 @@ +import os +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +os.environ.setdefault( + "MONGODB_URI", + "mongodb://theplug:pass@localhost:27017/theplug?authSource=admin", +) +os.environ.setdefault("MONGODB_DB_NAME", "theplug") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379") +os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key") +os.environ.setdefault("JWT_ALGORITHM", "HS256") +os.environ.setdefault("JWT_EXPIRE_MINUTES", "30") +os.environ.setdefault("AUTH_USERNAME", "admin") +os.environ.setdefault("AUTH_PASSWORD", "admin") + + +@pytest.fixture +def fake_user(): + from models.auth import AuthUserDocument + + return AuthUserDocument( + username="admin", + password_hash="hashed-password", + ) + + +@pytest.fixture +def auth_context(fake_user): + from core.security import AuthContext + from models.auth import AccessTokenPayload + + exp = int((datetime.now(UTC) + timedelta(minutes=30)).timestamp()) + payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp) + return AuthContext(token="test-token", user=fake_user, payload=payload) + + +@pytest.fixture +def app(fake_user, auth_context) -> FastAPI: + from api.auth.router import router as auth_router + from api.proxy.router import router as proxy_router + from core.security import AuthContext, get_auth_context, require_authenticated_user + from models.auth import AuthUserDocument + + app_instance = FastAPI() + app_instance.include_router(auth_router) + app_instance.include_router(proxy_router) + + async def _override_current_user() -> AuthUserDocument: + return fake_user + + async def _override_auth_context() -> AuthContext: + return auth_context + + app_instance.dependency_overrides[ + require_authenticated_user + ] = _override_current_user + app_instance.dependency_overrides[get_auth_context] = _override_auth_context + return app_instance + + +@pytest.fixture +def client(app: FastAPI): + with TestClient(app) as test_client: + yield test_client diff --git a/uv.lock b/uv.lock index ed3d133..2f6ed9a 100644 --- a/uv.lock +++ b/uv.lock @@ -401,6 +401,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -476,6 +485,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "passlib" version = "1.7.4" @@ -490,6 +508,15 @@ bcrypt = [ { name = "bcrypt" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -625,6 +652,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -817,6 +860,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] @@ -831,7 +875,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15.10" }] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.15.10" }, +] [[package]] name = "typer"