feat: GTI Gerenciador as the single source of truth for clients; bump to 1.1.2

Tenant gains source (LOCAL|GTI_GERENCIADOR), external_client_id, external_code,
active and synced_at (migration 0024). central/app/client_sync.py fetches from
the panel's new GET /internal/fiscal/clients (dedicated bearer token, never
MySQL credentials) and upserts Tenant rows, never touching a LOCAL row.
/agents/new now offers only active GTI_GERENCIADOR tenants, with a client-side
search box; the POST validates the same rule server-side, not just in the
dropdown. central/scripts/sync_clients_from_gerenciador.py is the operator-run
sync + reconciliation report.

GTI_FISCAL_VERSION/AGENT_VERSION bumped to 1.1.2 (release/VERSION single
source of truth), incorporating the installation_id persistence fix. Rebuilt
the EXE installer trio at 1.1.2 (scripts/build_windows_installer.ps1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-23 22:38:58 -03:00
co-authored by Claude Sonnet 5
parent ac7b503c2f
commit e7d77beb26
29 changed files with 453 additions and 38 deletions
+9 -1
View File
@@ -93,7 +93,14 @@ def publish_release(factory, version=VERSION):
def tenant_id(factory):
with factory() as session:
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
# /agents/new only offers active GTI_GERENCIADOR-sourced tenants
# (central/app/client_sync.py) — flip the shared fixture tenant so
# existing form-based tests keep working under that rule.
if tenant.source != "GTI_GERENCIADOR" or not tenant.active:
tenant.source, tenant.external_client_id, tenant.active = "GTI_GERENCIADOR", tenant.id, True
session.commit()
return tenant.id
def audits(factory):
@@ -228,6 +235,7 @@ def test_direct_authenticated_download_does_not_need_a_link(hub):
def test_get_agents_new_renders_form_not_captured_by_dynamic_route(hub):
client, factory = hub
publish_release(factory)
tenant_id(factory) # at least one active GTI_GERENCIADOR tenant, or the form shows the empty state instead
login(client)
response = client.get("/agents/new")
assert response.status_code == 200
+87
View File
@@ -0,0 +1,87 @@
"""Client mirror from GTI Gerenciador (central/app/client_sync.py, web.py's
/agents/new). GTI Gerenciador is the single source of truth: Tenant rows
with source='GTI_GERENCIADOR' are upserted from it; source='LOCAL' rows
(pre-existing dev/test tenants) are never touched or offered."""
import dataclasses
import pytest
from sqlalchemy import select
from central.app import client_sync, config
from central.app.models import Tenant
from central.tests.test_control_plane import setup_db
CLIENTS = [
{"external_client_id": 1, "code": "000001", "display_name": "Mercado Ativo", "active": True},
{"external_client_id": 2, "code": "000002", "display_name": "Loja Inativa", "active": False},
]
@pytest.fixture
def factory():
return setup_db()
def test_sync_creates_tenants_from_gerenciador(factory):
with factory() as session:
result = client_sync.sync_from_gerenciador(session, CLIENTS)
session.commit()
assert result.created == 2 and result.updated == 0 and result.fetched == 2
assert result.active == 1 and result.inactive == 1
rows = {row.external_client_id: row for row in session.scalars(select(Tenant)).all()}
assert rows[1].name == "Mercado Ativo" and rows[1].source == "GTI_GERENCIADOR" and rows[1].active is True
assert rows[2].active is False and rows[2].external_code == "000002"
def test_sync_is_idempotent_and_updates_changed_fields(factory):
with factory() as session:
client_sync.sync_from_gerenciador(session, CLIENTS)
session.commit()
result = client_sync.sync_from_gerenciador(session, CLIENTS)
session.commit()
assert result.created == 0 and result.unchanged == 2
renamed = [{**CLIENTS[0], "display_name": "Mercado Ativo Renomeado"}, CLIENTS[1]]
result2 = client_sync.sync_from_gerenciador(session, renamed)
session.commit()
assert result2.updated == 1 and result2.unchanged == 1
row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
assert row.name == "Mercado Ativo Renomeado"
def test_sync_never_touches_local_tenants(factory):
with factory() as session:
session.add(Tenant(name="Acme", source="LOCAL"))
session.commit()
client_sync.sync_from_gerenciador(session, CLIENTS)
session.commit()
local = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
assert local.source == "LOCAL" and local.external_client_id is None
def test_sync_disambiguates_a_name_collision_with_an_existing_tenant(factory):
with factory() as session:
session.add(Tenant(name="Mercado Ativo", source="LOCAL"))
session.commit()
client_sync.sync_from_gerenciador(session, CLIENTS)
session.commit()
new_row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
assert new_row.name == "Mercado Ativo (000001)" # the LOCAL tenant keeps the plain name
def test_deactivating_a_client_in_gerenciador_is_reflected_without_deleting_history(factory):
with factory() as session:
client_sync.sync_from_gerenciador(session, CLIENTS)
session.commit()
now_inactive = [{**CLIENTS[0], "active": False}, CLIENTS[1]]
client_sync.sync_from_gerenciador(session, now_inactive)
session.commit()
row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
assert row.active is False # row (and every FK pointing at it) is kept — never deleted
def test_disabled_without_configured_url_or_token(monkeypatch):
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, gerenciador_internal_url="", fiscal_internal_api_token=""))
assert client_sync.enabled() is False
with pytest.raises(client_sync.ClientSyncUnavailable):
client_sync.fetch_from_gerenciador()
+1 -1
View File
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
def test_version_endpoint_reports_release_and_fiscal_versions():
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
assert body["version"] == "1.1.1" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
assert body["version"] == "1.1.2" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
def test_production_refuses_the_dev_token(monkeypatch):
@@ -90,7 +90,14 @@ def io_bytes_bundle(version):
def tenant_id(factory):
with factory() as session:
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
# /agents/new only offers active GTI_GERENCIADOR-sourced tenants
# (central/app/client_sync.py) — flip the shared fixture tenant so
# existing form-based tests keep working under that rule.
if tenant.source != "GTI_GERENCIADOR" or not tenant.active:
tenant.source, tenant.external_client_id, tenant.active = "GTI_GERENCIADOR", tenant.id, True
session.commit()
return tenant.id
def make_installation(factory, release_id, name="Servidor E2E"):
@@ -0,0 +1,84 @@
"""GET/POST /agents/new only offers active GTI_GERENCIADOR-sourced tenants —
GTI Gerenciador is the single source of truth for clients, never a local ad
hoc list (central/app/client_sync.py, web.py)."""
import dataclasses
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from central.app import config
from central.app.main import create_app
from central.app.models import AgentEnrollment, AgentRelease, Tenant
from central.scripts.publish_agent_release import publish
from central.tests.test_agent_distribution import make_release_zip, write_temp_zip
from central.tests.test_control_plane import seed, setup_db
VERSION = "9.9.1"
@pytest.fixture
def hub(tmp_path, monkeypatch):
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, agent_release_storage_path=str(tmp_path / "releases"), web_cookie_secure=False))
factory = setup_db()
seed(factory) # tenant "Acme" (LOCAL, no source) + admin "operator"/"a-secure-password"
with factory() as session:
session.add(Tenant(name="Mercado Ativo", source="GTI_GERENCIADOR", external_client_id=1, active=True))
session.add(Tenant(name="Loja Inativa", source="GTI_GERENCIADOR", external_client_id=2, active=False))
release, _ = publish(session, write_temp_zip(make_release_zip(VERSION), VERSION), version=VERSION)
release_id = release.id
session.commit()
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
return client, factory, release_id
def login(client):
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
assert response.status_code == 303
def csrf(client):
return client.cookies.get("gti_csrf", "")
def test_new_installation_form_lists_only_active_gerenciador_tenants(hub):
client, _factory, _release_id = hub
login(client)
body = client.get("/agents/new").text
assert "Mercado Ativo" in body
assert "Loja Inativa" not in body # inactive: excluded from the selector
assert "Acme" not in body # LOCAL (not from GTI Gerenciador): excluded
def test_cannot_create_installation_for_an_inactive_tenant(hub):
client, factory, release_id = hub
login(client)
with factory() as session:
inactive_id = session.scalar(select(Tenant).where(Tenant.name == "Loja Inativa")).id
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": inactive_id, "display_name": "Teste", "release_id": release_id})
assert response.status_code == 400 and "ativo" in response.text.lower()
with factory() as session:
assert session.scalar(select(AgentEnrollment)) is None
def test_cannot_create_installation_for_a_local_tenant(hub):
client, factory, release_id = hub
login(client)
with factory() as session:
local_id = session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": local_id, "display_name": "Teste", "release_id": release_id})
assert response.status_code == 400
with factory() as session:
assert session.scalar(select(AgentEnrollment)) is None
def test_creates_installation_for_an_active_gerenciador_tenant(hub):
client, factory, release_id = hub
login(client)
with factory() as session:
active_id = session.scalar(select(Tenant).where(Tenant.name == "Mercado Ativo")).id
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": active_id, "display_name": "Teste", "release_id": release_id}, follow_redirects=False)
assert response.status_code == 303
with factory() as session:
enr = session.scalar(select(AgentEnrollment))
assert enr is not None and enr.tenant_id == active_id