feat: central RTC write modes (DISABLED/DRY_RUN/APPROVAL/ENABLED), R4 suggestion-only, preview/approval, per-record rollback, bulk blocker upsert; 1.2.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 22:46:05 -03:00
co-authored by Claude Sonnet 5
parent 3c10422e81
commit 42dc172763
50 changed files with 2948 additions and 915 deletions
+76 -16
View File
@@ -594,27 +594,87 @@ def _installation(client, agent_id):
from central.app.models import Installation as Inst
with client._session_factory() as session:
row = session.scalar(select(Inst).where(Inst.agent_id == agent_id))
return bool(row.rtc_auto_apply_authorized)
return row.rtc_write_mode
def test_rtc_auto_apply_authorization_is_off_by_default_and_needs_csrf_and_a_typed_confirmation():
def test_rtc_write_mode_is_disabled_by_default_and_needs_csrf_valid_mode_and_a_typed_confirmation_for_automatic():
client, agent_id, _ = _panel_client()
assert _installation(client, agent_id) is False
assert _installation(client, agent_id) == "DISABLED"
csrf = client.cookies.get("gti_csrf")
url = f"/agents/{agent_id}/rtc-auto-apply"
assert client.post(url, data={"csrf_token": "wrong", "decision": "authorize", "confirm": "PREENCHER"}).status_code == 403
assert client.post(url, data={"csrf_token": csrf, "decision": "authorize"}).status_code == 422
assert client.post(url, data={"csrf_token": csrf, "decision": "authorize", "confirm": "sim"}).status_code == 422
assert _installation(client, agent_id) is False
assert client.post(url, data={"csrf_token": csrf, "decision": "authorize", "confirm": "preencher"}, follow_redirects=False).status_code == 303
assert _installation(client, agent_id) is True
assert client.post(url, data={"csrf_token": csrf, "decision": "revoke"}, follow_redirects=False).status_code == 303
assert _installation(client, agent_id) is False
assert client.post(url, data={"csrf_token": csrf, "decision": "other"}).status_code == 422
url = f"/agents/{agent_id}/rtc-mode"
assert client.post(url, data={"csrf_token": "wrong", "mode": "DRY_RUN"}).status_code == 403
assert client.post(url, data={"csrf_token": csrf, "mode": "ENABLED"}).status_code == 422
assert client.post(url, data={"csrf_token": csrf, "mode": "ENABLED", "confirm": "sim"}).status_code == 422
assert client.post(url, data={"csrf_token": csrf, "mode": "bogus"}).status_code == 422
assert _installation(client, agent_id) == "DISABLED"
for mode, extra in (("DRY_RUN", {}), ("APPROVAL_REQUIRED", {}), ("ENABLED", {"confirm": "preencher"}), ("DISABLED", {})):
assert client.post(url, data={"csrf_token": csrf, "mode": mode, **extra}, follow_redirects=False).status_code == 303
assert _installation(client, agent_id) == mode
from central.app.models import AuditEvent
with client._session_factory() as session:
actions = [a.action for a in session.scalars(select(AuditEvent).order_by(AuditEvent.id)).all() if a.action.startswith("RTC_AUTO_APPLY")]
assert actions == ["RTC_AUTO_APPLY_AUTHORIZE", "RTC_AUTO_APPLY_REVOKE"]
actions = [a.action for a in session.scalars(select(AuditEvent).order_by(AuditEvent.id)).all() if a.action.startswith("RTC_")]
assert actions == ["RTC_WRITE_MODE"] * 4
def _seed_rtc(client, agent_id, n=120):
from central.app.models import Installation as Inst, RtcCandidate, RtcWriteAudit
with client._session_factory() as session:
inst = session.scalar(select(Inst).where(Inst.agent_id == agent_id))
inst.rtc_write_mode = "APPROVAL_REQUIRED"
for i in range(n):
session.add(RtcCandidate(tenant_id=inst.tenant_id, installation_id=inst.id, product_code=f"C{i:04d}", description=f"PRODUTO {i}", ncm="10063021", proposed="200003", cst_ibscbs="200", rule_id="RTC-R3", basis="RTC-R3:ANNEX", status="PENDING"))
audit = RtcWriteAudit(tenant_id=inst.tenant_id, installation_id=inst.id, agent_id=agent_id, product_code="A1", field="CCLASSTRIB", outcome="APPLIED", old_value=None, new_value="200003", rule_id="RTC-R3", action="APPLY")
session.add(audit)
session.commit()
return audit.id
def test_rtc_control_page_paginates_candidates_on_the_server_and_shows_modes_counts_and_audit():
client, agent_id, _ = _panel_client()
_seed_rtc(client, agent_id)
page1 = client.get(f"/agents/{agent_id}/rtc").text
assert "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente." in page1
for label in ("Desativada", "Dry-run", "Aprovação manual", "Automática"):
assert label in page1
assert "Pendentes (120)" in page1 and "C0000" in page1 and "C0049" in page1 and "C0050" not in page1 and "Página 1 de 3" in page1 and "Aplicar aprovados agora" in page1
page3 = client.get(f"/agents/{agent_id}/rtc?page=3").text
assert "C0119" in page3 and "C0099" not in page3 and "Página 3 de 3" in page3
assert "Reverter" in page3
def test_rtc_candidate_actions_approve_reject_apply_and_rollback_are_csrf_protected_and_scoped():
from central.app.models import Job, RtcCandidate, RtcWriteAudit
client, agent_id, _ = _panel_client()
audit_id = _seed_rtc(client, agent_id, n=10)
csrf = client.cookies.get("gti_csrf")
url = f"/agents/{agent_id}/rtc-candidates"
assert client.post(url, data={"csrf_token": "wrong", "action": "approve"}).status_code == 403
with client._session_factory() as session:
ids = [c.id for c in session.scalars(select(RtcCandidate).order_by(RtcCandidate.id)).all()]
assert client.post(url, content=f"csrf_token={csrf}&action=approve&ids={ids[0]}&ids={ids[1]}", headers={"content-type": "application/x-www-form-urlencoded"}, follow_redirects=False).status_code == 303
assert client.post(f"{url}?all=1", data={"csrf_token": csrf, "action": "approve"}, follow_redirects=False).status_code == 303
assert client.post(url, content=f"csrf_token={csrf}&action=reject&ids={ids[2]}", headers={"content-type": "application/x-www-form-urlencoded"}, follow_redirects=False).status_code == 303
with client._session_factory() as session:
counts = {c.product_code: c.status for c in session.scalars(select(RtcCandidate)).all()}
assert list(counts.values()).count("REJECTED") == 1 and list(counts.values()).count("APPROVED") == 9
jobs_before = len(session.scalars(select(Job)).all())
assert client.post(url, data={"csrf_token": csrf, "action": "apply_approved"}, follow_redirects=False).status_code == 303
with client._session_factory() as session:
job = session.scalars(select(Job).order_by(Job.id.desc())).first()
assert len(session.scalars(select(Job)).all()) == jobs_before + 1 and job.kind == "REPROCESS_PRODUCTS" and job.payload["reason"] == "RTC_APPLY_APPROVED" and len(job.payload["codigos"]) == 9
assert client.post(url, data={"csrf_token": csrf, "action": "nope"}).status_code == 422
roll = f"/agents/{agent_id}/rtc-rollback"
assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id}).status_code == 422 # typed confirmation required
assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id, "confirm": "reverter"}, follow_redirects=False).status_code == 303
assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id, "confirm": "REVERTER"}).status_code == 409 # already requested: no duplicate
with client._session_factory() as session:
assert session.get(RtcWriteAudit, audit_id).rollback_state == "REQUESTED"
def test_apply_approved_is_refused_outside_the_approval_mode():
client, agent_id, _ = _panel_client()
csrf = client.cookies.get("gti_csrf")
assert client.post(f"/agents/{agent_id}/rtc-candidates", data={"csrf_token": csrf, "action": "apply_approved"}).status_code == 409
def test_panel_shows_rtc_products_summary_and_the_pending_message_only_when_rtc_is_on():
@@ -626,6 +686,6 @@ def test_panel_shows_rtc_products_summary_and_the_pending_message_only_when_rtc_
_complete_sync(client, agent_id, {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True, "rtc_resolution": resolution})
on = client.get(f"/agents/{agent_id}").text
assert "Reforma Tributária ativa — cClassTrib pendente em 521 produtos" in on and "prontos para preenchimento 280" in on and "ambíguos/bloqueados 241" in on
assert "Preenchimento automático" in on and "Desativado" in on and "digite PREENCHER" in on
assert "Preenchimento automático" in on and "Desativada" in on and f"/agents/{agent_id}/rtc" in on
for legacy in ("Escopo anterior", "Escopo ampliado"):
assert legacy not in on