diff --git a/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md new file mode 100644 index 0000000..80beb11 --- /dev/null +++ b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md @@ -0,0 +1,73 @@ +# Task 3 Report: Safe Blocker Resolution Workflow + +## Status + +Implemented Task 3 only. The old hidden-context blocker action was replaced by a structured, server-rendered, CSRF-protected resolution workflow. The JSON control-panel route now uses the same validated transaction service and rejects arbitrary context text. + +## RED + +Created `central/tests/test_blocker_resolution_panel.py` before production implementation. + +Initial focused command: + +```text +python -m pytest central/tests/test_blocker_resolution_panel.py -q +``` + +Expected RED result: + +```text +ModuleNotFoundError: No module named 'central.app.blocker_resolution' +``` + +The failure occurred during collection because the required production module did not yet exist. + +## Implementation + +- Added `central/app/blocker_resolution.py` with structured input validation through `validate_entry`, workflow-scope restrictions, exact PRODUCT scope-key enforcement, persisted installation/company context derivation, context upsert, bounded `REPROCESS_PRODUCTS` job creation, sanitized audit recording, and one commit with rollback on failure. +- Added `GET /blockers/{blocker_id}` with installation-aware blocker detail and context form. +- Added `POST /blockers/{blocker_id}/resolve-context` with human authentication and CSRF enforcement. +- Updated `/v1/blockers/{blocker_id}/resolve-human` and added `/v1/blockers/{blocker_id}/resolve-context` to call the same service; neither accepts arbitrary context text. +- Removed the blocker-list hidden fixed-context form and `data-confirm` action. +- Added focused coverage for enum validation, unknown keys, unsupported NCM scope, empty reason, missing PRODUCT scope key, authentication, CSRF, context/job/audit persistence, exact product scoping, and secret redaction. + +## GREEN + +Focused command: + +```text +python -m pytest central/tests/test_blocker_resolution_panel.py -q +``` + +Result: + +```text +5 passed, 1 warning +``` + +Central suite: + +```text +python -m pytest central/tests -q +``` + +Result: + +```text +261 passed, 1 warning +``` + +The warnings are existing third-party test-environment warnings from `pytest_asyncio` configuration and `openpyxl` print-area parsing. + +## Safety Review + +- No Firebird, AI, SQL execution, shell, credential, or remote-write path was added. +- The workflow accepts only `scope`, `scope_key`, `key`, `value`, and `reason`; context keys and enum values are validated against existing shared rules. +- Company context is derived from persisted blocker/installation data, not arbitrary client `company_key` input. +- Jobs contain exactly the blocked product code. +- Audit context passes through existing redaction before persistence. + +## Concerns + +- Live PostgreSQL and agent execution were not exercised; verification used the repository's offline SQLite test fixtures. +- The blocker remains open until the agent's normal reprocess completion path reports resolution; this prevents marking it resolved before the bounded job succeeds. diff --git a/central/app/blocker_resolution.py b/central/app/blocker_resolution.py new file mode 100644 index 0000000..8e34b6c --- /dev/null +++ b/central/app/blocker_resolution.py @@ -0,0 +1,149 @@ +"""Validated, auditable context resolution for a single blocked product.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select + +from .audit import record_audit +from .context_keys import validate_entry +from .models import Agent, BlockedProduct, FiscalContextEntry, Installation, Tenant, Job + + +WORKFLOW_SCOPES = {"PRODUCT", "GROUP", "COMPANY"} +ENTRY_FIELDS = {"scope", "scope_key", "key", "value", "reason"} + + +def validate_resolution_input(payload): + """Validate only the structured context accepted by the blocker workflow.""" + if not isinstance(payload, dict): + return None, "resolution input must be an object" + unexpected = set(payload) - ENTRY_FIELDS + if unexpected: + return None, f"unsupported resolution fields: {sorted(unexpected)}" + + normalized, error = validate_entry(payload) + if error: + return None, error + if normalized["scope"] not in WORKFLOW_SCOPES: + return None, "scope must be one of ('PRODUCT', 'GROUP', 'COMPANY')" + if not normalized["reason"]: + return None, "reason is required" + return normalized, None + + +def _company_key(blocker, installation, tenant): + if installation is not None: + return (installation.company_code or installation.company_name or installation.customer_code or "").strip() + return (blocker.company or (tenant.name if tenant else "")).strip() + + +def _entry_view(row): + if row is None: + return None + return { + "company_key": row.company_key, + "scope": row.scope, + "scope_key": row.scope_key, + "key": row.key, + "value": row.value, + "source": row.source, + "reason": row.reason, + } + + +def resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=None): + """Persist context, enqueue one product reprocess, and audit atomically.""" + normalized, error = validate_resolution_input(payload) + if error: + raise ValueError(error) + + try: + blocker = session.get(BlockedProduct, blocker_id) + if blocker is None: + raise LookupError("blocker not found") + + installation = None + if blocker.installation_id is not None: + installation = session.get(Installation, blocker.installation_id) + if installation is None: + raise LookupError("blocker installation not found") + agent = session.get(Agent, installation.agent_id) + else: + agent = session.scalar( + select(Agent) + .where(Agent.tenant_id == blocker.tenant_id, Agent.enabled.is_(True)) + .order_by(Agent.id.asc()) + ) + if agent is None: + raise LookupError("blocker agent not found") + + tenant = session.get(Tenant, blocker.tenant_id) + company_key = _company_key(blocker, installation, tenant) + if not company_key: + raise ValueError("company context is unavailable") + if normalized["scope"] == "PRODUCT" and normalized["scope_key"] != blocker.product_code: + raise ValueError("PRODUCT scope_key must match the blocker product code") + + query = select(FiscalContextEntry).where( + FiscalContextEntry.tenant_id == blocker.tenant_id, + FiscalContextEntry.company_key == company_key, + FiscalContextEntry.scope == normalized["scope"], + FiscalContextEntry.scope_key == normalized["scope_key"], + FiscalContextEntry.key == normalized["key"], + ) + entry = session.scalar(query) + before = _entry_view(entry) + now = datetime.now(timezone.utc) + if entry is None: + entry = FiscalContextEntry( + tenant_id=blocker.tenant_id, + company_key=company_key, + source="OPERATOR", + updated_at=now, + **normalized, + ) + session.add(entry) + else: + entry.value = normalized["value"] + entry.reason = normalized["reason"] + entry.source = "OPERATOR" + entry.updated_at = now + + job = Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="PENDING", payload={"codigos": [blocker.product_code]}) + session.add(job) + session.flush() + audit = record_audit( + session, + admin_user_id=admin.id, + tenant_id=blocker.tenant_id, + action="RESOLVE_BLOCKER_WITH_CONTEXT", + target_type="blocker", + target_id=str(blocker.id), + context={ + "admin_user_id": admin.id, + "tenant_id": blocker.tenant_id, + "client": tenant.name if tenant else None, + "company_key": company_key, + "installation_id": installation.installation_id if installation else None, + "installation_db_id": installation.id if installation else None, + "product_code": blocker.product_code, + "blocker_id": blocker.id, + "key": normalized["key"], + "scope": normalized["scope"], + "scope_key": normalized["scope_key"], + "value": normalized["value"], + "reason": normalized["reason"], + "before": before, + "after": _entry_view(entry), + "reprocess_job_id": job.id, + "request_ip": request_ip, + }, + ) + session.flush() + session.commit() + return {"blocker_id": blocker.id, "job_id": job.id, "audit_id": audit.id} + except Exception: + session.rollback() + raise diff --git a/central/app/control_api.py b/central/app/control_api.py index b5df1e2..f3dae0c 100644 --- a/central/app/control_api.py +++ b/central/app/control_api.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from .audit import record_audit +from .blocker_resolution import resolve_blocker_with_context from .control_plane import dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events from .human_auth import require_csrf, require_human from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant @@ -109,19 +110,19 @@ def router(session_factory): return {"job_id": job.id, "status": job.status} finally: session.close() + @routes.post("/blockers/{blocker_id}/resolve-context") @routes.post("/blockers/{blocker_id}/resolve-human") def resolve_blocker(blocker_id: int, payload: dict, request: Request, identity=Depends(identity)): session, web_session, admin = identity try: require_csrf(web_session, payload.get("csrf_token")) - if payload.get("confirm") is not True or not str(payload.get("context", "")).strip(): raise HTTPException(400, "confirmation and explicit context required") - blocker = session.get(BlockedProduct, blocker_id) - if not blocker: raise HTTPException(404, "blocker not found") - if blocker.resolved_at is None: - blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL" - record_audit(session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker_id), context={"context": payload["context"]}) - session.commit() - return {"resolved": True, "blocker_id": blocker_id} + entry_payload = {key: payload.get(key) for key in ("scope", "scope_key", "key", "value", "reason")} + try: + return resolve_blocker_with_context(session, blocker_id, admin, entry_payload, request_ip=request.client.host if request.client else None) + except LookupError as error: + raise HTTPException(404, str(error)) from error + except ValueError as error: + raise HTTPException(422, str(error)) from error finally: session.close() return routes diff --git a/central/app/web.py b/central/app/web.py index 1d4c0b4..5388f73 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -8,9 +8,11 @@ from fastapi.templating import Jinja2Templates from sqlalchemy import select from .config import settings -from .control_plane import dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events +from .blocker_resolution import _company_key, resolve_blocker_with_context +from .context_keys import ALLOWED_KEYS, ENUMS +from .control_plane import blocker_detail, dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events from .human_auth import create_session, current_session, require_csrf, verify_password -from .models import AdminUser, Installation, Agent, Job +from .models import AdminUser, Agent, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant from .jobs import create_job from .audit import record_audit @@ -116,23 +118,40 @@ def router(session_factory): try: return render(request, "blockers.html", {"result": list_blockers(session, page=page, page_size=50, blocker_type=blocker_type), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")}) finally: session.close() - @routes.post("/blockers/{blocker_id}/resolve") - async def resolve_blocker(request: Request, blocker_id: int): + @routes.get("/blockers/{blocker_id}", response_class=HTMLResponse) + def blocker_page(request: Request, blocker_id: int): + session, auth_data, redirect = page_auth(request) + if redirect: return redirect + try: + detail = blocker_detail(session, blocker_id) + if detail is None: return HTMLResponse("Blocker not found", status_code=404) + blocker = session.get(BlockedProduct, blocker_id) + installation = session.get(Installation, blocker.installation_id) if blocker.installation_id else None + tenant = session.get(Tenant, blocker.tenant_id) + company_key = _company_key(blocker, installation, tenant) + current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all() + value_choices = sorted({value for values in ENUMS.values() for value in values}) + return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": current_context, "context_keys": sorted(ALLOWED_KEYS), "value_choices": value_choices, "error": None}) + finally: session.close() + + @routes.post("/blockers/{blocker_id}/resolve-context") + async def resolve_blocker_with_context_route(request: Request, blocker_id: int): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: values = await form_values(request); web_session, admin = auth_data require_csrf(web_session, values.get("csrf_token")) - if values.get("confirm") != "yes" or not values.get("context", "").strip(): return HTMLResponse("confirmation and context required", status_code=400) - from .models import BlockedProduct - blocker = session.get(BlockedProduct, blocker_id) - if not blocker: return HTMLResponse("Blocker not found", status_code=404) - from datetime import datetime, timezone - blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL" - record_audit(session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker_id), context={"context": values["context"]}) - session.commit() + payload = {key: values.get(key) for key in ("scope", "scope_key", "key", "value", "reason")} + try: + resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=request.client.host if request.client else None) + except LookupError: + return HTMLResponse("Blocker not found", status_code=404) + except ValueError as error: + detail = blocker_detail(session, blocker_id) + return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": [], "context_keys": sorted(ALLOWED_KEYS), "value_choices": sorted({value for values in ENUMS.values() for value in values}), "error": str(error)}, 422) return RedirectResponse("/blockers", status_code=303) - finally: session.close() + finally: + session.close() @routes.get("/jobs", response_class=HTMLResponse) def jobs(request: Request, page: int = 1, status: str | None = None): diff --git a/central/templates/blocker_detail.html b/central/templates/blocker_detail.html new file mode 100644 index 0000000..a0f03cd --- /dev/null +++ b/central/templates/blocker_detail.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %} +{% block content %} +

RESOLUÇÃO OPERACIONAL

Blocker {{ detail.id }}

{{ detail.client or detail.tenant_id }} · {{ detail.product_code }}

{{ detail.blocker_type }}
+{% if error %}

{{ error }}

{% endif %} +
+

Detalhes do blocker

Cliente
{{ detail.client or "-" }}
Servidor
{{ detail.server.hostname or "-" }}
Produto
{{ detail.product_code }}
Descrição
{{ detail.description or "-" }}
Campo
{{ detail.field or "-" }}
Tipo
{{ detail.blocker_type }}
Motivo
{{ detail.reason }}
Evidência
{{ detail.evidence }}
+

Contexto atual

Valor atual por chave persistida para esta empresa.

{% for entry in context_entries %}
{{ entry.key }}{{ entry.scope }} / {{ entry.scope_key or "empresa" }}{{ entry.value }}
{% else %}

Nenhum valor definido.

{% endfor %}
+
+

Salvar contexto e reprocessar

Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.

Cancelar
+{% endblock %} diff --git a/central/templates/blockers.html b/central/templates/blockers.html index c0eb5de..614bcdc 100644 --- a/central/templates/blockers.html +++ b/central/templates/blockers.html @@ -1 +1 @@ -{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}

FILA FISCAL

Blockers globais

{% for item in result["items"] %}{% else %}{% endfor %}
ClienteProdutoCampoTipoMotivoÚltima ocorrênciaAção
{{ item.client or item.tenant_id }}{{ item.product_code }}{{ item.description or "" }}{{ item.field }}{{ item.blocker_type }}{{ item.reason }}{{ item.last_seen_at }}{{ item.retry_count }} retries
Nenhum blocker aberto.
{% endblock %} +{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}

FILA FISCAL

Blockers globais

{% for item in result["items"] %}{% else %}{% endfor %}
ClienteProdutoCampoTipoMotivoÚltima ocorrênciaAção
{{ item.client or item.tenant_id }}{{ item.product_code }}{{ item.description or "" }}{{ item.field }}{{ item.blocker_type }}{{ item.reason }}{{ item.last_seen_at }}{{ item.retry_count }} retriesResolver com contexto
Nenhum blocker aberto.
{% endblock %} diff --git a/central/tests/test_blocker_resolution_panel.py b/central/tests/test_blocker_resolution_panel.py new file mode 100644 index 0000000..b1585d3 --- /dev/null +++ b/central/tests/test_blocker_resolution_panel.py @@ -0,0 +1,155 @@ +from fastapi.testclient import TestClient +from sqlalchemy import select + +from central.app.blocked_products import upsert_blocker +from central.app.blocker_resolution import resolve_blocker_with_context, validate_resolution_input +from central.app.main import create_app +from central.app.models import AdminUser, AuditEvent, FiscalContextEntry, Job, Installation +from central.tests.test_control_plane import seed, setup_db + + +def _seed_blocker(session_factory): + agent_id, admin_id = seed(session_factory) + with session_factory() as session: + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + installation.company_code = "ACME-COMPANY" + blocker = upsert_blocker( + session, + installation.tenant_id, + { + "company": "Acme", + "product_code": "P-001", + "description": "Produto de teste", + "blocker_type": "BLOCKED_MISSING_EVIDENCE", + "field": "CFOP", + "reason_code": "MISSING_CONTEXT", + "input_fingerprint": "input-1", + "evidence_summary": {"source": "test"}, + }, + installation_id=installation.id, + ) + return blocker.id, admin_id, installation.id + + +def test_validate_resolution_input_accepts_known_enum_values(): + for key, value in (("entry_scope", "INTER"), ("product_role", "RESALE")): + normalized, error = validate_resolution_input( + {"scope": "PRODUCT", "scope_key": "P-001", "key": key, "value": value, "reason": "operator note"} + ) + assert error is None + assert normalized == { + "scope": "PRODUCT", + "scope_key": "P-001", + "key": key, + "value": value, + "reason": "operator note", + } + + +def test_validate_resolution_input_rejects_unsafe_or_incomplete_values(): + invalid_payloads = ( + {"scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "ARBITRARY", "reason": "note"}, + {"scope": "PRODUCT", "scope_key": "P-001", "key": "not-a-key", "value": "x", "reason": "note"}, + {"scope": "NCM", "scope_key": "22030000", "key": "entry_scope", "value": "INTER", "reason": "note"}, + {"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": ""}, + {"scope": "PRODUCT", "key": "product_role", "value": "RESALE", "reason": "note"}, + {"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "note", "context": "free text"}, + ) + + for payload in invalid_payloads: + normalized, error = validate_resolution_input(payload) + assert normalized is None + assert error + + +def test_resolve_blocker_with_context_persists_one_context_row_job_and_sanitized_audit(): + session_factory = setup_db() + blocker_id, admin_id, installation_id = _seed_blocker(session_factory) + + with session_factory() as session: + admin = session.get(AdminUser, admin_id) + result = resolve_blocker_with_context( + session, + blocker_id, + admin, + { + "scope": "PRODUCT", + "scope_key": "P-001", + "key": "product_role", + "value": "RESALE", + "reason": "reviewed token=secret-value", + }, + request_ip="192.0.2.10", + ) + + entry = session.scalar(select(FiscalContextEntry).where(FiscalContextEntry.key == "product_role")) + job = session.get(Job, result["job_id"]) + audit = session.get(AuditEvent, result["audit_id"]) + + assert result["blocker_id"] == blocker_id + assert entry.company_key == "ACME-COMPANY" + assert entry.scope == "PRODUCT" + assert entry.scope_key == "P-001" + assert entry.value == "RESALE" + assert entry.source == "OPERATOR" + assert job.agent_id == session.get(Installation, installation_id).agent_id + assert job.kind == "REPROCESS_PRODUCTS" + assert job.payload == {"codigos": ["P-001"]} + assert audit.action == "RESOLVE_BLOCKER_WITH_CONTEXT" + assert audit.target_type == "blocker" + assert audit.target_id == str(blocker_id) + assert audit.context["reprocess_job_id"] == job.id + assert audit.context["before"] is None + assert audit.context["after"]["value"] == "RESALE" + assert audit.context["request_ip"] == "192.0.2.10" + assert "secret-value" not in str(audit.context) + assert session.query(FiscalContextEntry).count() == 1 + + +def test_human_resolution_detail_requires_auth_csrf_and_renders_safe_form(): + session_factory = setup_db() + blocker_id, _, _ = _seed_blocker(session_factory) + client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)) + + assert client.get(f"/blockers/{blocker_id}", follow_redirects=False).status_code == 303 + client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False) + detail = client.get(f"/blockers/{blocker_id}") + assert detail.status_code == 200 + assert "Salvar contexto e reprocessar" in detail.text + assert "Cliente" in detail.text and "Servidor" in detail.text and "Produto" in detail.text + assert "Evidência" in detail.text and "Valores aceitos" in detail.text + assert "data-confirm" not in detail.text + assert 'name="context"' not in detail.text + + missing_csrf = client.post( + f"/blockers/{blocker_id}/resolve-context", + data={"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"}, + ) + assert missing_csrf.status_code == 403 + + +def test_human_and_json_resolution_routes_use_the_same_validated_workflow(): + session_factory = setup_db() + blocker_id, _, _ = _seed_blocker(session_factory) + client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)) + client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False) + csrf = client.cookies.get("gti_csrf") + + invalid = client.post( + f"/v1/blockers/{blocker_id}/resolve-human", + json={"csrf_token": csrf, "scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "free text", "reason": "reviewed"}, + ) + assert invalid.status_code == 422 + + api_result = client.post( + f"/v1/blockers/{blocker_id}/resolve-human", + json={"csrf_token": csrf, "scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "INTER", "reason": "reviewed"}, + ) + assert api_result.status_code == 200 + assert set(api_result.json()) == {"blocker_id", "job_id", "audit_id"} + + unauthenticated = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)).post( + f"/v1/blockers/{blocker_id}/resolve-human", + json={"scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "INTER", "reason": "reviewed"}, + ) + assert unauthenticated.status_code == 401