fix(web): plugin settings endpoints - a refused save no longer leaks into config.json; GET masks secrets (#742)

* fix(config): load_config hands each caller a private copy

ConfigManager.load_config() returned its cached self.config itself (the
mtime fast path from #410 kept the full path's aliasing). Web handlers
edit what they load and then validate: the plugin form save applies the
posted fields to the loaded section (a shallow .copy(), so nested dicts
were the cache's own), and save_main_config sets its checkboxes before
it checks auto_update_channel. When the save was refused, the edit
stayed in the cache the fast path serves, and the next save of any
other setting wrote it to config.json: the refused value, and a nested
secret typed into the same form (mqtt.password, league.espn_s2,
flightaware.api_key) in plain text, since it never reached
config_secrets.json to be stripped. The form also reloaded showing the
refused values.

load_config() now returns a private copy on both paths, and
save_config/save_config_atomic keep a copy of what they were given, so
nothing a caller edits reaches the cache unless it is saved. Fixing it
here rather than in each handler covers every route that edits before it
validates. No caller relies on editing the cache without saving: every
src/ and web_interface/ caller either reads, or saves the dict it
edited. get_config() still returns the live dict for the display
process's readers.

The copy is a pickle round trip: on a Pi 4 with its real 64 KiB config,
2.0 ms against 6.9 ms for copy.deepcopy (json round trip 3.4 ms). Two
tests asserted the aliasing itself and now assert a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): GET /plugins/config masks secrets and refuses core sections

The route returned the plugin's section as load_config() has it, with
config_secrets.json merged in: API keys and tokens went out in plain
text. #276 masked them here; #330's rewrite of the route dropped it,
while the settings page and GET /config/secrets kept masking. It also
took any plugin_id, so ?plugin_id=web_auth returned the login's
cookie-signing key and password hash, and ?plugin_id=github the Plugin
Store token, which GET /config/main strips and redacts.

The route now refuses what _non_plugin_id_error refuses for reset and
uninstall (core sections, malformed ids) with a 400, and blanks x-secret
fields with mask_secret_fields after the defaults merge, as the page
does. A plugin with no schema has its credential-named fields blanked by
_redact_credentials, as GET /config/main does. Blank rather than the
bullets of GET /config/secrets: the save drops a blank secret as
"unchanged" (remove_empty_secrets) but would store the bullets, so the
response must post back as it came. Tested: GET, then POST the response
unchanged, keeps every stored secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): parse a table row's cells against the list's item schema

An array of objects drawn as a table posts each cell as
"cities.0.timezone". _get_schema_property stopped at "cities" (an array,
not an object with properties), so _parse_form_value_with_schema got no
schema for the cell and guessed: a blank optional text cell became None
and a text cell holding digits became an int. Validation refused both,
so every save of the page failed for as long as such a row existed --
geochron's city without a timezone, a countdown named "2027". A secret
cell is always drawn blank, so a plugin with secrets in its rows could
not be saved from the form at all.

The lookup now steps from an index segment into the array's items: to
the item schema itself for "color.2", into its properties for a row
cell. Number, boolean and required cells convert as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): a blank secret field saves as "unchanged", required or not

The settings page draws a stored secret blank (mask_secret_fields) and
posts the blank back. _parse_form_value_with_schema turned a blank
optional string into "" -- which the save drops as unchanged
(remove_empty_secrets) -- but a blank required one into None. For a
secret that is required with no default (youtube-stats' api_key) that
None failed validation, so every save of the page was refused until the
key was typed in again.

A blank text secret (x-secret, type string) now parses to "", whatever
its required list says; a list or object secret keeps getting [] or {},
which the save drops the same way. Not _SKIP_FIELD: skipping keeps the
value load_config() merged in, and the save would then write it back to
config_secrets.json -- after a secret change the cached section can
still hold the old one, so that write reverted it. A test covers that
sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): POST /plugins/config refuses core sections and malformed ids

Reset and uninstall check the plugin id with _non_plugin_id_error; the
save did not. {"plugin_id": "display", "config": {...}} found no schema,
so nothing was validated or filtered, and the body was merged into the
core display section along with "enabled": true -- rows: "banana"
included. A plugin_id that was not a string (a list, an object, a number)
reached config.get() or the schema lookup, raised TypeError, and came
back as a 500.

Both the JSON and the form path now call _non_plugin_id_error first and
answer its 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): a text field keeps "true", "[1, 2]" and "{}" as typed

_parse_form_value_with_schema guessed before it consulted the schema:
"true"/"false" became booleans, and a value starting with "[" or "{"
that parsed as JSON became a list or object, whatever the field's type.
A text setting holding "true", "False", "[1, 2]" or "{}" was then
refused by validation ("Expected type string, got bool"), and the save
with it.

A field whose schema type is string, or string-or-null, now returns the
posted text as it came. Every other type goes through the conversions as
before; numbers in text fields were already left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): copy the cached config without pickle

_private_copy was a pickle round trip. It only ever unpickled bytes it had
just made from our own dict, so nothing untrusted reached it, but it put
pickle in the config path and Codacy failed the PR for it (B301/B403).
The config is JSON data, so copying its dicts and lists is a full copy;
every other value is immutable. Measured on ledpi (Pi 4) with its real
60 KiB config: 2.11 ms, against 1.92 ms for pickle and 6.75 ms for
copy.deepcopy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): describe the config copy without pickle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 22:30:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 07abd87d5e
commit 0b039c875f
8 changed files with 609 additions and 19 deletions
+38 -1
View File
@@ -58,7 +58,8 @@ class TestFastPath:
for _ in range(10):
again = m.load_config()
assert counts["n"] == 0, "fast path must not re-open any config file"
assert again is first # same aliasing semantics as the full path
assert again == first
assert again is not first # each caller gets its own copy, see below
def test_config_change_triggers_reload(self, mgr):
m, config, secrets, template = mgr
@@ -98,6 +99,33 @@ class TestFastPath:
assert m.load_config()["timezone"] == "America/New_York"
class TestCallersGetACopy:
"""A web handler edits what load_config returned, then validates. When
validation failed, the edit stayed in the cache the fast path serves, and
the next unrelated save wrote it -- a nested secret included, in plain
text, because it had never reached config_secrets.json to be stripped."""
def test_editing_a_loaded_config_does_not_change_the_next_load(self, mgr):
m, config, secrets, template = mgr
loaded = m.load_config()
loaded["display"]["brightness"] = 1
loaded["weather"]["api_key"] = "typed-but-never-saved"
again = m.load_config()
assert again["display"]["brightness"] == 90
assert again["weather"]["api_key"] == "sek"
def test_the_full_path_also_returns_a_copy(self, mgr):
m, config, secrets, template = mgr
m.load_config()["display"]["brightness"] = 1 # first load: full path
assert m.load_config()["display"]["brightness"] == 90
def test_an_edit_never_reaches_a_later_save(self, mgr):
m, config, secrets, template = mgr
m.load_config()["display"]["new_secret"] = "hunter2" # then bailed out
m.save_config(m.load_config()) # some other handler saves
assert "hunter2" not in config.read_text()
class TestSaveCoherence:
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
m, config, secrets, template = mgr
@@ -111,6 +139,15 @@ class TestSaveCoherence:
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
assert counts["n"] == 0 # signature refreshed by save; no re-read
def test_the_saved_dict_does_not_become_the_cache(self, mgr):
m, config, secrets, template = mgr
m.load_config()
new = {"display": {"brightness": 42}, "timezone": "UTC",
"weather": {"api_key": "sek"}}
m.save_config(new)
new["display"]["brightness"] = 7 # the caller keeps using its dict
assert m.load_config()["display"]["brightness"] == 42
def test_cross_process_save_is_picked_up(self, mgr):
"""Another process writing config.json (different mtime) must bust
this process's fast path — the core cross-process guarantee."""
+4 -1
View File
@@ -143,7 +143,10 @@ class TestLoadFastPath:
manager = make_manager(tmp_path, config={"timezone": "UTC"})
first = manager.load_config()
second = manager.load_config()
assert second is first # same aliased dict, no re-read
# A copy of the cached dict, never the dict itself; that it is not
# re-read is test_config_load_cache's test_unchanged_files_are_not_reread
assert second == first
assert second is not first
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
manager = make_manager(
+13
View File
@@ -169,6 +169,10 @@ class TestGetSchemaProperty:
},
"fifa.world": {"type": "object",
"properties": {"enabled": {"type": "boolean"}}},
"cities": {"type": "array",
"items": {"type": "object",
"properties": {"timezone": {"type": "string"}}}},
"color": {"type": ["array", "null"], "items": {"type": "integer"}},
}
}
@@ -185,6 +189,15 @@ class TestGetSchemaProperty:
prop = _get_schema_property(self.SCHEMA, "fifa.world.enabled")
assert prop == {"type": "boolean"}
def test_an_index_steps_into_the_array_items(self):
# How a table row posts its cells
assert _get_schema_property(self.SCHEMA, "cities.0.timezone") == {"type": "string"}
assert _get_schema_property(self.SCHEMA, "color.2") == {"type": "integer"}
def test_a_non_index_under_an_array_is_not_found(self):
assert _get_schema_property(self.SCHEMA, "cities.timezone") is None
assert _get_schema_property(self.SCHEMA, "cities.0.nope") is None
def test_missing_path_returns_none(self):
assert _get_schema_property(self.SCHEMA, "nope.nope") is None
@@ -0,0 +1,393 @@
"""GET and POST /plugins/config against a real ConfigManager and SchemaManager.
Each class is one bug, reproduced through the endpoint the settings form and
API clients use, with assertions on config.json and config_secrets.json.
"""
import json
from unittest.mock import MagicMock
import pytest
from flask import Flask
from src.config_manager import ConfigManager
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints.api_v3 import api_v3
PLUGIN_ID = "demo"
OTHER_ID = "other"
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"api_key": {"type": "string", "x-secret": True, "default": ""},
"city": {"type": "string", "default": "Austin"},
"mqtt": {
"type": "object",
"properties": {
"host": {"type": "string", "default": ""},
"port": {"type": "integer", "default": 1883,
"minimum": 1, "maximum": 65535},
"password": {"type": "string", "x-secret": True, "default": ""},
},
},
"accounts": {
"type": "array",
"default": [],
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"token": {"type": "string", "x-secret": True},
},
},
},
},
}
OTHER_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"label": {"type": "string", "default": "x"},
},
}
STORED = {
PLUGIN_ID: {"enabled": True, "city": "Paris",
"mqtt": {"host": "broker", "port": 1883},
"accounts": [{"name": "a"}, {"name": "b"}]},
OTHER_ID: {"enabled": True, "label": "hello"},
}
STORED_SECRETS = {
PLUGIN_ID: {"api_key": "TOPSECRET",
"accounts": [{"token": "TOK-A"}, {"token": "TOK-B"}]},
}
_ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager')
@pytest.fixture
def env(tmp_path):
config_file = tmp_path / "config.json"
secrets_file = tmp_path / "config_secrets.json"
plugins_dir = tmp_path / "plugins"
for plugin_id, schema in ((PLUGIN_ID, SCHEMA), (OTHER_ID, OTHER_SCHEMA)):
plugin_dir = plugins_dir / plugin_id
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(json.dumps(schema))
(plugin_dir / "manifest.json").write_text(json.dumps({"id": plugin_id}))
config_file.write_text(json.dumps(STORED))
secrets_file.write_text(json.dumps(STORED_SECRETS))
sentinel = object()
originals = {name: getattr(api_v3, name, sentinel) for name in _ATTRS}
config_manager = ConfigManager(config_path=str(config_file),
secrets_path=str(secrets_file))
config_manager.template_path = str(tmp_path / "no-template.json")
plugin_manager = MagicMock()
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
OTHER_ID: {"id": OTHER_ID}}
plugin_manager.plugins_dir = plugins_dir
for name in _ATTRS:
setattr(api_v3, name, MagicMock())
api_v3.config_manager = config_manager
api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
api_v3.plugin_catalog = plugin_manager
api_v3.operation_queue = None
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
class Env:
client = app.test_client()
@staticmethod
def use_schema(schema, plugin_id=PLUGIN_ID):
(plugins_dir / plugin_id / "config_schema.json").write_text(json.dumps(schema))
@staticmethod
def store(section, plugin_id=PLUGIN_ID):
main = json.loads(config_file.read_text())
main[plugin_id] = section
config_file.write_text(json.dumps(main))
@staticmethod
def main():
return json.loads(config_file.read_text())
@staticmethod
def secrets():
return json.loads(secrets_file.read_text())
@staticmethod
def post_form(data, plugin_id=PLUGIN_ID):
return Env.client.post(f"/api/v3/plugins/config?plugin_id={plugin_id}",
data=data)
@staticmethod
def post_json(config, plugin_id=PLUGIN_ID):
return Env.client.post("/api/v3/plugins/config",
json={"plugin_id": plugin_id, "config": config})
yield Env
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 TestARejectedSaveLeavesNothingBehind:
"""The form save edited the cached config load_config hands out, then
failed validation. The cache kept the edit, and the next save of any
other setting wrote it to config.json -- the rejected value, and a
nested secret typed into the same form in plain text."""
REJECTED = {"mqtt.host": "broker", "mqtt.port": "99999",
"mqtt.password": "hunter2", "__rendered_section": ["mqtt"]}
def test_the_rejected_values_never_reach_config_json(self, env):
assert env.post_form(self.REJECTED).status_code == 400
resp = env.post_json({"label": "bye"}, plugin_id=OTHER_ID)
assert resp.status_code == 200, resp.get_json()
main = env.main()
assert main[OTHER_ID]["label"] == "bye"
assert main[PLUGIN_ID]["mqtt"] == {"host": "broker", "port": 1883}
assert "hunter2" not in json.dumps(main)
def test_the_form_reloads_with_the_stored_values(self, env):
assert env.post_form(self.REJECTED).status_code == 400
assert api_v3.config_manager.load_config()[PLUGIN_ID]["mqtt"]["port"] == 1883
class TestGetMasksSecrets:
"""GET /plugins/config returned the section with config_secrets.json
merged in, secrets and all: the masking #276 added was lost when the
route was rewritten. The settings page and GET /config/secrets mask."""
def test_secrets_come_back_blank(self, env):
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
assert data["api_key"] == ""
assert data["accounts"] == [{"name": "a", "token": ""}, {"name": "b", "token": ""}]
assert data["city"] == "Paris"
def test_posting_the_response_back_keeps_every_secret(self, env):
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
resp = env.post_json(data)
assert resp.status_code == 200, resp.get_json()
assert env.secrets()[PLUGIN_ID] == STORED_SECRETS[PLUGIN_ID]
assert "TOPSECRET" not in json.dumps(env.main())
def test_the_settings_form_posting_masked_fields_keeps_every_secret(self, env):
# The page renders secrets blank (pages_v3 masks the same way)
resp = env.post_form({
"api_key": "", "city": "Lyon", "mqtt.host": "broker", "mqtt.port": "1883",
"mqtt.password": "", "__rendered_section": ["api_key", "city", "mqtt"]})
assert resp.status_code == 200, resp.get_json()
assert env.secrets()[PLUGIN_ID] == STORED_SECRETS[PLUGIN_ID]
assert env.main()[PLUGIN_ID]["city"] == "Lyon"
def test_a_plugin_without_a_schema_has_credential_named_fields_blanked(self, env, tmp_path):
(tmp_path / "plugins" / "bare").mkdir()
env.store({"enabled": True, "station": "KAUS"}, plugin_id="bare")
secrets = env.secrets()
secrets["bare"] = {"api_token": "BARE-TOKEN"}
(tmp_path / "config_secrets.json").write_text(json.dumps(secrets))
data = env.client.get("/api/v3/plugins/config?plugin_id=bare").get_json()["data"]
assert data["api_token"] == ""
assert data["station"] == "KAUS"
@pytest.mark.parametrize("section", ["web_auth", "github", "display"])
def test_a_core_section_is_refused(self, env, tmp_path, section):
secrets = env.secrets()
secrets["web_auth"] = {"cookie_secret": "COOKIE-KEY", "password_hash": "HASH"}
secrets["github"] = {"api_token": "ghp_TOKEN"}
(tmp_path / "config_secrets.json").write_text(json.dumps(secrets))
env.store({"hardware": {"rows": 32}}, plugin_id="display")
resp = env.client.get(f"/api/v3/plugins/config?plugin_id={section}")
assert resp.status_code == 400
body = resp.get_data(as_text=True)
assert "COOKIE-KEY" not in body and "ghp_TOKEN" not in body
ROWS_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"cities": {
"type": "array",
"x-widget": "array-table",
"default": [],
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"timezone": {"type": "string"},
"lat": {"type": "number"},
"show": {"type": "boolean", "default": True},
},
"required": ["name", "lat"],
},
},
},
}
class TestArrayRowCellsFollowTheItemSchema:
"""A table row posts its cells as ``cities.0.timezone``. The schema
lookup stopped at the array, so each cell was parsed blind: a blank
optional text cell became null and a text cell holding digits became a
number, and either failed validation -- every save of the page, for as
long as the row existed (geochron's city without a timezone, a countdown
named "2027")."""
ROW = {"cities.0.name": "Tokyo", "cities.0.timezone": "Asia/Tokyo",
"cities.0.lat": "35.68", "cities.0.show": "true",
"__rendered_section": ["cities"]}
@pytest.fixture(autouse=True)
def _rows(self, env):
env.use_schema(ROWS_SCHEMA)
env.store({"enabled": True, "cities": [
{"name": "Tokyo", "timezone": "Asia/Tokyo", "lat": 35.68, "show": True}]})
def test_a_blank_optional_text_cell_saves(self, env):
resp = env.post_form({**self.ROW, "cities.0.timezone": ""})
assert resp.status_code == 200, resp.get_json()
assert env.main()[PLUGIN_ID]["cities"][0]["timezone"] == ""
def test_a_text_cell_of_digits_stays_text(self, env):
resp = env.post_form({**self.ROW, "cities.0.name": "2027"})
assert resp.status_code == 200, resp.get_json()
assert env.main()[PLUGIN_ID]["cities"][0]["name"] == "2027"
def test_number_and_boolean_cells_still_convert(self, env):
resp = env.post_form({**self.ROW, "cities.0.show": "false"})
assert resp.status_code == 200, resp.get_json()
assert env.main()[PLUGIN_ID]["cities"] == [
{"name": "Tokyo", "timezone": "Asia/Tokyo", "lat": 35.68, "show": False}]
class TestMaskedSecretCellsInARow:
"""The same lookup: a row's secret cell, rendered blank, came back as
null and failed validation, so a plugin with secrets in a list could not
be saved from its settings page at all."""
def test_the_stored_tokens_survive_a_save_of_the_form(self, env):
resp = env.post_form({
"city": "Lyon", "accounts.0.name": "a", "accounts.0.token": "",
"accounts.1.name": "b", "accounts.1.token": "",
"__rendered_section": ["city", "accounts"]})
assert resp.status_code == 200, resp.get_json()
assert env.secrets()[PLUGIN_ID] == STORED_SECRETS[PLUGIN_ID]
assert env.main()[PLUGIN_ID]["accounts"] == [{"name": "a"}, {"name": "b"}]
class TestABlankSecretIsLeftAsStored:
"""The form renders a secret blank and posts the blank back. For a
required secret with no default (youtube-stats' api_key) the blank was
read as null, failed validation, and blocked every save of the page
until the key was typed in again."""
@pytest.fixture(autouse=True)
def _required_secret(self, env):
schema = json.loads(json.dumps(SCHEMA))
del schema["properties"]["api_key"]["default"]
schema["required"] = ["api_key"]
env.use_schema(schema)
def test_saving_other_settings_keeps_the_stored_secret(self, env):
resp = env.post_form({"api_key": "", "city": "Lyon",
"__rendered_section": ["api_key", "city"]})
assert resp.status_code == 200, resp.get_json()
assert env.main()[PLUGIN_ID]["city"] == "Lyon"
assert env.secrets()[PLUGIN_ID]["api_key"] == "TOPSECRET"
def test_a_new_secret_is_still_saved(self, env):
resp = env.post_form({"api_key": "NEW-KEY", "city": "Lyon",
"__rendered_section": ["api_key", "city"]})
assert resp.status_code == 200, resp.get_json()
assert env.secrets()[PLUGIN_ID]["api_key"] == "NEW-KEY"
def test_a_changed_secret_then_left_blank_stays_changed(self, env):
# The second save must not write back what the first one's load
# had merged in (the old key)
env.post_form({"api_key": "NEW-KEY", "__rendered_section": ["api_key"]})
resp = env.post_form({"api_key": "", "city": "Nice",
"__rendered_section": ["api_key", "city"]})
assert resp.status_code == 200, resp.get_json()
assert env.secrets()[PLUGIN_ID]["api_key"] == "NEW-KEY"
def test_a_blank_list_secret_is_left_as_stored_too(self, env, tmp_path):
schema = json.loads(json.dumps(SCHEMA))
schema["properties"]["tokens"] = {"type": "array", "x-secret": True,
"items": {"type": "string"}, "default": []}
env.use_schema(schema)
secrets = env.secrets()
secrets[PLUGIN_ID]["tokens"] = ["t1", "t2"]
(tmp_path / "config_secrets.json").write_text(json.dumps(secrets))
resp = env.post_form({"tokens": "", "city": "Lyon",
"__rendered_section": ["tokens", "city"]})
assert resp.status_code == 200, resp.get_json()
assert env.secrets()[PLUGIN_ID]["tokens"] == ["t1", "t2"]
class TestSaveRefusesWhatIsNotAPluginId:
"""GET and reset refuse a core section or a malformed id; the save took
any of them. ``{"plugin_id": "display"}`` merged unvalidated values into
the core display section, and an id that was not a string raised a
TypeError, answered as a 500."""
def test_a_core_section_is_refused_and_left_alone(self, env):
env.store({"hardware": {"rows": 32}}, plugin_id="display")
resp = env.post_json({"hardware": {"rows": "banana"}}, plugin_id="display")
assert resp.status_code == 400
assert env.main()["display"] == {"hardware": {"rows": 32}}
def test_the_form_save_refuses_one_too(self, env):
resp = env.post_form({"password_hash": "x"}, plugin_id="web_auth")
assert resp.status_code == 400
assert "web_auth" not in env.main()
@pytest.mark.parametrize("plugin_id", [["demo"], {"id": "demo"}, 7, "", "../demo"])
def test_a_malformed_id_is_a_400(self, env, plugin_id):
resp = env.post_json({"city": "Lyon"}, plugin_id=plugin_id)
assert resp.status_code == 400
class TestTextFieldsKeepWhatWasTyped:
"""A text field holding "true", "False", "[1, 2]" or "{}" was converted
to a boolean, list or object before the schema's type was consulted, and
the save then failed validation for a perfectly good string."""
@pytest.mark.parametrize("typed", ["true", "False", "[1, 2]", "{}", "42"])
def test_a_text_field(self, env, typed):
resp = env.post_form({"city": typed, "__rendered_section": ["city"]})
assert resp.status_code == 200, resp.get_json()
assert env.main()[PLUGIN_ID]["city"] == typed
def test_a_nullable_text_field(self, env):
schema = json.loads(json.dumps(SCHEMA))
schema["properties"]["nickname"] = {"type": ["string", "null"], "default": None}
env.use_schema(schema)
resp = env.post_form({"nickname": "false", "__rendered_section": ["nickname"]})
assert resp.status_code == 200, resp.get_json()
assert env.main()[PLUGIN_ID]["nickname"] == "false"
def test_other_types_still_convert(self, env):
resp = env.post_form({"mqtt.host": "true", "mqtt.port": "8883",
"__rendered_section": ["mqtt"]})
assert resp.status_code == 200, resp.get_json()
assert env.main()[PLUGIN_ID]["mqtt"] == {"host": "true", "port": 8883}