2026-06-04 12:58:30 -07:00
|
|
|
"""Tests for the Slack account-link prompt."""
|
feat: Store-backed GitHub/Slack user mapping (self-service + admin) (#1369)
* Replace hardcoded GitHub-email map with Store-backed user mapping
Move the static GITHUB_USER_EMAIL_MAP to a Store-backed bidirectional
mapping (GitHub login <-> work email <-> optional Slack ID) with an
in-process cache, self-service onboarding, and admin management.
- agent/dashboard/user_mappings.py: Store CRUD + login/email/slack-id
indexes, sync cache readers for hot paths, async fallthrough, and a
bulk_import that preserves existing richer records.
- Migrate all read sites (auth.py, agent_overrides.py, authorship.py,
github_comments.py, webapp.py x2) off the dict.
- Unmapped Slack tags now run on the GitHub App installation token
(use_installation_token_fallback) and get an ephemeral "link your
GitHub account" prompt carrying the Slack id + email via a signed
account-link token threaded through the OAuth state.
- OAuth callback completes a self-service (org-gated) mapping from that
token, falling back to the verified GitHub email.
- Admin CRUD endpoints + one-time legacy import; dashboard UI section.
- Legacy dict retained only as the import payload (no longer read).
Tests: mapping store, account-link round-trip + completion, mapped vs
unmapped Slack flows; existing trust-gate tests updated to prime cache.
* Address review: cold-cache email resolution + stale alias de-indexing
- agent_overrides: add resolve_login_from_email_async that falls through to
the Store on a cold cache; use it at the async repo-resolution call sites
(Slack repo config, Linear comment, owner-metadata) so a mapped user still
resolves to their GitHub login + dashboard default_repo on a fresh worker.
- user_mappings.upsert_mapping: de-index the existing login before re-indexing
so a changed email/Slack id no longer leaves stale aliases resolving to the
login in-process.
- Tests for both fixes; update Slack repo-config test to patch the async resolver.
2026-06-01 14:37:19 -07:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("DASHBOARD_JWT_SECRET", "test-secret")
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
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
|