From f5c1eb4b9dc2afa30c9629e7834ca536901c21fd Mon Sep 17 00:00:00 2001 From: Mazen Tamer Salah <78306991+mazen-salah@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:20:10 +0300 Subject: [PATCH] fix(settings): degrade load_features to defaults on PermissionError load_settings() already catches PermissionError, but load_features() caught only FileNotFoundError/JSONDecodeError/ValueError. An existing-but-unreadable data/features.json (e.g. root-owned after a deploy) therefore raised instead of falling back to DEFAULT_FEATURES, taking down GET /api/auth/features and anything that reads feature flags. Add PermissionError to the except tuple to match load_settings(). Adds tests/test_load_features_permission_error.py. Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> --- src/settings.py | 2 +- tests/test_load_features_permission_error.py | 26 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/test_load_features_permission_error.py diff --git a/src/settings.py b/src/settings.py index f6540db53..f305355dc 100644 --- a/src/settings.py +++ b/src/settings.py @@ -283,7 +283,7 @@ def load_features() -> dict: if not isinstance(saved, dict): raise ValueError("features must be an object") merged = {**DEFAULT_FEATURES, **saved} - except (FileNotFoundError, json.JSONDecodeError, ValueError): + except (FileNotFoundError, PermissionError, json.JSONDecodeError, ValueError): merged = dict(DEFAULT_FEATURES) _features_cache = (now, merged) return merged diff --git a/tests/test_load_features_permission_error.py b/tests/test_load_features_permission_error.py new file mode 100644 index 000000000..309bcbcca --- /dev/null +++ b/tests/test_load_features_permission_error.py @@ -0,0 +1,26 @@ +"""load_features() must degrade to defaults if features.json is unreadable. + +load_settings() already catches PermissionError, but load_features() did not, so +an unreadable data/features.json (e.g. root-owned after a deploy) raised instead +of falling back to DEFAULT_FEATURES, taking down GET /api/auth/features. +""" +import builtins + +import src.settings as settings + + +def test_load_features_degrades_on_permission_error(monkeypatch): + # Ensure the cache does not short-circuit the read. + monkeypatch.setattr(settings, "_features_cache", None, raising=False) + + real_open = builtins.open + + def deny(path, *args, **kwargs): + if str(path) == str(settings.FEATURES_FILE): + raise PermissionError("denied") + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(builtins, "open", deny) + + result = settings.load_features() + assert result == dict(settings.DEFAULT_FEATURES)