feat(EPIC-13): chave editável e recusa do DELETE que ensina a repontar

A tela IA › Chaves de acesso à IA era um beco sem saída: só havia revalidar
e excluir, e a exclusão é bloqueada pela FK `ai_agent_versions.credential_id`
(ON DELETE RESTRICT) no instante em que qualquer versão usa a chave — inclusive
rascunho. A mensagem de erro mandava "remover as versões", instrução que apaga
o agente e que as FKs de `ai_agent_runs` e `ai_reply_drafts` nem deixam seguir.

Três correções:

1. PATCH /api/v1/ai/credentials/:id (admin) rotaciona a chave e/ou o rótulo NO
   LUGAR, reusando a cifragem AES-GCM de `guardarCredencial` (nada em claro,
   nada logado). A resposta sai da view segura; a validação nova segue o mesmo
   caminho em segundo plano do POST. Na tela, o card ganha o botão de editar
   (RotateCredentialDialog), habilitado mesmo com a chave em uso.

2. A recusa do DELETE agora conta quantas versões usam a chave, nomeia agentes
   e versões, devolve isso em `details` e ensina o caminho certo: repontar cada
   versão para outra credencial — deixando claro que apagar as versões
   destruiria o agente.

3. O contador "Em uso por" passa a contar TODA versão que referencia a
   credencial (draft, superseded, publicada, de agente arquivado), que é
   exatamente o que a FK enforça. Antes contava só a publicada e mostrava 0
   numa chave que o banco recusava excluir.

Audit `ai.credential_updated` na rotação; ES adicionado para as frases novas.
This commit is contained in:
Opp4System
2026-09-17 18:29:16 -03:00
committed by opp4s
parent c21e0e3ee8
commit 28a3e4c434
13 changed files with 1177 additions and 69 deletions
@@ -0,0 +1,23 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Chave de IA editável — dá para trocar sem excluir e recriar
---
Na tela IA › Chaves de acesso à IA, cada chave agora tem um botão de editar. Por ele você
troca a chave (e o nome) sem apagar e recriar: os agentes continuam ligados nela e, no próximo
atendimento, já usam a chave nova. Antes só existiam revalidar e excluir, e excluir era um beco
sem saída quando algum agente usava a chave.
Quem tentava o caminho antigo — excluir e recriar — batia numa recusa que ainda mandava "remover
as versões antes". Isso era duplo engano: remover a versão apaga o agente e o histórico dele, e
a própria instrução não tinha como ser seguida, porque a versão está presa por outros dois
vínculos. A recusa agora diz quantas versões usam aquela chave, nomeia os agentes e as versões,
e ensina o caminho que existe: apontar cada versão para outra chave. Para girar uma chave que
está em uso, o botão de editar resolve em um passo.
O número "Em uso por" de cada chave passou a contar todas as versões que apontam para ela —
inclusive rascunhos e versões antigas —, que é exatamente o que impede a exclusão. Antes ele
contava só a versão publicada e podia mostrar zero numa chave que o sistema não deixava excluir.
Nada a fazer na instalação: nenhuma chave existente é tocada e nenhum dado é convertido.
+174 -16
View File
@@ -1,24 +1,171 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* PATCH /api/v1/ai/credentials/:id (admin) — rotaciona a chave NO LUGAR
* DELETE /api/v1/ai/credentials/:id (admin)
*
* Bloqueia se a credential é referenciada por uma `ai_agent_versions` que é a
* `published_version_id` de algum agent não-arquivado da org.
* Caso contrário, deleta. A FK ON DELETE RESTRICT é a última linha de defesa
* (drafts não-publicadas também referenciam — preferimos erro 409 amigável).
* ─── Por que o PATCH existe ─────────────────────────────────────────────────
*
* A credencial é referenciada por `ai_agent_versions.credential_id` com FK
* `ON DELETE RESTRICT`. Enquanto a única saída para trocar uma chave era
* "excluir e recriar", o operador de um agente publicado ficava num beco: o
* DELETE recusa, e a mensagem antiga mandava remover versões — o que apaga o
* agente. O PATCH troca a chave da MESMA credencial (o id não muda), então o
* vínculo das versões continua válido e o próximo turno já usa a chave nova.
*
* ─── E por que o DELETE fala de REPONTAR, não de remover ────────────────────
*
* Quando a exclusão é bloqueada, a resposta conta quantas versões usam a chave
* e quais agentes — e ensina o caminho que existe: apontar a versão para outra
* credencial (o `AgentForm` já troca o `credential_id`). As versões estão presas
* por mais duas FKs (`ai_agent_runs.agent_version_id` RESTRICT e
* `ai_reply_drafts.agent_version_id` NO ACTION); "remover as versões" era uma
* instrução impossível de seguir que destruiria o agente se fosse seguida.
*/
import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { type Provider } from "@/lib/ai/provider-validators";
import { rotacionarCredencial } from "@/lib/ai/credenciais/guardar";
import {
versoesQueBloqueiam,
type VersaoQueBloqueia,
type VersaoVinculada,
} from "@/lib/ai/credenciais/uso";
import { createAdminClient } from "@/lib/supabase/admin";
import { contarUsoPublicado, type VersaoVinculada } from "@/lib/ai/credenciais/uso";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const SAFE_COLUMNS =
"id, organization_id, provider, label, api_key_last4, validated_at, validation_error, models_available, is_active, created_by, created_at, updated_at";
// O mesmo formato do POST: provider NÃO entra — trocar de provedor não é girar a
// chave, é outra credencial (a tela agrupa por provedor). Ao menos um campo.
const patchSchema = z
.object({
api_key: z.string().trim().min(8).max(2048).optional(),
label: z.string().trim().min(1).max(80).optional(),
})
.refine((v) => v.api_key !== undefined || v.label !== undefined, {
message: "Informe a chave nova ou o rótulo.",
});
/** Quantos nomes cabem na frase antes de virar "e mais N". */
const LIMITE_DE_NOMES = 5;
function nomeDaVersao(v: VersaoQueBloqueia): string {
if (!v.agentName) return `versão ${v.versionNumber ?? "sem número"}`;
return v.versionNumber != null ? `${v.agentName} v${v.versionNumber}` : v.agentName;
}
/**
* A frase da recusa. Ensina o caminho certo (repontar) e diz por que o caminho
* errado (apagar) é proibido, não só "não dá" — o operador precisa saber onde
* ir e o que NÃO fazer.
*/
function descreverBloqueio(versoes: VersaoQueBloqueia[]): string {
const n = versoes.length;
const nomes = versoes.slice(0, LIMITE_DE_NOMES).map(nomeDaVersao);
const restantes = n - nomes.length;
const lista = restantes > 0 ? `${nomes.join(", ")} e mais ${restantes}` : nomes.join(", ");
const sujeito = n === 1 ? "1 versão de agente" : `${n} versões de agente`;
const acao =
n === 1 ? "Aponte essa versão para outra chave" : "Aponte essas versões para outra chave";
const dano =
n === 1
? "apagar a versão destruiria o agente e o histórico dele"
: "apagar as versões destruiria os agentes e o histórico deles";
return `Esta chave está em uso por ${sujeito} (${lista}). ${acao} antes de excluir — ${dano}.`;
}
export async function PATCH(
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
const authz = await requireRole("admin", { requestId, resource: "ai_credentials" });
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
const { user: authUser, org: activeOrg } = authz;
let rawBody: unknown;
try {
rawBody = await req.json();
} catch {
return fail("invalid_request", t("Body JSON inválido."), 400, { requestId });
}
const parsed = patchSchema.safeParse(rawBody);
if (!parsed.success) {
return fail("validation_failed", t("Campos inválidos."), 422, {
requestId,
details: parsed.error.flatten(),
});
}
const input = parsed.data;
const admin = createAdminClient();
// A org vem do cookie/role (`requireRole`), nunca do body — e o filtro no
// fetch confirma que o id pertence a ela antes de qualquer escrita.
const { data: cred, error: fetchErr } = await admin
.from("ai_provider_credentials")
.select("id, organization_id, provider, label, api_key_last4")
.eq("id", id)
.maybeSingle();
if (fetchErr) {
return fail("internal_error", "Erro ao consultar credential.", 500, { requestId });
}
if (!cred || cred.organization_id !== activeOrg.orgId) {
return fail("not_found", t("Credential não encontrada."), 404, { requestId });
}
const resultado = await rotacionarCredencial({
admin,
orgId: activeOrg.orgId,
userId: authUser.id,
credentialId: id,
provider: cred.provider as Provider,
...(input.api_key !== undefined ? { apiKey: input.api_key } : {}),
...(input.label !== undefined ? { label: input.label } : {}),
requestId,
});
if (!resultado.ok) {
if (resultado.motivo === "label_em_uso") {
return fail(
"label_already_used",
t("Já existe uma credential com este label e provider."),
409,
{ requestId },
);
}
if (resultado.motivo === "nao_encontrada") {
return fail("not_found", t("Credential não encontrada."), 404, { requestId });
}
return fail("internal_error", "Erro ao atualizar credential.", 500, { requestId });
}
// Como no POST: a resposta sai da view segura, que nunca expõe campo cifrado.
const { data: updated } = await admin
.from("ai_provider_credentials_safe")
.select(SAFE_COLUMNS)
.eq("id", id)
.single();
return ok(updated, { requestId });
}
export async function DELETE(
_req: NextRequest,
ctx: { params: Promise<{ id: string }> },
@@ -49,11 +196,14 @@ export async function DELETE(
return fail("not_found", t("Credential não encontrada."), 404, { requestId });
}
// Está referenciada por alguma versão que é published_version_id de agent ativo?
// O que a FK `ON DELETE RESTRICT` enxerga: QUALQUER versão que aponte para a
// credencial — rascunho, superseded, de agente arquivado. A régua antiga
// contava só a publicada e deixava o operador com "Em uso por 0" numa chave
// que o banco recusava excluir.
const { data: linked, error: linkErr } = await admin
.from("ai_agent_versions")
.select(
"id, credential_id, ai_agents!ai_agent_versions_agent_id_fkey!inner(archived_at, published_version_id)",
"id, credential_id, version_number, status, ai_agents!ai_agent_versions_agent_id_fkey!inner(id, name, archived_at, published_version_id)",
)
.eq("credential_id", id)
.eq("organization_id", activeOrg.orgId);
@@ -62,15 +212,21 @@ export async function DELETE(
return fail("internal_error", "Erro ao verificar uso da credential.", 500, { requestId });
}
const inUse = (contarUsoPublicado((linked ?? []) as unknown as VersaoVinculada[])[id] ?? 0) > 0;
const bloqueiam = versoesQueBloqueiam((linked ?? []) as unknown as VersaoVinculada[])[id] ?? [];
if (inUse) {
return fail(
"credential_in_use",
t("Credential é usada por uma versão publicada de agent. Despublique antes de deletar."),
409,
{ requestId },
);
if (bloqueiam.length > 0) {
return fail("credential_in_use", t(descreverBloqueio(bloqueiam)), 409, {
requestId,
details: {
count: bloqueiam.length,
versions: bloqueiam.map((v) => ({
version_id: v.versionId,
version_number: v.versionNumber,
status: v.status,
agent_name: v.agentName,
})),
},
});
}
const { error: delErr } = await admin
@@ -81,9 +237,11 @@ export async function DELETE(
if (delErr) {
if (delErr.code === "23503") {
// Corrida: uma versão passou a usar a chave entre a checagem e o delete.
// Não manda mais "remover versões" — repete o caminho certo.
return fail(
"credential_in_use",
t("Credential referenciada (FK ON DELETE RESTRICT). Remova as versões antes."),
t("Uma versão de agente passou a usar esta chave agora. Recarregue a página e tente de novo."),
409,
{ requestId },
);
@@ -25,7 +25,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ArrowsClockwise, Trash } from "@/lib/ui/icons";
import { ArrowsClockwise, PencilSimple, Trash } from "@/lib/ui/icons";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import {
@@ -37,6 +37,7 @@ import {
import { useT } from "@/hooks/i18n/useT";
import { PROVEDORES } from "@/lib/ai/pontos/provedores";
import { descreverErroDeValidacao } from "@/lib/ai/credenciais/erro-de-validacao";
import { RotateCredentialDialog } from "./RotateCredentialDialog";
interface Props {
credential: CredentialRow;
@@ -65,6 +66,7 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) {
const router = useRouter();
const qc = useQueryClient();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const status = credentialStatus(credential);
@@ -170,6 +172,18 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) {
{canWrite && (
<div className="flex items-center justify-end gap-1 pt-1">
{/* Rotacionar é o caminho que NÃO passa pela exclusão — e por isso
fica habilitado mesmo com a chave em uso. É por aqui que o
operador de um agente publicado troca a chave. */}
<Button
variant="ghost"
size="icon"
aria-label={t("Editar credencial")}
disabled={isPending}
onClick={() => setEditOpen(true)}
>
<PencilSimple size={14} aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
@@ -186,9 +200,7 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) {
<span tabIndex={0}>{deleteButton}</span>
</TooltipTrigger>
<TooltipContent>
{t("Em uso por")} {usageCount} {t("agente")}
{usageCount === 1 ? "" : "s"} {t("publicado")}
{usageCount === 1 ? "" : "s"}.
{t("Em uso por")} {usageCount} {t("versão(ões) de agente")}. {t("Para trocar a chave, use editar; para excluir, aponte essas versões para outra chave.")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -198,6 +210,12 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) {
</div>
)}
<RotateCredentialDialog
open={editOpen}
onOpenChange={setEditOpen}
credential={credential}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -205,7 +223,11 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) {
{t("Remover credencial")} &ldquo;{credential.label}&rdquo;?
</AlertDialogTitle>
<AlertDialogDescription>
{t("Agents que usam esta credencial vão falhar ao executar. Esta ação não pode ser desfeita.")}
{/* O diálogo só abre com `usageCount === 0` (a chave em uso tem o
botão desabilitado), então não há agente a avisar — a frase
antiga ("agents vão falhar") descrevia um caso que não chega
aqui. O que sobra é o irreversível. */}
{t("Esta ação não pode ser desfeita.")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { z } from "zod";
import { refreshCredentialsView } from "../_actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import {
credentialsListQueryKey,
type CredentialRow,
} from "@/hooks/ai/useCredentials";
import { PROVEDORES } from "@/lib/ai/pontos/provedores";
import { descreverErroDeValidacao } from "@/lib/ai/credenciais/erro-de-validacao";
import { useT } from "@/hooks/i18n/useT";
/**
* A chave da tela NUNCA volta do servidor — só os últimos 4 dígitos. Por isso
* aqui a chave nova é opcional: em branco significa "mantenha a atual", que é o
* que permite renomear sem girar. Deixar o campo vazio não pode virar uma chave
* vazia no banco; quem trata isso é o corpo enviado, montado campo a campo.
*/
const formSchema = z.object({
label: z.string().trim().min(1, "Obrigatório").max(80),
api_key: z.string().trim().max(2048).optional(),
});
type FormValues = z.infer<typeof formSchema>;
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
credential: CredentialRow;
}
export function RotateCredentialDialog({ open, onOpenChange, credential }: Props) {
const t = useT();
const router = useRouter();
const qc = useQueryClient();
const [label, setLabel] = useState(credential.label);
const [apiKey, setApiKey] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
const provedor = PROVEDORES.find((p) => p.id === credential.provider) ?? PROVEDORES[0];
const chaveMudou = apiKey.trim() !== "";
const rotuloMudou = label.trim() !== credential.label;
const mudou = chaveMudou || rotuloMudou;
const reset = () => {
setLabel(credential.label);
setApiKey("");
setErrors({});
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
const chave = apiKey.trim();
// A chave só é obrigatória a partir de 8 caracteres QUANDO existe uma nova;
// em branco é "manter a atual", e é isso que impede renomear de girar a chave.
if (chave !== "" && chave.length < 8) {
setErrors({ api_key: t("API key muito curta") });
return;
}
const parsed = formSchema.safeParse({ label, api_key: chave || undefined });
if (!parsed.success) {
const flat = parsed.error.flatten().fieldErrors;
setErrors({
label: flat.label?.[0] ? t(flat.label[0]) : undefined,
api_key: flat.api_key?.[0] ? t(flat.api_key[0]) : undefined,
});
return;
}
if (!mudou) return;
setSubmitting(true);
const validandoToast = chaveMudou ? toast.loading(t("Chave salva. Validando…")) : null;
try {
const body: { label?: string; api_key?: string } = {};
if (rotuloMudou) body.label = label.trim();
if (chaveMudou) body.api_key = chave;
await apiClient.patch(`/api/v1/ai/credentials/${credential.id}`, body);
if (validandoToast) toast.dismiss(validandoToast);
toast.success(
chaveMudou
? t("Chave trocada. A validação segue em segundo plano.")
: t("Credencial atualizada."),
);
reset();
onOpenChange(false);
await qc.invalidateQueries({ queryKey: credentialsListQueryKey });
if (chaveMudou) {
// Mesma janela do cadastro: o resultado da validação chega depois da
// resposta, então a tela busca uma vez para refletir no card.
setTimeout(async () => {
await qc.invalidateQueries({ queryKey: credentialsListQueryKey });
const fresh = qc.getQueryData<CredentialRow[]>(credentialsListQueryKey);
const atual = fresh?.find((c) => c.id === credential.id);
if (atual?.models_available != null) {
toast.success(
`${t("Validada")} — ${atual.models_available.length} ${t("modelos disponíveis.")}`,
);
} else if (atual?.validation_error) {
const erro = descreverErroDeValidacao(atual.validation_error);
toast.error(
erro.generico
? `${t("Falha na validação")} (${atual.validation_error}).`
: t(erro.frase),
);
}
}, 3000);
}
await refreshCredentialsView();
router.refresh();
} catch (err) {
if (validandoToast) toast.dismiss(validandoToast);
showApiError(err);
} finally {
setSubmitting(false);
}
};
const onOpenChangeWrapped = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
return (
<Dialog open={open} onOpenChange={onOpenChangeWrapped}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("Editar credencial")}</DialogTitle>
<DialogDescription>
{t(
"Trocar a chave aqui mantém os agentes ligados nela: no próximo atendimento eles já usam a chave nova. Deixe a chave em branco para mudar só o nome.",
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cred-edit-label">{t("Nome")}</Label>
<Input
id="cred-edit-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("Ex: Produção")}
maxLength={80}
required
/>
{errors.label && <p className="text-xs text-destructive">{errors.label}</p>}
</div>
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<Label htmlFor="cred-edit-key">{t("Nova chave (opcional)")}</Label>
<a
className="text-xs underline underline-offset-4"
href={provedor.ondePegarAChave}
target="_blank"
rel="noreferrer"
>
{t("Pegar chave em")} {provedor.rotulo}
</a>
</div>
<Input
id="cred-edit-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provedor.prefixoDaChave}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
{t("Em branco mantém a chave atual")} (…{credential.api_key_last4 ?? "????"}).
</p>
{errors.api_key && <p className="text-xs text-destructive">{errors.api_key}</p>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChangeWrapped(false)}
disabled={submitting}
>
{t("Cancelar")}
</Button>
<Button type="submit" disabled={submitting || !mudou}>
{submitting ? t("Salvando…") : t("Salvar")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+6 -6
View File
@@ -5,7 +5,7 @@ import { ROLE_RANK } from "@/lib/auth/types";
import { createClient } from "@/lib/supabase/server";
import type { CredentialRow } from "@/hooks/ai/useCredentials";
import { traduzir } from "@/lib/i18n/dicionario";
import { contarUsoPublicado, type VersaoVinculada } from "@/lib/ai/credenciais/uso";
import { contarUsoQueBloqueia, type VersaoVinculada } from "@/lib/ai/credenciais/uso";
import { CredentialsList } from "./_components/CredentialsList";
export const dynamic = "force-dynamic";
@@ -32,17 +32,17 @@ export default async function CredentialsPage() {
const credentials = (data ?? []) as unknown as CredentialRow[];
const canWrite = ROLE_RANK[activeOrg.role] >= ROLE_RANK.admin;
// Mesma regra do DELETE: só conta a versão PUBLICADA de agente não arquivado.
// Mesma regra do DELETE — e a mesma da FK `ON DELETE RESTRICT`: TODA versão
// que aponta para a credencial trava a exclusão, não só a publicada. O número
// que a tela mostra é o que explica o bloqueio (ver `lib/ai/credenciais/uso.ts`).
let usageMap: Record<string, number> = {};
if (credentials.length > 0) {
const { data: linked } = await supabase
.from("ai_agent_versions")
.select(
"id, credential_id, ai_agents!ai_agent_versions_agent_id_fkey!inner(archived_at, published_version_id)",
)
.select("id, credential_id, version_number, status")
.eq("organization_id", activeOrg.orgId)
.in("credential_id", credentials.map((c) => c.id));
usageMap = contarUsoPublicado((linked ?? []) as unknown as VersaoVinculada[]);
usageMap = contarUsoQueBloqueia((linked ?? []) as unknown as VersaoVinculada[]);
}
return (
+9
View File
@@ -118,6 +118,15 @@ const COPY: Record<string, { variant: Variant; msg?: string }> = {
// qualquer genérico daqui alcança.
lost_reason_required: { variant: "warning" },
lost_reason_invalid: { variant: "warning" },
// ---- Chaves de IA em uso ----
//
// Recusa ROTINEIRA, não quebra: a chave está ligada a versões de agente e o
// caminho certo é repontar (ou editar para girar). A ROTA manda a frase
// específica — quantas versões, quais agentes —, então aqui só se declara o
// TOM. Sem `msg` de propósito: a lista de agentes é contexto que nenhuma
// frase genérica alcança.
credential_in_use: { variant: "warning" },
};
/**
+134 -9
View File
@@ -35,6 +35,19 @@ export type ResultadoDeGuardar =
detalhe?: string;
};
export type ResultadoDeRotacionar =
| { ok: true; id: string; last4: string | null; trocouChave: boolean }
| {
ok: false;
/**
* `nao_encontrada` cobre a corrida em que a credencial sumiu entre a
* leitura da rota e o update. `label_em_uso` é escolha do usuário; o
* resto é falha nossa — mesma divisão de `guardarCredencial`.
*/
motivo: "cifragem" | "label_em_uso" | "nao_encontrada" | "banco";
detalhe?: string;
};
export interface PedidoDeGuardar {
admin: ReturnType<typeof createAdminClient>;
orgId: string;
@@ -46,10 +59,27 @@ export interface PedidoDeGuardar {
requestId?: string;
}
/**
* As colunas cifradas de uma chave nova. Existe para o cadastro e a rotação
* usarem a MESMA cifragem: uma segunda chamada a `encryptKey` com parâmetros
* diferentes (ou, pior, um caminho que gravasse plaintext) divergiria em
* silêncio, e o ajuste que divergisse seria o de segurança.
*/
function colunasCifradas(apiKey: string) {
const encrypted = encryptKey(apiKey);
return {
api_key_encrypted: bufToBytea(encrypted.ciphertext),
api_key_iv: bufToBytea(encrypted.iv),
api_key_tag: bufToBytea(encrypted.tag),
api_key_last4: encrypted.last4,
last4: encrypted.last4,
};
}
export async function guardarCredencial(p: PedidoDeGuardar): Promise<ResultadoDeGuardar> {
let encrypted;
let cifrada: ReturnType<typeof colunasCifradas>;
try {
encrypted = encryptKey(p.apiKey);
cifrada = colunasCifradas(p.apiKey);
} catch (err) {
// Sem `console.error` com a chave por perto: o que interessa é que falhou.
return { ok: false, motivo: "cifragem", detalhe: err instanceof Error ? err.message : undefined };
@@ -61,10 +91,10 @@ export async function guardarCredencial(p: PedidoDeGuardar): Promise<ResultadoDe
organization_id: p.orgId,
provider: p.provider,
label: p.label,
api_key_encrypted: bufToBytea(encrypted.ciphertext),
api_key_iv: bufToBytea(encrypted.iv),
api_key_tag: bufToBytea(encrypted.tag),
api_key_last4: encrypted.last4,
api_key_encrypted: cifrada.api_key_encrypted,
api_key_iv: cifrada.api_key_iv,
api_key_tag: cifrada.api_key_tag,
api_key_last4: cifrada.api_key_last4,
is_active: true,
created_by: p.userId,
})
@@ -85,7 +115,7 @@ export async function guardarCredencial(p: PedidoDeGuardar): Promise<ResultadoDe
resourceType: "ai_provider_credential",
resourceId: id,
...(p.requestId ? { requestId: p.requestId } : {}),
metadata: { provider: p.provider, label: p.label, last4: encrypted.last4 },
metadata: { provider: p.provider, label: p.label, last4: cifrada.last4 },
});
// Fire-and-forget: o plaintext vive até o callback resolver, e a resposta não
@@ -94,7 +124,102 @@ export async function guardarCredencial(p: PedidoDeGuardar): Promise<ResultadoDe
// desfeita.
void validarEmSegundoPlano(p.admin, id, p.orgId, p.provider, p.apiKey);
return { ok: true, id, last4: encrypted.last4 };
return { ok: true, id, last4: cifrada.last4 };
}
/**
* ROTACIONAR A CHAVE SEM TROCAR DE CREDENCIAL.
*
* É o caminho que faltava: a exclusão é bloqueada pela FK enquanto qualquer
* versão de agente apontar para a credencial, e "excluir e recriar" era a única
* saída oferecida — beco sem fundo no instante em que o agente está publicado.
* Aqui a mesma credencial ganha chave nova (e/ou rótulo novo): o vínculo das
* versões continua apontando para ela, e no próximo turno elas já usam a chave
* nova.
*
* Sem `apiKey` NÃO se toca na chave: renomear não pode revalidar nada, porque
* não há chave nova para o provedor testar. Sem `label` o nome fica; quem decide
* o que mudou é o chamador, e o que faltar simplesmente não entra no patch.
*/
export interface PedidoDeRotacionar {
admin: ReturnType<typeof createAdminClient>;
orgId: string;
userId: string;
credentialId: string;
provider: Provider;
/** Presente = trocar a chave. Ausente = manter a atual. Plaintext: nunca logado. */
apiKey?: string;
/** Presente = trocar o rótulo. Ausente = manter. */
label?: string;
requestId?: string;
}
export async function rotacionarCredencial(
p: PedidoDeRotacionar,
): Promise<ResultadoDeRotacionar> {
const patch: Record<string, unknown> = {};
let last4: string | null = null;
if (p.apiKey !== undefined) {
let cifrada: ReturnType<typeof colunasCifradas>;
try {
cifrada = colunasCifradas(p.apiKey);
} catch (err) {
return { ok: false, motivo: "cifragem", detalhe: err instanceof Error ? err.message : undefined };
}
last4 = cifrada.last4;
patch.api_key_encrypted = cifrada.api_key_encrypted;
patch.api_key_iv = cifrada.api_key_iv;
patch.api_key_tag = cifrada.api_key_tag;
patch.api_key_last4 = cifrada.api_key_last4;
// Chave nova = veredito antigo deixa de valer. Sem zerar, a tela mostraria
// "Validada" (e os modelos da chave anterior) sobre uma chave que ninguém
// testou ainda — mentira com cara de confirmação.
patch.validated_at = null;
patch.validation_error = null;
patch.models_available = null;
}
if (p.label !== undefined) patch.label = p.label;
const { data: updated, error } = await p.admin
.from("ai_provider_credentials")
.update(patch)
.eq("id", p.credentialId)
.eq("organization_id", p.orgId)
.select("id")
.maybeSingle();
if (error) {
if (error.code === "23505") return { ok: false, motivo: "label_em_uso" };
return { ok: false, motivo: "banco", detalhe: error.message };
}
if (!updated) return { ok: false, motivo: "nao_encontrada" };
await audit({
action: "ai.credential_updated",
actorUserId: p.userId,
organizationId: p.orgId,
resourceType: "ai_provider_credential",
resourceId: p.credentialId,
...(p.requestId ? { requestId: p.requestId } : {}),
// `last4` (não a chave) e o rótulo: a trilha responde "quando girou e para
// onde", que é a pergunta de auditoria — nunca o segredo.
metadata: {
provider: p.provider,
label: p.label ?? null,
last4,
trocou_chave: p.apiKey !== undefined,
},
});
if (p.apiKey !== undefined) {
// Mesmo contrato do POST: validar é um segundo momento, que pode falhar por
// rede sem desfazer a rotação; a resposta não espera o provedor.
void validarEmSegundoPlano(p.admin, p.credentialId, p.orgId, p.provider, p.apiKey);
}
return { ok: true, id: p.credentialId, last4, trocouChave: p.apiKey !== undefined };
}
async function validarEmSegundoPlano(
@@ -115,7 +240,7 @@ async function validarEmSegundoPlano(
validation_error: null,
models_available: r.models,
}
: { validated_at: null, validation_error: r.error },
: { validated_at: null, validation_error: r.error, models_available: null },
)
.eq("id", credentialId)
.eq("organization_id", organizationId);
+79 -22
View File
@@ -1,43 +1,100 @@
/**
* A tela e o DELETE tinham duas regras de "em uso" — a tela contava qualquer
* versão de agente ativo, o DELETE só a publicada. Botão desabilitado com
* tooltip "em uso" quando a API deixaria excluir. Uma regra, dois consumidores.
* O número que a tela mostra tem de ser o MESMO que trava a exclusão.
*
* Antes esta régua contava só a versão PUBLICADA de agente não arquivado — e o
* operador via "Em uso por 0" numa credencial que o banco recusava excluir, por
* causa das versões em rascunho e superseded que a FK `ON DELETE RESTRICT`
* também enxerga. Contar publicadas responde "quantos agentes usam de verdade";
* a pergunta do botão de excluir é "o banco me deixa apagar isto", e a resposta
* é o total de `ai_agent_versions` que apontam para a credencial.
*/
import { describe, expect, it } from "vitest";
import { contarUsoPublicado, type VersaoVinculada } from "./uso";
import { contarUsoQueBloqueia, versoesQueBloqueiam, type VersaoVinculada } from "./uso";
const linha = (over: Partial<VersaoVinculada> & { publicada?: string | null; arquivado?: boolean }): VersaoVinculada => ({
const linha = (
over: Partial<VersaoVinculada> & {
publicada?: string | null;
arquivado?: boolean;
nome?: string | null;
},
): VersaoVinculada => ({
id: over.id ?? "v1",
credential_id: over.credential_id ?? "c1",
ai_agents: {
archived_at: over.arquivado ? "2026-01-01T00:00:00Z" : null,
published_version_id: over.publicada === undefined ? "v1" : over.publicada,
},
version_number: over.version_number ?? 1,
status: over.status ?? "draft",
ai_agents:
over.ai_agents === undefined
? {
id: "a1",
name: over.nome === undefined ? "Atendimento" : over.nome ?? "",
archived_at: over.arquivado ? "2026-01-01T00:00:00Z" : null,
published_version_id: over.publicada === undefined ? null : over.publicada,
}
: over.ai_agents,
});
describe("contarUsoPublicado", () => {
it("conta só a versão que É a publicada do agente", () => {
expect(contarUsoPublicado([linha({ id: "v1", publicada: "v1" })])).toEqual({ c1: 1 });
describe("contarUsoQueBloqueia", () => {
it("conta a versão em RASCUNHO — é ela que trava a FK, e a régua antiga dizia 0", () => {
expect(contarUsoQueBloqueia([linha({ id: "v1", status: "draft" })])).toEqual({ c1: 1 });
});
it("rascunho (versão não publicada) não conta", () => {
expect(contarUsoPublicado([linha({ id: "v2", publicada: "v1" })])).toEqual({});
it("conta a versão SUPERSEDED — continuou existindo e continuou travando", () => {
expect(contarUsoQueBloqueia([linha({ id: "v1", status: "superseded" })])).toEqual({
c1: 1,
});
});
it("agente arquivado não conta", () => {
expect(contarUsoPublicado([linha({ arquivado: true })])).toEqual({});
it("conta a versão publicada", () => {
expect(
contarUsoQueBloqueia([linha({ id: "v1", status: "published", publicada: "v1" })]),
).toEqual({ c1: 1 });
});
it("agente sem versão publicada não conta", () => {
expect(contarUsoPublicado([linha({ publicada: null })])).toEqual({});
it("conta versão de agente ARQUIVADO — arquivar não apaga versão, e a FK segue travando", () => {
expect(contarUsoQueBloqueia([linha({ id: "v1", arquivado: true })])).toEqual({ c1: 1 });
});
it("soma por credencial e aceita join como array", () => {
const r = contarUsoPublicado([
it("soma por credencial e não inventa chave para quem não tem versão", () => {
const r = contarUsoQueBloqueia([
linha({ id: "v1", credential_id: "c1" }),
{ id: "v9", credential_id: "c1", ai_agents: [{ archived_at: null, published_version_id: "v9" }] },
linha({ id: "v3", credential_id: "c2", publicada: "v3" }),
linha({ id: "v2", credential_id: "c1" }),
linha({ id: "v3", credential_id: "c2" }),
]);
expect(r).toEqual({ c1: 2, c2: 1 });
expect(r.c3).toBeUndefined();
});
});
describe("versoesQueBloqueiam", () => {
it("nomeia agente e versão para a mensagem dizer ONDE ir", () => {
const r = versoesQueBloqueiam([
linha({
id: "v9",
credential_id: "c1",
version_number: 4,
status: "draft",
nome: "Triagem",
}),
]);
expect(r.c1).toEqual([
{ versionId: "v9", versionNumber: 4, status: "draft", agentName: "Triagem" },
]);
});
it("aceita o join como array (o PostgREST varia a cardinalidade)", () => {
const r = versoesQueBloqueiam([
{
id: "v9",
credential_id: "c1",
version_number: 2,
status: "superseded",
ai_agents: [{ id: "a1", name: "SDR", archived_at: null, published_version_id: "v1" }],
},
]);
expect(r.c1?.[0]).toMatchObject({ agentName: "SDR", versionNumber: 2 });
});
it("a credencial sem versão não aparece no mapa (nada a listar)", () => {
expect(versoesQueBloqueiam([])).toEqual({});
});
});
+69 -9
View File
@@ -1,12 +1,29 @@
/**
* A REGRA de "credencial em uso": referenciada pela versão PUBLICADA de um
* agente não arquivado. Rascunho não conta (o operador pode trocar a chave do
* rascunho antes de publicar); arquivado não conta.
* A REGRA de "esta credencial pode ser excluída?" — e o número que a explica.
*
* ─── Por que o número é TODO vínculo, e não só a versão publicada ───────────
*
* A FK `ai_agent_versions.credential_id` é `ON DELETE RESTRICT`: o BANCO recusa
* a exclusão por causa de QUALQUER linha de `ai_agent_versions` que aponte para
* a credencial — rascunho, superseded, arquivada, tanto faz. Enquanto a tela
* contava só a versão PUBLICADA de agente não-arquivado, o operador via "Em uso
* por 0" numa chave que o DELETE recusava, e o botão mentia duas vezes: dizia
* que dava para excluir (o número) e, quando não dava, mandava "remover as
* versões" (instrução que apaga o agente e que as FKs de `ai_agent_runs` e
* `ai_reply_drafts` nem deixam seguir).
*
* O número honesto é, portanto, o mesmo que o banco enxerga: a contagem de
* `ai_agent_versions` que referenciam a credencial. É ele que responde à
* pergunta do botão — "o banco me deixa apagar isto?" —, e é ele que a tela
* mostra. O caminho de saída não é apagar versão: é REPONTAR a versão para
* outra credencial (o `AgentForm` já troca o `credential_id`).
*
* Consumida pela tela (`app/app/ai/credentials/page.tsx`) e pelo
* `DELETE /api/v1/ai/credentials/:id`. Enquanto eram duas cópias, divergiram.
*/
export interface AgenteResumo {
id: string;
name: string;
archived_at: string | null;
published_version_id: string | null;
}
@@ -14,17 +31,60 @@ export interface AgenteResumo {
export interface VersaoVinculada {
id: string;
credential_id: string;
/** O PostgREST devolve objeto ou array conforme a cardinalidade inferida. */
ai_agents: AgenteResumo | AgenteResumo[] | null;
/** Número da versão, para a mensagem dizer qual repontar. */
version_number?: number | null;
/** `draft` | `published` | `superseded` | `archived` — informativo na recusa. */
status?: string | null;
/**
* O join com o agente. Opcional de propósito: CONTAR não precisa dele (só o
* `credential_id` importa), e a tela busca o mínimo — quem precisa do nome é
* a mensagem do DELETE, e é ele que pede o join.
*/
ai_agents?: AgenteResumo | AgenteResumo[] | null;
}
export function contarUsoPublicado(linhas: VersaoVinculada[]): Record<string, number> {
/** O que trava a exclusão, em uma linha por versão — para a mensagem do DELETE. */
export interface VersaoQueBloqueia {
versionId: string;
versionNumber: number | null;
status: string | null;
agentName: string;
}
function agenteDe(linha: VersaoVinculada): AgenteResumo | null {
if (Array.isArray(linha.ai_agents)) return linha.ai_agents[0] ?? null;
return linha.ai_agents ?? null;
}
/**
* Quantas versões de agente referenciam cada credencial. É exatamente o que a
* FK `ON DELETE RESTRICT` considera — nada menos, nada mais.
*/
export function contarUsoQueBloqueia(linhas: VersaoVinculada[]): Record<string, number> {
const mapa: Record<string, number> = {};
for (const linha of linhas) {
const agente = Array.isArray(linha.ai_agents) ? linha.ai_agents[0] : linha.ai_agents;
if (!agente || agente.archived_at) continue;
if (agente.published_version_id !== linha.id) continue;
mapa[linha.credential_id] = (mapa[linha.credential_id] ?? 0) + 1;
}
return mapa;
}
/**
* Quem trava, com nome e versão, para a recusa do DELETE dizer ONDE ir. O
* operador precisa saber qual agente abrir e qual versão repontar — "há N
* versões" sem nome é um beco com outro nome.
*/
export function versoesQueBloqueiam(
linhas: VersaoVinculada[],
): Record<string, VersaoQueBloqueia[]> {
const mapa: Record<string, VersaoQueBloqueia[]> = {};
for (const linha of linhas) {
const agente = agenteDe(linha);
(mapa[linha.credential_id] ??= []).push({
versionId: linha.id,
versionNumber: linha.version_number ?? null,
status: linha.status ?? null,
agentName: agente?.name?.trim() || "",
});
}
return mapa;
}
+5
View File
@@ -585,6 +585,11 @@ export const AUDIT_ACTIONS = [
// Mover um card para OUTRO funil (issue #922) clona o negócio no destino e
// encerra o original: é a escrita que mexe em DOIS funis de uma vez.
"lead.moved_to_pipeline",
// A chave de IA girada NO LUGAR (PATCH /ai/credentials/:id). Distinto de
// `ai.credential_created` e `ai.credential_revalidated`: aqui o id não muda, e
// "quando esta chave foi trocada, e por quem" é a pergunta que só esta linha
// responde — a coluna `updated_at` se move por qualquer motivo.
"ai.credential_updated",
] as const;
/** Um código de auditoria. Derivado de `AUDIT_ACTIONS` — não redigite a lista. */
+19 -2
View File
@@ -8053,8 +8053,25 @@ export const DICIONARIO: Traducoes = {
"Body inválido.": { es: "Body inválido." },
"Cole o conteúdo do material antes de criar.": { es: "Pega el contenido del material antes de crear." },
"Consulta inválida.": { es: "Consulta inválida." },
"Credential referenciada (FK ON DELETE RESTRICT). Remova as versões antes.": { es: "Credential referenciada (FK ON DELETE RESTRICT). Elimina las versiones antes." },
"Credential é usada por uma versão publicada de agent. Despublique antes de deletar.": { es: "La credential es usada por una versión publicada de agent. Despublica antes de eliminar." },
// Tela de credentials — editar/rotacionar a chave (PATCH). O texto da RECUSA
// do DELETE não entra aqui: ele interpola a contagem e os nomes dos agentes,
// então é montado em runtime e não tem chave fixa para traduzir.
"Editar credencial": { es: "Editar credencial" },
"Nova chave (opcional)": { es: "Clave nueva (opcional)" },
"Em branco mantém a chave atual": { es: "En blanco mantiene la clave actual" },
"Chave salva. Validando…": { es: "Clave guardada. Validando…" },
"Chave trocada. A validação segue em segundo plano.": {
es: "Clave cambiada. La validación sigue en segundo plano.",
},
"Credencial atualizada.": { es: "Credencial actualizada." },
"Trocar a chave aqui mantém os agentes ligados nela: no próximo atendimento eles já usam a chave nova. Deixe a chave em branco para mudar só o nome.": {
es: "Cambiar la clave aquí mantiene los agentes conectados a ella: en la próxima atención ya usan la clave nueva. Deja la clave en blanco para cambiar solo el nombre.",
},
"versão(ões) de agente": { es: "versión(es) de agente" },
"Para trocar a chave, use editar; para excluir, aponte essas versões para outra chave.": {
es: "Para cambiar la clave, usa editar; para eliminar, apunta esas versiones a otra clave.",
},
"Esta ação não pode ser desfeita.": { es: "Esta acción no se puede deshacer." },
"Cursor inválido.": { es: "Cursor inválido." },
"Dê um nome ao material (2 a 120 caracteres).": { es: "Ponle un nombre al material (2 a 120 caracteres)." },
"Erro ao ativar a versão da memória.": { es: "Error al activar la versión de la memoria." },
@@ -0,0 +1,191 @@
/**
* A recusa do DELETE tem de ENSINAR o caminho certo.
*
* A mensagem antiga — "Remova as versões antes." — era dupla armadilha: remover
* a versão apaga o agente (e o histórico dele), e a própria instrução é
* impossível de seguir, porque a versão está presa por mais duas FKs
* (`ai_agent_runs.agent_version_id` RESTRICT e `ai_reply_drafts.agent_version_id`
* NO ACTION). O que existe é repontar a versão para outra credencial.
*
* Este teste também cobre a régua honesta: uma versão em RASCUNHO é suficiente
* para o banco recusar a exclusão, e a rota precisa recusar antes disso.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { DELETE } from "@/app/api/v1/ai/credentials/[id]/route";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
import { audit } from "@/lib/audit";
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
vi.mock("@/lib/impersonate/support", () => ({ requireSupportWrite: vi.fn(async () => null) }));
vi.mock("@/lib/audit", () => ({ audit: vi.fn() }));
const org = "11111111-1111-4111-8111-111111111111";
const id = "22222222-2222-4222-8222-222222222222";
type Resposta = { data?: unknown; error?: unknown };
type Fake = { from: (table: string) => unknown };
/** Chain mínimo que cobre select/filter/maybeSingle/single/delete e é thenable. */
function fakeAdmin(config: Record<string, Resposta>): Fake {
return {
from(table: string) {
let op = "select";
const respond = () => config[`${table}:${op}`] ?? config[table] ?? { data: null, error: null };
const chain: Record<string, unknown> = {
select: () => chain,
eq: () => chain,
in: () => chain,
order: () => chain,
limit: () => chain,
update: () => {
op = "update";
return chain;
},
insert: () => {
op = "insert";
return chain;
},
delete: () => {
op = "delete";
return chain;
},
maybeSingle: async () => respond(),
single: async () => respond(),
then: (resolve: (v: unknown) => unknown) => Promise.resolve(respond()).then(resolve),
};
return chain;
},
};
}
const cred = {
id,
organization_id: org,
provider: "anthropic",
label: "Produção",
api_key_last4: "abcd",
};
function versao(over: {
id?: string;
status?: string;
version_number?: number;
nome?: string;
}) {
return {
id: over.id ?? "v1",
credential_id: id,
version_number: over.version_number ?? 1,
status: over.status ?? "draft",
ai_agents: { id: "a1", name: over.nome ?? "Atendimento", archived_at: null, published_version_id: null },
};
}
function invocar() {
return DELETE(new NextRequest(`http://localhost/api/v1/ai/credentials/${id}`, { method: "DELETE" }), {
params: Promise.resolve({ id }),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireRole).mockResolvedValue({
ok: true,
org: { orgId: org, role: "admin", name: "Org" },
user: { id: "actor", idioma: "pt-BR" },
} as Awaited<ReturnType<typeof requireRole>>);
});
describe("DELETE /api/v1/ai/credentials/:id instrui a repontar", () => {
it("rascunho já é suficiente para recusar, e a frase diz o agente e a versão", async () => {
vi.mocked(createAdminClient).mockReturnValue(
fakeAdmin({
"ai_provider_credentials:select": { data: cred, error: null },
"ai_agent_versions:select": {
data: [versao({ id: "v4", version_number: 4, status: "draft", nome: "Triagem" })],
error: null,
},
}) as unknown as ReturnType<typeof createAdminClient>,
);
const res = await invocar();
const body = await res.json();
expect(res.status).toBe(409);
expect(body.error.code).toBe("credential_in_use");
// Ensina o caminho certo, nomeando quem travar.
expect(body.error.message).toContain("1 versão de agente");
expect(body.error.message).toContain("Triagem v4");
expect(body.error.message).toContain("Aponte essa versão para outra chave");
// E deixa claro por que a instrução antiga era perigosa.
expect(body.error.message).toContain("destruiria o agente");
expect(body.error.message).not.toContain("Remova as versões");
// O operador precisa saber onde ir: dados estruturados junto da frase.
expect(body.error.details).toMatchObject({
count: 1,
versions: [{ agent_name: "Triagem", version_number: 4, status: "draft" }],
});
expect(audit).not.toHaveBeenCalled();
});
it("conta todas as versões e lista cada agente (rascunho + superseded)", async () => {
vi.mocked(createAdminClient).mockReturnValue(
fakeAdmin({
"ai_provider_credentials:select": { data: cred, error: null },
"ai_agent_versions:select": {
data: [
versao({ id: "v3", version_number: 3, status: "superseded", nome: "Triagem" }),
versao({ id: "v2", version_number: 2, status: "draft", nome: "Atendimento" }),
],
error: null,
},
}) as unknown as ReturnType<typeof createAdminClient>,
);
const res = await invocar();
const body = await res.json();
expect(res.status).toBe(409);
expect(body.error.message).toContain("2 versões de agente");
expect(body.error.message).toContain("Triagem v3");
expect(body.error.message).toContain("Atendimento v2");
expect(body.error.details.count).toBe(2);
});
it("sem versão apontando, exclui de verdade e audita", async () => {
vi.mocked(createAdminClient).mockReturnValue(
fakeAdmin({
"ai_provider_credentials:select": { data: cred, error: null },
"ai_agent_versions:select": { data: [], error: null },
"ai_provider_credentials:delete": { error: null },
}) as unknown as ReturnType<typeof createAdminClient>,
);
const res = await invocar();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data).toEqual({ id, deleted: true });
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({ action: "ai.credential_deleted", resourceId: id }),
);
});
it("credencial de outra organização responde 404 e não tenta excluir", async () => {
vi.mocked(createAdminClient).mockReturnValue(
fakeAdmin({
"ai_provider_credentials:select": {
data: { ...cred, organization_id: "outra-org" },
error: null,
},
}) as unknown as ReturnType<typeof createAdminClient>,
);
const res = await invocar();
expect(res.status).toBe(404);
});
});
+223
View File
@@ -0,0 +1,223 @@
/**
* PATCH /api/v1/ai/credentials/:id — rotação da chave NO LUGAR.
*
* É o caminho que faltava: sem ele, trocar uma chave só tinha a saída
* "excluir e recriar", e a exclusão é bloqueada pela FK quando alguma versão a
* usa. Aqui se prova o essencial: a chave é cifrada (nunca gravada em claro),
* a resposta não a devolve, e o rótulo colidido é tratado como no POST.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
// Precisa existir ANTES de qualquer import: `lib/env.ts` lê o processo no
// carregamento do módulo, e é ele que entrega a chave de cifragem ao AES.
vi.hoisted(() => {
process.env.AI_CRED_AES_KEY = "iBc1Z2gYaAH4rEHs1dHQ2dvNQ6t4OfrdE1/Y6OSvtZY=";
});
import { PATCH } from "@/app/api/v1/ai/credentials/[id]/route";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
import { audit } from "@/lib/audit";
import { decryptKey, byteaToBuffer } from "@/lib/crypto/aes_gcm";
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
vi.mock("@/lib/impersonate/support", () => ({ requireSupportWrite: vi.fn(async () => null) }));
vi.mock("@/lib/audit", () => ({ audit: vi.fn() }));
vi.mock("@/lib/ai/provider-validators", () => ({
validateProviderKey: vi.fn(async () => ({ ok: true, models: ["claude-x"] })),
}));
const org = "11111111-1111-4111-8111-111111111111";
const id = "22222222-2222-4222-8222-222222222222";
const CHAVE_NOVA = "sk-ant-api03-NOVA-CHAVE-ABCD";
type Resposta = { data?: unknown; error?: unknown };
type Patch = Record<string, unknown>;
type Fake = {
from: (table: string) => unknown;
updates: Patch[];
};
function fakeAdmin(config: Record<string, Resposta>): Fake {
const updates: Patch[] = [];
return {
updates,
from(table: string) {
let op = "select";
const respond = () => config[`${table}:${op}`] ?? config[table] ?? { data: null, error: null };
const chain: Record<string, unknown> = {
select: () => chain,
eq: () => chain,
in: () => chain,
order: () => chain,
limit: () => chain,
update: (patch: Patch) => {
op = "update";
updates.push(patch);
return chain;
},
insert: () => {
op = "insert";
return chain;
},
delete: () => {
op = "delete";
return chain;
},
maybeSingle: async () => respond(),
single: async () => respond(),
then: (resolve: (v: unknown) => unknown) => Promise.resolve(respond()).then(resolve),
};
return chain;
},
};
}
const cred = {
id,
organization_id: org,
provider: "anthropic",
label: "Produção",
api_key_last4: "old1",
};
/** Linha que a view segura devolveria — nunca tem campo cifrado. */
const segura = {
id,
organization_id: org,
provider: "anthropic",
label: "Produção",
api_key_last4: "ABCD",
validated_at: null,
validation_error: null,
models_available: null,
is_active: true,
created_by: "actor",
created_at: "2026-09-17T00:00:00Z",
updated_at: "2026-09-17T00:00:00Z",
};
function invocar(body: unknown) {
return PATCH(new NextRequest(`http://localhost/api/v1/ai/credentials/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}), { params: Promise.resolve({ id }) });
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireRole).mockResolvedValue({
ok: true,
org: { orgId: org, role: "admin", name: "Org" },
user: { id: "actor", idioma: "pt-BR" },
} as Awaited<ReturnType<typeof requireRole>>);
});
describe("PATCH /api/v1/ai/credentials/:id", () => {
it("troca a chave: cifra, marca para revalidar e a chave NOVA é a que decifra", async () => {
const fake = fakeAdmin({
"ai_provider_credentials:select": { data: cred, error: null },
"ai_provider_credentials:update": { data: { id }, error: null },
"ai_provider_credentials_safe:select": { data: segura, error: null },
});
vi.mocked(createAdminClient).mockReturnValue(fake as unknown as ReturnType<typeof createAdminClient>);
const res = await invocar({ api_key: CHAVE_NOVA });
expect(res.status).toBe(200);
const patch = fake.updates[0] ?? {};
// Cifrado de verdade: nada de plaintext coluna adentro.
expect(JSON.stringify(patch)).not.toContain(CHAVE_NOVA);
expect(patch.api_key_last4).toBe("ABCD");
// O veredito anterior deixa de valer — a tela mostra "validando".
expect(patch.validated_at).toBeNull();
expect(patch.validation_error).toBeNull();
expect(patch.models_available).toBeNull();
// A chave que o agente vai usar é a nova: o round-trip real prova.
const decifrada = decryptKey({
ciphertext: byteaToBuffer(patch.api_key_encrypted),
iv: byteaToBuffer(patch.api_key_iv),
tag: byteaToBuffer(patch.api_key_tag),
});
expect(decifrada).toBe(CHAVE_NOVA);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
action: "ai.credential_updated",
resourceId: id,
organizationId: org,
}),
);
});
it("não vaza a chave na resposta (nem em claro, nem o campo cifrado)", async () => {
const fake = fakeAdmin({
"ai_provider_credentials:select": { data: cred, error: null },
"ai_provider_credentials:update": { data: { id }, error: null },
"ai_provider_credentials_safe:select": { data: segura, error: null },
});
vi.mocked(createAdminClient).mockReturnValue(fake as unknown as ReturnType<typeof createAdminClient>);
const res = await invocar({ api_key: CHAVE_NOVA });
const texto = JSON.stringify(await res.json());
expect(texto).not.toContain(CHAVE_NOVA);
expect(texto).not.toContain("api_key_encrypted");
expect(texto).not.toContain("api_key_iv");
expect(texto).not.toContain("api_key_tag");
expect(JSON.parse(texto).data.api_key_last4).toBe("ABCD");
});
it("só renomear não mexe na chave nem dispara revalidação", async () => {
const fake = fakeAdmin({
"ai_provider_credentials:select": { data: cred, error: null },
"ai_provider_credentials:update": { data: { id }, error: null },
"ai_provider_credentials_safe:select": { data: { ...segura, label: "Produção 2" }, error: null },
});
vi.mocked(createAdminClient).mockReturnValue(fake as unknown as ReturnType<typeof createAdminClient>);
const res = await invocar({ label: "Produção 2" });
expect(res.status).toBe(200);
const patch = fake.updates[0] ?? {};
expect(patch.label).toBe("Produção 2");
expect(patch.api_key_encrypted).toBeUndefined();
expect(patch.validated_at).toBeUndefined();
});
it("rótulo colidido responde 409 como o POST", async () => {
const fake = fakeAdmin({
"ai_provider_credentials:select": { data: cred, error: null },
"ai_provider_credentials:update": { data: null, error: { code: "23505", message: "duplicate key" } },
});
vi.mocked(createAdminClient).mockReturnValue(fake as unknown as ReturnType<typeof createAdminClient>);
const res = await invocar({ label: "Produção" });
expect(res.status).toBe(409);
expect((await res.json()).error.code).toBe("label_already_used");
expect(audit).not.toHaveBeenCalled();
});
it("sem campo algum responde 422, sem tocar no banco", async () => {
const fake = fakeAdmin({ "ai_provider_credentials:select": { data: cred, error: null } });
vi.mocked(createAdminClient).mockReturnValue(fake as unknown as ReturnType<typeof createAdminClient>);
const res = await invocar({});
expect(res.status).toBe(422);
expect(fake.updates).toHaveLength(0);
});
it("credencial de outra organização responde 404 e não grava", async () => {
const fake = fakeAdmin({
"ai_provider_credentials:select": { data: { ...cred, organization_id: "outra-org" }, error: null },
});
vi.mocked(createAdminClient).mockReturnValue(fake as unknown as ReturnType<typeof createAdminClient>);
const res = await invocar({ api_key: CHAVE_NOVA });
expect(res.status).toBe(404);
expect(fake.updates).toHaveLength(0);
});
});