fix: clarify unavailable legacy blocker resolution
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Final Fix Report — Control Panel Operational UX V1
|
||||
|
||||
Base: `c237820`
|
||||
|
||||
## Changes
|
||||
|
||||
- Legacy blockers without an exact installation binding remain fail-closed. Their detail page explains in Portuguese why resolution is unavailable and omits the resolution form. Direct HTML and JSON attempts return HTTP 409; missing blockers remain 404. Legacy details do not load context by guessing a company identity.
|
||||
- Resolution rejects derived `company_key` values longer than the database limit of 64 characters before creating context, job, or audit rows. No truncation or identity aliasing is performed.
|
||||
- Validation-error re-renders preserve the submitted key, value, scope, scope key, and reason, and reload current persisted context for a bound blocker.
|
||||
- Added regression coverage in `central/tests/test_blocker_resolution_panel.py` and `central/tests/test_control_panel_pages.py`.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD RED: focused tests initially failed on the missing company-key validation, unavailable-blocker response/UI, and form/context recovery behavior.
|
||||
- Focused: `python -m pytest central/tests/test_blocker_resolution_panel.py central/tests/test_control_panel_pages.py -q` — **26 passed**.
|
||||
- Full central suite: `python -m pytest central/tests -q` — **284 passed, 1 warning** (existing `openpyxl` print-area warning in `test_bootstrap_v1_extra.py`).
|
||||
- `git diff --check` — passed.
|
||||
|
||||
## Concerns
|
||||
|
||||
- Test output includes the existing `pytest-asyncio` configuration deprecation notice and one unrelated `openpyxl` warning; neither caused test failures.
|
||||
@@ -15,6 +15,11 @@ WORKFLOW_SCOPES = {"PRODUCT", "GROUP", "COMPANY"}
|
||||
ENTRY_FIELDS = {"scope", "scope_key", "key", "value", "reason"}
|
||||
MAX_VALUE_LENGTH = 512
|
||||
MAX_REASON_LENGTH = 2000
|
||||
MAX_COMPANY_KEY_LENGTH = 64
|
||||
|
||||
|
||||
class BlockerResolutionUnavailable(LookupError):
|
||||
"""The blocker exists, but has no proven installation to target safely."""
|
||||
|
||||
|
||||
def validate_resolution_input(payload):
|
||||
@@ -87,12 +92,16 @@ def resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip
|
||||
if agent.tenant_id != blocker.tenant_id:
|
||||
raise LookupError("blocker agent tenant mismatch")
|
||||
else:
|
||||
raise LookupError("blocker installation is required")
|
||||
raise BlockerResolutionUnavailable(
|
||||
"Blocker legado de empresa sem vínculo comprovado com um servidor; não é possível oferecer o reprocessamento de contexto com segurança."
|
||||
)
|
||||
|
||||
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 len(company_key) > MAX_COMPANY_KEY_LENGTH:
|
||||
raise ValueError(f"company key must be at most {MAX_COMPANY_KEY_LENGTH} characters")
|
||||
if normalized["scope"] == "PRODUCT" and normalized["scope_key"] != blocker.product_code:
|
||||
raise ValueError("PRODUCT scope_key must match the blocker product code")
|
||||
|
||||
|
||||
@@ -6,7 +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 .blocker_resolution import BlockerResolutionUnavailable, 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
|
||||
@@ -119,6 +119,8 @@ def router(session_factory):
|
||||
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 BlockerResolutionUnavailable as error:
|
||||
raise HTTPException(409, str(error)) from error
|
||||
except LookupError as error:
|
||||
raise HTTPException(404, str(error)) from error
|
||||
except ValueError as error:
|
||||
|
||||
+11
-4
@@ -9,7 +9,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from .config import settings
|
||||
from .blocker_resolution import _company_key, resolve_blocker_with_context
|
||||
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
|
||||
from .context_keys import ALLOWED_KEYS, ENUMS
|
||||
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
|
||||
from .human_auth import create_session, current_session, require_csrf, verify_password
|
||||
@@ -167,9 +167,9 @@ def router(session_factory):
|
||||
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()
|
||||
current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all() if installation else []
|
||||
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})
|
||||
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, "resolution_available": installation is not None, "resolution_values": {}})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/blockers/{blocker_id}/resolve-context")
|
||||
@@ -182,11 +182,18 @@ def router(session_factory):
|
||||
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 BlockerResolutionUnavailable as error:
|
||||
return HTMLResponse(str(error), status_code=409)
|
||||
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)
|
||||
blocker = session.get(BlockedProduct, blocker_id)
|
||||
installation = session.get(Installation, blocker.installation_id) if blocker and blocker.installation_id else None
|
||||
tenant = session.get(Tenant, blocker.tenant_id) if blocker else None
|
||||
company_key = _company_key(blocker, installation, tenant) if blocker else ""
|
||||
current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all() if blocker and installation else []
|
||||
return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": current_context, "context_keys": sorted(ALLOWED_KEYS), "value_choices": sorted({value for values in ENUMS.values() for value in values}), "error": str(error), "resolution_available": installation is not None, "resolution_values": payload}, 422)
|
||||
return RedirectResponse(f"/blockers/{blocker_id}", status_code=303)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<article class="panel"><h2>Detalhes do blocker</h2><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "-" }}</dd><dt>Instalação</dt><dd>{{ detail.installation.display_name 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 "OTHER" }}</dd><dt>Tipo</dt><dd>{{ detail.blocker_type }}</dd><dt>Motivo</dt><dd>{{ detail.reason }}</dd><dt>Idade</dt><dd>{{ detail.age }}</dd><dt>Primeira ocorrência</dt><dd title="{{ detail.first_seen_at }}">{{ detail.first_seen_display }}</dd><dt>Última ocorrência</dt><dd title="{{ detail.last_seen_at }}">{{ detail.last_seen_display }}</dd><dt>Evidência</dt><dd><pre class="evidence">{{ detail.evidence|tojson(indent=2) }}</pre></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 for="context-key">Chave de contexto<select id="context-key" name="key" required>{% for key in context_keys %}<option value="{{ key }}">{{ key }}</option>{% endfor %}</select></label><label for="context-value">Valor<input id="context-value" name="value" required maxlength="512"><small>Valores aceitos: {{ value_choices|join(", ") }}</small></label><label for="context-scope">Escopo<select id="context-scope" name="scope" required><option value="PRODUCT">PRODUCT</option><option value="GROUP">GROUP</option><option value="COMPANY">COMPANY</option></select></label><label for="scope-key">Chave do escopo<input id="scope-key" name="scope_key" value="{{ detail.product_code }}" required></label><label class="wide" for="context-reason">Nota<textarea id="context-reason" 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>
|
||||
{% if resolution_available %}
|
||||
<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 for="context-key">Chave de contexto<select id="context-key" name="key" required>{% for key in context_keys %}<option value="{{ key }}"{% if resolution_values.get('key') == key %} selected{% endif %}>{{ key }}</option>{% endfor %}</select></label><label for="context-value">Valor<input id="context-value" name="value" required maxlength="512" value="{{ resolution_values.get('value', '') }}"><small>Valores aceitos: {{ value_choices|join(", ") }}</small></label><label for="context-scope">Escopo<select id="context-scope" name="scope" required>{% for scope in ('PRODUCT', 'GROUP', 'COMPANY') %}<option value="{{ scope }}"{% if resolution_values.get('scope', 'PRODUCT') == scope %} selected{% endif %}>{{ scope }}</option>{% endfor %}</select></label><label for="scope-key">Chave do escopo<input id="scope-key" name="scope_key" value="{{ resolution_values.get('scope_key', detail.product_code) }}" required></label><label class="wide" for="context-reason">Nota<textarea id="context-reason" name="reason" required maxlength="2000">{{ resolution_values.get('reason', '') }}</textarea></label></div><div class="panel-title"><a href="/blockers">Cancelar</a><button type="submit">Salvar contexto e reprocessar</button></div></form></section>
|
||||
{% else %}
|
||||
<section class="panel"><h2>Resolução de contexto indisponível</h2><p class="error">Este é um blocker legado de empresa, sem vínculo comprovado com um servidor. Não é possível oferecer o reprocessamento de contexto com segurança.</p></section>
|
||||
{% endif %}
|
||||
<section class="split"><article class="panel"><h2>Eventos relacionados</h2>{% for event in detail.related_events[:10] %}<div class="event-row"><span>{{ event.severity }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}</small></span></div>{% else %}<p class="empty-state">Nenhum evento relacionado.</p>{% endfor %}</article><article class="panel"><h2>Jobs relacionados</h2>{% for job in detail.related_jobs[:10] %}<div class="list-row"><span><b>Job {{ job.job_id }}</b><small>{{ job.kind }} · {{ job.status }}</small></span><em>{{ job.duration }}</em></div>{% else %}<p class="empty-state">Nenhum job relacionado.</p>{% endfor %}</article></section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,7 +3,7 @@ import pytest
|
||||
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.blocker_resolution import BlockerResolutionUnavailable, resolve_blocker_with_context, validate_resolution_input
|
||||
from central.app.main import create_app
|
||||
from central.app.models import AdminUser, Agent, AuditEvent, BlockedProduct, FiscalContextEntry, Job, Installation, Tenant
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
@@ -117,7 +117,7 @@ def test_legacy_blocker_without_installation_fails_closed_without_job_or_audit()
|
||||
session.commit()
|
||||
admin = session.get(AdminUser, admin_id)
|
||||
|
||||
with pytest.raises(LookupError, match="installation"):
|
||||
with pytest.raises(BlockerResolutionUnavailable, match="Blocker legado"):
|
||||
resolve_blocker_with_context(session, blocker_id, admin, {"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"})
|
||||
|
||||
assert session.query(FiscalContextEntry).count() == 0
|
||||
@@ -125,6 +125,29 @@ def test_legacy_blocker_without_installation_fails_closed_without_job_or_audit()
|
||||
assert session.query(AuditEvent).count() == 0
|
||||
|
||||
|
||||
def test_resolution_rejects_company_key_over_database_limit_before_mutation():
|
||||
session_factory = setup_db()
|
||||
blocker_id, admin_id, installation_id = _seed_blocker(session_factory)
|
||||
|
||||
with session_factory() as session:
|
||||
installation = session.get(Installation, installation_id)
|
||||
installation.company_code = "C" * 65
|
||||
session.commit()
|
||||
admin = session.get(AdminUser, admin_id)
|
||||
|
||||
with pytest.raises(ValueError, match="company key.*64"):
|
||||
resolve_blocker_with_context(
|
||||
session,
|
||||
blocker_id,
|
||||
admin,
|
||||
{"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"},
|
||||
)
|
||||
|
||||
assert session.query(FiscalContextEntry).count() == 0
|
||||
assert session.query(Job).count() == 0
|
||||
assert session.query(AuditEvent).count() == 0
|
||||
|
||||
|
||||
def test_cross_tenant_installation_pointer_fails_before_mutation():
|
||||
session_factory = setup_db()
|
||||
blocker_id, admin_id, _ = _seed_blocker(session_factory)
|
||||
@@ -242,3 +265,47 @@ def test_human_and_json_resolution_routes_use_the_same_validated_workflow():
|
||||
json={"scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "INTER", "reason": "reviewed"},
|
||||
)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
|
||||
def test_unbound_blocker_resolution_routes_return_conflict_but_missing_stays_not_found():
|
||||
session_factory = setup_db()
|
||||
blocker_id, _, _ = _seed_blocker(session_factory)
|
||||
with session_factory() as session:
|
||||
session.get(BlockedProduct, blocker_id).installation_id = None
|
||||
session.commit()
|
||||
|
||||
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)
|
||||
payload = {"csrf_token": client.cookies.get("gti_csrf"), "scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"}
|
||||
|
||||
for route in (f"/blockers/{blocker_id}/resolve-context", f"/v1/blockers/{blocker_id}/resolve-human"):
|
||||
response = client.post(route, data=payload) if route.startswith("/blockers/") else client.post(route, json=payload)
|
||||
assert response.status_code == 409
|
||||
assert "sem vínculo comprovado" in response.text.lower()
|
||||
|
||||
missing = client.post("/v1/blockers/999999/resolve-human", json=payload)
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_invalid_resolution_rerenders_submitted_values_and_persisted_context():
|
||||
session_factory = setup_db()
|
||||
blocker_id, _, _ = _seed_blocker(session_factory)
|
||||
with session_factory() as session:
|
||||
blocker = session.get(BlockedProduct, blocker_id)
|
||||
session.add(FiscalContextEntry(tenant_id=blocker.tenant_id, company_key="ACME-COMPANY", scope="PRODUCT", scope_key="P-001", key="entry_scope", value="INTER", source="OPERATOR", reason="already saved"))
|
||||
session.commit()
|
||||
|
||||
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)
|
||||
response = client.post(
|
||||
f"/blockers/{blocker_id}/resolve-context",
|
||||
data={"csrf_token": client.cookies.get("gti_csrf"), "key": "product_role", "value": "NOT_VALID", "scope": "COMPANY", "scope_key": "ACME", "reason": "submitted reason"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert 'option value="product_role" selected' in response.text
|
||||
assert 'name="value" required maxlength="512" value="NOT_VALID"' in response.text
|
||||
assert 'option value="COMPANY" selected' in response.text
|
||||
assert 'name="scope_key" value="ACME"' in response.text
|
||||
assert "submitted reason" in response.text
|
||||
assert "entry_scope" in response.text and "INTER" in response.text
|
||||
|
||||
@@ -271,6 +271,28 @@ def test_agent_and_blocker_details_keep_safe_forms_and_human_event_rows():
|
||||
assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", re.sub(r' title="[^"]*"', "", blocker_response.text))
|
||||
|
||||
|
||||
def test_legacy_blocker_detail_explains_unavailable_resolution_without_form():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
blocker = upsert_blocker(
|
||||
session,
|
||||
installation.tenant_id,
|
||||
{"company": "Acme", "product_code": "LEGACY", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "ST", "reason_code": "LEGACY", "input_fingerprint": "unbound-detail"},
|
||||
)
|
||||
session.commit()
|
||||
blocker_id = blocker.id
|
||||
|
||||
response = client.get(f"/blockers/{blocker_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "blocker legado de empresa" in response.text.lower()
|
||||
assert "sem vínculo comprovado com um servidor" in response.text.lower()
|
||||
assert "não é possível oferecer o reprocessamento de contexto com segurança" in response.text.lower()
|
||||
assert "Salvar contexto e reprocessar" not in response.text
|
||||
assert f'action="/blockers/{blocker_id}/resolve-context"' not in response.text
|
||||
|
||||
|
||||
def test_agent_detail_resolves_agent_after_first_hundred_installations():
|
||||
client, _, _ = _panel_client()
|
||||
factory = client._session_factory
|
||||
|
||||
Reference in New Issue
Block a user