mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-08 12:18:06 +00:00
* refactor(web): use canonical secret helpers in api_v3; make ConfigManager secret strip/merge array-aware
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
* fix: repair broken helper paths across display, cache, odds, logging, resolver, repos, config, validator
Nine fixes for bugs surfaced while writing coverage for previously
untested modules (plus the bool-duration quirk pinned in PR #441):
- base_plugin.get_display_duration: exclude bools from both numeric
branches — display_duration=True no longer reads as a 1-second slot;
it falls through to config, then the 15.0 default.
- display_helper: draw_error_message/draw_no_data_message called
_draw_centered_text with the wrong arguments and crashed with
AttributeError — both now delegate to draw_centered_text.
draw_scorebug_layout drew status and clock at the same y, overprinting
each other — they now share one combined top line.
draw_ticker_layout drew its text starting at x=display_width (fully
off-canvas), returning a blank frame every time — now draws at x=0;
scroll_speed stays accepted-but-unused and is documented as such.
- api_helper.clear_cache guarded on a nonexistent CacheManager.clear()
method, silently never clearing anything; it now uses the real surface
(clear_cache/delete/list_cache_files) and no-ops safely otherwise.
- base_odds_manager._extract_espn_data raised AttributeError when ESPN
sent explicit JSON nulls ("homeTeamOdds": null) — every level now
null-safes with 'or {}'. format_odds_summary gated on
is_odds_available, which deliberately ignores money lines, so
ML-only odds formatted as "No odds available" — it now gates only on
empty/no_odds data and formats money lines.
- logging_config.ContextualFormatter mutated record.msg in place, so a
second handler prepended the context prefix twice; it now formats a
copy. log_error hardcoded exc_info=True and raised TypeError when the
caller passed exc_info — now kwargs.setdefault.
- dynamic_team_resolver wrote its "shared" class cache through self,
creating instance shadows — the cache was per-instance and every
scoreboard refetched rankings. Writes now go through the class.
- saved_repositories cleaned URLs with an unanchored .replace('.git','')
that mangled URLs merely containing '.git' (my.github.io -> myhub.io);
now strips only a trailing suffix. add/remove also roll back the
in-memory list when the save fails, so memory always matches disk.
- config_helper.merge_configs shallow-copied the base, aliasing every
un-overridden nested dict into the result — now deep-copies.
- startup_validator.validate_all accumulated errors/warnings across
calls — now resets both lists per run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* test: cover the previously untested modules
Nine new suites plus an extension, asserting the Phase-1b fixed behavior
and pinning the quirks deliberately left alone:
- test_logging_config.py: formatters (JSON shape, no record mutation,
single prefix through two handlers), PluginLoggerAdapter precedence,
setup_logging handler hygiene and LEDMATRIX_DEBUG, log_error exc_info.
- test_startup_validator.py: exact messages, error-vs-warning split,
accessor split (load_config vs get_config), cache-dir branches with
os.access monkeypatched (root can write anything in CI), idempotence,
raise_on_errors classification precedence.
- test_config_helper.py (full): load/save round trips, dot-notation
get/set incl. silent-failure contract, post-fix no-aliasing merge,
schema validation branches, the '{id}_config' key pin, default-enabled
pin.
- test_saved_repositories.py: three load shapes, bare-list rewrite pin,
trailing-only .git strip (my.github.io regression), save-failure
rollback, type-classification case-sensitivity pin.
- test_api_helper.py: rate-limit math, cache-hit short circuit, ESPN
URL/key formats, exact User-Agent guard, retry adapter, post-fix
clear_cache against the real CacheManager surface, ttl-dropped pin.
- test_base_odds_manager.py: cache-key/URL construction, no_odds
sentinel round trip, stale-cache fallback, null-safe extraction,
ML-only formatting, is_odds_available truth table (ML-blind by
contract), config key/attr mismatch pin.
- test_dynamic_team_resolver.py: expansion/dedup/slicing, dropped
unknown-dynamic names (TOP_ substring hazard pinned), genuinely
shared class cache (second instance: zero HTTP), TTL expiry,
failure degradation without raising.
- test_display_helper.py (full): the fixed error/no-data renders,
combined scorebug top line, non-blank ticker with scroll_speed
no-op pin, composite upconversion, logo bleed positions, square
orientation pin.
- test_skin_runtime_cache.py: discovery-cache hit/invalidation
semantics (manifest mtime, .py edits pinned as non-invalidating),
sys.modules namespacing contract incl. bare-name restore and stdlib
shadowing, entry-module execute-once, API minor-version tolerance,
skin_matches_target table.
- test_sports_capabilities.py (extended): _draw_celebration_layout
executed for real (flash window, matrix-dims fallback, highlight
alternation, logo-failure isolation), _should_celebrate_for direct,
strict duration boundary, score_to_int edges, both-teams-score
precedence, expired-coalesce refire, disabled-win baseline
preservation, id-less prune.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* test: real schedule/dim coverage for DisplayController; fix two vacuous schedule tests
New test_display_controller_schedule.py drives _check_schedule and
_check_dim_schedule on a bare controller stub: same-day and
midnight-crossing windows with inclusive boundaries, global vs per-day vs
legacy-inferred modes (and dim's global-only default — no legacy
inference), per-day disabled days, invalid %H:%M fallbacks, unknown
timezone -> UTC, dim_brightness default 30, inactive-display short
circuit, and the _was_display_active/_was_dimmed transition flags.
test_display_controller.py's test_schedule_disabled and
test_active_hours patched config_service.get_config — which
_check_schedule never reads — so both asserted the init-default value
and could not fail. Rewritten on the test_inactive_hours pattern
(inject controller.config['schedule'], reset the minute gate, flip the
flag to the opposite state first so the assertion has teeth).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* ci: raise coverage floor to 48%
Measured 50% with the new suites in place (was 47% baseline when the
gate was introduced at 45); floor stays two points under measured.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* fix: address CodeQL alert and review findings
- config_manager: the "secrets list longer than config list" warning now
interpolates only config-side data (no key name or secrets-derived
values), resolving the CodeQL clear-text-logging alert.
- base_plugin: validate_config rejects bool display_duration, matching
get_display_duration (bool is an int subclass and would otherwise pass
as a positive number).
- config_helper: merge_configs deep-copies override values in the
non-recursive branch so mutating the merged result cannot reach back
into override_config.
- saved_repositories: saves are atomic (temp file + fsync + os.replace),
so a failed write can no longer truncate saved_repositories.json.
- tests: regression cases for each fix, plus a pin that whole-item
array secrets (key[] + key[].field both marked) strip to empty {}
skeletons — no secret values can reach config.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
---------
Co-authored-by: Claude <noreply@anthropic.com>
336 lines
15 KiB
Python
336 lines
15 KiB
Python
"""
|
|
Tests for the ConfigManager secrets round-trip and the load_config fast path.
|
|
|
|
The contract under test: config_secrets.json values are deep-merged INTO the
|
|
in-memory config at load time, and stripped back OUT before anything is
|
|
written to config.json — so secrets live in exactly one file on disk. This
|
|
suite pins that round-trip plus its sharp edges, including the guard that a
|
|
save REFUSES (ConfigError) when the secrets file exists but can't be loaded,
|
|
rather than leaking merged secrets into config.json in plaintext.
|
|
|
|
Complements test_config_manager.py, which covers loading/migration/validation.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from src.config_manager import ConfigManager
|
|
from src.exceptions import ConfigError
|
|
|
|
|
|
def make_manager(tmp_path, config=None, secrets=None):
|
|
"""A ConfigManager over tmp_path files, template migration neutralized."""
|
|
config_file = tmp_path / "config.json"
|
|
secrets_file = tmp_path / "config_secrets.json"
|
|
config_file.write_text(json.dumps(config if config is not None else {}))
|
|
if secrets is not None:
|
|
secrets_file.write_text(json.dumps(secrets))
|
|
manager = ConfigManager(config_path=str(config_file),
|
|
secrets_path=str(secrets_file))
|
|
# Point the (CWD-relative) template at nothing so migration never runs —
|
|
# these tests assert exact on-disk contents.
|
|
manager.template_path = str(tmp_path / "no-template.json")
|
|
return manager
|
|
|
|
|
|
class TestLoadMergesSecrets:
|
|
def test_secrets_deep_merged_into_config(self, tmp_path):
|
|
manager = make_manager(
|
|
tmp_path,
|
|
config={"weather": {"city": "Austin"}, "timezone": "UTC"},
|
|
secrets={"weather": {"api_key": "s3cret"}},
|
|
)
|
|
loaded = manager.load_config()
|
|
assert loaded["weather"] == {"city": "Austin", "api_key": "s3cret"}
|
|
assert loaded["timezone"] == "UTC"
|
|
|
|
def test_secret_scalar_overrides_config_value(self, tmp_path):
|
|
manager = make_manager(
|
|
tmp_path,
|
|
config={"weather": {"api_key": "YOUR_API_KEY"}},
|
|
secrets={"weather": {"api_key": "real-key"}},
|
|
)
|
|
assert manager.load_config()["weather"]["api_key"] == "real-key"
|
|
|
|
def test_missing_secrets_file_loads_config_fine(self, tmp_path):
|
|
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
|
assert manager.load_config() == {"timezone": "UTC"}
|
|
|
|
def test_corrupt_secrets_file_loads_config_without_secrets(self, tmp_path):
|
|
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
|
(tmp_path / "config_secrets.json").write_text("{not json")
|
|
loaded = manager.load_config()
|
|
assert loaded["timezone"] == "UTC"
|
|
|
|
|
|
class TestSaveStripsSecrets:
|
|
def test_round_trip_keeps_secrets_out_of_config_json(self, tmp_path):
|
|
manager = make_manager(
|
|
tmp_path,
|
|
config={"weather": {"city": "Austin"}},
|
|
secrets={"weather": {"api_key": "s3cret"}},
|
|
)
|
|
loaded = manager.load_config()
|
|
assert loaded["weather"]["api_key"] == "s3cret" # merged in memory
|
|
|
|
manager.save_config(loaded)
|
|
|
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
|
assert "api_key" not in on_disk.get("weather", {})
|
|
assert on_disk["weather"]["city"] == "Austin"
|
|
# In-memory config still carries the secret for runtime use.
|
|
assert manager.config["weather"]["api_key"] == "s3cret"
|
|
|
|
def test_group_dropped_when_only_secrets_remain(self, tmp_path):
|
|
# _strip_secrets_recursive drops a group entirely when nothing
|
|
# non-secret is left in it.
|
|
manager = make_manager(
|
|
tmp_path,
|
|
config={},
|
|
secrets={"weather": {"api_key": "s3cret"}},
|
|
)
|
|
manager.save_config({"weather": {"api_key": "s3cret"}, "timezone": "UTC"})
|
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
|
assert on_disk == {"timezone": "UTC"}
|
|
|
|
def test_scalar_secret_key_stripped_at_top_level(self, tmp_path):
|
|
manager = make_manager(tmp_path, config={}, secrets={"token": "t"})
|
|
manager.save_config({"token": "t", "timezone": "UTC"})
|
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
|
assert on_disk == {"timezone": "UTC"}
|
|
|
|
def test_corrupt_secrets_file_refuses_save_no_plaintext_leak(self, tmp_path):
|
|
# Regression guard: when the secrets file exists but is corrupt at
|
|
# save time, stripping is impossible — the save must raise instead of
|
|
# writing the merged secrets into config.json in plaintext (the
|
|
# historical behavior).
|
|
manager = make_manager(
|
|
tmp_path,
|
|
config={"weather": {"city": "Austin"}},
|
|
secrets={"weather": {"api_key": "s3cret"}},
|
|
)
|
|
loaded = manager.load_config()
|
|
(tmp_path / "config_secrets.json").write_text("{corrupt")
|
|
|
|
with pytest.raises(ConfigError):
|
|
manager.save_config(loaded)
|
|
|
|
# On-disk config untouched: no secret leaked.
|
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
|
assert "api_key" not in on_disk.get("weather", {})
|
|
|
|
def test_corrupt_secrets_file_refuses_atomic_save_too(self, tmp_path):
|
|
# Same refusal on the atomic save path, which shared the leak.
|
|
manager = make_manager(
|
|
tmp_path,
|
|
config={"weather": {"city": "Austin"}},
|
|
secrets={"weather": {"api_key": "s3cret"}},
|
|
)
|
|
loaded = manager.load_config()
|
|
(tmp_path / "config_secrets.json").write_text("{corrupt")
|
|
|
|
with pytest.raises(ConfigError):
|
|
manager.save_config_atomic(loaded)
|
|
|
|
on_disk = json.loads((tmp_path / "config.json").read_text())
|
|
assert "api_key" not in on_disk.get("weather", {})
|
|
|
|
|
|
class TestLoadFastPath:
|
|
def test_unchanged_files_return_cached_dict(self, tmp_path):
|
|
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
|
|
|
|
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
|
|
manager = make_manager(
|
|
tmp_path,
|
|
config={"weather": {}},
|
|
secrets={"weather": {"api_key": "old"}},
|
|
)
|
|
assert manager.load_config()["weather"]["api_key"] == "old"
|
|
|
|
secrets_file = tmp_path / "config_secrets.json"
|
|
secrets_file.write_text(json.dumps({"weather": {"api_key": "new"}}))
|
|
# Force a different mtime_ns in case the write landed within the
|
|
# filesystem's timestamp granularity.
|
|
os.utime(secrets_file, ns=(1, 1))
|
|
|
|
assert manager.load_config()["weather"]["api_key"] == "new"
|
|
|
|
def test_same_mtime_same_size_change_served_stale(self, tmp_path):
|
|
# Characterized fast-path blind spot: the signature is (mtime_ns,
|
|
# size) only, so a same-length content swap with a forged identical
|
|
# mtime is not detected. Real writes bump mtime_ns, so this is
|
|
# acceptable — but it is a contract worth pinning.
|
|
manager = make_manager(tmp_path, config={"timezone": "AAA"})
|
|
config_file = tmp_path / "config.json"
|
|
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
|
manager._loaded_sig = None
|
|
first = manager.load_config()
|
|
assert first["timezone"] == "AAA"
|
|
|
|
config_file.write_text(json.dumps({"timezone": "BBB"})) # same length
|
|
os.utime(config_file, ns=(1_000_000_000, 1_000_000_000))
|
|
|
|
assert manager.load_config()["timezone"] == "AAA" # stale, by design
|
|
|
|
|
|
class TestArraySecretStripAndMerge:
|
|
"""Array-item secrets round-trip (parallel-placeholder lists).
|
|
|
|
secret_helpers.separate_secrets emits array secrets as a list parallel
|
|
to the regular list, with {} for items that carry no secrets. Strip
|
|
must remove the secret fields from config.json while preserving item
|
|
indices; load must merge them back into the right items. The regular
|
|
list's length is authoritative in both directions.
|
|
"""
|
|
|
|
def test_strip_removes_array_item_secrets_keeps_indices(self, tmp_path):
|
|
manager = make_manager(tmp_path)
|
|
data = {"plugin": {"accounts": [
|
|
{"name": "a", "token": "ta"},
|
|
{"name": "b"},
|
|
]}}
|
|
secrets = {"plugin": {"accounts": [{"token": "ta"}, {}]}}
|
|
stripped = manager._strip_secrets_recursive(data, secrets)
|
|
assert stripped == {"plugin": {"accounts": [{"name": "a"}, {"name": "b"}]}}
|
|
|
|
def test_strip_keeps_all_placeholder_items(self, tmp_path):
|
|
# Even when every item strips to nothing extra, the list survives
|
|
# with its indices — required for merge-on-load alignment.
|
|
manager = make_manager(tmp_path)
|
|
data = {"accounts": [{"token": "t1"}, {"token": "t2"}]}
|
|
secrets = {"accounts": [{"token": "t1"}, {"token": "t2"}]}
|
|
stripped = manager._strip_secrets_recursive(data, secrets)
|
|
assert stripped == {"accounts": [{}, {}]}
|
|
|
|
def test_strip_whole_scalar_array_secret_drops_key(self, tmp_path):
|
|
# A list of secret scalars is a whole-key secret, not the parallel
|
|
# shape — the key must vanish from config.json entirely.
|
|
manager = make_manager(tmp_path)
|
|
data = {"recovery_codes": ["a", "b"], "city": "Austin"}
|
|
secrets = {"recovery_codes": ["a", "b"]}
|
|
stripped = manager._strip_secrets_recursive(data, secrets)
|
|
assert stripped == {"city": "Austin"}
|
|
|
|
def test_strip_shape_mismatch_drops_key(self, tmp_path):
|
|
# Conservative contract: if the shapes disagree, never leak.
|
|
manager = make_manager(tmp_path)
|
|
data = {"accounts": {"name": "not-a-list"}}
|
|
secrets = {"accounts": [{"token": "t"}]}
|
|
stripped = manager._strip_secrets_recursive(data, secrets)
|
|
assert stripped == {}
|
|
|
|
def test_strip_ignores_extra_secrets_entries(self, tmp_path):
|
|
# Regular list length is authoritative: a user deleted an item.
|
|
manager = make_manager(tmp_path)
|
|
data = {"accounts": [{"name": "a", "token": "ta"}]}
|
|
secrets = {"accounts": [{"token": "ta"}, {"token": "tb"}]}
|
|
stripped = manager._strip_secrets_recursive(data, secrets)
|
|
assert stripped == {"accounts": [{"name": "a"}]}
|
|
|
|
def test_merge_restores_array_item_secrets(self, tmp_path):
|
|
manager = make_manager(tmp_path)
|
|
target = {"accounts": [{"name": "a"}, {"name": "b"}]}
|
|
manager._deep_merge(target, {"accounts": [{"token": "ta"}, {}]})
|
|
assert target == {"accounts": [
|
|
{"name": "a", "token": "ta"},
|
|
{"name": "b"},
|
|
]}
|
|
|
|
def test_merge_ignores_extra_secrets_entries_with_warning(self, tmp_path, caplog):
|
|
manager = make_manager(tmp_path)
|
|
target = {"accounts": [{"name": "a"}]}
|
|
with caplog.at_level("WARNING"):
|
|
manager._deep_merge(
|
|
target, {"accounts": [{"token": "ta"}, {"token": "ghost"}]})
|
|
assert target == {"accounts": [{"name": "a", "token": "ta"}]}
|
|
assert any("longer than the config list" in r.message for r in caplog.records)
|
|
|
|
def test_merge_non_dict_item_replaced_by_secret(self, tmp_path):
|
|
# Shape drift inside the list: the secret wins for that index.
|
|
manager = make_manager(tmp_path)
|
|
target = {"accounts": ["oddball", {"name": "b"}]}
|
|
manager._deep_merge(target, {"accounts": [{"token": "ta"}, {}]})
|
|
assert target == {"accounts": [{"token": "ta"}, {"name": "b"}]}
|
|
|
|
def test_merge_whole_scalar_array_still_replaces(self, tmp_path):
|
|
# Legacy behavior preserved: a non-parallel list replaces wholesale.
|
|
manager = make_manager(tmp_path)
|
|
target = {"recovery_codes": ["old"]}
|
|
manager._deep_merge(target, {"recovery_codes": ["new1", "new2"]})
|
|
assert target == {"recovery_codes": ["new1", "new2"]}
|
|
|
|
def test_full_save_load_round_trip(self, tmp_path):
|
|
# End to end on real files: save strips array secrets out of
|
|
# config.json; load merges them back into the right items.
|
|
manager = make_manager(
|
|
tmp_path,
|
|
config={"plugin": {"accounts": [
|
|
{"name": "a", "token": "s3cret-a"},
|
|
{"name": "b", "token": "s3cret-b"},
|
|
]}},
|
|
secrets={"plugin": {"accounts": [
|
|
{"token": "s3cret-a"}, {"token": "s3cret-b"},
|
|
]}},
|
|
)
|
|
loaded = manager.load_config()
|
|
assert loaded["plugin"]["accounts"][0]["token"] == "s3cret-a"
|
|
|
|
manager.save_config(loaded)
|
|
|
|
raw = (tmp_path / "config.json").read_text()
|
|
assert "s3cret" not in raw
|
|
on_disk = json.loads(raw)
|
|
assert on_disk["plugin"]["accounts"] == [{"name": "a"}, {"name": "b"}]
|
|
|
|
# A fresh manager (constructed directly — make_manager would
|
|
# overwrite the just-saved config.json) re-merges from the secrets
|
|
# file on load.
|
|
fresh = ConfigManager(config_path=str(tmp_path / "config.json"),
|
|
secrets_path=str(tmp_path / "config_secrets.json"))
|
|
fresh.template_path = str(tmp_path / "no-template.json")
|
|
reloaded = fresh.load_config()
|
|
assert reloaded["plugin"]["accounts"] == [
|
|
{"name": "a", "token": "s3cret-a"},
|
|
{"name": "b", "token": "s3cret-b"},
|
|
]
|
|
|
|
def test_whole_item_secret_list_never_leaks_values(self, tmp_path):
|
|
# When the ENTIRE array item is secret (schema marks both key[]
|
|
# and key[].field), separate_secrets stores the full item dicts in
|
|
# the secrets file. That shape also matches the parallel-list
|
|
# discriminator — which is safe: strip drops every leaf key that
|
|
# appears in the secret item, so only empty {} skeletons (item
|
|
# count, no values) can reach config.json, and merge-on-load
|
|
# restores the full items from those skeletons.
|
|
from src.web_interface.secret_helpers import (
|
|
find_secret_fields, separate_secrets)
|
|
schema_props = {"accounts": {
|
|
"type": "array",
|
|
"items": {"type": "object", "x-secret": True, "properties": {
|
|
"id": {"type": "string"},
|
|
"token": {"type": "string", "x-secret": True},
|
|
}},
|
|
}}
|
|
paths = find_secret_fields(schema_props)
|
|
assert paths == {"accounts[]", "accounts[].token"}
|
|
full = {"accounts": [{"id": "i1", "token": "s3cret-a"},
|
|
{"id": "i2", "token": "s3cret-b"}]}
|
|
_, secrets = separate_secrets(full, paths)
|
|
assert secrets == full # whole items are secret
|
|
|
|
manager = make_manager(tmp_path)
|
|
stripped = manager._strip_secrets_recursive(full, secrets)
|
|
assert stripped == {"accounts": [{}, {}]}
|
|
|
|
raw = json.dumps(stripped)
|
|
assert "s3cret" not in raw and "i1" not in raw
|
|
|
|
manager._deep_merge(stripped, secrets)
|
|
assert stripped == full # round trip restores the items
|