Files
openworker/tests/test_connectors.py
T

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")