open-swe/tests/webhooks/test_confluence_webhook.py
Adam Moussa ae1f883b4c
refactor: move tests into tests/<domain>/ layout
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.
2026-07-17 14:42:45 -04:00

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"