feat: add safe blocker resolution workflow

This commit is contained in:
gti
2026-09-22 18:47:04 -03:00
parent 25af5cf0f0
commit 5180725238
7 changed files with 430 additions and 22 deletions
@@ -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.
+149
View File
@@ -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
+9 -8
View File
@@ -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
View File
@@ -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):
+11
View File
@@ -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
View File
@@ -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