"""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 import webapp from agent.utils import atlassian_connect as ac from agent.webhooks import confluence as cf 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( webapp, "fetch_confluence_comment", new=AsyncMock(return_value=server_comment) ), patch.object( webapp, "fetch_confluence_page", new=AsyncMock(return_value={"title": "P", "url": "u"}) ), patch.object(webapp, "get_confluence_user_email", new=AsyncMock(return_value="jane@x.com")), patch.object(webapp, "resolve_login_from_email_async", new=AsyncMock(return_value="jane")), patch.object(webapp, "is_login_mapped", side_effect=lambda login: login in active), patch.object( webapp, "get_repo_config_from_confluence_mapping", return_value={"owner": "o", "name": "n"}, ), patch.object(webapp, "_is_repo_allowed", return_value=True), patch.object(webapp, "generate_thread_id_from_confluence_comment", return_value="th-1"), patch.object( webapp, "upsert_agent_thread_owner_metadata", new=AsyncMock(return_value=None) ), patch.object(webapp, "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(webapp.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"