mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 13:00:37 +00:00
1728 lines
66 KiB
Python
1728 lines
66 KiB
Python
"""Tests for the messaging connector core (C2 increment 1): targets, the send_message
|
|
tool, settings/authorization, and the gateway inbound loop — all offline via FakeAdapter.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from coworker.connectors import (
|
|
ConnectorSettings,
|
|
FakeAdapter,
|
|
Gateway,
|
|
MessageEvent,
|
|
SessionSource,
|
|
format_target,
|
|
is_authorized,
|
|
make_send_message_tool,
|
|
parse_target,
|
|
)
|
|
from coworker.connectors.base import SendResult
|
|
from coworker.secrets import SecretStore
|
|
|
|
|
|
# -- target tokens -------------------------------------------------------------
|
|
def test_target_round_trip():
|
|
assert format_target("telegram", "12345") == "telegram:12345"
|
|
assert format_target("slack", "C1", "168.9") == "slack:C1:168.9"
|
|
assert parse_target("telegram:12345") == ("telegram", "12345", None)
|
|
assert parse_target("slack:C1:168.9") == ("slack", "C1", "168.9")
|
|
|
|
|
|
def test_target_invalid():
|
|
for bad in ("", "telegram", "telegram:", ":123"):
|
|
with pytest.raises(ValueError):
|
|
parse_target(bad)
|
|
|
|
|
|
def test_session_source_target_and_label():
|
|
s = SessionSource(
|
|
platform="telegram", chat_id="42", user_name="Alice", chat_type="dm"
|
|
)
|
|
assert s.target == "telegram:42"
|
|
assert "Alice" in s.label() and "telegram" in s.label()
|
|
|
|
|
|
def test_message_tagged_text_carries_reply_handle():
|
|
s = SessionSource(
|
|
platform="slack", chat_id="C9", user_name="Bob", chat_type="channel"
|
|
)
|
|
ev = MessageEvent(text="ship it", source=s)
|
|
tag = ev.tagged_text()
|
|
assert "reply→slack:C9" in tag and "ship it" in tag
|
|
|
|
|
|
# -- send_message tool ---------------------------------------------------------
|
|
def _fake_senders(record):
|
|
def sender(token, chat_id, text, thread_id=None):
|
|
record.append(
|
|
{"token": token, "chat_id": chat_id, "text": text, "thread_id": thread_id}
|
|
)
|
|
return SendResult(True, message_id="99")
|
|
|
|
return {"telegram": sender, "slack": sender}
|
|
|
|
|
|
def test_send_message_success(tmp_path):
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
secrets.put("telegram:default", {"type": "token", "bot_token": "T0K"})
|
|
record = []
|
|
tool = make_send_message_tool(secrets, senders=_fake_senders(record))
|
|
|
|
out = tool(target="telegram:12345", text="hello")
|
|
assert out == {"ok": True, "message_id": "99", "target": "telegram:12345"}
|
|
assert record == [
|
|
{"token": "T0K", "chat_id": "12345", "text": "hello", "thread_id": None}
|
|
]
|
|
# tool carries gating metadata + an explicit schema
|
|
assert tool.__aisuite_tool_metadata__.requires_approval is True
|
|
assert tool.__coworker_schema__["function"]["name"] == "send_message"
|
|
|
|
|
|
def test_send_message_missing_token(tmp_path):
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
tool = make_send_message_tool(secrets, senders=_fake_senders([]))
|
|
assert "error" in tool(target="telegram:1", text="x")
|
|
|
|
|
|
def test_send_message_unknown_platform(tmp_path):
|
|
tool = make_send_message_tool(
|
|
SecretStore(tmp_path / "secrets.json"), senders=_fake_senders([])
|
|
)
|
|
assert "unknown platform" in tool(target="discord:1", text="x")["error"]
|
|
|
|
|
|
def test_send_message_bad_target(tmp_path):
|
|
tool = make_send_message_tool(
|
|
SecretStore(tmp_path / "secrets.json"), senders=_fake_senders([])
|
|
)
|
|
assert "error" in tool(target="nonsense", text="x")
|
|
|
|
|
|
# -- settings / authorization --------------------------------------------------
|
|
def test_is_authorized():
|
|
s = ConnectorSettings(platform="telegram", allowed_users={"u1"})
|
|
assert is_authorized(s, SessionSource("telegram", "c", user_id="u1"))
|
|
assert not is_authorized(s, SessionSource("telegram", "c", user_id="u2"))
|
|
# empty allowlist = nobody
|
|
assert not is_authorized(
|
|
ConnectorSettings("telegram"), SessionSource("telegram", "c", user_id="u1")
|
|
)
|
|
# allow_all opens it
|
|
assert is_authorized(
|
|
ConnectorSettings("telegram", allow_all=True),
|
|
SessionSource("telegram", "c", user_id="x"),
|
|
)
|
|
|
|
|
|
def test_load_settings_from_secretstore(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("TELEGRAM_ALLOWED_USERS", raising=False)
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
secrets.put(
|
|
"telegram:default", {"type": "token", "bot_token": "T", "allowed_users": ["u1"]}
|
|
)
|
|
settings = __import__(
|
|
"coworker.connectors.config", fromlist=["load_settings"]
|
|
).load_settings(secrets)
|
|
assert settings["telegram"].enabled is True
|
|
assert settings["telegram"].allowed_users == {"u1"}
|
|
assert settings["slack"].enabled is False # no token
|
|
|
|
|
|
def test_load_settings_env_allowlist(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "a, b ,c")
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
secrets.put("telegram:default", {"bot_token": "T"})
|
|
settings = __import__(
|
|
"coworker.connectors.config", fromlist=["load_settings"]
|
|
).load_settings(secrets)
|
|
assert settings["telegram"].allowed_users == {"a", "b", "c"}
|
|
|
|
|
|
# -- gateway inbound loop (FakeAdapter) ----------------------------------------
|
|
async def test_gateway_dispatches_authorized():
|
|
received: list[MessageEvent] = []
|
|
|
|
async def handler(ev: MessageEvent) -> None:
|
|
received.append(ev)
|
|
|
|
settings = {"fake": ConnectorSettings("fake", enabled=True, allowed_users={"u1"})}
|
|
gw = Gateway(settings=settings, handler=handler)
|
|
fake = FakeAdapter()
|
|
gw.register(fake)
|
|
live = await gw.start()
|
|
assert live == ["fake"] and fake.connected
|
|
|
|
await fake.inject("hi", user_id="u1")
|
|
assert len(received) == 1 and received[0].text == "hi"
|
|
|
|
await fake.inject("nope", user_id="intruder") # not in allowlist
|
|
assert len(received) == 1 # dropped
|
|
|
|
await gw.stop()
|
|
assert not fake.connected
|
|
|
|
|
|
async def test_gateway_deliver_via_adapter():
|
|
gw = Gateway(
|
|
settings={"fake": ConnectorSettings("fake", enabled=True, allow_all=True)}
|
|
)
|
|
fake = FakeAdapter()
|
|
gw.register(fake)
|
|
result = await gw.deliver("fake:c9", "pong")
|
|
assert result.ok
|
|
assert fake.outbox == [{"chat_id": "c9", "text": "pong", "thread_id": None}]
|
|
|
|
|
|
async def test_gateway_full_echo_loop():
|
|
"""Inbound → handler replies via deliver → lands in the adapter outbox."""
|
|
gw = Gateway(
|
|
settings={"fake": ConnectorSettings("fake", enabled=True, allow_all=True)}
|
|
)
|
|
fake = FakeAdapter()
|
|
|
|
async def echo(ev: MessageEvent) -> None:
|
|
await gw.deliver(ev.source.target, f"echo: {ev.text}")
|
|
|
|
gw.set_handler(echo)
|
|
gw.register(fake)
|
|
await fake.inject("ping", chat_id="c1", user_id="u1")
|
|
assert fake.outbox == [{"chat_id": "c1", "text": "echo: ping", "thread_id": None}]
|
|
|
|
|
|
# -- engine integration: send_message appears only when a connector is configured ----
|
|
class _StubProvider:
|
|
"""Minimal ProviderClient stand-in (build_engine never calls it)."""
|
|
|
|
def complete(self, **_kw): # pragma: no cover - never invoked at build time
|
|
from coworker.providers import AssistantTurn
|
|
|
|
return AssistantTurn()
|
|
|
|
def capabilities(self, _model): # pragma: no cover
|
|
from coworker.providers.base import ModelCapabilities
|
|
|
|
return ModelCapabilities()
|
|
|
|
def stream(self, **_kw): # pragma: no cover
|
|
from coworker.providers.base import StreamChunk
|
|
|
|
yield StreamChunk(turn=self.complete())
|
|
|
|
|
|
def test_engine_connector_tools_are_cowork_scoped(tmp_path):
|
|
from coworker.agent import build_engine
|
|
from coworker.agents import chat_agent, code_agent, cowork_agent, myhelper_agent
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
eng = build_engine(agent=chat_agent(), provider=_StubProvider(), secrets=secrets)
|
|
assert "send_message" not in eng.registry.names() # no connector yet
|
|
assert "browser_read_url" not in eng.registry.names()
|
|
|
|
secrets.put("telegram:default", {"bot_token": "T"})
|
|
chat = build_engine(agent=chat_agent(), provider=_StubProvider(), secrets=secrets)
|
|
code = build_engine(
|
|
agent=code_agent(),
|
|
workspace=tmp_path,
|
|
provider=_StubProvider(),
|
|
secrets=secrets,
|
|
)
|
|
cowork = build_engine(
|
|
agent=cowork_agent(),
|
|
workspace=tmp_path,
|
|
provider=_StubProvider(),
|
|
secrets=secrets,
|
|
)
|
|
helper = build_engine(
|
|
agent=myhelper_agent(),
|
|
workspace=tmp_path,
|
|
provider=_StubProvider(),
|
|
secrets=secrets,
|
|
)
|
|
|
|
assert "send_message" not in chat.registry.names()
|
|
assert "send_message" not in code.registry.names()
|
|
assert "browser_read_url" not in chat.registry.names()
|
|
assert "browser_read_url" not in code.registry.names()
|
|
|
|
assert "send_message" in cowork.registry.names()
|
|
assert "browser_read_url" in cowork.registry.names()
|
|
assert "browser_open_url" in cowork.registry.names()
|
|
assert "browser_click" in cowork.registry.names()
|
|
assert "browser_type" in cowork.registry.names()
|
|
assert "github_search" not in cowork.registry.names()
|
|
assert "send_message" in helper.registry.names()
|
|
assert "browser_read_url" not in helper.registry.names()
|
|
assert "browser_open_url" not in helper.registry.names()
|
|
|
|
# §36: browser READS (registry kind) are free; interactions still gate.
|
|
assert cowork.registry.get("browser_open_url").metadata.requires_approval is False
|
|
assert cowork.registry.get("browser_snapshot").metadata.requires_approval is False
|
|
assert cowork.registry.get("browser_click").metadata.requires_approval is True
|
|
assert cowork.registry.get("browser_type").metadata.requires_approval is True
|
|
cowork.permissions.allow_tool_for_session("browser_click")
|
|
decision = cowork.permissions.evaluate(
|
|
"browser_click",
|
|
{"target": "button"},
|
|
cowork.registry.get("browser_click").metadata,
|
|
)
|
|
assert decision.needs_user is True
|
|
|
|
secrets.put("github:default", {"token": "ghp_test", "enabled": True})
|
|
cowork_with_github = build_engine(
|
|
agent=cowork_agent(),
|
|
workspace=tmp_path,
|
|
provider=_StubProvider(),
|
|
secrets=secrets,
|
|
)
|
|
assert "github_search" in cowork_with_github.registry.names()
|
|
# §36: github_search is a registry READ — free; the write sibling still gates.
|
|
assert (
|
|
cowork_with_github.registry.get("github_search").metadata.requires_approval
|
|
is False
|
|
)
|
|
assert (
|
|
cowork_with_github.registry.get(
|
|
"github_create_issue"
|
|
).metadata.requires_approval
|
|
is True
|
|
)
|
|
|
|
|
|
# -- connector setup (descriptors / connect / disconnect / list) ---------------
|
|
def test_connector_list_descriptors(tmp_path):
|
|
from coworker.connectors import connector_list
|
|
|
|
by_name = {
|
|
c["name"]: c for c in connector_list(SecretStore(tmp_path / "secrets.json"))
|
|
}
|
|
assert (
|
|
by_name["telegram"]["two_way"] is True
|
|
and by_name["telegram"]["connected"] is False
|
|
)
|
|
# channels (chat capability) is narrower than two_way: GitHub is two-way via the
|
|
# relay (inbound mentions) but sessions can't subscribe to "GitHub channels".
|
|
assert by_name["telegram"]["channels"] is True
|
|
assert by_name["slack"]["channels"] is True
|
|
assert (
|
|
by_name["github"]["two_way"] is True and by_name["github"]["channels"] is False
|
|
)
|
|
assert (
|
|
by_name["gmail"]["available"] is True and by_name["gmail"]["connected"] is False
|
|
)
|
|
assert (
|
|
by_name["browser"]["available"] is True
|
|
and by_name["browser"]["connected"] is True
|
|
)
|
|
assert (
|
|
by_name["github"]["available"] is True
|
|
and by_name["github"]["connected"] is False
|
|
)
|
|
assert any(
|
|
t["name"] == "browser_open_url" and t["requires_approval"]
|
|
for t in by_name["browser"]["tools"]
|
|
)
|
|
# telegram exposes a bot_token field + setup instructions
|
|
keys = {f["key"] for f in by_name["telegram"]["fields"]}
|
|
assert "bot_token" in keys and by_name["telegram"]["instructions"]
|
|
|
|
|
|
def test_connector_list_pre_connect_copy(tmp_path):
|
|
"""Every connectable connector ships Access bullets for the pre-connect
|
|
detail page (UX-DECISIONS §38) — an empty Access section would render as
|
|
'this app tells you nothing about what it can do'."""
|
|
from coworker.connectors import connector_list
|
|
from coworker.connectors.catalog_copy import ACCESS
|
|
from coworker.connectors.descriptors import list_descriptors
|
|
|
|
for c in connector_list(SecretStore(tmp_path / "secrets.json")):
|
|
assert isinstance(c["about"], str)
|
|
assert c["access"] and all(
|
|
isinstance(line, str) and line for line in c["access"]
|
|
), f"{c['name']} has no access copy"
|
|
# Curated (non-fallback) copy is required for every AVAILABLE connector —
|
|
# the fallback line is only a net for experimental/placeholder entries.
|
|
missing = [
|
|
d.name
|
|
for d in list_descriptors()
|
|
if d.available and not d.experimental and d.name not in ACCESS
|
|
]
|
|
assert not missing, f"connectors missing curated access copy: {missing}"
|
|
|
|
|
|
def test_connector_list_connected_for_required_profiles(tmp_path):
|
|
from coworker.connectors import (
|
|
connect_connector,
|
|
connector_list,
|
|
update_connector_tools,
|
|
)
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
assert (
|
|
connect_connector(secrets, "github", {"token": "ghp_test"}, validate=False)[
|
|
"ok"
|
|
]
|
|
is True
|
|
)
|
|
assert (
|
|
connect_connector(
|
|
secrets,
|
|
"jira",
|
|
{
|
|
"base_url": "https://example.atlassian.net",
|
|
"email": "me@example.com",
|
|
"api_token": "tok",
|
|
},
|
|
validate=False,
|
|
)["ok"]
|
|
is True
|
|
)
|
|
|
|
by_name = {c["name"]: c for c in connector_list(secrets)}
|
|
assert (
|
|
by_name["github"]["connected"] is True and by_name["github"]["enabled"] is True
|
|
)
|
|
assert by_name["jira"]["connected"] is True and by_name["jira"]["enabled"] is True
|
|
|
|
assert (
|
|
update_connector_tools(secrets, "github", {"github_search": False})["ok"]
|
|
is True
|
|
)
|
|
by_name = {c["name"]: c for c in connector_list(secrets)}
|
|
gh_tools = {t["name"]: t for t in by_name["github"]["tools"]}
|
|
assert gh_tools["github_search"]["enabled"] is False
|
|
assert gh_tools["github_get_issue"]["enabled"] is True
|
|
|
|
|
|
def test_connect_disconnect_no_validate(tmp_path):
|
|
from coworker.connectors import (
|
|
connect_connector,
|
|
connector_list,
|
|
disconnect_connector,
|
|
)
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
res = connect_connector(
|
|
secrets,
|
|
"telegram",
|
|
{"bot_token": "T0K", "allowed_users": "u1, u2"},
|
|
validate=False,
|
|
)
|
|
assert res["ok"] is True
|
|
profile = secrets.get("telegram:default")
|
|
assert profile["bot_token"] == "T0K" and profile["allowed_users"] == ["u1", "u2"]
|
|
assert profile["enabled"] is True
|
|
|
|
listed = {c["name"]: c for c in connector_list(secrets)}["telegram"]
|
|
assert (
|
|
listed["connected"] is True
|
|
and listed["enabled"] is True
|
|
and listed["allowed_users"] == ["u1", "u2"]
|
|
)
|
|
|
|
assert disconnect_connector(secrets, "telegram")["ok"] is True
|
|
assert secrets.get("telegram:default") is None
|
|
|
|
|
|
def test_reconnect_does_not_clobber_secret_or_allowlist(tmp_path):
|
|
# Regression: a re-submit carrying the masked placeholder (or a blank allow-list) must not
|
|
# overwrite a stored real token / wipe the live allow-list.
|
|
from coworker.connectors import connect_connector
|
|
from coworker.connectors.descriptors import get_descriptor
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
placeholder = next(
|
|
f.placeholder for f in get_descriptor("telegram").fields if f.key == "bot_token"
|
|
)
|
|
connect_connector(
|
|
secrets,
|
|
"telegram",
|
|
{"bot_token": "REAL-TOKEN-123", "allowed_users": "u1, u2"},
|
|
validate=False,
|
|
)
|
|
|
|
# Re-submit with the field's mask + an empty allow-list → both must be preserved.
|
|
connect_connector(
|
|
secrets,
|
|
"telegram",
|
|
{"bot_token": placeholder, "allowed_users": ""},
|
|
validate=False,
|
|
)
|
|
prof = secrets.get("telegram:default")
|
|
assert prof["bot_token"] == "REAL-TOKEN-123" # not reset to the placeholder
|
|
assert prof["allowed_users"] == ["u1", "u2"] # not wiped
|
|
|
|
# A genuinely new token still updates.
|
|
connect_connector(
|
|
secrets, "telegram", {"bot_token": "NEW-TOKEN-999"}, validate=False
|
|
)
|
|
assert secrets.get("telegram:default")["bot_token"] == "NEW-TOKEN-999"
|
|
assert secrets.get("telegram:default")["allowed_users"] == [
|
|
"u1",
|
|
"u2",
|
|
] # still preserved
|
|
|
|
|
|
def test_connect_missing_required_field(tmp_path):
|
|
from coworker.connectors import connect_connector
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
res = connect_connector(
|
|
secrets, "slack", {"bot_token": "xoxb"}, validate=False
|
|
) # app_token missing
|
|
assert res["ok"] is False and "missing" in res["error"]
|
|
|
|
|
|
def test_manual_slack_reconnect_preserves_approval_owners(tmp_path):
|
|
from coworker.connectors import connect_connector
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
secrets.put(
|
|
"slack:default",
|
|
{
|
|
"bot_token": "xoxb-old",
|
|
"app_token": "xapp-old",
|
|
"allowed_users": ["U_OWNER"],
|
|
"approval_owner_ids": ["U_OWNER"],
|
|
},
|
|
)
|
|
result = connect_connector(
|
|
secrets,
|
|
"slack",
|
|
{"bot_token": "xoxb-new", "app_token": "xapp-new"},
|
|
validate=False,
|
|
)
|
|
assert result["ok"] is True
|
|
profile = secrets.get("slack:default")
|
|
assert profile["approval_owner_ids"] == ["U_OWNER"]
|
|
assert profile["allowed_users"] == ["U_OWNER"]
|
|
|
|
|
|
def test_connect_validation_runs(tmp_path):
|
|
from coworker.connectors import connect_connector
|
|
from coworker.connectors.descriptors import ValidationResult, get_descriptor
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
desc = get_descriptor("telegram")
|
|
orig = desc.validate
|
|
desc.validate = lambda creds: ValidationResult(True, identity="@mybot")
|
|
try:
|
|
res = connect_connector(
|
|
secrets, "telegram", {"bot_token": "T"}
|
|
) # validate=True
|
|
finally:
|
|
desc.validate = orig
|
|
assert res == {"ok": True, "account": "@mybot"}
|
|
assert secrets.get("telegram:default")["account"] == "@mybot"
|
|
|
|
|
|
def test_connectors_rest(tmp_path, monkeypatch):
|
|
from fastapi.testclient import TestClient
|
|
|
|
from coworker.connectors.descriptors import ValidationResult, get_descriptor
|
|
from coworker.server.app import create_app
|
|
from coworker.server.manager import SessionManager
|
|
|
|
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
|
desc = get_descriptor("telegram")
|
|
monkeypatch.setattr(
|
|
desc, "validate", lambda creds: ValidationResult(True, identity="@testbot")
|
|
)
|
|
|
|
client = TestClient(create_app(SessionManager(data_dir=tmp_path / "data")))
|
|
|
|
listed = client.get("/v1/connectors").json()["connectors"]
|
|
assert any(c["name"] == "telegram" for c in listed)
|
|
|
|
r = client.post(
|
|
"/v1/connectors/telegram/connect",
|
|
json={"fields": {"bot_token": "T0K", "allowed_users": "u1"}},
|
|
)
|
|
assert r.json() == {"ok": True, "account": "@testbot"}
|
|
|
|
tg = {c["name"]: c for c in client.get("/v1/connectors").json()["connectors"]}[
|
|
"telegram"
|
|
]
|
|
assert tg["connected"] is True and tg["account"] == "@testbot"
|
|
# secrets never leak over REST
|
|
assert "T0K" not in client.get("/v1/connectors").text
|
|
|
|
assert client.post("/v1/connectors/telegram/disconnect").json()["ok"] is True
|
|
assert {c["name"]: c for c in client.get("/v1/connectors").json()["connectors"]}[
|
|
"telegram"
|
|
]["connected"] is False
|
|
|
|
|
|
# -- inbound: event mappers ----------------------------------------------------
|
|
def test_telegram_message_mapper():
|
|
from types import SimpleNamespace
|
|
|
|
from coworker.connectors import telegram_message_to_event
|
|
|
|
msg = SimpleNamespace(
|
|
text="hello",
|
|
message_id=7,
|
|
chat=SimpleNamespace(id=12345, type="private"),
|
|
from_user=SimpleNamespace(id=99, full_name="Alice"),
|
|
message_thread_id=None,
|
|
)
|
|
ev = telegram_message_to_event(msg)
|
|
assert ev.text == "hello" and ev.source.target == "telegram:12345"
|
|
assert ev.source.user_id == "99" and ev.source.chat_type == "dm"
|
|
# non-text (e.g. a sticker) maps to None
|
|
assert (
|
|
telegram_message_to_event(
|
|
SimpleNamespace(text=None, chat=SimpleNamespace(id=1, type="private"))
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_slack_event_mapper_and_loop_guard():
|
|
from coworker.connectors import slack_event_to_event
|
|
|
|
ev = slack_event_to_event(
|
|
{
|
|
"text": "ship it",
|
|
"channel": "C9",
|
|
"user": "U1",
|
|
"channel_type": "channel",
|
|
"ts": "1.2",
|
|
},
|
|
"BOT",
|
|
)
|
|
assert (
|
|
ev.text == "ship it"
|
|
and ev.source.target == "slack:C9"
|
|
and ev.source.chat_type == "channel"
|
|
)
|
|
# bot echo / edits / empty → dropped (reply-loop guard)
|
|
assert slack_event_to_event({"text": "x", "user": "BOT"}, "BOT") is None
|
|
assert slack_event_to_event({"text": "x", "bot_id": "B1"}, None) is None
|
|
assert (
|
|
slack_event_to_event({"subtype": "message_changed", "text": "x"}, None) is None
|
|
)
|
|
|
|
|
|
def test_make_adapter():
|
|
from coworker.connectors import SlackAdapter, TelegramAdapter, make_adapter
|
|
|
|
assert isinstance(make_adapter("telegram", {"bot_token": "T"}), TelegramAdapter)
|
|
assert isinstance(
|
|
make_adapter("slack", {"bot_token": "x", "app_token": "y"}), SlackAdapter
|
|
)
|
|
assert make_adapter("slack", {"bot_token": "x"}) is None # app_token missing
|
|
assert make_adapter("telegram", {}) is None
|
|
|
|
|
|
async def test_slack_resolves_and_caches_display_name():
|
|
from coworker.connectors import SlackAdapter
|
|
|
|
calls: list[str] = []
|
|
|
|
class _Client:
|
|
async def users_info(self, user):
|
|
calls.append(user)
|
|
return {"user": {"name": "ann", "profile": {"display_name": "Ann"}}}
|
|
|
|
class _App:
|
|
client = _Client()
|
|
|
|
a = SlackAdapter("b", "x")
|
|
a._app = _App()
|
|
assert await a._display_name("U1") == "Ann"
|
|
assert await a._display_name("U1") == "Ann" # served from cache
|
|
assert calls == ["U1"] # only one API round-trip
|
|
|
|
class _BadClient:
|
|
async def users_info(self, user):
|
|
raise RuntimeError("nope")
|
|
|
|
a._app.client = _BadClient()
|
|
assert (
|
|
await a._display_name("U2") is None
|
|
) # failure → None (caller falls back to the id)
|
|
assert await a._display_name("") is None # no id → no call
|
|
|
|
|
|
async def test_slack_resolve_channel_name():
|
|
from coworker.connectors import SlackAdapter
|
|
|
|
calls: list[str] = []
|
|
|
|
class _Client:
|
|
async def conversations_info(self, channel):
|
|
calls.append(channel)
|
|
return {"channel": {"id": channel, "name": "ocw-test"}}
|
|
|
|
class _App:
|
|
client = _Client()
|
|
|
|
a = SlackAdapter("b", "x")
|
|
a._app = _App()
|
|
assert await a._channel_name("C1") == "ocw-test"
|
|
assert await a._channel_name("C1") == "ocw-test" # served from cache
|
|
assert calls == ["C1"] # only one API round-trip
|
|
# public §2.1 wrapper delegates to the cached resolver (no extra call)
|
|
assert await a.resolve_channel_name("C1") == "ocw-test"
|
|
assert calls == ["C1"]
|
|
|
|
class _BadClient:
|
|
async def conversations_info(self, channel):
|
|
raise RuntimeError("nope")
|
|
|
|
a._app.client = _BadClient()
|
|
assert (
|
|
await a._channel_name("C2") is None
|
|
) # failure → None (caller falls back to the id)
|
|
assert await a._channel_name("") is None # no id → no call
|
|
|
|
|
|
# -- chat-ID auto-capture + connector allow-list -------------------------------
|
|
async def test_gateway_records_recent_senders():
|
|
gw = Gateway(
|
|
settings={"fake": ConnectorSettings("fake", enabled=True, allowed_users={"u1"})}
|
|
)
|
|
fake = FakeAdapter()
|
|
gw.register(fake)
|
|
await fake.inject(
|
|
"hi", user_id="u2", user_name="Bob"
|
|
) # unauthorized → dropped but captured
|
|
await fake.inject("yo", user_id="u1", user_name="Al") # authorized
|
|
recent = gw.recent_senders()
|
|
assert [r["user_id"] for r in recent] == ["u1", "u2"] # most-recent first
|
|
assert recent[1]["user_name"] == "Bob"
|
|
# same sender again de-dupes and moves to front
|
|
await fake.inject("again", user_id="u2")
|
|
assert [r["user_id"] for r in gw.recent_senders("fake")] == ["u2", "u1"]
|
|
|
|
|
|
def test_manager_allow_disallow(tmp_path, monkeypatch):
|
|
from coworker.connectors import connect_connector
|
|
from coworker.server.manager import SessionManager
|
|
|
|
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
|
m = SessionManager(data_dir=tmp_path / "data")
|
|
connect_connector(m.secrets, "telegram", {"bot_token": "T"}, validate=False)
|
|
|
|
assert m.allow_user("telegram", "12345")["allowed_users"] == ["12345"]
|
|
assert m.secrets.get("telegram:default")["allowed_users"] == ["12345"]
|
|
assert m.disallow_user("telegram", "12345")["allowed_users"] == []
|
|
assert m.allow_user("slack", "x")["ok"] is False # slack not connected
|
|
|
|
|
|
# -- new tool connectors (linear / gitlab / discord / stripe / asana / hubspot /
|
|
# dropbox / box) --------------------------------------------------------------
|
|
_NEW_CONNECTORS = {
|
|
"linear": {"api_key": "lin_api_x"},
|
|
"gitlab": {"token": "glpat-x"},
|
|
"discord": {"bot_token": "B0T"},
|
|
"stripe": {"api_key": "rk_test_x"},
|
|
"asana": {"token": "asana_pat"},
|
|
"hubspot": {"token": "pat-x"},
|
|
"dropbox": {"access_token": "dbx"},
|
|
"box": {"access_token": "boxtok"},
|
|
"quickbooks": {"access_token": "qbo", "realm_id": "9341453"},
|
|
"whatsapp": {"access_token": "wa_tok", "phone_number_id": "555111"},
|
|
# Batch-2 account-patterned connectors: stored as legacy flat :default
|
|
# profiles on purpose — the accounts layer migrates them lazily, so these
|
|
# also regression-pin the migration on the tool path.
|
|
"posthog": {"api_key": "phx_x", "project_id": "77"},
|
|
"mixpanel": {"username": "svc.user", "secret": "mp_sec", "project_id": "88"},
|
|
"amplitude": {"api_key": "amp_key_abc123", "secret_key": "amp_sec"},
|
|
"apollo": {"api_key": "apo_x"},
|
|
"hunter": {"api_key": "hun_x"},
|
|
"notion": {"access_token": "ntn_x"},
|
|
"attio": {"access_token": "attio_x"},
|
|
# Batch 3.
|
|
"clickup": {"api_token": "pk_x"},
|
|
"close": {"api_key": "api_close_x"},
|
|
"figma": {"access_token": "figd_x"},
|
|
"google_drive": {"access_token": "ya29.x"},
|
|
# account_id/base_uri pre-cached so routing tests skip userinfo discovery
|
|
# (discovery itself is covered by test_docusign_account_discovery_caches).
|
|
"docusign": {
|
|
"access_token": "ds_x",
|
|
"account_id": "acc-1",
|
|
"base_uri": "https://demo.docusign.net",
|
|
},
|
|
"canva": {"access_token": "cnv_x"},
|
|
}
|
|
|
|
|
|
def test_new_connector_descriptors_listed(tmp_path):
|
|
from coworker.connectors import connector_list
|
|
|
|
by_name = {
|
|
c["name"]: c for c in connector_list(SecretStore(tmp_path / "secrets.json"))
|
|
}
|
|
for name in _NEW_CONNECTORS:
|
|
assert by_name[name]["available"] is True
|
|
assert by_name[name]["connected"] is False
|
|
assert by_name[name]["two_way"] is False
|
|
assert by_name[name]["instructions"]
|
|
assert by_name[name]["tools"], f"{name} has no tools in the catalog"
|
|
# stripe and quickbooks are deliberately read-only
|
|
assert all(t["kind"] == "read" for t in by_name["stripe"]["tools"])
|
|
assert all(t["kind"] == "read" for t in by_name["dropbox"]["tools"])
|
|
assert all(t["kind"] == "read" for t in by_name["box"]["tools"])
|
|
assert all(t["kind"] == "read" for t in by_name["quickbooks"]["tools"])
|
|
# google_drive (scope discipline) and canva (exports are renders) too
|
|
assert all(t["kind"] == "read" for t in by_name["google_drive"]["tools"])
|
|
assert all(t["kind"] == "read" for t in by_name["canva"]["tools"])
|
|
|
|
|
|
def test_new_connectors_connect_and_gate_tools(tmp_path):
|
|
from coworker.connectors import connect_connector, connector_list
|
|
from coworker.connectors.integration_tools import make_integration_tools
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
for name, fields in _NEW_CONNECTORS.items():
|
|
assert connect_connector(secrets, name, fields, validate=False)["ok"] is True
|
|
|
|
by_name = {c["name"]: c for c in connector_list(secrets)}
|
|
for name in _NEW_CONNECTORS:
|
|
assert by_name[name]["connected"] is True and by_name[name]["enabled"] is True
|
|
|
|
# only the enabled connectors' tools survive the filter
|
|
tools = make_integration_tools(secrets, enabled_connectors={"linear", "box"})
|
|
names = {t.__name__ for t in tools}
|
|
assert "linear_search_issues" in names and "box_search" in names
|
|
assert "gitlab_search" not in names and "stripe_list_charges" not in names
|
|
|
|
|
|
def test_new_tools_error_when_not_connected(tmp_path):
|
|
from coworker.connectors.integration_tools import make_integration_tools
|
|
|
|
tools = {
|
|
t.__name__: t
|
|
for t in make_integration_tools(SecretStore(tmp_path / "secrets.json"))
|
|
}
|
|
assert "not connected" in tools["linear_search_issues"](query="q")["error"]
|
|
assert "not connected" in tools["gitlab_search"](query="q")["error"]
|
|
assert "not connected" in tools["discord_send_message"]("1", "hi")["error"]
|
|
assert "not connected" in tools["stripe_search_customers"]("e:'a'")["error"]
|
|
assert "not connected" in tools["asana_get_task"]("1")["error"]
|
|
assert "not connected" in tools["hubspot_search"]("acme")["error"]
|
|
assert "not connected" in tools["posthog_query"]("SELECT 1")["error"]
|
|
assert "not connected" in tools["mixpanel_top_events"]()["error"]
|
|
assert (
|
|
"not connected"
|
|
in tools["amplitude_active_users"]("20260701", "20260707")["error"]
|
|
)
|
|
assert "not connected" in tools["apollo_enrich_company"]("acme.io")["error"]
|
|
assert "not connected" in tools["hunter_verify_email"]("a@b.co")["error"]
|
|
assert "not connected" in tools["dropbox_list_folder"]()["error"]
|
|
assert "not connected" in tools["box_read_file"]("1")["error"]
|
|
assert "not connected" in tools["quickbooks_query"]("SELECT * FROM Bill")["error"]
|
|
assert "not connected" in tools["clickup_list_teams"]()["error"]
|
|
assert "not connected" in tools["close_search_leads"]("acme")["error"]
|
|
assert "not connected" in tools["figma_get_file"]("KEY1")["error"]
|
|
assert "not connected" in tools["drive_search_files"]("plan")["error"]
|
|
assert "not connected" in tools["docusign_list_envelopes"]()["error"]
|
|
assert "not connected" in tools["canva_list_designs"]()["error"]
|
|
|
|
|
|
def _connected_tools(tmp_path, monkeypatch, calls):
|
|
"""All new connectors connected + _request recorded instead of hitting the network."""
|
|
import coworker.connectors.integration_tools as it
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
for name, fields in _NEW_CONNECTORS.items():
|
|
secrets.put(f"{name}:default", {**fields, "enabled": True})
|
|
|
|
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
|
|
calls.append(
|
|
{
|
|
"method": method,
|
|
"url": url,
|
|
"headers": headers or {},
|
|
"params": params,
|
|
"json": json,
|
|
"auth": auth,
|
|
}
|
|
)
|
|
return {"ok": True, "data": {}}
|
|
|
|
monkeypatch.setattr(it, "_request", fake_request)
|
|
return {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
|
|
|
|
def test_new_tools_request_routing(tmp_path, monkeypatch):
|
|
calls = []
|
|
tools = _connected_tools(tmp_path, monkeypatch, calls)
|
|
|
|
tools["linear_search_issues"]("crash", max_results=5)
|
|
assert calls[-1]["url"] == "https://api.linear.app/graphql"
|
|
assert calls[-1]["headers"]["Authorization"] == "lin_api_x" # raw key, no Bearer
|
|
assert calls[-1]["json"]["variables"] == {"term": "crash", "first": 5}
|
|
|
|
tools["gitlab_get_issue"]("group/repo", 7)
|
|
assert (
|
|
calls[-1]["url"] == "https://gitlab.com/api/v4/projects/group%2Frepo/issues/7"
|
|
)
|
|
assert calls[-1]["headers"]["PRIVATE-TOKEN"] == "glpat-x"
|
|
|
|
tools["discord_send_message"]("123", "x" * 3000)
|
|
assert calls[-1]["url"] == "https://discord.com/api/v10/channels/123/messages"
|
|
assert calls[-1]["headers"]["Authorization"] == "Bot B0T"
|
|
assert len(calls[-1]["json"]["content"]) == 2000 # discord hard limit
|
|
|
|
tools["stripe_list_charges"](customer_id="cus_1", max_results=99)
|
|
assert calls[-1]["params"] == {"limit": 20, "customer": "cus_1"}
|
|
|
|
tools["asana_search_tasks"]("WS1", "report")
|
|
assert "workspaces/WS1/typeahead" in calls[-1]["url"]
|
|
|
|
tools["hubspot_search"]("acme", object_type="bogus")
|
|
assert calls[-1]["url"].endswith("/crm/v3/objects/contacts/search") # fallback
|
|
|
|
tools["dropbox_list_folder"]("Docs")
|
|
assert calls[-1]["json"] == {"path": "/Docs"} # leading slash added
|
|
tools["dropbox_list_folder"]()
|
|
assert calls[-1]["json"] == {"path": ""} # root stays empty
|
|
|
|
tools["dropbox_read_file"]("/a.txt")
|
|
assert "Dropbox-API-Arg" in calls[-1]["headers"]
|
|
|
|
tools["box_read_file"]("f1")
|
|
assert calls[-1]["url"] == "https://api.box.com/2.0/files/f1/content"
|
|
|
|
tools["quickbooks_query"]("SELECT * FROM Invoice", max_results=5)
|
|
assert (
|
|
calls[-1]["url"] == "https://quickbooks.api.intuit.com/v3/company/9341453/query"
|
|
)
|
|
assert calls[-1]["params"]["query"] == "SELECT * FROM Invoice MAXRESULTS 5"
|
|
tools["quickbooks_query"]("SELECT * FROM Bill MAXRESULTS 3")
|
|
assert (
|
|
calls[-1]["params"]["query"] == "SELECT * FROM Bill MAXRESULTS 3"
|
|
) # untouched
|
|
|
|
tools["quickbooks_get_report"]("ProfitAndLoss", start_date="2026-01-01")
|
|
assert calls[-1]["url"].endswith("/reports/ProfitAndLoss")
|
|
assert calls[-1]["params"] == {"start_date": "2026-01-01"}
|
|
|
|
tools["whatsapp_send_message"]("15551234567", "x" * 5000)
|
|
assert calls[-1]["url"] == "https://graph.facebook.com/v21.0/555111/messages"
|
|
assert calls[-1]["json"]["messaging_product"] == "whatsapp"
|
|
assert len(calls[-1]["json"]["text"]["body"]) == 4096 # whatsapp hard limit
|
|
|
|
tools["whatsapp_send_template"]("15551234567", "order_update")
|
|
assert calls[-1]["json"]["template"] == {
|
|
"name": "order_update",
|
|
"language": {"code": "en_US"},
|
|
}
|
|
|
|
|
|
def test_registry_has_no_duplicate_names():
|
|
"""A new full descriptor once coexisted with a stale placeholder (both
|
|
named "notion") — the Connectors page showed the connector twice and the
|
|
tool registry carried colliding tool names. Guard both registries."""
|
|
from coworker.connectors.descriptors import DESCRIPTORS
|
|
from coworker.connectors.tool_defs import TOOL_DEFS
|
|
|
|
names = [d.name for d in DESCRIPTORS]
|
|
assert len(names) == len(set(names)), sorted(n for n in names if names.count(n) > 1)
|
|
tools = [t.name for t in TOOL_DEFS]
|
|
assert len(tools) == len(set(tools)), sorted(t for t in tools if tools.count(t) > 1)
|
|
|
|
|
|
def test_batch2_tools_request_routing(tmp_path, monkeypatch):
|
|
"""The five key-based batch-2 connectors: right endpoints, right auth style,
|
|
and every result stamped with the serving account (legacy flat profiles
|
|
migrate on first use — see _NEW_CONNECTORS)."""
|
|
calls = []
|
|
tools = _connected_tools(tmp_path, monkeypatch, calls)
|
|
|
|
out = tools["posthog_query"]("SELECT event FROM events")
|
|
assert calls[-1]["url"] == "https://us.posthog.com/api/projects/77/query"
|
|
assert calls[-1]["headers"]["Authorization"] == "Bearer phx_x"
|
|
assert calls[-1]["json"]["query"]["kind"] == "HogQLQuery"
|
|
assert out["account"] == "77" # stamped for approvals/transcripts
|
|
|
|
tools["posthog_list_insights"]("signups", max_results=5)
|
|
assert calls[-1]["url"].endswith("/api/projects/77/insights")
|
|
assert calls[-1]["params"] == {"limit": 5, "search": "signups"}
|
|
|
|
tools["mixpanel_segmentation"]("purchase", "2026-07-01", "2026-07-07", unit="bogus")
|
|
assert calls[-1]["url"] == "https://mixpanel.com/api/query/segmentation"
|
|
assert calls[-1]["params"]["project_id"] == "88"
|
|
assert calls[-1]["params"]["unit"] == "day" # bogus unit falls back
|
|
|
|
tools["mixpanel_top_events"]()
|
|
assert calls[-1]["url"].endswith("/api/query/events/top")
|
|
|
|
tools["amplitude_active_users"]("2026-07-01", "2026-07-07", metric="new")
|
|
assert calls[-1]["url"] == "https://amplitude.com/api/2/users"
|
|
assert calls[-1]["params"]["start"] == "20260701" # dashes normalized
|
|
assert calls[-1]["params"]["m"] == "new"
|
|
|
|
tools["amplitude_event_totals"]("signup", "20260701", "20260707")
|
|
assert calls[-1]["url"].endswith("/api/2/events/segmentation")
|
|
assert '"event_type": "signup"' in calls[-1]["params"]["e"]
|
|
|
|
tools["apollo_enrich_person"](email="maya@acme.io")
|
|
assert calls[-1]["url"] == "https://api.apollo.io/api/v1/people/match"
|
|
assert calls[-1]["headers"]["X-Api-Key"] == "apo_x"
|
|
assert "provide an email" in tools["apollo_enrich_person"]()["error"]
|
|
|
|
tools["apollo_enrich_company"]("acme.io")
|
|
assert calls[-1]["params"] == {"domain": "acme.io"}
|
|
|
|
tools["apollo_search_people"]("VP engineering fintech", max_results=7)
|
|
assert calls[-1]["json"]["per_page"] == 7
|
|
|
|
tools["hunter_domain_search"]("acme.io", max_results=3)
|
|
assert calls[-1]["url"] == "https://api.hunter.io/v2/domain-search"
|
|
assert calls[-1]["params"] == {"domain": "acme.io", "limit": 3, "api_key": "hun_x"}
|
|
|
|
tools["hunter_find_email"]("acme.io", "Maya", "Chen")
|
|
assert calls[-1]["params"]["first_name"] == "Maya"
|
|
|
|
tools["hunter_verify_email"]("maya@acme.io")
|
|
assert calls[-1]["url"].endswith("/email-verifier")
|
|
|
|
tools["notion_search"]("roadmap", max_results=5)
|
|
assert calls[-1]["url"] == "https://api.notion.com/v1/search"
|
|
assert calls[-1]["headers"]["Notion-Version"] == "2022-06-28"
|
|
assert calls[-1]["json"] == {"query": "roadmap", "page_size": 5}
|
|
|
|
tools["notion_query_database"]("db1")
|
|
assert calls[-1]["url"].endswith("/v1/databases/db1/query")
|
|
assert (
|
|
"must be a Notion filter"
|
|
in tools["notion_query_database"]("db1", "nope")["error"]
|
|
)
|
|
|
|
tools["notion_create_page"]("pg1", "Weekly notes", "line one\n\nline two")
|
|
assert calls[-1]["json"]["parent"] == {"page_id": "pg1"}
|
|
assert len(calls[-1]["json"]["children"]) == 2 # blank lines dropped
|
|
|
|
tools["attio_list_objects"]()
|
|
assert calls[-1]["url"] == "https://api.attio.com/v2/objects"
|
|
assert calls[-1]["headers"]["Authorization"] == "Bearer attio_x"
|
|
|
|
tools["attio_query_records"]("companies", max_results=50)
|
|
assert calls[-1]["url"].endswith("/v2/objects/companies/records/query")
|
|
assert calls[-1]["json"] == {"limit": 50}
|
|
|
|
tools["attio_get_record"]("people", "r1")
|
|
assert calls[-1]["url"].endswith("/v2/objects/people/records/r1")
|
|
|
|
tools["attio_create_note"]("companies", "r1", "Call notes", "went well")
|
|
assert calls[-1]["url"] == "https://api.attio.com/v2/notes"
|
|
assert calls[-1]["json"]["data"]["parent_record_id"] == "r1"
|
|
assert calls[-1]["json"]["data"]["format"] == "plaintext"
|
|
|
|
|
|
def test_batch3_tools_request_routing(tmp_path, monkeypatch):
|
|
calls = []
|
|
tools = _connected_tools(tmp_path, monkeypatch, calls)
|
|
|
|
# ClickUp: raw personal token in Authorization (no Bearer).
|
|
tools["clickup_list_teams"]()
|
|
assert calls[-1]["url"] == "https://api.clickup.com/api/v2/team"
|
|
assert calls[-1]["headers"]["Authorization"] == "pk_x"
|
|
|
|
tools["clickup_list_tasks"]("l-9", include_closed=True)
|
|
assert calls[-1]["url"] == "https://api.clickup.com/api/v2/list/l-9/task"
|
|
assert calls[-1]["params"]["include_closed"] == "true"
|
|
|
|
tools["clickup_create_task"]("l-9", "Ship logos", "brand marks")
|
|
assert calls[-1]["method"] == "POST"
|
|
assert calls[-1]["json"] == {"name": "Ship logos", "description": "brand marks"}
|
|
|
|
tools["clickup_update_task"]("t-1", status="done")
|
|
assert calls[-1]["method"] == "PUT"
|
|
assert calls[-1]["json"] == {"status": "done"}
|
|
assert "nothing to update" in tools["clickup_update_task"]("t-1")["error"]
|
|
|
|
tools["clickup_add_comment"]("t-1", "on it")
|
|
assert calls[-1]["url"].endswith("/task/t-1/comment")
|
|
assert calls[-1]["json"] == {"comment_text": "on it"}
|
|
|
|
# Close: basic auth (key as username, blank password).
|
|
tools["close_search_leads"]("status:potential acme", max_results=5)
|
|
assert calls[-1]["url"] == "https://api.close.com/api/v1/lead/"
|
|
assert calls[-1]["auth"] == ("api_close_x", "")
|
|
assert calls[-1]["params"] == {"query": "status:potential acme", "_limit": 5}
|
|
|
|
tools["close_get_lead"]("lead_1")
|
|
assert calls[-1]["url"] == "https://api.close.com/api/v1/lead/lead_1/"
|
|
|
|
tools["close_list_opportunities"](lead_id="lead_1")
|
|
assert calls[-1]["params"]["lead_id"] == "lead_1"
|
|
|
|
tools["close_create_lead"]("Acme", contact_name="Ada", contact_email="a@acme.io")
|
|
assert calls[-1]["json"]["contacts"] == [
|
|
{"name": "Ada", "emails": [{"email": "a@acme.io"}]}
|
|
]
|
|
|
|
tools["close_update_opportunity"]("opp_1", status_id="stat_won")
|
|
assert calls[-1]["url"].endswith("/opportunity/opp_1/")
|
|
assert "nothing to update" in tools["close_update_opportunity"]("opp_1")["error"]
|
|
|
|
tools["close_log_note"]("lead_1", "call went well")
|
|
assert calls[-1]["url"] == "https://api.close.com/api/v1/activity/note/"
|
|
|
|
# Figma: PAT in X-Figma-Token.
|
|
tools["figma_get_comments"]("KEY1")
|
|
assert calls[-1]["url"] == "https://api.figma.com/v1/files/KEY1/comments"
|
|
assert calls[-1]["headers"]["X-Figma-Token"] == "figd_x"
|
|
|
|
tools["figma_post_comment"]("KEY1", "looks good", reply_to="c9")
|
|
assert calls[-1]["json"] == {"message": "looks good", "comment_id": "c9"}
|
|
|
|
tools["figma_export_images"]("KEY1", "1:2,1:3", format="svg")
|
|
assert calls[-1]["url"] == "https://api.figma.com/v1/images/KEY1"
|
|
assert calls[-1]["params"]["ids"] == "1:2,1:3"
|
|
|
|
# Drive: bearer token; quotes escaped into the q expression.
|
|
tools["drive_search_files"]("Q3 plan's", max_results=5)
|
|
assert calls[-1]["url"] == "https://www.googleapis.com/drive/v3/files"
|
|
assert calls[-1]["headers"]["Authorization"] == "Bearer ya29.x"
|
|
assert "Q3 plan\\'s" in calls[-1]["params"]["q"]
|
|
assert "trashed=false" in calls[-1]["params"]["q"]
|
|
|
|
tools["drive_list_folder"]()
|
|
assert calls[-1]["params"]["q"] == "'root' in parents and trashed=false"
|
|
|
|
# Docusign: cached account routes straight to the account's base_uri.
|
|
tools["docusign_list_envelopes"](status="completed", since_days=7)
|
|
assert calls[-1]["url"] == (
|
|
"https://demo.docusign.net/restapi/v2.1/accounts/acc-1/envelopes"
|
|
)
|
|
assert calls[-1]["params"]["status"] == "completed"
|
|
|
|
tools["docusign_get_envelope"]("env-1")
|
|
assert calls[-1]["url"].endswith("/envelopes/env-1")
|
|
assert calls[-1]["params"] == {"include": "recipients"}
|
|
|
|
tools["docusign_send_from_template"]("tpl-1", "a@b.co", "Ada", subject="NDA")
|
|
assert calls[-1]["json"]["templateRoles"] == [
|
|
{"email": "a@b.co", "name": "Ada", "roleName": "Signer"}
|
|
]
|
|
assert calls[-1]["json"]["status"] == "sent"
|
|
|
|
# Canva: bearer token; export is a job POST.
|
|
tools["canva_list_designs"]("deck", max_results=5)
|
|
assert calls[-1]["url"] == "https://api.canva.com/rest/v1/designs"
|
|
assert calls[-1]["headers"]["Authorization"] == "Bearer cnv_x"
|
|
assert calls[-1]["params"] == {"limit": 5, "query": "deck"}
|
|
|
|
tools["canva_export_design"]("d1", format="png")
|
|
assert calls[-1]["url"] == "https://api.canva.com/rest/v1/exports"
|
|
assert calls[-1]["json"] == {"design_id": "d1", "format": {"type": "png"}}
|
|
|
|
tools["canva_get_export"]("exp1")
|
|
assert calls[-1]["url"] == "https://api.canva.com/rest/v1/exports/exp1"
|
|
|
|
|
|
def test_docusign_account_discovery_caches(tmp_path, monkeypatch):
|
|
import coworker.connectors.integration_tools as it
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
secrets.put("docusign:default", {"access_token": "ds_x", "enabled": True})
|
|
calls = []
|
|
|
|
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
|
|
calls.append(url)
|
|
if url.endswith("/oauth/userinfo"):
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"accounts": [
|
|
{
|
|
"account_id": "other",
|
|
"base_uri": "https://x",
|
|
"is_default": False,
|
|
},
|
|
{
|
|
"account_id": "acc-9",
|
|
"base_uri": "https://eu.docusign.net",
|
|
"is_default": True,
|
|
},
|
|
]
|
|
},
|
|
}
|
|
return {"ok": True, "data": {}}
|
|
|
|
monkeypatch.setattr(it, "_request", fake_request)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
|
|
tools["docusign_list_templates"]()
|
|
assert calls[0] == "https://account.docusign.com/oauth/userinfo"
|
|
assert calls[1] == "https://eu.docusign.net/restapi/v2.1/accounts/acc-9/templates"
|
|
# Discovery result is cached on the profile — no second userinfo round-trip.
|
|
assert secrets.get("docusign:default")["account_id"] == "acc-9"
|
|
tools["docusign_list_envelopes"]()
|
|
assert not any(u.endswith("/oauth/userinfo") for u in calls[2:])
|
|
|
|
|
|
def test_drive_read_file_exports_google_docs(tmp_path, monkeypatch):
|
|
import coworker.connectors.integration_tools as it
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
secrets.put("google_drive:default", {"access_token": "ya29.x", "enabled": True})
|
|
|
|
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
|
|
if url.endswith("/export"):
|
|
return {"ok": True, "data": "Doc body text"}
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"id": "f1",
|
|
"name": "Plan",
|
|
"mimeType": "application/vnd.google-apps.document",
|
|
},
|
|
}
|
|
|
|
monkeypatch.setattr(it, "_request", fake_request)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
out = tools["drive_read_file"]("f1")
|
|
assert out["ok"] is True and out["content"] == "Doc body text"
|
|
|
|
# Native Google types with no text export refuse instead of dumping binary.
|
|
def fake_request_drawing(method, url, **kw):
|
|
return {
|
|
"ok": True,
|
|
"data": {"mimeType": "application/vnd.google-apps.drawing"},
|
|
}
|
|
|
|
monkeypatch.setattr(it, "_request", fake_request_drawing)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
assert "cannot read" in tools["drive_read_file"]("f2")["error"]
|
|
|
|
|
|
def test_notion_read_page_flattens_blocks(tmp_path, monkeypatch):
|
|
import coworker.connectors.integration_tools as it
|
|
from coworker.connectors import accounts
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
accounts.add_account(secrets, "notion", "ws1", {"access_token": "t"})
|
|
|
|
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
|
|
if "/blocks/" in url:
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"results": [
|
|
{
|
|
"type": "heading_1",
|
|
"heading_1": {"rich_text": [{"plain_text": "Title"}]},
|
|
},
|
|
{
|
|
"type": "paragraph",
|
|
"paragraph": {"rich_text": [{"plain_text": "Body text"}]},
|
|
},
|
|
{"type": "divider", "divider": {}},
|
|
]
|
|
},
|
|
}
|
|
return {"ok": True, "data": {"properties": {"p": 1}, "url": "https://n/x"}}
|
|
|
|
monkeypatch.setattr(it, "_request", fake_request)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
out = tools["notion_read_page"]("pg1")
|
|
assert out["text"] == "Title\nBody text"
|
|
assert out["account"] == "ws1" and out["url"] == "https://n/x"
|
|
|
|
|
|
def test_managed_callback_profile_keys_by_account_id(tmp_path):
|
|
"""Managed OAuth on an account-patterned connector: the broker's account_id
|
|
keys the profile; a second workspace is a second account."""
|
|
from coworker.cloud import managed_profile_from_callback
|
|
from coworker.connectors import accounts
|
|
from coworker.connectors.setup import managed_connect_connector
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
p1 = managed_profile_from_callback(
|
|
{
|
|
"access_token": "t1",
|
|
"account": "Rohit's Workspace",
|
|
"account_id": "ws-1",
|
|
"provider": "notion",
|
|
"connection_id": "c1",
|
|
}
|
|
)
|
|
out = managed_connect_connector(secrets, "notion", p1)
|
|
assert out["ok"] and out["account_id"] == "ws-1"
|
|
p2 = managed_profile_from_callback(
|
|
{"access_token": "t2", "account": "Ops Space", "account_id": "ws-2"}
|
|
)
|
|
managed_connect_connector(secrets, "notion", p2)
|
|
assert [a for a, _ in accounts.list_accounts(secrets, "notion")] == ["ws-1", "ws-2"]
|
|
# display names survive; default stays the first workspace
|
|
rows = accounts.account_rows(secrets, "notion")
|
|
assert rows[0]["name"] == "Rohit's Workspace" and rows[0]["default"]
|
|
|
|
|
|
def test_google_drive_multi_account_keys_by_email(tmp_path):
|
|
"""Managed Drive must add multiple accounts keyed by email — the same way
|
|
Gmail does — not by the opaque Google `sub`. The broker sends both `account`
|
|
(email) and `account_id` (sub); account_field="@identity" makes the email win."""
|
|
from coworker.cloud import managed_profile_from_callback
|
|
from coworker.connectors import accounts
|
|
from coworker.connectors.setup import managed_connect_connector
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
p1 = managed_profile_from_callback(
|
|
{
|
|
"access_token": "t1",
|
|
"account": "rohit@opencoworker.app",
|
|
"account_id": "114835900000000000001", # Google sub — must NOT be the key
|
|
"provider": "google",
|
|
"connection_id": "c1",
|
|
}
|
|
)
|
|
managed_connect_connector(secrets, "google_drive", p1)
|
|
p2 = managed_profile_from_callback(
|
|
{
|
|
"access_token": "t2",
|
|
"account": "work@acme.com",
|
|
"account_id": "114835900000000000002",
|
|
"provider": "google",
|
|
}
|
|
)
|
|
managed_connect_connector(secrets, "google_drive", p2)
|
|
|
|
ids = [a for a, _ in accounts.list_accounts(secrets, "google_drive")]
|
|
assert ids == ["rohit@opencoworker.app", "work@acme.com"], ids
|
|
# The default resolves to the first email, and the account param selects the other.
|
|
_, _, prof = accounts.resolve(secrets, "google_drive", "work@acme.com")
|
|
assert prof["access_token"] == "t2"
|
|
|
|
|
|
def test_outlook_managed_multi_account_keys_by_email(tmp_path, monkeypatch):
|
|
"""Managed Outlook mirrors Gmail/Drive: broker `account` (email from the
|
|
Microsoft id_token) keys each mailbox; tools take an account param."""
|
|
import coworker.connectors.integration_tools as it
|
|
from coworker.cloud import managed_profile_from_callback
|
|
from coworker.connectors import accounts
|
|
from coworker.connectors.setup import managed_connect_connector
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
for email, tok in (("rohit@openworker.com", "g1"), ("ops@acme.com", "g2")):
|
|
managed_connect_connector(
|
|
secrets,
|
|
"outlook",
|
|
managed_profile_from_callback(
|
|
{"access_token": tok, "account": email, "provider": "microsoft"}
|
|
),
|
|
)
|
|
ids = [a for a, _ in accounts.list_accounts(secrets, "outlook")]
|
|
assert ids == ["ops@acme.com", "rohit@openworker.com"], ids
|
|
|
|
calls = []
|
|
|
|
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
|
|
calls.append({"url": url, "headers": headers or {}})
|
|
return {"ok": True, "data": {}}
|
|
|
|
monkeypatch.setattr(it, "_request", fake_request)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
out = tools["outlook_search_messages"]("q", account="ops@acme.com")
|
|
assert out["account"] == "ops@acme.com"
|
|
assert calls[-1]["headers"]["Authorization"] == "Bearer g2"
|
|
# default account = first connected (rohit@ was added first)
|
|
out = tools["outlook_list_events"]()
|
|
assert out["account"] == "rohit@openworker.com"
|
|
# Bare list = the next-7-days calendarView (recurrences expanded), not /me/events.
|
|
assert calls[-1]["url"] == "https://graph.microsoft.com/v1.0/me/calendarView"
|
|
|
|
|
|
def test_outlook_calendar_tools_hit_the_right_graph_endpoints(tmp_path, monkeypatch):
|
|
"""The calendar CRUD + invite-response tools map onto Microsoft Graph:
|
|
create carries attendees/location/Teams flags, update PATCHes only the
|
|
provided fields, respond posts to the accept/decline/tentativelyAccept
|
|
action endpoints."""
|
|
import coworker.connectors.integration_tools as it
|
|
from coworker.cloud import managed_profile_from_callback
|
|
from coworker.connectors.setup import managed_connect_connector
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
managed_connect_connector(
|
|
secrets,
|
|
"outlook",
|
|
managed_profile_from_callback(
|
|
{
|
|
"access_token": "tok",
|
|
"account": "rohit@openworker.com",
|
|
"provider": "microsoft",
|
|
}
|
|
),
|
|
)
|
|
|
|
calls = []
|
|
|
|
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
|
|
calls.append({"method": method, "url": url, "params": params, "json": json})
|
|
return {"ok": True, "data": {}}
|
|
|
|
monkeypatch.setattr(it, "_request", fake_request)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
|
|
tools["outlook_create_event"](
|
|
"Sync",
|
|
"2026-07-20T10:00:00",
|
|
"2026-07-20T10:30:00",
|
|
attendees="a@x.com, b@y.com",
|
|
location="Room 4",
|
|
teams_meeting=True,
|
|
)
|
|
payload = calls[-1]["json"]
|
|
assert [a["emailAddress"]["address"] for a in payload["attendees"]] == [
|
|
"a@x.com",
|
|
"b@y.com",
|
|
]
|
|
assert payload["location"] == {"displayName": "Room 4"}
|
|
assert payload["isOnlineMeeting"] is True
|
|
|
|
tools["outlook_update_event"]("ev1", subject="Moved", start="2026-07-21T10:00:00")
|
|
assert calls[-1]["method"] == "PATCH"
|
|
assert calls[-1]["url"].endswith("/me/events/ev1")
|
|
# PATCH semantics: untouched fields stay out of the payload.
|
|
assert set(calls[-1]["json"]) == {"subject", "start"}
|
|
|
|
tools["outlook_delete_event"]("ev1")
|
|
assert calls[-1]["method"] == "DELETE"
|
|
assert calls[-1]["url"].endswith("/me/events/ev1")
|
|
|
|
tools["outlook_respond_event"]("ev1", "tentative", comment="might be late")
|
|
assert calls[-1]["url"].endswith("/me/events/ev1/tentativelyAccept")
|
|
assert calls[-1]["json"] == {"comment": "might be late", "sendResponse": True}
|
|
|
|
out = tools["outlook_respond_event"]("ev1", "maybe")
|
|
assert "error" in out
|
|
|
|
# A time-windowed list passes the window through to calendarView.
|
|
tools["outlook_list_events"](
|
|
start="2026-07-20T00:00:00Z", end="2026-07-22T00:00:00Z"
|
|
)
|
|
assert calls[-1]["params"]["startDateTime"] == "2026-07-20T00:00:00Z"
|
|
assert calls[-1]["params"]["endDateTime"] == "2026-07-22T00:00:00Z"
|
|
assert calls[-1]["params"]["$orderby"] == "start/dateTime"
|
|
|
|
|
|
def test_batch2_account_param_picks_the_profile(tmp_path, monkeypatch):
|
|
"""Two PostHog projects connected → the account param routes the call; the
|
|
default pointer serves bare calls; unknown accounts fail closed."""
|
|
import coworker.connectors.integration_tools as it
|
|
from coworker.connectors import accounts
|
|
|
|
calls = []
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
accounts.add_account(
|
|
secrets, "posthog", "11", {"api_key": "k11", "project_id": "11"}
|
|
)
|
|
accounts.add_account(
|
|
secrets, "posthog", "22", {"api_key": "k22", "project_id": "22"}
|
|
)
|
|
|
|
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
|
|
calls.append({"url": url, "headers": headers or {}})
|
|
return {"ok": True, "data": {}}
|
|
|
|
monkeypatch.setattr(it, "_request", fake_request)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
|
|
out = tools["posthog_query"]("SELECT 1", account="22")
|
|
assert "/projects/22/" in calls[-1]["url"]
|
|
assert calls[-1]["headers"]["Authorization"] == "Bearer k22"
|
|
assert out["account"] == "22"
|
|
|
|
out = tools["posthog_query"]("SELECT 1") # default = first added
|
|
assert "/projects/11/" in calls[-1]["url"] and out["account"] == "11"
|
|
|
|
out = tools["posthog_query"]("SELECT 1", account="99")
|
|
assert "no posthog account matching" in out["error"]
|
|
|
|
|
|
def test_hubspot_search_properties_and_filters(tmp_path, monkeypatch):
|
|
"""Custom properties (VC-thesis fields etc.) are invisible to the search API
|
|
unless requested, and unmatchable by free-text query — the properties/filters
|
|
params are what make property-driven workflows possible at all."""
|
|
calls = []
|
|
tools = _connected_tools(tmp_path, monkeypatch, calls)
|
|
|
|
tools["hubspot_search"](
|
|
object_type="companies",
|
|
properties="org_type, check_min,check_max",
|
|
filters='[{"property":"org_type","operator":"EQ","value":"VC"}]',
|
|
max_results=50,
|
|
)
|
|
body = calls[-1]["json"]
|
|
assert body["properties"] == ["org_type", "check_min", "check_max"]
|
|
assert body["filterGroups"] == [
|
|
{"filters": [{"property": "org_type", "operator": "EQ", "value": "VC"}]}
|
|
]
|
|
assert body["limit"] == 50 and "query" not in body
|
|
|
|
tools["hubspot_search"]("acme") # plain free-text still works
|
|
assert calls[-1]["json"]["query"] == "acme"
|
|
|
|
n = len(calls) # none of the error paths below may reach the network
|
|
assert "JSON array" in tools["hubspot_search"](filters="not json")["error"]
|
|
assert "property" in tools["hubspot_search"](filters='[{"value":"x"}]')["error"]
|
|
assert "query" in tools["hubspot_search"]()["error"]
|
|
assert len(calls) == n
|
|
|
|
tools["hubspot_get_object"](
|
|
"deals", "42", properties="round,portfolio_company", associations="companies"
|
|
)
|
|
assert calls[-1]["params"] == {
|
|
"properties": "round,portfolio_company",
|
|
"associations": "companies",
|
|
}
|
|
tools["hubspot_get_object"]("deals", "42") # no params → none sent
|
|
assert calls[-1]["params"] is None
|
|
|
|
|
|
def test_new_write_tools_require_approval(tmp_path, monkeypatch):
|
|
# §36: the tool registry's kind is law — connector WRITES gate, READS never do
|
|
# (reads on a service the user explicitly connected are the point of connecting it).
|
|
tools = _connected_tools(tmp_path, monkeypatch, [])
|
|
for name in (
|
|
"linear_create_issue",
|
|
"gitlab_create_issue",
|
|
"discord_send_message",
|
|
"asana_create_task",
|
|
"hubspot_create_contact",
|
|
"whatsapp_send_message",
|
|
"whatsapp_send_template",
|
|
"notion_create_page",
|
|
"attio_create_note",
|
|
"clickup_create_task",
|
|
"clickup_update_task",
|
|
"clickup_add_comment",
|
|
"close_create_lead",
|
|
"close_update_opportunity",
|
|
"close_log_note",
|
|
"figma_post_comment",
|
|
"docusign_send_from_template",
|
|
):
|
|
assert tools[name].__aisuite_tool_metadata__.requires_approval is True, name
|
|
for name in (
|
|
"stripe_search_customers",
|
|
"dropbox_read_file",
|
|
"box_search",
|
|
"drive_read_file",
|
|
"figma_export_images",
|
|
"docusign_list_envelopes",
|
|
"canva_export_design",
|
|
):
|
|
assert tools[name].__aisuite_tool_metadata__.requires_approval is False, name
|
|
|
|
|
|
def test_gitlab_self_hosted_base_url(tmp_path, monkeypatch):
|
|
import coworker.connectors.integration_tools as it
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
secrets.put(
|
|
"gitlab:default",
|
|
{"token": "t", "base_url": "https://git.example.com/", "enabled": True},
|
|
)
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
it, "_request", lambda m, url, **kw: calls.append(url) or {"ok": True}
|
|
)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
tools["gitlab_search"]("q")
|
|
assert calls[0] == "https://git.example.com/api/v4/search"
|
|
|
|
|
|
def test_quickbooks_sandbox_environment(tmp_path, monkeypatch):
|
|
import coworker.connectors.integration_tools as it
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
secrets.put(
|
|
"quickbooks:default",
|
|
{"access_token": "t", "realm_id": "r1", "environment": "sandbox"},
|
|
)
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
it, "_request", lambda m, url, **kw: calls.append(url) or {"ok": True}
|
|
)
|
|
tools = {t.__name__: t for t in it.make_integration_tools(secrets)}
|
|
tools["quickbooks_list_customers"]()
|
|
assert calls[0] == "https://sandbox-quickbooks.api.intuit.com/v3/company/r1/query"
|
|
|
|
|
|
def test_new_connector_validators_wired():
|
|
from coworker.connectors.descriptors import get_descriptor
|
|
|
|
for name in (
|
|
"linear",
|
|
"gitlab",
|
|
"discord",
|
|
"asana",
|
|
"hubspot",
|
|
"dropbox",
|
|
"box",
|
|
"quickbooks",
|
|
"whatsapp",
|
|
"clickup",
|
|
"close",
|
|
"figma",
|
|
"google_drive",
|
|
"docusign",
|
|
"canva",
|
|
):
|
|
assert get_descriptor(name).validate is not None, name
|
|
# stripe restricted-key permissions vary, so it has no whoami validator
|
|
assert get_descriptor("stripe").validate is None
|
|
|
|
|
|
def test_validate_whoami_helper(monkeypatch):
|
|
import coworker.connectors.descriptors as d
|
|
|
|
class _Resp:
|
|
def __init__(self, status, payload):
|
|
self.status_code = status
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
def fake(status, payload):
|
|
import httpx
|
|
|
|
monkeypatch.setattr(httpx, "request", lambda *a, **k: _Resp(status, payload))
|
|
|
|
fake(200, {"username": "alice"})
|
|
res = d._validate_gitlab({"token": "t"})
|
|
assert res.ok and res.identity == "@alice"
|
|
|
|
fake(401, {"message": "401 Unauthorized"})
|
|
res = d._validate_gitlab({"token": "bad"})
|
|
assert not res.ok and "Unauthorized" in res.error
|
|
|
|
# 200 but unexpected shape (e.g. GraphQL errors payload) must not pass
|
|
fake(200, {"errors": [{"message": "auth"}]})
|
|
res = d._validate_linear({"api_key": "bad"})
|
|
assert not res.ok
|
|
|
|
|
|
# -- experimental connector gating ----------------------------------------------
|
|
@pytest.fixture
|
|
def experimental_descriptor():
|
|
"""Register a synthetic experimental connector through the same hook the
|
|
experimental package uses, and clean it up afterwards."""
|
|
import coworker.connectors.descriptors as d
|
|
|
|
desc = d.ConnectorDescriptor(
|
|
name="dangerzone",
|
|
title="Danger Zone",
|
|
icon="!",
|
|
blurb="Test-only experimental connector.",
|
|
auth="token",
|
|
two_way=False,
|
|
fields=[d.Field("token", "Token", secret=True)],
|
|
instructions=["test only"],
|
|
experimental=True,
|
|
risk_notice="This may eat your laundry.",
|
|
)
|
|
d.register_descriptor(desc)
|
|
yield desc
|
|
d.DESCRIPTORS.remove(desc)
|
|
d._BY_NAME.pop(desc.name, None)
|
|
|
|
|
|
def test_experimental_hidden_until_enabled(tmp_path, experimental_descriptor):
|
|
from coworker.connectors import connector_list, set_experimental_enabled
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
assert "dangerzone" not in {c["name"] for c in connector_list(secrets)}
|
|
|
|
assert set_experimental_enabled(secrets, True)["enabled"] is True
|
|
listed = {c["name"]: c for c in connector_list(secrets)}
|
|
assert listed["dangerzone"]["experimental"] is True
|
|
assert "laundry" in listed["dangerzone"]["risk_notice"]
|
|
# first-party connectors are never flagged
|
|
assert listed["github"]["experimental"] is False
|
|
assert listed["whatsapp"]["experimental"] is False
|
|
|
|
set_experimental_enabled(secrets, False)
|
|
assert "dangerzone" not in {c["name"] for c in connector_list(secrets)}
|
|
|
|
|
|
def test_experimental_connect_requires_optin_and_ack(tmp_path, experimental_descriptor):
|
|
from coworker.connectors import (
|
|
connect_connector,
|
|
connector_list,
|
|
set_experimental_enabled,
|
|
)
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
res = connect_connector(secrets, "dangerzone", {"token": "t"}, validate=False)
|
|
assert res["ok"] is False and "disabled" in res["error"]
|
|
|
|
set_experimental_enabled(secrets, True)
|
|
res = connect_connector(secrets, "dangerzone", {"token": "t"}, validate=False)
|
|
assert res["ok"] is False and "acknowledgment" in res["error"]
|
|
assert "laundry" in res["risk_notice"] # surfaced so the UI can show it
|
|
|
|
res = connect_connector(
|
|
secrets, "dangerzone", {"token": "t"}, validate=False, acknowledged=True
|
|
)
|
|
assert res["ok"] is True
|
|
|
|
# turning the setting off hides it (and gates its tools) even though connected
|
|
set_experimental_enabled(secrets, False)
|
|
assert "dangerzone" not in {c["name"] for c in connector_list(secrets)}
|
|
|
|
|
|
def test_experimental_does_not_gate_regular_connectors(tmp_path):
|
|
from coworker.connectors import connect_connector
|
|
|
|
secrets = SecretStore(tmp_path / "secrets.json")
|
|
res = connect_connector(
|
|
secrets, "github", {"token": "ghp_x"}, validate=False
|
|
) # no acknowledged flag
|
|
assert res["ok"] is True
|
|
|
|
|
|
def test_experimental_rest_roundtrip(tmp_path, monkeypatch, experimental_descriptor):
|
|
from fastapi.testclient import TestClient
|
|
|
|
from coworker.server.app import create_app
|
|
from coworker.server.manager import SessionManager
|
|
|
|
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
|
client = TestClient(create_app(SessionManager(data_dir=tmp_path / "data")))
|
|
|
|
assert client.get("/v1/settings").json()["experimental_connectors"] is False
|
|
names = {c["name"] for c in client.get("/v1/connectors").json()["connectors"]}
|
|
assert "dangerzone" not in names
|
|
|
|
assert (
|
|
client.post(
|
|
"/v1/settings/experimental-connectors", json={"value": True}
|
|
).json()["enabled"]
|
|
is True
|
|
)
|
|
assert client.get("/v1/settings").json()["experimental_connectors"] is True
|
|
names = {c["name"] for c in client.get("/v1/connectors").json()["connectors"]}
|
|
assert "dangerzone" in names
|
|
|
|
r = client.post(
|
|
"/v1/connectors/dangerzone/connect", json={"fields": {"token": "T"}}
|
|
).json()
|
|
assert r["ok"] is False and "acknowledgment" in r["error"]
|
|
r = client.post(
|
|
"/v1/connectors/dangerzone/connect",
|
|
json={"fields": {"token": "T"}, "acknowledge_risk": True},
|
|
).json()
|
|
assert r["ok"] is True
|
|
|
|
|
|
def test_experimental_package_loads_cleanly():
|
|
"""The experimental package import hook is a no-op when the package is empty or absent."""
|
|
from coworker.connectors.descriptors import DESCRIPTORS
|
|
from coworker.connectors.experimental import EXPERIMENTAL_DESCRIPTORS
|
|
|
|
assert EXPERIMENTAL_DESCRIPTORS == []
|
|
assert all(d.experimental is False for d in DESCRIPTORS if d.name != "dangerzone")
|