diff --git a/api/routes.py b/api/routes.py index 2fc7114..dd05eec 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2,6 +2,7 @@ Hermes Web UI -- Route handlers for GET and POST endpoints. Extracted from server.py (Sprint 11) so server.py is a thin shell. """ +import html as _html import json import os import queue @@ -116,8 +117,7 @@ def handle_get(handler, parsed) -> bool: content_type='text/html; charset=utf-8') if parsed.path == '/login': - import html as _html - _bn = _html.escape(load_settings().get('bot_name', 'Hermes')) + _bn = _html.escape(load_settings().get('bot_name') or 'Hermes') _page = _LOGIN_PAGE_HTML.replace('{{BOT_NAME}}', _bn).replace('{{BOT_NAME_INITIAL}}', _bn[0].upper()) return t(handler, _page, content_type='text/html; charset=utf-8') @@ -526,6 +526,8 @@ def handle_post(handler, parsed) -> bool: # ── Settings (POST) ── if parsed.path == '/api/settings': + if 'bot_name' in body: + body['bot_name'] = (str(body['bot_name']) or '').strip() or 'Hermes' saved = save_settings(body) saved.pop('password_hash', None) # never expose hash to client return j(handler, saved) diff --git a/static/index.html b/static/index.html index 0ff59d6..5157d0a 100644 --- a/static/index.html +++ b/static/index.html @@ -375,7 +375,7 @@
Display name for the assistant throughout the UI. Defaults to Hermes.
- +
diff --git a/static/ui.js b/static/ui.js index b4d09ce..352357c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -506,7 +506,7 @@ function renderMessages(){ const tsVal=m._ts||m.timestamp; const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():''; const _bn=window._botName||'Hermes'; - row.innerHTML=`
${isUser?'Y':_bn.charAt(0).toUpperCase()}
${isUser?'You':esc(_bn)}${tsTitle?`${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`:''}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; + row.innerHTML=`
${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}
${isUser?'You':esc(_bn)}${tsTitle?`${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`:''}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; row.dataset.rawText = String(content).trim(); inner.appendChild(row); } diff --git a/tests/test_sprint27.py b/tests/test_sprint27.py new file mode 100644 index 0000000..bd5386e --- /dev/null +++ b/tests/test_sprint27.py @@ -0,0 +1,136 @@ +""" +Sprint 27 Tests: configurable assistant display name (bot_name). +Tests cover settings API round-trip, empty/missing input defaults, +login page rendering, and server-side sanitization. +""" +import json +import urllib.error +import urllib.request + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def get_raw(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read().decode(), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +# ── Default value ───────────────────────────────────────────────────────── + +def test_settings_default_bot_name(): + """GET /api/settings should return bot_name defaulting to 'Hermes'.""" + d, status = get("/api/settings") + assert status == 200 + assert "bot_name" in d + assert d["bot_name"] == "Hermes" + + +# ── Round-trip ──────────────────────────────────────────────────────────── + +def test_settings_set_bot_name(): + """POST /api/settings with bot_name should persist and round-trip.""" + try: + d, status = post("/api/settings", {"bot_name": "TestBot"}) + assert status == 200 + assert d.get("bot_name") == "TestBot" + d2, _ = get("/api/settings") + assert d2.get("bot_name") == "TestBot" + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +def test_settings_bot_name_special_chars(): + """bot_name with safe special characters should persist correctly.""" + try: + d, status = post("/api/settings", {"bot_name": "My Assistant 2.0"}) + assert status == 200 + d2, _ = get("/api/settings") + assert d2.get("bot_name") == "My Assistant 2.0" + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +# ── Server-side sanitization ────────────────────────────────────────────── + +def test_settings_empty_bot_name_defaults_to_hermes(): + """Posting an empty bot_name should default to 'Hermes' server-side.""" + try: + d, status = post("/api/settings", {"bot_name": ""}) + assert status == 200 + assert d.get("bot_name") == "Hermes" + d2, _ = get("/api/settings") + assert d2.get("bot_name") == "Hermes" + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +def test_settings_whitespace_bot_name_defaults_to_hermes(): + """Posting a whitespace-only bot_name should default to 'Hermes'.""" + try: + d, status = post("/api/settings", {"bot_name": " "}) + assert status == 200 + assert d.get("bot_name") == "Hermes" + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +# ── Login page rendering ────────────────────────────────────────────────── + +def test_login_page_shows_default_bot_name(): + """GET /login should contain 'Hermes' in title and h1 when default.""" + html, status = get_raw("/login") + assert status == 200 + assert "Hermes" in html + assert "<h1>Hermes</h1>" in html + + +def test_login_page_shows_custom_bot_name(): + """GET /login should reflect the configured bot_name.""" + try: + post("/api/settings", {"bot_name": "Aria"}) + html, status = get_raw("/login") + assert status == 200 + assert "<title>Aria" in html + assert "<h1>Aria</h1>" in html + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +def test_login_page_empty_name_does_not_crash(): + """Login page must not 500 even if somehow bot_name is empty in settings.""" + # Force an empty value by patching settings file directly — skipped here + # because the server-side guard in POST /api/settings prevents storing empty. + # Instead, verify that /login returns 200 reliably. + html, status = get_raw("/login") + assert status == 200 + assert "Sign in" in html + + +def test_login_page_xss_escaped(): + """bot_name with HTML special chars should be escaped in the login page.""" + try: + post("/api/settings", {"bot_name": "<script>alert(1)</script>"}) + html, status = get_raw("/login") + assert status == 200 + # Raw tag must not appear unescaped + assert "<script>alert(1)</script>" not in html + # Escaped form should appear + assert "<script>" in html + finally: + post("/api/settings", {"bot_name": "Hermes"})