fix(ia): a troca de modelo nao era auditada — 22P02 engolido pelo fire-and-forget

Achado DIRIGINDO A TELA, que e o unico jeito de ter achado: nenhum gate
assertava a linha de auditoria, e o erro so aparece no log do servidor.

    [audit] insert error invalid input syntax for type uuid: "stage_classifier"
            { action: 'ai.purpose_binding_updated' }

`api_audit_log.resource_id` e **uuid**; a rota mandava `corpo.purpose`, que
e texto. O insert falhava com 22P02 e, como o audit e fire-and-forget, o
erro nao chegava a lugar nenhum. Resultado: NENHUMA troca de modelo era
auditada — num painel cujo efeito e mudar para onde vao o dinheiro e os
dados do cliente. DoD 5 do repo violado em silencio.

Conserto: `resourceId` recebe o id da LINHA (o upsert passa a devolve-lo) e
o `purpose` vai para o metadata, onde texto e aceito. Guarda em
`provedores-x-registry.test.ts`.

PROVA PELA TELA (DoD 12), em ambiente com o baseline aplicado e as
migrations 0139/0141:

- `prova-painel-provedores.spec.ts` 8/8 verdes, com o erro de audit
  desaparecido do log (0 ocorrencias contra 1 antes).
- Caso novo: numa instalacao SEM agente publicado — o estado de quem acabou
  de instalar — os dois pontos que respondem o cliente ficam EDITAVEIS.
  Antes apareciam sem seletor, dizendo que sao governados por uma versao
  publicada que nao existe. Evidencia em
  `evidence/provedores/08-sem-agente-publicado-destravado.png`: "Responder o
  cliente" e "Trabalhar o funil" com Provedor/Modelo/Chave e botao Salvar.
- O timeout da spec foi a 90s, e a razao esta escrita no arquivo: cada caso
  refaz o login e o TOTP tem janela de 30s, entao dois logins na mesma
  janela obrigam a esperar a virada — espera que comia o timeout padrao e
  aparecia como "o botao nao respondeu". Medido por instrumento que a tela
  NAO e o gargalo: abrir cada grupo do painel com 424 modelos no catalogo
  custa 60-105ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkS3mzwtXughmjVC5FCoNo
This commit is contained in:
Rafael Melgaço
2026-08-08 14:29:06 -03:00
co-authored by Claude Opus 5
parent 09f52739dd
commit ef38ef7ebf
11 changed files with 92 additions and 2 deletions
+10 -2
View File
@@ -262,7 +262,7 @@ export async function PUT(req: NextRequest): Promise<Response> {
},
{ onConflict: "organization_id,purpose" },
)
.select("purpose, provider, model_id, credential_id, base_url, is_enabled")
.select("id, purpose, provider, model_id, credential_id, base_url, is_enabled")
.maybeSingle();
if (error) return fail("save_failed", error.message, 500);
@@ -277,11 +277,19 @@ export async function PUT(req: NextRequest): Promise<Response> {
organizationId: org.orgId,
actorUserId: user.id,
resourceType: "ai_purpose_binding",
resourceId: corpo.purpose,
// O ID DA LINHA, não o `purpose`. `api_audit_log.resource_id` é **uuid**, e
// `purpose` é texto (`stage_classifier`): o INSERT falhava com 22P02
// (`invalid input syntax for type uuid`) e — como o audit é
// fire-and-forget — o erro ia só para o log do servidor. Resultado: NENHUMA
// troca de modelo era auditada, num painel cujo efeito é justamente mudar
// para onde o dinheiro e os dados do cliente vão. Achado dirigindo a tela;
// nenhum gate via, porque nada assertava a linha de auditoria.
resourceId: (gravado as { id?: string }).id ?? null,
// O modelo entra no metadata, a credencial NÃO — só o id dela seria
// inócuo, mas o hábito de mandar campo de credencial para o audit é o que
// acaba vazando a chave quando alguém troca o campo de lugar.
metadata: {
purpose: corpo.purpose,
provider: corpo.provider,
model_id: corpo.model_id,
tem_endpoint_proprio: Boolean(corpo.base_url),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

+47
View File
@@ -30,6 +30,23 @@ import { lerCreds, loginComoAdmin, type CredsE2E } from "./helpers/login-admin";
*/
let creds: CredsE2E;
/**
* 90s por caso, e o motivo é o MFA — não lentidão da tela.
*
* Cada teste refaz o login no `beforeEach`, e o TOTP tem janela de 30 segundos:
* dois logins dentro da mesma janela apresentam o MESMO código, que o GoTrue
* recusa como reutilização. O helper trata isso esperando a janela virar
* (`login-admin.ts:91`) — e essa espera cabe dentro do timeout padrão de 30s do
* Playwright, comendo-o inteiro. O sintoma não é honesto: quem falha é o
* primeiro `page.click` depois do login, e a mensagem fala do botão.
*
* Medido: rodando isolada (`-g F3`), a spec passa em 15s; na suíte, o terceiro
* caso estourava sempre. Com o painel medido por instrumento — abrir cada grupo
* com 424 modelos no catálogo custa 60–105ms —, não há o que otimizar na tela: o
* custo é a janela do segundo fator.
*/
test.describe.configure({ timeout: 90_000 });
test.beforeAll(() => {
creds = lerCreds();
});
@@ -157,3 +174,33 @@ test("as duas telas têm porta na navegação — pelo hub de IA", async ({ page
await expect(page.getByRole("link", { name: /Provedores/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Execuções/ })).toBeVisible();
});
test("instalação sem agente publicado: os dois pontos principais são EDITÁVEIS", async ({ page }) => {
// O estado de quem acabou de instalar: nenhuma versão de agente publicada.
// Antes deste caso, o painel mostrava `agent_turn` e `operator_turn` sem
// seletor, com a frase "usa o modelo definido na versão publicada do agente"
// e um link para configurar num lugar vazio — a primeira tela da feature,
// travada no primeiro uso. `mandadoPeloAgente` era incondicional; agora
// depende de existir publicação, que é a mesma condição do resolvedor.
const semPublicado = await page.evaluate(async () => {
const r = await fetch("/api/v1/ai/providers");
const j = await r.json();
const p = (j?.data?.pontos ?? []) as Array<{ id: string; mandadoPeloAgente: boolean }>;
return {
agent_turn: p.find((x) => x.id === "agent_turn")?.mandadoPeloAgente,
operator_turn: p.find((x) => x.id === "operator_turn")?.mandadoPeloAgente,
total: p.length,
};
});
// Controle positivo: sem pontos, as duas asserções abaixo passariam vazias.
expect(semPublicado.total).toBeGreaterThan(20);
expect(semPublicado.agent_turn, "agent_turn continua travado sem agente publicado").toBe(false);
expect(semPublicado.operator_turn, "operator_turn continua travado sem agente publicado").toBe(false);
await page.goto("/app/ai/providers");
await page.waitForSelector('[data-testid="painel-de-provedores"]');
await page.click('[data-testid="avancado-atender"]');
// E a prova PELA TELA: o seletor de provedor daquele ponto existe e responde.
await expect(page.locator('[data-testid="provider-agent_turn"]')).toBeVisible();
await page.screenshot({ path: "evidence/provedores/08-sem-agente-publicado-destravado.png", fullPage: true });
});
+35
View File
@@ -177,3 +177,38 @@ describe("lista de provedores × os pontos de ESCRITA", () => {
).toEqual([]);
});
});
/**
* A MUTAÇÃO DO PAINEL PRECISA DEIXAR LINHA DE AUDITORIA.
*
* `api_audit_log.resource_id` é **uuid**. A rota mandava `corpo.purpose`
* (`"stage_classifier"`), o INSERT falhava com 22P02 e — como o audit é
* fire-and-forget — o erro ia só para o log do servidor:
*
* [audit] insert error invalid input syntax for type uuid: "stage_classifier"
*
* Ou seja: NENHUMA troca de modelo era auditada, num painel cujo efeito é mudar
* para onde vão o dinheiro e os dados do cliente. Achado dirigindo a tela — o
* DoD 5 do repo exige audit em toda mutação e nada assertava a linha.
*/
describe("PUT /api/v1/ai/providers — auditoria", () => {
it("o resourceId é o id da linha, não o purpose", async () => {
const { readFileSync } = await import("node:fs");
const fonte = readFileSync("app/api/v1/ai/providers/route.ts", "utf8");
const bloco = fonte.slice(fonte.indexOf("ai.purpose_binding_updated"));
expect(
bloco,
"resourceId voltou a receber o purpose — `resource_id` é uuid e o insert do audit morre com 22P02",
).not.toMatch(/resourceId:\s*corpo\.purpose/);
expect(bloco).toMatch(/resourceId:\s*\(gravado as \{ id\?: string \}\)\.id/);
// E o purpose continua registrado — no metadata, onde texto é aceito.
expect(bloco).toMatch(/purpose:\s*corpo\.purpose/);
});
it("o upsert traz o id de volta (senão o resourceId seria sempre null)", async () => {
const { readFileSync } = await import("node:fs");
const fonte = readFileSync("app/api/v1/ai/providers/route.ts", "utf8");
expect(fonte).toMatch(/\.select\("id, purpose, provider, model_id/);
});
});