mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -646,6 +646,7 @@ class TestListWorkstreamsTrustedTeamVisibility:
|
||||
"kind",
|
||||
"parent_ws_id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
}
|
||||
assert row["kind"] == "interactive"
|
||||
assert row["user_id"] == "user-shape"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user