from pathlib import Path from unittest.mock import MagicMock import pytest from httpx import ASGITransport, AsyncClient from porchlight.app import create_app from porchlight.config import Settings from porchlight.dependencies import ( get_credential_repo, get_magic_link_repo, get_user_repo, ) from porchlight.store.protocols import ( CredentialRepository, MagicLinkRepository, UserRepository, ) async def test_health_endpoint(client: AsyncClient) -> None: response = await client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "ok" async def test_app_has_title(client: AsyncClient) -> None: response = await client.get("/openapi.json") assert response.status_code == 200 data = response.json() assert data["info"]["title"] == "Porchlight" async def test_app_has_repos_on_state(client: AsyncClient) -> None: app = client._transport.app # type: ignore[union-attr] assert isinstance(app.state.user_repo, UserRepository) assert isinstance(app.state.credential_repo, CredentialRepository) assert isinstance(app.state.magic_link_repo, MagicLinkRepository) async def test_landing_page(client: AsyncClient) -> None: response = await client.get("/") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] body = response.text assert "

Porchlight

" in body assert "/manage/profile" in body assert "/admin/users" in body assert "My Account" in body assert "Administration" in body async def test_dependency_functions() -> None: request = MagicMock() request.app.state.user_repo = "user_repo_sentinel" request.app.state.credential_repo = "credential_repo_sentinel" request.app.state.magic_link_repo = "magic_link_repo_sentinel" assert get_user_repo(request) == "user_repo_sentinel" assert get_credential_repo(request) == "credential_repo_sentinel" assert get_magic_link_repo(request) == "magic_link_repo_sentinel" def test_create_app_requires_session_secret_in_production() -> None: settings = Settings(issuer="https://op.example.com", sqlite_path=":memory:", debug=False) with pytest.raises(RuntimeError, match="SESSION_SECRET"): create_app(settings) def test_create_app_allows_missing_secret_on_localhost() -> None: settings = Settings(issuer="http://localhost:8000", sqlite_path=":memory:") assert create_app(settings) is not None def test_create_app_allows_missing_secret_in_debug() -> None: settings = Settings(issuer="https://op.example.com", sqlite_path=":memory:", debug=True) assert create_app(settings) is not None async def test_session_cookie_has_explicit_max_age(client: AsyncClient) -> None: # Visiting /login establishes a session (CSRF token), setting the cookie. res = await client.get("/login") set_cookies = res.headers.get_list("set-cookie") session_cookies = [c for c in set_cookies if c.startswith("session=")] assert session_cookies, "no session cookie set" assert "Max-Age=28800" in session_cookies[0] async def test_security_headers_present(client: AsyncClient) -> None: res = await client.get("/login") assert res.headers.get("x-content-type-options") == "nosniff" assert res.headers.get("x-frame-options") == "DENY" assert "default-src 'self'" in res.headers.get("content-security-policy", "") assert "frame-ancestors 'none'" in res.headers.get("content-security-policy", "") assert res.headers.get("referrer-policy") == "strict-origin-when-cross-origin" async def test_hsts_present_when_https_only(tmp_path: Path) -> None: settings = Settings( issuer="https://op.example.com", sqlite_path=":memory:", session_secret="x" * 32, session_https_only=True, signing_key_path=str(tmp_path / "keys"), ) app = create_app(settings) transport = ASGITransport(app=app) async with app.router.lifespan_context(app), AsyncClient(transport=transport, base_url=settings.issuer) as ac: res = await ac.get("/health") assert "max-age=" in res.headers.get("strict-transport-security", "")