"""Unit tests for agent_team.confluence.client (OAuth 2LO + basic auth + dry-run updater). Fully hermetic: the HTTP transport is dependency-injected with an in-memory fake :class:`HttpClient`, so no network call, OAuth token, or live Confluence is exercised. Credentials are injected via ``monkeypatch.setenv``/``delenv`` and never read from a real environment. """ from __future__ import annotations import base64 import json from typing import Any from urllib import parse as _urlparse import pytest from agent_team.nodes.confluence_writer_llm import ( ConfluenceDraftError, parse_confluence_reply, ) from agent_team.confluence.client import ( ACCESSIBLE_RESOURCES_URL, ATLASSIAN_API_BASE, DEFAULT_OAUTH_TOKEN_URL, ConfluenceAuth, ConfluenceClient, ConfluenceError, PlannedPageUpdate, body_diff, count_storage_macros, init_auth, storage_macro_signature, ) # --------------------------------------------------------------------------- # # Fakes / fixtures # --------------------------------------------------------------------------- # _CONFLUENCE_ENV_KEYS = ( "CONFLUENCE_OAUTH_CLIENT_ID", "CONFLUENCE_OAUTH_CLIENT_SECRET", "CONFLUENCE_OAUTH_TOKEN_URL", "CONFLUENCE_CLOUD_ID", "CONFLUENCE_BASE_URL", "CONFLUENCE_EMAIL", "CONFLUENCE_API_TOKEN", ) class FakeHttp: """In-memory :class:`HttpClient` recording calls and returning scripted ``(status, body)`` per verb. ``responses`` maps a verb to a list of scripted ``(status, body)`` tuples, consumed in order; a single tuple is reused for every call to that verb. """ def __init__( self, *, get: Any = None, post: Any = None, put: Any = None, ) -> None: self._scripts: dict[str, Any] = {"get": get, "post": post, "put": put} self.calls: list[dict[str, Any]] = [] def _next(self, verb: str) -> tuple[int, Any]: script = self._scripts[verb] if script is None: raise AssertionError(f"unexpected {verb.upper()} call") if isinstance(script, list): return script.pop(0) return script def get( self, url: str, *, headers: dict[str, str] ) -> tuple[int, dict[str, Any] | bytes]: self.calls.append({"verb": "get", "url": url, "headers": headers}) return self._next("get") def post( self, url: str, *, headers: dict[str, str], data: bytes ) -> tuple[int, dict[str, Any] | bytes]: self.calls.append( {"verb": "post", "url": url, "headers": headers, "data": data} ) return self._next("post") def put( self, url: str, *, headers: dict[str, str], data: bytes ) -> tuple[int, dict[str, Any] | bytes]: self.calls.append({"verb": "put", "url": url, "headers": headers, "data": data}) return self._next("put") def _clear_confluence_env(monkeypatch: pytest.MonkeyPatch) -> None: for key in _CONFLUENCE_ENV_KEYS: monkeypatch.delenv(key, raising=False) def _storage_page(page_id: str, version: int, value: str) -> dict[str, Any]: return { "id": page_id, "title": "Existing Title", "version": {"number": version}, "body": {"storage": {"representation": "storage", "value": value}}, } # --------------------------------------------------------------------------- # # OAuth 2LO path # --------------------------------------------------------------------------- # def test_oauth_token_post_and_cloud_id_from_env( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret") monkeypatch.setenv("CONFLUENCE_CLOUD_ID", "cloud-123") http = FakeHttp(post=(200, {"access_token": "tok-abc"})) auth = init_auth(http) assert auth.mode == "oauth" assert auth.base == f"{ATLASSIAN_API_BASE}/cloud-123" assert auth.base == "https://api.atlassian.com/ex/confluence/cloud-123" assert auth.headers()["Authorization"] == "Bearer tok-abc" # Exactly one POST (the token request) — cloudId came from env, no GET. (post_call,) = [c for c in http.calls if c["verb"] == "post"] assert post_call["url"] == DEFAULT_OAUTH_TOKEN_URL form = dict(_urlparse.parse_qsl(post_call["data"].decode("utf-8"))) assert form["grant_type"] == "client_credentials" assert form["client_id"] == "cid" assert form["client_secret"] == "secret" assert not [c for c in http.calls if c["verb"] == "get"] def test_oauth_cloud_id_resolved_from_accessible_resources( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret") monkeypatch.setenv("CONFLUENCE_BASE_URL", "https://seahaven.atlassian.net") resources = [ {"id": "other-cloud", "url": "https://other.atlassian.net"}, {"id": "match-cloud", "url": "https://seahaven.atlassian.net"}, ] http = FakeHttp( post=(200, {"access_token": "tok-xyz"}), get=(200, resources), ) auth = init_auth(http) # Site URL matches the second resource, so its id wins. assert auth.base == f"{ATLASSIAN_API_BASE}/match-cloud" get_call = next(c for c in http.calls if c["verb"] == "get") assert get_call["url"] == ACCESSIBLE_RESOURCES_URL assert get_call["headers"]["Authorization"] == "Bearer tok-xyz" def test_oauth_cloud_id_falls_back_to_first_resource( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret") # No CONFLUENCE_BASE_URL -> no site match -> first resource id wins. resources = [{"id": "first-cloud", "url": "https://a.atlassian.net"}] http = FakeHttp(post=(200, {"access_token": "t"}), get=(200, resources)) auth = init_auth(http) assert auth.base == f"{ATLASSIAN_API_BASE}/first-cloud" def test_oauth_token_request_failure_raises_skip( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret") http = FakeHttp(post=(401, {"error": "invalid_client"})) with pytest.raises(ConfluenceError) as exc: init_auth(http) assert exc.value.status == 0 # conf_api_init-style "skip, no false alarm". def test_oauth_unresolvable_cloud_id_raises_skip( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret") http = FakeHttp(post=(200, {"access_token": "t"}), get=(200, [])) with pytest.raises(ConfluenceError) as exc: init_auth(http) assert exc.value.status == 0 def test_oauth_token_url_override(monkeypatch: pytest.MonkeyPatch) -> None: _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret") monkeypatch.setenv("CONFLUENCE_CLOUD_ID", "cloud-1") monkeypatch.setenv("CONFLUENCE_OAUTH_TOKEN_URL", "https://custom.example/oauth") http = FakeHttp(post=(200, {"access_token": "t"})) init_auth(http) post_call = next(c for c in http.calls if c["verb"] == "post") assert post_call["url"] == "https://custom.example/oauth" # --------------------------------------------------------------------------- # # Basic-auth fallback # --------------------------------------------------------------------------- # def test_basic_auth_fallback_when_oauth_absent( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_BASE_URL", "https://seahaven.atlassian.net/") monkeypatch.setenv("CONFLUENCE_EMAIL", "adam@seahavenind.com") monkeypatch.setenv("CONFLUENCE_API_TOKEN", "api-tok") http = FakeHttp() # No HTTP call expected for basic-auth resolution. auth = init_auth(http) assert auth.mode == "basic" # Trailing slash stripped. assert auth.base == "https://seahaven.atlassian.net" expected = base64.b64encode(b"adam@seahavenind.com:api-tok").decode("ascii") assert auth.headers()["Authorization"] == f"Basic {expected}" assert http.calls == [] def test_oauth_wins_over_basic_when_both_present( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret") monkeypatch.setenv("CONFLUENCE_CLOUD_ID", "cloud-1") monkeypatch.setenv("CONFLUENCE_BASE_URL", "https://seahaven.atlassian.net") monkeypatch.setenv("CONFLUENCE_EMAIL", "adam@seahavenind.com") monkeypatch.setenv("CONFLUENCE_API_TOKEN", "api-tok") http = FakeHttp(post=(200, {"access_token": "tok"})) auth = init_auth(http) assert auth.mode == "oauth" # --------------------------------------------------------------------------- # # Missing-creds path (detectable skip, no crash) # --------------------------------------------------------------------------- # def test_missing_creds_raises_skip_status_zero( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) http = FakeHttp() with pytest.raises(ConfluenceError) as exc: init_auth(http) assert exc.value.status == 0 # mirrors conf_api_init returning non-zero. assert http.calls == [] def test_incomplete_basic_creds_raises_skip( monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_confluence_env(monkeypatch) # Base URL + email but NO api token -> not fully configured -> skip. monkeypatch.setenv("CONFLUENCE_BASE_URL", "https://seahaven.atlassian.net") monkeypatch.setenv("CONFLUENCE_EMAIL", "adam@seahavenind.com") with pytest.raises(ConfluenceError) as exc: init_auth(FakeHttp()) assert exc.value.status == 0 def test_client_uses_injected_env_mapping() -> None: # No monkeypatch: the explicit env mapping is read at call time, not import. env = { "CONFLUENCE_BASE_URL": "https://seahaven.atlassian.net", "CONFLUENCE_EMAIL": "adam@seahavenind.com", "CONFLUENCE_API_TOKEN": "tok", } page = _storage_page("100", 4, "
hi
") http = FakeHttp(get=(200, page)) client = ConfluenceClient(http=http, env=env) fetched = client.get_page("100") assert fetched["version"]["number"] == 4 # Assert the request host EXACTLY (scheme+netloc parsed), not a string prefix: # a `.startswith("https://seahaven.atlassian.net")` check passes for a spoofed # host like `https://seahaven.atlassian.net.evil.com` (CodeQL: incomplete URL # substring sanitization). Compare the parsed components instead. parts = _urlparse.urlsplit(http.calls[0]["url"]) assert (parts.scheme, parts.netloc) == ("https", "seahaven.atlassian.net") assert parts.path.startswith("/wiki/api/v2/pages/100") # --------------------------------------------------------------------------- # # get_page # --------------------------------------------------------------------------- # def test_get_page_parses_storage_format() -> None: page = _storage_page("12345", 7, "Current body
") http = FakeHttp(get=(200, page)) auth = ConfluenceAuth( mode="oauth", base="https://api.atlassian.com/ex/confluence/cloud-1", _headers={"Authorization": "Bearer t", "Accept": "application/json"}, ) client = ConfluenceClient(http=http, auth=auth) result = client.get_page("12345") assert result["version"]["number"] == 7 assert result["body"]["storage"]["value"] == "Current body
" (call,) = http.calls assert call["url"] == ( "https://api.atlassian.com/ex/confluence/cloud-1" "/wiki/api/v2/pages/12345?body-format=storage" ) def test_get_page_encodes_injected_id_in_request_path() -> None: # Defense-in-depth: a malformed (injected) id must not appear verbatim in the # request path — it would otherwise rewrite the authenticated request # (request-path injection carrying the service-account credential). page = _storage_page("123", 1, "x
") http = FakeHttp(get=(200, page)) auth = ConfluenceAuth(mode="basic", base="https://x.atlassian.net") client = ConfluenceClient(http=http, auth=auth) injected = "123?body-format=atlas_doc_format" client.get_page(injected) (call,) = http.calls # The raw injected query must NOT survive as a literal path/query segment. assert injected not in call["url"] # The whole id is percent-encoded into a single path segment; the "?" that # would start a new query string is escaped to %3F and cannot inject. assert ( "/pages/123%3Fbody-format%3Datlas_doc_format?body-format=storage" in call["url"] ) def test_get_page_non_2xx_raises_with_status() -> None: http = FakeHttp(get=(404, {"message": "Not Found"})) auth = ConfluenceAuth(mode="basic", base="https://x.atlassian.net") client = ConfluenceClient(http=http, auth=auth) with pytest.raises(ConfluenceError) as exc: client.get_page("999") assert exc.value.status == 404 assert "Not Found" in exc.value.body # --------------------------------------------------------------------------- # # update_page — dry-run (default) issues NO PUT # --------------------------------------------------------------------------- # def _basic_client(http: FakeHttp) -> ConfluenceClient: auth = ConfluenceAuth( mode="basic", base="https://seahaven.atlassian.net", _headers={"Authorization": "Basic x", "Accept": "application/json"}, ) return ConfluenceClient(http=http, auth=auth) def test_update_page_dry_run_default_issues_no_put() -> None: current = _storage_page("777", 3, "old
") http = FakeHttp(get=(200, current)) # PUT script omitted -> would assert. client = _basic_client(http) planned = client.update_page("777", "New Title", "new
", 3) assert isinstance(planned, PlannedPageUpdate) assert planned.applied is False assert planned.new_version == 4 # current + 1 assert planned.current_version == 3 assert planned.body_storage == "new
" # A unified diff against the current body shows the change. assert "old
" in planned.body_delta assert "new
" in planned.body_delta # Only the read (GET) happened — NO PUT. assert [c["verb"] for c in http.calls] == ["get"] def test_update_page_dry_run_diff_against_empty_when_read_fails() -> None: # get_page returns 404 -> client swallows and diffs against an empty baseline. http = FakeHttp(get=(404, {"message": "gone"})) client = _basic_client(http) planned = client.update_page("404id", "T", "brand new
", 0) assert planned.applied is False assert planned.new_version == 1 assert "brand new
" in planned.body_delta # --------------------------------------------------------------------------- # # update_page — apply=True issues the PUT # --------------------------------------------------------------------------- # def test_update_page_apply_true_issues_put() -> None: current = _storage_page("555", 9, "old
") updated = _storage_page("555", 10, "updated
") http = FakeHttp(get=(200, current), put=(200, updated)) client = _basic_client(http) result = client.update_page("555", "Title", "updated
", 9, apply=True) assert result.applied is True assert result.new_version == 10 put_call = next(c for c in http.calls if c["verb"] == "put") assert put_call["url"] == "https://seahaven.atlassian.net/wiki/api/v2/pages/555" payload = json.loads(put_call["data"].decode("utf-8")) assert payload["id"] == "555" assert payload["status"] == "current" assert payload["title"] == "Title" assert payload["version"]["number"] == 10 assert payload["body"]["representation"] == "storage" assert payload["body"]["value"] == "updated
" assert put_call["headers"]["Content-Type"] == "application/json" def test_update_page_apply_true_non_2xx_put_raises() -> None: current = _storage_page("321", 1, "x
") http = FakeHttp(get=(200, current), put=(409, {"message": "version conflict"})) client = _basic_client(http) with pytest.raises(ConfluenceError) as exc: client.update_page("321", "T", "y
", 1, apply=True) assert exc.value.status == 409 assert "version conflict" in exc.value.body # --------------------------------------------------------------------------- # # Pure planning helper # --------------------------------------------------------------------------- # def test_parse_confluence_reply_rejects_non_numeric_page_id() -> None: # Layer-1 guard: an LLM-supplied page_id that is not a bare numeric content # id (here carrying a request-path-injection payload) must fail the draft. reply = json.dumps( { "title": "T", "body_storage": "b
", "page_id": "123?body-format=atlas_doc_format", } ) with pytest.raises(ConfluenceDraftError): parse_confluence_reply(reply) def test_parse_confluence_reply_accepts_numeric_page_id() -> None: reply = json.dumps({"title": "T", "body_storage": "b
", "page_id": "123456"}) draft = parse_confluence_reply(reply) assert draft["page_id"] == "123456" def test_body_diff_empty_when_identical() -> None: assert body_diff("same
", "same
", page_id="1") == "" def test_body_diff_shows_added_and_removed() -> None: delta = body_diff("line a\nline b\n", "line a\nline c\n", page_id="99") assert "page/99@current" in delta assert "page/99@planned" in delta assert "-line b" in delta assert "+line c" in delta # --------------------------------------------------------------------------- # # count_storage_macros + page_has_macros (macro-preservation support) # --------------------------------------------------------------------------- # def test_count_storage_macros_counts_structured_and_adf_extensions() -> None: body = ( 'text
" 'no macros here
") == 0 def test_storage_macro_signature_keys_by_identity() -> None: body = ( 'plain
") http = FakeHttp(get=(200, page)) auth = ConfluenceAuth(mode="basic", base="https://x.atlassian.net") client = ConfluenceClient(http=http, auth=auth) assert client.page_has_macros("100") is False def test_oauth_cloud_id_ambiguous_multiple_resources_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: # AUTHZ-CONF-02: with no site_url to disambiguate and MULTIPLE accessible # sites, refuse to silently pick the first — require CONFLUENCE_CLOUD_ID. _clear_confluence_env(monkeypatch) monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_ID", "cid") monkeypatch.setenv("CONFLUENCE_OAUTH_CLIENT_SECRET", "secret") resources = [ {"id": "site-a", "url": "https://a.atlassian.net"}, {"id": "site-b", "url": "https://b.atlassian.net"}, ] http = FakeHttp(post=(200, {"access_token": "t"}), get=(200, resources)) with pytest.raises(ConfluenceError, match="multiple accessible Confluence sites"): init_auth(http)