diff --git a/.env.production b/.env.production index e54b702..cda401c 100644 --- a/.env.production +++ b/.env.production @@ -27,7 +27,13 @@ PORTS=3000,8000 # Both spellings are listed so this keeps working if the typo is ever corrected # in Dokploy. Exact origins, never a wildcard: the app sends an Authorization # header, and browsers reject credentialed requests to a wildcard origin. -API_CORS_ORIGINS=https://catalouge.nearle.ai.in,https://catalogue.nearle.ai.in +# +# app.nearledaily.com is the merchant console, which calls /api/nutrition/* +# from the browser. Until it was listed here every one of those calls failed +# as an opaque "Failed to fetch" while curl returned 200 - the server saw a +# healthy request and the browser discarded the response. localhost:3100 is +# that console in local development. +API_CORS_ORIGINS=https://catalouge.nearle.ai.in,https://catalogue.nearle.ai.in,https://app.nearledaily.com,http://localhost:3100 # --- Authentication -------------------------------------------------------- AUTH_ENABLED=true diff --git a/tests/test_cors_origins.py b/tests/test_cors_origins.py new file mode 100644 index 0000000..e59a886 --- /dev/null +++ b/tests/test_cors_origins.py @@ -0,0 +1,129 @@ +"""The browser is the only client that enforces CORS, and no test here is one. + +THE FAILURE THIS FILE EXISTS FOR +-------------------------------- +The merchant console called `https://mcp.nearle.ai.in/api/...` from Chrome and got +`TypeError: Failed to fetch` for a whole day. The same URL under curl returned 200 +with the right body, so every server-side check - ours and theirs - passed. The +response carried `Access-Control-Allow-Credentials: true` and no +`Access-Control-Allow-Origin`, and the browser discarded it before any code saw it. + +The cause was not missing middleware. `CORSMiddleware` was installed and correct; +the console's origin was simply not in `API_CORS_ORIGINS`, so Starlette declined to +echo the header. Nothing in the server log looked wrong: a healthy 200, every time. + +That is why the deployed allowlist is asserted as DATA below. A unit test cannot +fail the way a browser fails, so the next best thing is to pin the one value whose +absence produces a silent, total outage for a first-party client. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +ENV_PRODUCTION = Path(__file__).resolve().parents[1] / ".env.production" + +# Every first-party browser client of this API. A new one added to the console +# and not added here is the bug above, repeated. +REQUIRED_ORIGINS = ( + "https://app.nearledaily.com", # merchant console + "http://localhost:3100", # merchant console, local development + "https://catalogue.nearle.ai.in", # catalogue frontend +) + + +def _deployed_origins(): + """The allowlist the image ships with. + + Read from the file rather than from `settings`, because the test process has + its own environment - importing the setting would assert on the developer's + machine instead of on what gets deployed. + """ + if not ENV_PRODUCTION.exists(): + pytest.skip(".env.production is not present in this checkout") + for line in ENV_PRODUCTION.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("API_CORS_ORIGINS="): + value = line.split("=", 1)[1] + return [o.strip() for o in value.split(",") if o.strip()] + return [] + + +@pytest.mark.parametrize("origin", REQUIRED_ORIGINS) +def test_every_first_party_browser_client_is_allowed(origin): + assert origin in _deployed_origins(), ( + "%s is missing from API_CORS_ORIGINS in .env.production. Browser calls " + "from it fail as an opaque 'Failed to fetch' while curl still returns " + "200, so nothing server-side will catch this." % origin + ) + + +def test_the_allowlist_is_not_a_wildcard(): + """A wildcard would disable `allow_credentials` (see the guard in main.py), + silently breaking any authenticated browser call to this API.""" + assert "*" not in _deployed_origins() + + +def test_every_origin_is_a_bare_scheme_and_host(): + """An Origin header is scheme + host + port, never a path and never a + trailing slash. `https://app.nearledaily.com/` does not match and fails + exactly as if it were absent.""" + for origin in _deployed_origins(): + assert re.fullmatch(r"https?://[A-Za-z0-9.\-]+(:\d+)?", origin), origin + + +# --------------------------------------------------------------------------- +# The middleware itself +# --------------------------------------------------------------------------- +# These run against whatever origins the TEST environment carries, so they pin +# the behaviour rather than the deployed list: an allowed origin is echoed, an +# unknown one is not, and a preflight from an unknown origin is refused. + +def _allowed_origin(): + from app.infrastructure.settings import API_CORS_ORIGINS + if not API_CORS_ORIGINS: + pytest.skip("no CORS origins configured in the test environment") + return API_CORS_ORIGINS[0] + + +def test_an_allowed_origin_is_echoed_back(client): + origin = _allowed_origin() + + response = client.get("/api/health", headers={"Origin": origin}) + + assert response.headers.get("access-control-allow-origin") == origin + + +def test_an_unknown_origin_gets_no_header_at_all(client): + """The response still returns 200 with a correct body - which is why this is + invisible everywhere except a browser.""" + response = client.get("/api/health", headers={"Origin": "https://not-listed.example"}) + + assert response.status_code == 200 + assert "access-control-allow-origin" not in response.headers + + +def test_a_preflight_from_an_allowed_origin_succeeds(client): + response = client.options( + "/api/health", + headers={"Origin": _allowed_origin(), + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "content-type"}, + ) + + assert response.status_code == 200 + assert response.headers.get("access-control-allow-origin") == _allowed_origin() + + +def test_a_preflight_from_an_unknown_origin_is_refused(client): + """Starlette answers 400 here. It reads as a malformed request in the log, + which is how this was mistaken for a second, unrelated bug.""" + response = client.options( + "/api/health", + headers={"Origin": "https://not-listed.example", + "Access-Control-Request-Method": "GET"}, + ) + + assert response.status_code == 400