diff --git a/.env.example b/.env.example index fe8dd34..b9026b4 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,4 @@ TEST_SECTION="Section F" # 1 and PES University (Ring Road), or 2 and PES University (Electronic City) TEST_CAMPUS_CODE=2 TEST_CAMPUS="PES University (Electronic City)" +# Environment variable PESU_AUTH_ENVIRONMENT can take on values in ("development", "staging", "production"). defaults to "development" when not set diff --git a/README.md b/README.md index de187e5..8b24eda 100644 --- a/README.md +++ b/README.md @@ -258,11 +258,20 @@ headers and no body, for monitors that check that way. #### Response Object -| **Field** | **Type** | **Description** | -| ----------- | ---------- | ------------------------------------------------------------------- | -| `status` | `boolean` | `true` if healthy, `false` if there was an error | -| `message` | `str` | "ok" if healthy, error message otherwise | -| `timestamp` | `datetime` | A timezone offset timestamp indicating the time of the health check | +| **Field** | **Type** | **Description** | +| ------------- | ---------- | ------------------------------------------------------------------- | +| `status` | `boolean` | `true` if healthy, `false` if there was an error | +| `message` | `str` | "ok" if healthy, error message otherwise | +| `timestamp` | `datetime` | A timezone offset timestamp indicating the time of the health check | +| `version` | `str` | The version of pesu-auth that is running | +| `environment` | `str` | gets its value from `PESU_AUTH_ENVIRONMENT` | + +`PESU_AUTH_ENVIRONMENT` +An explicit environment variable. Its value is read once at startup. + +| **Allowed values** | **Default value** | +| ---------------------------------------- | ----------------- | +| `development`, `staging` or `production` | `development` | ### `/metrics` diff --git a/app/app.py b/app/app.py index 30f258c..e916b51 100644 --- a/app/app.py +++ b/app/app.py @@ -15,14 +15,15 @@ import uvicorn from fastapi import Depends, FastAPI from fastapi.exceptions import RequestValidationError -from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, RedirectResponse +from fastapi.requests import Request +from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response from fastapi.routing import APIRoute if TYPE_CHECKING: from collections.abc import AsyncGenerator from fastapi.requests import Request - from fastapi.responses import Response + from fastapi.response import Response from starlette.middleware.base import RequestResponseEndpoint from pydantic import ValidationError @@ -43,7 +44,7 @@ ) from app.metrics.middleware import record_request_metrics from app.metrics.prometheus import PROMETHEUS_CONTENT_TYPE, MetricsFormat, render_prometheus -from app.models import MetricsModel, RequestModel, ResponseModel +from app.models import HealthModel, MetricsModel, RequestModel, ResponseModel from app.pesu import PESUAcademy IST = ZoneInfo("Asia/Kolkata") @@ -85,6 +86,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # Startup metrics.increment(LIFESPAN_EVENTS, event="startup") logging.info("PESUAuth API startup") + if os.environ.get("PESU_AUTH_ENVIRONMENT", "development") not in ("development", "staging", "production"): + raise ValueError("Invaild enivronment value. Value must be development, staging or production") + app.state.environment = os.environ.get("PESU_AUTH_ENVIRONMENT", "development") yield @@ -326,19 +330,23 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR @app.head("/health", include_in_schema=False) @app.get( "/health", + response_model=HealthModel, response_class=JSONResponse, responses=health_docs.response_examples, tags=["Monitoring"], ) -async def health() -> JSONResponse: +async def health(request: Request) -> JSONResponse: """Health check endpoint.""" logging.debug("Health check requested.") return JSONResponse( status_code=200, + headers={"Cache-Control": "no-store"}, content={ "status": True, "message": "ok", "timestamp": datetime.datetime.now(IST).isoformat(), + "version": version("pesu-auth"), + "environment": request.app.state.environment, }, ) diff --git a/app/docs/health.py b/app/docs/health.py index 3565819..b064074 100644 --- a/app/docs/health.py +++ b/app/docs/health.py @@ -1,17 +1,23 @@ """Custom docs for the /health PESUAuth endpoint.""" from app.docs.base import ApiDocs -from app.models import ResponseModel +from app.models import HealthModel, ResponseModel health_docs = ApiDocs( request_examples={}, response_examples={ 200: { "description": "Successful Health Check.", - "model": ResponseModel, + "model": HealthModel, "content": { "application/json": { - "example": {"status": True, "message": "ok", "timestamp": "2024-07-28T22:30:10.103368+05:30"} + "example": { + "status": True, + "message": "ok", + "timestamp": "2024-07-28T22:30:10.103368+05:30", + "version": "5.0.0", + "environment": "development", + } } }, }, diff --git a/app/models/__init__.py b/app/models/__init__.py index 5f941aa..a0770d4 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ """Custom models for the PESUAuth API.""" +from .health import HealthModel as HealthModel from .metrics import AuthenticationCountsModel as AuthenticationCountsModel from .metrics import LatencyModel as LatencyModel from .metrics import MetricsModel as MetricsModel diff --git a/app/models/health.py b/app/models/health.py new file mode 100644 index 0000000..30cc0cb --- /dev/null +++ b/app/models/health.py @@ -0,0 +1,53 @@ +"""Model representing response from health endpoint.""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class HealthModel(BaseModel): + """Model representing response from health endpoint.""" + + model_config = ConfigDict(strict=True, alias_generator=to_camel, populate_by_name=True) + + status: bool = Field( + ..., + title="Health status", + description="indicates whether the service is alive", + json_schema_extra={"example": True}, + ) + + message: str = Field( + ..., + title="Health message", + description="A string indicating the service's health", + json_schema_extra={"example": "ok"}, + ) + + timestamp: datetime = Field( + ..., + # Relaxed from the model-wide strict=True for this field alone. The API builds this model + # from a datetime but serializes an ISO string onto the wire, so a strict model could not + # parse its own responses -- which made the published schema unusable to a client wanting + # to validate with it, and forced the documentation tests into JSON mode to compensate. + strict=False, + title="timestamp", + description="returns the timestamp of when /health was used", + json_schema_extra={"example": "2024-07-28T22:30:10.103368+05:30"}, + ) + + version: str = Field( + ..., + title="version", + description="The version of pesu-auth that is running", + json_schema_extra={"example": "5.0.0"}, + ) + + environment: Literal["development", "staging", "production"] = Field( + default="development", + title="environment", + description="an environment variable", + json_schema_extra={"example": "developement"}, + ) diff --git a/pyproject.toml b/pyproject.toml index 09b5114..33978bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pesu-auth" -version = "5.4.0" +version = "5.5.0" description = "A simple API to authenticate PESU credentials using PESU Academy." readme = "README.md" requires-python = ">=3.14" diff --git a/tests/conftest.py b/tests/conftest.py index 1b82635..b5f7594 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,7 +110,6 @@ def check(profile, fields): assert list(profile) == [field for field in PROFILE_VARIABLES if field in fields], "the fields are out of order" mismatched = [field for field in fields if profile[field] != expected_profile[field]] assert not mismatched, f"these fields do not match their TEST_* values: {mismatched}" - return check diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index 8a448ab..1bf762d 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -528,7 +528,7 @@ def assert_security_headers(response): def test_integration_security_headers_on_general_endpoints(client): """General endpoints must carry standard security headers and not carry Cache-Control: no-store.""" - for path in ["/", "/health", "/metrics"]: + for path in ["/", "/metrics"]: response = client.get(path) assert response.status_code == 200 assert_security_headers(response) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 6679515..4d0bb7e 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -15,7 +15,7 @@ status_code_conformance, ) -from app.models import MetricsModel, ResponseModel +from app.models import HealthModel, MetricsModel, ResponseModel from app.pesu import DISPATCHER_URL, LOGIN_URL REPOSITORY_ROOT = Path(__file__).resolve().parents[2] @@ -27,7 +27,7 @@ CREDENTIAL_VARIABLES = ("TEST_EMAIL", "TEST_PRN", "TEST_SRN", "TEST_MOBILE", "TEST_PASSWORD") METRICS_TOKEN = "test-metrics-token" # The models the OpenAPI docs refer to by name -DOCUMENTED_MODELS = {"ResponseModel": ResponseModel, "MetricsModel": MetricsModel} +DOCUMENTED_MODELS = {"HealthModel": HealthModel, "ResponseModel": ResponseModel, "MetricsModel": MetricsModel} # Login responses' accessToken values that give no token: None stands for the key being absent UNUSABLE_TOKENS = {"missing": None, "object": {"token": "x"}, "list": ["x"]} AGENT_FRONTMATTER = '---\nname: reviewer\ndescription: Reviews diffs. Read-only.\ntools: ["read"]\n---\n' diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py new file mode 100644 index 0000000..a6afac7 --- /dev/null +++ b/tests/unit/test_health.py @@ -0,0 +1,51 @@ +import os +from importlib.metadata import version + +import pytest +from fastapi.testclient import TestClient + +from app.app import app, lifespan +from app.models import HealthModel + +@pytest.fixture() +def client(): + with TestClient(app, raise_server_exceptions=False) as client: + yield client + +def test_version(client): + response = client.get("/health") + if response.status_code == 200: + json_data = response.json() + actual_version = json_data["version"] + assert actual_version == version("pesu-auth") + + +def test_head_method_for_health(client): + head_response = client.head("/health") + assert head_response.status_code == 200 + assert head_response.text == "" + +def test_get_cache_no_store_header(client): + get_response = client.get("/health") + assert get_response.headers.get("Cache-Control") == "no-store" +def test_head_cache_no_store_header(client): + head_response = client.head("/health") + assert head_response.headers.get("Cache-Control") == "no-store" + +@pytest.mark.parametrize("valid_cases", ("development","production","staging")) + +def test_valid_cases_for_PESU_AUTH_ENVIRONMENT(monkeypatch, valid_cases): + monkeypatch.setenv("PESU_AUTH_ENVIRONMENT",valid_cases) + assert os.environ.get("PESU_AUTH_ENVIRONMENT") == valid_cases + +def test_default_fallback_PESU_AUTH_ENVIRONMENT(): + assert app.state.environment == "development" + +@pytest.mark.parametrize("invalid_cases", ("","prod","Development")) + +def test_invalid_cases_for_PESU_AUTH_ENVIRONMENT(monkeypatch, invalid_cases): + monkeypatch.setenv("PESU_AUTH_ENVIRONMENT", invalid_cases) + with pytest.raises(ValueError) as exc_info: + with TestClient(app): + pass + assert "Invaild enivronment value. Value must be development, staging or production" in str(exc_info.value) diff --git a/tests/unit/test_openapi_docs.py b/tests/unit/test_openapi_docs.py index 4bbd041..113a8b1 100644 --- a/tests/unit/test_openapi_docs.py +++ b/tests/unit/test_openapi_docs.py @@ -18,7 +18,7 @@ ProfileParseError, UpstreamError, ) -from app.models import MetricsModel, ProfileModel, RequestModel, ResponseModel +from app.models import HealthModel, MetricsModel, ProfileModel, RequestModel, ResponseModel from app.pesu import PESUAcademy