From 9f99b6c6d73ed3130167998ef8be550adabdb2b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:08:59 +0000 Subject: [PATCH] fix: handle empty BACKEND_CORS_ORIGINS env var to prevent JSONDecodeError In pydantic_settings v2, List[str] fields are JSON-parsed by the env source before pydantic validators run. When BACKEND_CORS_ORIGINS="" (empty string) in Kubernetes, json.loads("") raised JSONDecodeError, crashing alembic migrations. Fixes: - Add env_ignore_empty=True to class Config so pydantic_settings skips empty-string env vars and falls back to the field default - Update assemble_cors_origins validator to explicitly handle empty and whitespace-only strings (returns []), JSON array strings (parsed via json.loads), and filters empty tokens from comma-separated values - Add backend/app/tests/test_config.py covering all CORS origins parsing scenarios including the empty-string regression case Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/53bb1064-2a32-4691-8397-9d4663bc18a8 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- backend/app/core/config.py | 13 ++++-- backend/app/tests/test_config.py | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 backend/app/tests/test_config.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 717c513..28b7445 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,3 +1,4 @@ +import json import logging import secrets from functools import lru_cache @@ -77,15 +78,21 @@ class Settings(BaseSettings): def assemble_cors_origins( # pylint: disable=no-self-argument cls, v: Union[str, List[str]] ) -> List[str]: - if isinstance(v, str) and not v.startswith("["): - return [i.strip() for i in v.split(",")] - if isinstance(v, (list, str)): + if isinstance(v, str): + v = v.strip() + if not v: + return [] + if v.startswith("["): + return json.loads(v) + return [i.strip() for i in v.split(",") if i.strip()] + if isinstance(v, list): return v raise ValueError(v) class Config: env_file = ".env" case_sensitive = True + env_ignore_empty = True @lru_cache() diff --git a/backend/app/tests/test_config.py b/backend/app/tests/test_config.py new file mode 100644 index 0000000..ff0bd64 --- /dev/null +++ b/backend/app/tests/test_config.py @@ -0,0 +1,72 @@ +""" +Tests for application settings / config.py. + +Covers BACKEND_CORS_ORIGINS parsing including the empty-string case that +previously caused a JSONDecodeError in pydantic_settings v2 before validators +could run (see: pydantic_settings sources/providers/env.py decode_complex_value). +""" + +import pytest + +from app.core.config import Settings + + +class TestBackendCorsOriginsValidator: + """Tests for the assemble_cors_origins validator.""" + + def test_comma_separated_string(self): + """Comma-separated origins are split into a list.""" + settings = Settings( + BACKEND_CORS_ORIGINS="http://localhost:3000,http://localhost:5173" + ) + assert settings.BACKEND_CORS_ORIGINS == [ + "http://localhost:3000", + "http://localhost:5173", + ] + + def test_single_origin_string(self): + """A single origin as a string is wrapped in a list.""" + settings = Settings(BACKEND_CORS_ORIGINS="https://example.com") + assert settings.BACKEND_CORS_ORIGINS == ["https://example.com"] + + def test_empty_string_returns_empty_list(self): + """An empty string must not raise JSONDecodeError; returns an empty list.""" + settings = Settings(BACKEND_CORS_ORIGINS="") + assert settings.BACKEND_CORS_ORIGINS == [] + + def test_whitespace_only_string_returns_empty_list(self): + """A whitespace-only string is treated the same as empty.""" + settings = Settings(BACKEND_CORS_ORIGINS=" ") + assert settings.BACKEND_CORS_ORIGINS == [] + + def test_list_passthrough(self): + """A list value is passed through unchanged.""" + origins = ["https://a.example.com", "https://b.example.com"] + settings = Settings(BACKEND_CORS_ORIGINS=origins) + assert settings.BACKEND_CORS_ORIGINS == origins + + def test_default_when_not_provided(self): + """Defaults are returned when BACKEND_CORS_ORIGINS is not set.""" + settings = Settings() + assert "http://localhost:3000" in settings.BACKEND_CORS_ORIGINS + assert "http://localhost:5173" in settings.BACKEND_CORS_ORIGINS + + def test_comma_separated_with_spaces(self): + """Extra whitespace around origins is stripped.""" + settings = Settings( + BACKEND_CORS_ORIGINS=" http://a.example.com , http://b.example.com " + ) + assert settings.BACKEND_CORS_ORIGINS == [ + "http://a.example.com", + "http://b.example.com", + ] + + def test_json_array_string(self): + """A JSON array string is parsed into a list.""" + settings = Settings( + BACKEND_CORS_ORIGINS='["https://a.example.com", "https://b.example.com"]' + ) + assert settings.BACKEND_CORS_ORIGINS == [ + "https://a.example.com", + "https://b.example.com", + ]