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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user