mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 22:58:06 +00:00
test(api): cover the raw config write endpoints
/config/raw/main and /config/raw/secrets write whatever JSON they are given straight to config.json and config_secrets.json, bypassing the secret-separation path the rest of the config surface goes through. Given how carefully that surface keeps secrets out of config.json, the pair that skips it was worth pinning precisely. Backed by a real ConfigManager over tmp_path, so the assertions are against files on disk. 20 tests covering both routes: what lands in which file, that a raw secrets write never touches config.json and vice versa, the GitHub token reload, the uninitialized-manager and empty-body branches, and the ConfigError path that carries config_path through to the response. The bypass itself is pinned as intentional rather than changed — these back the raw JSON editor, so writing the body verbatim is the feature. The test says so explicitly, because the failure mode is someone later routing plugin config through here as a convenience and silently losing secret separation. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Endpoint tests for POST /config/raw/main and POST /config/raw/secrets.
|
||||
|
||||
These write whatever JSON they are given straight to config.json and
|
||||
config_secrets.json, bypassing the secret-separation path that
|
||||
/config/main and the plugin-config endpoints go through. Given how much
|
||||
care the rest of the config surface takes to keep secrets out of
|
||||
config.json, an untested pair of endpoints that writes it verbatim is
|
||||
worth pinning precisely.
|
||||
|
||||
Like test_api_v3_secret_roundtrip.py, these run a REAL ConfigManager over
|
||||
tmp_path so the assertions are against files on disk rather than mock
|
||||
calls.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from src.config_manager import ConfigManager # noqa: E402
|
||||
from src.exceptions import ConfigError # noqa: E402
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
MAIN = "/api/v3/config/raw/main"
|
||||
SECRETS = "/api/v3/config/raw/secrets"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"timezone": "UTC"}))
|
||||
secrets_file = tmp_path / "config_secrets.json"
|
||||
|
||||
config_manager = ConfigManager(
|
||||
config_path=str(config_file), secrets_path=str(secrets_file))
|
||||
config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
|
||||
_SENTINEL = object()
|
||||
attrs = ('config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager',
|
||||
'schema_manager', 'operation_queue', 'operation_history',
|
||||
'cache_manager')
|
||||
originals = {name: getattr(api_v3, name, _SENTINEL) for name in attrs}
|
||||
|
||||
for name in attrs:
|
||||
setattr(api_v3, name, MagicMock())
|
||||
api_v3.config_manager = config_manager
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
class Env:
|
||||
pass
|
||||
|
||||
e = Env()
|
||||
e.client = app.test_client()
|
||||
e.config_manager = config_manager
|
||||
e.config_file = config_file
|
||||
e.secrets_file = secrets_file
|
||||
yield e
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is _SENTINEL:
|
||||
if hasattr(api_v3, name):
|
||||
delattr(api_v3, name)
|
||||
else:
|
||||
setattr(api_v3, name, original)
|
||||
|
||||
|
||||
class TestSaveRawMain:
|
||||
def test_writes_the_body_to_config_json(self, env):
|
||||
response = env.client.post(MAIN, json={"timezone": "America/Chicago"})
|
||||
assert response.status_code == 200
|
||||
assert json.loads(env.config_file.read_text()) == {"timezone": "America/Chicago"}
|
||||
|
||||
def test_replaces_rather_than_merges(self, env):
|
||||
env.client.post(MAIN, json={"only": "this"})
|
||||
assert json.loads(env.config_file.read_text()) == {"only": "this"}
|
||||
|
||||
def test_does_not_touch_the_secrets_file(self, env):
|
||||
env.secrets_file.write_text(json.dumps({"weather": {"api_key": "k"}}))
|
||||
env.client.post(MAIN, json={"timezone": "UTC"})
|
||||
assert json.loads(env.secrets_file.read_text()) == {"weather": {"api_key": "k"}}
|
||||
|
||||
def test_uninitialized_manager_is_a_500(self, env):
|
||||
api_v3.config_manager = None
|
||||
response = env.client.post(MAIN, json={"timezone": "UTC"})
|
||||
assert response.status_code == 500
|
||||
assert "not initialized" in response.get_json()["message"]
|
||||
|
||||
def test_empty_object_is_a_400(self, env):
|
||||
response = env.client.post(MAIN, json={})
|
||||
assert response.status_code == 400
|
||||
assert "No data provided" in response.get_json()["message"]
|
||||
|
||||
def test_bodyless_post_is_a_400(self, env):
|
||||
response = env.client.post(MAIN)
|
||||
assert response.status_code == 400
|
||||
assert "No data provided" in response.get_json()["message"]
|
||||
|
||||
def test_malformed_json_is_a_400_in_the_app_shape(self, env):
|
||||
response = env.client.post(MAIN, data="{not json",
|
||||
content_type="application/json")
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert body["status"] == "error"
|
||||
|
||||
def test_config_error_is_a_500_with_context(self, env, monkeypatch):
|
||||
def refuse(kind, data):
|
||||
raise ConfigError("cannot write", config_path="/etc/x.json")
|
||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", refuse)
|
||||
response = env.client.post(MAIN, json={"timezone": "UTC"})
|
||||
assert response.status_code == 500
|
||||
assert "/etc/x.json" in json.dumps(response.get_json())
|
||||
|
||||
def test_unexpected_error_is_a_500(self, env, monkeypatch):
|
||||
def boom(kind, data):
|
||||
raise RuntimeError("disk on fire")
|
||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
||||
response = env.client.post(MAIN, json={"timezone": "UTC"})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
class TestSaveRawSecrets:
|
||||
def test_writes_only_to_the_secrets_file(self, env):
|
||||
response = env.client.post(SECRETS, json={"weather": {"api_key": "s3cret"}})
|
||||
assert response.status_code == 200
|
||||
assert json.loads(env.secrets_file.read_text()) == {"weather": {"api_key": "s3cret"}}
|
||||
|
||||
def test_secret_values_never_reach_config_json(self, env):
|
||||
env.client.post(SECRETS, json={"weather": {"api_key": "s3cret"}})
|
||||
assert "s3cret" not in env.config_file.read_text()
|
||||
|
||||
def test_existing_main_config_is_untouched(self, env):
|
||||
before = env.config_file.read_text()
|
||||
env.client.post(SECRETS, json={"weather": {"api_key": "k"}})
|
||||
assert env.config_file.read_text() == before
|
||||
|
||||
def test_github_token_is_reloaded_for_the_store_manager(self, env):
|
||||
store = MagicMock()
|
||||
store._load_github_token.return_value = "ghp_new"
|
||||
api_v3.plugin_store_manager = store
|
||||
env.client.post(SECRETS, json={"github": {"token": "ghp_new"}})
|
||||
store._load_github_token.assert_called_once()
|
||||
assert store.github_token == "ghp_new"
|
||||
|
||||
def test_absent_store_manager_is_fine(self, env):
|
||||
api_v3.plugin_store_manager = None
|
||||
assert env.client.post(SECRETS, json={"a": 1}).status_code == 200
|
||||
|
||||
def test_uninitialized_manager_is_a_500(self, env):
|
||||
api_v3.config_manager = None
|
||||
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
|
||||
|
||||
def test_empty_object_is_a_400(self, env):
|
||||
assert env.client.post(SECRETS, json={}).status_code == 400
|
||||
|
||||
def test_bodyless_post_is_a_400(self, env):
|
||||
assert env.client.post(SECRETS).status_code == 400
|
||||
|
||||
def test_error_is_a_500(self, env, monkeypatch):
|
||||
def boom(kind, data):
|
||||
raise RuntimeError("nope")
|
||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
||||
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
|
||||
|
||||
|
||||
class TestRawEndpointsBypassSecretSeparation:
|
||||
"""Pinned behaviour, deliberately not "fixed".
|
||||
|
||||
These endpoints are the escape hatch for editing the config files
|
||||
directly from the web UI's raw JSON editor. They write what they are
|
||||
given, so a secret typed into the main-config editor lands in
|
||||
config.json in plain text — unlike /config/main and the plugin-config
|
||||
endpoints, which route x-secret fields into config_secrets.json.
|
||||
|
||||
That is the point of a raw editor, but it is a sharp edge worth
|
||||
stating out loud: anyone adding a "convenience" that posts plugin
|
||||
config through this endpoint would silently lose secret separation.
|
||||
"""
|
||||
|
||||
def test_secret_shaped_keys_are_written_verbatim_to_main(self, env):
|
||||
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
|
||||
on_disk = json.loads(env.config_file.read_text())
|
||||
assert on_disk["weather"]["api_key"] == "PLAINTEXT-KEY"
|
||||
|
||||
def test_no_separation_happens_on_the_raw_path(self, env):
|
||||
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
|
||||
# Nothing was moved aside into the secrets file.
|
||||
assert not env.secrets_file.exists() or "PLAINTEXT-KEY" not in env.secrets_file.read_text()
|
||||
Reference in New Issue
Block a user