888f8fd16e
ruff --select UP007,UP045 --fix converted ~1760 sites across the backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The remaining module-level alias targets that ruff conservatively skips (BindableFloatInput, ColorList, DeviceConfig) were converted by hand earlier in the pass. black -formatted the result so the wider unions fit cleanly under the 100-char line budget. pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007", "UP045"] so future legacy imports fire CI on every push. The pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise UP045 (split off from UP007 in v0.13).
128 lines
4.5 KiB
Python
128 lines
4.5 KiB
Python
"""E2E: Authentication enforcement.
|
|
|
|
Tests that protected endpoints require valid auth, and public endpoints work
|
|
without auth.
|
|
|
|
Uses the `client` fixture (which has the correct auth header set), and
|
|
helpers to make unauthenticated requests by temporarily removing the header.
|
|
"""
|
|
|
|
|
|
def _unauth_get(client, url):
|
|
"""Make a GET request without the Authorization header."""
|
|
saved = client.headers.pop("Authorization", None)
|
|
try:
|
|
return client.get(url)
|
|
finally:
|
|
if saved is not None:
|
|
client.headers["Authorization"] = saved
|
|
|
|
|
|
def _unauth_request(client, method, url, **kwargs):
|
|
"""Make a request without the Authorization header."""
|
|
saved = client.headers.pop("Authorization", None)
|
|
try:
|
|
return client.request(method, url, **kwargs)
|
|
finally:
|
|
if saved is not None:
|
|
client.headers["Authorization"] = saved
|
|
|
|
|
|
def _with_header(client, method, url, auth_value, **kwargs):
|
|
"""Make a request with a custom Authorization header."""
|
|
saved = client.headers.get("Authorization")
|
|
client.headers["Authorization"] = auth_value
|
|
try:
|
|
return client.request(method, url, **kwargs)
|
|
finally:
|
|
if saved is not None:
|
|
client.headers["Authorization"] = saved
|
|
else:
|
|
client.headers.pop("Authorization", None)
|
|
|
|
|
|
class TestAuthEnforcement:
|
|
"""Verify API key authentication is enforced correctly."""
|
|
|
|
def test_request_without_auth_returns_401(self, client):
|
|
"""Protected endpoint without Authorization header returns 401."""
|
|
resp = _unauth_get(client, "/api/v1/devices")
|
|
assert resp.status_code == 401
|
|
|
|
def test_request_with_wrong_key_returns_401(self, client):
|
|
"""Protected endpoint with an incorrect API key returns 401."""
|
|
resp = _with_header(
|
|
client,
|
|
"GET",
|
|
"/api/v1/devices",
|
|
auth_value="Bearer wrong-key-12345",
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_request_with_correct_key_returns_200(self, client):
|
|
"""Protected endpoint with valid API key succeeds."""
|
|
resp = client.get("/api/v1/devices")
|
|
assert resp.status_code == 200
|
|
|
|
def test_health_endpoint_is_public(self, client):
|
|
"""Health check does not require authentication."""
|
|
resp = _unauth_get(client, "/health")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "healthy"
|
|
|
|
def test_version_endpoint_is_public(self, client):
|
|
"""Version endpoint does not require authentication."""
|
|
resp = _unauth_get(client, "/api/v1/version")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "version" in data
|
|
assert "api_version" in data
|
|
|
|
def test_post_without_auth_returns_401(self, client):
|
|
"""Creating a device without auth fails."""
|
|
resp = _unauth_request(
|
|
client,
|
|
"POST",
|
|
"/api/v1/devices",
|
|
json={
|
|
"name": "Unauthorized Device",
|
|
"url": "mock://test",
|
|
"device_type": "mock",
|
|
"led_count": 10,
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_delete_without_auth_returns_401(self, client):
|
|
"""Deleting a device without auth fails."""
|
|
resp = _unauth_request(client, "DELETE", "/api/v1/devices/some_id")
|
|
assert resp.status_code == 401
|
|
|
|
def test_backup_without_auth_returns_401(self, client):
|
|
"""Backup endpoint requires authentication."""
|
|
resp = _unauth_get(client, "/api/v1/system/backup")
|
|
assert resp.status_code == 401
|
|
|
|
def test_color_strip_sources_without_auth_returns_401(self, client):
|
|
"""Color strip source listing requires authentication."""
|
|
resp = _unauth_get(client, "/api/v1/color-strip-sources")
|
|
assert resp.status_code == 401
|
|
|
|
def test_output_targets_without_auth_returns_401(self, client):
|
|
"""Output target listing requires authentication."""
|
|
resp = _unauth_get(client, "/api/v1/output-targets")
|
|
assert resp.status_code == 401
|
|
|
|
def test_malformed_bearer_token_returns_401_or_403(self, client):
|
|
"""A malformed Authorization header is rejected."""
|
|
resp = _with_header(
|
|
client,
|
|
"GET",
|
|
"/api/v1/devices",
|
|
auth_value="just-a-key",
|
|
)
|
|
# FastAPI's HTTPBearer returns 403 for malformed format,
|
|
# or 401 depending on auto_error setting. Accept either.
|
|
assert resp.status_code in (401, 403)
|