diff --git a/agent/pyproject.toml b/agent/pyproject.toml index f244d15..49f5aa9 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gti-fiscal-agent" -version = "1.1.1" +version = "1.1.2" requires-python = ">=3.10" dependencies = ["httpx>=0.27,<1"] diff --git a/agent/scripts/run_agent_service.py b/agent/scripts/run_agent_service.py index c74a462..83988f4 100644 --- a/agent/scripts/run_agent_service.py +++ b/agent/scripts/run_agent_service.py @@ -97,7 +97,7 @@ class Config: jobs_enabled = (env.get("AGENT_JOBS_ENABLED", "1") or "1").strip().upper() self.agent_jobs_enabled = jobs_enabled not in ("0", "FALSE", "NO") self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5")) - self.agent_version = env.get("AGENT_VERSION", "1.1.1") + self.agent_version = env.get("AGENT_VERSION", "1.1.2") self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1) self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite")) # Pilot write authorization: both must be explicitly set by the installer for @@ -198,7 +198,7 @@ def execute_job(job, config, executor, state, env): result_summary = { "coverage": coverage, "fiscal_mode": getattr(config, "fiscal_mode", None), - "release_version": env.get("AGENT_VERSION") or "1.1.1", + "release_version": env.get("AGENT_VERSION") or "1.1.2", "products_processed": summary["processed"], "writes_attempted": summary["writes_attempted"], "writes_succeeded": summary["writes_succeeded"], @@ -331,7 +331,7 @@ def run(config, env, *, max_iterations=None, stopper=None): def enforce_startup_preflight(env): """Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed.""" from gti_fiscal_agent.preflight import run_preflight - report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.1") + report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.2") logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False)) print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False)) if report["PREFLIGHT"] != "PASS": diff --git a/agent/src/gti_fiscal_agent/preflight.py b/agent/src/gti_fiscal_agent/preflight.py index 98f83f1..a9f5284 100644 --- a/agent/src/gti_fiscal_agent/preflight.py +++ b/agent/src/gti_fiscal_agent/preflight.py @@ -9,7 +9,7 @@ import os import sqlite3 from pathlib import Path -AGENT_VERSION_DEFAULT = "1.1.1" +AGENT_VERSION_DEFAULT = "1.1.2" REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD") REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest") FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY") diff --git a/central/alembic/versions/0024_tenant_gerenciador_mirror.py b/central/alembic/versions/0024_tenant_gerenciador_mirror.py new file mode 100644 index 0000000..583176d --- /dev/null +++ b/central/alembic/versions/0024_tenant_gerenciador_mirror.py @@ -0,0 +1,35 @@ +"""Tenant mirror identity from GTI Gerenciador. + +GTI Gerenciador (the panel) becomes the source of truth for clients. +tenants gains source ('LOCAL' | 'GTI_GERENCIADOR'), external_client_id +(Gerenciador's CLIENTE.ID), external_code, active and synced_at. Existing +rows (pre-dating this sync) become source='LOCAL' and are never offered for +a new installation, but keep every foreign key that already points at them. + +Revision ID: 0024_tenant_gerenciador_mirror +Revises: 0023_install_codes +""" +from alembic import op +import sqlalchemy as sa + +revision = "0024_tenant_gerenciador_mirror" +down_revision = "0023_install_codes" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("tenants", sa.Column("source", sa.String(16), nullable=False, server_default="LOCAL")) + op.add_column("tenants", sa.Column("external_client_id", sa.Integer(), nullable=True)) + op.add_column("tenants", sa.Column("external_code", sa.String(16), nullable=True)) + op.add_column("tenants", sa.Column("active", sa.Boolean(), nullable=False, server_default="true")) + op.add_column("tenants", sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_tenants_external_client_id", "tenants", ["external_client_id"]) + op.create_unique_constraint("uq_tenant_external_identity", "tenants", ["source", "external_client_id"]) + + +def downgrade(): + op.drop_constraint("uq_tenant_external_identity", "tenants", type_="unique") + op.drop_index("ix_tenants_external_client_id", table_name="tenants") + for column in ("synced_at", "active", "external_code", "external_client_id", "source"): + op.drop_column("tenants", column) diff --git a/central/app/client_sync.py b/central/app/client_sync.py new file mode 100644 index 0000000..e5970c0 --- /dev/null +++ b/central/app/client_sync.py @@ -0,0 +1,103 @@ +"""Client mirror from GTI Gerenciador — the single source of truth for +clients (central/app/models.py: Tenant.source/external_client_id/active). +The Fiscal Hub never receives MySQL credentials; it calls the panel's +read-only internal endpoint instead, authenticated by a dedicated bearer +token (FISCAL_INTERNAL_API_TOKEN, shared only between the two apps' envs +— never SESSION_SECRET, the SSO key, an admin password or an Agent token). + +Run `central/scripts/sync_clients_from_gerenciador.py` to pull a fresh copy; +`Tenant.source='LOCAL'` rows (pre-existing dev/test tenants) are never +touched or offered for a new installation. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone + +import httpx +from sqlalchemy import select + +from . import config +from .models import Tenant + +SOURCE = "GTI_GERENCIADOR" + + +class ClientSyncUnavailable(Exception): + pass + + +def enabled() -> bool: + return bool(config.settings.gerenciador_internal_url and config.settings.fiscal_internal_api_token) + + +def fetch_from_gerenciador() -> list[dict]: + if not enabled(): + raise ClientSyncUnavailable("GERENCIADOR_INTERNAL_URL/FISCAL_INTERNAL_API_TOKEN not configured") + headers = {"Authorization": f"Bearer {config.settings.fiscal_internal_api_token}"} + try: + response = httpx.get(config.settings.gerenciador_internal_url, headers=headers, timeout=config.settings.http_timeout_seconds) + except httpx.HTTPError as error: + raise ClientSyncUnavailable(f"request failed: {error}") from error + if response.status_code != 200: + raise ClientSyncUnavailable(f"unexpected status {response.status_code}: {response.text[:200]}") + return response.json()["clients"] + + +@dataclass +class SyncResult: + fetched: int + created: int + updated: int + unchanged: int + active: int + inactive: int + + +def sync_from_gerenciador(session, clients: list[dict]) -> SyncResult: + """Upsert `clients` (each: external_client_id, code, display_name, active) + into Tenant rows with source='GTI_GERENCIADOR'. Never touches a + source='LOCAL' row. A display-name collision with another tenant is + disambiguated with the external code rather than failing the whole sync + over one client (Tenant.name is UNIQUE).""" + now = datetime.now(timezone.utc) + existing_by_external = {row.external_client_id: row for row in session.scalars(select(Tenant).where(Tenant.source == SOURCE)).all()} + taken_names = {row.name for row in session.scalars(select(Tenant)).all()} + created = updated = unchanged = 0 + + for client in clients: + external_id = client["external_client_id"] + code = client["code"] + active = bool(client["active"]) + wanted_name = (client.get("display_name") or "").strip() or f"Cliente {code}" + row = existing_by_external.get(external_id) + + if row is None: + name = wanted_name if wanted_name not in taken_names else f"{wanted_name} ({code})" + row = Tenant(name=name, source=SOURCE, external_client_id=external_id, external_code=code, active=active, synced_at=now) + session.add(row) + taken_names.add(name) + created += 1 + continue + + changed = False + if row.name != wanted_name and wanted_name not in taken_names: + taken_names.discard(row.name) + row.name = wanted_name + taken_names.add(wanted_name) + changed = True + if row.external_code != code: + row.external_code = code + changed = True + if row.active != active: + row.active = active + changed = True + row.synced_at = now + if changed: + updated += 1 + else: + unchanged += 1 + + session.flush() + active_count = sum(1 for c in clients if c["active"]) + return SyncResult(fetched=len(clients), created=created, updated=updated, unchanged=unchanged, active=active_count, inactive=len(clients) - active_count) diff --git a/central/app/config.py b/central/app/config.py index 71e9526..6cef656 100644 --- a/central/app/config.py +++ b/central/app/config.py @@ -52,6 +52,11 @@ class Settings: agent_download_link_ttl_seconds: int = int(os.getenv("AGENT_DOWNLOAD_LINK_TTL_SECONDS", "1800")) agent_download_max_uses_default: int = int(os.getenv("AGENT_DOWNLOAD_MAX_USES_DEFAULT", "1")) agent_enrollment_ttl_seconds: int = int(os.getenv("AGENT_ENROLLMENT_TTL_SECONDS", "86400")) + # ---- Client mirror from GTI Gerenciador (central/app/client_sync.py) ---- + # GTI Gerenciador is the single source of truth for clients; the Fiscal Hub + # never gets MySQL credentials. Both empty = sync disabled (documented). + gerenciador_internal_url: str = os.getenv("GERENCIADOR_INTERNAL_URL", "") # e.g. https://gtigerenciador.../internal/fiscal/clients + fiscal_internal_api_token: str = os.getenv("FISCAL_INTERNAL_API_TOKEN", "") ncm_dataset_url: str = os.getenv("NCM_DATASET_URL", "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json") cest_dataset_url: str = os.getenv("CEST_DATASET_URL", "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18") dataset_storage_path: str = os.getenv("DATASET_STORAGE_PATH", "./data/datasets") diff --git a/central/app/models.py b/central/app/models.py index 536e11a..24f81f0 100644 --- a/central/app/models.py +++ b/central/app/models.py @@ -215,9 +215,22 @@ class RtcCredPresEntry(Base): class Tenant(Base): + """A client. GTI Gerenciador (the panel) is the single source of truth — + see central/app/client_sync.py. `source='GTI_GERENCIADOR'` rows are + upserted by that sync, keyed by `external_client_id` (Gerenciador's + CLIENTE.ID, the stable technical PK — CLIENTE.CODIGO is editable and + never used as identity, same reasoning as FUNCIONARIO_PAINEL.ID in SSO). + `source='LOCAL'` rows predate this sync (dev/test tenants) and are never + offered for a new installation.""" __tablename__ = "tenants" + __table_args__ = (UniqueConstraint("source", "external_client_id", name="uq_tenant_external_identity"),) id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128), unique=True) + source: Mapped[str] = mapped_column(String(16), default="LOCAL", server_default="LOCAL") # LOCAL | GTI_GERENCIADOR + external_client_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + external_code: Mapped[str | None] = mapped_column(String(16), nullable=True) + active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class Agent(Base): diff --git a/central/app/version.py b/central/app/version.py index 2b077e2..89bfd78 100644 --- a/central/app/version.py +++ b/central/app/version.py @@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds once published — never a second, independent version number. Fiscal semantics are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py, DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump.""" -GTI_FISCAL_VERSION = "1.1.1" +GTI_FISCAL_VERSION = "1.1.2" API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x diff --git a/central/app/web.py b/central/app/web.py index 62b3d17..a5e96f8 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -10,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import select -from . import agent_distribution, config, enrollment, sso +from . import agent_distribution, client_sync, config, enrollment, sso from .config import settings from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context from .context_keys import ALLOWED_KEYS, ENUMS @@ -200,14 +200,22 @@ def router(session_factory): return RedirectResponse("/agents/releases", status_code=303) finally: session.close() + def _active_gerenciador_tenants(session, search: str | None = None): + # GTI Gerenciador is the single source of truth for clients (central/app/ + # client_sync.py) — a new installation never offers a LOCAL (dev/test) or + # inactive tenant, so it can never pick a client that doesn't exist there. + query = select(Tenant).where(Tenant.source == client_sync.SOURCE, Tenant.active.is_(True)) + if search: query = query.where(Tenant.name.ilike(f"%{search}%")) + return session.scalars(query.order_by(Tenant.name)).all() + @routes.get("/agents/new", response_class=HTMLResponse) - def new_installation_form(request: Request): + def new_installation_form(request: Request, search: str | None = None): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: - tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all() + tenants = _active_gerenciador_tenants(session, search) releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all() - return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None}) + return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None, "search": search or ""}) finally: session.close() @routes.post("/agents/new") @@ -224,13 +232,17 @@ def router(session_factory): errors = [] tenant = session.get(Tenant, int(tenant_id)) if tenant_id and tenant_id.isdigit() else None release = session.get(AgentRelease, int(release_id)) if release_id and release_id.isdigit() else None - if not tenant: errors.append("Selecione um cliente válido.") + # Server-side, not just a hidden option in the form: a LOCAL or + # inactive tenant can never receive a new installation, matching + # GTI Gerenciador's own active/inactive rule. + if tenant and (tenant.source != client_sync.SOURCE or not tenant.active): tenant = None + if not tenant: errors.append("Selecione um cliente ativo válido (cadastrado no GTI Gerenciador).") if not display_name: errors.append("Informe um nome para a instalação.") if not release or release.status != "PUBLISHED": errors.append("Selecione uma versão publicada do Agent.") if errors: - tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all() + tenants = _active_gerenciador_tenants(session) releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all() - return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors)}, 400) + return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors), "search": ""}, 400) enrollment_uuid = str(uuid.uuid4()) enr = AgentEnrollment(enrollment_uuid=enrollment_uuid, tenant_id=tenant.id, display_name=display_name, expected_hostname=expected_hostname, release_id=release.id, status="PENDING", created_by=admin.id) session.add(enr) diff --git a/central/pyproject.toml b/central/pyproject.toml index 67af5cb..6e6376c 100644 --- a/central/pyproject.toml +++ b/central/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gti-fiscal-hub" -version = "1.1.1" +version = "1.1.2" requires-python = ">=3.10" dependencies = [ "fastapi>=0.115,<1", diff --git a/central/scripts/sync_clients_from_gerenciador.py b/central/scripts/sync_clients_from_gerenciador.py new file mode 100644 index 0000000..d1e1a14 --- /dev/null +++ b/central/scripts/sync_clients_from_gerenciador.py @@ -0,0 +1,39 @@ +"""Administrative CLI: pull the client registry from GTI Gerenciador (the +panel) and mirror it into Tenant rows (source='GTI_GERENCIADOR'). GTI +Gerenciador is the single source of truth; the Fiscal Hub never gets MySQL +credentials — this only calls the panel's read-only internal endpoint. + +Usage (from inside the Hub container, or anywhere DATABASE_URL + +GERENCIADOR_INTERNAL_URL + FISCAL_INTERNAL_API_TOKEN are reachable): + + python central/scripts/sync_clients_from_gerenciador.py + +Prints a reconciliation summary; never prints the bearer token. +""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "central")) + +from sqlalchemy.orm import Session # noqa: E402 + +from app import client_sync # noqa: E402 +from app.db import engine # noqa: E402 + + +def main(): + clients = client_sync.fetch_from_gerenciador() + with Session(engine) as session: + result = client_sync.sync_from_gerenciador(session, clients) + session.commit() + print(f"CLIENT_SYNC=ok fetched={result.fetched} created={result.created} updated={result.updated} unchanged={result.unchanged}") + print(f"TOTAL_CLIENTS_GERENCIADOR={result.fetched} ACTIVE_CLIENTS_GERENCIADOR={result.active} INACTIVE_CLIENTS_GERENCIADOR={result.inactive}") + + +if __name__ == "__main__": + try: + main() + except client_sync.ClientSyncUnavailable as error: + print(f"CLIENT_SYNC=unavailable reason={error}") + raise SystemExit(1) diff --git a/central/templates/agent_new_installation.html b/central/templates/agent_new_installation.html index d333391..4a093fc 100644 --- a/central/templates/agent_new_installation.html +++ b/central/templates/agent_new_installation.html @@ -6,12 +6,14 @@ {% if error %}{% endif %} {% if not releases %}

Nenhuma versão do Agent está publicada ainda. Publique uma release primeiro (python central/scripts/publish_agent_release.py) e ela aparecerá aqui.

+ {% elif not tenants %} +

Nenhum cliente ativo encontrado no GTI Gerenciador. Verifique o cadastro por lá, ou rode a sincronização (python central/scripts/sync_clients_from_gerenciador.py).

{% else %}
@@ -28,6 +30,14 @@

+ {% endif %} {% endblock %} diff --git a/central/templates/agent_pending_detail.html b/central/templates/agent_pending_detail.html index ece1f65..4d6218f 100644 --- a/central/templates/agent_pending_detail.html +++ b/central/templates/agent_pending_detail.html @@ -35,7 +35,7 @@

Detalhes

- + diff --git a/central/tests/test_agent_distribution.py b/central/tests/test_agent_distribution.py index 19e67a9..22a527e 100644 --- a/central/tests/test_agent_distribution.py +++ b/central/tests/test_agent_distribution.py @@ -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 diff --git a/central/tests/test_client_sync.py b/central/tests/test_client_sync.py new file mode 100644 index 0000000..18e05e5 --- /dev/null +++ b/central/tests/test_client_sync.py @@ -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() diff --git a/central/tests/test_health.py b/central/tests/test_health.py index 4b070dd..9739ce9 100644 --- a/central/tests/test_health.py +++ b/central/tests/test_health.py @@ -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): diff --git a/central/tests/test_install_code_enrollment.py b/central/tests/test_install_code_enrollment.py index 496d17a..91aca4a 100644 --- a/central/tests/test_install_code_enrollment.py +++ b/central/tests/test_install_code_enrollment.py @@ -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"): diff --git a/central/tests/test_new_installation_clients.py b/central/tests/test_new_installation_clients.py new file mode 100644 index 0000000..1221a53 --- /dev/null +++ b/central/tests/test_new_installation_clients.py @@ -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 diff --git a/deploy/agent-windows/agent.env.template b/deploy/agent-windows/agent.env.template index 8094076..4cb575b 100644 --- a/deploy/agent-windows/agent.env.template +++ b/deploy/agent-windows/agent.env.template @@ -19,4 +19,4 @@ FB_PASSWORD= FISCAL_MODE=DRY_RUN FIREBIRD_WRITES=0 POLL_INTERVAL_SECONDS=5 -AGENT_VERSION=1.1.0 +AGENT_VERSION=1.1.2 diff --git a/deploy/agent-windows/gti-agent.ps1 b/deploy/agent-windows/gti-agent.ps1 index 2086e97..0a6d0bc 100644 --- a/deploy/agent-windows/gti-agent.ps1 +++ b/deploy/agent-windows/gti-agent.ps1 @@ -164,7 +164,7 @@ switch ($Command) { "GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId", "COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource", "FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass", - 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.1.0' + 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.1.2' ) Set-Content -Path $EnvFile -Value $lines -Encoding UTF8 } diff --git a/deploy/agent-windows/pilot-construcao.env.example b/deploy/agent-windows/pilot-construcao.env.example index 0181b34..6a826a1 100644 --- a/deploy/agent-windows/pilot-construcao.env.example +++ b/deploy/agent-windows/pilot-construcao.env.example @@ -19,4 +19,4 @@ FB_PASSWORD= FISCAL_MODE=DRY_RUN FIREBIRD_WRITES=0 POLL_INTERVAL_SECONDS=5 -AGENT_VERSION=1.1.0 +AGENT_VERSION=1.1.2 diff --git a/deploy/agent-windows/profiles/mmmodas.env.example b/deploy/agent-windows/profiles/mmmodas.env.example index aa750b8..f7f9705 100644 --- a/deploy/agent-windows/profiles/mmmodas.env.example +++ b/deploy/agent-windows/profiles/mmmodas.env.example @@ -19,4 +19,4 @@ FB_PASSWORD= FISCAL_MODE=DRY_RUN FIREBIRD_WRITES=0 POLL_INTERVAL_SECONDS=5 -AGENT_VERSION=1.1.0 +AGENT_VERSION=1.1.2 diff --git a/deploy/agent-windows/profiles/rincao.env.example b/deploy/agent-windows/profiles/rincao.env.example index 782974c..ee569d9 100644 --- a/deploy/agent-windows/profiles/rincao.env.example +++ b/deploy/agent-windows/profiles/rincao.env.example @@ -19,4 +19,4 @@ FB_PASSWORD= FISCAL_MODE=DRY_RUN FIREBIRD_WRITES=0 POLL_INTERVAL_SECONDS=5 -AGENT_VERSION=1.1.0 +AGENT_VERSION=1.1.2 diff --git a/deploy/agent-windows/profiles/uniao.env.example b/deploy/agent-windows/profiles/uniao.env.example index 482ad99..d83ea5a 100644 --- a/deploy/agent-windows/profiles/uniao.env.example +++ b/deploy/agent-windows/profiles/uniao.env.example @@ -19,4 +19,4 @@ FB_PASSWORD= FISCAL_MODE=DRY_RUN FIREBIRD_WRITES=0 POLL_INTERVAL_SECONDS=5 -AGENT_VERSION=1.1.0 +AGENT_VERSION=1.1.2 diff --git a/deploy/hub/.env.example b/deploy/hub/.env.example index d3dd82f..b287c28 100644 --- a/deploy/hub/.env.example +++ b/deploy/hub/.env.example @@ -1,5 +1,5 @@ # GTI Fiscal Hub — production environment (copy to .env, never commit .env) -GTI_FISCAL_VERSION=1.1.0 +GTI_FISCAL_VERSION=1.1.2 # REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24 POSTGRES_PASSWORD= @@ -33,5 +33,13 @@ AGENT_ONLINE_THRESHOLD_SECONDS=120 # AGENT_DOWNLOAD_MAX_USES_DEFAULT=1 # AGENT_ENROLLMENT_TTL_SECONDS=86400 +# Client mirror from GTI Gerenciador (docs/PILOT_DEPLOYMENT.md, "Client integration"). +# GTI Gerenciador is the single source of truth for clients — the Fiscal Hub never +# gets MySQL credentials. Both empty = sync disabled (no client selector data). +GERENCIADOR_INTERNAL_URL=https://gtigerenciador.example.gti/internal/fiscal/clients +# Dedicated bearer token, shared only with the panel's FISCAL_INTERNAL_API_TOKEN — +# never SESSION_SECRET, the SSO key, an admin password or an Agent token. +FISCAL_INTERNAL_API_TOKEN= + # Directory (host) where pg_dump backups are written by backup.sh BACKUP_DIR=./backups diff --git a/deploy/hub/docker-compose.prod.yml b/deploy/hub/docker-compose.prod.yml index 3740b20..0afad10 100644 --- a/deploy/hub/docker-compose.prod.yml +++ b/deploy/hub/docker-compose.prod.yml @@ -36,7 +36,7 @@ services: build: context: ../.. dockerfile: central/Dockerfile - image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.1.0} + image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.1.2} restart: unless-stopped depends_on: postgres: @@ -65,6 +65,10 @@ services: AGENT_DOWNLOAD_LINK_TTL_SECONDS: ${AGENT_DOWNLOAD_LINK_TTL_SECONDS:-1800} AGENT_DOWNLOAD_MAX_USES_DEFAULT: ${AGENT_DOWNLOAD_MAX_USES_DEFAULT:-1} AGENT_ENROLLMENT_TTL_SECONDS: ${AGENT_ENROLLMENT_TTL_SECONDS:-86400} + # Client mirror from GTI Gerenciador (central/app/client_sync.py): the panel is + # the single source of truth for clients. Both empty = sync disabled. + GERENCIADOR_INTERNAL_URL: ${GERENCIADOR_INTERNAL_URL:-} + FISCAL_INTERNAL_API_TOKEN: ${FISCAL_INTERNAL_API_TOKEN:-} volumes: - agent_releases:/app/data/releases ports: diff --git a/release/RELEASE_MANIFEST.json b/release/RELEASE_MANIFEST.json index d0fd6fe..69ece72 100644 --- a/release/RELEASE_MANIFEST.json +++ b/release/RELEASE_MANIFEST.json @@ -1,14 +1,14 @@ { "artifact": "RELEASE_MANIFEST", - "GTI_FISCAL_VERSION": "1.1.1", - "generated_at": "2026-09-23T16:10:58+00:00", + "GTI_FISCAL_VERSION": "1.1.2", + "generated_at": "2026-09-24T01:35:00+00:00", "hub": { "version": "1.1.1", "api_compat": "1.0", "engine_version": "0.3.0", "decision_version": "mission-04c-ipi-ex-split" }, - "agent_version": "1.1.1", + "agent_version": "1.1.2", "fiscal_engine": { "ENGINE_VERSION": "0.3.0", "DECISION_VERSION": "mission-04c-ipi-ex-split", @@ -17,10 +17,10 @@ "ICMS_CONTEXT_ENGINE_VERSION": "icms-context-v1" }, "migrations": { - "alembic_head_in_repo": "0023_install_codes", - "applied_revision": "0023_install_codes", - "applied_revision_source": "pending production deploy (Phase E of 1.1.1): pg_dump backup + alembic upgrade v3 HEAD will be verified on 10.0.10.117 before publishing 1.1.1", - "count": 23 + "alembic_head_in_repo": "0024_tenant_gerenciador_mirror", + "applied_revision": "0024_tenant_gerenciador_mirror", + "applied_revision_source": "container projeto-fiscal-hub-1", + "count": 24 }, "datasets_active": [ { diff --git a/release/VERSION b/release/VERSION index 8cfbc90..45a1b3f 100644 --- a/release/VERSION +++ b/release/VERSION @@ -1 +1 @@ -1.1.1 \ No newline at end of file +1.1.2 diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py index 975f3fb..b62e131 100644 --- a/tests/test_release_candidate.py +++ b/tests/test_release_candidate.py @@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT)) def test_versions_are_coherent_across_hub_agent_and_packages(): from central.app.version import GTI_FISCAL_VERSION v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip() - assert v == GTI_FISCAL_VERSION == "1.1.1" + assert v == GTI_FISCAL_VERSION == "1.1.2" pep = v.replace("-rc", "rc") for pp in ("central/pyproject.toml", "agent/pyproject.toml"): assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8") - assert '"AGENT_VERSION", "1.1.1"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8") + assert '"AGENT_VERSION", "1.1.2"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8") manifest = ROOT / "release" / "RELEASE_MANIFEST.json" if manifest.exists(): m = json.loads(manifest.read_text(encoding="utf-8"))
Cliente{{ tenant.name if tenant else "-" }}
Cliente{{ tenant.name if tenant else "-" }}{% if tenant and not tenant.active %} INATIVO{% endif %}
Nome{{ enr.display_name }}
Hostname esperado{{ enr.expected_hostname or "não informado" }}
Versão do Agent{{ release.version if release else "-" }} ({{ release.platform if release else "" }}/{{ release.architecture if release else "" }})