mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
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:
@@ -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.
|
||||
@@ -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")} “{credential.label}”?
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user