mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-17 09:21:34 -06:00
ed7823273b
PR 447 / 93cb3d9 (Copilot autonomous follow-up) added a JS path that
reads ``wsSnapshot.pending_approval_detail`` off the
``GET /v1/api/workstreams/{ws_id}`` snapshot in coordinator.js
init() so a freshly-loaded chat tab can paint the inline approval
gate immediately at reload, without waiting for the SSE
approve_request replay (which leaves a brief --running flash on the
inflight orphan placeholder).
But the server's ``WorkstreamDetailResponse`` schema only declared
``{ws_id, name, state, user_id, kind}`` and the lifted
``make_detail_handler`` matched: nothing was populating
``pending_approval`` or ``pending_approval_detail`` on the wire.
The frontend block silently no-op'd at runtime; Copilot's
accompanying assertion only grep'd the JS source for the literal
strings, so it stayed green while the actual contract was missing.
Extend the contract to match the Copilot frontend:
- Add ``pending_approval: bool`` + ``pending_approval_detail:
PendingApprovalDetail | None`` to ``WorkstreamDetailResponse``,
same shape as the dashboard / cluster live projection.
- ``make_detail_handler`` reads ``ws.ui._pending_approval`` (only
treats it as live when ``isinstance(_, dict)`` so MagicMock-
based unit tests don't trip the path) and calls
``ui.serialize_pending_approval_detail()`` to fill the detail.
A serializer raise falls back to ``pending_approval=True`` +
``detail=None`` instead of 500ing the whole response — SSE
replay still carries the authoritative payload.
- ``test_returns_workstream_fields`` updated for the two extra
fields (False / None on a MagicMock UI).
- ``test_pending_approval_fields_propagate_from_ui`` is the new
behavioural test: stub a UI with a realistic
``_pending_approval`` dict + serializer return, assert the JSON
surfaces ``pending_approval=True`` + the items list.
- ``test_pending_serializer_failure_falls_back_to_bool_only``
pins the defensive degradation so a future serializer
regression can't 500 every reload.
Tests: 4822 pass (3 deselected live). Ruff + mypy clean.
1087 lines
43 KiB
Python
1087 lines
43 KiB
Python
"""Tests for workstream management endpoints added in PRs #314-#315."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
if TYPE_CHECKING:
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
from turnstone.core.auth import AuthResult
|
|
from turnstone.core.session_routes import (
|
|
SessionEndpointConfig,
|
|
make_detail_handler,
|
|
make_history_handler,
|
|
make_open_handler,
|
|
)
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
from turnstone.core.workstream import WorkstreamKind
|
|
from turnstone.server import (
|
|
delete_workstream_endpoint,
|
|
list_interface_settings,
|
|
refresh_workstream_title,
|
|
set_workstream_title,
|
|
update_interface_setting,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth bypass middleware
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
request.state.auth_result = AuthResult(
|
|
user_id="test-user",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="config",
|
|
permissions=frozenset({"read", "write", "approve"}),
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
@pytest.fixture
|
|
def _inject_storage(storage):
|
|
"""Swap global storage registry for the test backend."""
|
|
import turnstone.core.storage._registry as reg
|
|
|
|
old = reg._storage
|
|
reg._storage = storage
|
|
yield storage
|
|
reg._storage = old
|
|
|
|
|
|
@pytest.fixture
|
|
def delete_client(_inject_storage):
|
|
"""Return ``(TestClient, Starlette app)`` for the delete endpoint.
|
|
|
|
``app.state.auth_storage`` is pre-attached so the endpoint's
|
|
pre-delete snapshot block runs (without it the snapshot is
|
|
skipped and the lifecycle event lands with ``name=""``). Tests
|
|
that need a wired manager attach ``app.state.workstreams = mgr``
|
|
on the returned app; tests that don't care just ignore the
|
|
second tuple member.
|
|
"""
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route(
|
|
"/api/workstreams/{ws_id}/delete",
|
|
delete_workstream_endpoint,
|
|
methods=["POST"],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = _inject_storage
|
|
return TestClient(app), app
|
|
|
|
|
|
@pytest.fixture
|
|
def title_client(_inject_storage):
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route(
|
|
"/api/workstreams/{ws_id}/title",
|
|
set_workstream_title,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/api/workstreams/{ws_id}/refresh-title",
|
|
refresh_workstream_title,
|
|
methods=["POST"],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
mock_mgr = MagicMock()
|
|
app.state.workstreams = mock_mgr
|
|
return TestClient(app), mock_mgr
|
|
|
|
|
|
@pytest.fixture
|
|
def open_client(_inject_storage):
|
|
"""Build a TestClient with the lifted ``open`` handler wired the
|
|
same way ``server.py`` does — alias resolver, no post-load
|
|
callback (the tests assert HTTP-shape only, not the SSE replay).
|
|
|
|
The alias resolver is wrapped in a lazy lookup so each test's
|
|
``@patch("turnstone.core.memory.resolve_workstream")`` is
|
|
visible at request time. A direct function reference would
|
|
bind to the unpatched original at fixture-construction time.
|
|
"""
|
|
|
|
def _lazy_alias_resolver(ws_id: str) -> str | None:
|
|
from turnstone.core.memory import resolve_workstream
|
|
|
|
return resolve_workstream(ws_id)
|
|
|
|
mock_mgr = MagicMock()
|
|
cfg = SessionEndpointConfig(
|
|
permission_gate=None,
|
|
manager_lookup=lambda _r: (mock_mgr, None),
|
|
tenant_check=None,
|
|
not_found_label="Workstream not found",
|
|
audit_action_prefix="workstream",
|
|
open_resolve_alias=_lazy_alias_resolver,
|
|
)
|
|
open_handler = make_open_handler(cfg)
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route(
|
|
"/api/workstreams/{ws_id}/open",
|
|
open_handler,
|
|
methods=["POST"],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.workstreams = mock_mgr
|
|
gq: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
app.state.global_queue = gq
|
|
return TestClient(app), mock_mgr, gq
|
|
|
|
|
|
@pytest.fixture
|
|
def settings_client(_inject_storage):
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/admin/settings", list_interface_settings),
|
|
Route(
|
|
"/api/admin/settings/{key:path}",
|
|
update_interface_setting,
|
|
methods=["POST", "PUT"],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.config_store = None
|
|
app.state.global_queue = queue.Queue()
|
|
return TestClient(app)
|
|
|
|
|
|
# ===========================================================================
|
|
# DELETE workstream
|
|
# ===========================================================================
|
|
|
|
|
|
class TestDeleteWorkstream:
|
|
def test_delete_success(self, delete_client, storage):
|
|
client, _ = delete_client
|
|
storage.register_workstream("ws-abc", "node-1", name="test")
|
|
r = client.post("/v1/api/workstreams/ws-abc/delete")
|
|
assert r.status_code == 200
|
|
assert r.json()["deleted"] == "ws-abc"
|
|
|
|
def test_delete_not_found(self, delete_client):
|
|
client, _ = delete_client
|
|
r = client.post("/v1/api/workstreams/nonexistent/delete")
|
|
assert r.status_code == 404
|
|
assert "not found" in r.json()["error"].lower()
|
|
|
|
def test_delete_error_redacted(self, delete_client, storage):
|
|
"""500 response should not leak exception internals."""
|
|
client, _ = delete_client
|
|
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
|
|
with patch(
|
|
"turnstone.core.memory.delete_workstream",
|
|
side_effect=RuntimeError("secret internal detail"),
|
|
):
|
|
r = client.post("/v1/api/workstreams/ws-abc/delete")
|
|
assert r.status_code == 500
|
|
assert "Delete failed" in r.json()["error"]
|
|
assert "secret" not in r.json()["error"]
|
|
|
|
def test_delete_fires_lifecycle_event_with_snapshotted_name(self, delete_client, storage):
|
|
"""The endpoint must call ``mgr.delete(ws_id, name=...)`` after a
|
|
successful storage delete so the cluster collector → coord
|
|
adapter chain can re-emit ``child_ws_closed`` and the operator's
|
|
child-tree drops the row. Without this the deleted child stays
|
|
visible (with its last-known state) until a full reload — a
|
|
coordinator that spawns→completes→deletes children leaves an
|
|
ever-growing tree on the dashboard."""
|
|
client, app = delete_client
|
|
storage.register_workstream("ws-event", "node-1", name="needs-event", user_id="test-user")
|
|
mgr = MagicMock()
|
|
app.state.workstreams = mgr
|
|
r = client.post("/v1/api/workstreams/ws-event/delete")
|
|
assert r.status_code == 200
|
|
# The event-emission call must use the name we snapshotted
|
|
# before the storage row was wiped.
|
|
mgr.delete.assert_called_once_with("ws-event", name="needs-event")
|
|
|
|
def test_delete_event_emit_failure_does_not_500(self, delete_client, storage):
|
|
"""A best-effort event emit: if the manager's emitter chokes
|
|
(queue full, adapter mid-shutdown, etc.) the storage row is
|
|
already gone and the response must still be 200 — rolling
|
|
back the delete to satisfy a fan-out failure would corrupt
|
|
the operator's view of an already-vanished workstream."""
|
|
client, app = delete_client
|
|
storage.register_workstream("ws-flaky", "node-1", name="flaky", user_id="test-user")
|
|
mgr = MagicMock()
|
|
mgr.delete.side_effect = RuntimeError("queue full or whatever")
|
|
app.state.workstreams = mgr
|
|
r = client.post("/v1/api/workstreams/ws-flaky/delete")
|
|
assert r.status_code == 200
|
|
assert r.json()["deleted"] == "ws-flaky"
|
|
|
|
|
|
# ===========================================================================
|
|
# SET title
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSetWorkstreamTitle:
|
|
def test_set_title_success(self, title_client, storage):
|
|
client, mock_mgr = title_client
|
|
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
|
|
# mgr.get returning None makes _require_ws_access fall through to
|
|
# the storage-backed ownership check (caller == "test-user" matches
|
|
# the registered owner). Tests that need a ws returned from the
|
|
# manager set up mock_ws.user_id explicitly.
|
|
mock_mgr.get.return_value = None
|
|
r = client.post(
|
|
"/v1/api/workstreams/ws-abc/title",
|
|
json={"title": "New Title"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["title"] == "New Title"
|
|
|
|
def test_set_title_empty(self, title_client, storage):
|
|
client, mock_mgr = title_client
|
|
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
|
|
mock_mgr.get.return_value = None
|
|
r = client.post(
|
|
"/v1/api/workstreams/ws-abc/title",
|
|
json={"title": ""},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "required" in r.json()["error"].lower()
|
|
|
|
def test_set_title_missing_body(self, title_client, storage):
|
|
client, mock_mgr = title_client
|
|
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
|
|
mock_mgr.get.return_value = None
|
|
r = client.post(
|
|
"/v1/api/workstreams/ws-abc/title",
|
|
json={},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
def test_set_title_truncation(self, title_client, storage):
|
|
client, mock_mgr = title_client
|
|
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
|
|
mock_mgr.get.return_value = None
|
|
long_title = "x" * 200
|
|
r = client.post(
|
|
"/v1/api/workstreams/ws-abc/title",
|
|
json={"title": long_title},
|
|
)
|
|
assert r.status_code == 200
|
|
assert len(r.json()["title"]) <= 80
|
|
|
|
def test_set_title_alias_conflict(self, title_client, storage):
|
|
client, mock_mgr = title_client
|
|
storage.register_workstream("ws-1", "node-1", name="first", user_id="test-user")
|
|
storage.register_workstream("ws-2", "node-1", name="second", user_id="test-user")
|
|
storage.set_workstream_alias("ws-1", "taken-name")
|
|
mock_mgr.get.return_value = None
|
|
r = client.post(
|
|
"/v1/api/workstreams/ws-2/title",
|
|
json={"title": "taken-name"},
|
|
)
|
|
assert r.status_code == 409
|
|
|
|
|
|
# ===========================================================================
|
|
# REFRESH title
|
|
# ===========================================================================
|
|
|
|
|
|
class TestRefreshWorkstreamTitle:
|
|
def test_refresh_success(self, title_client, storage):
|
|
client, mock_mgr = title_client
|
|
storage.register_workstream("ws-abc", "node-1", name="test", user_id="test-user")
|
|
# The in-memory fast path on _require_ws_access checks ws.user_id
|
|
# before falling back to storage, so the mock returned by
|
|
# mgr.get must carry the expected owner.
|
|
mock_ws = MagicMock()
|
|
mock_ws.user_id = "test-user"
|
|
mock_ws.session = MagicMock()
|
|
mock_mgr.get.return_value = mock_ws
|
|
with patch("turnstone.core.memory.get_workstream_display_name", return_value="Old Title"):
|
|
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
|
|
assert r.status_code == 200
|
|
mock_ws.session.request_title_refresh.assert_called_once_with("Old Title")
|
|
|
|
def test_refresh_not_found(self, title_client):
|
|
client, mock_mgr = title_client
|
|
mock_mgr.get.return_value = None
|
|
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
|
|
assert r.status_code == 404
|
|
|
|
def test_refresh_no_session(self, title_client):
|
|
client, mock_mgr = title_client
|
|
mock_ws = MagicMock()
|
|
mock_ws.session = None
|
|
mock_mgr.get.return_value = mock_ws
|
|
r = client.post("/v1/api/workstreams/ws-abc/refresh-title")
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ===========================================================================
|
|
# OPEN workstream
|
|
# ===========================================================================
|
|
|
|
|
|
class TestOpenWorkstream:
|
|
@patch("turnstone.core.memory.resolve_workstream")
|
|
def test_open_already_loaded(self, mock_resolve, open_client):
|
|
"""The lifted body returns ``ws.name`` directly on the
|
|
already-loaded shortcut path (not a display-alias re-lookup).
|
|
Pre-lift interactive routed through ``get_workstream_display_name``
|
|
here; the lift consolidates on the in-memory ``ws.name`` field
|
|
for parity with coord's pre-lift behaviour. Frontend already
|
|
re-fetches names from the dashboard endpoint so a freshly-
|
|
renamed workstream still surfaces its alias on subsequent
|
|
listings — no observable user-facing regression."""
|
|
client, mock_mgr, gq = open_client
|
|
mock_resolve.return_value = "ws-abc"
|
|
mock_ws = MagicMock()
|
|
mock_ws.id = "ws-abc"
|
|
mock_ws.name = "My WS"
|
|
mock_mgr.get.return_value = mock_ws
|
|
r = client.post("/v1/api/workstreams/ws-abc/open")
|
|
assert r.status_code == 200
|
|
assert r.json()["already_loaded"] is True
|
|
assert r.json()["ws_id"] == "ws-abc"
|
|
assert r.json()["name"] == "My WS"
|
|
|
|
@patch("turnstone.core.memory.resolve_workstream")
|
|
def test_open_not_found(self, mock_resolve, open_client):
|
|
client, mock_mgr, gq = open_client
|
|
mock_resolve.return_value = None
|
|
r = client.post("/v1/api/workstreams/nonexistent/open")
|
|
assert r.status_code == 404
|
|
|
|
@patch("turnstone.core.memory.resolve_workstream")
|
|
def test_open_no_storage_row(self, mock_resolve, open_client, _inject_storage):
|
|
"""``mgr.open`` returns ``None`` for missing storage rows, kind
|
|
mismatches, and tombstoned rows — all surface as 404 with
|
|
``cfg.not_found_label``. Pre-lift returned a more specific
|
|
``"Workstream not found in storage"`` from a separate
|
|
pre-mgr.create storage probe; the lift consolidates the
|
|
404 path through ``mgr.open``'s single None-return contract
|
|
(the kind-specific failure mode is internal detail not worth
|
|
a distinct error string)."""
|
|
client, mock_mgr, gq = open_client
|
|
mock_resolve.return_value = "ws-abc"
|
|
mock_mgr.get.return_value = None # not loaded
|
|
mock_mgr.open.return_value = (
|
|
None # mgr.open's contract: None for missing/wrong-kind/tombstone
|
|
)
|
|
r = client.post("/v1/api/workstreams/ws-abc/open")
|
|
assert r.status_code == 404
|
|
assert "not found" in r.json()["error"].lower()
|
|
|
|
@patch("turnstone.core.memory.resolve_workstream")
|
|
def test_open_calls_mgr_open_not_mgr_create(self, mock_resolve, open_client):
|
|
"""Post-P3 reckoning item #3: interactive ``open`` must route
|
|
through ``mgr.open()`` (which fires ``emit_rehydrated``), not
|
|
``mgr.create(ws_id=...)`` (the pre-lift workaround that
|
|
bypassed ``emit_rehydrated`` entirely, leaving it dead-by-
|
|
routing on interactive). Asserting ``mgr.open`` was called
|
|
+ ``mgr.create`` was NOT called pins the load-bearing
|
|
behaviour change."""
|
|
client, mock_mgr, gq = open_client
|
|
mock_resolve.return_value = "ws-resolved"
|
|
mock_mgr.get.return_value = None # not loaded
|
|
loaded_ws = MagicMock()
|
|
loaded_ws.id = "ws-resolved"
|
|
loaded_ws.name = "resolved"
|
|
mock_mgr.open.return_value = loaded_ws
|
|
|
|
r = client.post("/v1/api/workstreams/some-alias/open")
|
|
assert r.status_code == 200
|
|
mock_mgr.open.assert_called_once_with("ws-resolved")
|
|
mock_mgr.create.assert_not_called()
|
|
|
|
@patch("turnstone.core.memory.resolve_workstream")
|
|
def test_open_resolves_alias_before_lookup(self, mock_resolve, open_client):
|
|
"""``cfg.open_resolve_alias`` runs first — the path-param can
|
|
be a user-friendly alias that resolves to a hex id, and the
|
|
already-loaded shortcut + mgr.open both see the resolved id.
|
|
Pre-lift behaviour preserved verbatim (interactive's friendly-
|
|
alias UX survives the lift)."""
|
|
client, mock_mgr, gq = open_client
|
|
mock_resolve.return_value = "ws-canonical-id"
|
|
mock_mgr.get.return_value = None
|
|
loaded_ws = MagicMock()
|
|
loaded_ws.id = "ws-canonical-id"
|
|
loaded_ws.name = "x"
|
|
mock_mgr.open.return_value = loaded_ws
|
|
|
|
r = client.post("/v1/api/workstreams/my-friendly-alias/open")
|
|
assert r.status_code == 200
|
|
mock_resolve.assert_called_once_with("my-friendly-alias")
|
|
mock_mgr.get.assert_called_once_with("ws-canonical-id")
|
|
mock_mgr.open.assert_called_once_with("ws-canonical-id")
|
|
|
|
@patch("turnstone.core.memory.resolve_workstream")
|
|
def test_open_post_load_callback_fires_with_request_and_ws(self, mock_resolve, _inject_storage):
|
|
"""``cfg.open_post_load`` is the kind-specific hook for
|
|
post-mgr.open work (interactive uses it for UI replay +
|
|
handler-side ws_created enqueue; coord wires None). Verify
|
|
the callback receives ``(request, ws)`` exactly once on a
|
|
successful open and is NOT fired on the already-loaded
|
|
shortcut (the pre-lift handler also returned early in the
|
|
already-loaded branch before any post-load work)."""
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
captured: list[tuple[str, Any]] = []
|
|
|
|
def _post_load(request: Any, ws_obj: Any) -> None:
|
|
captured.append((ws_obj.id, ws_obj.name))
|
|
|
|
def _lazy_alias(ws_id: str) -> str | None:
|
|
from turnstone.core.memory import resolve_workstream
|
|
|
|
return resolve_workstream(ws_id)
|
|
|
|
mock_mgr = MagicMock()
|
|
cfg = SessionEndpointConfig(
|
|
permission_gate=None,
|
|
manager_lookup=lambda _r: (mock_mgr, None),
|
|
tenant_check=None,
|
|
not_found_label="Workstream not found",
|
|
audit_action_prefix="workstream",
|
|
open_resolve_alias=_lazy_alias,
|
|
open_post_load=_post_load,
|
|
)
|
|
handler = make_open_handler(cfg)
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/workstreams/{ws_id}/open", handler, methods=["POST"]),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
client = TestClient(app)
|
|
|
|
# Already-loaded path: post_load must NOT fire (pre-lift
|
|
# parity — the original handler returned early without any
|
|
# post-load work in the already-loaded branch).
|
|
mock_resolve.return_value = "ws-loaded"
|
|
loaded_ws = MagicMock()
|
|
loaded_ws.id = "ws-loaded"
|
|
loaded_ws.name = "loaded-name"
|
|
mock_mgr.get.return_value = loaded_ws
|
|
r = client.post("/v1/api/workstreams/ws-loaded/open")
|
|
assert r.status_code == 200
|
|
assert r.json()["already_loaded"] is True
|
|
assert captured == [], "post_load fired on the already-loaded shortcut"
|
|
|
|
# Load-from-storage path: post_load fires with (request, ws).
|
|
mock_resolve.return_value = "ws-fresh"
|
|
mock_mgr.get.return_value = None
|
|
opened_ws = MagicMock()
|
|
opened_ws.id = "ws-fresh"
|
|
opened_ws.name = "fresh-name"
|
|
mock_mgr.open.return_value = opened_ws
|
|
r = client.post("/v1/api/workstreams/ws-fresh/open")
|
|
assert r.status_code == 200
|
|
assert captured == [("ws-fresh", "fresh-name")]
|
|
|
|
@patch("turnstone.core.memory.resolve_workstream")
|
|
def test_open_500_message_uses_kind_noun_from_cfg(self, mock_resolve, _inject_storage):
|
|
"""``cfg.audit_action_prefix`` ("workstream" interactive,
|
|
"coordinator" coord) is woven into the 500 error string so
|
|
coord callers see ``"failed to open coordinator"`` and
|
|
interactive callers see ``"failed to open workstream"``,
|
|
matching the pre-lift wording on both sides. Pre-fix
|
|
(Copilot review on PR #414) the message was hardcoded
|
|
``"failed to open workstream"`` for both kinds — coord
|
|
callers got misleading text."""
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
def _lazy_alias(ws_id: str) -> str | None:
|
|
from turnstone.core.memory import resolve_workstream
|
|
|
|
return resolve_workstream(ws_id)
|
|
|
|
mock_mgr = MagicMock()
|
|
cfg = SessionEndpointConfig(
|
|
permission_gate=None,
|
|
manager_lookup=lambda _r: (mock_mgr, None),
|
|
tenant_check=None,
|
|
not_found_label="coordinator not found",
|
|
audit_action_prefix="coordinator", # coord-shaped cfg
|
|
open_resolve_alias=_lazy_alias,
|
|
)
|
|
handler = make_open_handler(cfg)
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/workstreams/{ws_id}/open", handler, methods=["POST"]),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
client = TestClient(app)
|
|
|
|
mock_resolve.return_value = "ws-fresh"
|
|
mock_mgr.get.return_value = None
|
|
mock_mgr.open.side_effect = RuntimeError("session factory blew up")
|
|
|
|
r = client.post("/v1/api/workstreams/ws-fresh/open")
|
|
assert r.status_code == 500
|
|
body = r.json()
|
|
# Per-kind noun in the message.
|
|
assert "failed to open coordinator" in body["error"]
|
|
# Correlation id present so support can match a log entry.
|
|
assert "correlation_id=" in body["error"]
|
|
# Exception text is NOT echoed (no internal-detail leak).
|
|
assert "session factory blew up" not in body["error"]
|
|
|
|
@patch("turnstone.core.memory.resolve_workstream")
|
|
def test_open_swallows_post_load_exception(self, mock_resolve, _inject_storage):
|
|
"""A bug in ``open_post_load`` must NOT block the open from
|
|
returning 200 — the workstream is already loaded by mgr.open
|
|
and the post-load is observational. Mirrors the same swallow
|
|
pattern in ``make_cancel_handler``'s ``cancel_forensics``
|
|
wrapper."""
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
def _raises_post_load(_request: Any, _ws: Any) -> None:
|
|
raise RuntimeError("post-load blew up")
|
|
|
|
def _lazy_alias(ws_id: str) -> str | None:
|
|
from turnstone.core.memory import resolve_workstream
|
|
|
|
return resolve_workstream(ws_id)
|
|
|
|
mock_mgr = MagicMock()
|
|
cfg = SessionEndpointConfig(
|
|
permission_gate=None,
|
|
manager_lookup=lambda _r: (mock_mgr, None),
|
|
tenant_check=None,
|
|
not_found_label="Workstream not found",
|
|
audit_action_prefix="workstream",
|
|
open_resolve_alias=_lazy_alias,
|
|
open_post_load=_raises_post_load,
|
|
)
|
|
handler = make_open_handler(cfg)
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/workstreams/{ws_id}/open", handler, methods=["POST"]),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
client = TestClient(app)
|
|
|
|
mock_resolve.return_value = "ws-fresh"
|
|
mock_mgr.get.return_value = None
|
|
opened_ws = MagicMock()
|
|
opened_ws.id = "ws-fresh"
|
|
opened_ws.name = "fresh-name"
|
|
mock_mgr.open.return_value = opened_ws
|
|
|
|
r = client.post("/v1/api/workstreams/ws-fresh/open")
|
|
assert r.status_code == 200
|
|
assert r.json()["ws_id"] == "ws-fresh"
|
|
|
|
|
|
# ===========================================================================
|
|
# LIST interface settings
|
|
# ===========================================================================
|
|
|
|
|
|
class TestListInterfaceSettings:
|
|
def test_list_defaults(self, settings_client):
|
|
r = settings_client.get("/v1/api/admin/settings")
|
|
assert r.status_code == 200
|
|
settings = r.json()["settings"]
|
|
keys = [s["key"] for s in settings]
|
|
assert "interface.theme" in keys
|
|
assert "interface.close_tab_action" in keys
|
|
# All should be defaults when no config store
|
|
for s in settings:
|
|
assert s["source"] == "default"
|
|
|
|
def test_list_only_interface_keys(self, settings_client):
|
|
r = settings_client.get("/v1/api/admin/settings")
|
|
settings = r.json()["settings"]
|
|
for s in settings:
|
|
assert s["key"].startswith("interface.")
|
|
|
|
|
|
# ===========================================================================
|
|
# UPDATE interface setting
|
|
# ===========================================================================
|
|
|
|
|
|
class TestUpdateInterfaceSetting:
|
|
def test_update_theme(self, settings_client, _inject_storage):
|
|
r = settings_client.post(
|
|
"/v1/api/admin/settings/interface.theme",
|
|
json={"value": "light"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == "light"
|
|
|
|
def test_update_via_put(self, settings_client, _inject_storage):
|
|
r = settings_client.put(
|
|
"/v1/api/admin/settings/interface.theme",
|
|
json={"value": "dark"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == "dark"
|
|
|
|
def test_reject_non_interface_key(self, settings_client):
|
|
r = settings_client.post(
|
|
"/v1/api/admin/settings/judge.enabled",
|
|
json={"value": True},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "interface" in r.json()["error"].lower()
|
|
|
|
def test_reject_unknown_key(self, settings_client):
|
|
r = settings_client.post(
|
|
"/v1/api/admin/settings/interface.nonexistent",
|
|
json={"value": "x"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "unknown" in r.json()["error"].lower()
|
|
|
|
def test_reject_missing_value(self, settings_client):
|
|
r = settings_client.post(
|
|
"/v1/api/admin/settings/interface.theme",
|
|
json={},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "value" in r.json()["error"].lower()
|
|
|
|
def test_reject_invalid_choice(self, settings_client):
|
|
r = settings_client.post(
|
|
"/v1/api/admin/settings/interface.theme",
|
|
json={"value": "neon-pink"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# History / detail — interactive parity with the lifted factories
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Stage 2 ``history`` / ``detail`` verb lift adds these endpoints to the
|
|
# interactive surface as a feature gain (pre-lift only coord exposed
|
|
# them). The lifted factories live in :mod:`turnstone.core.session_routes`;
|
|
# coord parity coverage lives in :mod:`tests.test_coordinator_endpoints`.
|
|
# These tests pin the interactive wiring against the same factory.
|
|
|
|
|
|
def _interactive_endpoint_cfg(mock_mgr: Any) -> SessionEndpointConfig:
|
|
"""Interactive-shaped cfg wired the same way ``server.py`` does.
|
|
|
|
Shared by both :func:`_build_history_app` and :func:`_build_detail_app`
|
|
— every field both factories actually read is present (the detail
|
|
factory ignores ``list_kind`` since it relies on ``mgr.open()`` for
|
|
cross-kind isolation, but the field is harmless to set).
|
|
"""
|
|
return SessionEndpointConfig(
|
|
permission_gate=None, # auth middleware covers it
|
|
manager_lookup=lambda _r: (mock_mgr, None),
|
|
tenant_check=None,
|
|
not_found_label="Workstream not found",
|
|
audit_action_prefix="workstream",
|
|
list_kind=WorkstreamKind.INTERACTIVE,
|
|
)
|
|
|
|
|
|
def _build_history_app(mock_mgr: Any, storage: Any) -> TestClient:
|
|
cfg = _interactive_endpoint_cfg(mock_mgr)
|
|
handler = make_history_handler(cfg)
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/workstreams/{ws_id}/history", handler, methods=["GET"]),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.workstreams = mock_mgr
|
|
app.state.auth_storage = storage
|
|
return TestClient(app)
|
|
|
|
|
|
def _build_detail_app(mock_mgr: Any) -> TestClient:
|
|
cfg = _interactive_endpoint_cfg(mock_mgr)
|
|
handler = make_detail_handler(cfg)
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[Route("/api/workstreams/{ws_id}", handler, methods=["GET"])],
|
|
),
|
|
],
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.workstreams = mock_mgr
|
|
return TestClient(app)
|
|
|
|
|
|
class TestHistoryInteractive:
|
|
"""Interactive parity for the lifted ``GET /v1/api/workstreams/{ws_id}/history``."""
|
|
|
|
def test_returns_messages_for_in_memory_workstream(self, _inject_storage):
|
|
ws_id = "ws-int-1"
|
|
_inject_storage.register_workstream(ws_id, kind="interactive", user_id="test-user")
|
|
_inject_storage.save_message(ws_id, "user", "hello interactive")
|
|
mock_ws = MagicMock()
|
|
mock_ws.id = ws_id
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = mock_ws
|
|
client = _build_history_app(mock_mgr, _inject_storage)
|
|
|
|
r = client.get(f"/v1/api/workstreams/{ws_id}/history")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ws_id"] == ws_id
|
|
assert any(
|
|
m.get("role") == "user" and m.get("content") == "hello interactive"
|
|
for m in body["messages"]
|
|
)
|
|
|
|
def test_serves_storage_only_workstream(self, _inject_storage):
|
|
"""Persisted-but-not-loaded interactives serve history without
|
|
rehydrating — same shape as coord. Pre-lift interactive had no
|
|
history endpoint at all, so this is a feature gain."""
|
|
ws_id = "ws-cold"
|
|
_inject_storage.register_workstream(ws_id, kind="interactive", user_id="test-user")
|
|
_inject_storage.save_message(ws_id, "assistant", "from cold storage")
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = None # not loaded
|
|
client = _build_history_app(mock_mgr, _inject_storage)
|
|
|
|
r = client.get(f"/v1/api/workstreams/{ws_id}/history")
|
|
assert r.status_code == 200
|
|
assert any(m.get("content") == "from cold storage" for m in r.json()["messages"])
|
|
|
|
def test_404_on_missing_ws_id(self, _inject_storage):
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = None
|
|
client = _build_history_app(mock_mgr, _inject_storage)
|
|
|
|
r = client.get("/v1/api/workstreams/no-such-ws/history")
|
|
assert r.status_code == 404
|
|
assert r.json()["error"] == "Workstream not found"
|
|
|
|
def test_404_on_cross_kind_coord_ws_id(self, _inject_storage):
|
|
"""Cross-kind isolation on the storage fallback: a coord ws_id
|
|
in shared storage 404s on the interactive history endpoint.
|
|
Mirrors :func:`test_history_404_when_kind_interactive` in
|
|
``tests.test_coordinator_endpoints``."""
|
|
ws_id = "ws-coord-1"
|
|
_inject_storage.register_workstream(ws_id, kind="coordinator", user_id="test-user")
|
|
_inject_storage.save_message(ws_id, "user", "coord-only content")
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = None
|
|
client = _build_history_app(mock_mgr, _inject_storage)
|
|
|
|
r = client.get(f"/v1/api/workstreams/{ws_id}/history")
|
|
assert r.status_code == 404
|
|
assert "coord-only content" not in r.text
|
|
|
|
def test_clamps_limit_query_param(self, _inject_storage):
|
|
"""Same [1, 500] clamp as coord — pre-lift interactive had no
|
|
history endpoint to enforce a clamp, so this is the
|
|
first-time bound. Out-of-range / unparseable values fall
|
|
back to defaults instead of erroring."""
|
|
ws_id = "ws-clamp"
|
|
_inject_storage.register_workstream(ws_id, kind="interactive", user_id="test-user")
|
|
for i in range(4):
|
|
_inject_storage.save_message(ws_id, "user", f"msg-{i}")
|
|
mock_ws = MagicMock()
|
|
mock_ws.id = ws_id
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = mock_ws
|
|
client = _build_history_app(mock_mgr, _inject_storage)
|
|
|
|
base = f"/v1/api/workstreams/{ws_id}/history"
|
|
# No limit → default 100 returns all 4.
|
|
assert len(client.get(base).json()["messages"]) == 4
|
|
# limit=2 → only 2.
|
|
assert len(client.get(base, params={"limit": 2}).json()["messages"]) == 2
|
|
# 0 → clamps to 1.
|
|
assert len(client.get(base, params={"limit": 0}).json()["messages"]) == 1
|
|
# Garbage → falls back to 100.
|
|
assert client.get(base, params={"limit": "garbage"}).status_code == 200
|
|
# Above-cap → clamps to 500 (response is still 200; we have 4 rows).
|
|
assert client.get(base, params={"limit": 999}).status_code == 200
|
|
|
|
|
|
class TestDetailInteractive:
|
|
"""Interactive parity for the lifted ``GET /v1/api/workstreams/{ws_id}``.
|
|
|
|
The lifted ``make_detail_handler`` factory never reads storage —
|
|
cross-kind isolation is enforced inside ``mgr.open()`` and the
|
|
response is built from in-memory ``Workstream`` fields. The
|
|
``mgr.open`` calls are mocked via ``MagicMock`` here, so the
|
|
storage-registry side effect that ``_inject_storage`` would
|
|
otherwise provide is irrelevant; the fixture is intentionally
|
|
omitted from these methods (unlike :class:`TestHistoryInteractive`
|
|
where the storage backend serves the message rows).
|
|
"""
|
|
|
|
def test_returns_workstream_fields(self):
|
|
ws_id = "ws-detail-1"
|
|
ws_state = MagicMock()
|
|
ws_state.value = "idle"
|
|
loaded_ws = MagicMock()
|
|
loaded_ws.id = ws_id
|
|
loaded_ws.name = "my-interactive"
|
|
loaded_ws.state = ws_state
|
|
loaded_ws.user_id = "test-user"
|
|
loaded_ws.kind = "interactive"
|
|
# No pending approval — leave .ui's MagicMock attrs alone; the
|
|
# handler isinstance-checks ``_pending_approval`` against ``dict``
|
|
# before treating it as live, so MagicMock attribute pollution
|
|
# doesn't trigger the pending path.
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = loaded_ws
|
|
client = _build_detail_app(mock_mgr)
|
|
|
|
r = client.get(f"/v1/api/workstreams/{ws_id}")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body == {
|
|
"ws_id": ws_id,
|
|
"name": "my-interactive",
|
|
"state": "idle",
|
|
"user_id": "test-user",
|
|
"kind": "interactive",
|
|
"pending_approval": False,
|
|
"pending_approval_detail": None,
|
|
}
|
|
|
|
def test_pending_approval_fields_propagate_from_ui(self):
|
|
"""When the workstream's UI is parked on an approval, the detail
|
|
response surfaces ``pending_approval=True`` + the serialized
|
|
``pending_approval_detail`` so a freshly-loaded chat tab can
|
|
paint the inline gate without waiting for the SSE
|
|
``approve_request`` replay (which would otherwise produce a
|
|
brief ``--running`` flash on reload)."""
|
|
ws_id = "ws-pending-1"
|
|
ws_state = MagicMock()
|
|
ws_state.value = "attention"
|
|
loaded_ws = MagicMock()
|
|
loaded_ws.id = ws_id
|
|
loaded_ws.name = "coord-1"
|
|
loaded_ws.state = ws_state
|
|
loaded_ws.user_id = "test-user"
|
|
loaded_ws.kind = "coordinator"
|
|
# Realistic _pending_approval shape (mirrors what
|
|
# SessionUIBase.approve_tools assigns) + a serializer that
|
|
# returns the merged-with-verdicts payload.
|
|
loaded_ws.ui._pending_approval = {
|
|
"type": "approve_request",
|
|
"items": [
|
|
{
|
|
"call_id": "c-1",
|
|
"func_name": "spawn_workstream",
|
|
"needs_approval": True,
|
|
},
|
|
],
|
|
"judge_pending": True,
|
|
}
|
|
loaded_ws.ui.serialize_pending_approval_detail = MagicMock(
|
|
return_value={
|
|
"call_id": "c-1",
|
|
"judge_pending": True,
|
|
"items": [
|
|
{
|
|
"call_id": "c-1",
|
|
"func_name": "spawn_workstream",
|
|
"needs_approval": True,
|
|
"heuristic_verdict": {
|
|
"recommendation": "approve",
|
|
"risk_level": "low",
|
|
"confidence": 0.9,
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = loaded_ws
|
|
client = _build_detail_app(mock_mgr)
|
|
|
|
r = client.get(f"/v1/api/workstreams/{ws_id}")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["pending_approval"] is True
|
|
assert body["pending_approval_detail"]["call_id"] == "c-1"
|
|
assert body["pending_approval_detail"]["judge_pending"] is True
|
|
items = body["pending_approval_detail"]["items"]
|
|
assert len(items) == 1
|
|
assert items[0]["func_name"] == "spawn_workstream"
|
|
assert items[0]["needs_approval"] is True
|
|
|
|
def test_pending_serializer_failure_falls_back_to_bool_only(self):
|
|
"""A malformed verdict that crashes ``serialize_pending_approval_detail``
|
|
must NOT fail the detail response — the boolean still informs
|
|
the UI that an approval is pending; SSE replay carries the
|
|
authoritative payload. Defensive against a future serializer
|
|
regression silently 500ing every page load."""
|
|
ws_id = "ws-pending-broken"
|
|
ws_state = MagicMock()
|
|
ws_state.value = "attention"
|
|
loaded_ws = MagicMock()
|
|
loaded_ws.id = ws_id
|
|
loaded_ws.name = "coord-broken"
|
|
loaded_ws.state = ws_state
|
|
loaded_ws.user_id = "test-user"
|
|
loaded_ws.kind = "coordinator"
|
|
loaded_ws.ui._pending_approval = {"items": []}
|
|
loaded_ws.ui.serialize_pending_approval_detail = MagicMock(
|
|
side_effect=RuntimeError("verdict object is malformed"),
|
|
)
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = loaded_ws
|
|
client = _build_detail_app(mock_mgr)
|
|
|
|
r = client.get(f"/v1/api/workstreams/{ws_id}")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["pending_approval"] is True
|
|
assert body["pending_approval_detail"] is None
|
|
|
|
def test_lazy_rehydrates_on_miss(self):
|
|
"""``mgr.get`` miss → ``mgr.open`` rehydrate. Same flow as coord;
|
|
pre-lift interactive had no detail endpoint so this is the
|
|
first time the rehydrate path is exercised on this surface."""
|
|
ws_id = "ws-cold-detail"
|
|
ws_state = MagicMock()
|
|
ws_state.value = "closed"
|
|
rehydrated = MagicMock()
|
|
rehydrated.id = ws_id
|
|
rehydrated.name = "rehydrated"
|
|
rehydrated.state = ws_state
|
|
rehydrated.user_id = "owner"
|
|
rehydrated.kind = "interactive"
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = None
|
|
mock_mgr.open.return_value = rehydrated
|
|
client = _build_detail_app(mock_mgr)
|
|
|
|
r = client.get(f"/v1/api/workstreams/{ws_id}")
|
|
assert r.status_code == 200
|
|
assert r.json()["name"] == "rehydrated"
|
|
mock_mgr.open.assert_called_once_with(ws_id)
|
|
|
|
def test_404_on_missing_ws_id(self):
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = None
|
|
# ``mgr.open`` returns None for missing rows / kind mismatch /
|
|
# tombstoned rows — all 404 with the per-kind label.
|
|
mock_mgr.open.return_value = None
|
|
client = _build_detail_app(mock_mgr)
|
|
|
|
r = client.get("/v1/api/workstreams/no-such-ws")
|
|
assert r.status_code == 404
|
|
assert r.json()["error"] == "Workstream not found"
|
|
|
|
def test_503_on_session_factory_misconfig(self):
|
|
"""``ValueError`` from ``mgr.open`` (e.g. a model alias that no
|
|
longer resolves) surfaces as 503 with the factory's
|
|
remediation text — not a correlation-id'd 500. Mirrors
|
|
:func:`make_open_handler`."""
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = None
|
|
mock_mgr.open.side_effect = ValueError("alias 'gone' no longer resolves")
|
|
client = _build_detail_app(mock_mgr)
|
|
|
|
r = client.get("/v1/api/workstreams/ws-misconfig")
|
|
assert r.status_code == 503
|
|
assert "alias 'gone' no longer resolves" in r.json()["error"]
|
|
|
|
def test_correlation_id_on_unexpected_rehydrate_failure(self):
|
|
"""Bare ``Exception`` from ``mgr.open`` → 500 + correlation_id +
|
|
per-kind noun in user-facing message. Exception text is NOT
|
|
echoed (no internal-detail leak)."""
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = None
|
|
mock_mgr.open.side_effect = RuntimeError("internal stack frame leak")
|
|
client = _build_detail_app(mock_mgr)
|
|
|
|
r = client.get("/v1/api/workstreams/ws-broken")
|
|
assert r.status_code == 500
|
|
body = r.json()
|
|
assert "internal stack frame leak" not in body["error"]
|
|
assert "correlation_id=" in body["error"]
|
|
# Per-kind noun via cfg.audit_action_prefix.
|
|
assert "workstream" in body["error"]
|