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