This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/tests/test_confluence_client.py
Adam Moussa c7f9c1bac2 feat(agent-team): Confluence-writer node (draft -> approve gate -> write)
Add a Confluence documentation lane to the Plane-2 pipeline, flag-gated behind
AGENT_TEAM_CONFLUENCE_ENABLED (default off; daemon behavior unchanged when off).

- confluence/client.py: OAuth 2LO + Basic REST client, dry-run-default writes
- confluence/mermaid.py: vendored ADF-only Mermaid editor (macro-count +
  revert-diff guards, dry-run default)
- nodes/confluence_writer.py(+_llm): conf_draft -> conf_gate -> conf_write,
  both direct (task_kind=confluence) and post-build documentation flows
- task_model/graph/coordinator: new phases, state channels, route_after_intake,
  CONFLUENCE_APPROVAL_KIND gate delivery, task_kind forwarding
- db schema v5: widen pending_questions kind CHECK (atomic rebuild)
- tests for client, mermaid, writer node, ledger v5, coordinator gate, e2e
2026-06-25 10:56:48 -04:00

473 lines
17 KiB
Python

"""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,
init_auth,
)
# --------------------------------------------------------------------------- #
# 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, "<p>hi</p>")
http = FakeHttp(get=(200, page))
client = ConfluenceClient(http=http, env=env)
fetched = client.get_page("100")
assert fetched["version"]["number"] == 4
assert http.calls[0]["url"].startswith("https://seahaven.atlassian.net")
# --------------------------------------------------------------------------- #
# get_page
# --------------------------------------------------------------------------- #
def test_get_page_parses_storage_format() -> None:
page = _storage_page("12345", 7, "<p>Current body</p>")
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"] == "<p>Current body</p>"
(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, "<p>x</p>")
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, "<p>old</p>")
http = FakeHttp(get=(200, current)) # PUT script omitted -> would assert.
client = _basic_client(http)
planned = client.update_page("777", "New Title", "<p>new</p>", 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 == "<p>new</p>"
# A unified diff against the current body shows the change.
assert "<p>old</p>" in planned.body_delta
assert "<p>new</p>" 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", "<p>brand new</p>", 0)
assert planned.applied is False
assert planned.new_version == 1
assert "<p>brand new</p>" 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, "<p>old</p>")
updated = _storage_page("555", 10, "<p>updated</p>")
http = FakeHttp(get=(200, current), put=(200, updated))
client = _basic_client(http)
result = client.update_page("555", "Title", "<p>updated</p>", 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"] == "<p>updated</p>"
assert put_call["headers"]["Content-Type"] == "application/json"
def test_update_page_apply_true_non_2xx_put_raises() -> None:
current = _storage_page("321", 1, "<p>x</p>")
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", "<p>y</p>", 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": "<p>b</p>",
"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": "<p>b</p>", "page_id": "123456"})
draft = parse_confluence_reply(reply)
assert draft["page_id"] == "123456"
def test_body_diff_empty_when_identical() -> None:
assert body_diff("<p>same</p>", "<p>same</p>", 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