feat: add safe blocker resolution workflow
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from .audit import record_audit
|
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 .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 .human_auth import require_csrf, require_human
|
||||||
from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant
|
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}
|
return {"job_id": job.id, "status": job.status}
|
||||||
finally: session.close()
|
finally: session.close()
|
||||||
|
|
||||||
|
@routes.post("/blockers/{blocker_id}/resolve-context")
|
||||||
@routes.post("/blockers/{blocker_id}/resolve-human")
|
@routes.post("/blockers/{blocker_id}/resolve-human")
|
||||||
def resolve_blocker(blocker_id: int, payload: dict, request: Request, identity=Depends(identity)):
|
def resolve_blocker(blocker_id: int, payload: dict, request: Request, identity=Depends(identity)):
|
||||||
session, web_session, admin = identity
|
session, web_session, admin = identity
|
||||||
try:
|
try:
|
||||||
require_csrf(web_session, payload.get("csrf_token"))
|
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")
|
entry_payload = {key: payload.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
|
||||||
blocker = session.get(BlockedProduct, blocker_id)
|
try:
|
||||||
if not blocker: raise HTTPException(404, "blocker not found")
|
return resolve_blocker_with_context(session, blocker_id, admin, entry_payload, request_ip=request.client.host if request.client else None)
|
||||||
if blocker.resolved_at is None:
|
except LookupError as error:
|
||||||
blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
|
raise HTTPException(404, str(error)) from error
|
||||||
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"]})
|
except ValueError as error:
|
||||||
session.commit()
|
raise HTTPException(422, str(error)) from error
|
||||||
return {"resolved": True, "blocker_id": blocker_id}
|
|
||||||
finally: session.close()
|
finally: session.close()
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
|
|||||||
+32
-13
@@ -8,9 +8,11 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from .config import settings
|
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 .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 .jobs import create_job
|
||||||
from .audit import record_audit
|
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", "")})
|
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()
|
finally: session.close()
|
||||||
|
|
||||||
@routes.post("/blockers/{blocker_id}/resolve")
|
@routes.get("/blockers/{blocker_id}", response_class=HTMLResponse)
|
||||||
async def resolve_blocker(request: Request, blocker_id: int):
|
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)
|
session, auth_data, redirect = page_auth(request)
|
||||||
if redirect: return redirect
|
if redirect: return redirect
|
||||||
try:
|
try:
|
||||||
values = await form_values(request); web_session, admin = auth_data
|
values = await form_values(request); web_session, admin = auth_data
|
||||||
require_csrf(web_session, values.get("csrf_token"))
|
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)
|
payload = {key: values.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
|
||||||
from .models import BlockedProduct
|
try:
|
||||||
blocker = session.get(BlockedProduct, blocker_id)
|
resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=request.client.host if request.client else None)
|
||||||
if not blocker: return HTMLResponse("Blocker not found", status_code=404)
|
except LookupError:
|
||||||
from datetime import datetime, timezone
|
return HTMLResponse("Blocker not found", status_code=404)
|
||||||
blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
|
except ValueError as error:
|
||||||
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"]})
|
detail = blocker_detail(session, blocker_id)
|
||||||
session.commit()
|
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)
|
return RedirectResponse("/blockers", status_code=303)
|
||||||
finally: session.close()
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
@routes.get("/jobs", response_class=HTMLResponse)
|
@routes.get("/jobs", response_class=HTMLResponse)
|
||||||
def jobs(request: Request, page: int = 1, status: str | None = None):
|
def jobs(request: Request, page: int = 1, status: str | None = None):
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="detail-head"><div><p class="eyebrow">RESOLUÇÃO OPERACIONAL</p><h1>Blocker {{ detail.id }}</h1><p class="muted">{{ detail.client or detail.tenant_id }} · {{ detail.product_code }}</p></div><span class="badge blocked large">{{ detail.blocker_type }}</span></div>
|
||||||
|
{% if error %}<div class="panel"><p class="error">{{ error }}</p></div>{% endif %}
|
||||||
|
<section class="split">
|
||||||
|
<article class="panel"><h2>Detalhes do blocker</h2><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "-" }}</dd><dt>Servidor</dt><dd>{{ detail.server.hostname or "-" }}</dd><dt>Produto</dt><dd>{{ detail.product_code }}</dd><dt>Descrição</dt><dd>{{ detail.description or "-" }}</dd><dt>Campo</dt><dd>{{ detail.field or "-" }}</dd><dt>Tipo</dt><dd>{{ detail.blocker_type }}</dd><dt>Motivo</dt><dd>{{ detail.reason }}</dd><dt>Evidência</dt><dd>{{ detail.evidence }}</dd></dl></article>
|
||||||
|
<article class="panel"><h2>Contexto atual</h2><p class="muted">Valor atual por chave persistida para esta empresa.</p>{% for entry in context_entries %}<div class="list-row"><span><b>{{ entry.key }}</b><small>{{ entry.scope }} / {{ entry.scope_key or "empresa" }}</small></span><em>{{ entry.value }}</em></div>{% else %}<p class="muted">Nenhum valor definido.</p>{% endfor %}</article>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Salvar contexto e reprocessar</h2><p class="muted">Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.</p><form method="post" action="/blockers/{{ detail.id }}/resolve-context"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="form-grid"><label>Chave de contexto<select name="key" required>{% for key in context_keys %}<option value="{{ key }}">{{ key }}</option>{% endfor %}</select></label><label>Valor<input name="value" required maxlength="512"><small>Valores aceitos: {{ value_choices|join(", ") }}</small></label><label>Escopo<select name="scope" required><option value="PRODUCT">PRODUCT</option><option value="GROUP">GROUP</option><option value="COMPANY">COMPANY</option></select></label><label>Chave do escopo<input name="scope_key" value="{{ detail.product_code }}" required></label><label class="wide">Nota<textarea name="reason" required maxlength="2000"></textarea></label></div><div class="panel-title"><a href="/blockers">Cancelar</a><button type="submit">Salvar contexto e reprocessar</button></div></form></section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1 +1 @@
|
|||||||
{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers globais</h1></div></div><div class="panel table-wrap"><table><thead><tr><th>Cliente</th><th>Produto</th><th>Campo</th><th>Tipo</th><th>Motivo</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or item.tenant_id }}</td><td><b>{{ item.product_code }}</b><small>{{ item.description or "" }}</small></td><td>{{ item.field }}</td><td><span class="badge blocked">{{ item.blocker_type }}</span></td><td>{{ item.reason }}</td><td>{{ item.last_seen_at }}<small>{{ item.retry_count }} retries</small></td><td><form method="post" action="/blockers/{{ item.id }}/resolve" data-confirm="Resolver este blocker com contexto explícito?"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="confirm" value="yes"><input type="hidden" name="context" value="Resolved after operator review"><button class="secondary">Resolver</button></form></td></tr>{% else %}<tr><td colspan="7" class="muted">Nenhum blocker aberto.</td></tr>{% endfor %}</tbody></table></div>{% endblock %}
|
{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers globais</h1></div></div><div class="panel table-wrap"><table><thead><tr><th>Cliente</th><th>Produto</th><th>Campo</th><th>Tipo</th><th>Motivo</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or item.tenant_id }}</td><td><b>{{ item.product_code }}</b><small>{{ item.description or "" }}</small></td><td>{{ item.field }}</td><td><span class="badge blocked">{{ item.blocker_type }}</span></td><td>{{ item.reason }}</td><td>{{ item.last_seen_at }}<small>{{ item.retry_count }} retries</small></td><td><a class="secondary" href="/blockers/{{ item.id }}">Resolver com contexto</a></td></tr>{% else %}<tr><td colspan="7" class="muted">Nenhum blocker aberto.</td></tr>{% endfor %}</tbody></table></div>{% endblock %}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user