From d0ea3762b2a97a16f79645c78e6f22f4102caa3f Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:15:54 +0530 Subject: [PATCH 1/3] feat(*): introduce aws secrets manager --- .env.example | 5 +++ backend/app/core/config.py | 70 ++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 20 +++++------ 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index c05e3e17a..4955eb2b1 100644 --- a/.env.example +++ b/.env.example @@ -96,3 +96,8 @@ SMTP_PASSWORD= EMAILS_FROM_EMAIL= EMAILS_FROM_NAME=Kaapi FRONTEND_HOST= + +# AWS Secrets Manager +USE_AWS_SECRETS=false +AWS_SECRETS_NAMES= +AWS_SECRETS_REGION= diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ee72442f8..bee8859a7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,3 +1,5 @@ +import json +import logging import secrets import warnings import os @@ -12,9 +14,56 @@ model_validator, ) from pydantic_core import MultiHostUrl -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import ( + BaseSettings, + SettingsConfigDict, +) from typing_extensions import Self +logger = logging.getLogger(__name__) + + +def load_secret_from_aws( + secret_names: str, field_map: dict[str, str] +) -> dict[str, Any]: + """Fetch one or more AWS secrets and remap their JSON keys. + + secret_names: comma-separated Secrets Manager IDs + (e.g. "kaapi-staging-db,kaapi-staging-cache"). + field_map: maps JSON keys in the secrets → Settings field names + (e.g. {"password": "POSTGRES_PASSWORD"}). + """ + if os.getenv("USE_AWS_SECRETS", "").strip().lower() not in ("1", "true", "yes"): + return {} + + names = [n.strip() for n in secret_names.split(",") if n.strip()] + if not names: + return {} + + import boto3 + + region = os.getenv("AWS_SECRETS_REGION") or os.getenv("AWS_DEFAULT_REGION") + client_kwargs: dict[str, Any] = {"region_name": region} if region else {} + client = boto3.client("secretsmanager", **client_kwargs) + + merged: dict[str, Any] = {} + for name in names: + data = json.loads(client.get_secret_value(SecretId=name)["SecretString"]) + if not isinstance(data, dict): + raise ValueError( + f"Secret '{name}' must be a JSON object " f"(got {type(data).__name__})" + ) + merged.update(data) + + overrides = { + field: merged[key] for key, field in field_map.items() if key in merged + } + logger.info( + f"[load_secret_from_aws] Loaded AWS secrets | " + f"{{'names': {names}, 'fields': {sorted(overrides)}}}" + ) + return overrides + def parse_cors(origins: Any) -> list[str] | str: # If it's a plain comma-separated string, split it into a list @@ -104,6 +153,11 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: AWS_DEFAULT_REGION: str = "" AWS_S3_BUCKET_PREFIX: str = "" + # AWS Secrets Manager + USE_AWS_SECRETS: bool = False + AWS_SECRETS_NAMES: str = "" + AWS_SECRETS_REGION: str = "" + # RabbitMQ configuration for Celery broker RABBITMQ_HOST: str = "localhost" RABBITMQ_PORT: int = 5672 @@ -195,8 +249,18 @@ def get_settings() -> Settings: env_files = {"testing": "../.env.test", "development": "../.env"} env_file = env_files.get(environment, "../.env") - # Create Settings instance with the appropriate env file - return Settings(_env_file=env_file) + # Pull Postgres credentials from AWS Secrets Manager when enabled. + db_overrides = load_secret_from_aws( + secret_names=os.getenv("AWS_SECRETS_NAMES", ""), + field_map={ + "username": "POSTGRES_USER", + "password": "POSTGRES_PASSWORD", + "host": "POSTGRES_SERVER", + "port": "POSTGRES_PORT", + }, + ) + + return Settings(_env_file=env_file, **db_overrides) # Export settings instance diff --git a/docker-compose.yml b/docker-compose.yml index 10fc0d914..b1cce1891 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,14 +8,14 @@ services: env_file: - .env environment: - POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER not set} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set} - POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB not set} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-kaapi} PGDATA: /var/lib/postgresql/data/pgdata volumes: - kaapi-postgres:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-kaapi}"] interval: 10s timeout: 10s retries: 5 @@ -30,8 +30,8 @@ services: env_file: - .env command: > - sh -c "if [ -n \"${REDIS_PASSWORD}\" ]; then - redis-server --requirepass \"${REDIS_PASSWORD}\"; + sh -c "if [ -n \"${REDIS_PASSWORD:-}\" ]; then + redis-server --requirepass \"${REDIS_PASSWORD:-}\"; else redis-server; fi" @@ -40,7 +40,7 @@ services: ports: - "6379:6379" healthcheck: - test: ["CMD-SHELL", "if [ -n \"${REDIS_PASSWORD}\" ]; then redis-cli -a \"${REDIS_PASSWORD}\" ping; else redis-cli ping; fi"] + test: ["CMD-SHELL", "if [ -n \"${REDIS_PASSWORD:-}\" ]; then redis-cli -a \"${REDIS_PASSWORD:-}\" ping; else redis-cli ping; fi"] interval: 10s timeout: 10s retries: 5 @@ -53,9 +53,9 @@ services: env_file: - .env environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:?RABBITMQ_USER not set} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD not set} - RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:?RABBITMQ_VHOST not set} + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest} + RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-/} volumes: - kaapi-rabbitmq:/var/lib/rabbitmq ports: From 82d0c37c1b09502cc58478d6df9639fd91c3763d Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:53:51 +0530 Subject: [PATCH 2/3] fix(*): create the test config for testing the config things --- backend/app/tests/core/test_config.py | 142 ++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/app/tests/core/test_config.py diff --git a/backend/app/tests/core/test_config.py b/backend/app/tests/core/test_config.py new file mode 100644 index 000000000..d1b0a290a --- /dev/null +++ b/backend/app/tests/core/test_config.py @@ -0,0 +1,142 @@ +"""Tests for load_secret_from_aws helper in app.core.config.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from app.core.config import load_secret_from_aws + + +@pytest.fixture(autouse=True) +def _clean_aws_env(monkeypatch): + """Strip any AWS-related env vars that may leak from .env.test.""" + for var in ( + "USE_AWS_SECRETS", + "AWS_SECRETS_NAMES", + "AWS_SECRETS_REGION", + "AWS_DEFAULT_REGION", + ): + monkeypatch.delenv(var, raising=False) + + +class TestLoadSecretFromAws: + def test_toggle_off_returns_empty(self, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "false") + assert ( + load_secret_from_aws("any-secret", {"password": "POSTGRES_PASSWORD"}) == {} + ) + + def test_toggle_unset_returns_empty(self): + assert ( + load_secret_from_aws("any-secret", {"password": "POSTGRES_PASSWORD"}) == {} + ) + + def test_empty_secret_names_returns_empty(self, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "true") + assert load_secret_from_aws("", {"password": "POSTGRES_PASSWORD"}) == {} + + def test_whitespace_only_secret_names_returns_empty(self, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "true") + assert load_secret_from_aws(" , ,", {"password": "POSTGRES_PASSWORD"}) == {} + + @patch("boto3.client") + def test_single_secret_maps_all_fields(self, mock_boto, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "true") + monkeypatch.setenv("AWS_SECRETS_REGION", "ap-south-1") + mock_client = MagicMock() + mock_client.get_secret_value.return_value = { + "SecretString": json.dumps( + {"username": "u", "password": "p", "host": "h", "port": 5432} + ) + } + mock_boto.return_value = mock_client + + result = load_secret_from_aws( + "kaapi-db", + { + "username": "POSTGRES_USER", + "password": "POSTGRES_PASSWORD", + "host": "POSTGRES_SERVER", + "port": "POSTGRES_PORT", + }, + ) + + assert result == { + "POSTGRES_USER": "u", + "POSTGRES_PASSWORD": "p", + "POSTGRES_SERVER": "h", + "POSTGRES_PORT": 5432, + } + mock_boto.assert_called_once_with("secretsmanager", region_name="ap-south-1") + mock_client.get_secret_value.assert_called_once_with(SecretId="kaapi-db") + + @patch("boto3.client") + def test_missing_keys_in_secret_are_skipped(self, mock_boto, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "true") + mock_client = MagicMock() + mock_client.get_secret_value.return_value = { + "SecretString": json.dumps({"password": "p"}) + } + mock_boto.return_value = mock_client + + result = load_secret_from_aws( + "kaapi-db", + {"username": "POSTGRES_USER", "password": "POSTGRES_PASSWORD"}, + ) + + assert result == {"POSTGRES_PASSWORD": "p"} + + @patch("boto3.client") + def test_multiple_secrets_merge_left_to_right(self, mock_boto, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "true") + mock_client = MagicMock() + mock_client.get_secret_value.side_effect = [ + {"SecretString": json.dumps({"password": "first"})}, + {"SecretString": json.dumps({"password": "second"})}, + ] + mock_boto.return_value = mock_client + + result = load_secret_from_aws("a,b", {"password": "POSTGRES_PASSWORD"}) + + assert result == {"POSTGRES_PASSWORD": "second"} + assert mock_client.get_secret_value.call_count == 2 + + @patch("boto3.client") + def test_non_dict_payload_raises(self, mock_boto, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "true") + mock_client = MagicMock() + mock_client.get_secret_value.return_value = { + "SecretString": json.dumps(["not", "a", "dict"]) + } + mock_boto.return_value = mock_client + + with pytest.raises(ValueError, match="must be a JSON object"): + load_secret_from_aws("kaapi-db", {"password": "POSTGRES_PASSWORD"}) + + @patch("boto3.client") + def test_region_falls_back_to_aws_default_region(self, mock_boto, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "true") + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") + mock_client = MagicMock() + mock_client.get_secret_value.return_value = { + "SecretString": json.dumps({"password": "p"}) + } + mock_boto.return_value = mock_client + + load_secret_from_aws("kaapi-db", {"password": "POSTGRES_PASSWORD"}) + + mock_boto.assert_called_once_with("secretsmanager", region_name="us-east-1") + + @patch("boto3.client") + def test_no_region_builds_client_without_region_kwarg(self, mock_boto, monkeypatch): + monkeypatch.setenv("USE_AWS_SECRETS", "true") + mock_client = MagicMock() + mock_client.get_secret_value.return_value = { + "SecretString": json.dumps({"password": "p"}) + } + mock_boto.return_value = mock_client + + load_secret_from_aws("kaapi-db", {"password": "POSTGRES_PASSWORD"}) + + mock_boto.assert_called_once_with("secretsmanager") From 16260273c72a08bcf6dd3cb62bc85cdc29f7b2b1 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:42:36 +0530 Subject: [PATCH 3/3] suggestion: made the some changes --- .env.example | 2 +- backend/app/core/config.py | 120 ++++++++++++------------ backend/app/tests/core/test_config.py | 128 ++++++++++++-------------- 3 files changed, 123 insertions(+), 127 deletions(-) diff --git a/.env.example b/.env.example index 4955eb2b1..c8ffae77c 100644 --- a/.env.example +++ b/.env.example @@ -99,5 +99,5 @@ FRONTEND_HOST= # AWS Secrets Manager USE_AWS_SECRETS=false -AWS_SECRETS_NAMES= AWS_SECRETS_REGION= +AWS_POSTGRES_SECRET_NAME= diff --git a/backend/app/core/config.py b/backend/app/core/config.py index bee8859a7..26900746a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,3 +1,4 @@ +import boto3 import json import logging import secrets @@ -23,48 +24,6 @@ logger = logging.getLogger(__name__) -def load_secret_from_aws( - secret_names: str, field_map: dict[str, str] -) -> dict[str, Any]: - """Fetch one or more AWS secrets and remap their JSON keys. - - secret_names: comma-separated Secrets Manager IDs - (e.g. "kaapi-staging-db,kaapi-staging-cache"). - field_map: maps JSON keys in the secrets → Settings field names - (e.g. {"password": "POSTGRES_PASSWORD"}). - """ - if os.getenv("USE_AWS_SECRETS", "").strip().lower() not in ("1", "true", "yes"): - return {} - - names = [n.strip() for n in secret_names.split(",") if n.strip()] - if not names: - return {} - - import boto3 - - region = os.getenv("AWS_SECRETS_REGION") or os.getenv("AWS_DEFAULT_REGION") - client_kwargs: dict[str, Any] = {"region_name": region} if region else {} - client = boto3.client("secretsmanager", **client_kwargs) - - merged: dict[str, Any] = {} - for name in names: - data = json.loads(client.get_secret_value(SecretId=name)["SecretString"]) - if not isinstance(data, dict): - raise ValueError( - f"Secret '{name}' must be a JSON object " f"(got {type(data).__name__})" - ) - merged.update(data) - - overrides = { - field: merged[key] for key, field in field_map.items() if key in merged - } - logger.info( - f"[load_secret_from_aws] Loaded AWS secrets | " - f"{{'names': {names}, 'fields': {sorted(overrides)}}}" - ) - return overrides - - def parse_cors(origins: Any) -> list[str] | str: # If it's a plain comma-separated string, split it into a list if isinstance(origins, str) and not origins.startswith("["): @@ -95,9 +54,9 @@ class Settings(BaseSettings): PROJECT_NAME: str API_VERSION: str = "0.5.0" SENTRY_DSN: HttpUrl | None = None - POSTGRES_SERVER: str + POSTGRES_SERVER: str = "" POSTGRES_PORT: int = 5432 - POSTGRES_USER: str + POSTGRES_USER: str = "" POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" KAAPI_GUARDRAILS_AUTH: str = "" @@ -155,8 +114,8 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: # AWS Secrets Manager USE_AWS_SECRETS: bool = False - AWS_SECRETS_NAMES: str = "" AWS_SECRETS_REGION: str = "" + AWS_POSTGRES_SECRET_NAME: str = "" # RabbitMQ configuration for Celery broker RABBITMQ_HOST: str = "localhost" @@ -230,6 +189,64 @@ def _check_default_secret(self, var_name: str, value: str | None) -> None: else: raise ValueError(message) + @model_validator(mode="after") + def _apply_aws_secrets(self) -> Self: + """Overlay service credentials from AWS Secrets Manager when enabled. + + Each AWS_*_SECRET_NAME points to a secret whose SecretString is a + JSON object (e.g. the format produced by RDS/ElastiCache rotation). + Each secret is processed independently with its own field map so + services that share key names (username/password/host/port) do not + overwrite each other. Keys absent from a secret leave the existing + .env-derived value untouched. + """ + if not self.USE_AWS_SECRETS: + return self + + secret_configs: list[tuple[str, dict[str, str]]] = [ + ( + self.AWS_POSTGRES_SECRET_NAME, + { + "username": "POSTGRES_USER", + "password": "POSTGRES_PASSWORD", + "host": "POSTGRES_SERVER", + "port": "POSTGRES_PORT", + }, + ), + ] + + active = [(name, field_map) for name, field_map in secret_configs if name] + if not active: + return self + + region = self.AWS_SECRETS_REGION or self.AWS_DEFAULT_REGION + client_kwargs: dict[str, Any] = {"region_name": region} if region else {} + client = boto3.client("secretsmanager", **client_kwargs) + + for secret_name, field_map in active: + self._load_secret(client, secret_name, field_map) + + return self + + def _load_secret( + self, client: Any, secret_name: str, field_map: dict[str, str] + ) -> None: + data = json.loads(client.get_secret_value(SecretId=secret_name)["SecretString"]) + if not isinstance(data, dict): + raise ValueError( + f"Secret '{secret_name}' must be a JSON object " + f"(got {type(data).__name__})" + ) + applied: list[str] = [] + for key, field in field_map.items(): + if key in data: + setattr(self, field, data[key]) + applied.append(field) + logger.info( + f"[_load_secret] Loaded AWS secret | " + f"{{'name': '{secret_name}', 'fields': {sorted(applied)}}}" + ) + @model_validator(mode="after") def _enforce_non_default_secrets(self) -> Self: self._check_default_secret("SECRET_KEY", self.SECRET_KEY) @@ -249,18 +266,7 @@ def get_settings() -> Settings: env_files = {"testing": "../.env.test", "development": "../.env"} env_file = env_files.get(environment, "../.env") - # Pull Postgres credentials from AWS Secrets Manager when enabled. - db_overrides = load_secret_from_aws( - secret_names=os.getenv("AWS_SECRETS_NAMES", ""), - field_map={ - "username": "POSTGRES_USER", - "password": "POSTGRES_PASSWORD", - "host": "POSTGRES_SERVER", - "port": "POSTGRES_PORT", - }, - ) - - return Settings(_env_file=env_file, **db_overrides) + return Settings(_env_file=env_file) # Export settings instance diff --git a/backend/app/tests/core/test_config.py b/backend/app/tests/core/test_config.py index d1b0a290a..aa16912aa 100644 --- a/backend/app/tests/core/test_config.py +++ b/backend/app/tests/core/test_config.py @@ -1,11 +1,21 @@ -"""Tests for load_secret_from_aws helper in app.core.config.""" +"""Tests for the _apply_aws_secrets validator on Settings.""" import json from unittest.mock import MagicMock, patch import pytest -from app.core.config import load_secret_from_aws +from app.core.config import Settings + +_REQUIRED_FIELDS = { + "PROJECT_NAME": "test", + "POSTGRES_SERVER": "localhost", + "POSTGRES_USER": "env-user", + "POSTGRES_PASSWORD": "env-pw", + "EMAIL_TEST_USER": "t@example.com", + "FIRST_SUPERUSER": "s@example.com", + "FIRST_SUPERUSER_PASSWORD": "superpw", +} @pytest.fixture(autouse=True) @@ -13,98 +23,71 @@ def _clean_aws_env(monkeypatch): """Strip any AWS-related env vars that may leak from .env.test.""" for var in ( "USE_AWS_SECRETS", - "AWS_SECRETS_NAMES", + "AWS_POSTGRES_SECRET_NAME", "AWS_SECRETS_REGION", "AWS_DEFAULT_REGION", ): monkeypatch.delenv(var, raising=False) -class TestLoadSecretFromAws: - def test_toggle_off_returns_empty(self, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "false") - assert ( - load_secret_from_aws("any-secret", {"password": "POSTGRES_PASSWORD"}) == {} - ) +def _make_settings(**overrides) -> Settings: + """Build a Settings instance bypassing the .env file.""" + return Settings(_env_file=None, **{**_REQUIRED_FIELDS, **overrides}) - def test_toggle_unset_returns_empty(self): - assert ( - load_secret_from_aws("any-secret", {"password": "POSTGRES_PASSWORD"}) == {} - ) - def test_empty_secret_names_returns_empty(self, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "true") - assert load_secret_from_aws("", {"password": "POSTGRES_PASSWORD"}) == {} +class TestApplyAwsSecrets: + def test_toggle_off_keeps_env_values(self): + s = _make_settings(USE_AWS_SECRETS=False) + assert s.POSTGRES_PASSWORD == "env-pw" + assert s.POSTGRES_USER == "env-user" - def test_whitespace_only_secret_names_returns_empty(self, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "true") - assert load_secret_from_aws(" , ,", {"password": "POSTGRES_PASSWORD"}) == {} + def test_toggle_on_but_no_secret_names_keeps_env_values(self): + s = _make_settings(USE_AWS_SECRETS=True) + assert s.POSTGRES_PASSWORD == "env-pw" + assert s.POSTGRES_USER == "env-user" @patch("boto3.client") - def test_single_secret_maps_all_fields(self, mock_boto, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "true") - monkeypatch.setenv("AWS_SECRETS_REGION", "ap-south-1") + def test_postgres_secret_overrides_postgres_fields(self, mock_boto): mock_client = MagicMock() mock_client.get_secret_value.return_value = { "SecretString": json.dumps( - {"username": "u", "password": "p", "host": "h", "port": 5432} + {"username": "u", "password": "p", "host": "h", "port": 5433} ) } mock_boto.return_value = mock_client - result = load_secret_from_aws( - "kaapi-db", - { - "username": "POSTGRES_USER", - "password": "POSTGRES_PASSWORD", - "host": "POSTGRES_SERVER", - "port": "POSTGRES_PORT", - }, + setting = _make_settings( + USE_AWS_SECRETS=True, + AWS_POSTGRES_SECRET_NAME="kaapi-db", + AWS_SECRETS_REGION="ap-south-1", ) - assert result == { - "POSTGRES_USER": "u", - "POSTGRES_PASSWORD": "p", - "POSTGRES_SERVER": "h", - "POSTGRES_PORT": 5432, - } + assert setting.POSTGRES_USER == "u" + assert setting.POSTGRES_PASSWORD == "p" + assert setting.POSTGRES_SERVER == "h" + assert setting.POSTGRES_PORT == 5433 mock_boto.assert_called_once_with("secretsmanager", region_name="ap-south-1") mock_client.get_secret_value.assert_called_once_with(SecretId="kaapi-db") @patch("boto3.client") - def test_missing_keys_in_secret_are_skipped(self, mock_boto, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "true") + def test_missing_keys_fall_back_to_env(self, mock_boto): mock_client = MagicMock() mock_client.get_secret_value.return_value = { - "SecretString": json.dumps({"password": "p"}) + "SecretString": json.dumps({"password": "aws-pw"}) } mock_boto.return_value = mock_client - result = load_secret_from_aws( - "kaapi-db", - {"username": "POSTGRES_USER", "password": "POSTGRES_PASSWORD"}, + setting = _make_settings( + USE_AWS_SECRETS=True, + AWS_POSTGRES_SECRET_NAME="kaapi-db", ) - assert result == {"POSTGRES_PASSWORD": "p"} - - @patch("boto3.client") - def test_multiple_secrets_merge_left_to_right(self, mock_boto, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "true") - mock_client = MagicMock() - mock_client.get_secret_value.side_effect = [ - {"SecretString": json.dumps({"password": "first"})}, - {"SecretString": json.dumps({"password": "second"})}, - ] - mock_boto.return_value = mock_client - - result = load_secret_from_aws("a,b", {"password": "POSTGRES_PASSWORD"}) - - assert result == {"POSTGRES_PASSWORD": "second"} - assert mock_client.get_secret_value.call_count == 2 + assert setting.POSTGRES_PASSWORD == "aws-pw" + assert setting.POSTGRES_USER == "env-user" + assert setting.POSTGRES_SERVER == "localhost" @patch("boto3.client") - def test_non_dict_payload_raises(self, mock_boto, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "true") + def test_non_dict_payload_raises(self, mock_boto): mock_client = MagicMock() mock_client.get_secret_value.return_value = { "SecretString": json.dumps(["not", "a", "dict"]) @@ -112,31 +95,38 @@ def test_non_dict_payload_raises(self, mock_boto, monkeypatch): mock_boto.return_value = mock_client with pytest.raises(ValueError, match="must be a JSON object"): - load_secret_from_aws("kaapi-db", {"password": "POSTGRES_PASSWORD"}) + _make_settings( + USE_AWS_SECRETS=True, + AWS_POSTGRES_SECRET_NAME="kaapi-db", + ) @patch("boto3.client") - def test_region_falls_back_to_aws_default_region(self, mock_boto, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "true") - monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") + def test_region_falls_back_to_aws_default_region(self, mock_boto): mock_client = MagicMock() mock_client.get_secret_value.return_value = { "SecretString": json.dumps({"password": "p"}) } mock_boto.return_value = mock_client - load_secret_from_aws("kaapi-db", {"password": "POSTGRES_PASSWORD"}) + _make_settings( + USE_AWS_SECRETS=True, + AWS_POSTGRES_SECRET_NAME="kaapi-db", + AWS_DEFAULT_REGION="us-east-1", + ) mock_boto.assert_called_once_with("secretsmanager", region_name="us-east-1") @patch("boto3.client") - def test_no_region_builds_client_without_region_kwarg(self, mock_boto, monkeypatch): - monkeypatch.setenv("USE_AWS_SECRETS", "true") + def test_no_region_builds_client_without_kwarg(self, mock_boto): mock_client = MagicMock() mock_client.get_secret_value.return_value = { "SecretString": json.dumps({"password": "p"}) } mock_boto.return_value = mock_client - load_secret_from_aws("kaapi-db", {"password": "POSTGRES_PASSWORD"}) + _make_settings( + USE_AWS_SECRETS=True, + AWS_POSTGRES_SECRET_NAME="kaapi-db", + ) mock_boto.assert_called_once_with("secretsmanager")