diff --git a/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/final-fix-report.md b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/final-fix-report.md new file mode 100644 index 0000000..1ae7f58 --- /dev/null +++ b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/final-fix-report.md @@ -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. diff --git a/central/app/blocker_resolution.py b/central/app/blocker_resolution.py index d2aa7a2..eea0afa 100644 --- a/central/app/blocker_resolution.py +++ b/central/app/blocker_resolution.py @@ -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") diff --git a/central/app/control_api.py b/central/app/control_api.py index f3dae0c..d19f24e 100644 --- a/central/app/control_api.py +++ b/central/app/control_api.py @@ -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: diff --git a/central/app/web.py b/central/app/web.py index 2fbbc31..ddf5faa 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -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() diff --git a/central/templates/blocker_detail.html b/central/templates/blocker_detail.html index d18ace2..98d3219 100644 --- a/central/templates/blocker_detail.html +++ b/central/templates/blocker_detail.html @@ -7,6 +7,10 @@

Detalhes do blocker

Cliente
{{ detail.client or "-" }}
Instalação
{{ detail.installation.display_name or "-" }}
Servidor
{{ detail.server.hostname or "-" }}
Produto
{{ detail.product_code }}
Descrição
{{ detail.description or "-" }}
Campo
{{ detail.field or "OTHER" }}
Tipo
{{ detail.blocker_type }}
Motivo
{{ detail.reason }}
Idade
{{ detail.age }}
Primeira ocorrência
{{ detail.first_seen_display }}
Última ocorrência
{{ detail.last_seen_display }}
Evidência
{{ detail.evidence|tojson(indent=2) }}

Contexto atual

Valor atual por chave persistida para esta empresa.

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

Nenhum valor definido.

{% endfor %}
-

Salvar contexto e reprocessar

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

Cancelar
+{% if resolution_available %} +

Salvar contexto e reprocessar

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

Cancelar
+{% else %} +

Resolução de contexto indisponível

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.

+{% endif %}

Eventos relacionados

{% for event in detail.related_events[:10] %}
{{ event.severity }}{{ event.event_type }}{{ event.message }}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}
{% else %}

Nenhum evento relacionado.

{% endfor %}

Jobs relacionados

{% for job in detail.related_jobs[:10] %}
Job {{ job.job_id }}{{ job.kind }} · {{ job.status }}{{ job.duration }}
{% else %}

Nenhum job relacionado.

{% endfor %}
{% endblock %} diff --git a/central/tests/test_blocker_resolution_panel.py b/central/tests/test_blocker_resolution_panel.py index 9f1f988..01b6650 100644 --- a/central/tests/test_blocker_resolution_panel.py +++ b/central/tests/test_blocker_resolution_panel.py @@ -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 diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index ca199ff..6ad3442 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -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