Address the verifier's residual on the macro-loss fix: the guard was raw-count based, so a body that DROPPED the real Mermaid macro while ADDING an unrelated macro (equal count) could slip through. Replace count_storage_macros in the guard with storage_macro_signature (per-ac:name multiset) and refuse if ANY macro identity loses occurrences. +2 tests.
678 lines
27 KiB
Python
678 lines
27 KiB
Python
"""Unit tests for agent_team.nodes.confluence_writer (CONF_DRAFT/GATE/WRITE).
|
|
|
|
Covers the three LangGraph nodes plus the conditional-edge router:
|
|
|
|
* :func:`conf_draft_node` — the reasoning->JSON draft call. Pins the post-#60
|
|
contract: a small turn headroom (``max_turns == 4``, like the planner) and
|
|
TOOLS-OFF (no allowed_tools / budget) — the high-cap + read-only-tools approach
|
|
was reverted live in PR #61 (feedback_claude_sdk_single_shot).
|
|
* :func:`conf_gate_node` — the resumable human gate. Driven through a real
|
|
in-memory LangGraph (interrupt + ``Command(resume=...)``) to assert the
|
|
interrupt payload ``kind`` and the approve / request_changes / abandon /
|
|
unrecognized / ceiling routing.
|
|
* :func:`conf_write_node` — dry-run-by-default vs. apply-flag live write through
|
|
an INJECTED fake ConfluenceClient; the mermaid path routing.
|
|
* :func:`route_after_conf_gate` — approve / revise / terminal mapping.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
try: # InMemorySaver is the modern name; fall back on older langgraph.
|
|
from langgraph.checkpoint.memory import InMemorySaver as _Saver
|
|
except ImportError: # pragma: no cover - environment-dependent
|
|
from langgraph.checkpoint.memory import MemorySaver as _Saver
|
|
|
|
from langgraph.graph import END, START, StateGraph
|
|
from langgraph.types import Command
|
|
|
|
from agent_team import billing
|
|
from agent_team.billing import BillingMode, ClaudeResult
|
|
from agent_team.nodes import confluence_writer as cw
|
|
from agent_team.nodes.confluence_writer import (
|
|
CONFLUENCE_APPROVAL_KIND,
|
|
MAX_CONFLUENCE_GATE_VISITS,
|
|
ConfluenceWriteError,
|
|
conf_draft_node,
|
|
conf_gate_node,
|
|
conf_write_node,
|
|
route_after_conf_gate,
|
|
)
|
|
from agent_team.task_model import Phase, PipelineState, TaskStatus
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fixtures / helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
_VALID_DRAFT = {
|
|
"title": "AWS Architecture Map — agent-team",
|
|
"body_storage": "<p>The coordinator daemon runs on the R720.</p>",
|
|
"page_id": "1540098",
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_invoker():
|
|
"""Restore the module invoker after each test (mirrors test_planner)."""
|
|
original = billing._invoker
|
|
yield
|
|
billing._invoker = original
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_apply_env(monkeypatch):
|
|
"""Ensure the apply/allowlist env never leaks in from the host environment."""
|
|
monkeypatch.delenv("AGENT_TEAM_CONFLUENCE_APPLY", raising=False)
|
|
monkeypatch.delenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", raising=False)
|
|
|
|
|
|
def _bind_invoker(reply: str) -> list[dict[str, Any]]:
|
|
"""Bind a fake Claude invoker returning ``reply``; capture its calls."""
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
def _fake(prompt: str, *, mode: BillingMode, **kw: Any) -> ClaudeResult:
|
|
calls.append({"prompt": prompt, "mode": mode, "kw": kw})
|
|
return ClaudeResult(text=reply, mode=mode, usage={"input_tokens": 1})
|
|
|
|
billing.set_invoker(_fake)
|
|
return calls
|
|
|
|
|
|
def _state(**overrides: Any) -> PipelineState:
|
|
base: PipelineState = PipelineState(
|
|
thread_id="t-1",
|
|
status=TaskStatus.ACTIVE.value,
|
|
current_phase=cw.CONF_DRAFT_PHASE,
|
|
)
|
|
base.update(overrides) # type: ignore[typeddict-item]
|
|
return base
|
|
|
|
|
|
class _FakeUpdateOutcome:
|
|
"""Stand-in for the client's PlannedPageUpdate (carries ``.applied``)."""
|
|
|
|
def __init__(self, *, page_id, applied: bool) -> None:
|
|
self.page_id = page_id
|
|
self.applied = applied
|
|
|
|
|
|
class FakeConfluenceClient:
|
|
"""In-memory ConfluenceClient seam: records writes, no network.
|
|
|
|
Mirrors the REAL injected-client shape :func:`conf_write_node` expects:
|
|
``update_page(page_id, title, body_storage, version_number, *, apply=False)``
|
|
(the live path passes ``apply=True`` and a ``version_number`` it reads off
|
|
``get_page``), and an optional ``page_has_macros`` capability drives the
|
|
mermaid-routing branch. ``get_page`` returns the current version so the node
|
|
can derive ``version_number``.
|
|
"""
|
|
|
|
def __init__(self, *, has_macros: bool = False, current_version: int = 7) -> None:
|
|
self.update_calls: list[dict[str, Any]] = []
|
|
self.macro_checks: list[Any] = []
|
|
self.get_page_calls: list[Any] = []
|
|
self._has_macros = has_macros
|
|
self._current_version = current_version
|
|
|
|
def get_page(self, page_id) -> dict[str, Any]:
|
|
self.get_page_calls.append(page_id)
|
|
return {
|
|
"id": page_id,
|
|
"version": {"number": self._current_version},
|
|
"body": {"storage": {"value": "<p>old</p>"}},
|
|
}
|
|
|
|
def update_page(
|
|
self, *, page_id, title, body_storage, version_number, apply=False
|
|
) -> _FakeUpdateOutcome:
|
|
self.update_calls.append(
|
|
{
|
|
"page_id": page_id,
|
|
"title": title,
|
|
"body_storage": body_storage,
|
|
"version_number": version_number,
|
|
"apply": apply,
|
|
}
|
|
)
|
|
return _FakeUpdateOutcome(page_id=page_id or "new-123", applied=apply)
|
|
|
|
def page_has_macros(self, page_id) -> bool:
|
|
self.macro_checks.append(page_id)
|
|
return self._has_macros
|
|
|
|
|
|
class FakeAdfConfluenceClient(FakeConfluenceClient):
|
|
"""Fake client that ALSO exposes the ADF seam the mermaid live path needs.
|
|
|
|
``get_page_adf`` returns a parsed ADF doc; ``update_page_adf`` records the
|
|
persisted doc and returns an outcome carrying ``.applied`` so the mermaid
|
|
branch reports its applied state honestly.
|
|
"""
|
|
|
|
def __init__(self, *, adf: dict[str, Any] | None = None, **kw: Any) -> None:
|
|
super().__init__(**kw)
|
|
self.adf_get_calls: list[Any] = []
|
|
self.adf_put_calls: list[dict[str, Any]] = []
|
|
self._adf = adf or {"type": "doc", "version": 1, "content": []}
|
|
|
|
def get_page_adf(self, page_id) -> dict[str, Any]:
|
|
self.adf_get_calls.append(page_id)
|
|
return self._adf
|
|
|
|
def update_page_adf(self, page_id, new_adf) -> _FakeUpdateOutcome:
|
|
self.adf_put_calls.append({"page_id": page_id, "new_adf": new_adf})
|
|
return _FakeUpdateOutcome(page_id=page_id, applied=True)
|
|
|
|
|
|
# A minimal single-node graph wrapping conf_gate_node so interrupt()/resume work
|
|
# against a real checkpointer (mirrors the e2e harness, lighter).
|
|
def _gate_graph():
|
|
builder: StateGraph = StateGraph(PipelineState)
|
|
builder.add_node("gate", conf_gate_node)
|
|
builder.add_edge(START, "gate")
|
|
builder.add_edge("gate", END)
|
|
return builder.compile(checkpointer=_Saver())
|
|
|
|
|
|
def _run_to_interrupt(graph, state: PipelineState, thread_id: str = "t-1") -> dict:
|
|
"""Invoke the gate graph; return the single interrupt payload."""
|
|
cfg = {"configurable": {"thread_id": thread_id}}
|
|
result = graph.invoke(state, cfg)
|
|
interrupts = result["__interrupt__"]
|
|
assert len(interrupts) == 1
|
|
return interrupts[0].value
|
|
|
|
|
|
def _resume(graph, decision: Any, thread_id: str = "t-1") -> dict:
|
|
"""Resume the suspended gate graph with ``decision``; return final state."""
|
|
cfg = {"configurable": {"thread_id": thread_id}}
|
|
return graph.invoke(Command(resume=decision), cfg)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# conf_draft_node — agentic-invoker contract (PR #61)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_conf_draft_node_builds_draft_and_advances_to_gate() -> None:
|
|
_bind_invoker(json.dumps(_VALID_DRAFT))
|
|
out = conf_draft_node(_state(task="document the agent-team stack"))
|
|
assert out["current_phase"] == cw.CONF_GATE_PHASE
|
|
assert out["status"] == TaskStatus.ACTIVE.value
|
|
assert out["confluence_draft"]["title"] == _VALID_DRAFT["title"]
|
|
assert out["confluence_draft"]["page_id"] == "1540098"
|
|
|
|
|
|
def test_conf_draft_node_passes_reasoning_turn_headroom() -> None:
|
|
# The single-shot default (max_turns=1) crashes reasoning nodes; the draft
|
|
# asks for the same small headroom the planner uses (4) — NOT a high agentic
|
|
# cap (the merged #60 fix / PR #61 reverted the high-cap + tools approach).
|
|
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
|
|
conf_draft_node(_state(task="x"))
|
|
assert calls[0]["kw"].get("max_turns", 1) == 4
|
|
|
|
|
|
def test_conf_draft_node_runs_tools_off() -> None:
|
|
# Load-bearing pin (memory feedback_claude_sdk_single_shot): the draft is a
|
|
# reasoning->JSON node and must run TOOLS-OFF. Giving it repo tools was proven
|
|
# live to exhaust the turn cap / return narration (#60). Any repo context goes
|
|
# INTO the prompt, never via Claude tools — so no allowed_tools, no budget.
|
|
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
|
|
conf_draft_node(_state(task="x"))
|
|
assert not calls[0]["kw"].get("allowed_tools")
|
|
assert "budget_usd" not in calls[0]["kw"]
|
|
|
|
|
|
def test_conf_draft_node_forwards_config_to_billing_seam() -> None:
|
|
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
|
|
conf_draft_node(_state(task="x"), config={"billing_mode": "api"})
|
|
assert calls[0]["mode"] is BillingMode.API
|
|
|
|
|
|
def test_conf_draft_node_sends_task_into_prompt() -> None:
|
|
calls = _bind_invoker(json.dumps(_VALID_DRAFT))
|
|
conf_draft_node(_state(task="UNIQUE-DOC-MARKER"))
|
|
assert "UNIQUE-DOC-MARKER" in calls[0]["prompt"]
|
|
|
|
|
|
def test_conf_draft_node_garbled_reply_raises() -> None:
|
|
from agent_team.nodes.confluence_writer_llm import ConfluenceDraftError
|
|
|
|
_bind_invoker("not json at all")
|
|
with pytest.raises(ConfluenceDraftError):
|
|
conf_draft_node(_state(task="x"))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# conf_gate_node — interrupt payload + resume routing (real graph)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_conf_gate_interrupts_with_confluence_kind() -> None:
|
|
graph = _gate_graph()
|
|
payload = _run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
|
|
assert payload["kind"] == CONFLUENCE_APPROVAL_KIND
|
|
assert payload["kind"] == "confluence_approval"
|
|
assert payload["thread_id"] == "t-1"
|
|
assert payload["confluence_draft"]["title"] == _VALID_DRAFT["title"]
|
|
assert "preview" in payload and _VALID_DRAFT["title"] in payload["preview"]
|
|
# The gate turn lives in the high disjoint namespace (>= the plan-gate base).
|
|
assert payload["turn"] >= cw._CONF_GATE_TURN_BASE
|
|
# The coordinator notify path requires a question_set; it must carry the same
|
|
# ids/turn and a single decision question whose prompt is the draft preview.
|
|
qs = payload["question_set"]
|
|
assert qs.thread_id == "t-1"
|
|
assert qs.question_id == payload["question_id"]
|
|
assert qs.turn == payload["turn"]
|
|
assert len(qs.questions) == 1
|
|
assert _VALID_DRAFT["title"] in qs.questions[0]
|
|
assert "approve" in qs.questions[0] and "abandon" in qs.questions[0]
|
|
assert qs.context["kind"] == CONFLUENCE_APPROVAL_KIND
|
|
|
|
|
|
def test_conf_gate_approve_routes_to_write() -> None:
|
|
graph = _gate_graph()
|
|
_run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
|
|
out = _resume(graph, {"decision": "approve", "notes": ""})
|
|
assert out["current_phase"] == cw.CONF_WRITE_PHASE
|
|
assert out["status"] == TaskStatus.ACTIVE.value
|
|
assert out["confluence_gate_visits"] == 1
|
|
|
|
|
|
def test_conf_gate_request_changes_loops_to_draft_with_feedback() -> None:
|
|
graph = _gate_graph()
|
|
_run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
|
|
out = _resume(graph, {"decision": "request_changes", "notes": "fix the title"})
|
|
assert out["current_phase"] == cw.CONF_DRAFT_PHASE
|
|
assert out["status"] == TaskStatus.ACTIVE.value
|
|
assert out["confluence_feedback"] == "fix the title"
|
|
assert out["confluence_gate_visits"] == 1
|
|
|
|
|
|
def test_conf_gate_abandon_fails_with_reason() -> None:
|
|
graph = _gate_graph()
|
|
_run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
|
|
out = _resume(graph, {"decision": "abandon", "notes": "drop it"})
|
|
assert out["status"] == TaskStatus.FAILED.value
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert "abandoned" in (out.get("failure_reason") or "")
|
|
|
|
|
|
def test_conf_gate_unrecognized_decision_maps_to_request_changes() -> None:
|
|
# Free-text prose (no recognized verb) must fail SAFE to request_changes —
|
|
# never an accidental approve or silent abandon (mirrors the plan gate).
|
|
graph = _gate_graph()
|
|
_run_to_interrupt(graph, _state(confluence_draft=dict(_VALID_DRAFT)))
|
|
out = _resume(graph, "please tighten the architecture section")
|
|
assert out["current_phase"] == cw.CONF_DRAFT_PHASE
|
|
assert out["status"] == TaskStatus.ACTIVE.value
|
|
assert "tighten the architecture section" in out["confluence_feedback"]
|
|
|
|
|
|
def test_conf_gate_ceiling_parks_without_interrupt() -> None:
|
|
# At the visit ceiling the gate does NOT interrupt: it returns terminal PARKED
|
|
# so the human loop always terminates.
|
|
out = conf_gate_node(
|
|
_state(
|
|
confluence_draft=dict(_VALID_DRAFT),
|
|
confluence_gate_visits=MAX_CONFLUENCE_GATE_VISITS,
|
|
)
|
|
)
|
|
assert out["status"] == TaskStatus.PARKED.value
|
|
assert out["current_phase"] == Phase.PARKED.value
|
|
assert "ceiling" in (out.get("failure_reason") or "")
|
|
|
|
|
|
def test_conf_gate_visits_bump_monotonically_across_loops() -> None:
|
|
graph = _gate_graph()
|
|
_run_to_interrupt(
|
|
graph,
|
|
_state(confluence_draft=dict(_VALID_DRAFT), confluence_gate_visits=1),
|
|
thread_id="t-loop",
|
|
)
|
|
out = _resume(graph, {"decision": "approve"}, thread_id="t-loop")
|
|
assert out["confluence_gate_visits"] == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# route_after_conf_gate
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_route_after_conf_gate_approve() -> None:
|
|
state = _state(status=TaskStatus.ACTIVE.value, current_phase=cw.CONF_WRITE_PHASE)
|
|
assert route_after_conf_gate(state) == "approve"
|
|
|
|
|
|
def test_route_after_conf_gate_revise() -> None:
|
|
state = _state(status=TaskStatus.ACTIVE.value, current_phase=cw.CONF_DRAFT_PHASE)
|
|
assert route_after_conf_gate(state) == "revise"
|
|
|
|
|
|
def test_route_after_conf_gate_terminal_on_failed() -> None:
|
|
state = _state(status=TaskStatus.FAILED.value, current_phase=Phase.PARKED.value)
|
|
assert route_after_conf_gate(state) == "terminal"
|
|
|
|
|
|
def test_route_after_conf_gate_terminal_on_parked() -> None:
|
|
state = _state(status=TaskStatus.PARKED.value, current_phase=Phase.PARKED.value)
|
|
assert route_after_conf_gate(state) == "terminal"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# conf_write_node — dry-run default vs. apply flag, injected client
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_conf_write_dry_run_writes_nothing_by_default() -> None:
|
|
client = FakeConfluenceClient()
|
|
out = conf_write_node(_state(confluence_draft=dict(_VALID_DRAFT)), client=client)
|
|
assert client.update_calls == [] # NOTHING was written
|
|
assert out["status"] == TaskStatus.DONE.value
|
|
assert out["current_phase"] == cw.CONF_DONE_PHASE
|
|
result = out["confluence_result"]
|
|
assert result["applied"] is False
|
|
assert result["dry_run"] is True
|
|
assert result["action"] == "update"
|
|
assert "planned_change" in result and "revert_diff" in result
|
|
|
|
|
|
def test_conf_write_dry_run_create_action_when_no_page_id() -> None:
|
|
draft = {k: v for k, v in _VALID_DRAFT.items() if k != "page_id"}
|
|
out = conf_write_node(_state(confluence_draft=draft), client=FakeConfluenceClient())
|
|
assert out["confluence_result"]["action"] == "create"
|
|
|
|
|
|
def test_conf_write_apply_via_config_calls_client() -> None:
|
|
client = FakeConfluenceClient()
|
|
out = conf_write_node(
|
|
_state(confluence_draft=dict(_VALID_DRAFT)),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert len(client.update_calls) == 1
|
|
call = client.update_calls[0]
|
|
assert call["page_id"] == "1540098"
|
|
# The live path MUST pass apply=True and a version_number (read off get_page);
|
|
# without both the client's update_page raises / never writes (the defect).
|
|
assert call["apply"] is True
|
|
assert call["version_number"] == 7 # FakeConfluenceClient.get_page current
|
|
assert client.get_page_calls == ["1540098"]
|
|
result = out["confluence_result"]
|
|
# ``applied`` is DERIVED from the returned outcome (.applied), not hardcoded.
|
|
assert result["applied"] is True
|
|
assert result["dry_run"] is False
|
|
assert out["status"] == TaskStatus.DONE.value
|
|
|
|
|
|
def test_conf_write_apply_via_env_calls_client(monkeypatch) -> None:
|
|
monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_APPLY", "1")
|
|
client = FakeConfluenceClient()
|
|
conf_write_node(_state(confluence_draft=dict(_VALID_DRAFT)), client=client)
|
|
assert len(client.update_calls) == 1
|
|
|
|
|
|
def test_conf_write_missing_draft_raises() -> None:
|
|
with pytest.raises(ConfluenceWriteError):
|
|
conf_write_node(_state(confluence_draft={"title": "only title"}))
|
|
|
|
|
|
def test_conf_write_mermaid_path_only_when_edits_and_macros(monkeypatch) -> None:
|
|
# The mermaid route fires ONLY when the draft has edits AND the page carries
|
|
# macros. plan_mermaid_edits is PURE ADF: (adf, edits, *, apply=False). We
|
|
# assert routing by patching it and checking it received the parsed ADF plus
|
|
# MermaidEdit objects converted from the draft dicts.
|
|
import agent_team.confluence.mermaid as mermaid_mod
|
|
|
|
routed: dict[str, Any] = {}
|
|
|
|
def _fake_plan(adf, edits=None, *, apply=False):
|
|
routed["called"] = True
|
|
routed["adf"] = adf
|
|
routed["edits"] = edits
|
|
routed["apply"] = apply
|
|
return mermaid_mod.MermaidEditResult(
|
|
macro_count=1, new_adf=adf, revert_diff=[], skip_mermaid=False
|
|
)
|
|
|
|
monkeypatch.setattr(mermaid_mod, "plan_mermaid_edits", _fake_plan)
|
|
|
|
draft = dict(_VALID_DRAFT)
|
|
draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}]
|
|
|
|
adf_doc = {"type": "doc", "version": 1, "content": ["macro"]}
|
|
client = FakeAdfConfluenceClient(has_macros=True, adf=adf_doc)
|
|
out = conf_write_node(
|
|
_state(confluence_draft=draft),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert routed.get("called") is True
|
|
assert routed["apply"] is True
|
|
assert routed["adf"] is adf_doc # pure ADF fetched via get_page_adf
|
|
# draft edit dicts were converted to MermaidEdit(macro_key, new_source).
|
|
assert routed["edits"][0].macro_key == "m1"
|
|
assert routed["edits"][0].new_source == "graph TD; A-->B"
|
|
assert client.adf_get_calls == ["1540098"]
|
|
assert len(client.adf_put_calls) == 1 # persisted via update_page_adf
|
|
assert client.update_calls == [] # NOT routed through storage update_page
|
|
assert out["confluence_result"]["applied"] is True
|
|
|
|
|
|
def test_conf_write_mermaid_apply_without_adf_seam_raises() -> None:
|
|
# The base fake client has NO ADF persistence (get_page_adf/update_page_adf):
|
|
# a Mermaid live apply MUST fail loudly rather than falsely record success
|
|
# (ADF-only edits cannot round-trip through storage without dropping macros).
|
|
draft = dict(_VALID_DRAFT)
|
|
draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}]
|
|
client = FakeConfluenceClient(has_macros=True)
|
|
with pytest.raises(ConfluenceWriteError, match="ADF persistence"):
|
|
conf_write_node(
|
|
_state(confluence_draft=draft),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
|
|
|
|
def test_conf_write_applied_reflects_outcome_not_hardcoded() -> None:
|
|
# The result's ``applied`` is DERIVED from the returned outcome, so a client
|
|
# that declines to apply is reported as applied=False (never a FALSE success).
|
|
class _DeclineClient(FakeConfluenceClient):
|
|
def update_page(
|
|
self, *, page_id, title, body_storage, version_number, apply=False
|
|
):
|
|
self.update_calls.append({"page_id": page_id, "apply": apply})
|
|
return _FakeUpdateOutcome(page_id=page_id, applied=False)
|
|
|
|
out = conf_write_node(
|
|
_state(confluence_draft=dict(_VALID_DRAFT)),
|
|
config={"confluence_apply": True},
|
|
client=_DeclineClient(),
|
|
)
|
|
assert out["confluence_result"]["applied"] is False
|
|
assert out["confluence_result"]["dry_run"] is False
|
|
|
|
|
|
def test_conf_write_no_mermaid_route_when_page_lacks_macros() -> None:
|
|
# Edits present but the page has NO macros: fall back to a plain body update.
|
|
draft = dict(_VALID_DRAFT)
|
|
draft["mermaid_edits"] = [{"macro_id": "m1", "mermaid": "graph TD; A-->B"}]
|
|
|
|
client = FakeConfluenceClient(has_macros=False)
|
|
conf_write_node(
|
|
_state(confluence_draft=draft),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert len(client.update_calls) == 1 # fell back to update_page
|
|
assert client.macro_checks == ["1540098"] # the probe ran
|
|
|
|
|
|
def test_conf_write_no_mermaid_route_without_edits() -> None:
|
|
# No mermaid edits at all: plain body update even on a macro page.
|
|
client = FakeConfluenceClient(has_macros=True)
|
|
conf_write_node(
|
|
_state(confluence_draft=dict(_VALID_DRAFT)),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert len(client.update_calls) == 1
|
|
|
|
|
|
def test_conf_write_client_failure_normalized_to_write_error() -> None:
|
|
class _BoomClient(FakeConfluenceClient):
|
|
def update_page(self, **kw):
|
|
raise RuntimeError("network down")
|
|
|
|
with pytest.raises(ConfluenceWriteError, match="confluence write failed"):
|
|
conf_write_node(
|
|
_state(confluence_draft=dict(_VALID_DRAFT)),
|
|
config={"confluence_apply": True},
|
|
client=_BoomClient(),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Macro-preservation guard — a storage write must NEVER drop diagram macros
|
|
# (the page-1540098 data-loss class; security-review BLOCKER fix)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class _MacroBodyClient(FakeConfluenceClient):
|
|
"""Fake whose current page body carries Confluence macros (storage XHTML)."""
|
|
|
|
def __init__(self, *, current_macros: int = 1, **kw: Any) -> None:
|
|
super().__init__(**kw)
|
|
self._body = "".join(
|
|
f'<ac:structured-macro ac:name="mermaid-cloud" id="m{i}">'
|
|
f'<ac:parameter ac:name="code">graph TD; A-->B{i}</ac:parameter>'
|
|
"</ac:structured-macro>"
|
|
for i in range(current_macros)
|
|
)
|
|
|
|
def get_page(self, page_id) -> dict[str, Any]:
|
|
self.get_page_calls.append(page_id)
|
|
return {
|
|
"id": page_id,
|
|
"version": {"number": self._current_version},
|
|
"body": {"storage": {"value": self._body}},
|
|
}
|
|
|
|
|
|
def test_conf_write_storage_refuses_to_drop_macros() -> None:
|
|
# BLOCKER fix: the live page has a Mermaid macro; the model-authored body
|
|
# (plain <p>) has none. A wholesale storage PUT would erase the diagram, so
|
|
# the write must REFUSE rather than overwrite (no update_page issued).
|
|
client = _MacroBodyClient(current_macros=1)
|
|
with pytest.raises(ConfluenceWriteError, match="would DROP diagram/extension"):
|
|
conf_write_node(
|
|
_state(confluence_draft=dict(_VALID_DRAFT)),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert client.update_calls == [] # nothing was written
|
|
|
|
|
|
def test_conf_write_storage_refuses_macro_swap_at_equal_count() -> None:
|
|
# Identity-aware guard: the proposed body DROPS the Mermaid macro but ADDS an
|
|
# unrelated macro, keeping the raw count equal (1 -> 1). A count-only guard
|
|
# would wave this through; the signature guard must still refuse.
|
|
client = _MacroBodyClient(current_macros=1) # current: one mermaid-cloud macro
|
|
draft = dict(_VALID_DRAFT)
|
|
draft["body_storage"] = (
|
|
'<ac:structured-macro ac:name="info"><ac:rich-text-body>'
|
|
"<p>note</p></ac:rich-text-body></ac:structured-macro>"
|
|
)
|
|
with pytest.raises(ConfluenceWriteError, match="would DROP diagram/extension"):
|
|
conf_write_node(
|
|
_state(confluence_draft=draft),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert client.update_calls == []
|
|
|
|
|
|
def test_conf_write_storage_allows_when_macros_preserved() -> None:
|
|
# When the new body reproduces the macro count, the write proceeds.
|
|
client = _MacroBodyClient(current_macros=1)
|
|
draft = dict(_VALID_DRAFT)
|
|
draft["body_storage"] = (
|
|
'<ac:structured-macro ac:name="mermaid-cloud" id="m0">'
|
|
'<ac:parameter ac:name="code">graph TD; A-->B0_edited</ac:parameter>'
|
|
"</ac:structured-macro>"
|
|
)
|
|
conf_write_node(
|
|
_state(confluence_draft=draft),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert len(client.update_calls) == 1
|
|
|
|
|
|
def test_page_has_macros_fails_closed_without_capability() -> None:
|
|
# A client with NO page_has_macros method must be probed via the storage body
|
|
# and detect macros there (fail-closed), routing a macro page with edits into
|
|
# the ADF path (which raises here, since this client has no ADF seam) rather
|
|
# than the destructive storage overwrite.
|
|
class _NoCapMacroClient(_MacroBodyClient):
|
|
page_has_macros = None # type: ignore[assignment]
|
|
|
|
draft = dict(_VALID_DRAFT)
|
|
draft["mermaid_edits"] = [{"macro_id": "m0", "mermaid": "graph TD; A-->B"}]
|
|
with pytest.raises(ConfluenceWriteError, match="ADF persistence"):
|
|
conf_write_node(
|
|
_state(confluence_draft=draft),
|
|
config={"confluence_apply": True},
|
|
client=_NoCapMacroClient(),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Page allowlist (AUTHZ-CONF-01 defense-in-depth) + fail-honest applied default
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_conf_write_refuses_page_outside_allowlist(monkeypatch) -> None:
|
|
monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", "999,1234")
|
|
client = FakeConfluenceClient()
|
|
with pytest.raises(ConfluenceWriteError, match="not in the .*allowlist"):
|
|
conf_write_node(
|
|
_state(confluence_draft=dict(_VALID_DRAFT)), # page_id 1540098
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert client.update_calls == []
|
|
|
|
|
|
def test_conf_write_allows_page_on_allowlist(monkeypatch) -> None:
|
|
monkeypatch.setenv("AGENT_TEAM_CONFLUENCE_ALLOWED_PAGE_IDS", "1540098, 999")
|
|
client = FakeConfluenceClient()
|
|
conf_write_node(
|
|
_state(confluence_draft=dict(_VALID_DRAFT)),
|
|
config={"confluence_apply": True},
|
|
client=client,
|
|
)
|
|
assert len(client.update_calls) == 1
|
|
|
|
|
|
def test_conf_write_applied_defaults_false_when_attr_missing() -> None:
|
|
# Fail-honest: an outcome object lacking ``.applied`` must NOT be reported as
|
|
# a successful write (was: defaulted True).
|
|
class _AttrlessOutcome:
|
|
page_id = "1540098"
|
|
|
|
class _AttrlessClient(FakeConfluenceClient):
|
|
def update_page(self, **kw):
|
|
self.update_calls.append(kw)
|
|
return _AttrlessOutcome()
|
|
|
|
out = conf_write_node(
|
|
_state(confluence_draft=dict(_VALID_DRAFT)),
|
|
config={"confluence_apply": True},
|
|
client=_AttrlessClient(),
|
|
)
|
|
assert out["confluence_result"]["applied"] is False
|