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
473 lines
17 KiB
Python
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
|