test: restore dashboard, provider and Desktop re-bind guards dropped by #120071
- test_kanban_dashboard_plugin::test_dashboard_markdown_html_is_sanitized_before_render: runs the real bundle's sanitizer and MarkdownBlock under node; script/event handler/javascript: HTML never reaches dangerouslySetInnerHTML (security). - test_meta_ai_profile::test_images_ride_user_turns_not_tool_results: vision on, tool-message vision off, so screenshots never 400 in tool results (#101668). - session-info.test.ts "rebuilt-runtime re-bind" (7 cases, replaces the regex test tests/desktop/test_bots_chat_stream_rekey.py): the pane adopts a rebuilt runtime id only when lineage matches and the old runtime is idle (#93942, #94417).
This commit is contained in:
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
$selectedStoredSessionId,
|
||||
$workspaceCwdOwner,
|
||||
@@ -147,3 +148,75 @@ describe('handleSessionInfoEvent workspace ownership', () => {
|
||||
expect(next).toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
// #93942 scenario B: a mid-conversation model/provider switch rebuilds the
|
||||
// runtime, which then speaks under a NEW session_id. Without the re-bind the
|
||||
// pane keeps listening on the dead id and every later event of the same
|
||||
// conversation fails the isActiveEvent gate until a full resume.
|
||||
describe('handleSessionInfoEvent rebuilt-runtime re-bind', () => {
|
||||
function rebuiltRuntimeInfo(storedSessionId: unknown, oldState?: Partial<ClientSessionState>): GatewayEventContext {
|
||||
const ctx = sessionInfoEvent({
|
||||
activeSessionId: 'runtime-old',
|
||||
cwd: '',
|
||||
explicitSid: 'runtime-new',
|
||||
storedSessionId: 'stored-1'
|
||||
})
|
||||
|
||||
ctx.payload = { ...ctx.payload, stored_session_id: storedSessionId } as typeof ctx.payload
|
||||
|
||||
if (oldState) {
|
||||
ctx.deps.sessionStateByRuntimeIdRef.current.set('runtime-old', {
|
||||
...createClientSessionState('stored-1'),
|
||||
...oldState
|
||||
})
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
$selectedStoredSessionId.set('stored-1')
|
||||
$activeSessionId.set('runtime-old')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('adopts the rebuilt runtime id when its lineage matches the open conversation', () => {
|
||||
const ctx = rebuiltRuntimeInfo('stored-1', { busy: false })
|
||||
|
||||
handleSessionInfoEvent(ctx)
|
||||
|
||||
expect($activeSessionId.get()).toBe('runtime-new')
|
||||
expect(ctx.deps.activeSessionIdRef.current).toBe('runtime-new')
|
||||
expect($selectedStoredSessionId.get()).toBe('stored-1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['busy', { busy: true }],
|
||||
['awaiting a response', { awaitingResponse: true }],
|
||||
['streaming', { streamId: 'stream-1' }]
|
||||
] as const)('refuses to hijack the pane while the old runtime is %s', (_label, oldState) => {
|
||||
const ctx = rebuiltRuntimeInfo('stored-1', oldState)
|
||||
|
||||
handleSessionInfoEvent(ctx)
|
||||
|
||||
expect($activeSessionId.get()).toBe('runtime-old')
|
||||
expect(ctx.deps.activeSessionIdRef.current).toBe('runtime-old')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an empty', ''],
|
||||
['a missing', undefined],
|
||||
['a different conversation', 'stored-other']
|
||||
])('does not re-bind on %s stored_session_id', (_label, storedSessionId) => {
|
||||
const ctx = rebuiltRuntimeInfo(storedSessionId)
|
||||
|
||||
handleSessionInfoEvent(ctx)
|
||||
|
||||
expect($activeSessionId.get()).toBe('runtime-old')
|
||||
expect(ctx.deps.activeSessionIdRef.current).toBe('runtime-old')
|
||||
})
|
||||
})
|
||||
|
||||
66
tests/plugins/fixtures/kanban_markdown_sanitize_probe.js
Normal file
66
tests/plugins/fixtures/kanban_markdown_sanitize_probe.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Behavioral probe for the dashboard markdown XSS guard: extracts the markdown
|
||||
// helpers + MarkdownBlock from the shipped bundle (no build step — the bundle IS
|
||||
// the source) and runs them. Exits 0 and prints "PASS" when
|
||||
// 1. sanitizeMarkdownHtml strips non-allowlisted tags, event-handler attributes
|
||||
// and non-http(s)/mailto hrefs from raw HTML;
|
||||
// 2. MarkdownBlock routes its HTML through the sanitizer before
|
||||
// dangerouslySetInnerHTML (proved by swapping renderMarkdown for an identity
|
||||
// function that passes raw HTML straight through);
|
||||
// 3. ordinary markdown still renders (bold, safe links).
|
||||
// Run via: node kanban_markdown_sanitize_probe.js <path-to-bundle>
|
||||
const fs = require("fs");
|
||||
|
||||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||||
const start = src.indexOf("function escapeHtml(");
|
||||
const blockStart = src.indexOf("function MarkdownBlock(", start);
|
||||
if (start === -1 || blockStart === -1) {
|
||||
console.error("markdown helpers / MarkdownBlock not found in bundle");
|
||||
process.exit(1);
|
||||
}
|
||||
const bodyStart = src.indexOf("{", blockStart);
|
||||
let depth = 0;
|
||||
let end = bodyStart;
|
||||
for (; end < src.length; end++) {
|
||||
if (src[end] === "{") depth++;
|
||||
else if (src[end] === "}") { depth--; if (depth === 0) break; }
|
||||
}
|
||||
const code = src.slice(start, end + 1);
|
||||
|
||||
const h = (tag, props, ...children) => ({ tag, props, children });
|
||||
eval(code); // sloppy-mode direct eval: function declarations land in this scope
|
||||
|
||||
const failures = [];
|
||||
const check = (cond, msg) => { if (!cond) failures.push(msg); };
|
||||
const DANGER = [/<img/i, /<script/i, /<iframe/i, /<svg/i, /\son\w+\s*=/i, /javascript:/i];
|
||||
const assertClean = (html, label) => {
|
||||
for (const re of DANGER) check(!re.test(html), `${label}: ${re} survived in ${JSON.stringify(html)}`);
|
||||
};
|
||||
|
||||
const RAW =
|
||||
'<img src=x onerror=alert(1)><script>alert(1)</script><iframe src="https://evil"></iframe>' +
|
||||
'<svg onload=alert(1)></svg><a href="javascript:alert(1)" onclick="steal()">y</a>' +
|
||||
'<p onmouseover="steal()">z</p><a href="https://ok.example/">ok</a>';
|
||||
|
||||
// 1. The sanitizer itself.
|
||||
const cleaned = sanitizeMarkdownHtml(RAW);
|
||||
assertClean(cleaned, "sanitizeMarkdownHtml");
|
||||
check(cleaned.includes("<p>z</p>"), `allowlisted <p> lost: ${cleaned}`);
|
||||
check(cleaned.includes('href="https://ok.example/"'), `safe https href lost: ${cleaned}`);
|
||||
|
||||
// 2. MarkdownBlock must sanitize whatever the renderer produces.
|
||||
const realRender = renderMarkdown;
|
||||
renderMarkdown = (s) => s;
|
||||
const wired = MarkdownBlock({ source: RAW }).props.dangerouslySetInnerHTML.__html;
|
||||
assertClean(wired, "MarkdownBlock(raw renderer)");
|
||||
renderMarkdown = realRender;
|
||||
|
||||
// 3. Ordinary markdown still renders through the real renderer.
|
||||
const md = MarkdownBlock({ source: "**bold** and [link](https://example.com)" }).props.dangerouslySetInnerHTML.__html;
|
||||
check(md.includes("<strong>bold</strong>"), `bold lost: ${md}`);
|
||||
check(md.includes('href="https://example.com"'), `link lost: ${md}`);
|
||||
|
||||
if (failures.length) {
|
||||
console.error(failures.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("PASS");
|
||||
@@ -179,6 +179,26 @@ def test_tenant_filter(client):
|
||||
assert total == 1
|
||||
|
||||
|
||||
def test_dashboard_markdown_html_is_sanitized_before_render():
|
||||
"""Task markdown reaches ``dangerouslySetInnerHTML``, so the rendered HTML must
|
||||
pass through ``sanitizeMarkdownHtml`` (tag allowlist, no event handlers, only
|
||||
http(s)/mailto hrefs). Runs the real bundle functions under node — including a
|
||||
raw-HTML renderer swap that proves MarkdownBlock applies the sanitizer — rather
|
||||
than substring-matching the bundle text.
|
||||
"""
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node not available")
|
||||
bundle = Path(__file__).resolve().parents[2] / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
||||
probe = Path(__file__).parent / "fixtures" / "kanban_markdown_sanitize_probe.js"
|
||||
result = subprocess.run(
|
||||
[node, str(probe), str(bundle)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}"
|
||||
assert "PASS" in result.stdout
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,13 @@ def _profile():
|
||||
|
||||
|
||||
class TestMetaAIProfile:
|
||||
def test_images_ride_user_turns_not_tool_results(self):
|
||||
"""Muse accepts images on user turns but 400s on image parts inside
|
||||
tool-result envelopes (#101668): vision must stay on while tool-message
|
||||
vision stays off, so tool screenshots are routed as text/user turns."""
|
||||
p = _profile()
|
||||
assert p.supports_vision is True
|
||||
assert p.supports_vision_tool_messages is False
|
||||
|
||||
def test_live_catalog_filters_non_chat_models(self, monkeypatch):
|
||||
p = _profile()
|
||||
|
||||
Reference in New Issue
Block a user