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_writer.py
Adam Moussa f56ee1c58d harden(agent-team): identity-aware macro-preservation guard
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.
2026-06-25 11:22:07 -04:00

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