mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-07 19:58:08 +00:00
api_v3.py carried three inline nested copies of find_secret_fields/
separate_secrets (main-config save, plugin-config save, plugin-config
reset). They drifted from each other (one lacked isinstance guards) and
none supported the canonical module's array-item secrets
(accounts[].token). All three endpoints now import from
src/web_interface/secret_helpers.
Adopting the canonical behavior makes array-item secrets reachable, and
their parallel-placeholder shape ([{'token': ...}, {}] alongside the
regular list) was not survivable by ConfigManager's round-trip:
_strip_secrets_recursive dropped the whole key (losing the regular
fields from config.json) and _deep_merge replaced the regular list
wholesale on load. Both are now array-aware:
- strip removes the secret fields from each item and ALWAYS keeps the
list so indices survive for merge-on-load; whole-key secrets (scalar
lists, shape mismatches) still drop the key entirely — never leak.
- merge folds each secrets item into the config item at the same index,
skipping {} placeholders. The regular list's length is authoritative
in both directions: a user deleting an array item never has it
resurrected from a stale secrets entry (extras warn and are ignored).
api_v3's own deep_merge intentionally still replaces lists wholesale —
form posts carry complete arrays and index-merging would resurrect
deleted items; a comment now documents that.
Tests: the parity guard flips from 'exactly 3 inline copies' to 'zero,
and the canonical import must exist'; TestArraySecretStripAndMerge
covers the new strip/merge semantics incl. length-mismatch contracts;
new test_api_v3_secret_roundtrip.py drives all three endpoints through
a Flask client with a REAL ConfigManager+SchemaManager over tmp_path,
proving secrets land in config_secrets.json, config.json stays clean,
and a fresh load merges them back into the right array items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
81 lines
3.1 KiB
Python
81 lines
3.1 KiB
Python
"""
|
|
Drift guard: api_v3 must use the canonical secret helpers.
|
|
|
|
Historically web_interface/blueprints/api_v3.py carried THREE inline
|
|
nested-function copies of ``find_secret_fields``/``separate_secrets`` (in the
|
|
main-config save, plugin-config save, and plugin-config reset endpoints).
|
|
They lacked the canonical module's array-item secret support and drifted from
|
|
each other. They have been migrated onto
|
|
``src/web_interface/secret_helpers`` — this file now guards against copies
|
|
REAPPEARING, and keeps the canonical array-item behavior executable.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
|
|
|
API_V3_PATH = (Path(__file__).resolve().parents[2]
|
|
/ "web_interface" / "blueprints" / "api_v3.py")
|
|
|
|
# The migration is complete: any inline reimplementation is a regression.
|
|
EXPECTED_INLINE_COPIES = 0
|
|
|
|
|
|
class TestNoInlineCopies:
|
|
def _count(self, name: str) -> int:
|
|
source = API_V3_PATH.read_text(encoding="utf-8")
|
|
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
|
|
|
def test_no_inline_find_secret_fields(self):
|
|
count = self._count("find_secret_fields")
|
|
assert count == EXPECTED_INLINE_COPIES, (
|
|
f"api_v3.py has {count} inline find_secret_fields definitions, "
|
|
f"expected {EXPECTED_INLINE_COPIES}. Import it from "
|
|
f"src/web_interface/secret_helpers instead of re-implementing it."
|
|
)
|
|
|
|
def test_no_inline_separate_secrets(self):
|
|
count = self._count("separate_secrets")
|
|
assert count == EXPECTED_INLINE_COPIES, (
|
|
f"api_v3.py has {count} inline separate_secrets definitions, "
|
|
f"expected {EXPECTED_INLINE_COPIES}. Import it from "
|
|
f"src/web_interface/secret_helpers instead of re-implementing it."
|
|
)
|
|
|
|
def test_canonical_import_present(self):
|
|
# Tripwire: the endpoints still need the helpers, so removing the
|
|
# import means either dead secret handling or a new local copy.
|
|
source = API_V3_PATH.read_text(encoding="utf-8")
|
|
assert re.search(
|
|
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
|
source,
|
|
), "api_v3.py no longer imports the canonical secret helpers"
|
|
|
|
|
|
class TestCanonicalArrayItemBehavior:
|
|
"""Executable documentation of the array-item secret contract the
|
|
endpoints now inherit from the canonical module."""
|
|
|
|
SCHEMA = {
|
|
"accounts": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"token": {"type": "string", "x-secret": True},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
def test_canonical_module_routes_array_item_secrets(self):
|
|
paths = find_secret_fields(self.SCHEMA)
|
|
assert "accounts[].token" in paths
|
|
|
|
config = {"accounts": [{"name": "a", "token": "s3cret"}]}
|
|
regular, secrets = separate_secrets(config, paths)
|
|
assert regular == {"accounts": [{"name": "a"}]}
|
|
assert secrets == {"accounts": [{"token": "s3cret"}]}
|