Repository navigation
Conversation
aditeyabaral
left a comment
There was a problem hiding this comment.
Thanks for picking this up, @Tvisha-A! The runtime behaviour is right: /health returns version and environment for all three values and when the variable is unset, a bad value fails startup loudly, and the Swagger 200/500 models are the right way round. The comments below are what's left before this meets the issue's "Done when" list.
CI is red on every job because uv.lock wasn't regenerated after the version bump (see the comment on pyproject.toml). That stops CI at uv sync --locked, so it never actually linted or tested this PR.
A few things that don't belong to a single line:
- Commit metadata: the commit has no body. Conventional Commits here should explain why in the body. Its author email is
tvishaagrawal.12@ggmail.com(noteggmail), which doesn't match a GitHub account, so the commit isn't credited to your profile. Fix it withgit config user.emailandgit commit --amend --reset-author. - PR description:
/healthalready answeredHEADondevbefore this PR; this PR adds tests for it, so the description should say that. There's a strayuv run --group benchmark ...line inside the checklist, and "Changes are backwards compatible" can be ticked, since the change only adds fields. - Live tests: this doesn't touch the login path, so not running them is low risk. Please still say in the description that they weren't run, because CI doesn't run them on PRs from forks either.
| [project] | ||
| name = "pesu-auth" | ||
| version = "5.4.0" | ||
| version = "5.5.0" |
There was a problem hiding this comment.
The bump to 5.5.0 is right, but uv.lock still records 5.4.0. The version check fails with pyproject.toml says 5.5.0 but uv.lock says 5.4.0, and every other job stops at uv sync --locked. Run uv lock and commit uv.lock along with this.
| @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 |
There was a problem hiding this comment.
This test never starts the app: it sets the variable and then checks that os.environ holds what was just set, so it passes even if the lifespan ignores PESU_AUTH_ENVIRONMENT entirely. The issue asks for each valid value to be tested, so start the app and check what /health reports:
| @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 | |
| @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) | |
| with TestClient(app) as client: | |
| response = client.get("/health") | |
| assert response.json()["environment"] == valid_cases | |
| def test_default_fallback_PESU_AUTH_ENVIRONMENT(): | ||
| assert app.state.environment == "development" |
There was a problem hiding this comment.
This only passes because an earlier test's lifespan has already set app.state.environment. Run on its own (pytest tests/unit/test_health.py::test_default_fallback_PESU_AUTH_ENVIRONMENT) it fails, and it also fails if the variable is set in the developer's shell. Clear the variable, start the app, and check the response:
| def test_default_fallback_PESU_AUTH_ENVIRONMENT(): | |
| assert app.state.environment == "development" | |
| def test_default_fallback_PESU_AUTH_ENVIRONMENT(monkeypatch): | |
| monkeypatch.delenv("PESU_AUTH_ENVIRONMENT", raising=False) | |
| with TestClient(app) as client: | |
| response = client.get("/health") | |
| assert response.json()["environment"] == "development" | |
| default="development", | ||
| title="environment", | ||
| description="an environment variable", | ||
| json_schema_extra={"example": "developement"}, |
There was a problem hiding this comment.
A few problems with this field:
default="development"takesenvironmentout ofrequiredin the published schema, though every response includes it. Use...like the other fields; the default belongs in the lifespan, which already has one.- The example
"developement"is misspelled, and isn't one of the field's own allowed values. - "an environment variable" doesn't tell a reader anything. Say what it means and where it comes from.
| default="development", | |
| title="environment", | |
| description="an environment variable", | |
| json_schema_extra={"example": "developement"}, | |
| ..., | |
| title="Environment", | |
| description="The deployment this instance is running as, set by `PESU_AUTH_ENVIRONMENT`.", | |
| json_schema_extra={"example": "development"}, |
| logging.debug("Health check requested.") | ||
| return JSONResponse( | ||
| status_code=200, | ||
| headers={"Cache-Control": "no-store"}, |
There was a problem hiding this comment.
#254 put all of the header logic in _security_headers (line 64) so that it applies to every response, including the 500 that unhandled_exception_handler renders. Because the header is set here instead, a 500 from /health goes out without Cache-Control. Drop it here:
| headers={"Cache-Control": "no-store"}, |
and add /health to the check in _security_headers. That function isn't part of this diff, so it can't be a suggestion:
Returns:
dict[str, str]: The general security headers, plus `Cache-Control: no-store` for /authenticate and /health.
"""
headers = dict(SECURITY_HEADERS)
if request.url.path in ("/authenticate", "/health"):
headers["Cache-Control"] = "no-store"Also update the security_headers_middleware docstring ("prevent caching for /authenticate") and the README's "Response headers" section, which still says only /authenticate gets no-store.
| # 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 |
There was a problem hiding this comment.
The issue asks for a commented-out line. It also shouldn't go at the end of the TEST_* profile block. That file's header says the application never reads it, so give it its own section that says the app reads it from its environment:
| # Environment variable PESU_AUTH_ENVIRONMENT can take on values in ("development", "staging", "production"). defaults to "development" when not set | |
| # Not a test variable: the app reads it from its environment and reports it on /health. One of | |
| # development, staging or production, and development when unset; any other value fails startup. | |
| # PESU_AUTH_ENVIRONMENT=development |
| 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") |
There was a problem hiding this comment.
A few things here:
- The variable is read twice. Read it once into a local.
- Fix the typos ("Invaild enivronment") and include the value that was rejected, so whoever deployed it can see what's wrong.
- A comment saying why this is checked at startup helps the next reader.
| 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") | |
| # Read once, so that a bad deploy fails here instead of /health reporting a wrong value | |
| environment = os.environ.get("PESU_AUTH_ENVIRONMENT", "development") | |
| if environment not in ("development", "staging", "production"): | |
| raise ValueError(f"PESU_AUTH_ENVIRONMENT must be development, staging or production, not {environment!r}") | |
| app.state.environment = environment |
The test on lines 48-51 of tests/unit/test_health.py checks the old message; there's a suggestion there to match this one, so apply both together (Add suggestion to batch). Optionally, the three values are now listed here and in HealthModel's Literal; a shared Literal alias, checked with typing.get_args, would keep the two from drifting apart.
| "status": True, | ||
| "message": "ok", | ||
| "timestamp": datetime.datetime.now(IST).isoformat(), | ||
| "version": version("pesu-auth"), |
There was a problem hiding this comment.
version() reads the package metadata from disk on every call, and /health is called a lot. app.version already holds the same value, computed once at import:
| "version": version("pesu-auth"), | |
| "version": app.version, |
| if response.status_code == 200: | ||
| json_data = response.json() | ||
| actual_version = json_data["version"] | ||
| assert actual_version == version("pesu-auth") |
There was a problem hiding this comment.
If /health returns anything other than 200, this test asserts nothing and passes. Assert the status first:
| if response.status_code == 200: | |
| json_data = response.json() | |
| actual_version = json_data["version"] | |
| assert actual_version == version("pesu-auth") | |
| assert response.status_code == 200 | |
| assert response.json()["version"] == version("pesu-auth") |
| title="timestamp", | ||
| description="returns the timestamp of when /health was used", |
There was a problem hiding this comment.
Titles and descriptions here are cased inconsistently ("Health status" vs "timestamp" and "version"), and this one reads oddly. Match ResponseModel:
| title="timestamp", | |
| description="returns the timestamp of when /health was used", | |
| title="Health Check Timestamp", | |
| description="Time of the health check, in IST (UTC+05:30).", |
The same goes for the other fields: e.g. "Version" for version's title, and capitalised descriptions for status and message.
aditeyabaral
left a comment
There was a problem hiding this comment.
Follow-up to my review above: I've added suggestions to the comments, so you can apply the fixes from this page. Some comments moved to the lines their suggestion replaces. Where a comment says to apply suggestions together, use Add suggestion to batch and commit them at once.
| 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 |
There was a problem hiding this comment.
fastapi.response doesn't exist (import fastapi.response raises ModuleNotFoundError). This only goes unnoticed because it's under TYPE_CHECKING. Request is also imported twice (lines 18 and 25), and Response is imported at runtime though it's only used in annotations.
health() doesn't need the request parameter: app is already in module scope, so it can read app.state.environment directly. Then these imports can go back to what they were:
| 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 fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, RedirectResponse | |
| from fastapi.routing import APIRoute | |
| if TYPE_CHECKING: | |
| from collections.abc import AsyncGenerator | |
| from fastapi.requests import Request | |
| from fastapi.responses import Response |
Apply this together with the suggestions on lines 338 and 349 (Add suggestion to batch). If only this one is applied, FastAPI can no longer resolve request: Request and treats it as a required query parameter, so /health answers 400.
| tags=["Monitoring"], | ||
| ) | ||
| async def health() -> JSONResponse: | ||
| async def health(request: Request) -> JSONResponse: |
There was a problem hiding this comment.
Part of the import fix on lines 18-26: app is in module scope, so health() needs no request.
| async def health(request: Request) -> JSONResponse: | |
| async def health() -> JSONResponse: |
Apply this together with the suggestions on lines 18-26 and 349 (Add suggestion to batch).
| "message": "ok", | ||
| "timestamp": datetime.datetime.now(IST).isoformat(), | ||
| "version": version("pesu-auth"), | ||
| "environment": request.app.state.environment, |
There was a problem hiding this comment.
Part of the import fix on lines 18-26:
| "environment": request.app.state.environment, | |
| "environment": app.state.environment, |
Apply this together with the suggestions on lines 18-26 and 338 (Add suggestion to batch).
| 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) |
There was a problem hiding this comment.
Goes with the suggestion on lines 89-91 of app/app.py, which fixes the typos in this message; apply both together. match= is the usual way to check an exception's message:
| 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) | |
| with pytest.raises(ValueError, match="PESU_AUTH_ENVIRONMENT must be development, staging or production"): | |
| with TestClient(app): | |
| pass |
| @@ -528,7 +528,7 @@ def assert_security_headers(response): | |||
|
|
|||
| def test_integration_security_headers_on_general_endpoints(client): | |||
There was a problem hiding this comment.
Removing /health from the test below means nothing checks any more that /health sends the standard security headers. Since it now sends no-store, it needs its own test, for both GET and HEAD:
| def test_integration_security_headers_on_general_endpoints(client): | |
| def test_integration_health_security_headers(client): | |
| """/health must carry the standard security headers and Cache-Control: no-store, on GET and HEAD.""" | |
| for response in (client.get("/health"), client.head("/health")): | |
| assert response.status_code == 200 | |
| assert_security_headers(response) | |
| assert response.headers["Cache-Control"] == "no-store" | |
| def test_integration_security_headers_on_general_endpoints(client): |
| 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 |
There was a problem hiding this comment.
Ruff doesn't check tests/, but please match the style of the other test files. Add a module docstring, and drop the imports nothing uses (lifespan and HealthModel, and os once the environment tests no longer need it):
| 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 | |
| """Tests for /health: the version and environment fields, HEAD, and Cache-Control.""" | |
| from importlib.metadata import version | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from app.app import app |
For the rest, run uv run ruff format tests/unit/test_health.py once. It adds the two blank lines between top-level functions, removes the blank lines between @pytest.mark.parametrize and its function (lines 36 and 45), and adds spaces after commas.
| 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 |
There was a problem hiding this comment.
This blank line deletion isn't related to this issue. Please put it back:
| return check | |
| return check |
jois-code
left a comment
There was a problem hiding this comment.
I left an inline comment about the outdated 5.0.0 response example. CI currently stops before running tests: uv sync --locked --all-groups fails because pyproject.toml is at 5.5.0 but uv.lock is out of date.
| "status": True, | ||
| "message": "ok", | ||
| "timestamp": "2024-07-28T22:30:10.103368+05:30", | ||
| "version": "5.0.0", |
There was a problem hiding this comment.
this PR bumps the package to 5.5.0, while /health returns the installed package version. Please update the example to match the current version
📌 Description
🧱 Type of Change
pyproject.toml,uv.lock🧪 How Has This Been Tested?
tests/unit/)tests/functional/)tests/integration/)✅ Checklist
scripts/run_tests.py)pre-commit run --all-files).envvars updated (if applicable)scripts/benchmark/benchmark_requests.py)uv run --group benchmark python benchmark_requests.py --num-requests 100 --parallel --tag baseline
🛠️ Affected API Behaviour
app/app.py– Modified/authenticateroute logicapp/pesu.py– Updated PESU Academy login or profile handlingapp/exceptions/– Error types, their status codes or messagesapp/metrics/– Metrics collection, exposition or the/metricstokenapp/docs/– OpenAPI (Swagger) examples and descriptions🧩 Models
app/models/request.py– Input validation or request schema changesapp/models/response.py– Authentication response formattingapp/models/profile.py– Profile response schemaapp/models/upstream.py– Parsing of PESU Academy's login and profile responses🐳 DevOps & Config
Dockerfile– Changes to base image or build process.github/workflows/*.yml– CI/CD pipeline or deployment updatespyproject.toml/uv.lock– Dependency version changes.pre-commit-config.yaml– Linting or formatting hook changes.github/scripts/check_version_bump.py– The CI version check📊 Benchmarks & Analysis
scripts/benchmark/benchmark_requests.py– Performance or latency measurement changesscripts/benchmark/analyze_benchmark.py– Benchmark result analysis changesscripts/run_tests.py– Custom test runner logic or behavior updates📸 Screenshots / API Demos (if applicable)
🧠 Additional Notes (if applicable)
yet to pass all tests due to invalid pesu credentials