No security headers were set. Add SecurityHeadersMiddleware applying Content-Security-Policy (configurable), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, and Strict-Transport-Security on HTTPS deployments. Verified HTMX/WebAuthn/forms still work under the CSP. Refs: porchlight-1ph Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
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 "<h1>Porchlight</h1>" 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", "")
|