mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
36419a9809
Workstreams attached to a private project were visible -- including their conversation content -- to holders of admin.cluster.inspect / admin.coordinator (both default builtin-admin permissions), defeating the project's confidentiality boundary. Enforce that a private project's resources are visible only to people IN the project (owner, workstream creator, or an explicit member), even for admins. Surfaces closed: - WorkstreamProjectVisibility bypass narrowed to service scope only (node->console machine plumbing, re-filtered per-user at the console edge). No human principal bypasses; admin.cluster.inspect gates the inspect surfaces, not tenancy. This flows to /dashboard, session listings, the attachment row-gate, cluster_workstreams, cluster_node_detail, and cluster_snapshot/SSE. - cluster_ws_detail 404-masks a workstream in a private project the caller can't see; cluster_ws_live_bulk routes such ids to the denied list (no private-project oracle). - Coordinator operator verbs (history/export/detail/send/approve/set_title/open/ children/tasks/attachments) now enforce project tenancy: _coordinator_tenant_check on coord_endpoint_config, the gate in _resolve_coordinator_or_404 (children/tasks), the tenant_check now run in make_open_handler before rehydrate, and a project-visibility check in _coord_attachment_owner. admin.coordinator gates the surface cluster-wide, but a non-member is 404-masked. The tenant-check mirrors the manager-first + coordinator-kind ladder so kind-isolation is preserved. - service scope is no longer user-assignable: admin_create_token and both turnstone-admin CLI mint paths reject it via reject_unassignable_scopes, so an admin.users holder cannot self-mint a service token and restore the bypass. Service scope is minted only by ServiceTokenManager / the JWT secret. - The events/global node proxy (service-elevated cross-tenant firehose) is gated on admin.cluster.inspect so a plain authenticated user cannot reach it through the console proxy. Updates the OpenAPI description, the row-gate/tenancy-filter docstrings, and adds tests for every surface (visibility predicate + cluster detail/bulk + coordinator history/export/children/open/attachments + events/global proxy + scope-mint rejection); inverts the tests that pinned the old admin-bypass contract.
551 lines
22 KiB
Python
551 lines
22 KiB
Python
"""Private-project workstream visibility enforcement.
|
|
|
|
Covers the tenancy predicate (:class:`WorkstreamProjectVisibility`), the
|
|
create-time attach gate (:func:`ensure_project_attachable`), the row-access
|
|
gate in :func:`resolve_workstream_owner`, and the saved-list filter in
|
|
``_collect_saved_rows`` — the choke points that keep workstreams attached
|
|
to a private project out of non-members' listings and 403 their direct
|
|
access.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.auth import (
|
|
WorkstreamProjectVisibility,
|
|
ensure_project_attachable,
|
|
)
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
def _fake_storage(
|
|
*,
|
|
visibility: str = "private",
|
|
owner: str = "alice",
|
|
members: tuple[str, ...] = (),
|
|
missing: bool = False,
|
|
) -> MagicMock:
|
|
storage = MagicMock()
|
|
if missing:
|
|
storage.get_project.return_value = None
|
|
else:
|
|
storage.get_project.return_value = {
|
|
"project_id": "p1",
|
|
"name": "P1",
|
|
"owner_id": owner,
|
|
"visibility": visibility,
|
|
"state": "active",
|
|
}
|
|
storage.is_project_member.side_effect = lambda pid, uid: uid in members
|
|
return storage
|
|
|
|
|
|
class _FakeAuth:
|
|
def __init__(
|
|
self,
|
|
user_id: str,
|
|
scopes: tuple[str, ...] = (),
|
|
permissions: tuple[str, ...] = (),
|
|
) -> None:
|
|
self.user_id = user_id
|
|
self._scopes = set(scopes)
|
|
self._permissions = set(permissions)
|
|
|
|
def has_scope(self, scope: str) -> bool:
|
|
return scope in self._scopes
|
|
|
|
def has_permission(self, permission: str) -> bool:
|
|
return permission in self._permissions
|
|
|
|
|
|
def _request_for(
|
|
uid: str,
|
|
scopes: tuple[str, ...] = (),
|
|
permissions: tuple[str, ...] = (),
|
|
) -> Any:
|
|
return SimpleNamespace(state=SimpleNamespace(auth_result=_FakeAuth(uid, scopes, permissions)))
|
|
|
|
|
|
class TestWsVisiblePredicate:
|
|
def test_no_project_always_visible(self) -> None:
|
|
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage())
|
|
assert vis.ws_visible(None)
|
|
assert vis.ws_visible("")
|
|
|
|
def test_dangling_project_visible(self) -> None:
|
|
# Project deletion leaves ws links behind — no row, no privacy.
|
|
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage(missing=True))
|
|
assert vis.ws_visible("p1")
|
|
|
|
def test_public_project_visible_to_anyone(self) -> None:
|
|
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage(visibility="public"))
|
|
assert vis.ws_visible("p1")
|
|
|
|
def test_private_hidden_from_non_member(self) -> None:
|
|
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage())
|
|
assert not vis.ws_visible("p1")
|
|
|
|
def test_private_visible_to_project_owner(self) -> None:
|
|
vis = WorkstreamProjectVisibility("alice", storage=_fake_storage())
|
|
assert vis.ws_visible("p1")
|
|
|
|
def test_private_visible_to_member(self) -> None:
|
|
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage(members=("bob",)))
|
|
assert vis.ws_visible("p1")
|
|
|
|
def test_private_visible_to_ws_creator(self) -> None:
|
|
# A workstream's own creator never loses sight of it, even after
|
|
# a membership revoke leaves a legacy private-project link.
|
|
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage())
|
|
assert vis.ws_visible("p1", ws_owner="bob")
|
|
|
|
def test_private_hidden_from_anonymous(self) -> None:
|
|
vis = WorkstreamProjectVisibility("", storage=_fake_storage())
|
|
assert not vis.ws_visible("p1")
|
|
|
|
def test_bypass_sees_everything(self) -> None:
|
|
vis = WorkstreamProjectVisibility("bob", bypass=True, storage=_fake_storage())
|
|
assert vis.ws_visible("p1")
|
|
|
|
def test_storage_error_fails_closed(self) -> None:
|
|
storage = MagicMock()
|
|
storage.get_project.side_effect = RuntimeError("db down")
|
|
vis = WorkstreamProjectVisibility("bob", storage=storage)
|
|
assert not vis.ws_visible("p1")
|
|
|
|
def test_project_rows_memoized(self) -> None:
|
|
storage = _fake_storage(visibility="public")
|
|
vis = WorkstreamProjectVisibility("bob", storage=storage)
|
|
assert vis.ws_visible("p1")
|
|
assert vis.ws_visible("p1")
|
|
assert storage.get_project.call_count == 1
|
|
|
|
def test_for_request_bypass_rules(self) -> None:
|
|
# Only service scope bypasses (node→console machine plumbing,
|
|
# re-filtered per-user at the console edge).
|
|
assert WorkstreamProjectVisibility.for_request(
|
|
_request_for("bob", scopes=("service",))
|
|
)._bypass
|
|
# admin.cluster.inspect gates the inspect *surfaces* but does NOT
|
|
# bypass private-project tenancy — the admin filters as themselves.
|
|
assert not WorkstreamProjectVisibility.for_request(
|
|
_request_for("bob", permissions=("admin.cluster.inspect",))
|
|
)._bypass
|
|
assert not WorkstreamProjectVisibility.for_request(_request_for("bob"))._bypass
|
|
|
|
|
|
class TestEnsureProjectAttachable:
|
|
def test_no_project_allowed(self) -> None:
|
|
assert ensure_project_attachable("bob", "", storage=_fake_storage()) is None
|
|
|
|
def test_unknown_project_is_400(self) -> None:
|
|
denied = ensure_project_attachable("bob", "p1", storage=_fake_storage(missing=True))
|
|
assert denied is not None and denied[0] == 400
|
|
|
|
def test_public_project_allowed(self) -> None:
|
|
assert (
|
|
ensure_project_attachable("bob", "p1", storage=_fake_storage(visibility="public"))
|
|
is None
|
|
)
|
|
|
|
def test_private_member_and_owner_allowed(self) -> None:
|
|
assert (
|
|
ensure_project_attachable("bob", "p1", storage=_fake_storage(members=("bob",))) is None
|
|
)
|
|
assert ensure_project_attachable("alice", "p1", storage=_fake_storage()) is None
|
|
|
|
def test_private_non_member_is_403(self) -> None:
|
|
denied = ensure_project_attachable("bob", "p1", storage=_fake_storage())
|
|
assert denied is not None and denied[0] == 403
|
|
|
|
def test_anonymous_private_is_403(self) -> None:
|
|
denied = ensure_project_attachable("", "p1", storage=_fake_storage())
|
|
assert denied is not None and denied[0] == 403
|
|
|
|
def test_storage_error_fails_closed(self) -> None:
|
|
storage = MagicMock()
|
|
storage.get_project.side_effect = RuntimeError("db down")
|
|
denied = ensure_project_attachable("bob", "p1", storage=storage)
|
|
assert denied is not None and denied[0] == 403
|
|
|
|
|
|
class TestResolveWorkstreamOwnerProjectGate:
|
|
"""Integration against the real (ephemeral) storage: the row-access
|
|
gate every interactive ws-scoped verb inherits via tenant_check."""
|
|
|
|
def _seed(self, *, member: bool) -> None:
|
|
from turnstone.core.memory import register_workstream
|
|
from turnstone.core.storage import get_storage
|
|
|
|
storage = get_storage()
|
|
storage.create_project("p1", "Secret", "alice")
|
|
if member:
|
|
storage.add_project_member("p1", "bob")
|
|
register_workstream("ws-priv", user_id="alice", project_id="p1")
|
|
|
|
def test_non_member_gets_403(self, tmp_db: str) -> None:
|
|
from turnstone.core.web_helpers import resolve_workstream_owner
|
|
|
|
self._seed(member=False)
|
|
owner, err = resolve_workstream_owner(_request_for("bob"), "ws-priv")
|
|
assert err is not None and err.status_code == 403
|
|
|
|
def test_member_resolves_owner(self, tmp_db: str) -> None:
|
|
from turnstone.core.web_helpers import resolve_workstream_owner
|
|
|
|
self._seed(member=True)
|
|
owner, err = resolve_workstream_owner(_request_for("bob"), "ws-priv")
|
|
assert err is None
|
|
assert owner == "alice"
|
|
|
|
def test_ws_creator_bypasses(self, tmp_db: str) -> None:
|
|
from turnstone.core.memory import register_workstream
|
|
from turnstone.core.storage import get_storage
|
|
from turnstone.core.web_helpers import resolve_workstream_owner
|
|
|
|
storage = get_storage()
|
|
storage.create_project("p1", "Secret", "alice")
|
|
# bob created a ws in alice's private project, then lost access —
|
|
# bob still reaches his own workstream.
|
|
register_workstream("ws-bob", user_id="bob", project_id="p1")
|
|
owner, err = resolve_workstream_owner(_request_for("bob"), "ws-bob")
|
|
assert err is None
|
|
assert owner == "bob"
|
|
|
|
def test_admin_inspect_does_not_bypass(self, tmp_db: str) -> None:
|
|
# A permitted admin (admin.cluster.inspect) who isn't the owner /
|
|
# creator / member of a private project is still 403'd at the row
|
|
# gate — the permission gates the inspect surface, not the tenancy.
|
|
from turnstone.core.web_helpers import resolve_workstream_owner
|
|
|
|
self._seed(member=False)
|
|
owner, err = resolve_workstream_owner(
|
|
_request_for("bob", permissions=("admin.cluster.inspect",)), "ws-priv"
|
|
)
|
|
assert err is not None and err.status_code == 403
|
|
|
|
def test_missing_ws_still_404s(self, tmp_db: str) -> None:
|
|
from turnstone.core.web_helpers import resolve_workstream_owner
|
|
|
|
owner, err = resolve_workstream_owner(_request_for("bob"), "nope")
|
|
assert err is not None and err.status_code == 404
|
|
|
|
def test_public_project_ws_resolves(self, tmp_db: str) -> None:
|
|
from turnstone.core.memory import register_workstream
|
|
from turnstone.core.storage import get_storage
|
|
from turnstone.core.web_helpers import resolve_workstream_owner
|
|
|
|
storage = get_storage()
|
|
storage.create_project("p1", "Open", "alice")
|
|
storage.update_project("p1", visibility="public")
|
|
register_workstream("ws-pub", user_id="alice", project_id="p1")
|
|
owner, err = resolve_workstream_owner(_request_for("bob"), "ws-pub")
|
|
assert err is None
|
|
assert owner == "alice"
|
|
|
|
|
|
class TestSavedListFilter:
|
|
"""The saved-sessions collector drops private-project rows server-side
|
|
and carries project_id on surviving rows (real ephemeral DB)."""
|
|
|
|
async def test_saved_rows_filtered_and_carry_project_id(self, tmp_db: str) -> None:
|
|
from turnstone.core.memory import register_workstream, save_message
|
|
from turnstone.core.session_routes import (
|
|
SessionEndpointConfig,
|
|
_collect_saved_rows,
|
|
)
|
|
from turnstone.core.storage import get_storage
|
|
from turnstone.core.workstream import WorkstreamKind
|
|
|
|
storage = get_storage()
|
|
storage.create_project("p1", "Secret", "alice")
|
|
storage.create_project("p2", "Open", "alice")
|
|
storage.update_project("p2", visibility="public")
|
|
|
|
register_workstream("ws-plain", user_id="alice")
|
|
register_workstream("ws-priv", user_id="alice", project_id="p1")
|
|
register_workstream("ws-pub", user_id="alice", project_id="p2")
|
|
register_workstream("ws-own", user_id="bob", project_id="p1")
|
|
for wid in ("ws-plain", "ws-priv", "ws-pub", "ws-own"):
|
|
save_message(wid, "user", "hello")
|
|
|
|
cfg = SessionEndpointConfig(
|
|
permission_gate=None,
|
|
manager_lookup=lambda request: (None, None),
|
|
tenant_check=None,
|
|
not_found_label="Workstream not found",
|
|
audit_action_prefix="workstream",
|
|
list_kind=WorkstreamKind.INTERACTIVE,
|
|
saved_state_filter=None,
|
|
saved_loaded_lookup=None,
|
|
)
|
|
|
|
rows = await _collect_saved_rows(cfg, _request_for("bob"))
|
|
ids = {r["ws_id"] for r in rows}
|
|
# bob: no membership in p1 — alice's private ws is dropped; the
|
|
# public-project ws, the project-less ws, and bob's own
|
|
# private-project ws all survive.
|
|
assert ids == {"ws-plain", "ws-pub", "ws-own"}
|
|
by_id = {r["ws_id"]: r for r in rows}
|
|
assert by_id["ws-pub"]["project_id"] == "p2"
|
|
assert by_id["ws-plain"]["project_id"] is None
|
|
|
|
rows_alice = await _collect_saved_rows(cfg, _request_for("alice"))
|
|
assert {r["ws_id"] for r in rows_alice} == {"ws-plain", "ws-priv", "ws-pub", "ws-own"}
|
|
|
|
|
|
class TestTriStateVisibility:
|
|
def test_undetermined_on_storage_error(self) -> None:
|
|
storage = MagicMock()
|
|
storage.get_project.side_effect = RuntimeError("db down")
|
|
vis = WorkstreamProjectVisibility("bob", storage=storage)
|
|
assert vis.ws_visibility("p1") is None
|
|
# The boolean form stays fail-closed.
|
|
assert vis.ws_visible("p1") is False
|
|
|
|
def test_definitive_verdicts(self) -> None:
|
|
assert (
|
|
WorkstreamProjectVisibility(
|
|
"bob", storage=_fake_storage(visibility="public")
|
|
).ws_visibility("p1")
|
|
is True
|
|
)
|
|
assert (
|
|
WorkstreamProjectVisibility("bob", storage=_fake_storage()).ws_visibility("p1") is False
|
|
)
|
|
|
|
|
|
class _ScriptedVis:
|
|
"""ws_visibility stub: per-pid verdict, or a list consumed per call."""
|
|
|
|
def __init__(self, verdicts: dict, bypass: bool = False) -> None:
|
|
self.verdicts = dict(verdicts)
|
|
self.bypass = bypass
|
|
self.calls = 0
|
|
|
|
def ws_visibility(self, pid, ws_owner=""):
|
|
self.calls += 1
|
|
v = self.verdicts.get(pid or "", True)
|
|
if isinstance(v, list):
|
|
return v.pop(0) if len(v) > 1 else v[0]
|
|
return v
|
|
|
|
|
|
class TestClusterTenancyFilter:
|
|
def _snap(self):
|
|
return {
|
|
"nodes": [
|
|
{
|
|
"node_id": "node-a",
|
|
"workstreams": [
|
|
{"ws_id": "w-vis", "state": "running", "project_id": "", "user_id": "a"},
|
|
{"ws_id": "w-priv", "state": "running", "project_id": "ph", "user_id": "a"},
|
|
],
|
|
}
|
|
],
|
|
"overview": {
|
|
"nodes": 1,
|
|
"workstreams": 2,
|
|
"states": {"running": 2, "thinking": 0, "idle": 0},
|
|
},
|
|
}
|
|
|
|
def test_snapshot_filters_rows_and_rederives_overview(self) -> None:
|
|
from turnstone.console.server import _ClusterTenancyFilter
|
|
|
|
filt = _ClusterTenancyFilter(_ScriptedVis({"ph": False}))
|
|
snap = filt.filter_snapshot(self._snap())
|
|
assert [w["ws_id"] for w in snap["nodes"][0]["workstreams"]] == ["w-vis"]
|
|
# Overview no longer leaks the hidden row's existence or state.
|
|
assert snap["overview"]["workstreams"] == 1
|
|
assert snap["overview"]["states"] == {"running": 1, "thinking": 0, "idle": 0}
|
|
# Later sparse events for the hidden ws are suppressed.
|
|
assert filt.event_visible({"type": "cluster_state", "ws_id": "w-priv"}) is False
|
|
assert filt.event_visible({"type": "cluster_state", "ws_id": "w-vis"}) is True
|
|
|
|
def test_bypass_leaves_snapshot_untouched(self) -> None:
|
|
from turnstone.console.server import _ClusterTenancyFilter
|
|
|
|
filt = _ClusterTenancyFilter(_ScriptedVis({"ph": False}, bypass=True))
|
|
snap = filt.filter_snapshot(self._snap())
|
|
assert len(snap["nodes"][0]["workstreams"]) == 2
|
|
assert snap["overview"]["workstreams"] == 2 # collector aggregate preserved
|
|
assert filt.event_visible({"type": "cluster_state", "ws_id": "w-priv"}) is True
|
|
assert filt.event_touches_storage({"type": "ws_created", "ws_id": "x"}) is False
|
|
|
|
def test_ws_created_judged_and_closed_cleans_up(self) -> None:
|
|
from turnstone.console.server import _ClusterTenancyFilter
|
|
|
|
filt = _ClusterTenancyFilter(_ScriptedVis({"ph": False}))
|
|
created = {"type": "ws_created", "ws_id": "w1", "project_id": "ph", "user_id": "b"}
|
|
assert filt.event_visible(created) is False
|
|
assert filt.event_visible({"type": "ws_rename", "ws_id": "w1"}) is False
|
|
# The close of a never-shown workstream is itself suppressed…
|
|
assert filt.event_visible({"type": "ws_closed", "ws_id": "w1"}) is False
|
|
# …and the state is cleaned, so an unrelated later event passes.
|
|
assert filt.event_visible({"type": "cluster_state", "ws_id": "w1"}) is True
|
|
|
|
def test_undetermined_suppresses_then_retries(self) -> None:
|
|
from turnstone.console.server import _ClusterTenancyFilter
|
|
|
|
vis = _ScriptedVis({"pu": [None, True]})
|
|
filt = _ClusterTenancyFilter(vis)
|
|
created = {"type": "ws_created", "ws_id": "w1", "project_id": "pu", "user_id": "b"}
|
|
# Storage blip: suppressed but NOT pinned hidden.
|
|
assert filt.event_visible(created) is False
|
|
assert "w1" in filt._unresolved
|
|
# Within the retry interval later events stay suppressed without
|
|
# re-hitting storage.
|
|
calls_before = vis.calls
|
|
assert filt.event_visible({"type": "cluster_state", "ws_id": "w1"}) is False
|
|
assert vis.calls == calls_before
|
|
# Past the interval the row is re-judged and recovers.
|
|
filt._RETRY_INTERVAL_S = 0.0
|
|
filt._retry_after["w1"] = 0.0
|
|
assert filt.event_touches_storage({"type": "cluster_state", "ws_id": "w1"}) is True
|
|
assert filt.event_visible({"type": "cluster_state", "ws_id": "w1"}) is True
|
|
assert "w1" not in filt._unresolved
|
|
|
|
def test_denied_verdict_pins_hidden(self) -> None:
|
|
from turnstone.console.server import _ClusterTenancyFilter
|
|
|
|
vis = _ScriptedVis({"pu": [None, False]})
|
|
filt = _ClusterTenancyFilter(vis)
|
|
filt._RETRY_INTERVAL_S = 0.0
|
|
assert (
|
|
filt.event_visible(
|
|
{"type": "ws_created", "ws_id": "w1", "project_id": "pu", "user_id": "b"}
|
|
)
|
|
is False
|
|
)
|
|
filt._retry_after["w1"] = 0.0
|
|
assert filt.event_visible({"type": "cluster_state", "ws_id": "w1"}) is False
|
|
assert "w1" in filt._hidden and "w1" not in filt._unresolved
|
|
|
|
|
|
class TestCreateValidatorProjectGate:
|
|
"""The interactive create validator's attach gate: explicit ids are
|
|
strict, inherited ids tolerate a deleted project (real ephemeral DB)."""
|
|
|
|
async def test_inherited_dangling_project_is_stripped(self, tmp_db: str) -> None:
|
|
from turnstone.core.memory import register_workstream
|
|
from turnstone.server import _interactive_create_validate_request
|
|
|
|
register_workstream("coord-1", user_id="alice", kind="coordinator", project_id="p-gone")
|
|
body: dict = {"kind": "interactive", "parent_ws_id": "coord-1"}
|
|
err = await _interactive_create_validate_request(MagicMock(), body, "alice", [])
|
|
assert err is None
|
|
assert (body.get("project_id") or "") == ""
|
|
|
|
async def test_explicit_unknown_project_still_400s(self, tmp_db: str) -> None:
|
|
from turnstone.server import _interactive_create_validate_request
|
|
|
|
body: dict = {"kind": "interactive", "project_id": "nope"}
|
|
err = await _interactive_create_validate_request(MagicMock(), body, "alice", [])
|
|
assert err is not None and err.status_code == 400
|
|
|
|
async def test_inherited_private_revoked_membership_403s(self, tmp_db: str) -> None:
|
|
from turnstone.core.memory import register_workstream
|
|
from turnstone.core.storage import get_storage
|
|
from turnstone.server import _interactive_create_validate_request
|
|
|
|
get_storage().create_project("p-priv", "P", "zed")
|
|
register_workstream("coord-2", user_id="alice", kind="coordinator", project_id="p-priv")
|
|
body: dict = {"kind": "interactive", "parent_ws_id": "coord-2"}
|
|
err = await _interactive_create_validate_request(MagicMock(), body, "alice", [])
|
|
assert err is not None and err.status_code == 403
|
|
|
|
async def test_inherited_accessible_project_passes(self, tmp_db: str) -> None:
|
|
from turnstone.core.memory import register_workstream
|
|
from turnstone.core.storage import get_storage
|
|
from turnstone.server import _interactive_create_validate_request
|
|
|
|
storage = get_storage()
|
|
storage.create_project("p-ok", "P", "zed")
|
|
storage.add_project_member("p-ok", "alice")
|
|
register_workstream("coord-3", user_id="alice", kind="coordinator", project_id="p-ok")
|
|
body: dict = {"kind": "interactive", "parent_ws_id": "coord-3"}
|
|
err = await _interactive_create_validate_request(MagicMock(), body, "alice", [])
|
|
assert err is None
|
|
assert body["project_id"] == "p-ok"
|
|
|
|
|
|
class TestSavedListPagination:
|
|
"""The saved-list collector pages past invisible rows instead of
|
|
letting a post-SQL filter shrink the window."""
|
|
|
|
def _row(self, i: int, project_id: str | None) -> tuple:
|
|
return (
|
|
f"ws-{i:03d}",
|
|
None,
|
|
None,
|
|
f"n{i}",
|
|
"2026-01-01T00:00:00",
|
|
f"{99999 - i}", # updated: descending with i
|
|
1,
|
|
"node-a",
|
|
"idle",
|
|
"interactive",
|
|
None,
|
|
None,
|
|
0,
|
|
0,
|
|
None,
|
|
project_id,
|
|
"alice",
|
|
None, # persona
|
|
)
|
|
|
|
def _cfg(self):
|
|
from turnstone.core.session_routes import SessionEndpointConfig
|
|
from turnstone.core.workstream import WorkstreamKind
|
|
|
|
return SessionEndpointConfig(
|
|
permission_gate=None,
|
|
manager_lookup=lambda request: (None, None),
|
|
tenant_check=None,
|
|
not_found_label="Workstream not found",
|
|
audit_action_prefix="workstream",
|
|
list_kind=WorkstreamKind.INTERACTIVE,
|
|
saved_state_filter=None,
|
|
saved_loaded_lookup=None,
|
|
)
|
|
|
|
def _patch(self, monkeypatch: pytest.MonkeyPatch, rows: list) -> None:
|
|
def _fake(limit=20, *, kind=None, user_id=None, state=None, offset=0):
|
|
return rows[offset : offset + limit]
|
|
|
|
monkeypatch.setattr("turnstone.core.memory.list_workstreams_with_history", _fake)
|
|
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage()) # denies any pid
|
|
monkeypatch.setattr(
|
|
WorkstreamProjectVisibility,
|
|
"for_request",
|
|
classmethod(lambda cls, request, storage=None: vis),
|
|
)
|
|
|
|
async def test_pages_past_invisible_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from turnstone.core.session_routes import _collect_saved_rows
|
|
|
|
rows = [self._row(i, "ph") for i in range(60)] + [
|
|
self._row(i, None) for i in range(60, 130)
|
|
]
|
|
self._patch(monkeypatch, rows)
|
|
result = await _collect_saved_rows(self._cfg(), MagicMock())
|
|
assert len(result) == 50
|
|
assert result[0]["ws_id"] == "ws-060"
|
|
assert result[-1]["ws_id"] == "ws-109"
|
|
|
|
async def test_scan_cap_terminates(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from turnstone.core.session_routes import _collect_saved_rows
|
|
|
|
rows = [self._row(i, "ph") for i in range(5000)]
|
|
self._patch(monkeypatch, rows)
|
|
result = await _collect_saved_rows(self._cfg(), MagicMock())
|
|
assert result == []
|