feat(missao-7): bloco D - endurecimento HTTP, cobertura de testes e runbook de disaster recovery
This commit is contained in:
+12
-1
@@ -4,7 +4,7 @@ import logging
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from .config import settings
|
||||
from .db import SessionLocal, database_is_available
|
||||
@@ -24,6 +24,17 @@ def create_app(database_probe: Callable[[], bool] = database_is_available, sessi
|
||||
raise RuntimeError("AGENT_DEV_TOKEN must not be set when APP_ENV=production (register real Agents with central/scripts/register_agent.py)")
|
||||
logger.info("starting GTI Fiscal Hub (environment=%s)", settings.environment)
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
if getattr(settings, "web_cookie_secure", False):
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
if not database_probe():
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Missão 7 — Endurecimento e testes de cobertura dos módulos fiscais."""
|
||||
from datetime import datetime, timezone
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from central.app.models import Base, Dataset
|
||||
from central.app.reference_registry import (
|
||||
register_draft, approve_draft, reject_draft, list_registry,
|
||||
diff_against_vigente, technical_checklist, RegistryError
|
||||
)
|
||||
from central.app.classification import (
|
||||
Classifier, classify_tipi_rate, description_tokens, normalize_description
|
||||
)
|
||||
from central.app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_db():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def test_reference_registry_lifecycle_and_approval_workflow(memory_db):
|
||||
"""Testa o ciclo de vida completo do Reference Registry: registro, diff, checklist, aprovação e rejeição."""
|
||||
# 1. Registrar versão 1
|
||||
d1 = register_draft(
|
||||
memory_db,
|
||||
kind="NCM",
|
||||
provider="Siscomex",
|
||||
source_url="https://siscomex.gov.br",
|
||||
sha256="a" * 64,
|
||||
version_label="v1",
|
||||
record_count=15100,
|
||||
act="Resolução Gecex 2026",
|
||||
)
|
||||
assert d1.status == "DRAFT"
|
||||
|
||||
# Idempotência de registro
|
||||
d1_again = register_draft(
|
||||
memory_db,
|
||||
kind="NCM",
|
||||
provider="Siscomex",
|
||||
source_url="https://siscomex.gov.br",
|
||||
sha256="a" * 64,
|
||||
version_label="v1",
|
||||
)
|
||||
assert d1_again.id == d1.id
|
||||
|
||||
# Checklist técnico
|
||||
chk = technical_checklist(memory_db, d1)
|
||||
assert chk["ready_to_approve"] is True
|
||||
assert chk["volume"]["passed"] is True
|
||||
|
||||
# Aprovar versão 1 -> VIGENTE
|
||||
approved = approve_draft(memory_db, d1.id, admin_name="Operador Teste")
|
||||
assert approved.status == "VIGENTE"
|
||||
assert approved.approved_by == "Operador Teste"
|
||||
|
||||
# 2. Registrar versão 2
|
||||
d2 = register_draft(
|
||||
memory_db,
|
||||
kind="NCM",
|
||||
provider="Siscomex",
|
||||
source_url="https://siscomex.gov.br",
|
||||
sha256="b" * 64,
|
||||
version_label="v2",
|
||||
record_count=15200,
|
||||
)
|
||||
diff = diff_against_vigente(memory_db, d2)
|
||||
assert diff["is_first_version"] is False
|
||||
assert diff["record_count_delta"] == 100
|
||||
|
||||
# Aprovar versão 2 -> Torna d1 OBSOLETO
|
||||
approve_draft(memory_db, d2.id, admin_name="Admin 2")
|
||||
memory_db.refresh(d1)
|
||||
assert d1.status == "OBSOLETO"
|
||||
assert d2.status == "VIGENTE"
|
||||
|
||||
# Rejeitar draft com erro
|
||||
d3 = register_draft(
|
||||
memory_db,
|
||||
kind="NCM",
|
||||
provider="Siscomex",
|
||||
source_url="https://siscomex.gov.br",
|
||||
sha256="c" * 64,
|
||||
version_label="v3",
|
||||
parse_errors={"line_1": "invalid format"},
|
||||
)
|
||||
# Não pode aprovar draft com erros de parse
|
||||
with pytest.raises(RegistryError, match="recorded parse errors"):
|
||||
approve_draft(memory_db, d3.id, admin_name="Admin")
|
||||
|
||||
# Rejeitar draft
|
||||
rejected = reject_draft(memory_db, d3.id, admin_name="Admin")
|
||||
assert rejected.status == "REJECTED"
|
||||
|
||||
# Listar registry
|
||||
items = list_registry(memory_db)
|
||||
assert len(items) == 3
|
||||
|
||||
|
||||
def test_classification_tipi_rate_classification_and_tokens():
|
||||
"""Testa classificação determinística de taxas TIPI e tratamento de stopwords."""
|
||||
class MockEntry:
|
||||
def __init__(self, ex=None, text=None, rate=None):
|
||||
self.ex = ex
|
||||
self.ipi_rate_text = text
|
||||
self.ipi_rate = rate
|
||||
|
||||
assert classify_tipi_rate(MockEntry(ex="01")) == "EX_SPECIFIC"
|
||||
assert classify_tipi_rate(MockEntry(text=None)) == "SOURCE_BLANK"
|
||||
assert classify_tipi_rate(MockEntry(text="NT")) == "NT_OR_NON_TAXED"
|
||||
assert classify_tipi_rate(MockEntry(text="NÃO TRIBUTADO")) == "NT_OR_NON_TAXED"
|
||||
assert classify_tipi_rate(MockEntry(rate=0, text="0")) == "EXPLICIT_ZERO"
|
||||
assert classify_tipi_rate(MockEntry(rate=15.0, text="15%")) == "OTHER"
|
||||
|
||||
# Stopwords
|
||||
tokens = description_tokens("CERVEJA DE MALTE COM LUPULO E CEVADA")
|
||||
assert "DE" not in tokens
|
||||
assert "COM" not in tokens
|
||||
assert "E" not in tokens
|
||||
assert "CERVEJA" in tokens
|
||||
assert "MALTE" in tokens
|
||||
|
||||
|
||||
def test_http_security_headers_middleware():
|
||||
"""Testa se o middleware de cabeçalhos de segurança HTTP adiciona os headers corretos."""
|
||||
app = create_app(database_probe=lambda: True, bootstrap_token="test-token")
|
||||
client = TestClient(app)
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert response.headers.get("X-Frame-Options") == "SAMEORIGIN"
|
||||
assert response.headers.get("X-XSS-Protection") == "1; mode=block"
|
||||
assert response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
||||
Reference in New Issue
Block a user