mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 12:43:16 +00:00
* 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.
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""Tests for the Slack→GitHub account-link OAuth threading."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from agent.dashboard import oauth
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("DASHBOARD_JWT_SECRET", "test-secret")
|
|
|
|
|
|
def test_account_link_round_trip() -> None:
|
|
token = oauth.issue_account_link(slack_user_id="U123", work_email="dev@x.com")
|
|
payload = oauth.decode_account_link(token)
|
|
assert payload is not None
|
|
assert payload["slack_user_id"] == "U123"
|
|
assert payload["work_email"] == "dev@x.com"
|
|
assert payload["kind"] == "account_link"
|
|
|
|
|
|
def test_decode_account_link_rejects_garbage() -> None:
|
|
assert oauth.decode_account_link("") is None
|
|
assert oauth.decode_account_link("not-a-jwt") is None
|
|
|
|
|
|
def test_decode_account_link_rejects_wrong_kind() -> None:
|
|
# A session token is a valid JWT but not an account-link token.
|
|
session = oauth.issue_session(login="x", email="x@x.com", avatar_url=None)
|
|
assert oauth.decode_account_link(session) is None
|
|
|
|
|
|
def test_build_account_link_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("DASHBOARD_API_BASE_URL", "https://api.example.com/")
|
|
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
|
|
url = oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com")
|
|
assert url is not None
|
|
assert url.startswith("https://api.example.com/dashboard/api/auth/login?link=")
|
|
# The embedded token must decode back to the same identity.
|
|
token = url.split("link=", 1)[1].split("&", 1)[0]
|
|
from urllib.parse import unquote
|
|
|
|
payload = oauth.decode_account_link(unquote(token))
|
|
assert payload["slack_user_id"] == "U1"
|
|
|
|
|
|
def test_build_account_link_url_redirects_to_profile_settings(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("DASHBOARD_API_BASE_URL", "https://api.example.com")
|
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
|
url = oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com")
|
|
assert url is not None
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
query = parse_qs(urlparse(url).query)
|
|
assert query["redirect_to"] == ["https://app.example.com/my-settings"]
|
|
|
|
|
|
def test_build_account_link_url_none_without_base(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("DASHBOARD_API_BASE_URL", raising=False)
|
|
assert oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com") is None
|
|
|
|
|
|
def test_account_link_prompt_posts_generic_token_free_link(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The prompt posts a plain settings link in the thread — no per-user token."""
|
|
import asyncio
|
|
|
|
from agent import webapp
|
|
|
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
|
calls: dict[str, object] = {}
|
|
|
|
async def fake_reply(channel_id, thread_ts, text):
|
|
calls["reply"] = {"channel_id": channel_id, "thread_ts": thread_ts, "text": text}
|
|
return True
|
|
|
|
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
|
|
|
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="unlinked"))
|
|
assert calls["reply"]["channel_id"] == "C1"
|
|
assert calls["reply"]["thread_ts"] == "1.1"
|
|
assert "https://app.example.com/my-settings" in calls["reply"]["text"]
|
|
# No signed account-link token may appear in the public thread.
|
|
assert "link=" not in calls["reply"]["text"]
|
|
|
|
|
|
def test_account_link_prompt_revoked_wording(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import asyncio
|
|
|
|
from agent import webapp
|
|
|
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
|
calls: dict[str, object] = {}
|
|
|
|
async def fake_reply(channel_id, thread_ts, text):
|
|
calls["text"] = text
|
|
return True
|
|
|
|
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
|
|
|
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="revoked"))
|
|
assert "no longer valid" in calls["text"]
|
|
assert "link=" not in calls["text"]
|
|
|
|
|
|
def test_account_link_prompt_skips_when_dashboard_url_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import asyncio
|
|
|
|
from agent import webapp
|
|
|
|
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
|
|
posted = False
|
|
|
|
async def fake_reply(channel_id, thread_ts, text):
|
|
nonlocal posted
|
|
posted = True
|
|
return True
|
|
|
|
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
|
|
|
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="unlinked"))
|
|
assert posted is False
|