Newer
Older
navi-1 / tests / unit / api / test_session_files.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy 21 hours ago 9 KB api: split the sessions routes into a package
"""Unit tests for session file API endpoint logic."""

import base64
import io
from unittest.mock import MagicMock

import pytest
from fastapi import HTTPException
from PIL import Image

import navi.api.routes.sessions as sessions_mod
import navi.session_files as session_files_mod
from navi.auth import User
from navi.core.session import InMemorySessionStore


@pytest.fixture
async def store():
    session_store = InMemorySessionStore()
    await session_store.create("assistant")
    return session_store


@pytest.fixture(autouse=True)
def _session_files_dir(monkeypatch, tmp_path):
    from navi.config import Settings

    async def _to_thread(func, *args, **kwargs):
        return func(*args, **kwargs)

    from navi.api.routes.sessions import files as files_mod

    monkeypatch.setattr(files_mod.asyncio, "to_thread", _to_thread)
    _test_settings = Settings(
        session_files_dir=str(tmp_path / "sessions"),
        session_files_max_size_mb=1,
    )
    monkeypatch.setattr(files_mod, "settings", _test_settings)
    monkeypatch.setattr(session_files_mod, "settings", _test_settings)


async def _session_id(store: InMemorySessionStore) -> str:
    sessions = await store.list_all()
    return sessions[0].id


class FakeUploadFile:
    def __init__(self, name: str, data: bytes, content_type: str = "application/octet-stream") -> None:
        self.filename = name
        self.content_type = content_type
        self._data = data
        self._offset = 0

    async def read(self, size: int = -1) -> bytes:
        if self._offset >= len(self._data):
            return b""
        if size < 0:
            size = len(self._data) - self._offset
        chunk = self._data[self._offset:self._offset + size]
        self._offset += len(chunk)
        return chunk


def _upload(name: str, data: bytes, content_type: str = "application/octet-stream") -> FakeUploadFile:
    return FakeUploadFile(name, data, content_type)


_FAKE_USER = User(id="test-user", email="test@example.com", display_name="Test", role="admin", permissions=[])


async def test_upload_duplicate_filename_gets_numbered_name(store):
    session_id = await _session_id(store)

    first = await sessions_mod.upload_file(session_id, _upload("report.txt", b"one"), store, _FAKE_USER)
    second = await sessions_mod.upload_file(session_id, _upload("report.txt", b"two"), store, _FAKE_USER)

    assert first["name"] == "report.txt"
    assert second["name"] == "report_1.txt"


async def test_upload_forbidden_extension_rejected(store):
    session_id = await _session_id(store)

    with pytest.raises(HTTPException) as exc:
        await sessions_mod.upload_file(session_id, _upload("run.sh", b"echo nope"), store, _FAKE_USER)

    assert exc.value.status_code == 400
    assert "Executable files" in exc.value.detail


async def test_download_rejects_path_traversal(store):
    session_id = await _session_id(store)

    with pytest.raises(HTTPException) as exc:
        await sessions_mod.download_file(session_id, "../secret.txt", store)

    assert exc.value.status_code == 403


async def test_download_browser_renderable_file_is_inline(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "page.html"
    dest.write_text("<h1>Hello</h1>")

    response = await sessions_mod.download_file(session_id, "page.html", store)

    assert response.media_type == "text/html"
    assert "inline" in response.headers["content-disposition"]


async def test_download_query_forces_attachment(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "page.html"
    dest.write_text("<h1>Hello</h1>")

    response = await sessions_mod.download_file(session_id, "page.html", store, download=True)

    assert response.media_type == "text/html"
    assert "attachment" in response.headers["content-disposition"]


async def test_download_archive_is_attachment(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "bundle.zip"
    dest.write_bytes(b"zip")

    response = await sessions_mod.download_file(session_id, "bundle.zip", store)

    assert response.media_type == "application/zip"
    assert "attachment" in response.headers["content-disposition"]


async def test_download_audio_is_inline(store):
    # Served as an attachment an audio file is downloaded instead of played.
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "voice.mp3"
    dest.write_bytes(b"ID3\x04\x00" + b"\x00" * 16)

    response = await sessions_mod.download_file(session_id, "voice.mp3", store)

    assert response.media_type == "audio/mpeg"
    assert "inline" in response.headers["content-disposition"]


async def test_download_video_is_inline(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "clip.mp4"
    dest.write_bytes(b"\x00\x00\x00\x18ftypisom" + b"\x00" * 16)

    response = await sessions_mod.download_file(session_id, "clip.mp4", store)

    assert response.media_type == "video/mp4"
    assert "inline" in response.headers["content-disposition"]


async def test_download_markdown_is_inline_with_its_own_mime(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "report.md"
    dest.write_text("# Report\n")

    response = await sessions_mod.download_file(session_id, "report.md", store)

    assert response.media_type == "text/markdown"
    assert "inline" in response.headers["content-disposition"]


async def test_download_flag_forces_attachment_for_media(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "voice.mp3"
    dest.write_bytes(b"ID3\x04\x00" + b"\x00" * 16)

    response = await sessions_mod.download_file(session_id, "voice.mp3", store, download=True)

    assert "attachment" in response.headers["content-disposition"]


async def test_file_listing_carries_content_type(store):
    session_id = await _session_id(store)
    directory = session_files_mod.ensure_session_dir(session_id)
    (directory / "voice.mp3").write_bytes(b"ID3\x04\x00" + b"\x00" * 16)
    (directory / "notes.md").write_text("# Notes\n")

    files = {item["path"]: item for item in session_files_mod.list_session_files(session_id)}

    assert files["voice.mp3"]["content_type"] == "audio"
    assert files["notes.md"]["content_type"] == "text"


async def test_get_session_content_returns_store_items(store, monkeypatch):
    session_id = await _session_id(store)

    async def fake_list_for_session(sid):
        assert sid == session_id
        return [{"filename": "logo.svg"}]

    from navi.api.routes.sessions import detail as detail_mod

    monkeypatch.setattr(detail_mod, "list_for_session", fake_list_for_session)

    result = await sessions_mod.get_session_content(session_id, store)

    assert result == {"session_id": session_id, "content": [{"filename": "logo.svg"}]}


# ── save_images: the images a user pastes into the chat ──────────────────────


def _png_bytes(colour: tuple = (255, 0, 0, 128)) -> bytes:
    """A tiny RGBA PNG — alpha present, so a JPEG round-trip would show up as damage."""
    buf = io.BytesIO()
    Image.new("RGBA", (2, 2), colour).save(buf, format="PNG")
    return buf.getvalue()


def _jpg_bytes() -> bytes:
    buf = io.BytesIO()
    Image.new("RGB", (2, 2), (0, 128, 255)).save(buf, format="JPEG")
    return buf.getvalue()


def _b64(raw: bytes) -> str:
    return base64.b64encode(raw).decode()


async def test_save_images_writes_each_one_under_its_own_format(store):
    png, jpg = _png_bytes(), _jpg_bytes()
    session_id = await _session_id(store)

    names = session_files_mod.save_images(session_id, [_b64(png), _b64(jpg)])

    assert names == ["image_1.png", "image_2.jpg"]
    directory = session_files_mod.session_dir(session_id)
    assert (directory / "image_1.png").read_bytes() == png
    assert (directory / "image_2.jpg").read_bytes() == jpg
    # The bytes are stored as they arrived: an RGBA paste stays RGBA.
    assert Image.open(directory / "image_1.png").mode == "RGBA"


async def test_save_images_tolerates_a_data_url_prefix(store):
    session_id = await _session_id(store)

    names = session_files_mod.save_images(
        session_id, [f"data:image/png;base64,{_b64(_png_bytes())}"]
    )

    assert names == ["image_1.png"]


async def test_save_images_reuses_a_file_that_already_holds_those_bytes(store):
    raw = _png_bytes()
    session_id = await _session_id(store)

    first = session_files_mod.save_images(session_id, [_b64(raw)])
    again = session_files_mod.save_images(session_id, [_b64(raw)])

    assert first == again == ["image_1.png"]
    assert len(list(session_files_mod.session_dir(session_id).glob("image_*"))) == 1


async def test_save_images_gives_a_different_picture_the_next_number(store):
    session_id = await _session_id(store)
    session_files_mod.save_images(session_id, [_b64(_png_bytes())])

    names = session_files_mod.save_images(
        session_id, [_b64(_png_bytes(colour=(0, 255, 0, 255)))]
    )

    assert names == ["image_2.png"]
    assert len(list(session_files_mod.session_dir(session_id).glob("image_*"))) == 2


async def test_save_images_skips_what_it_cannot_read_and_says_so(store, monkeypatch):
    log = MagicMock()
    monkeypatch.setattr(session_files_mod, "log", log)
    session_id = await _session_id(store)

    names = session_files_mod.save_images(
        session_id,
        ["abc", _b64(_png_bytes()), _b64(b"\x00\x01\x02\x03")],
    )

    # One name per input, in order — an unreadable one is an empty slot, not a gap.
    assert names == ["", "image_1.png", ""]
    assert [call.args[0] for call in log.warning.call_args_list] == [
        "session_files.image_not_stored",
        "session_files.image_not_stored",
    ]
    # Nothing was written under a name that would claim it is a PNG.
    assert [p.name for p in session_files_mod.session_dir(session_id).glob("image_*")] == [
        "image_1.png"
    ]