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 .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
|
||||
|
||||
+32
-13
@@ -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):
|
||||
|
||||
@@ -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