mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
Applies the plan's C5 step: git mv every test per the domain-reorg
move-map (movemap-m50.txt) into tests/{agent,analyzer,auth,dashboard,
github,middleware,models,reviewer,sandbox,slack,tools,webhooks}/, plus
the 13 fork-only placements from the scoping report §2c (Atlassian
webhook tests -> tests/webhooks/, test_atlassian_connect.py and
test_auth_error_leak.py -> tests/auth/, jira/confluence util tests ->
tests/tools/, test_repo_binding_isolation.py -> tests/sandbox/,
bot-identity/autofix tests -> tests/github/).
Path-only move: the only content edits are parents[1] -> parents[2]
fixes in test_e2b_integration.py and test_daytona_integration.py,
required because their __file__-relative ROOT path gained one more
directory level in the move.
Monkeypatch retargets for these files were already completed in C4;
none remained outstanding here.
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Confluence Connect lifecycle overwrite guard, webhook corroboration, descriptor.
|
|
|
|
The lifecycle tests mock the JWT verifier (verified separately in
|
|
test_atlassian_connect.py) to isolate the accept/verify/store/reject logic —
|
|
especially that a failed re-install/uninstall leaves the stored secret intact.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from agent.utils import atlassian_connect as ac
|
|
from agent.webhooks import common as webhook_common
|
|
from agent.webhooks import confluence as cf
|
|
from agent.webhooks import confluence_routes
|
|
|
|
|
|
def _req() -> object:
|
|
return SimpleNamespace(
|
|
method="POST",
|
|
url=SimpleNamespace(path="/connect/installed", query=""),
|
|
headers={},
|
|
query_params={},
|
|
)
|
|
|
|
|
|
def _install(fn, body: dict[str, Any], *, existing, verify_ok: bool = True):
|
|
puts: list[dict] = []
|
|
dels: list[str] = []
|
|
|
|
async def fake_get(_ck):
|
|
return existing
|
|
|
|
async def fake_put(client_key, shared_secret, base_url, product_type, *, first_install):
|
|
puts.append({"client_key": client_key, "secret": shared_secret, "first": first_install})
|
|
|
|
async def fake_del(client_key):
|
|
dels.append(client_key)
|
|
|
|
with (
|
|
patch.object(ac, "get_installation", new=AsyncMock(side_effect=fake_get)),
|
|
patch.object(ac, "put_installation", new=AsyncMock(side_effect=fake_put)),
|
|
patch.object(ac, "delete_installation", new=AsyncMock(side_effect=fake_del)),
|
|
patch.object(ac, "CONNECT_EXPECTED_CLIENT_KEYS", frozenset({"t"})),
|
|
patch.object(
|
|
ac,
|
|
"verify_asymmetric_install_jwt",
|
|
new=AsyncMock(return_value=({"iss": "t"} if verify_ok else None)),
|
|
),
|
|
):
|
|
code, _detail = asyncio.run(fn(_req(), body))
|
|
return code, puts, dels
|
|
|
|
|
|
# --- first install (trust-on-first-use, host-gated) ------------------------
|
|
|
|
|
|
def test_first_install_stores_secret() -> None:
|
|
code, puts, _ = _install(
|
|
cf.process_install,
|
|
{"clientKey": "t", "sharedSecret": "s1", "baseUrl": "https://x.atlassian.net"},
|
|
existing=None,
|
|
)
|
|
assert code == 204
|
|
assert puts == [{"client_key": "t", "secret": "s1", "first": True}]
|
|
|
|
|
|
def test_first_install_missing_secret_400() -> None:
|
|
code, puts, _ = _install(cf.process_install, {"clientKey": "t"}, existing=None)
|
|
assert code == 400
|
|
assert puts == []
|
|
|
|
|
|
def test_install_bad_signature_rejected() -> None:
|
|
# Even a first install now requires a valid Atlassian signature (no TOFU).
|
|
code, puts, _ = _install(
|
|
cf.process_install,
|
|
{"clientKey": "t", "sharedSecret": "s", "baseUrl": "https://x.atlassian.net"},
|
|
existing=None,
|
|
verify_ok=False,
|
|
)
|
|
assert code == 401
|
|
assert puts == []
|
|
|
|
|
|
def test_install_rejected_when_client_key_not_allowed() -> None:
|
|
# CONF-01: a valid Atlassian signature from a NON-allowlisted tenant (any
|
|
# attacker who installs the public descriptor on their own site) is rejected.
|
|
puts: list = []
|
|
|
|
async def fake_put(*a, **k):
|
|
puts.append(a)
|
|
|
|
with (
|
|
patch.object(ac, "get_installation", new=AsyncMock(return_value=None)),
|
|
patch.object(
|
|
ac, "verify_asymmetric_install_jwt", new=AsyncMock(return_value={"iss": "attacker"})
|
|
),
|
|
patch.object(ac, "CONNECT_EXPECTED_CLIENT_KEYS", frozenset({"our-tenant"})),
|
|
patch.object(ac, "put_installation", new=AsyncMock(side_effect=fake_put)),
|
|
):
|
|
code, _ = asyncio.run(
|
|
cf.process_install(
|
|
_req(),
|
|
{
|
|
"clientKey": "attacker",
|
|
"sharedSecret": "s",
|
|
"baseUrl": "https://attacker.atlassian.net",
|
|
},
|
|
)
|
|
)
|
|
assert code == 403
|
|
assert puts == []
|
|
|
|
|
|
def test_install_bad_host_rejected_when_allowlist_configured() -> None:
|
|
# Defense-in-depth host check (only enforced when CONNECT_EXPECTED_BASE_URL set).
|
|
puts: list = []
|
|
|
|
async def fake_put(*a, **k):
|
|
puts.append(a)
|
|
|
|
with (
|
|
patch.object(ac, "get_installation", new=AsyncMock(return_value=None)),
|
|
patch.object(ac, "verify_asymmetric_install_jwt", new=AsyncMock(return_value={"iss": "t"})),
|
|
patch.object(ac, "CONNECT_EXPECTED_CLIENT_KEYS", frozenset({"t"})),
|
|
patch.object(ac, "CONNECT_EXPECTED_BASE_URL_HOSTS", frozenset({"x.atlassian.net"})),
|
|
patch.object(ac, "base_url_host_allowed", return_value=False),
|
|
patch.object(ac, "put_installation", new=AsyncMock(side_effect=fake_put)),
|
|
):
|
|
code, _ = asyncio.run(
|
|
cf.process_install(
|
|
_req(), {"clientKey": "t", "sharedSecret": "s", "baseUrl": "https://evil.com"}
|
|
)
|
|
)
|
|
assert code == 403
|
|
assert puts == []
|
|
|
|
|
|
# --- re-install overwrite guard (the security-critical path) ---------------
|
|
|
|
_EXISTING = {"client_key": "t", "shared_secret": "stored-secret"}
|
|
|
|
|
|
def test_reinstall_bad_jwt_preserves_stored_secret() -> None:
|
|
code, puts, _ = _install(
|
|
cf.process_install,
|
|
{"clientKey": "t", "sharedSecret": "attacker", "baseUrl": "https://x.atlassian.net"},
|
|
existing=_EXISTING,
|
|
verify_ok=False,
|
|
)
|
|
assert code == 401
|
|
assert puts == [] # stored secret NOT overwritten
|
|
|
|
|
|
def test_reinstall_valid_jwt_overwrites() -> None:
|
|
code, puts, _ = _install(
|
|
cf.process_install,
|
|
{"clientKey": "t", "sharedSecret": "rotated", "baseUrl": "https://x.atlassian.net"},
|
|
existing=_EXISTING,
|
|
verify_ok=True,
|
|
)
|
|
assert code == 204
|
|
assert puts == [{"client_key": "t", "secret": "rotated", "first": False}]
|
|
|
|
|
|
# --- uninstall guard -------------------------------------------------------
|
|
|
|
|
|
def test_uninstall_bad_jwt_keeps_record() -> None:
|
|
code, _puts, dels = _install(
|
|
cf.process_uninstall, {"clientKey": "t"}, existing=_EXISTING, verify_ok=False
|
|
)
|
|
assert code == 401
|
|
assert dels == []
|
|
|
|
|
|
def test_uninstall_valid_jwt_deletes() -> None:
|
|
code, _puts, dels = _install(
|
|
cf.process_uninstall, {"clientKey": "t"}, existing=_EXISTING, verify_ok=True
|
|
)
|
|
assert code == 204
|
|
assert dels == ["t"]
|
|
|
|
|
|
def test_uninstall_no_record_idempotent() -> None:
|
|
code, _puts, dels = _install(cf.process_uninstall, {"clientKey": "t"}, existing=None)
|
|
assert code == 204
|
|
assert dels == []
|
|
|
|
|
|
# --- webhook corroboration (identity/body from server, not payload) --------
|
|
|
|
|
|
def _run_comment(payload: dict, server_comment: dict | None, *, active: set[str] | None = None):
|
|
captured: dict = {}
|
|
active = {"jane"} if active is None else active
|
|
|
|
async def fake_dispatch(
|
|
thread_id, content, configurable, *, source, metadata=None, client=None
|
|
):
|
|
captured["configurable"] = configurable
|
|
captured["source"] = source
|
|
return {"run_id": "r1"}
|
|
|
|
with (
|
|
patch.object(
|
|
webhook_common, "fetch_confluence_comment", new=AsyncMock(return_value=server_comment)
|
|
),
|
|
patch.object(
|
|
webhook_common,
|
|
"fetch_confluence_page",
|
|
new=AsyncMock(return_value={"title": "P", "url": "u"}),
|
|
),
|
|
patch.object(
|
|
webhook_common, "get_confluence_user_email", new=AsyncMock(return_value="jane@x.com")
|
|
),
|
|
patch.object(
|
|
webhook_common, "resolve_login_from_email_async", new=AsyncMock(return_value="jane")
|
|
),
|
|
patch.object(webhook_common, "is_login_mapped", side_effect=lambda login: login in active),
|
|
patch.object(
|
|
webhook_common,
|
|
"get_repo_config_from_confluence_mapping",
|
|
return_value={"owner": "o", "name": "n"},
|
|
),
|
|
patch.object(webhook_common, "_is_repo_allowed", return_value=True),
|
|
patch.object(
|
|
webhook_common, "generate_thread_id_from_confluence_comment", return_value="th-1"
|
|
),
|
|
patch.object(
|
|
webhook_common, "upsert_agent_thread_owner_metadata", new=AsyncMock(return_value=None)
|
|
),
|
|
patch.object(webhook_common, "dispatch_agent_run", side_effect=fake_dispatch),
|
|
):
|
|
asyncio.run(cf.process_confluence_comment(payload))
|
|
return captured
|
|
|
|
|
|
def _server_comment(
|
|
*, account_id="real", name="Real", body="@openswe fix it", space="IT", page="99"
|
|
):
|
|
return {
|
|
"author": {"account_id": account_id, "name": name},
|
|
"body": body,
|
|
"page_id": page,
|
|
"space_key": space,
|
|
}
|
|
|
|
|
|
def test_webhook_uses_server_comment_not_payload() -> None:
|
|
payload = {"comment": {"id": "555"}, "userAccountId": "victim", "body": "benign"}
|
|
cap = _run_comment(payload, _server_comment())
|
|
assert cap["source"] == "confluence"
|
|
conf = cap["configurable"]["confluence"]
|
|
assert conf["comment_id"] == "555"
|
|
assert conf["space_key"] == "IT"
|
|
assert cap["configurable"]["github_login"] == "jane"
|
|
|
|
|
|
def test_webhook_uncorroborated_comment_dropped() -> None:
|
|
cap = _run_comment({"comment": {"id": "555"}}, None)
|
|
assert cap == {} # no dispatch
|
|
|
|
|
|
def test_webhook_without_mention_dropped() -> None:
|
|
cap = _run_comment({"comment": {"id": "555"}}, _server_comment(body="just a normal comment"))
|
|
assert cap == {}
|
|
|
|
|
|
def test_webhook_pending_mapping_unattributed() -> None:
|
|
cap = _run_comment({"comment": {"id": "555"}}, _server_comment(), active=set())
|
|
assert "github_login" not in cap["configurable"]
|
|
|
|
|
|
def test_webhook_bot_own_comment_dropped() -> None:
|
|
# CONF-02: a comment authored by the app's own account is ignored (no loop).
|
|
with patch.object(cf, "CONFLUENCE_BOT_ACCOUNT_ID", "bot-acct"):
|
|
cap = _run_comment({"comment": {"id": "555"}}, _server_comment(account_id="bot-acct"))
|
|
assert cap == {}
|
|
|
|
|
|
# --- descriptor ------------------------------------------------------------
|
|
|
|
|
|
def test_descriptor_signed_install_true_and_read_scope() -> None:
|
|
desc = asyncio.run(confluence_routes.connect_descriptor())
|
|
assert desc["apiMigrations"]["signed-install"] is True
|
|
assert desc["scopes"] == ["READ"]
|
|
assert desc["authentication"]["type"] == "jwt"
|
|
assert desc["modules"]["webhooks"][0]["event"] == "comment_created"
|