Merge PR #317 — duas cópias do repo derrubavam o WhatsApp, e o 401 ficou 3 dias invisível

fix(whatsapp): duas cópias do repo derrubavam as conexões, e o 401 ficou 3 dias invisível
This commit is contained in:
Rafael Melgaço
2026-08-24 15:14:49 -03:00
committed by GitHub
14 changed files with 569 additions and 12 deletions
+10 -3
View File
@@ -25,6 +25,7 @@ import { ok, fail } from "@/lib/api/wrappers";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
import { requireRole } from "@/lib/auth/require-role";
import { CHANNEL_PROVIDER_WAHA } from "@/lib/channels/capabilities";
import { numeroObservadoDaSessao } from "@/lib/channels/numero-observado";
import { isChannelStatus } from "@/lib/schemas/channels";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
@@ -166,9 +167,15 @@ export async function GET(
me?: { id?: string; pushName?: string };
};
if (remote.status) liveStatus = remote.status;
// WAHA expõe o número (JID `<phone>@c.us`) quando a sessão está WORKING.
const jid = remote.me?.id;
if (jid && !phoneNumber) phoneNumber = jid.replace(/@.*/, "");
// O número vem do JID (`<phone>@c.us`), e a regra de quando ele VALE mora
// em `numeroObservadoDaSessao` — inclusive por que não basta gravar sempre.
// O que havia aqui só preenchia a coluna VAZIA, então um re-pareamento com
// outro aparelho deixava o banco mentindo para sempre.
phoneNumber = numeroObservadoDaSessao({
jid: remote.me?.id,
statusAoVivo: liveStatus,
gravado: phoneNumber,
});
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
// 404 no WAHA = sessão não iniciada lá → considera STOPPED.
+36 -2
View File
@@ -12,7 +12,7 @@ Ao mudar um invariante aqui, atualize os dois na mesma sessão.
| Se você quer… | Vá para |
|---|---|
| saber se sua mudança precisa virar imagem publicada | §Os 7 invariantes, nº 1 |
| saber se sua mudança precisa virar imagem publicada | §Os 8 invariantes, nº 1 |
| escolher a tag que uma instalação de cliente consome | §Política de canais |
| lançar uma versão | §Checklist de release |
| entender por que o namespace é `melgarafael` e não uma org | o ADR |
@@ -58,7 +58,7 @@ porque a exceção é o que apaga a regra.
---
## Os 7 invariantes (verificáveis)
## Os 8 invariantes (verificáveis)
### 1. Nenhum serviço de produção constrói na máquina do cliente
@@ -245,6 +245,40 @@ default que preserva o comportamento anterior**; se ela precisa existir, quem a
Medido no app real: com `APP_VERSION=9.9.9-teste` o endpoint responde `9.9.9-teste`; sem ela,
`desconhecido` — nunca um número plausível.
### 8. Uma instalação, um dono — o projeto Docker não se compartilha
Só a árvore que criou os contêineres pode atualizá-los. Uma segunda cópia do repo na
mesma VPS **recusa** mexer, e diz por quê.
- **Por quê:** `docker compose` deriva o nome do projeto do *basename* do diretório.
`/root/DeskcommCRM` e `/root/apagar6/DeskcommCRM` viram ambos `deskcommcrm` — um
conjunto só de contêineres, dois `.env` diferentes. Cada `up -d` recria o parque com as
credenciais da sua árvore, e a outra passa a falar com serviços que não a reconhecem.
- **Anti-exemplo real (medido, 2026-08):** o cron rodava o `agent.sh` das duas árvores a
cada 5 minutos. Em 21/08 13:30 a cópia de teste recriou o contêiner do WAHA com a chave
dela; às 14:47 o app foi recriado da árvore de produção, com outra. Resultado: **três
dias** com `waha_create_401` em toda chamada — nenhum número de WhatsApp conectava — e as
sessões caindo a cada recriação. O mesmo aconteceu com o `srh`, que ficou com o token da
árvore errada e derrubou o rate limit (`GET /api/v1/health` → `redis: down, http_401`).
- **Por que o `flock` não bastava:** ele tranca por **diretório** (`$PROJECT_DIR/.update.lock`),
e as duas árvores pegam locks diferentes enquanto disputam os mesmos contêineres. A trava
tem de ser pelo que elas de fato compartilham — o projeto Docker.
- **Como se detecta:** o label `com.docker.compose.project.working_dir`, que todo contêiner
do compose carrega, nomeia a árvore que o criou.
```bash
docker ps -a --filter "label=com.docker.compose.project=$(basename "$PWD" | tr 'A-Z' 'a-z')" \
--format '{{.Names}} => {{.Label "com.docker.compose.project.working_dir"}}'
```
- **Escape:** `DESKCOMM_ASSUMIR_PROJETO=1` assume o parque de propósito. Existe para a
instalação que **mudou de pasta** de verdade; é explícito porque assumir por engano é o
defeito que o guarda existe para impedir. Uma árvore alheia que já **não está no disco**
não conta como rival — senão o guarda nasceria vermelho em quem só moveu a instalação.
- **Verificação:** `tests/shell/dono-do-projeto.test.sh` (no `pnpm test:shell`) — cobre
parque limpo, parque próprio, parque alheio, parque **misto** (o caso medido), pasta
movida, o escape, e os dois call sites (`agent.sh` e `update.sh`).
---
## Política de canais
+1 -1
View File
@@ -87,7 +87,7 @@ Detalham schema SQL e payloads exatos. **Consulte antes de modelar qualquer cois
| [`doctrine/sistema-vivo/`](doctrine/sistema-vivo/README.md) | **Manual do Sistema Vivo** — 8 capítulos plugáveis (princípio universal + aplicação de referência). O *porquê* de cada invariante, e como adotar a doutrina em outro sistema |
| [`doctrine/restricao-de-canal.md`](doctrine/restricao-de-canal.md) | Auto-restrição × hetero-restrição de canais externos; contrato de parâmetros derivado |
| [`doctrine/separacao-fala-e-operacao.md`](doctrine/separacao-fala-e-operacao.md) | Vocabulário interno nunca vaza para o cliente |
| [`doctrine/packaging.md`](doctrine/packaging.md) | **Doutrina de Packaging — a LEI.** 7 invariantes + política de canais + checklist de release (item 15 do DoD) |
| [`doctrine/packaging.md`](doctrine/packaging.md) | **Doutrina de Packaging — a LEI.** 8 invariantes + política de canais + checklist de release (item 15 do DoD) |
| [`adr/0001-packaging-e-distribuicao.md`](adr/0001-packaging-e-distribuicao.md) | ADR do packaging: namespace, os 3 packages, e o que foi recusado |
| [`architecture/agent-turn.html`](architecture/agent-turn.html) | Diagrama do turno do agente (inbound → guardrails → outbound) |
| [`architecture/teto-de-orcamento.architecture.json`](architecture/teto-de-orcamento.architecture.json) | **Mapa vivo do teto de gasto com IA** — quem alimenta o gate, o que a parada NÃO desfaz sozinha, e o laço de retorno (invariante 7) |
+78
View File
@@ -52,6 +52,84 @@ nome_do_projeto_atual() {
printf '%s' "${COMPOSE_PROJECT_NAME:-$(nome_do_projeto_compose "${PROJECT_DIR:-$PWD}")}"
}
# ── Quem é o DONO deste projeto Docker ───────────────────────────────────────
#
# Duas cópias do repo na mesma VPS — o clone de produção e um de teste ao lado —
# recebem o MESMO nome de projeto compose: o docker o deriva do basename do
# diretório, e `/root/DeskcommCRM` e `/root/apagar6/DeskcommCRM` dão os dois
# `deskcommcrm`. Os contêineres são UM conjunto só; os `.env` são dois. Cada
# `up -d` recria o parque com as credenciais da SUA árvore, e a outra fica
# falando com um transporte que não a reconhece mais.
#
# Não é hipótese. Numa VPS real o clone de teste recriou o contêiner do WhatsApp
# com a chave dele às 13:30; o app foi recriado da árvore de produção às 14:47,
# com outra chave; e por TRÊS DIAS toda chamada ao WAHA respondeu 401 — nenhum
# número conectava, nenhuma mensagem entrava, e o painel só dizia "não foi
# possível verificar a conexão".
#
# O `flock` do agent.sh não protege disso: ele é por DIRETÓRIO, então as duas
# árvores pegam locks diferentes enquanto disputam os mesmos contêineres. A
# trava tem de ser pelo que elas de fato compartilham — o projeto Docker.
#
# O sinal é o próprio Docker: todo contêiner criado pelo compose carrega o label
# `com.docker.compose.project.working_dir` com a árvore que o criou.
donos_do_projeto_em_execucao() { # → um diretório por linha, sem repetir
docker ps -a \
--filter "label=com.docker.compose.project=$(nome_do_projeto_atual)" \
--format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null \
| grep -v '^$' | sort -u
}
# Imprime as árvores ALHEIAS que ainda são instalações VIVAS; sai 0 quando existe
# ao menos uma. Sem contêiner no ar não há dono, e uma instalação nova assume
# legitimamente — por isso o silêncio aqui é "pode seguir", não "não sei".
#
# "Viva" é o filtro que impede este guarda de nascer vermelho em quem não fez
# nada de errado: quem MOVEU a instalação de pasta deixa contêineres apontando
# para um caminho que não existe mais. Esse não é um rival disputando o parque —
# é o endereço antigo desta mesma instalação, e recusar ali travaria as
# atualizações para sempre, num log que ninguém lê. Só conta como rival a árvore
# que ainda está no disco COM um compose: aquela de onde um segundo cron
# realmente consegue rodar `up -d`.
projeto_pertence_a_outra_arvore() {
local dir vivas=""
while IFS= read -r dir; do
[ -n "$dir" ] || continue
[ "$dir" != "${PROJECT_DIR:-$PWD}" ] || continue
[ -f "$dir/$COMPOSE" ] || continue
vivas="${vivas}${vivas:+$'\n'}${dir}"
done <<EOF
$(donos_do_projeto_em_execucao)
EOF
[ -n "$vivas" ] || return 1
printf '%s' "$vivas"
}
# O guarda que o agent.sh e o update.sh chamam antes de tocar em contêiner.
#
# Falha FECHADA na ação (não mexe em parque alheio) e ABERTA na informação: diz
# qual árvore é a dona e como assumir de propósito. Parar calado deixaria o dono
# da VPS achando que o agente atualiza, quando ele desiste a cada 5 minutos.
#
# `DESKCOMM_ASSUMIR_PROJETO=1` é a saída para o caso legítimo — a instalação
# mudou de pasta e os contêineres ainda apontam para a antiga. É explícita de
# propósito: assumir por engano é justamente o defeito que esta função existe
# para impedir.
recusar_projeto_de_outra_arvore() { # recusar_projeto_de_outra_arvore <como reportar>
local alheias reportar="${1:-}"
alheias="$(projeto_pertence_a_outra_arvore)" || return 0
[ "${DESKCOMM_ASSUMIR_PROJETO:-}" != "1" ] || return 0
local recado
recado="os contêineres do projeto '$(nome_do_projeto_atual)' foram criados por outra cópia do repo ($(printf '%s' "$alheias" | tr '\n' ' ')) — esta aqui é $(printf '%s' "${PROJECT_DIR:-$PWD}"). Duas cópias com o mesmo nome de projeto disputam os MESMOS contêineres e cada uma os recria com o .env dela, o que derruba as conexões de WhatsApp e quebra as credenciais. Deixe apenas UMA no cron (crontab -e) ou, se esta é mesmo a instalação boa, rode com DESKCOMM_ASSUMIR_PROJETO=1"
if [ -n "$reportar" ] && command -v "$reportar" >/dev/null 2>&1; then
"$reportar" "$recado"
else
printf '%s\n' "$recado" >&2
fi
return 1
}
# A bridge que ESTE projeto reserva para o proxy externo. Um `basename` cru
# diverge numa pasta com maiúscula, ponto ou underscore inicial — e aí o kit
# cria uma rede e o compose procura outra.
+7
View File
@@ -37,6 +37,13 @@ log_err() { # log_err <mensagem> — grava com timestamp, corta pra ~200 linhas
{ tail -n 200 "$ERRLOG" > "${ERRLOG}.tmp" && mv "${ERRLOG}.tmp" "$ERRLOG"; } 2>/dev/null || true
}
# Antes de qualquer outra coisa: esta cópia do repo manda neste projeto Docker?
#
# Cedo de propósito — antes até de ANUNCIAR a versão. Uma cópia que não é a dona
# anunciaria a versão da árvore dela, e o app ofereceria "Atualizar agora" com
# base num número que não descreve o que está no ar.
recusar_projeto_de_outra_arvore log_err || exit 0
post() { # post <json> → corpo da resposta em 2xx; VAZIO em qualquer falha
# (quem chama, ex. o laço de retry do run_result, usa "saiu vazio" como sinal
# de falha — por isso o corpo só é impresso no ramo de sucesso).
+6
View File
@@ -26,6 +26,12 @@ while [ $# -gt 0 ]; do
shift
done
# ── 0-. Esta cópia do repo é a dona dos contêineres? ─────────────────────────
# Antes do cron e antes do git: uma segunda cópia que atualiza por cima recria o
# parque com o .env DELA. Foi o que deixou o WhatsApp de uma VPS real três dias
# em 401. Ver `recusar_projeto_de_outra_arvore` em _common.sh.
recusar_projeto_de_outra_arvore || die "Atualização interrompida para não quebrar a instalação que está no ar."
# ── 0. Liga o agente da tela ANTES de qualquer decisão de versão ─────────────
# Instalar o cron aqui, e não no fim, é o que faz o bootstrap ter fim: os
# caminhos "já está na versão mais recente" e "essa versão é anterior à sua"
+41 -1
View File
@@ -13,8 +13,28 @@ import { resolveWhatsappIdForContactCard } from "@/lib/waha/resolve-contact-what
import { bareWaMessageId, parseWahaMessageId } from "@/lib/waha/message-id";
import { resolveWahaChatId } from "@/lib/waha/send";
import type { FetchedMedia } from "@/lib/messaging/media/types";
import { DETALHE_CREDENCIAL_RECUSADA } from "../health";
import type { ChannelAdapter, ChannelHealth, OutboundEnvelope, RecipientInput } from "../types";
/**
* O HTTP que o WAHA devolveu, lido do PREFIXO da mensagem de erro.
*
* `lib/waha/client.ts` lança `waha_<status>` ou `waha_<operação>_<status>`, às
* vezes seguido do corpo da resposta. Procurar `"404"` com `includes` — como
* este arquivo fazia — varreria o CORPO junto: um `waha_stop_500: {"detail":
* "upstream 404"}` viraria "sessão parada", dando um transporte quebrado por
* explicado.
*
* Medido, e sem inflar: pelo caminho do `checkHealth` isso NÃO era alcançável
* hoje — quem ele chama é `getSessionQr`, e essa lança `waha_<status>` seco,
* sem corpo. A troca é robustez, não o conserto de um defeito observado; o que
* conserta o defeito observado é o ramo 401/403 abaixo.
*/
export function statusHttpDoErroWaha(msg: string): number | null {
const m = /^waha_(?:[a-z]+_)?(\d{3})\b/.exec(msg);
return m ? Number(m[1]) : null;
}
export const wahaAdapter: ChannelAdapter = {
provider: "waha",
@@ -104,7 +124,27 @@ export const wahaAdapter: ChannelAdapter = {
return { reachable: true, status: r.status ?? null, detail: null };
} catch (err) {
const msg = err instanceof Error ? err.message : "erro_desconhecido";
if (msg.includes("404")) return { reachable: true, status: "STOPPED", detail: null };
const http = statusHttpDoErroWaha(msg);
// Sessão não existe no transporte → parada. É o único desfecho em que
// dá para AFIRMAR o estado da sessão a partir de um erro.
if (http === 404) return { reachable: true, status: "STOPPED", detail: null };
// A chave foi recusada. Não é o estado da sessão que está em jogo — é o
// acesso ao transporte inteiro, e enquanto durar NENHUMA conexão
// funciona. Continua `reachable: false` porque de fato não se sabe o
// estado da sessão; o que muda é o `detail`, que a Central lê para dizer
// ao operador que escanear o QR não vai resolver.
//
// Sem isto, um 401 caía no ramo genérico e virava "Não foi possível
// verificar a conexão" — um aviso `warn` que descreve oscilação de rede.
// Numa VPS real isso durou TRÊS DIAS: a chave do WAHA tinha sido trocada
// por uma segunda cópia do repo, nada funcionava, e a única pista visível
// sugeria um soluço passageiro.
if (http === 401 || http === 403) {
return { reachable: false, status: null, detail: DETALHE_CREDENCIAL_RECUSADA };
}
return { reachable: false, status: null, detail: msg.slice(0, 200) };
}
},
+35 -4
View File
@@ -52,6 +52,16 @@ export const STATUS_QUE_AVISAM = ["SCAN_QR_CODE", "FAILED", "STOPPED"] as const;
/** Marca os avisos desta origem, para resolver o do canal CERTO depois. */
export const REF_KIND_SESSAO = "channel_session";
/**
* O transporte respondeu, e a resposta foi "sua credencial não vale".
*
* Vive aqui, e não no adapter, porque quem lê este código é a regra de aviso
* logo abaixo: é um contrato entre os dois, e um literal repetido nas duas
* pontas divergiria no primeiro renomeio — com o gate verde, porque nenhum
* `includes` reclama de string que ninguém mais escreve.
*/
export const DETALHE_CREDENCIAL_RECUSADA = "credencial_recusada_pelo_transporte";
/**
* Marca do episódio aberto por um EMPURRÃO do provedor.
*
@@ -84,11 +94,32 @@ export interface SaudeObservada {
* pergunta de quem lê o aviso é exatamente essa.
*/
export function avisoDaConexao(saude: SaudeObservada, apelido: string): AvisoDeConexao | null {
// Não deu para perguntar. NÃO é o mesmo que estar caído, e o aviso não pode
// afirmar o que não sabe: a ação aqui é olhar o serviço, não escanear um QR.
// `warn` e não `critical` porque uma oscilação de rede cabe neste ramo, e
// gritar por ela ensinaria o operador a ignorar a cor.
if (!saude.reachable) {
// "Não deu para perguntar" tem DOIS motivos que pedem ações opostas, e
// tratá-los igual foi o defeito medido: numa VPS real a chave do WAHA foi
// trocada por uma segunda cópia do repo, TUDO parou, e por três dias a
// Central mostrou apenas um `warn` dizendo "não foi possível verificar" —
// a frase que se usa para descrever um soluço de rede. O dono só descobriu
// ao tentar conectar um número e ver o 401 na cara.
//
// Quando o transporte RESPONDE recusando a credencial, não há incerteza
// sobre a causa nem sobre o que fazer — e escanear o QR, que é o reflexo de
// quem lê "conexão caída", não resolve nada.
if (saude.detail === DETALHE_CREDENCIAL_RECUSADA) {
return {
kind: "channel_number_alert",
severity: "critical",
title: `Conexão "${apelido}": o servidor de WhatsApp recusou a chave de acesso`,
body:
"Escanear o QR não resolve: a chave que o CRM usa para falar com o servidor de WhatsApp não confere com a que o servidor espera. Enquanto isso durar, nenhuma mensagem entra nem sai por NENHUMA conexão. Quem cuida do servidor precisa conferir a WAHA_API_KEY do .env e recriar o contêiner do WhatsApp.",
episodio: "CREDENCIAL_RECUSADA",
};
}
// Não deu para perguntar mesmo. NÃO é o mesmo que estar caído, e o aviso
// não pode afirmar o que não sabe: a ação aqui é olhar o serviço, não
// escanear um QR. `warn` e não `critical` porque uma oscilação de rede cabe
// neste ramo, e gritar por ela ensinaria o operador a ignorar a cor.
return {
kind: "channel_number_alert",
severity: "warn",
+55
View File
@@ -0,0 +1,55 @@
/**
* Qual número o transporte está mesmo atendendo nesta conexão.
*
* ─── O defeito, medido em produção ──────────────────────────────────────────
*
* A rota de saúde da conexão gravava o número assim:
*
* if (jid && !phoneNumber) phoneNumber = jid.replace(/@.*\/, "");
*
* — só quando a coluna ainda estava VAZIA. O primeiro pareamento gravava, e
* dali em diante o valor era imutável. Re-parear a conexão com OUTRO aparelho é
* exatamente o que o dono faz quando o WhatsApp cai, e o banco seguia dizendo o
* número antigo para sempre.
*
* Medido numa instalação real: a conexão de produção atendia `551148633324`, e
* o banco dizia `553198966398` — o número de um pareamento anterior, de outra
* organização. Os 23 avisos abertos na Central nomeavam o número errado.
*
* Isso não é cosmético. `health.ts` escolhe o apelido do aviso justamente para
* responder "QUAL conexão caiu?" — a primeira pergunta de quem lê. Com o dado
* errado, o aviso manda o operador pegar o celular errado, e a impressão que
* fica é "esse negócio vive caindo e eu nunca consigo reconectar".
*
* ─── Por que não basta gravar sempre ────────────────────────────────────────
*
* Porque o `me` do WAHA é o do ÚLTIMO pareamento que vingou, e ele continua
* sendo servido enquanto a sessão está fora do ar. Medido no mesmo dia: com
* duas sessões em `FAILED`, a API devolvia o MESMO `me` para as duas — o de uma
* delas. Gravar isso trocaria um número errado por outro, e ainda por cima
* poderia colidir com a trava de número único do banco.
*
* Só `WORKING` é observação: é o estado em que o transporte fala do aparelho
* que ele está de fato atendendo agora.
*/
/** O estado em que o `me` do transporte descreve o aparelho de verdade. */
const STATUS_EM_QUE_O_NUMERO_VALE = "WORKING";
export function numeroObservadoDaSessao(input: {
/** JID como o transporte devolve: `5511999998888@c.us`. */
jid: string | null | undefined;
/** Status lido AGORA, não o que estava no banco. */
statusAoVivo: string | null | undefined;
/** O que já está gravado — devolvido de volta quando não há observação boa. */
gravado: string | null;
}): string | null {
if (!input.jid) return input.gravado;
if ((input.statusAoVivo ?? "").toUpperCase() !== STATUS_EM_QUE_O_NUMERO_VALE) {
return input.gravado;
}
const numero = input.jid.replace(/@.*/, "").trim();
// JID sem parte local (`@c.us`) não descreve aparelho nenhum: manter o que
// está gravado é melhor que apagar o único dado que a tela tinha.
return numero || input.gravado;
}
+1 -1
View File
@@ -24,7 +24,7 @@
"test:journeys": "playwright test -c tests/journeys/playwright.config.ts",
"test:unit": "vitest run",
"test:db": "bash scripts/test-db.sh",
"test:shell": "bash tests/shell/update-guard.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash hostgator-setup-kit/test-validators.sh",
"test:shell": "bash tests/shell/update-guard.test.sh && bash tests/shell/dono-do-projeto.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash hostgator-setup-kit/test-validators.sh",
"test:invariants": "bash scripts/test-db.sh",
"lint:channels": "tsx scripts/lint-channels.ts",
"gov:verify": "pnpm typecheck && pnpm lint && pnpm lint:channels && pnpm test:unit",
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Prova do guarda que impede DUAS cópias do repo de disputarem os mesmos
# contêineres — `recusar_projeto_de_outra_arvore` em `_common.sh`, e os dois
# call sites que o usam (`agent.sh` e `update.sh`).
#
# bash tests/shell/dono-do-projeto.test.sh
#
# ── O defeito que ele guarda, medido numa VPS de verdade ────────────────────
#
# `/root/DeskcommCRM` e `/root/apagar6/DeskcommCRM` — o clone de produção e um
# de teste ao lado — têm o mesmo basename, logo o mesmo nome de projeto compose
# (`deskcommcrm`). O cron rodava o agent.sh das DUAS a cada 5 minutos. Em
# 21/08 13:30 o clone de teste recriou o contêiner do WhatsApp com a chave do
# .env dele; às 14:47 o app foi recriado da árvore de produção, com outra chave.
# Resultado: `waha_create_401` em toda chamada, por três dias, nenhum número
# conectando — e a única pista na tela era "não foi possível verificar".
#
# O `flock` do agent.sh não pega isso: ele tranca por DIRETÓRIO, e as duas
# árvores pegam locks diferentes enquanto disputam o mesmo parque.
#
# Nada aqui toca a máquina de quem roda: `docker` é um dublê que devolve os
# labels que o teste manda.
set -uo pipefail
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../hostgator-setup-kit" && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
FAILS=0
check() { # check <descrição> <comando...>
if "${@:2}"; then printf ' ✓ %s\n' "$1"; else printf ' ✗ %s\n' "$1"; FAILS=$((FAILS + 1)); fi
}
# ── Dublê de docker ──────────────────────────────────────────────────────────
# Devolve, uma por linha, as árvores em $DONOS — que é o que
# `docker ps --format '{{.Label "...working_dir"}}'` imprime de verdade.
mkdir -p "$WORK/bin"
cat > "$WORK/bin/docker" <<'STUB'
#!/usr/bin/env bash
case " $* " in
*" ps "*) printf '%s\n' ${DONOS:-} ;;
*) : ;;
esac
STUB
chmod +x "$WORK/bin/docker"
PATH="$WORK/bin:$PATH"
# ── Duas árvores de verdade no disco ─────────────────────────────────────────
# Precisam existir COM um compose: o guarda só conta como rival a instalação que
# ainda está no disco — quem apenas moveu a pasta deixa contêineres apontando
# para um caminho morto, e travar esse caso seria um gate nascendo vermelho em
# quem não fez nada de errado.
PROD="$WORK/root/DeskcommCRM"
TESTE="$WORK/root/apagar6/DeskcommCRM"
MUDOU_DE_PASTA="$WORK/root/endereco-antigo" # de propósito: NÃO é criado
mkdir -p "$PROD" "$TESTE"
touch "$PROD/docker-compose.prod.yml" "$TESTE/docker-compose.prod.yml"
# `recusar_...` roda numa subshell para que o `set -e` do _common.sh e um
# eventual `exit` não derrubem este arquivo de teste.
guarda() { # guarda <PROJECT_DIR> [DONOS...] → exit code; mensagem no stdout
local dir="$1"; shift
(
DONOS="$*" PROJECT_DIR="$dir" \
bash -c '. "$0"/_common.sh 2>/dev/null || true
PROJECT_DIR="$1"
recusar_projeto_de_outra_arvore' "$KIT_DIR" "$dir" 2>&1
)
}
printf '\n▶ o guarda em si\n'
saida="$(guarda "$PROD")"; rc=$?
check "sem contêiner no ar, a instalação nova assume (rc=0)" test "$rc" -eq 0
saida="$(guarda "$PROD" "$PROD")"; rc=$?
check "parque criado pela MESMA árvore segue (rc=0)" test "$rc" -eq 0
saida="$(guarda "$PROD" "$TESTE")"; rc=$?
check "parque criado por OUTRA árvore é recusado (rc≠0)" test "$rc" -ne 0
check "a recusa nomeia a árvore intrusa" grep -q "apagar6" <<<"$saida"
check "a recusa nomeia a árvore corrente" grep -qF "$PROD" <<<"$saida"
check "a recusa ensina a saída (crontab)" grep -q "crontab" <<<"$saida"
# O caso REAL da VPS: app/waha de uma árvore, redis/srh da outra. Uma checagem
# que olhasse só o PRIMEIRO contêiner daria o parque por são.
saida="$(guarda "$PROD" "$PROD" "$TESTE" "$PROD")"; rc=$?
check "parque MISTO (o caso medido) é recusado" test "$rc" -ne 0
saida="$(DESKCOMM_ASSUMIR_PROJETO=1 guarda "$PROD" "$TESTE")"; rc=$?
check "DESKCOMM_ASSUMIR_PROJETO=1 é a saída explícita (rc=0)" test "$rc" -eq 0
# O gate não pode nascer vermelho em quem só mudou a instalação de pasta: os
# contêineres seguem apontando para o endereço antigo, que já não existe. Aquilo
# não é um rival — é esta mesma instalação, no endereço de ontem.
saida="$(guarda "$PROD" "$MUDOU_DE_PASTA")"; rc=$?
check "instalação MOVIDA de pasta não é rival (rc=0)" test "$rc" -eq 0
# Mas se a árvore antiga ainda está lá com um compose, ela PODE rodar um segundo
# cron — e aí é rival de novo.
saida="$(guarda "$PROD" "$TESTE" "$MUDOU_DE_PASTA")"; rc=$?
check "árvore morta + árvore VIVA: a viva ainda faz recusar" test "$rc" -ne 0
check "e a recusa não cita o endereço morto" bash -c '! grep -qF "$1" <<<"$2"' _ "$MUDOU_DE_PASTA" "$saida"
printf '\n▶ os call sites (guardar a função não basta se ninguém a chama)\n'
check "agent.sh chama o guarda e desiste" \
grep -qE '^recusar_projeto_de_outra_arvore .*\|\| exit 0' "$KIT_DIR/agent.sh"
check "update.sh chama o guarda e morre" \
grep -qE '^recusar_projeto_de_outra_arvore .*\|\| die ' "$KIT_DIR/update.sh"
# O guarda do agent.sh precisa vir ANTES do POST que anuncia a versão: uma cópia
# que não é dona anunciaria a versão da árvore DELA, e o app ofereceria
# "Atualizar agora" com base num número que não descreve o que está no ar.
linha_guarda="$(grep -n '^recusar_projeto_de_outra_arvore' "$KIT_DIR/agent.sh" | cut -d: -f1)"
linha_post="$(grep -n '^RESP="\$(post ' "$KIT_DIR/agent.sh" | cut -d: -f1)"
check "no agent.sh o guarda vem ANTES de anunciar a versão" \
test -n "$linha_guarda" -a -n "$linha_post" -a "${linha_guarda:-9999}" -lt "${linha_post:-0}"
printf '\n'
if [ "$FAILS" -gt 0 ]; then printf '✗ %d falha(s)\n' "$FAILS"; exit 1; fi
printf '✓ guarda do dono do projeto: tudo verde\n'
+69
View File
@@ -5,6 +5,8 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getAdapter } from '@/lib/channels';
import { DETALHE_CREDENCIAL_RECUSADA } from '@/lib/channels/health';
import { statusHttpDoErroWaha } from '@/lib/channels/adapters/waha';
/** A organização atravessa o seam desde a issue #236. */
const ORG = "00000000-0000-4000-8000-000000000236";
@@ -145,4 +147,71 @@ describe('adapter WAHA', () => {
convert: true,
});
});
/**
* ─── O que estes casos custaram ────────────────────────────────────────────
*
* Numa VPS de produção, uma segunda cópia do repo recriou o contêiner do WAHA
* com a chave do `.env` DELA. A partir dali toda chamada respondia 401. O
* `checkHealth` jogava isso no ramo genérico, a Central dizia "não foi
* possível verificar a conexão" — a frase de um soluço de rede — e ficou
* assim por TRÊS DIAS, com o WhatsApp inteiro parado.
*/
describe('checkHealth traduz o erro do transporte', () => {
/** Sobe o WAHA e faz `fetch` responder um HTTP cru (o client lança daí). */
function stubWahaHttp(status: number, body = '{}') {
vi.stubEnv('WAHA_API_BASE_URL', WAHA_BASE);
vi.stubEnv('WAHA_API_KEY', 'hash123');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status })));
}
it('401 é credencial recusada, não "não deu para perguntar"', async () => {
stubWahaHttp(401, '{"message":"Unauthorized","statusCode":401}');
const h = await getAdapter('waha').checkHealth!({ organizationId: ORG, sessionRef: 'org_x' });
expect(h.detail).toBe(DETALHE_CREDENCIAL_RECUSADA);
// Segue sem afirmar o estado da SESSÃO: o que se sabe é que o acesso ao
// transporte foi negado, não que este número caiu.
expect(h.status).toBeNull();
});
it('403 idem — chave existe e não vale', async () => {
stubWahaHttp(403);
const h = await getAdapter('waha').checkHealth!({ organizationId: ORG, sessionRef: 'org_x' });
expect(h.detail).toBe(DETALHE_CREDENCIAL_RECUSADA);
});
it('404 é sessão parada — o único estado que dá para afirmar por erro', async () => {
stubWahaHttp(404);
const h = await getAdapter('waha').checkHealth!({ organizationId: ORG, sessionRef: 'org_x' });
expect(h).toEqual({ reachable: true, status: 'STOPPED', detail: null });
});
it('500 fica em "não sei" — inventar estado é o que ensina a ignorar o aviso', async () => {
stubWahaHttp(500);
const h = await getAdapter('waha').checkHealth!({ organizationId: ORG, sessionRef: 'org_x' });
expect(h.reachable).toBe(false);
expect(h.status).toBeNull();
expect(h.detail).not.toBe(DETALHE_CREDENCIAL_RECUSADA);
});
it('lê o status do PREFIXO, nas duas formas que o client lança', () => {
// Duas formas porque `lib/waha/client.ts` tem duas: `getSessionQr` lança
// `waha_<status>` seco, e create/start/stop/logout/delete anexam o CORPO
// da resposta. Só a primeira chega ao checkHealth hoje — mas a função é
// compartilhada, e é o corpo que torna a leitura por `includes` ambígua.
expect(statusHttpDoErroWaha('waha_401')).toBe(401);
expect(statusHttpDoErroWaha('waha_create_401: {"message":"Unauthorized"}')).toBe(401);
expect(statusHttpDoErroWaha('waha_404')).toBe(404);
// O caso que `includes("404")` erra: o 404 está no CORPO de um 500. Ler o
// prefixo devolve o status de verdade; varrer a string inteira daria 404
// e transformaria um transporte quebrado em "essa sessão está parada".
expect(statusHttpDoErroWaha('waha_stop_500: {"detail":"upstream 404 not found"}')).toBe(500);
// Nada que não venha do client vira status — inclusive texto que CONTÉM
// um número de três dígitos.
expect(statusHttpDoErroWaha('fetch failed 401')).toBeNull();
expect(statusHttpDoErroWaha('erro_desconhecido')).toBeNull();
});
});
});
+26
View File
@@ -21,6 +21,7 @@ import { beforeEach, describe, expect, it } from "vitest";
* filtrar por organização parece certo até haver dois números ligados.
*/
import {
DETALHE_CREDENCIAL_RECUSADA,
STATUS_QUE_AVISAM,
avisoDaConexao,
sincronizarSaudeDaConexao,
@@ -74,6 +75,31 @@ describe("quando avisar", () => {
expect(a?.episodio).toBe("UNREACHABLE");
});
it("credencial RECUSADA é crítica, e diz que o QR não resolve", () => {
// O irmão do caso acima, e o que ele custou: os dois entram por
// `reachable: false`, mas pedem ações opostas. Numa VPS real a chave do
// WAHA foi trocada por uma segunda cópia do repo; tudo parou; e por TRÊS
// DIAS a Central mostrou só o `warn` de "não foi possível verificar" — a
// frase de uma oscilação passageira. Quem lê "conexão caída" corre atrás do
// QR, e o QR não conserta chave errada.
const a = avisoDaConexao(
{ reachable: false, status: null, detail: DETALHE_CREDENCIAL_RECUSADA },
"Vendas",
);
expect(a?.severity).toBe("critical");
expect(a?.episodio).toBe("CREDENCIAL_RECUSADA");
expect(a?.title).toContain("Vendas");
expect(a?.body).toMatch(/QR não resolve/i);
// Episódio PRÓPRIO: se dividisse "UNREACHABLE" com o caso acima, a troca de
// um pelo outro não abriria aviso nenhum — o dedup por episódio veria o
// mesmo valor e ficaria calado justamente na piora.
const oscilacao = avisoDaConexao(
{ reachable: false, status: null, detail: "ECONNREFUSED" },
"Vendas",
);
expect(a?.episodio).not.toBe(oscilacao?.episodio);
});
it("estado desconhecido não vira aviso — o vocabulário é do transporte", () => {
expect(avisoDaConexao({ reachable: true, status: "ALGO_NOVO", detail: null }, "V")).toBeNull();
});
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { numeroObservadoDaSessao } from "@/lib/channels/numero-observado";
/**
* ─── O defeito, medido numa instalação real ────────────────────────────────
*
* A rota de saúde gravava o número só quando a coluna estava VAZIA
* (`if (jid && !phoneNumber)`). O primeiro pareamento gravava; dali em diante o
* valor era imutável. Re-parear com outro aparelho — o que o dono faz toda vez
* que o WhatsApp cai — deixava o banco mentindo para sempre.
*
* O que se mediu: a conexão de produção atendia `551148633324`, o banco dizia
* `553198966398` (um pareamento anterior, de OUTRA organização), e os 23 avisos
* abertos na Central nomeavam o número errado. O aviso existe para responder
* "QUAL conexão caiu?"; com o dado errado ele manda pegar o celular errado.
*/
const LIA = "551148633324";
const ANTIGO = "553198966398";
describe("qual número o transporte está mesmo atendendo", () => {
it("corrige o número quando o aparelho muda — o defeito medido", () => {
expect(
numeroObservadoDaSessao({
jid: `${LIA}@c.us`,
statusAoVivo: "WORKING",
gravado: ANTIGO,
}),
).toBe(LIA);
});
it("preenche quando ainda não havia nada — o caso que já funcionava", () => {
expect(
numeroObservadoDaSessao({ jid: `${LIA}@c.us`, statusAoVivo: "WORKING", gravado: null }),
).toBe(LIA);
});
it("FORA de WORKING não grava: o `me` é o do último pareamento que vingou", () => {
// Medido no mesmo dia: com DUAS sessões em FAILED, a API do WAHA devolvia o
// MESMO `me` para as duas — o de uma delas. Gravar ali trocaria um número
// errado por outro, e ainda poderia bater na trava de número único do banco.
for (const status of ["FAILED", "STOPPED", "STARTING", "SCAN_QR_CODE"]) {
expect(
numeroObservadoDaSessao({ jid: `${ANTIGO}@c.us`, statusAoVivo: status, gravado: LIA }),
).toBe(LIA);
}
});
it("status ausente também não grava — sem observação não há o que afirmar", () => {
expect(
numeroObservadoDaSessao({ jid: `${ANTIGO}@c.us`, statusAoVivo: null, gravado: LIA }),
).toBe(LIA);
});
it("sem jid mantém o que está gravado", () => {
expect(
numeroObservadoDaSessao({ jid: null, statusAoVivo: "WORKING", gravado: LIA }),
).toBe(LIA);
});
it("jid sem parte local não APAGA o número que a tela já mostrava", () => {
expect(
numeroObservadoDaSessao({ jid: "@c.us", statusAoVivo: "WORKING", gravado: LIA }),
).toBe(LIA);
});
it("aceita o status em minúsculas — o vocabulário vem do transporte", () => {
expect(
numeroObservadoDaSessao({ jid: `${LIA}@c.us`, statusAoVivo: "working", gravado: ANTIGO }),
).toBe(LIA);
});
});
describe("o call site — guardar a função não basta se a rota não a usa", () => {
it("a rota de saúde da conexão chama a função, e não grava o jid na mão", async () => {
const { readFileSync } = await import("node:fs");
const fonte = readFileSync("app/api/v1/channel-sessions/[id]/route.ts", "utf-8");
expect(fonte).toContain("numeroObservadoDaSessao({");
// A forma antiga, que é o defeito: preencher só quando está vazio.
expect(fonte).not.toMatch(/if\s*\(jid\s*&&\s*!phoneNumber\)/);
});
});