@@ -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