Skip to content

Commit 9456d30

Browse files
committed
test(policy): fix JS-harness drift guard + lock policy-tab behaviour (homeassistant-ai#966)
The merged-in JSDOM behaviour test (homeassistant-ai#1425) failed collection because its hardcoded _TOP_LEVEL_ELEMENT_IDS list didn't yet know about the policy-tab handlers this PR adds (policy-master-toggle, policy-save-global-btn). Add them, plus matching DOM stubs in _build_min_dom so the init pass doesn't throw on the addEventListener calls. While the file is open, add three behavioural tests that pin the new condition-builder UX wiring: - Master toggle change POSTs to /api/settings/features with the enable_tool_security_policies flag (so the on-tab toggle stays a true mirror of the Server-Settings checkbox). - /api/policy/pending 503 renders "Tool Security Policies is turned off" when the addon flag is off (avoids the old misleading "sidecar / unavailable" copy). - /api/policy/pending 503 propagates the server's addon-log message verbatim when the flag IS on but the queue is unreachable (so users know where to look for ImportError details). The parse-coverage path catches syntax breaks already; these tests catch behavioural regressions on top of it.
1 parent 94c3299 commit 9456d30

1 file changed

Lines changed: 175 additions & 0 deletions

File tree

tests/src/unit/test_settings_ui_js_behavior.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ def settings_script() -> str:
7272
"panel-tools",
7373
"panel-server",
7474
"panel-backups",
75+
# Tool Security Policies tab (#966): the master-toggle checkbox
76+
# mirrors the Server-Settings flag and posts to /api/settings/features;
77+
# the global-settings save button writes wait_seconds / TTL.
78+
"policy-master-toggle",
79+
"policy-save-global-btn",
7580
]
7681

7782

@@ -109,6 +114,10 @@ def _build_min_dom() -> str:
109114
continue # rendered as a child of restartNotice above
110115
elif el_id == "search":
111116
rows.append('<input id="search" />')
117+
elif el_id == "policy-master-toggle":
118+
rows.append('<input id="policy-master-toggle" type="checkbox" />')
119+
elif el_id == "policy-save-global-btn":
120+
rows.append('<button id="policy-save-global-btn"></button>')
112121
else:
113122
rows.append(f'<div id="{el_id}"></div>')
114123
body = "\n ".join(rows)
@@ -535,3 +544,169 @@ def test_error_response_does_not_default_to_restart_required(
535544
f"failed save must not broadcast restart-required; "
536545
f"got broadcasts={result.broadcasts}"
537546
)
547+
548+
549+
# ---------------------------------------------------------------------------
550+
# Tool Security Policies tab (#966)
551+
# ---------------------------------------------------------------------------
552+
553+
554+
def _policy_panel_dom() -> str:
555+
"""MIN_DOM stub plus the elements the policy tab queries.
556+
557+
The script binds top-level handlers on policy-master-toggle /
558+
policy-save-global-btn (covered by MIN_DOM), but the bodies of
559+
policyLoadConfig / policyLoadPending fetch and write into
560+
policy-* sub-elements that don't appear in MIN_DOM. Add the
561+
minimum set so the policy-tab invocations don't throw on missing
562+
elements when we exercise them directly.
563+
"""
564+
extras = """
565+
<div id="policy-pending-list"></div>
566+
<div id="policy-load-error" style="display:none"></div>
567+
<div id="policy-rules-empty" style="display:none"></div>
568+
<div id="policy-rules-list"></div>
569+
<input id="policy-wait-seconds" />
570+
<input id="policy-ttl-minutes" />
571+
"""
572+
return MIN_DOM.replace("</body>", extras + "</body>")
573+
574+
575+
class TestPolicyTabFlow:
576+
"""Locks in the new condition-builder UX wiring: master toggle
577+
posts to the same feature-flag endpoint the Server-Settings tab
578+
uses, and the pending-list shows the right copy depending on
579+
whether the feature is on or off."""
580+
581+
def test_master_toggle_change_posts_to_features_endpoint(
582+
self, settings_script: str
583+
) -> None:
584+
"""Clicking the master toggle on the Policies tab must POST
585+
``{flags: {enable_tool_security_policies: true}}`` to
586+
``/api/settings/features`` — same endpoint as the Server
587+
Settings checkbox. Without this the two surfaces would drift
588+
and the user couldn't trust the on-tab toggle to actually
589+
flip addon config."""
590+
fetches = {
591+
**DEFAULT_FETCHES,
592+
"/api/settings/features": {
593+
"status": 200,
594+
"json": {"restart_required": True},
595+
},
596+
}
597+
result = run_script(
598+
settings_script,
599+
initial_html=_policy_panel_dom(),
600+
fetch_map=fetches,
601+
invoke="""
602+
const cb = document.getElementById('policy-master-toggle');
603+
cb.checked = true;
604+
cb.dispatchEvent(new Event('change'));
605+
await new Promise(r => setTimeout(r, 50));
606+
""",
607+
)
608+
_assert_clean_init(result)
609+
flag_posts = [
610+
f
611+
for f in result.fetches
612+
if f["method"] == "POST" and "/api/settings/features" in f["url"]
613+
]
614+
assert len(flag_posts) >= 1, (
615+
f"expected POST to /api/settings/features; got {result.fetches}"
616+
)
617+
# The body is JSON-serialised; assert the right flag landed in it.
618+
bodies = [f.get("body", "") for f in flag_posts]
619+
assert any(
620+
"enable_tool_security_policies" in str(b) and "true" in str(b).lower()
621+
for b in bodies
622+
), f"expected enable_tool_security_policies:true in body; got {bodies}"
623+
624+
def test_pending_list_shows_off_message_when_feature_disabled(
625+
self, settings_script: str
626+
) -> None:
627+
"""When the addon flag is off, /api/policy/pending 503s. The
628+
UI should tell the user the feature is just turned off — NOT
629+
the misleading "sidecar / unavailable" text the earlier code
630+
showed. This catches regressions where the new copy gets
631+
clobbered back to the generic message."""
632+
fetches = {
633+
**DEFAULT_FETCHES,
634+
# Feature flag explicitly disabled
635+
"/api/settings/features": {
636+
"status": 200,
637+
"json": {
638+
"flags": {
639+
"enable_tool_security_policies": {"value": False},
640+
},
641+
},
642+
},
643+
# Stub policy config endpoints so policyLoadConfig doesn't 500
644+
"/api/policy/config": {
645+
"status": 200,
646+
"json": {"wait_seconds": 60, "approval_ttl_minutes": 5, "rules": []},
647+
},
648+
"/api/policy/pending": {
649+
"status": 503,
650+
"json": {"error": "irrelevant when flag is off"},
651+
},
652+
}
653+
result = run_script(
654+
settings_script,
655+
initial_html=_policy_panel_dom(),
656+
fetch_map=fetches,
657+
invoke="""
658+
await window.policyLoadConfig();
659+
await window.policyLoadPending();
660+
""",
661+
)
662+
_assert_clean_init(result)
663+
# `dom` is the full final-state document snapshot; grep the
664+
# pending-list region for the new off-state copy.
665+
assert "turned off" in result.dom.lower(), (
666+
f"expected 'turned off' in pending-list snapshot; "
667+
f"dom contains: {result.dom[-2000:]}"
668+
)
669+
670+
def test_pending_list_shows_server_message_when_feature_on_but_503(
671+
self, settings_script: str
672+
) -> None:
673+
"""Feature is on but the queue is unreachable (sidecar mode or
674+
ImportError at startup). The server's 503 message should
675+
propagate verbatim so the user knows to check the addon log,
676+
instead of the generic "feature off" text."""
677+
fetches = {
678+
**DEFAULT_FETCHES,
679+
"/api/settings/features": {
680+
"status": 200,
681+
"json": {
682+
"flags": {
683+
"enable_tool_security_policies": {"value": True},
684+
},
685+
},
686+
},
687+
"/api/policy/config": {
688+
"status": 200,
689+
"json": {"wait_seconds": 60, "approval_ttl_minutes": 5, "rules": []},
690+
},
691+
"/api/policy/pending": {
692+
"status": 503,
693+
"json": {
694+
"error": "Tool security policies live approvals are not active. "
695+
"Check the addon log for ImportError / RuntimeError details."
696+
},
697+
},
698+
}
699+
result = run_script(
700+
settings_script,
701+
initial_html=_policy_panel_dom(),
702+
fetch_map=fetches,
703+
invoke="""
704+
await window.policyLoadConfig();
705+
await window.policyLoadPending();
706+
""",
707+
)
708+
_assert_clean_init(result)
709+
assert "addon log" in result.dom.lower(), (
710+
f"expected addon-log message in pending-list snapshot; "
711+
f"dom contains: {result.dom[-2000:]}"
712+
)

0 commit comments

Comments
 (0)