feat(webui): support image uploads in composer and message bubbles
This commit is contained in:
@@ -234,6 +234,87 @@ async def test_process_message_persists_user_message_before_turn_completes(tmp_p
|
||||
assert persisted.updated_at >= persisted.created_at
|
||||
|
||||
|
||||
# 1x1 PNG used by the media-persistence tests. ``extract_documents`` runs
|
||||
# at the top of ``_process_message`` and filters ``msg.media`` down to
|
||||
# paths that magic-byte-sniff as images, so the test fixture needs real
|
||||
# bytes on disk (not just placeholder paths).
|
||||
_PNG_1X1 = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||
b"\x00\x00\x00\nIDATx\x9cc\x00\x00\x00\x02\x00\x01"
|
||||
b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_persists_media_paths_on_user_turn(tmp_path: Path) -> None:
|
||||
"""User turns that attach images must record the media paths alongside
|
||||
the text so the webui can rehydrate previews on session replay.
|
||||
|
||||
This is the producer half of the signed-media-URL round-trip: paths are
|
||||
stored here, then :meth:`WebSocketChannel._augment_media_urls` maps them
|
||||
onto signed URLs on the way out.
|
||||
"""
|
||||
img_a = tmp_path / "uuid-1.png"
|
||||
img_a.write_bytes(_PNG_1X1)
|
||||
img_b = tmp_path / "uuid-2.png"
|
||||
img_b.write_bytes(_PNG_1X1)
|
||||
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("interrupt")) # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="c-media",
|
||||
content="look",
|
||||
media=[str(img_a), str(img_b)],
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="interrupt"):
|
||||
await loop._process_message(msg)
|
||||
|
||||
loop.sessions.invalidate("websocket:c-media")
|
||||
persisted = loop.sessions.get_or_create("websocket:c-media")
|
||||
assert [m["role"] for m in persisted.messages] == ["user"]
|
||||
assert persisted.messages[0]["content"] == "look"
|
||||
assert persisted.messages[0]["media"] == [str(img_a), str(img_b)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_persists_media_only_turn_without_text(tmp_path: Path) -> None:
|
||||
"""A turn with images but no text still persists (previously silent-dropped).
|
||||
|
||||
The old early-persist gate skipped messages without text, leaving pure
|
||||
image turns un-checkpointed. They now materialise as an empty-content
|
||||
user row with ``media`` attached.
|
||||
"""
|
||||
img = tmp_path / "only.png"
|
||||
img.write_bytes(_PNG_1X1)
|
||||
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="c-images-only",
|
||||
content="",
|
||||
media=[str(img)],
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
await loop._process_message(msg)
|
||||
|
||||
loop.sessions.invalidate("websocket:c-images-only")
|
||||
persisted = loop.sessions.get_or_create("websocket:c-images-only")
|
||||
assert len(persisted.messages) == 1
|
||||
assert persisted.messages[0]["role"] == "user"
|
||||
assert persisted.messages[0]["content"] == ""
|
||||
assert persisted.messages[0]["media"] == [str(img)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_does_not_duplicate_early_persisted_user_message(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
|
||||
@@ -217,3 +217,55 @@ def test_window_cuts_mid_tool_group():
|
||||
# leaving orphan tool results for split_a at the front.
|
||||
history = session.get_history(max_messages=6)
|
||||
_assert_no_orphans(history)
|
||||
|
||||
|
||||
# --- Image breadcrumbs: media kwarg is synthesized into content for replay ---
|
||||
|
||||
|
||||
def test_get_history_synthesizes_image_breadcrumb_from_media_kwarg():
|
||||
"""Persisted user turns carry image paths as a ``media`` kwarg; LLM
|
||||
replay must still see an ``[image: path]`` breadcrumb so the assistant's
|
||||
follow-up reply has a referent instead of trailing an empty user row."""
|
||||
session = Session(key="test:media")
|
||||
session.messages.append(
|
||||
{"role": "user", "content": "look", "media": ["/m/a.png", "/m/b.png"]}
|
||||
)
|
||||
session.messages.append({"role": "assistant", "content": "nice"})
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history == [
|
||||
{"role": "user", "content": "look\n[image: /m/a.png]\n[image: /m/b.png]"},
|
||||
{"role": "assistant", "content": "nice"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
|
||||
"""Turns with no text but attached images must not replay as empty
|
||||
strings — the LLM would otherwise see a bare user turn followed by an
|
||||
unexplained assistant answer."""
|
||||
session = Session(key="test:image-only")
|
||||
session.messages.append({"role": "user", "content": "", "media": ["/m/pic.png"]})
|
||||
session.messages.append({"role": "assistant", "content": "I see a cat"})
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
||||
|
||||
|
||||
def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
||||
"""``media`` only ever appears on user entries in practice, but the
|
||||
synthesizer must be defensive: assistants / tools with list content
|
||||
don't get the breadcrumb pasted on top."""
|
||||
session = Session(key="test:defensive")
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "structured"}],
|
||||
"media": ["/m/x.png"], # nonsense but shouldn't crash
|
||||
}
|
||||
)
|
||||
history = session.get_history(max_messages=500)
|
||||
# List content is passed through verbatim — the synthesizer only
|
||||
# rewrites plain-string content.
|
||||
assert history[0]["content"] == [{"type": "text", "text": "structured"}]
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Tests for WS envelope media handling (client image upload path).
|
||||
|
||||
Exercises ``WebSocketChannel._dispatch_envelope`` for the ``message`` branch:
|
||||
decoding base64 data URLs, rejecting malformed / oversized / non-whitelisted
|
||||
payloads, preserving backward compatibility with media-less frames, and
|
||||
forwarding saved paths to ``_handle_message``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket import (
|
||||
WebSocketChannel,
|
||||
_extract_data_url_mime,
|
||||
)
|
||||
|
||||
|
||||
def _tiny_png_data_url() -> str:
|
||||
"""A 1-pixel PNG prefixed as a data URL — just enough for magic-bytes sniffing."""
|
||||
# 1x1 transparent PNG
|
||||
png = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
|
||||
b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx"
|
||||
b"\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\x00\x18\xdd\x8d\xb4\x00"
|
||||
b"\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
return f"data:image/png;base64,{base64.b64encode(png).decode()}"
|
||||
|
||||
|
||||
def _data_url(mime: str, payload: bytes) -> str:
|
||||
return f"data:{mime};base64,{base64.b64encode(payload).decode()}"
|
||||
|
||||
|
||||
def _make_channel() -> WebSocketChannel:
|
||||
bus = MagicMock()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "websocketRequiresToken": False},
|
||||
bus,
|
||||
)
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
return channel
|
||||
|
||||
|
||||
# -- Pure helpers --------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
("data:image/png;base64,AAAA", "image/png"),
|
||||
("data:image/jpeg;base64,AAAA", "image/jpeg"),
|
||||
("data:IMAGE/PNG;base64,AAAA", "image/png"),
|
||||
("data:image/svg+xml;base64,AAAA", "image/svg+xml"),
|
||||
("data:text/plain;base64,AAAA", "text/plain"),
|
||||
("http://evil.example/x.png", None),
|
||||
("data:image/png,AAAA", None), # missing `;base64`
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_extract_data_url_mime(url: Any, expected: str | None) -> None:
|
||||
assert _extract_data_url_mime(url) == expected
|
||||
|
||||
|
||||
# -- max_message_bytes bump ----------------------------------------------------
|
||||
|
||||
|
||||
def test_max_message_bytes_default_supports_multi_image_frame() -> None:
|
||||
"""Default 36 MB must comfortably hold 4 × 6 MB base64-encoded images."""
|
||||
from nanobot.channels.websocket import WebSocketConfig
|
||||
|
||||
default = WebSocketConfig().max_message_bytes
|
||||
# 4 images × 6 MB × 1.37 base64 overhead ≈ 33 MB
|
||||
assert default >= 33 * 1024 * 1024
|
||||
# Upper bound 40 MB matches plan
|
||||
with pytest.raises(Exception):
|
||||
WebSocketConfig(max_message_bytes=41_943_040 + 1)
|
||||
|
||||
|
||||
# -- _dispatch_envelope message branch + media --------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_without_media_backward_compatible() -> None:
|
||||
"""Existing clients that don't send ``media`` keep working unchanged."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {"type": "message", "chat_id": "abc123", "content": "hello"}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
call = channel._handle_message.call_args
|
||||
assert call.kwargs["chat_id"] == "abc123"
|
||||
assert call.kwargs["content"] == "hello"
|
||||
# When no media, we pass ``media=None`` so downstream treats it as absent.
|
||||
assert call.kwargs["media"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "look at this",
|
||||
"media": [{"data_url": _tiny_png_data_url(), "name": "shot.png"}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
paths = channel._handle_message.call_args.kwargs["media"]
|
||||
assert isinstance(paths, list) and len(paths) == 1
|
||||
saved = Path(paths[0])
|
||||
assert saved.exists()
|
||||
assert saved.suffix == ".png"
|
||||
assert saved.is_relative_to(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_multiple_images(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "a couple",
|
||||
"media": [
|
||||
{"data_url": _tiny_png_data_url()},
|
||||
{"data_url": _tiny_png_data_url()},
|
||||
{"data_url": _tiny_png_data_url()},
|
||||
],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
paths = channel._handle_message.call_args.kwargs["media"]
|
||||
assert len(paths) == 3
|
||||
# Saved filenames must be unique.
|
||||
assert len({Path(p).name for p in paths}) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_only_message_allows_empty_text(tmp_path) -> None:
|
||||
"""When media is attached, empty text is acceptable."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "",
|
||||
"media": [{"data_url": _tiny_png_data_url()}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
# Error event NOT sent.
|
||||
mock_conn.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "hi",
|
||||
"media": [{"data_url": _tiny_png_data_url()}] * 5,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
mock_conn.send.assert_awaited_once()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["event"] == "error"
|
||||
assert err["detail"] == "image_rejected"
|
||||
assert err["reason"] == "too_many_images"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_oversize_payload(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
oversized = b"x" * (9 * 1024 * 1024) # > 8 MB WS limit
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "big",
|
||||
"media": [{"data_url": _data_url("image/png", oversized)}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "image_rejected"
|
||||
assert err["reason"] == "size"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_non_image_mime(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "pdf?",
|
||||
"media": [{"data_url": _data_url("application/pdf", b"%PDF-1.4")}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "image_rejected"
|
||||
assert err["reason"] == "mime"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_svg_mime(tmp_path) -> None:
|
||||
"""SVG is explicitly rejected — XSS surface inside embedded scripts."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "svg",
|
||||
"media": [{"data_url": _data_url("image/svg+xml", b"<svg/>")}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "mime"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_malformed_data_url(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "nope",
|
||||
"media": [{"data_url": "http://evil.example/image.png"}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "decode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_broken_base64(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "nope",
|
||||
"media": [{"data_url": "data:image/png;base64,not-valid-base64!!!"}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "decode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_when_media_item_shape_wrong(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "huh",
|
||||
# Not a dict — plain string at the top level.
|
||||
"media": ["data:image/png;base64,XXXX"],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "malformed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_when_media_field_is_not_list() -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "huh",
|
||||
"media": "not-a-list",
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "image_rejected"
|
||||
assert err["reason"] == "malformed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_media_does_not_partially_persist(tmp_path) -> None:
|
||||
"""If the second image is invalid, the first must not be forwarded."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "mixed",
|
||||
"media": [
|
||||
{"data_url": _tiny_png_data_url()},
|
||||
{"data_url": _data_url("application/pdf", b"%PDF-1.4")},
|
||||
],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
# The first image was saved to disk (we don't roll it back — the caller
|
||||
# is expected to not reference it) but the agent never sees the paths.
|
||||
# That's the important invariant: no partial publish.
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "mime"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_empty_text_without_media() -> None:
|
||||
"""When no media is attached, whitespace-only content is still rejected
|
||||
(matches the existing behavior for backward compat)."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": " ",
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "missing content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_string_content_still_rejected() -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": 42,
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "missing content"
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Tests for the signed ``/api/media/<sig>/<payload>`` route and its replay
|
||||
integration on ``/api/sessions/<key>/messages``.
|
||||
|
||||
The route is the return path for images attached to persisted user turns:
|
||||
:meth:`WebSocketChannel._sign_media_path` mints URLs during session reads,
|
||||
and :meth:`WebSocketChannel._handle_media_fetch` serves the bytes back.
|
||||
These tests cover the two halves end-to-end plus the adversarial edges
|
||||
(bad signatures, ``..`` traversal, non-existent files, non-image types).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket import (
|
||||
WebSocketChannel,
|
||||
_b64url_decode,
|
||||
_b64url_encode,
|
||||
)
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
# PNG magic bytes + a couple of sentinel bytes so we can verify byte-for-byte
|
||||
# round-trip of the served payload. Stays under mimetype + size limits.
|
||||
_PNG_BYTES = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||
b"\x00\x00\x00\nIDATx\x9cc\x00\x00\x00\x02\x00\x01"
|
||||
b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def _ch(
|
||||
bus: Any,
|
||||
*,
|
||||
session_manager: SessionManager | None = None,
|
||||
port: int,
|
||||
) -> WebSocketChannel:
|
||||
return WebSocketChannel(
|
||||
{
|
||||
"enabled": True,
|
||||
"allowFrom": ["*"],
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"path": "/",
|
||||
"websocketRequiresToken": False,
|
||||
},
|
||||
bus,
|
||||
session_manager=session_manager,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bus() -> MagicMock:
|
||||
b = MagicMock()
|
||||
b.publish_inbound = AsyncMock()
|
||||
return b
|
||||
|
||||
|
||||
async def _http_get(
|
||||
url: str, headers: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
return await asyncio.to_thread(
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sign_media_path: the URL minter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sign_media_path_rejects_paths_outside_media_root(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Paths that resolve outside ``get_media_dir()`` must not be signed.
|
||||
|
||||
This is the single most important invariant of the whole scheme:
|
||||
if the minter ever signed an arbitrary path, the HMAC would legitimise
|
||||
it for the fetch handler and we'd hand out a disk-read primitive.
|
||||
"""
|
||||
outside = tmp_path / "secrets" / "cred.txt"
|
||||
outside.parent.mkdir()
|
||||
outside.write_text("nope")
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
channel = _ch(bus, port=0)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
assert channel._sign_media_path(outside) is None
|
||||
# Traversal via the media root is also rejected — the resolve() step
|
||||
# normalises ``..`` out before the relative_to check.
|
||||
assert channel._sign_media_path(media / ".." / "secrets" / "cred.txt") is None
|
||||
|
||||
|
||||
def test_sign_media_path_round_trips_via_hmac(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""The signature embeds exactly ``HMAC-SHA256(secret, payload)[:16]``."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
(media / "a.png").write_bytes(_PNG_BYTES)
|
||||
channel = _ch(bus, port=0)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
url = channel._sign_media_path(media / "a.png")
|
||||
assert url is not None
|
||||
assert url.startswith("/api/media/")
|
||||
sig, payload = url[len("/api/media/"):].split("/", 1)
|
||||
expected = hmac.new(
|
||||
channel._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
assert _b64url_decode(sig) == expected
|
||||
# The payload decodes back to the *relative* path — no absolute-path leaks.
|
||||
assert _b64url_decode(payload).decode() == "a.png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/media/<sig>/<payload>: the serving handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_serves_signed_file(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Valid signature + existing file => 200 with correct bytes + MIME."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
target = media / "round-trip.png"
|
||||
target.write_bytes(_PNG_BYTES)
|
||||
|
||||
channel = _ch(bus, port=29920)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
url_path = channel._sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29920{url_path}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == _PNG_BYTES
|
||||
assert resp.headers["content-type"].startswith("image/png")
|
||||
# Immutable cache header lets the browser skip round-trips on replay.
|
||||
assert "immutable" in resp.headers.get("cache-control", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_rejects_bad_signature(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A payload re-signed with a different secret must 401.
|
||||
|
||||
Protects against a restart: old URLs baked into a stale tab become
|
||||
un-forgeable once ``_media_secret`` regenerates.
|
||||
"""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
(media / "f.png").write_bytes(_PNG_BYTES)
|
||||
|
||||
channel = _ch(bus, port=29921)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
good = channel._sign_media_path(media / "f.png")
|
||||
assert good is not None
|
||||
_, payload = good[len("/api/media/"):].split("/", 1)
|
||||
# Forge a sig with a *different* secret.
|
||||
forged_mac = hmac.new(
|
||||
b"\x00" * 32, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
forged = f"/api/media/{_b64url_encode(forged_mac)}/{payload}"
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29921{forged}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_rejects_path_traversal_payload(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Even a validly-signed ``..`` payload must not escape the media root.
|
||||
|
||||
The signer never *emits* such payloads, but an attacker who somehow
|
||||
obtained the secret (or the channel was misconfigured) must still be
|
||||
stopped by the resolve()+relative_to() guard in the serving path.
|
||||
"""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
secret_file = tmp_path / "secret.txt"
|
||||
secret_file.write_text("classified")
|
||||
|
||||
channel = _ch(bus, port=29922)
|
||||
# Hand-craft a traversal payload the legit signer would refuse to mint.
|
||||
payload = _b64url_encode(b"../secret.txt")
|
||||
mac = hmac.new(
|
||||
channel._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
url = f"/api/media/{_b64url_encode(mac)}/{payload}"
|
||||
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29922{url}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
assert resp.status_code == 404
|
||||
assert b"classified" not in resp.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_404s_missing_file(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A signed URL for a file that no longer exists degrades to 404 so the
|
||||
client can fall back to the placeholder tile instead of breaking."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
target = media / "gone.png"
|
||||
target.write_bytes(_PNG_BYTES)
|
||||
|
||||
channel = _ch(bus, port=29923)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
url_path = channel._sign_media_path(target)
|
||||
assert url_path is not None
|
||||
target.unlink() # the file vanishes between signing and fetching
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29923{url_path}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_degrades_non_image_to_octet_stream(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A non-image extension must not be served as its native MIME.
|
||||
|
||||
Defence-in-depth: if media_dir ever contained (say) an HTML file, we
|
||||
do not want the browser to render it as HTML via the signed route.
|
||||
"""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
(media / "scary.html").write_bytes(b"<script>alert(1)</script>")
|
||||
|
||||
channel = _ch(bus, port=29924)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
payload = _b64url_encode(b"scary.html")
|
||||
mac = hmac.new(
|
||||
channel._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
url = f"/api/media/{_b64url_encode(mac)}/{payload}"
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29924{url}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/octet-stream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/sessions/<key>/messages: media_urls hydration on session read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_messages_exposes_signed_media_urls(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""The read path must map persisted ``media`` paths onto signed URLs
|
||||
and strip the raw path — the client never learns the server's layout."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
img = media / "u.png"
|
||||
img.write_bytes(_PNG_BYTES)
|
||||
|
||||
sm = SessionManager(tmp_path / "ws_state")
|
||||
sess = Session(key="websocket:media-hydrate")
|
||||
sess.add_message("user", "look at this", media=[str(img)])
|
||||
sess.add_message("assistant", "nice")
|
||||
sm.save(sess)
|
||||
|
||||
channel = _ch(bus, session_manager=sm, port=29925)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get("http://127.0.0.1:29925/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29925/api/sessions/websocket:media-hydrate/messages",
|
||||
headers=auth,
|
||||
)
|
||||
body = resp.json()
|
||||
# The signed URL round-trips end-to-end: fetching it yields the same bytes.
|
||||
user_msg = next(m for m in body["messages"] if m["role"] == "user")
|
||||
urls = user_msg["media_urls"]
|
||||
assert isinstance(urls, list) and len(urls) == 1
|
||||
assert urls[0]["name"] == "u.png"
|
||||
assert urls[0]["url"].startswith("/api/media/")
|
||||
# Raw paths must not leak to the wire.
|
||||
assert "media" not in user_msg
|
||||
|
||||
# And the URL actually works.
|
||||
fetched = await _http_get(f"http://127.0.0.1:29925{urls[0]['url']}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.content == _PNG_BYTES
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_messages_skips_vanished_media(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Paths that no longer resolve inside the media root produce no URL —
|
||||
the message is still delivered, just without the preview."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
|
||||
sm = SessionManager(tmp_path / "ws_state")
|
||||
sess = Session(key="websocket:vanished")
|
||||
sess.add_message("user", "missing pic", media=[str(media / "absent.png")])
|
||||
sm.save(sess)
|
||||
|
||||
channel = _ch(bus, session_manager=sm, port=29926)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get("http://127.0.0.1:29926/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29926/api/sessions/websocket:vanished/messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
user_msg = next(m for m in resp.json()["messages"] if m["role"] == "user")
|
||||
# absent.png lives inside the media root so it *does* get a signed
|
||||
# URL (we don't stat the file at signing time — that would slow
|
||||
# the listing). Fetching the URL is where the 404 surfaces.
|
||||
urls = user_msg.get("media_urls") or []
|
||||
assert len(urls) == 1
|
||||
fetched = await _http_get(f"http://127.0.0.1:29926{urls[0]['url']}")
|
||||
assert fetched.status_code == 404
|
||||
assert "media" not in user_msg
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for ``nanobot.utils.media_decode``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.utils.media_decode import (
|
||||
DEFAULT_MAX_BYTES,
|
||||
FileSizeExceeded,
|
||||
MAX_FILE_SIZE,
|
||||
save_base64_data_url,
|
||||
)
|
||||
|
||||
|
||||
def _data_url(payload: bytes, mime: str = "image/png") -> str:
|
||||
return f"data:{mime};base64,{base64.b64encode(payload).decode()}"
|
||||
|
||||
|
||||
def test_saves_png_with_correct_extension(tmp_path) -> None:
|
||||
result = save_base64_data_url(_data_url(b"fake png"), tmp_path)
|
||||
assert result is not None
|
||||
assert result.endswith(".png")
|
||||
assert (tmp_path / result.split("/")[-1]).read_bytes() == b"fake png"
|
||||
|
||||
|
||||
def test_returns_none_for_malformed_data_url(tmp_path) -> None:
|
||||
assert save_base64_data_url("not-a-data-url", tmp_path) is None
|
||||
|
||||
|
||||
def test_returns_none_for_broken_base64(tmp_path) -> None:
|
||||
# Python's b64decode strips non-alphabet chars by default, so we need a
|
||||
# payload whose alphabet-filtered length breaks padding.
|
||||
assert save_base64_data_url("data:image/png;base64,not-valid-base64!!!", tmp_path) is None
|
||||
|
||||
|
||||
def test_unknown_mime_falls_back_to_bin(tmp_path) -> None:
|
||||
result = save_base64_data_url(_data_url(b"xyz", mime="unknown/type"), tmp_path)
|
||||
assert result is not None
|
||||
assert result.endswith(".bin")
|
||||
|
||||
|
||||
def test_default_limit_is_10mb(tmp_path) -> None:
|
||||
"""Backwards-compatible default — the API path depends on this."""
|
||||
assert DEFAULT_MAX_BYTES == 10 * 1024 * 1024
|
||||
assert MAX_FILE_SIZE == 10 * 1024 * 1024
|
||||
|
||||
oversized = b"x" * (11 * 1024 * 1024)
|
||||
with pytest.raises(FileSizeExceeded, match="10MB limit"):
|
||||
save_base64_data_url(_data_url(oversized), tmp_path)
|
||||
|
||||
|
||||
def test_explicit_max_bytes_overrides_default(tmp_path) -> None:
|
||||
"""WS channel passes 8 MB; a 9 MB payload should be rejected there even
|
||||
though it would pass the 10 MB API limit."""
|
||||
payload = b"y" * (9 * 1024 * 1024)
|
||||
with pytest.raises(FileSizeExceeded, match="8MB limit"):
|
||||
save_base64_data_url(_data_url(payload), tmp_path, max_bytes=8 * 1024 * 1024)
|
||||
|
||||
|
||||
def test_saved_file_lives_under_media_dir(tmp_path) -> None:
|
||||
result = save_base64_data_url(_data_url(b"ok"), tmp_path)
|
||||
assert result is not None
|
||||
assert result.startswith(str(tmp_path))
|
||||
|
||||
|
||||
def test_legacy_symbols_reexported_from_api_server() -> None:
|
||||
"""Existing tests import ``_save_base64_data_url`` / ``_FileSizeExceeded``
|
||||
from ``nanobot.api.server`` — keep the aliases working."""
|
||||
from nanobot.api import server
|
||||
|
||||
assert server._save_base64_data_url is save_base64_data_url
|
||||
assert server._FileSizeExceeded is FileSizeExceeded
|
||||
assert server.MAX_FILE_SIZE == MAX_FILE_SIZE
|
||||
Reference in New Issue
Block a user