Compare commits

..
Author SHA1 Message Date
Alexandre Teixeira 958848f9f8 test: interleave concurrent IMAP deletion before expunge 2026-10-29 01:51:41 +00:00
2 changed files with 21 additions and 123 deletions
-119
View File
@@ -1,119 +0,0 @@
"""The chat stream must never pass another owner's document to the model."""
import json
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.auth import AuthManager
from routes.auth_routes import SESSION_COOKIE
from tests.helpers.database import isolated_session_database
@pytest.fixture
def document_chat(tmp_path, monkeypatch):
from routes import chat_routes, chat_helpers, session_routes
from src.agent_tools import document_tools
from src.auth_helpers import effective_user
monkeypatch.setenv("AUTH_ENABLED", "true")
auth = AuthManager(str(tmp_path / "auth.json"))
for owner in ("alice", "bob"):
assert auth.create_user(owner, "disposable-test-password")
captured = []
async def context(sess, request, *args, **kwargs):
# Preprocessing and external model I/O are outside the owner lookup.
# The actual route, session-owner gate and document ORM queries run.
return chat_helpers.ChatContext(
preface=[], rag_sources=[], web_sources=[], used_memories=[],
messages=[{"role": "user", "content": "Summarize my writing"}],
context_length=4096, was_compacted=False, user=effective_user(request), uprefs={},
preset=chat_helpers.PresetInfo(0.2, 128, None, None),
preprocessed=chat_helpers.PreprocessedMessage("", "", "", [], []),
)
async def model(endpoint, model, messages, **kwargs):
doc = kwargs.get("active_document")
captured.append(None if doc is None else (doc.id, doc.owner, doc.current_content))
text = doc.current_content if doc is not None else "no document context"
yield f'data: {json.dumps({"delta": text})}\n\n'
yield 'data: [DONE]\n\n'
monkeypatch.setattr(chat_routes, "build_chat_context", context)
monkeypatch.setattr(chat_routes, "stream_agent_loop", model)
monkeypatch.setattr(chat_routes, "save_assistant_response", lambda *a, **kw: None)
monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *a, **kw: None)
monkeypatch.setattr(document_tools, "_active_document_id", None)
with isolated_session_database(tmp_path) as (manager, database):
factory = database.SessionLocal
for module in (chat_routes, chat_helpers, session_routes):
monkeypatch.setattr(module, "SessionLocal", factory)
# Existing route tests may re-import session_routes after chat imported
# its owner check. Bind that real function's database too, without
# replacing the authorization logic or reloading production modules.
monkeypatch.setitem(chat_routes._verify_session_owner.__globals__, "SessionLocal", factory)
with factory() as db:
db.add_all([
database.Session(id="alice-chat", name="Alice", owner="alice",
endpoint_url="http://model.invalid/v1", model="fixture-model"),
database.Session(id="bob-chat", name="Bob", owner="bob",
endpoint_url="http://model.invalid/v1", model="fixture-model"),
database.ModelEndpoint(id="fixture-endpoint", name="Fixture",
base_url="http://model.invalid/v1", owner="alice"),
])
db.commit()
app = FastAPI()
app.state.auth_manager = auth
@app.middleware("http")
async def fixture_session(request, call_next):
request.state.current_user = auth.get_username_for_token(request.cookies.get(SESSION_COOKIE))
return await call_next(request)
app.include_router(chat_routes.setup_chat_routes(
manager, SimpleNamespace(), SimpleNamespace(), SimpleNamespace(),
SimpleNamespace(), SimpleNamespace(),
))
token = auth.create_session("alice", "disposable-test-password")
assert token
with TestClient(app) as client:
client.cookies.set(SESSION_COOKIE, token)
yield SimpleNamespace(client=client, factory=factory, database=database,
captured=captured, document_tools=document_tools)
@pytest.mark.parametrize("source", ["explicit", "session", "email-session", "memory"])
@pytest.mark.parametrize("document_owner", ["alice", "bob"])
def test_chat_document_context_is_owner_scoped(document_chat, source, document_owner):
case = document_chat
form = {"message": "Summarize my writing", "session": "alice-chat", "mode": "agent",
"compare_mode": "true", "no_memory": "true", "use_rag": "false"}
with case.factory() as db:
for owner in ("alice", "bob"):
# A stale session link is not evidence that the caller owns a doc.
linked = "alice-chat" if source in {"session", "email-session"} and owner == document_owner else None
db.add(case.database.Document(id=f"{owner}-doc", owner=owner, session_id=linked,
title=f"{owner} draft",
language="email" if source == "email-session" else "markdown",
current_content=f"{owner}-private-context", is_active=True))
db.commit()
if source == "explicit":
form.update(active_doc_id=f"{document_owner}-doc", active_doc_state="visible")
elif source == "memory":
case.document_tools.set_active_document(f"{document_owner}-doc")
response = case.client.post("/api/chat_stream", data=form)
assert response.status_code == 200, response.text
if document_owner == "alice":
assert case.captured == [("alice-doc", "alice", "alice-private-context")]
assert "alice-private-context" in response.text
else:
assert case.captured == [None]
assert "bob-private-context" not in response.text
with case.factory() as db:
assert db.get(case.database.Document, "bob-doc").owner == "bob"
assert db.get(case.database.Document, "bob-doc").session_id == (
"alice-chat" if source in {"session", "email-session"} and document_owner == "bob" else None
)
+21 -4
View File
@@ -315,15 +315,32 @@ def test_server_lacking_uidplus_never_calls_mailbox_expunge(bulk_delete):
bulk_delete.index_delete.assert_not_called() bulk_delete.index_delete.assert_not_called()
def test_concurrent_client_marks_unrelated_deleted_message_during_operation(bulk_delete): def test_concurrent_client_marks_unrelated_deleted_message_during_operation(bulk_delete, monkeypatch):
# Another client marks unrelated UID 999 \Deleted before/during the operation mailbox = bulk_delete.mailbox
bulk_delete.mailbox.inbox.add("999") mailbox.inbox.add("999")
bulk_delete.mailbox.deleted.add("999") assert "999" not in mailbox.deleted
uid_command = mailbox.uid
interleavings = []
def other_client_marks_deleted(command, uid, *args):
response = uid_command(command, uid, *args)
if command == "STORE" and uid == b"101" and args[0] == "+FLAGS":
# The requested message is flagged; another client now flags 999
# before the server receives the subsequent EXPUNGE command.
assert mailbox.deleted == {"101"}
assert not any(cmd == "EXPUNGE" for cmd, _ in mailbox.calls)
mailbox.deleted.add("999")
interleavings.append("STORE")
return response
monkeypatch.setattr(mailbox, "uid", other_client_marks_deleted)
result = bulk_delete.delete(["101"]) result = bulk_delete.delete(["101"])
assert result["success"] is True assert result["success"] is True
assert result["deleted_uids"] == ["101"] assert result["deleted_uids"] == ["101"]
assert interleavings == ["STORE"]
assert mailbox.inbox == {"102", "103", "999"}
# 101 was expunged, unrelated 999 is NEVER expunged # 101 was expunged, unrelated 999 is NEVER expunged
assert "101" not in bulk_delete.mailbox.inbox assert "101" not in bulk_delete.mailbox.inbox
assert "999" in bulk_delete.mailbox.inbox assert "999" in bulk_delete.mailbox.inbox