## Thinking Path The detail-load endpoint (and the foreign-session synthesizer) already distinguish "session owned by a KNOWN other profile" (return ``409 session_profile_mismatch`` so the client can offer to switch to it per #5419) from "session missing/legacy with no profile stamped" (return ``404 Session not found`` so the frontend's self-heal clears the stale URL + localStorage). The generic request-guard ``_session_id_visible_to_request_profile`` flattened both cases to a ``404 Session not found``, so any cross-profile POST/PATCH/DELETE (archive, rename, pin, delete, session-attach, anchor-scene, …) and the stream-related status/cancel/replay paths failed with a misleading "Session not found" instead of the actionable 409 the detail endpoint emits. The reporter's wire evidence: ``` POST /api/session/archive {"session_id":"<id>","archived":true} -> 404 {"error": "Session not found"} GET /api/session?session_id=<same id> -> 409 {"error": "Session belongs to a different profile", "code": "session_profile_mismatch", "session_id": "<id>", "profile": "def"} ``` Same session id, same moment: the detail endpoint reports a profile mismatch, archive masks it as 404. The fix mirrors the detail endpoint's contract in the generic guard and in every other inline check that previously went to ``bad(handler, "Session not found", 404)`` for a known-other-profile session. ## What Changed 1. ``api/routes.py:595`` — ``_session_id_visible_to_request_profile`` now emits ``409 session_profile_mismatch`` for a session owned by a known other profile, and keeps ``404 Session not found`` only for the None-profile (unknown/legacy) case so the frontend self-heal still fires for actually-missing sids. 2. ``api/routes.py:5348`` (anchor-scene persist), ``:23994`` (compression-recovery start), ``:24385`` (chat/start with the inline placeholder-retag rules preserved), ``:28621`` (session import_cli's already-imported branch) — the same 409-vs-404 split, with the existing branches kept intact (the chat/start retag path, the import_cli ``allow_all_profiles`` short circuit). 3. The ``_stream_id_visible_to_request_profile`` wrapper already delegates to ``_session_id_visible_to_request_profile`` for the owner-session check, so the stream status / cancel / replay endpoints get the new contract for free. ## Why It Matters A user picking the configured default model in the dropdown sees two visually identical entries for the same model id, picks the wrong "prefixed" one (the one matching the dropdown's order), the agent returns 400, and the session state is corrupted with a non-functional model id until the user manually clears it. The dropdown's own deduplication already handles `@provider:model` correctly via the documented prefix form; only the synthesised ``{provider}/{model}`` form was wrong. The cross-profile write paths (anchor-scene persist, compression-recovery start, chat/start, session import) all had the same gap: a successful auth under profile A could persist or recover state against a session owned by profile B, surfacing a 404 to profile A's user that reads like "the session is gone" — but the session is in fact intact, just owned by another profile. The 409 contract is what closes the gap: the client gets a structured signal it can act on, and the session state stays consistent. ## Verification - ``tests/test_issue7710_cross_profile_404_to_409.py`` (new, 6 tests): - 5 behavioural: profile-mismatch path returns 409 with the ``session_profile_mismatch`` payload, the None-profile path keeps 404 for the frontend self-heal, visible session passes silently, missing session passes silently, ``emit_error=False`` suppresses the response. - 1 source-shape: the helper body MUST contain a 409 branch with the ``session_profile_mismatch`` code; a silent reversion to the single-line ``bad(..., 404)`` fails the suite. - ``tests/test_issue4812_session_sse_stream.py`` — 1 assertion updated to the new 409 payload (the SSE route walks through the generic guard). - ``tests/test_session_active_profile_authorization.py`` — 7 assertions updated to the 409 payload (duplicate, file, chat/start foreign, chat/stream/status, chat/stream/cancel, chat/stream replay). Stream tests use ``foreign_session`` (the owner session id), NOT the stream id, because ``_stream_id_visible_to_request_profile`` delegates to the owner-session check. - ``tests/test_anchor_scene_persistence.py`` — 1 assertion updated to the 409 payload. The 3 ``Assistant message not found`` 404 cases (lines 454/561/611) are kept as-is — they're not profile mismatches, they're missing-message 404s. - ``tests/test_issue4067_import_cli_cross_profile_guard.py`` — 1 assertion updated to the 409 payload, plus the ``_capture`` mock signature updated to accept the ``status`` keyword that ``j(handler, payload, status=200, ...)`` already passes. Other tests in the existing suite: **161 passed across 8 related test files** (session active-profile authorization, anchor scene, issue 4067, issue 4812 SSE, issue 7710 new, chat start claim, workspace stale recovery, issue 5420 profile switch). ## Contract Change The following routes now return ``409 session_profile_mismatch`` (``{error, code, session_id, profile}``) for a session owned by a known other profile, where they previously returned ``404 Session not found``: - ``POST /api/session/duplicate`` (now also goes through ``_guard_request_session_visibility`` for the body session id) - ``GET /api/file?session_id=…`` - ``POST /api/chat/start`` (the inline profile check after ``_get_or_materialize_session``) - ``GET /api/chat/stream/status?stream_id=…`` - ``GET /api/chat/stream/cancel?stream_id=…`` - ``GET /api/chat/stream?stream_id=…`` (the dead-stream replay branch) - ``POST /api/session/anchor-scene`` - ``POST /api/session/compression-recovery/start`` - ``POST /api/session/import_cli`` (the already-imported branch when ``allow_all_profiles`` is False) The ``404 Session not found`` self-heal path is preserved for the None-profile (unknown/legacy) case across all of these routes, so the frontend's existing stale-URL/localStorage cleanup behaviour is unchanged for actually-missing sessions. ## Risks - A consumer that previously relied on the 404 to detect "this session belongs to another profile" will now see a 409 with a ``code`` field. The detail-load endpoint has emitted this exact contract since #5419, and the frontend already handles it (offers to switch to the owning profile). The cross-profile write paths now match the detail-load contract; the frontend's already-shipped switcher UI handles both surfaces consistently. - The anchor-scene / compression-recovery / chat-start tests that were updated are a small set (5 total); the existing ``Assistant message not found`` 404 paths in ``test_anchor_scene_persistence`` are kept untouched (those are not profile mismatches). ## Related - #7710 — original issue (elkpvl-commits, 2026-09-22) with the full repro and the suggested fix shape. - #7549 — the underlying cross-profile archive bug, already tracked by @webtecnica in #7559 (which is the "business-level" fix: row-profile carry + frontend sends profile + materialize into the row's own profile). This PR is the orthogonal error-code-level fix: the request-guard's 404-vs-409 split. - #5419 — the original auto-switch-profiles-for-session-links feature, whose frontend switcher UI consumes the ``session_profile_mismatch`` code on the 409.
177 lines
7.3 KiB
Python
177 lines
7.3 KiB
Python
"""Regression tests for issue #7710 — cross-profile session guards return 409 + ``session_profile_mismatch`` instead of masking a known-other-profile session as 404.
|
|
|
|
The detail-load endpoint (and the foreign-session synthesizer) already
|
|
distinguish "session owned by a KNOWN other profile" (return ``409
|
|
session_profile_mismatch``) from "session missing/legacy with no
|
|
profile stamped" (return ``404 Session not found`` so the frontend
|
|
self-heal clears the stale URL).
|
|
|
|
The generic request-guard
|
|
``_session_id_visible_to_request_profile`` (used by
|
|
``_guard_request_session_visibility``) flattened both cases to a
|
|
``404 Session not found``, so any cross-profile POST/PATCH/DELETE
|
|
(archive, rename, pin, delete, …) failed with a misleading "Session
|
|
not found" instead of the actionable 409 the detail endpoint emits.
|
|
The fix mirrors the detail endpoint's contract in the generic guard.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _read_helper_body() -> str:
|
|
"""Extract ``_session_id_visible_to_request_profile`` as importable text."""
|
|
import textwrap
|
|
src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
|
start = src.index("def _session_id_visible_to_request_profile(")
|
|
end_marker = "def _stream_id_owner_session_id"
|
|
end = src.index(end_marker, start)
|
|
return textwrap.dedent(src[start:end])
|
|
|
|
|
|
def _exec_helper(scope: dict) -> None:
|
|
"""Execute the helper body inside ``scope``."""
|
|
body = _read_helper_body()
|
|
exec(body, scope)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Profile-mismatch path: known other profile → 409 + session_profile_mismatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(self, profile: str | None) -> None:
|
|
self.profile = profile
|
|
|
|
|
|
class _FakeHandler:
|
|
"""Minimal stand-in for the routes handler the helper writes through."""
|
|
|
|
def __init__(self) -> None:
|
|
self.writes: list[tuple[int, dict | str | None]] = []
|
|
|
|
|
|
@pytest.fixture
|
|
def helper_scope():
|
|
"""Build an exec scope with the helper's dependencies stubbed.
|
|
|
|
``get_session`` is the only thing the helper actually calls into
|
|
(besides the visibility predicate which is itself inlined). The
|
|
visibility predicate is monkey-patched per-test so we can drive both
|
|
the "match" and "mismatch" branches deterministically.
|
|
"""
|
|
scope: dict = {
|
|
# j / bad — the helper writes through these. Record instead of
|
|
# emit so the test can assert status + body.
|
|
"j": lambda handler, payload, status=200, **_: handler.writes.append((status, payload)),
|
|
"bad": lambda handler, msg, status=400: handler.writes.append((status, msg)),
|
|
# get_session is required so the helper can resolve the row.
|
|
"get_session": lambda sid, metadata_only=True: scope["_resolve"](sid),
|
|
# is_safe_session_id — accept anything that looks like a sid.
|
|
"is_safe_session_id": lambda sid: bool(sid) and len(str(sid)) < 256,
|
|
}
|
|
return scope
|
|
|
|
|
|
def test_profile_mismatch_returns_409_with_code_and_profile(helper_scope) -> None:
|
|
"""A session owned by a known other profile MUST yield 409 ``session_profile_mismatch``."""
|
|
handler = _FakeHandler()
|
|
helper_scope["_resolve"] = lambda sid: _FakeSession(profile="alpha")
|
|
# Visibility predicate: returns False (profile mismatch).
|
|
helper_scope["_session_visible_to_active_profile"] = lambda session_profile, _h: False
|
|
|
|
_exec_helper(helper_scope)
|
|
fn = helper_scope["_session_id_visible_to_request_profile"]
|
|
assert fn(handler, "sess-1") is False
|
|
assert len(handler.writes) == 1, handler.writes
|
|
status, body = handler.writes[0]
|
|
assert status == 409, body
|
|
assert body["code"] == "session_profile_mismatch"
|
|
assert body["session_id"] == "sess-1"
|
|
assert body["profile"] == "alpha"
|
|
assert "different profile" in body["error"].lower()
|
|
|
|
|
|
def test_unknown_profile_returns_404_for_frontend_self_heal(helper_scope) -> None:
|
|
"""A session with ``profile=None`` MUST keep 404 so the frontend's self-heal fires."""
|
|
handler = _FakeHandler()
|
|
helper_scope["_resolve"] = lambda sid: _FakeSession(profile=None)
|
|
helper_scope["_session_visible_to_active_profile"] = lambda session_profile, _h: False
|
|
|
|
_exec_helper(helper_scope)
|
|
fn = helper_scope["_session_id_visible_to_request_profile"]
|
|
assert fn(handler, "sess-1") is False
|
|
assert len(handler.writes) == 1, handler.writes
|
|
status, msg = handler.writes[0]
|
|
assert status == 404
|
|
assert msg == "Session not found"
|
|
|
|
|
|
def test_visible_session_returns_no_error(helper_scope) -> None:
|
|
"""A session owned by the active profile MUST pass the guard silently."""
|
|
handler = _FakeHandler()
|
|
helper_scope["_resolve"] = lambda sid: _FakeSession(profile="default")
|
|
helper_scope["_session_visible_to_active_profile"] = lambda session_profile, _h: True
|
|
|
|
_exec_helper(helper_scope)
|
|
fn = helper_scope["_session_id_visible_to_request_profile"]
|
|
assert fn(handler, "sess-1") is True
|
|
assert handler.writes == []
|
|
|
|
|
|
def test_missing_session_returns_no_error(helper_scope) -> None:
|
|
"""A session id that get_session cannot resolve MUST pass the guard silently (the route will 404 later)."""
|
|
handler = _FakeHandler()
|
|
helper_scope["_resolve"] = lambda sid: (_ for _ in ()).throw(KeyError(sid))
|
|
|
|
_exec_helper(helper_scope)
|
|
fn = helper_scope["_session_id_visible_to_request_profile"]
|
|
assert fn(handler, "sess-1") is True
|
|
assert handler.writes == []
|
|
|
|
|
|
def test_emit_error_false_suppresses_response(helper_scope) -> None:
|
|
"""``emit_error=False`` MUST return False without writing any response (used by the guard's exemption probe)."""
|
|
handler = _FakeHandler()
|
|
helper_scope["_resolve"] = lambda sid: _FakeSession(profile="alpha")
|
|
helper_scope["_session_visible_to_active_profile"] = lambda session_profile, _h: False
|
|
|
|
_exec_helper(helper_scope)
|
|
fn = helper_scope["_session_id_visible_to_request_profile"]
|
|
assert fn(handler, "sess-1", emit_error=False) is False
|
|
assert handler.writes == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Source-shape: the synthesised 409 payload MUST keep the four contract
|
|
# fields and the status code, in the helper. A silent reversion to the
|
|
# original single-line ``bad(..., 404)`` fails the suite.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_source_emits_409_not_404_in_helper() -> None:
|
|
"""The helper body MUST contain a 409 ``session_profile_mismatch`` branch.
|
|
|
|
Catches a future regression that folds the two cases back together
|
|
(or drops the 409 status code in favour of 404). The 404 case is
|
|
still preserved for the None-profile self-heal path, but only as a
|
|
fall-through inside the same block — not as the sole outcome.
|
|
"""
|
|
body = _read_helper_body()
|
|
assert "status=409" in body, (
|
|
"the helper no longer emits 409 for a known-other-profile session"
|
|
)
|
|
assert "session_profile_mismatch" in body, (
|
|
"the helper no longer carries the ``session_profile_mismatch`` code"
|
|
)
|
|
assert "Session not found" in body, (
|
|
"the None-profile 404 self-heal path was dropped — "
|
|
"keep the 404 for the None-profile branch so the frontend "
|
|
"self-heal still fires for actually-missing sids"
|
|
)
|