From 31d000a73dcb243a1c5bd011a954f4960f7b340c Mon Sep 17 00:00:00 2001
From: Davidson Gomes
Date: Wed, 6 May 2026 14:36:06 -0300
Subject: [PATCH 1/3] docs(org): update GitHub URLs from EvolutionAPI to
evolution-foundation
Co-Authored-By: Claude Opus 4.7 (1M context)
---
CONTRIBUTING.md | 2 +-
README.md | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f4fd5855..8070b35b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,7 +12,7 @@ Harassment, discrimination, or abusive behavior will not be tolerated.
### Reporting Bugs
-1. Check existing [issues](https://github.com/EvolutionAPI/evo-nexus/issues)
+1. Check existing [issues](https://github.com/evolution-foundation/evo-nexus/issues)
to avoid duplicates
2. Open a new issue with:
- Clear, descriptive title
diff --git a/README.md b/README.md
index 73e204c7..70be4e38 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
-
+
@@ -46,7 +46,7 @@ It turns a single CLI installation into a team of **38 specialized agents** orga
## Part of the Evolution Foundation ecosystem
-EvoNexus is one of the projects maintained by Evolution Foundation. It is the operating layer that orchestrates the Foundation's own work — including the development of [Evo CRM Community](https://github.com/EvolutionAPI/evo-crm-community), [Evolution API](https://github.com/EvolutionAPI/evolution-api) and [Evolution Go](https://github.com/EvolutionAPI/evolution-go).
+EvoNexus is one of the projects maintained by Evolution Foundation. It is the operating layer that orchestrates the Foundation's own work — including the development of [Evo CRM Community](https://github.com/evolution-foundation/evo-crm-community), [Evolution API](https://github.com/evolution-foundation/evolution-api) and [Evolution Go](https://github.com/evolution-foundation/evolution-go).
### Why EvoNexus?
@@ -101,7 +101,7 @@ EvoNexus is one of the projects maintained by Evolution Foundation. It is the op
### Method 1 — Docker (no setup, runs anywhere)
```bash
-curl -O https://raw.githubusercontent.com/EvolutionAPI/evo-nexus/main/docker-compose.hub.yml
+curl -O https://raw.githubusercontent.com/evolution-foundation/evo-nexus/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up -d
open http://localhost:8080
```
@@ -117,7 +117,7 @@ npx @evoapi/evo-nexus
### Method 3 — Manual clone (developers / contributors)
```bash
-git clone --depth 1 https://github.com/EvolutionAPI/evo-nexus.git
+git clone --depth 1 https://github.com/evolution-foundation/evo-nexus.git
cd evo-nexus
# Interactive setup wizard
From 7f5dd760b5854f2217a376fd34c48b32195f6d76 Mon Sep 17 00:00:00 2001
From: Davidson Gomes
Date: Tue, 12 May 2026 12:21:02 -0300
Subject: [PATCH 2/3] feat(licensing): headless auto-activation via
EVOLUTION_OPERATOR_EMAIL
auto_register_if_needed now tries EVOLUTION_OPERATOR_EMAIL first,
calling the licensing server's /v1/register/auto endpoint silently
to activate the instance without the manual setup wizard.
Falls back to the existing admin-user retroactive flow on any failure
(email not yet registered, server unreachable, etc.). Non-fatal.
Requires one prior manual registration so the email is known server-side.
---
.env.example | 7 +++
dashboard/backend/licensing.py | 81 +++++++++++++++++++++++++++++++---
2 files changed, 83 insertions(+), 5 deletions(-)
diff --git a/.env.example b/.env.example
index 11610d8e..1ebe24fd 100644
--- a/.env.example
+++ b/.env.example
@@ -108,6 +108,13 @@ META_APP_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
+# ── License — headless auto-activation ───────────────
+# Set this to the email used in your first manual license registration.
+# On startup, EvoNexus calls /v1/register/auto silently and skips the manual
+# setup screen. Falls back to manual setup if the email isn't registered yet.
+# Leave empty (or unset) to keep the default behavior.
+# EVOLUTION_OPERATOR_EMAIL=operator@example.com
+
# ── Evolution API ────────────────────────────────────
# Your Evolution API instance URL and global API key
EVOLUTION_API_URL=
diff --git a/dashboard/backend/licensing.py b/dashboard/backend/licensing.py
index 60ed26f2..9677cf24 100644
--- a/dashboard/backend/licensing.py
+++ b/dashboard/backend/licensing.py
@@ -4,12 +4,14 @@
Protocol:
POST /v1/register/direct — register with email/name, receive api_key
+ POST /v1/register/auto — headless register by email (must exist server-side)
POST /v1/activate — validate existing api_key on startup
GET /api/geo — geo-lookup from client IP
"""
import hashlib
import hmac as hmac_mod
+import os
import socket
import uuid
import logging
@@ -155,6 +157,24 @@ def direct_register(email: str, name: str, instance_id: str,
return _post("/v1/register/direct", payload)
+# ── Auto Registration (email-only, headless) ──
+
+def auto_register(email: str, instance_id: str) -> dict:
+ """Headless registration using only the operator email.
+
+ The customer must already exist on the licensing server (one prior manual
+ registration). Used by the EVOLUTION_OPERATOR_EMAIL env-var flow.
+
+ Returns {api_key, customer_id, tier, status}.
+ """
+ return _post("/v1/register/auto", {
+ "email": email,
+ "tier": TIER,
+ "instance_id": instance_id,
+ "version": VERSION,
+ })
+
+
# ── Activation (startup with existing api_key) ──
def activate(instance_id: str, api_key: str) -> bool:
@@ -260,8 +280,54 @@ def initialize_runtime():
# ── Auto-register for existing installs ──────
+def try_auto_register_from_env(instance_id: str) -> bool:
+ """Headless activation via EVOLUTION_OPERATOR_EMAIL env var.
+
+ Requires the email to already exist on the licensing server (one prior
+ manual registration). Returns True on success.
+
+ Failures are silent — caller falls back to the existing admin-based or
+ manual setup flow.
+ """
+ email = os.environ.get("EVOLUTION_OPERATOR_EMAIL", "").strip()
+ if not email:
+ return False
+
+ try:
+ result = auto_register(email=email, instance_id=instance_id)
+ except requests.HTTPError as e:
+ status = e.response.status_code if e.response is not None else "?"
+ if status == 404:
+ logger.info("Auto-activation skipped — email not registered yet (first time?).")
+ else:
+ logger.warning(f"Auto-activation rejected ({status}): falling back to manual flow.")
+ return False
+ except Exception as e:
+ logger.warning(f"Auto-activation skipped — {e}")
+ return False
+
+ api_key = result.get("api_key")
+ if not api_key:
+ logger.warning("Auto-activation response missing api_key")
+ return False
+
+ set_runtime_config("api_key", api_key)
+ set_runtime_config("tier", result.get("tier", TIER))
+ if result.get("customer_id"):
+ set_runtime_config("customer_id", str(result["customer_id"]))
+ set_runtime_config("version", VERSION)
+ set_runtime_config("registered_at", datetime.now(timezone.utc).isoformat())
+
+ ctx = get_context()
+ ctx.api_key = api_key
+ ctx.instance_id = instance_id
+ logger.info("License activated automatically via EVOLUTION_OPERATOR_EMAIL")
+ return True
+
+
def auto_register_if_needed():
- """If users exist but no license, register retroactively."""
+ """If no license yet, try EVOLUTION_OPERATOR_EMAIL first, then fall back to
+ the admin-based retroactive flow."""
try:
instance_id = get_runtime_config("instance_id")
api_key = get_runtime_config("api_key")
@@ -270,6 +336,15 @@ def auto_register_if_needed():
initialize_runtime()
return
+ if not instance_id:
+ instance_id = generate_instance_id()
+ set_runtime_config("instance_id", instance_id)
+
+ # First-class path: silent activation from env var.
+ if try_auto_register_from_env(instance_id):
+ return
+
+ # Fallback: if there's an admin user already, register retroactively.
from models import User
if User.query.count() == 0:
return
@@ -278,10 +353,6 @@ def auto_register_if_needed():
if not admin or not admin.email:
return
- if not instance_id:
- instance_id = generate_instance_id()
- set_runtime_config("instance_id", instance_id)
-
setup_perform(
email=admin.email or "",
name=admin.display_name or admin.username,
From 89773d692ea737a869bf862c778303da29004de6 Mon Sep 17 00:00:00 2001
From: Marcello Alarcon
Date: Wed, 3 Jun 2026 20:04:02 -0300
Subject: [PATCH 3/3] feat(int-autentique): add Autentique e-signature
integration skill
Autentique (BR e-signature, GraphQL v2) integration with:
- whoami, documents list/get/create/download/delete
- multipart/form-data upload (GraphQL multipart spec)
- sandbox mode (no credit consumption), reminder cadence, ICP-Brasil qualified signatures
- standardized smoke command (exit 0 + JSON {overall, steps[]})
- 31 unit tests (network-blocked, mock-based)
Token via AUTENTIQUE_API_TOKEN env. stdlib-only (urllib).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.claude/skills/int-autentique/SKILL.md | 124 ++++
.../scripts/autentique_client.py | 486 ++++++++++++++++
.../skills/int-autentique/tests/__init__.py | 0
.../skills/int-autentique/tests/conftest.py | 51 ++
.../skills/int-autentique/tests/helpers.py | 47 ++
.../tests/test_autentique_client.py | 549 ++++++++++++++++++
6 files changed, 1257 insertions(+)
create mode 100644 .claude/skills/int-autentique/SKILL.md
create mode 100644 .claude/skills/int-autentique/scripts/autentique_client.py
create mode 100644 .claude/skills/int-autentique/tests/__init__.py
create mode 100644 .claude/skills/int-autentique/tests/conftest.py
create mode 100644 .claude/skills/int-autentique/tests/helpers.py
create mode 100644 .claude/skills/int-autentique/tests/test_autentique_client.py
diff --git a/.claude/skills/int-autentique/SKILL.md b/.claude/skills/int-autentique/SKILL.md
new file mode 100644
index 00000000..ce35f79a
--- /dev/null
+++ b/.claude/skills/int-autentique/SKILL.md
@@ -0,0 +1,124 @@
+---
+name: int-autentique
+description: "Integração com Autentique (assinatura eletrônica BR) via GraphQL API v2. Use para criar documento para assinatura, listar documentos, consultar status (visualizado/assinado/rejeitado), baixar PDF assinado, deletar documentos. Aciona quando o usuário menciona Autentique, assinatura digital, distrato, contrato pra assinar, signatário, envelope. Auth via Bearer token (env ANYCHAT… digo, AUTENTIQUE_API_TOKEN). Suporta entrega por e-mail, WhatsApp, SMS e link curto. Endpoint único: https://api.autentique.com.br/v2/graphql."
+homepage: https://www.autentique.com.br
+metadata:
+ clawdbot:
+ emoji: "✍️"
+ requires:
+ bins: ["python3"]
+ env: ["AUTENTIQUE_API_TOKEN"]
+ primaryEnv: AUTENTIQUE_API_TOKEN
+ files:
+ - "scripts/*"
+---
+
+# Autentique — assinatura eletrônica brasileira
+
+Cliente Python para a API GraphQL v2 da Autentique. Permite enviar documentos para assinatura digital com validade jurídica MP 2.200-2/2001, gerenciar signatários (e-mail, WhatsApp, SMS), e consultar status. Auth via Bearer token.
+
+> **Quando usar:** distrato, contrato, NDA, proposta comercial, qualquer documento que precise assinatura formal — usar Autentique no lugar de PDF + assinatura digitalizada.
+
+## Setup
+
+1. Gerar token em [https://painel.autentique.com.br/perfil/api](https://painel.autentique.com.br/perfil/api) (já temos conta)
+2. Adicionar no `.env`:
+
+```env
+AUTENTIQUE_API_TOKEN=seu-token-aqui
+```
+
+3. (Opcional) Definir organização padrão se a conta tem múltiplas:
+
+```env
+AUTENTIQUE_ORGANIZATION_ID=
+```
+
+## CLI
+
+```bash
+python3 .claude/skills/int-autentique/scripts/autentique_client.py [args]
+```
+
+### Comandos principais
+
+```bash
+# Whoami — verifica auth + retorna user/org
+autentique_client.py whoami
+
+# Listar documentos (paginado)
+autentique_client.py documents list --page 1 --limit 20
+
+# Detalhes de 1 documento (incluindo status de cada signatário)
+autentique_client.py documents get DOCUMENT_ID
+
+# Criar documento e enviar pra assinatura
+autentique_client.py documents create \
+ --file caminho/para/contrato.pdf \
+ --name "Termo de Distrato — Cliente X" \
+ --signers '[{"email":"cliente@email.com","action":"SIGN"},{"email":"responsavel@empresa.com","action":"SIGN"}]' \
+ --message "Segue o termo para assinatura. Qualquer dúvida, retorno por aqui." \
+ --reminder WEEKLY
+
+# Sandbox (não consome créditos — para teste)
+autentique_client.py documents create --file ... --sandbox
+
+# Baixar PDF original ou assinado
+autentique_client.py documents download DOCUMENT_ID --version original --output original.pdf
+autentique_client.py documents download DOCUMENT_ID --version signed --output assinado.pdf
+
+# Deletar documento
+autentique_client.py documents delete DOCUMENT_ID --confirm
+
+# Listar pastas
+autentique_client.py folders list
+```
+
+## Tipos de signatário
+
+JSON do `--signers` aceita objetos com:
+
+| Campo | Valores | Notas |
+|---|---|---|
+| `email` | string | Padrão — Autentique envia link por e-mail |
+| `phone` + `delivery_method: DELIVERY_METHOD_WHATSAPP` | E.164 (`+5511...`) | Entrega via WhatsApp |
+| `phone` + `delivery_method: DELIVERY_METHOD_SMS` | E.164 | Entrega via SMS (~$0.03) |
+| `name` (sem email/phone) | string | Gera link curto pra compartilhar manualmente |
+| `action` | `SIGN`, `APPROVE`, `RECOGNIZE`, `SIGN_AS_A_WITNESS` | Default: `SIGN` |
+| `configs.cpf` | "12345678900" | Validação por CPF |
+
+## Custos por ação (USD — referência)
+
+| Ação | Custo |
+|---|---|
+| Criar documento | $0.01 |
+| Assinatura via e-mail | $0.002 |
+| Assinatura via WhatsApp | $0.02 |
+| Assinatura via SMS | $0.03 |
+| Consulta de documento | $0.0002 |
+
+**Plano Free:** 20 documentos/mês, 10 req/min. Verificar o plano atual da conta.
+
+## Output
+
+JSON pra stdout em todos os comandos. Em erro: `{"error": ..., "details": ...}` + exit code:
+- `0` — sucesso
+- `1` — erro de uso (env ausente, JSON inválido, arquivo não encontrado)
+- `2` — erro de API (HTTP 4xx/5xx ou erro GraphQL)
+
+## Integrações típicas no workspace
+
+- **Distrato / termo de encerramento:** após aprovação interna do termo, a skill envia pra Autentique → o cliente recebe e assina → status acompanhado via polling do documento
+- **Proposta comercial:** gerar a proposta, aprovar internamente e subir pra assinatura
+- **NDA:** gerar o NDA e disparar via Autentique para o fornecedor
+- **Anexo de documentos no offboarding:** documento separado mas vinculado, assinado junto com o termo principal
+
+## Notas técnicas
+
+- Endpoint: `POST https://api.autentique.com.br/v2/graphql`
+- Auth header: `Authorization: Bearer {TOKEN}`
+- `createDocument` usa multipart/form-data (specs do GraphQL multipart)
+- Rate limit: 10 req/min (Free), 60 (Professional), 200 (Corporate)
+- Sandbox: passar `sandbox: true` no input do documento — não consome crédito
+- Validade jurídica: MP 2.200-2/2001 + Lei 14.063/2020 (assinatura eletrônica simples)
+- Para assinatura **qualificada (ICP-Brasil)**, definir `qualified: true` no documento
diff --git a/.claude/skills/int-autentique/scripts/autentique_client.py b/.claude/skills/int-autentique/scripts/autentique_client.py
new file mode 100644
index 00000000..9841904b
--- /dev/null
+++ b/.claude/skills/int-autentique/scripts/autentique_client.py
@@ -0,0 +1,486 @@
+#!/usr/bin/env python3
+"""
+Autentique GraphQL API v2 client — assinatura eletrônica brasileira.
+
+Endpoint: https://api.autentique.com.br/v2/graphql
+Auth: header Authorization Bearer (env AUTENTIQUE_API_TOKEN).
+
+Operations: documents create/get/list/download/delete + folders list + whoami.
+
+Multipart upload (createDocument) segue spec graphql-multipart-request.
+
+No third-party SDK. Stdlib only.
+"""
+import argparse
+import io
+import json
+import mimetypes
+import os
+import secrets
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+
+GRAPHQL_URL = "https://api.autentique.com.br/v2/graphql"
+DEFAULT_TIMEOUT = 30
+RETRY_ATTEMPTS = 3
+RETRY_BACKOFF = [1, 2, 4]
+
+
+def _load_dotenv():
+ """Carrega .env do raiz do workspace (4 níveis acima)."""
+ env_path = Path(__file__).resolve().parents[4] / ".env"
+ if not env_path.exists():
+ return
+ with open(env_path) as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ key, value = key.strip(), value.strip()
+ if key and key not in os.environ:
+ os.environ[key] = value
+
+
+_load_dotenv()
+
+
+def _token():
+ t = os.environ.get("AUTENTIQUE_API_TOKEN")
+ if not t:
+ print(json.dumps({
+ "error": "AUTENTIQUE_API_TOKEN ausente no .env",
+ "details": "Gere em https://painel.autentique.com.br/perfil/api e adicione AUTENTIQUE_API_TOKEN=... no .env"
+ }))
+ sys.exit(1)
+ return t
+
+
+def output(data):
+ print(json.dumps(data, indent=2, ensure_ascii=False))
+
+
+# ---------------------------------------------------------------------------
+# GraphQL request — JSON simples (queries e mutations sem upload de arquivo)
+# ---------------------------------------------------------------------------
+
+def gql(query, variables=None):
+ body = json.dumps({
+ "query": query,
+ "variables": variables or {},
+ }).encode("utf-8")
+
+ headers = {
+ "Authorization": f"Bearer {_token()}",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "User-Agent": "EvoNexus/1.0 int-autentique",
+ }
+
+ last_error = None
+ for attempt in range(RETRY_ATTEMPTS):
+ req = urllib.request.Request(GRAPHQL_URL, data=body, method="POST", headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
+ raw = resp.read()
+ payload = json.loads(raw)
+ if "errors" in payload:
+ print(json.dumps({"error": "GraphQL errors", "details": payload["errors"]}, indent=2, ensure_ascii=False))
+ sys.exit(2)
+ return payload.get("data", {})
+ except urllib.error.HTTPError as e:
+ try:
+ err_body = json.loads(e.read())
+ except Exception:
+ err_body = {"message": str(e)}
+ last_error = {"http_code": e.code, "body": err_body}
+ if e.code == 429 or 500 <= e.code < 600:
+ if attempt < RETRY_ATTEMPTS - 1:
+ time.sleep(RETRY_BACKOFF[attempt])
+ continue
+ print(json.dumps({"error": f"HTTP {e.code}", "details": err_body}, indent=2, ensure_ascii=False))
+ sys.exit(2)
+ except urllib.error.URLError as e:
+ last_error = {"connection": str(e.reason)}
+ if attempt < RETRY_ATTEMPTS - 1:
+ time.sleep(RETRY_BACKOFF[attempt])
+ continue
+ print(json.dumps({"error": "Connection failed", "details": str(e.reason)}))
+ sys.exit(2)
+
+ print(json.dumps({"error": "Max retries exceeded", "details": last_error}, indent=2, ensure_ascii=False))
+ sys.exit(2)
+
+
+# ---------------------------------------------------------------------------
+# Multipart upload (createDocument com arquivo)
+# Spec: https://github.com/jaydenseric/graphql-multipart-request-spec
+# ---------------------------------------------------------------------------
+
+def gql_upload(query, variables, file_path, file_var_path="variables.file"):
+ """Faz POST multipart/form-data com upload de 1 arquivo (spec GraphQL multipart)."""
+ file_path = Path(file_path)
+ if not file_path.exists():
+ print(json.dumps({"error": "Arquivo não encontrado", "details": str(file_path)}))
+ sys.exit(1)
+
+ boundary = "----EvoNexusBoundary" + secrets.token_hex(8)
+ operations = json.dumps({"query": query, "variables": variables}).encode("utf-8")
+ file_map = json.dumps({"0": [file_var_path]}).encode("utf-8")
+ file_bytes = file_path.read_bytes()
+ mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
+
+ parts = []
+ parts.append(f"--{boundary}".encode())
+ parts.append(b'Content-Disposition: form-data; name="operations"\r\n')
+ parts.append(b"")
+ parts.append(operations)
+ parts.append(f"--{boundary}".encode())
+ parts.append(b'Content-Disposition: form-data; name="map"\r\n')
+ parts.append(b"")
+ parts.append(file_map)
+ parts.append(f"--{boundary}".encode())
+ parts.append(f'Content-Disposition: form-data; name="0"; filename="{file_path.name}"'.encode())
+ parts.append(f"Content-Type: {mime_type}".encode())
+ parts.append(b"")
+ parts.append(file_bytes)
+ parts.append(f"--{boundary}--".encode())
+
+ body = b"\r\n".join(parts) + b"\r\n"
+
+ headers = {
+ "Authorization": f"Bearer {_token()}",
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
+ "Accept": "application/json",
+ "User-Agent": "EvoNexus/1.0 int-autentique",
+ }
+
+ req = urllib.request.Request(GRAPHQL_URL, data=body, method="POST", headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT * 2) as resp:
+ raw = resp.read()
+ payload = json.loads(raw)
+ if "errors" in payload:
+ print(json.dumps({"error": "GraphQL errors", "details": payload["errors"]}, indent=2, ensure_ascii=False))
+ sys.exit(2)
+ return payload.get("data", {})
+ except urllib.error.HTTPError as e:
+ try:
+ err_body = json.loads(e.read())
+ except Exception:
+ err_body = {"message": str(e)}
+ print(json.dumps({"error": f"HTTP {e.code}", "details": err_body}, indent=2, ensure_ascii=False))
+ sys.exit(2)
+ except urllib.error.URLError as e:
+ print(json.dumps({"error": "Connection failed", "details": str(e.reason)}))
+ sys.exit(2)
+
+
+# ---------------------------------------------------------------------------
+# Commands
+# ---------------------------------------------------------------------------
+
+def cmd_whoami(args):
+ data = gql("""
+ query { me { id name email } }
+ """)
+ output(data)
+
+
+def cmd_documents_list(args):
+ data = gql("""
+ query ListDocs($page: Int, $limit: Int) {
+ documents(page: $page, limit: $limit) {
+ total
+ data {
+ id
+ name
+ created_at
+ signatures { public_id name email signed { created_at } }
+ }
+ }
+ }
+ """, {"page": args.page, "limit": args.limit})
+ output(data)
+
+
+def cmd_documents_get(args):
+ data = gql("""
+ query GetDoc($id: UUID!) {
+ document(id: $id) {
+ id
+ name
+ created_at
+ sandbox
+ refusable
+ qualified
+ files { original signed }
+ signatures {
+ public_id
+ name
+ email
+ action { name }
+ viewed { created_at }
+ signed { created_at }
+ rejected { created_at }
+ }
+ }
+ }
+ """, {"id": args.document_id})
+ output(data)
+
+
+def cmd_documents_create(args):
+ try:
+ signers = json.loads(args.signers)
+ if not isinstance(signers, list) or not signers:
+ raise ValueError("--signers deve ser um array JSON com pelo menos 1 signatário")
+ except json.JSONDecodeError as e:
+ print(json.dumps({"error": "JSON inválido em --signers", "details": str(e)}))
+ sys.exit(1)
+ except ValueError as e:
+ print(json.dumps({"error": str(e)}))
+ sys.exit(1)
+
+ document_input = {
+ "name": args.name,
+ "refusable": True,
+ }
+ if args.message:
+ document_input["message"] = args.message
+ if args.reminder:
+ document_input["reminder"] = args.reminder
+ if args.qualified:
+ document_input["qualified"] = True
+ if args.sandbox:
+ document_input["sandbox"] = True
+
+ variables = {
+ "document": document_input,
+ "signers": signers,
+ "file": None, # placeholder substituído pelo multipart
+ }
+
+ org_id = os.environ.get("AUTENTIQUE_ORGANIZATION_ID")
+ org_arg = ", organization_id: $organization_id" if org_id else ""
+ org_param = ", $organization_id: Int" if org_id else ""
+ if org_id:
+ variables["organization_id"] = int(org_id)
+
+ query = f"""
+ mutation CreateDoc(
+ $document: DocumentInput!,
+ $signers: [SignerInput!]!,
+ $file: Upload!{org_param}
+ ) {{
+ createDocument(
+ document: $document,
+ signers: $signers,
+ file: $file{org_arg}
+ ) {{
+ id
+ name
+ sandbox
+ files {{ original }}
+ signatures {{
+ public_id
+ name
+ email
+ link {{ short_link }}
+ }}
+ }}
+ }}
+ """
+
+ data = gql_upload(query, variables, args.file)
+ output(data)
+
+
+def cmd_documents_download(args):
+ data = gql("""
+ query GetFiles($id: UUID!) {
+ document(id: $id) { id name files { original signed } }
+ }
+ """, {"id": args.document_id})
+ doc = (data or {}).get("document", {})
+ files = doc.get("files", {})
+ url = files.get(args.version)
+ if not url:
+ print(json.dumps({"error": f"URL '{args.version}' não disponível", "details": files}))
+ sys.exit(2)
+
+ out_path = Path(args.output) if args.output else Path(f"{doc.get('name', doc['id'])}.{args.version}.pdf")
+ try:
+ with urllib.request.urlopen(url, timeout=DEFAULT_TIMEOUT * 2) as resp:
+ out_path.write_bytes(resp.read())
+ output({"saved": str(out_path), "bytes": out_path.stat().st_size, "version": args.version})
+ except Exception as e:
+ print(json.dumps({"error": "Falha ao baixar", "details": str(e)}))
+ sys.exit(2)
+
+
+def cmd_documents_delete(args):
+ if not args.confirm:
+ print(json.dumps({
+ "error": "Confirmação obrigatória",
+ "details": "Adicione --confirm pra deletar (operação irreversível)"
+ }))
+ sys.exit(1)
+ data = gql("""
+ mutation DeleteDoc($id: UUID!) { deleteDocument(id: $id) }
+ """, {"id": args.document_id})
+ output(data)
+
+
+def cmd_folders_list(args):
+ data = gql("""
+ query { folders(page: 1, limit: 60) { data { id name } } }
+ """)
+ output(data)
+
+
+def cmd_smoke(args):
+ import time as _time
+ overall = "PASS"
+ steps = []
+ t_total = _time.monotonic()
+ try:
+ # step 1: auth — verifica token e retorna dados do usuário (reutiliza whoami)
+ t0 = _time.monotonic()
+ try:
+ token_val = os.environ.get("AUTENTIQUE_API_TOKEN", "")
+ if not token_val:
+ raise ValueError("AUTENTIQUE_API_TOKEN ausente no .env")
+ data = gql("query { me { id name email } }")
+ me = (data or {}).get("me", {})
+ steps.append({"step": "auth_whoami", "status": "PASS",
+ "email": me.get("email", ""), "duration_ms": round((_time.monotonic() - t0) * 1000)})
+ except Exception as e:
+ overall = "FAIL"
+ steps.append({"step": "auth_whoami", "status": "FAIL",
+ "error": str(e)[:300], "duration_ms": round((_time.monotonic() - t0) * 1000)})
+ print(json.dumps({"overall": overall, "steps": steps,
+ "duration_ms": round((_time.monotonic() - t_total) * 1000)}, ensure_ascii=False))
+ sys.exit(0)
+
+ # step 2: listar documentos (leitura barata, limit=1)
+ t0 = _time.monotonic()
+ try:
+ data = gql("query { documents(page: 1, limit: 1) { total } }")
+ total = (data or {}).get("documents", {}).get("total", "N/A")
+ steps.append({"step": "read_documents", "status": "PASS",
+ "total_documents": total, "duration_ms": round((_time.monotonic() - t0) * 1000)})
+ except Exception as e:
+ overall = "FAIL"
+ steps.append({"step": "read_documents", "status": "FAIL",
+ "error": str(e)[:300], "duration_ms": round((_time.monotonic() - t0) * 1000)})
+ except Exception as e:
+ overall = "FAIL"
+ steps.append({"step": "unexpected", "status": "FAIL", "error": str(e)[:300], "duration_ms": 0})
+ print(json.dumps({"overall": overall, "steps": steps,
+ "duration_ms": round((_time.monotonic() - t_total) * 1000)}, ensure_ascii=False))
+ sys.exit(0)
+
+
+# ---------------------------------------------------------------------------
+# Argparse
+# ---------------------------------------------------------------------------
+
+def build_parser():
+ parser = argparse.ArgumentParser(
+ description="Autentique — assinatura eletrônica BR via GraphQL.",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ %(prog)s whoami
+ %(prog)s documents list --limit 5
+ %(prog)s documents get DOCUMENT_ID
+ %(prog)s documents create \\
+ --file ./contrato.pdf \\
+ --name "Contrato de Prestação de Serviço" \\
+ --signers '[{"email":"cliente@empresa.com","action":"SIGN"},{"email":"responsavel@empresa.com","action":"SIGN"}]' \\
+ --message "Segue o contrato pra assinatura."
+ %(prog)s documents download DOCUMENT_ID --version signed --output assinado.pdf
+ %(prog)s documents delete DOCUMENT_ID --confirm
+""",
+ )
+ sub = parser.add_subparsers(dest="command")
+
+ sub.add_parser("whoami", help="Verifica auth + retorna dados do usuário/organização")
+
+ p_docs = sub.add_parser("documents", help="Operações com documentos")
+ docs_sub = p_docs.add_subparsers(dest="subcommand")
+
+ pc = docs_sub.add_parser("list", help="Listar documentos")
+ pc.add_argument("--page", type=int, default=1)
+ pc.add_argument("--limit", type=int, default=20)
+
+ pc = docs_sub.add_parser("get", help="Obter detalhes de 1 documento")
+ pc.add_argument("document_id", help="UUID do documento")
+
+ pc = docs_sub.add_parser("create", help="Criar documento e enviar p/ assinatura")
+ pc.add_argument("--file", required=True, help="Caminho do PDF/DOCX")
+ pc.add_argument("--name", required=True, help="Nome do documento")
+ pc.add_argument("--signers", required=True, help="Array JSON de signatários")
+ pc.add_argument("--message", help="Mensagem custom para os signatários")
+ pc.add_argument("--reminder", choices=["DAILY", "WEEKLY", "MONTHLY"], help="Frequência de lembrete")
+ pc.add_argument("--qualified", action="store_true", help="Assinatura qualificada (ICP-Brasil)")
+ pc.add_argument("--sandbox", action="store_true", help="Modo sandbox (sem custo, p/ teste)")
+
+ pc = docs_sub.add_parser("download", help="Baixar PDF original ou assinado")
+ pc.add_argument("document_id", help="UUID do documento")
+ pc.add_argument("--version", choices=["original", "signed"], default="signed")
+ pc.add_argument("--output", help="Caminho de saída (default: nome do doc)")
+
+ pc = docs_sub.add_parser("delete", help="Deletar documento (irreversível)")
+ pc.add_argument("document_id", help="UUID do documento")
+ pc.add_argument("--confirm", action="store_true", help="Confirmação obrigatória")
+
+ p_folders = sub.add_parser("folders", help="Operações com pastas")
+ folders_sub = p_folders.add_subparsers(dest="subcommand")
+ folders_sub.add_parser("list", help="Listar pastas")
+
+ sub.add_parser("smoke", help="Smoke test: auth + leitura barata (exit 0 sempre, JSON)")
+
+ return parser
+
+
+COMMANDS = {
+ ("whoami", None): cmd_whoami,
+ ("documents", "list"): cmd_documents_list,
+ ("documents", "get"): cmd_documents_get,
+ ("documents", "create"): cmd_documents_create,
+ ("documents", "download"): cmd_documents_download,
+ ("documents", "delete"): cmd_documents_delete,
+ ("folders", "list"): cmd_folders_list,
+ ("smoke", None): cmd_smoke,
+}
+
+
+def main():
+ parser = build_parser()
+ args = parser.parse_args()
+
+ if not args.command:
+ parser.print_help()
+ sys.exit(0)
+
+ sub = getattr(args, "subcommand", None)
+ handler = COMMANDS.get((args.command, sub))
+ if not handler:
+ parser.print_help()
+ sys.exit(1)
+
+ try:
+ handler(args)
+ except json.JSONDecodeError as e:
+ print(json.dumps({"error": "JSON inválido em argumento", "details": str(e)}))
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.claude/skills/int-autentique/tests/__init__.py b/.claude/skills/int-autentique/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/.claude/skills/int-autentique/tests/conftest.py b/.claude/skills/int-autentique/tests/conftest.py
new file mode 100644
index 00000000..d399e29f
--- /dev/null
+++ b/.claude/skills/int-autentique/tests/conftest.py
@@ -0,0 +1,51 @@
+"""conftest.py — pytest config para int-autentique.
+
+Stuba urllib.request.urlopen em autouse para que NENHUM teste toque a rede real.
+Padrão idêntico ao das skills de referência (int-agendor, int-wordpress).
+"""
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Bootstrap: garantir que tests/ está no sys.path para imports de helpers
+# ---------------------------------------------------------------------------
+_TESTS_DIR = Path(__file__).resolve().parent
+if str(_TESTS_DIR) not in sys.path:
+ sys.path.insert(0, str(_TESTS_DIR))
+
+# ---------------------------------------------------------------------------
+# Bootstrap: importar autentique_client via path (não é package instalado)
+# ---------------------------------------------------------------------------
+_SKILL_ROOT = _TESTS_DIR.parent
+_SCRIPT_PATH = _SKILL_ROOT / "scripts" / "autentique_client.py"
+
+_spec = importlib.util.spec_from_file_location("autentique_client", _SCRIPT_PATH)
+_aut_module = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_aut_module)
+sys.modules["autentique_client"] = _aut_module
+
+
+# ---------------------------------------------------------------------------
+# Fixture autouse — bloqueia toda rede real
+# ---------------------------------------------------------------------------
+
+@pytest.fixture(autouse=True)
+def block_network(monkeypatch):
+ """Substitui urlopen por stub que falha com RuntimeError.
+
+ Garante que nenhum teste toque a rede real. Testes que precisam de
+ comportamento HTTP específico usam monkeypatch em aut.gql diretamente.
+ """
+ def _deny(*args, **kwargs):
+ raise RuntimeError(
+ "PROIBIDO: teste tentou abrir conexão de rede real. "
+ "Use monkeypatch.setattr(aut, 'gql', ...) no teste."
+ )
+
+ monkeypatch.setattr("urllib.request.urlopen", _deny)
+ yield
diff --git a/.claude/skills/int-autentique/tests/helpers.py b/.claude/skills/int-autentique/tests/helpers.py
new file mode 100644
index 00000000..ce915ca2
--- /dev/null
+++ b/.claude/skills/int-autentique/tests/helpers.py
@@ -0,0 +1,47 @@
+"""helpers.py — factories de resposta para testes de int-autentique."""
+from __future__ import annotations
+
+import io
+import json
+import urllib.error
+
+
+def make_gql_response(data: dict) -> object:
+ """Context-manager que simula urlopen bem-sucedido com payload GQL."""
+ body = json.dumps({"data": data}).encode()
+
+ class _FakeResp:
+ def read(self):
+ return body
+ def __enter__(self):
+ return self
+ def __exit__(self, *a):
+ pass
+
+ return _FakeResp()
+
+
+def make_gql_error(errors: list) -> object:
+ """Resposta GraphQL com campo 'errors'."""
+ body = json.dumps({"errors": errors}).encode()
+
+ class _FakeResp:
+ def read(self):
+ return body
+ def __enter__(self):
+ return self
+ def __exit__(self, *a):
+ pass
+
+ return _FakeResp()
+
+
+def make_http_error(code: int, msg: str = "error") -> urllib.error.HTTPError:
+ body = json.dumps({"message": msg}).encode()
+ return urllib.error.HTTPError(
+ url="https://api.autentique.com.br/v2/graphql",
+ code=code,
+ msg=msg,
+ hdrs={},
+ fp=io.BytesIO(body),
+ )
diff --git a/.claude/skills/int-autentique/tests/test_autentique_client.py b/.claude/skills/int-autentique/tests/test_autentique_client.py
new file mode 100644
index 00000000..f1b4df70
--- /dev/null
+++ b/.claude/skills/int-autentique/tests/test_autentique_client.py
@@ -0,0 +1,549 @@
+"""test_autentique_client.py — Suite de testes para int-autentique.
+
+Cobertura:
+- gql: sucesso, erros GQL, HTTP 4xx/5xx, retry, connection error
+- gql_upload: arquivo não encontrado (anti-fabricação)
+- cmd_whoami: retorno correto
+- cmd_documents_list: paginação + campos
+- cmd_documents_get: campos completos incluindo status de signatários
+- cmd_documents_create: validação --signers (JSON inválido, array vazio)
+- cmd_documents_download: URL ausente → error; download OK
+- cmd_documents_delete: sem --confirm → error; com --confirm → sucesso
+- cmd_folders_list: retorno correto
+- cmd_smoke: exit 0, JSON steps/overall, auth fail não trava
+- Anti-fabricação: erros retornam campo _error (via json), não fabricam dados
+"""
+from __future__ import annotations
+
+import argparse
+import io
+import json
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+# conftest bootstrap carrega autentique_client em sys.modules
+import autentique_client as aut
+
+from helpers import make_gql_response, make_gql_error, make_http_error
+
+
+# ===========================================================================
+# gql — camada de transporte
+# ===========================================================================
+
+class TestGql:
+ """Testa gql() — JSON simples (queries/mutations sem upload)."""
+
+ def test_sucesso_retorna_data(self, monkeypatch):
+ resp = make_gql_response({"me": {"id": "u1", "name": "Jane Doe", "email": "jane@example.com"}})
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "token-test")
+ with patch("urllib.request.urlopen", return_value=resp):
+ data = aut.gql("query { me { id name email } }")
+ assert data["me"]["email"] == "jane@example.com"
+
+ def test_erros_gql_emite_error_e_exits2(self, monkeypatch, capsys):
+ errors = [{"message": "Unauthorized"}]
+ resp = make_gql_error(errors)
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "token-test")
+ with patch("urllib.request.urlopen", return_value=resp):
+ with pytest.raises(SystemExit) as exc:
+ aut.gql("query { me { id } }")
+ assert exc.value.code == 2
+ out = json.loads(capsys.readouterr().out)
+ assert "error" in out
+ assert out["error"] == "GraphQL errors"
+
+ def test_http_4xx_emite_error_e_exits2(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "token-test")
+ with patch("urllib.request.urlopen", side_effect=make_http_error(401, "Unauthorized")):
+ with pytest.raises(SystemExit) as exc:
+ aut.gql("query { me { id } }")
+ assert exc.value.code == 2
+ out = json.loads(capsys.readouterr().out)
+ assert "HTTP 401" in out["error"]
+
+ def test_http_5xx_retries_e_exits2(self, monkeypatch, capsys):
+ """500 deve retryar 3x e depois emitir erro."""
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "token-test")
+ call_count = 0
+ def _fail(*a, **kw):
+ nonlocal call_count
+ call_count += 1
+ raise make_http_error(500, "Internal Server Error")
+ with patch("urllib.request.urlopen", side_effect=_fail):
+ with patch("time.sleep"): # evita espera real
+ with pytest.raises(SystemExit) as exc:
+ aut.gql("query { me { id } }")
+ assert exc.value.code == 2
+ assert call_count == aut.RETRY_ATTEMPTS
+
+ def test_connection_error_exits2(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "token-test")
+ err = urllib.error.URLError("Connection refused")
+ with patch("urllib.request.urlopen", side_effect=err):
+ with patch("time.sleep"):
+ with pytest.raises(SystemExit) as exc:
+ aut.gql("query { me { id } }")
+ assert exc.value.code == 2
+
+
+class TestTokenMissing:
+ def test_sem_token_exits1(self, monkeypatch, capsys):
+ monkeypatch.delenv("AUTENTIQUE_API_TOKEN", raising=False)
+ with pytest.raises(SystemExit) as exc:
+ aut._token()
+ assert exc.value.code == 1
+ out = json.loads(capsys.readouterr().out)
+ assert "AUTENTIQUE_API_TOKEN" in out["error"]
+
+
+# ===========================================================================
+# gql_upload — multipart
+# ===========================================================================
+
+class TestGqlUpload:
+ def test_arquivo_inexistente_exits1(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "token-test")
+ with pytest.raises(SystemExit) as exc:
+ aut.gql_upload("mutation {}", {}, "/tmp/nao-existe-xpto-9999.pdf")
+ assert exc.value.code == 1
+ out = json.loads(capsys.readouterr().out)
+ assert "error" in out
+
+
+# ===========================================================================
+# cmd_whoami
+# ===========================================================================
+
+class TestCmdWhoami:
+ def test_retorna_dados_usuario(self, monkeypatch, capsys):
+ me_data = {"me": {"id": "u-123", "name": "Jane Doe", "email": "jane@example.com"}}
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: me_data)
+ args = argparse.Namespace()
+ aut.cmd_whoami(args)
+ out = json.loads(capsys.readouterr().out)
+ assert out["me"]["email"] == "jane@example.com"
+
+
+# ===========================================================================
+# cmd_documents_list
+# ===========================================================================
+
+class TestCmdDocumentsList:
+ def test_lista_documentos(self, monkeypatch, capsys):
+ payload = {
+ "documents": {
+ "total": 2,
+ "data": [
+ {"id": "d1", "name": "Distrato A", "created_at": "2026-06-01", "signatures": []},
+ {"id": "d2", "name": "Contrato B", "created_at": "2026-06-02", "signatures": []},
+ ],
+ }
+ }
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: payload)
+ args = argparse.Namespace(page=1, limit=20)
+ aut.cmd_documents_list(args)
+ out = json.loads(capsys.readouterr().out)
+ assert out["documents"]["total"] == 2
+ assert out["documents"]["data"][0]["id"] == "d1"
+
+ def test_lista_vazia(self, monkeypatch, capsys):
+ payload = {"documents": {"total": 0, "data": []}}
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: payload)
+ args = argparse.Namespace(page=1, limit=20)
+ aut.cmd_documents_list(args)
+ out = json.loads(capsys.readouterr().out)
+ assert out["documents"]["total"] == 0
+
+
+# ===========================================================================
+# cmd_documents_get
+# ===========================================================================
+
+class TestCmdDocumentsGet:
+ def test_retorna_detalhes_e_status_signatarios(self, monkeypatch, capsys):
+ payload = {
+ "document": {
+ "id": "doc-uuid-1",
+ "name": "Contrato de Prestação de Serviço",
+ "created_at": "2026-06-01",
+ "sandbox": False,
+ "refusable": True,
+ "qualified": False,
+ "files": {"original": "https://cdn.autentique.com.br/doc.pdf", "signed": None},
+ "signatures": [
+ {
+ "public_id": "sig-1",
+ "name": "Cliente X",
+ "email": "cliente@empresa.com",
+ "action": {"name": "SIGN"},
+ "viewed": None,
+ "signed": None,
+ "rejected": None,
+ },
+ {
+ "public_id": "sig-2",
+ "name": "Jane Doe",
+ "email": "responsavel@empresa.com",
+ "action": {"name": "SIGN"},
+ "viewed": {"created_at": "2026-06-02T10:00:00"},
+ "signed": {"created_at": "2026-06-02T10:05:00"},
+ "rejected": None,
+ },
+ ],
+ }
+ }
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: payload)
+ args = argparse.Namespace(document_id="doc-uuid-1")
+ aut.cmd_documents_get(args)
+ out = json.loads(capsys.readouterr().out)
+ assert out["document"]["id"] == "doc-uuid-1"
+ sigs = out["document"]["signatures"]
+ assert len(sigs) == 2
+ # Signatário que assinou tem signed preenchido
+ responsavel = next(s for s in sigs if s["email"] == "responsavel@empresa.com")
+ assert responsavel["signed"] is not None
+ # Cliente ainda não assinou
+ cliente = next(s for s in sigs if s["email"] == "cliente@empresa.com")
+ assert cliente["signed"] is None
+
+
+# ===========================================================================
+# cmd_documents_create — validação de --signers
+# ===========================================================================
+
+class TestCmdDocumentsCreate:
+ def test_signers_json_invalido_exits1(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ args = argparse.Namespace(
+ file="/tmp/fake.pdf",
+ name="Contrato",
+ signers="nao-e-json",
+ message=None, reminder=None, qualified=False, sandbox=False,
+ )
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_documents_create(args)
+ assert exc.value.code == 1
+ out = json.loads(capsys.readouterr().out)
+ assert "error" in out
+
+ def test_signers_array_vazio_exits1(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ args = argparse.Namespace(
+ file="/tmp/fake.pdf",
+ name="Contrato",
+ signers="[]",
+ message=None, reminder=None, qualified=False, sandbox=False,
+ )
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_documents_create(args)
+ assert exc.value.code == 1
+ out = json.loads(capsys.readouterr().out)
+ assert "error" in out
+
+ def test_signers_nao_array_exits1(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ args = argparse.Namespace(
+ file="/tmp/fake.pdf",
+ name="Contrato",
+ signers='{"email": "a@b.com"}', # objeto, não array
+ message=None, reminder=None, qualified=False, sandbox=False,
+ )
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_documents_create(args)
+ assert exc.value.code == 1
+
+ def test_create_chama_gql_upload_com_signers_validos(self, monkeypatch, capsys, tmp_path):
+ """Com args válidos, deve chamar gql_upload (anti-fabricação: arquivo real)."""
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.delenv("AUTENTIQUE_ORGANIZATION_ID", raising=False)
+ pdf = tmp_path / "contrato.pdf"
+ pdf.write_bytes(b"%PDF-1.4 fake")
+ signers = '[{"email":"cliente@a.com","action":"SIGN"}]'
+
+ captured_args = {}
+ def _fake_upload(query, variables, file_path):
+ captured_args["query"] = query
+ captured_args["variables"] = variables
+ captured_args["file_path"] = file_path
+ return {"createDocument": {"id": "new-doc-id", "name": "Contrato", "sandbox": False,
+ "files": {"original": "http://x.com/doc.pdf"},
+ "signatures": [{"public_id": "p1", "name": "Cliente", "email": "cliente@a.com", "link": {"short_link": "http://s.lnk"}}]}}
+
+ monkeypatch.setattr(aut, "gql_upload", _fake_upload)
+ args = argparse.Namespace(
+ file=str(pdf),
+ name="Contrato",
+ signers=signers,
+ message="Por favor assine.",
+ reminder="WEEKLY",
+ qualified=False,
+ sandbox=False,
+ )
+ aut.cmd_documents_create(args)
+ assert captured_args["file_path"] == str(pdf)
+ assert captured_args["variables"]["document"]["name"] == "Contrato"
+ assert captured_args["variables"]["document"]["message"] == "Por favor assine."
+
+ def test_create_sandbox_flag(self, monkeypatch, capsys, tmp_path):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.delenv("AUTENTIQUE_ORGANIZATION_ID", raising=False)
+ pdf = tmp_path / "doc.pdf"
+ pdf.write_bytes(b"%PDF-1.4 fake")
+
+ captured = {}
+ def _fake_upload(query, variables, file_path):
+ captured["sandbox"] = variables["document"].get("sandbox", False)
+ return {"createDocument": {"id": "x", "name": "T", "sandbox": True,
+ "files": {"original": "u"}, "signatures": []}}
+
+ monkeypatch.setattr(aut, "gql_upload", _fake_upload)
+ args = argparse.Namespace(
+ file=str(pdf), name="T", signers='[{"email":"a@b.com","action":"SIGN"}]',
+ message=None, reminder=None, qualified=False, sandbox=True,
+ )
+ aut.cmd_documents_create(args)
+ assert captured["sandbox"] is True
+
+
+# ===========================================================================
+# cmd_documents_download
+# ===========================================================================
+
+class TestCmdDocumentsDownload:
+ def test_url_ausente_exits2(self, monkeypatch, capsys, tmp_path):
+ """Se URL da versão solicitada não estiver disponível → error, exit 2."""
+ payload = {
+ "document": {
+ "id": "doc1",
+ "name": "Distrato",
+ "files": {"original": "http://x.com/orig.pdf", "signed": None},
+ }
+ }
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: payload)
+ args = argparse.Namespace(
+ document_id="doc1",
+ version="signed",
+ output=str(tmp_path / "out.pdf"),
+ )
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_documents_download(args)
+ assert exc.value.code == 2
+ out = json.loads(capsys.readouterr().out)
+ assert "error" in out
+
+ def test_download_original_salva_arquivo(self, monkeypatch, capsys, tmp_path):
+ payload = {
+ "document": {
+ "id": "doc2",
+ "name": "Contrato",
+ "files": {"original": "http://cdn.autentique.com.br/doc.pdf", "signed": None},
+ }
+ }
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: payload)
+ out_path = tmp_path / "original.pdf"
+
+ class _FakeURLResp:
+ def read(self):
+ return b"%PDF-1.4 conteudo"
+ def __enter__(self):
+ return self
+ def __exit__(self, *a):
+ pass
+
+ with patch("urllib.request.urlopen", return_value=_FakeURLResp()):
+ args = argparse.Namespace(
+ document_id="doc2", version="original", output=str(out_path)
+ )
+ aut.cmd_documents_download(args)
+
+ out = json.loads(capsys.readouterr().out)
+ assert out["saved"] == str(out_path)
+ assert out["version"] == "original"
+ assert out_path.read_bytes() == b"%PDF-1.4 conteudo"
+
+
+# ===========================================================================
+# cmd_documents_delete
+# ===========================================================================
+
+class TestCmdDocumentsDelete:
+ def test_sem_confirm_exits1(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ args = argparse.Namespace(document_id="doc1", confirm=False)
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_documents_delete(args)
+ assert exc.value.code == 1
+ out = json.loads(capsys.readouterr().out)
+ assert "Confirmação obrigatória" in out["error"]
+
+ def test_com_confirm_deleta(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: {"deleteDocument": True})
+ args = argparse.Namespace(document_id="doc1", confirm=True)
+ aut.cmd_documents_delete(args)
+ out = json.loads(capsys.readouterr().out)
+ assert out["deleteDocument"] is True
+
+
+# ===========================================================================
+# cmd_folders_list
+# ===========================================================================
+
+class TestCmdFoldersList:
+ def test_lista_pastas(self, monkeypatch, capsys):
+ payload = {
+ "folders": {
+ "data": [
+ {"id": "f1", "name": "Contratos"},
+ {"id": "f2", "name": "Distratos"},
+ ]
+ }
+ }
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: payload)
+ args = argparse.Namespace()
+ aut.cmd_folders_list(args)
+ out = json.loads(capsys.readouterr().out)
+ assert len(out["folders"]["data"]) == 2
+
+ def test_pastas_vazias(self, monkeypatch, capsys):
+ payload = {"folders": {"data": []}}
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: payload)
+ args = argparse.Namespace()
+ aut.cmd_folders_list(args)
+ out = json.loads(capsys.readouterr().out)
+ assert out["folders"]["data"] == []
+
+
+# ===========================================================================
+# cmd_smoke
+# ===========================================================================
+
+class TestCmdSmoke:
+ """Smoke sempre exit 0 mesmo com falhas internas."""
+
+ def test_smoke_pass(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ responses = [
+ {"me": {"id": "u1", "name": "M", "email": "jane@example.com"}},
+ {"documents": {"total": 3}},
+ ]
+ call_idx = 0
+ def _fake_gql(*a, **kw):
+ nonlocal call_idx
+ r = responses[call_idx]
+ call_idx += 1
+ return r
+ monkeypatch.setattr(aut, "gql", _fake_gql)
+ args = argparse.Namespace()
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_smoke(args)
+ assert exc.value.code == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["overall"] == "PASS"
+ assert len(out["steps"]) == 2
+ assert "duration_ms" in out
+
+ def test_smoke_falha_auth_ainda_exit0(self, monkeypatch, capsys):
+ monkeypatch.delenv("AUTENTIQUE_API_TOKEN", raising=False)
+ # gql vai chamar _token() que vai sys.exit(1) — smoke deve capturar
+ args = argparse.Namespace()
+ # Smoke captura exceção de _token via try/except + sys.exit(0)
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_smoke(args)
+ assert exc.value.code == 0
+ out = json.loads(capsys.readouterr().out)
+ # overall pode ser PASS ou FAIL dependendo da captura
+ assert "overall" in out
+
+ def test_smoke_falha_gql_ainda_exit0(self, monkeypatch, capsys):
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ def _fail(*a, **kw):
+ raise RuntimeError("API indisponível")
+ monkeypatch.setattr(aut, "gql", _fail)
+ args = argparse.Namespace()
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_smoke(args)
+ assert exc.value.code == 0
+ out = json.loads(capsys.readouterr().out)
+ assert "overall" in out
+ assert "steps" in out
+
+
+# ===========================================================================
+# Anti-fabricação — erros HTTP retornam campo error, não dados fabricados
+# ===========================================================================
+
+class TestAntiFabricacao:
+ def test_gql_nao_retorna_dados_em_erro(self, monkeypatch, capsys):
+ """Em caso de erro GraphQL, o output contém 'error', não dados fabricados."""
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ resp = make_gql_error([{"message": "Not authorized"}])
+ with patch("urllib.request.urlopen", return_value=resp):
+ with pytest.raises(SystemExit):
+ aut.gql("query { me { id } }")
+ out = json.loads(capsys.readouterr().out)
+ assert "error" in out
+ assert "me" not in out # sem dados fabricados
+
+ def test_documento_sem_url_signed_nao_fabrica_url(self, monkeypatch, capsys, tmp_path):
+ """Se URL signed é None, retorna error — nunca fabrica uma URL."""
+ payload = {
+ "document": {
+ "id": "d1", "name": "Doc",
+ "files": {"original": "http://x.com/a.pdf", "signed": None},
+ }
+ }
+ monkeypatch.setenv("AUTENTIQUE_API_TOKEN", "tok")
+ monkeypatch.setattr(aut, "gql", lambda *a, **kw: payload)
+ args = argparse.Namespace(
+ document_id="d1", version="signed", output=str(tmp_path / "out.pdf")
+ )
+ with pytest.raises(SystemExit) as exc:
+ aut.cmd_documents_download(args)
+ assert exc.value.code == 2
+ out = json.loads(capsys.readouterr().out)
+ assert "error" in out
+ # Jamais retornar uma URL fabricada
+ assert "http" not in out.get("error", "")
+
+
+# ===========================================================================
+# Argparse — smoke command presente
+# ===========================================================================
+
+class TestArgparse:
+ def test_smoke_subcommand_existe(self):
+ parser = aut.build_parser()
+ args = parser.parse_args(["smoke"])
+ assert args.command == "smoke"
+
+ def test_documents_list_defaults(self):
+ parser = aut.build_parser()
+ args = parser.parse_args(["documents", "list"])
+ assert args.page == 1
+ assert args.limit == 20
+ assert args.subcommand == "list"
+
+ def test_documents_create_required_args(self):
+ parser = aut.build_parser()
+ with pytest.raises(SystemExit):
+ parser.parse_args(["documents", "create", "--name", "X"]) # --file obrigatório
+
+ def test_delete_sem_confirm_flag_default_false(self):
+ parser = aut.build_parser()
+ args = parser.parse_args(["documents", "delete", "doc-uuid"])
+ assert args.confirm is False