open-swe/tests/test_auth_sources.py
Johannes du Plessis c8a997c125
fix: reliable, safe Slack account-connect prompt + first-login Slack dialog (#1383)
* fix: deliver Slack account-link prompt as a visible threaded reply

Blocked Slack users got no prompt at all. Prod logs show chat.postEphemeral
returns ok, but ephemeral messages are silently dropped in Slack's assistant
threads (where Open SWE runs), so the user sees nothing. Post the prompt as a
normal threaded reply instead — the same channel the agent uses to reply.

* fix: deliver Slack auth-failure prompt as a visible threaded reply

leave_failure_comment() tried an ephemeral message first and only fell back
to a thread reply on failure. Ephemeral messages succeed (ok) but are dropped
in Slack's assistant threads, so the fallback never fired and the user saw no
auth-failure prompt. Post the visible threaded reply directly, matching the
account-link prompt fix.

* fix: prompt blocked Slack users with a generic, token-free dashboard link

Addresses the review findings that posting the per-user account-link token /
auth URL in a visible thread lets any channel member bind their GitHub account
to the triggering user's Slack identity.

Drop the per-user signed link entirely. Both the account-link prompt
(_post_account_link_prompt) and the runtime auth-failure prompt
(leave_failure_comment) now post a plain dashboard settings link
(build_settings_url) as a visible threaded reply. The user signs in with GitHub
from their own session and connects Slack via verified OIDC on the settings
page — no secret in the thread, nothing to hijack, and no DM machinery.

* feat: nudge first-time users to connect Slack from the dashboard home

Show a Connect Slack banner on the agents landing page whenever Slack OAuth is
enabled and the user hasn't linked Slack yet. A first-time user (no Slack
mapping) sees it immediately after signing in; it disappears once connected.

* feat: prompt first-time users to connect Slack via a dialog

Replace the inline Connect Slack card on the agents home with a modal dialog
(Base UI). It opens automatically once the mapping query resolves to
"not connected" and closes itself once Slack is linked; "Maybe later" dismisses
it for the session. No new dependency — uses the design system's Base UI.

* copy: frame Slack connect as resolving the user's GitHub account

Drop 'act/reply on your behalf' wording across the connect-Slack dialog, the
Slack thread prompts (blocked + auth-failure), and the settings description.
Connecting Slack lets Open SWE resolve the user's GitHub account when they tag
it in Slack.
2026-06-02 20:55:07 -07:00

168 lines
5.8 KiB
Python

from __future__ import annotations
import asyncio
import pytest
from agent.utils import auth
def test_leave_failure_comment_posts_generic_token_free_slack_notice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Slack auth failures post a generic notice, never the (possibly sensitive) message."""
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
thread_called: dict[str, str] = {}
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool:
thread_called["channel_id"] = channel_id
thread_called["thread_ts"] = thread_ts
thread_called["message"] = message
return True
monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply)
monkeypatch.setattr(
auth,
"get_config",
lambda: {
"configurable": {
"slack_thread": {
"channel_id": "C123",
"thread_ts": "1.2",
"triggering_user_id": "U123",
}
}
},
)
# Pass a message that embeds a per-user auth URL; it must NOT be echoed publicly.
asyncio.run(auth.leave_failure_comment("slack", "Click https://auth.example/secret-token"))
assert thread_called["channel_id"] == "C123"
assert thread_called["thread_ts"] == "1.2"
assert "secret-token" not in thread_called["message"]
assert "https://app.example.com/my-settings" in thread_called["message"]
def _slack_config(github_login: str | None = "mason-gh") -> dict:
configurable: dict = {
"source": "slack",
"user_email": "mason@example.com",
"thread_id": "t1",
}
if github_login is not None:
configurable["github_login"] = github_login
return {"configurable": configurable}
def _stub_dashboard_store(
monkeypatch: pytest.MonkeyPatch,
*,
token: str | None,
expires_at: str | None = "2099-01-01T00:00:00Z",
cached: tuple[str | None, str | None, str | None] = (None, None, None),
) -> None:
from agent.dashboard import profiles
async def fake_get_from_thread(thread_id: str):
return cached
async def fake_get_valid(login: str):
return token
async def fake_get_value(namespace, key):
return {"token_expires_at": expires_at}
async def fake_persist(thread_id: str, tok: str, expires_at: str | None = None):
return "enc"
monkeypatch.setattr(auth, "get_github_token_from_thread", fake_get_from_thread)
monkeypatch.setattr(auth, "persist_encrypted_github_token", fake_persist)
monkeypatch.setattr(profiles, "get_valid_access_token", fake_get_valid)
monkeypatch.setattr(profiles, "_get_value", fake_get_value)
def test_resolve_github_token_slack_uses_dashboard_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, encrypted, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert token == "user-tok"
assert encrypted == "enc"
assert expires_at == "2099-01-01T00:00:00Z"
def test_resolve_github_token_slack_ignores_stale_thread_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Slack thread ids are shared, so a prior user's cached token must NOT be
# returned. Resolution always goes by github_login via the dashboard store.
_stub_dashboard_store(
monkeypatch,
token="bob-token",
cached=("alice-token", "alice-enc", "2099-01-01T00:00:00Z"),
)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, _, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert token == "bob-token"
def test_resolve_github_token_slack_no_token_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
with pytest.raises(auth.GitHubUserAuthRequired):
asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
def test_resolve_github_token_per_user_wins_over_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fail_bot(thread_id: str):
raise AssertionError("bot token must not be used when a user token exists")
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fail_bot)
token, _, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert token == "user-tok"
def test_resolve_github_token_slack_no_token_falls_back_to_bot_in_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fake_bot(thread_id: str):
return ("bot-tok", "bot-enc", None)
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
token, encrypted, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert (token, encrypted, expires_at) == ("bot-tok", "bot-enc", None)
@pytest.mark.parametrize("source", ["github", "linear"])
def test_resolve_github_token_bot_only_mode_non_slack_uses_bot(
monkeypatch: pytest.MonkeyPatch, source: str
) -> None:
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fake_bot(thread_id: str):
return ("bot-tok", "bot-enc", None)
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
config = {"configurable": {"source": source, "github_login": "octo", "thread_id": "t1"}}
token, _, _ = asyncio.run(auth.resolve_github_token(config, "t1"))
assert token == "bot-tok"