Repository: cal-itp/benefits

Issue:
Better defaults for environment variables
New environment variables being added to the application cause an issue where people's `.env` files get out of sync, and hit confusing errors. It seems like most of us can use the [`.env.sample`](https://github.com/cal-itp/benefits/blob/dev/.devcontainer/.env.sample) as-is, so what if instead of having to make a copy of that file regularly, we instead had defaults that made the application work (at least in development) without modification? That way, the `.env` just gets used for overrides, making it very clear what has been changed from the defaults.


Discussion:

Resolving patch:
diff --git a/benefits/core/middleware.py b/benefits/core/middleware.py
--- a/benefits/core/middleware.py
+++ b/benefits/core/middleware.py
@@ -34,8 +34,8 @@ class RateLimit(MiddlewareMixin):
     """Middleware checks settings and session to ensure rate limit is respected."""
 
     def process_request(self, request):
-        if any((settings.RATE_LIMIT < 1, len(settings.RATE_LIMIT_METHODS) < 1, settings.RATE_LIMIT_PERIOD < 1)):
-            logger.debug("RATE_LIMIT, RATE_LIMIT_METHODS, or RATE_LIMIT_PERIOD are not configured")
+        if not settings.RATE_LIMIT_ENABLED:
+            logger.debug("Rate Limiting is not configured")
             return None
 
         if request.method in settings.RATE_LIMIT_METHODS:
diff --git a/benefits/settings.py b/benefits/settings.py
--- a/benefits/settings.py
+++ b/benefits/settings.py
@@ -12,14 +12,14 @@ def _filter_empty(ls):
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 # SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
+SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "secret")
 
 # SECURITY WARNING: don't run with debug turned on in production!
 DEBUG = os.environ.get("DJANGO_DEBUG", "False").lower() == "true"
 
 ADMIN = os.environ.get("DJANGO_ADMIN", "False").lower() == "true"
 
-ALLOWED_HOSTS = _filter_empty(os.environ["DJANGO_ALLOWED_HOSTS"].split(","))
+ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(","))
 
 # Application definition
 
@@ -69,7 +69,7 @@ def _filter_empty(ls):
 CSRF_COOKIE_AGE = None
 CSRF_COOKIE_SAMESITE = "Strict"
 CSRF_COOKIE_HTTPONLY = True
-CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ["DJANGO_TRUSTED_ORIGINS"].split(","))
+CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ.get("DJANGO_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(","))
 
 # With `Strict`, the user loses their Django session between leaving our app to
 # sign in with OAuth, and coming back into our app from the OAuth redirect.
@@ -140,7 +140,7 @@ def _filter_empty(ls):
 DATABASES = {
     "default": {
         "ENGINE": "django.db.backends.sqlite3",
-        "NAME": os.environ.get("DJANGO_DB", "django") + ".db",
+        "NAME": "django.db",
     }
 }
 
@@ -168,14 +168,16 @@ def _filter_empty(ls):
 
 # OAuth configuration
 
-OAUTH_CLIENT_NAME = os.environ.get("DJANGO_OAUTH_CLIENT_NAME")
+OAUTH_AUTHORITY = os.environ.get("DJANGO_OAUTH_AUTHORITY", "http://example.com")
+OAUTH_CLIENT_NAME = os.environ.get("DJANGO_OAUTH_CLIENT_NAME", "benefits-oauth-client-name")
+OAUTH_CLIENT_ID = os.environ.get("DJANGO_OAUTH_CLIENT_ID", "benefits-oauth-client-id")
 
 if OAUTH_CLIENT_NAME:
     AUTHLIB_OAUTH_CLIENTS = {
         OAUTH_CLIENT_NAME: {
-            "client_id": os.environ.get("DJANGO_OAUTH_CLIENT_ID"),
-            "server_metadata_url": f"{os.environ.get('DJANGO_OAUTH_AUTHORITY')}/.well-known/openid-configuration",
-            "client_kwargs": {"code_challenge_method": "S256", "scope": os.environ.get("DJANGO_OAUTH_SCOPE")},
+            "client_id": OAUTH_CLIENT_ID,
+            "server_metadata_url": f"{OAUTH_AUTHORITY}/.well-known/openid-configuration",
+            "client_kwargs": {"code_challenge_method": "S256", "scope": "openid"},
         }
     }
 
@@ -231,6 +233,28 @@ def _filter_empty(ls):
 
 ANALYTICS_KEY = os.environ.get("ANALYTICS_KEY")
 
+# rate limit configuration
+
+# number of requests allowed in the given period
+RATE_LIMIT = int(os.environ.get("DJANGO_RATE_LIMIT", 5))
+
+# HTTP request methods to rate limit
+RATE_LIMIT_METHODS = os.environ.get("DJANGO_RATE_LIMIT_METHODS", "POST").upper().split(",")
+
+# number of seconds before additional requests are denied
+RATE_LIMIT_PERIOD = int(os.environ.get("DJANGO_RATE_LIMIT_PERIOD", 60))
+
+# Rate Limit feature flag
+RATE_LIMIT_ENABLED = all((RATE_LIMIT > 0, len(RATE_LIMIT_METHODS) > 0, RATE_LIMIT_PERIOD > 0))
+
+# reCAPTCHA configuration
+
+RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL", "https://www.google.com/recaptcha/api.js")
+RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
+RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
+RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL", "https://www.google.com/recaptcha/api/siteverify")
+RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))
+
 # Content Security Policy
 # Configuration docs at https://django-csp.readthedocs.io/en/latest/configuration.html
 
@@ -239,42 +263,40 @@ def _filter_empty(ls):
 
 CSP_DEFAULT_SRC = ["'self'"]
 
+CSP_CONNECT_SRC = ["'self'", "https://api.amplitude.com/"]
 env_connect_src = _filter_empty(os.environ.get("DJANGO_CSP_CONNECT_SRC", "").split(","))
-CSP_CONNECT_SRC = ["'self'"]
 CSP_CONNECT_SRC.extend(env_connect_src)
 
+CSP_FONT_SRC = ["https://california.azureedge.net/cdt/statetemplate/", "https://fonts.gstatic.com/"]
 env_font_src = _filter_empty(os.environ.get("DJANGO_CSP_FONT_SRC", "").split(","))
-CSP_FONT_SRC = list(env_font_src)
+CSP_FONT_SRC.extend(env_font_src)
 
 CSP_FRAME_ANCESTORS = ["'none'"]
+
 CSP_FRAME_SRC = ["'none'"]
 env_frame_src = _filter_empty(os.environ.get("DJANGO_CSP_FRAME_SRC", "").split(","))
-if any(env_frame_src):
-    CSP_FRAME_SRC = list(env_frame_src)
+CSP_FRAME_SRC.extend(env_frame_src)
+if RECAPTCHA_ENABLED:
+    CSP_FRAME_SRC.append("https://www.google.com")
+
 
+CSP_SCRIPT_SRC = [
+    "'unsafe-inline'",
+    "https://california.azureedge.net/cdt/statetemplate/",
+    "https://cdn.amplitude.com/libs/",
+    "https://code.jquery.com/",
+    "*.littlepay.com",
+]
 env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(","))
-CSP_SCRIPT_SRC = ["'unsafe-inline'"]
 CSP_SCRIPT_SRC.extend(env_script_src)
-
+if RECAPTCHA_ENABLED:
+    CSP_SCRIPT_SRC.extend(["https://www.google.com/recaptcha/", "https://www.gstatic.com/recaptcha/releases/"])
+
+CSP_STYLE_SRC = [
+    "'self'",
+    "'unsafe-inline'",
+    "https://california.azureedge.net/cdt/statetemplate/",
+    "https://fonts.googleapis.com/css",
+]
 env_style_src = _filter_empty(os.environ.get("DJANGO_CSP_STYLE_SRC", "").split(","))
-CSP_STYLE_SRC = ["'self'", "'unsafe-inline'"]
 CSP_STYLE_SRC.extend(env_style_src)
-
-# rate limit configuration
-
-# number of requests allowed in the given period
-RATE_LIMIT = int(os.environ.get("DJANGO_RATE_LIMIT", 0))
-
-# HTTP request methods to rate limit
-RATE_LIMIT_METHODS = os.environ.get("DJANGO_RATE_LIMIT_METHODS", "").upper().split(",")
-
-# number of seconds before additional requests are denied
-RATE_LIMIT_PERIOD = int(os.environ.get("DJANGO_RATE_LIMIT_PERIOD", 0))
-
-# reCAPTCHA configuration
-
-RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL")
-RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
-RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
-RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL")
-RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))


Test patch:
diff --git a/tests/cypress/.env.tests b/tests/cypress/.env.tests
deleted file mode 100644
--- a/tests/cypress/.env.tests
+++ /dev/null
@@ -1,23 +0,0 @@
-# Django config
-DJANGO_ADMIN=false
-DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,[::1]
-DJANGO_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1
-DJANGO_CSP_FONT_SRC=https://california.azureedge.net/cdt/statetemplate/,https://fonts.gstatic.com/
-DJANGO_CSP_SCRIPT_SRC=https://code.jquery.com/,https://cdn.amplitude.com/,https://california.azureedge.net/
-DJANGO_CSP_STYLE_SRC=https://california.azureedge.net/cdt/statetemplate/,https://fonts.googleapis.com/css
-DJANGO_DB=django
-DJANGO_DEBUG=false
-DJANGO_RECAPTCHA_API_URL=https://www.google.com/recaptcha/api.js
-DJANGO_RECAPTCHA_SITE_KEY=
-DJANGO_RECAPTCHA_SECRET_KEY=
-DJANGO_RECAPTCHA_VERIFY_URL=https://www.google.com/recaptcha/api/siteverify
-DJANGO_INIT_PATH=fixtures/??_*.json
-DJANGO_LOG_LEVEL=INFO
-DJANGO_OAUTH_AUTHORITY=https://example.com
-DJANGO_OAUTH_CLIENT_ID=client-id
-DJANGO_OAUTH_CLIENT_NAME=testclient
-DJANGO_OAUTH_SCOPE=openid
-DJANGO_SECRET_KEY=secret
-DJANGO_RATE_LIMIT=5
-DJANGO_RATE_LIMIT_METHODS=POST
-DJANGO_RATE_LIMIT_PERIOD=30
diff --git a/tests/cypress/package-lock.json b/tests/cypress/package-lock.json
--- a/tests/cypress/package-lock.json
+++ b/tests/cypress/package-lock.json
@@ -9,8 +9,7 @@
       "version": "1.0.0",
       "license": "AGPL-3.0-or-later",
       "devDependencies": {
-        "cypress": "^9.7.0",
-        "env-cmd": "^10.1.0"
+        "cypress": "^9.7.0"
       }
     },
     "node_modules/@cypress/request": {
@@ -408,6 +407,7 @@
       "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==",
       "dev": true,
       "dependencies": {
+        "colors": "1.4.0",
         "string-width": "^4.2.0"
       },
       "engines": {
@@ -661,31 +661,6 @@
         "node": ">=8.6"
       }
     },
-    "node_modules/env-cmd": {
-      "version": "10.1.0",
-      "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-10.1.0.tgz",
-      "integrity": "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==",
-      "dev": true,
-      "dependencies": {
-        "commander": "^4.0.0",
-        "cross-spawn": "^7.0.0"
-      },
-      "bin": {
-        "env-cmd": "bin/env-cmd.js"
-      },
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
-    "node_modules/env-cmd/node_modules/commander": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
-      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
-      "dev": true,
-      "engines": {
-        "node": ">= 6"
-      }
-    },
     "node_modules/escape-string-regexp": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -748,6 +723,7 @@
       "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
       "dev": true,
       "dependencies": {
+        "@types/yauzl": "^2.9.1",
         "debug": "^4.1.1",
         "get-stream": "^5.1.0",
         "yauzl": "^2.10.0"
@@ -1111,6 +1087,7 @@
       "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
       "dev": true,
       "dependencies": {
+        "graceful-fs": "^4.1.6",
         "universalify": "^2.0.0"
       },
       "optionalDependencies": {
@@ -2341,24 +2318,6 @@
         "ansi-colors": "^4.1.1"
       }
     },
-    "env-cmd": {
-      "version": "10.1.0",
-      "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-10.1.0.tgz",
-      "integrity": "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==",
-      "dev": true,
-      "requires": {
-        "commander": "^4.0.0",
-        "cross-spawn": "^7.0.0"
-      },
-      "dependencies": {
-        "commander": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
-          "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
-          "dev": true
-        }
-      }
-    },
     "escape-string-regexp": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
diff --git a/tests/cypress/package.json b/tests/cypress/package.json
--- a/tests/cypress/package.json
+++ b/tests/cypress/package.json
@@ -5,16 +5,15 @@
   "main": "index.js",
   "scripts": {
     "cypress": "npm test",
-    "cypress:open": "env-cmd -f ./.env.tests npx cypress open",
+    "cypress:open": "npx cypress open",
     "cypress:feature": "npm test -- --spec \"specs/feature/**/*\"",
     "cypress:ui": "npm test -- --spec \"specs/ui/**/*\"",
-    "test": "env-cmd -f ./.env.tests npx cypress run"
+    "test": "npx cypress run"
   },
   "author": "",
   "license": "AGPL-3.0-or-later",
   "private": true,
   "devDependencies": {
-    "cypress": "^9.7.0",
-    "env-cmd": "^10.1.0"
+    "cypress": "^9.7.0"
   }
 }
diff --git a/tests/pytest/core/test_session.py b/tests/pytest/core/test_session.py
--- a/tests/pytest/core/test_session.py
+++ b/tests/pytest/core/test_session.py
@@ -200,7 +200,8 @@ def test_rate_limit_time_default(app_request):
     t0 = session.rate_limit_time(app_request)
     t1 = int(time.time())
 
-    assert t1 >= t0
+    # rate limit expiry time should be in the future
+    assert t0 >= t1
 
 
 @pytest.mark.django_db
diff --git a/tests/pytest/run.sh b/tests/pytest/run.sh
--- a/tests/pytest/run.sh
+++ b/tests/pytest/run.sh
@@ -6,6 +6,3 @@ pytest --cov=benefits --cov-branch
 # clean out old coverage results
 rm -rf benefits/static/coverage
 coverage html --directory benefits/static/coverage
-
-echo
-echo "Launch the app and visit http://localhost:${DJANGO_LOCAL_PORT}/static/coverage/index.html"