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
@@ -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),
|
||||
|
||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 260 KiB |
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||