fix(projects): full-suite findings — type-guard the visibility gate, bind acting user without breaking send stubs

ws_visible only treats real strings as project links (a test double or
corrupted value means no-project, not private-and-denied), the mgr-path
project_id is coerced likewise, and the HTTP send path binds the acting
user via a getattr-guarded bind_acting_user call inside the fresh-turn
closure instead of a send() kwarg — per-kind session stubs with explicit
send signatures keep working. Row-shape contract tests (interactive +
coordinator twins) grow the intentional project_id key.
This commit is contained in:
Patrick Buckley
2026-07-01 15:59:31 -07:00
parent bf9299de1a
commit 80b8997b88
6 changed files with 29 additions and 9 deletions
+3 -1
View File
@@ -4,7 +4,7 @@ import asyncio
import json
import queue
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import ANY, MagicMock
import pytest
@@ -1046,6 +1046,8 @@ class TestConsoleHTTPEndpoints:
page=1,
per_page=25,
extra_rows=[],
# Per-request private-project tenancy closure — identity varies.
row_filter=ANY,
)
def test_get_workstreams_per_page_capped(self, client, mock_collector):
+1
View File
@@ -520,6 +520,7 @@ def test_active_list_row_shape_includes_unified_fields(storage):
"kind",
"parent_ws_id",
"user_id",
"project_id",
}
assert row["name"] == "lifted-coord"
assert row["kind"] == "coordinator"
+1
View File
@@ -646,6 +646,7 @@ class TestListWorkstreamsTrustedTeamVisibility:
"kind",
"parent_ws_id",
"user_id",
"project_id",
}
assert row["kind"] == "interactive"
assert row["user_id"] == "user-shape"
+7 -1
View File
@@ -333,7 +333,13 @@ class WorkstreamProjectVisibility:
"""Apply the class rules to one workstream row."""
if self._bypass:
return True
pid = (project_id or "").strip()
# Only a real string can name a project — anything else (None,
# a test double, a corrupted row) means "no project link", not
# "private". Keeps the fail-closed branch for genuine lookup
# failures rather than type noise.
if not project_id or not isinstance(project_id, str):
return True
pid = project_id.strip()
if not pid:
return True
if ws_owner and ws_owner == self._user_id:
+15 -6
View File
@@ -1543,12 +1543,14 @@ def make_retry_handler(
# A retry is a fresh turn initiated by the authenticated caller —
# rebind per-user MCP credential resolution to them before the
# re-send dispatches (the per-kind ``dispatch_retry`` closure
# calls ``send()`` without identity kwargs).
# calls ``send()`` without identity kwargs). getattr-guarded so
# per-kind session stubs without the method keep working.
from turnstone.core.web_helpers import auth_user_id
acting_uid = auth_user_id(request)
if acting_uid:
session.bind_acting_user(acting_uid)
bind_acting = getattr(session, "bind_acting_user", None)
if acting_uid and callable(bind_acting):
bind_acting(acting_uid)
retry_msg = session.retry()
@@ -2655,7 +2657,8 @@ def make_list_handler(cfg: SessionEndpointConfig) -> Handler:
titles = resolve_titles([ws.id for ws in wss])
rows: list[dict[str, Any]] = []
for ws in wss:
project_id = getattr(ws, "project_id", "") or ""
raw_pid = getattr(ws, "project_id", "")
project_id = raw_pid if isinstance(raw_pid, str) else ""
# Private-project tenancy — drop rows the requester may
# not see (same predicate as the saved list).
if not visibility.ws_visible(project_id, ws_owner=ws.user_id or ""):
@@ -3797,8 +3800,14 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
kwargs["attachments"] = resolved_atts
if send_id:
kwargs["send_id"] = send_id
if acting_uid:
kwargs["acting_user_id"] = acting_uid
# Fresh turn: rebind per-user MCP credentials to the
# authenticated sender. Bound here (not via a send()
# kwarg) so per-kind session stubs with explicit send
# signatures keep working; getattr-guarded for the same
# reason. The queue path above never rebinds.
bind = getattr(session, "bind_acting_user", None)
if acting_uid and callable(bind):
bind(acting_uid)
session.send(message, **kwargs)
except GenerationCancelled:
# Safety net — send() normally handles this internally.
+2 -1
View File
@@ -349,7 +349,8 @@ def resolve_workstream_owner(
ws_mem = mgr.get(ws_id)
if ws_mem is not None:
owner = ws_mem.user_id or ""
project_id = getattr(ws_mem, "project_id", "") or ""
raw_pid = getattr(ws_mem, "project_id", "")
project_id = raw_pid if isinstance(raw_pid, str) else ""
if owner is None:
# Not in memory — storage resolves persisted-but-not-loaded rows.