diff --git a/backend/scripts/test-indexing-db.mts b/backend/scripts/test-indexing-db.mts index 7563c5f..ba47ba3 100644 --- a/backend/scripts/test-indexing-db.mts +++ b/backend/scripts/test-indexing-db.mts @@ -63,7 +63,7 @@ try { }); process.exitCode=exitCode; if (exitCode === 0) { - const suggestions = spawnSync(process.execPath, ["--import", "tsx", "--test", "src/modules/suggestions/suggestions.db.test.ts"], { + const suggestions = spawnSync(process.execPath, ["--import", "tsx", "--test", "src/modules/suggestions/suggestions.db.test.ts", "src/modules/pbis/pbis.relations.db.test.ts", "src/modules/documents/chunks.http.db.test.ts"], { env: { ...process.env, NODE_ENV: "test", DATABASE_URL: url, ARCHIVE_TEST_DATABASE_URL: url }, stdio: "inherit", }); diff --git a/backend/src/app.ts b/backend/src/app.ts index aadcc43..84a8b0a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -57,6 +57,9 @@ app.get("/health", async (_req: Request, res: Response) => { }); }); +// O callback de ingestão usa token de serviço e valida documento/projeto no próprio serviço. +app.use("/api/v1/projects/:projectId/documents/:documentId/chunks", chunksRouter); + app.use("/api/v1", createProjectAccessRouter()); app.use("/api", createProjectAccessRouter()); @@ -110,8 +113,6 @@ app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter); // Documentos do projeto (S1-19/S1-20/S1-22) app.use("/api/v1/projects/:projectId/documents", requireAuth, documentsRouter); -// Server-to-server: pipeline de ingestão persistindo chunks indexados do documento. -app.use("/api/v1/projects/:projectId/documents/:documentId/chunks", chunksRouter); app.use("/api/v1/projects/:projectId/index-events", indexingRouter); app.use("/api/v1/projects/:projectId/documents", documentTextRouter); diff --git a/backend/src/modules/documents/chunks.http.db.test.ts b/backend/src/modules/documents/chunks.http.db.test.ts new file mode 100644 index 0000000..4a06c48 --- /dev/null +++ b/backend/src/modules/documents/chunks.http.db.test.ts @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { DocumentsRepository } from "./documents.repository.js"; +import { AdminRepository } from "../admin/admin.repository.js"; + +test("Callback de chunks autentica o serviço e preserva as guardas de documentos", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async t => { + const db = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const { app } = await import("../../app.js"); + const { pool } = await import("../../database/db.js"); + const { env } = await import("../../config/env.js"); + const server = app.listen(0, "127.0.0.1"); + await new Promise(resolve => server.once("listening", resolve)); + const base = "http://127.0.0.1:" + (server.address() as { port: number }).port + "/api/v1/projects/"; + const [project, otherProject, document, user] = Array.from({ length: 4 }, () => randomUUID()); + const token = env.DOCUMENT_INGESTION_TOKEN || "sinapse-dev-document-ingestion-token-2026"; + const payload = { processamento_lease_id: randomUUID() as string, chunks: [{ chunk_index: 0, content: "Texto de teste", embedding: Array(1024).fill(0.1) }] }; + const documents = new DocumentsRepository(db); + const claim = async () => { + const claimed = (await documents.claimPendingIngestion(100)).find(row => row.id === document); + assert.ok(claimed); + return claimed.processamento_lease_id; + }; + const endpoint = (projectId = project) => base + projectId + "/documents/" + document + "/chunks"; + const send = (url = endpoint(), credential: string | null = token, body: unknown = payload) => fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...(credential ? { Authorization: "Bearer " + credential } : {}) }, + body: JSON.stringify(body), + }); + try { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'QA',$2,'h','admin')", [user, user + "@chunks.test"]); + for (const id of [project, otherProject]) { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES ($1,$2,'QA','ativo')", [id, id]); + } + await db.query("INSERT INTO documento(id,projeto_id,nome,caminho) VALUES ($1,$2,'QA.txt','qa.txt')", [document, project]); + payload.processamento_lease_id = await claim(); + await t.test("recusa token ausente ou inválido sem gravar chunks", async () => { + for (const credential of [null, "token-invalido"]) { + const response = await send(endpoint(), credential); + assert.equal(response.status, 401); + assert.equal((await response.json() as { code: string }).code, "INGEST_TOKEN_INVALID"); + } + assert.equal((await db.query("SELECT count(*)::int AS n FROM chunk WHERE entidade_id=$1", [document])).rows[0].n, 0); + }); + await t.test("token sozinho não substitui a reserva de execução", async () => { + assert.equal((await send(endpoint(), token, { chunks: payload.chunks })).status, 400); + assert.equal((await send(endpoint(), token, { ...payload, processamento_lease_id: "invalida" })).status, 400); + assert.equal((await send(endpoint(), token, { ...payload, processamento_lease_id: randomUUID() })).status, 409); + assert.equal((await db.query("SELECT count(*)::int AS n FROM chunk WHERE entidade_id=$1", [document])).rows[0].n, 0); + }); + await t.test("token e reserva válidos funcionam sem sessão e repetir o envio não duplica chunks", async () => { + for (let i = 0; i < 2; i++) { + const response = await send(); + assert.equal(response.status, 200, await response.text()); + } + assert.equal((await db.query("SELECT count(*)::int AS n FROM chunk WHERE entidade_id=$1", [document])).rows[0].n, 1); + assert.equal((await db.query("SELECT status_processamento FROM documento WHERE id=$1", [document])).rows[0].status_processamento, "processado"); + }); + await t.test("não permite associar documento ao projeto errado", async () => { + const response = await send(endpoint(otherProject)); + assert.equal(response.status, 404); + assert.equal((await db.query("SELECT projeto_id FROM chunk WHERE entidade_id=$1", [document])).rows[0].projeto_id, project); + }); + await t.test("expurgo invalida callback antigo; nova reserva funciona e respostas antigas não sobrescrevem", async () => { + const oldLease = payload.processamento_lease_id; + await new AdminRepository(db).purgeProjectContext(project!, project!, user!); + assert.equal((await send()).status, 409); + assert.equal((await db.query("SELECT count(*)::int AS n FROM chunk WHERE entidade_id=$1", [document])).rows[0].n, 0); + assert.equal((await db.query("SELECT status_processamento FROM documento WHERE id=$1", [document])).rows[0].status_processamento, "falha"); + assert.equal(await documents.retryIngestion(project!, document!), true); + assert.equal((await send()).status, 409); + const expiredLease = await claim(); + assert.notEqual(expiredLease, oldLease); + assert.equal((await send()).status, 409); + payload.processamento_lease_id = expiredLease; + await db.query("UPDATE documento SET processamento_bloqueado_ate=clock_timestamp()-INTERVAL '1 second' WHERE id=$1", [document]); + assert.equal((await send()).status, 409); + const freshLease = await claim(); + assert.notEqual(freshLease, expiredLease); + payload.processamento_lease_id = freshLease; + payload.chunks[0]!.content = "Conteúdo da nova tentativa"; + assert.equal((await send()).status, 200); + const saved = (await db.query("SELECT id,texto FROM chunk WHERE entidade_id=$1", [document])).rows; + payload.processamento_lease_id = oldLease; + payload.chunks[0]!.content = "Resposta antiga"; + assert.equal((await send()).status, 409); + payload.processamento_lease_id = expiredLease; + assert.equal((await send()).status, 409); + assert.deepEqual((await db.query("SELECT id,texto FROM chunk WHERE entidade_id=$1", [document])).rows, saved); + payload.processamento_lease_id = freshLease; + assert.equal((await send()).status, 200); + assert.deepEqual((await db.query("SELECT id,texto FROM chunk WHERE entidade_id=$1", [document])).rows, saved); + }); + await t.test("projeto arquivado continua recusando escrita", async () => { + await db.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [project]); + const response = await send(); + assert.equal(response.status, 409); + assert.equal((await response.json() as { code: string }).code, "ARCHIVE_CONFLICT"); + }); + await t.test("token de serviço não concede acesso às rotas de usuário", async () => { + for (const path of [base + project + "/documents", base + project + "/documents/" + document + "/chunks/extra"]) { + const response = await fetch(path, { headers: { Authorization: "Bearer " + token } }); + assert.equal(response.status, 401); + assert.equal((await response.json() as { code: string }).code, "UNAUTHORIZED"); + } + }); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [[project, otherProject]]); + await db.query("DELETE FROM usuario WHERE id=$1", [user]); + await db.end(); + await pool.end(); + } +}); diff --git a/backend/src/modules/documents/chunks.routes.ts b/backend/src/modules/documents/chunks.routes.ts index 599500e..5324dca 100644 --- a/backend/src/modules/documents/chunks.routes.ts +++ b/backend/src/modules/documents/chunks.routes.ts @@ -34,8 +34,9 @@ export function createChunksRouter(service: ChunksService = chunksService): Rout * summary: Persiste chunks com embeddings (pipeline de ingestão -> backend) * description: > * Chamado pelo pipeline após extrair texto, chunking e embeddings. - * Substitui qualquer chunk anterior do mesmo documento (reindexação - * idempotente) e marca o documento como 'processado'. Autenticação por + * Exige processamento_lease_id da reserva vigente emitida pelo backend. + * Entregas antigas ou canceladas retornam 409. Repetições concluídas + * retornam o resultado salvo, sem regravar. Autenticação por * token compartilhado em Authorization: Bearer . * tags: [Documents] * security: @@ -50,7 +51,7 @@ export function createChunksRouter(service: ChunksService = chunksService): Rout * 404: * description: Projeto ou documento não encontrados * 409: - * description: Projeto arquivado + * description: Projeto arquivado ou reserva expirada, cancelada ou substituída */ router.post("/", requireIngestToken, async (req: Request, res: Response, next: NextFunction) => { try { @@ -60,6 +61,7 @@ export function createChunksRouter(service: ChunksService = chunksService): Rout const result = await service.persist({ projetoId: paramOf(req.params.projectId), documentoId: paramOf(req.params.documentId), + leaseId: (req.body as { processamento_lease_id?: string }).processamento_lease_id as string, chunks: (req.body as { chunks?: unknown }).chunks as never, }); res.status(200).json(result); diff --git a/backend/src/modules/documents/chunks.service.ts b/backend/src/modules/documents/chunks.service.ts index d88c039..1ce66ed 100644 --- a/backend/src/modules/documents/chunks.service.ts +++ b/backend/src/modules/documents/chunks.service.ts @@ -1,6 +1,6 @@ import type { PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { AppError, ConflictError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; import { ArchiveConflict } from "../projects/archive.types.js"; import { lockHierarchy } from "../projects/hierarchy-archive.js"; import { auditService } from "../audit/audit.service.js"; @@ -15,6 +15,7 @@ export interface IncomingChunk { export interface PersistChunksInput { projetoId: string; documentoId: string; + leaseId: string; chunks: IncomingChunk[]; } @@ -57,6 +58,7 @@ export class ChunksService { async persist(input: PersistChunksInput): Promise { validateUuid(input.projetoId, "ID do projeto"); validateUuid(input.documentoId, "ID do documento"); + validateUuid(input.leaseId, "Reserva de processamento"); if (!Array.isArray(input.chunks) || input.chunks.length === 0) { throw new ValidationError("Envie ao menos um chunk."); } @@ -83,13 +85,32 @@ export class ChunksService { throw new ArchiveConflict("Projeto arquivado é somente leitura e não recebe novos chunks."); } - const document = await client.query<{ id: string; projeto_id: string; status_processamento: string }>( - "SELECT id, projeto_id, status_processamento FROM documento WHERE id = $1 AND projeto_id = $2 FOR UPDATE", + const document = await client.query<{ status_processamento: string; processamento_lease_id: string | null; lease_active: boolean }>( + "SELECT status_processamento, processamento_lease_id, processamento_bloqueado_ate > clock_timestamp() AS lease_active FROM documento WHERE id = $1 AND projeto_id = $2 FOR UPDATE", [input.documentoId, input.projetoId], ); if (document.rowCount === 0) { throw new NotFoundError("Documento não encontrado no projeto informado."); } + const current = document.rows[0]!; + if (current.status_processamento === "processado") { + const receipt = await client.query<{ total: number; matching: number }>( + `SELECT count(*)::int AS total, + count(*) FILTER (WHERE metadados_json->>'ingestion_lease_id'=$3)::int AS matching + FROM chunk WHERE projeto_id=$1 AND entidade_tipo='documento' AND entidade_id=$2`, + [input.projetoId, input.documentoId, input.leaseId], + ); + const row = receipt.rows[0]!; + if (row.total > 0 && row.total === row.matching) { + await client.query("COMMIT"); + return { documento_id: input.documentoId, projeto_id: input.projetoId, + total_chunks: row.total, status_processamento: "processado" }; + } + } + if (current.status_processamento !== "processando" + || current.processamento_lease_id !== input.leaseId || !current.lease_active) { + throw new ConflictError("A reserva de ingestão expirou, foi cancelada ou pertence a outra execução."); + } // Reindexação idempotente: substitui chunks anteriores do mesmo documento. await client.query( @@ -105,14 +126,16 @@ export class ChunksService { input.projetoId, input.documentoId, chunk.content, - JSON.stringify({ ...(chunk.metadata ?? {}), chunk_index: chunk.chunk_index }), + JSON.stringify({ ...(chunk.metadata ?? {}), chunk_index: chunk.chunk_index, ingestion_lease_id: input.leaseId }), toVectorLiteral(chunk.embedding), ], ); } await client.query( - "UPDATE documento SET status_processamento = 'processado', updated_at = CURRENT_TIMESTAMP WHERE id = $1", + `UPDATE documento SET status_processamento='processado', processamento_erro=NULL, + processamento_lease_id=NULL, processamento_bloqueado_ate=NULL, + processamento_tentativas=processamento_tentativas+1, updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [input.documentoId], ); @@ -139,7 +162,7 @@ export class ChunksService { }; } catch (error) { await client.query("ROLLBACK"); - if (error instanceof AppError) throw error; + if (error instanceof AppError || error instanceof ArchiveConflict) throw error; throw new AppError("Não foi possível persistir os chunks agora.", 503, "CHUNK_PERSIST_FAILED"); } finally { client.release(); diff --git a/backend/src/modules/pbis/pbis.controller.ts b/backend/src/modules/pbis/pbis.controller.ts index 17eec64..38dc65e 100644 --- a/backend/src/modules/pbis/pbis.controller.ts +++ b/backend/src/modules/pbis/pbis.controller.ts @@ -78,7 +78,7 @@ export class PbisController { listRelations = async (req: Request, res: Response, next: NextFunction): Promise => { try { const id = getParamId(req.params.id); - const result = await this.relationsService.list(id); + const result = await this.relationsService.list(id, req.auth!.id); res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/modules/pbis/pbis.relations.db.test.ts b/backend/src/modules/pbis/pbis.relations.db.test.ts new file mode 100644 index 0000000..6b7cd6a --- /dev/null +++ b/backend/src/modules/pbis/pbis.relations.db.test.ts @@ -0,0 +1,139 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { PbiRelationsRepository } from "./pbis.relations.repository.js"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { ConflictError, ValidationError } from "../../shared/errors.js"; + +test("Relações: isolamento, arquivamento e ciclos longos", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async t => { + const db = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const repo = new PbiRelationsRepository(db); + const [po, dev, developer, projectA, projectB, epicA, epicB, featureA, featureB] = Array.from({ length: 9 }, randomUUID); + const ids = Array.from({ length: 53 }, randomUUID); + try { + for (const [id, role] of [[po, "po"], [dev, "dev"]]) { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'QA',$2,'h',$3)", [id, id + "@relations.test", role]); + } + for (const [project, epic, feature] of [[projectA, epicA, featureA], [projectB, epicB, featureB]]) { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES ($1,$2,'QA','ativo')", [project, project]); + await db.query("INSERT INTO epico(id,projeto_id,titulo) VALUES ($1,$2,'QA')", [epic, project]); + await db.query("INSERT INTO feature(id,epico_id,titulo) VALUES ($1,$2,'QA')", [feature, epic]); + } + await db.query("INSERT INTO desenvolvedor(id,usuario_id) VALUES ($1,$2)", [developer, dev]); + await db.query("INSERT INTO alocacao(desenvolvedor_id,projeto_id,data_inicio) VALUES ($1,$2,CURRENT_TIMESTAMP - INTERVAL '1 day')", [developer, projectA]); + for (const [i, id] of ids.entries()) { + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que) VALUES ($1,$2,$3,'Consultar dados','PO','Consultar','Validar')", + [id, i === 52 ? featureB : featureA, id]); + } + await t.test("filtra relações de outro projeto e preserva as autorizadas", async () => { + await repo.createRelation(ids[0]!, ids[52]!, "similar_a", po!); + await repo.createRelation(ids[0]!, ids[1]!, "similar_a", po!); + assert.equal((await repo.listRelations(ids[0]!, po!)).length, 2); + assert.deepEqual((await repo.listRelations(ids[0]!, dev!)).map(r => r.relacionado_id), [ids[1]]); + await db.query("INSERT INTO alocacao(desenvolvedor_id,projeto_id,data_inicio) VALUES ($1,$2,CURRENT_TIMESTAMP - INTERVAL '1 day')", [developer, projectB]); + assert.equal((await repo.listRelations(ids[0]!, dev!)).length, 2); + await db.query("UPDATE alocacao SET data_fim=CURRENT_TIMESTAMP - INTERVAL '1 second' WHERE projeto_id=$1", [projectB]); + assert.equal((await repo.listRelations(ids[0]!, dev!)).length, 1); + }); + await t.test("recusa criação e remoção se qualquer ponta ou ancestral estiver arquivado", async () => { + const relation = await repo.createRelation(ids[2]!, ids[52]!, "similar_a", po!); + for (const [table, id] of [["pbi", ids[2]], ["pbi", ids[52]], ["feature", featureB], ["epico", epicB], ["projeto", projectB]]) { + const previous = (await db.query(`SELECT status FROM ${table} WHERE id=$1`, [id])).rows[0].status; + await db.query(`UPDATE ${table} SET status='arquivado' WHERE id=$1`, [id]); + await assert.rejects(repo.createRelation(ids[2]!, ids[52]!, "substitui", po!), ArchiveConflict); + await assert.rejects(repo.deleteRelation(ids[2]!, relation.id, po!), ArchiveConflict); + await db.query(`UPDATE ${table} SET status=$2 WHERE id=$1`, [id, previous]); + } + await repo.deleteRelation(ids[2]!, relation.id, po!); + }); + await t.test("detecta ciclo após 51 arestas e aceita caminho sem ciclo", async () => { + for (let i = 0; i < 51; i++) { + await repo.createRelation(ids[i]!, ids[i + 1]!, "depende_de", po!); + } + await assert.rejects(repo.createRelation(ids[51]!, ids[0]!, "depende_de", po!), /Ciclo de dependência/); + assert.equal((await db.query("SELECT count(*)::int AS n FROM pbi_relacao WHERE tipo_relacao='depende_de' AND pbi_origem_id=ANY($1::uuid[])", [ids])).rows[0].n, 51); + }); + await t.test("repetir uma relação dirigida retorna conflito de relação", async () => { + for (const tipo of ["depende_de", "substitui"] as const) { + await repo.createRelation(ids[0]!, ids[52]!, tipo, po!); + await assert.rejects(repo.createRelation(ids[0]!, ids[52]!, tipo, po!), error => { + assert.ok(error instanceof ConflictError); + assert.equal(error.statusCode, 409); + assert.match(error.message, /relação.*já existe/); + return true; + }); + } + }); + } finally { + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [[projectA, projectB]]); + await db.query("DELETE FROM usuario WHERE id=ANY($1::uuid[])", [[po, dev]]); + await db.end(); + } +}); + +test("Dependências concorrentes entre projetos não fecham um ciclo", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL, timeout: 15000 }, async () => { + const db = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const repo = new PbiRelationsRepository(db); + const user = randomUUID(); + const projects: string[] = []; + const ids: string[] = []; + let release: (() => void) | undefined; + let pending: Promise[] = []; + try { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'QA',$2,'h','po')", [user, user + "@relations.test"]); + for (let i = 0; i < 4; i++) { + const [project, epic, feature, pbi] = Array.from({ length: 4 }, () => randomUUID()); + projects.push(project!); + ids.push(pbi!); + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES ($1,$2,'QA','ativo')", [project, project]); + await db.query("INSERT INTO epico(id,projeto_id,titulo) VALUES ($1,$2,'QA')", [epic, project]); + await db.query("INSERT INTO feature(id,epico_id,titulo) VALUES ($1,$2,'QA')", [feature, epic]); + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que) VALUES ($1,$2,$3,'Consultar','PO','x','y')", [pbi, feature, pbi]); + } + await repo.createRelation(ids[0]!, ids[1]!, "depende_de", user); + await repo.createRelation(ids[2]!, ids[3]!, "depende_de", user); + let firstChecked!: () => void; + const checked = new Promise(resolve => { firstChecked = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + const original = repo.checkDependencyCycle.bind(repo); + let calls = 0; + repo.checkDependencyCycle = async (...args) => { + const result = await original(...args); + if (++calls === 1) { + firstChecked(); + await gate; + } + return result; + }; + const first = repo.createRelation(ids[1]!, ids[2]!, "depende_de", user); + pending = [first]; + await checked; + const second = repo.createRelation(ids[3]!, ids[0]!, "depende_de", user); + pending.push(second); + const settled = Promise.allSettled([first, second]); + // Aguarda a segunda transação disputar o grafo enquanto a primeira ainda não inseriu. + let waiting = false; + for (let i = 0; i < 100; i++) { + const row = (await db.query("SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event='advisory' AND query LIKE '%pbi_relacao:depende_de%') AS waiting")).rows[0]; + if (row.waiting) { waiting = true; break; } + if (calls > 1) break; + await new Promise(resolve => setTimeout(resolve, 20)); + } + release!(); + const results = await settled; + assert.equal(waiting, true, "a segunda alteração deve aguardar a validação da primeira"); + assert.equal(results.filter(r => r.status === "fulfilled").length, 1); + const rejected = results.find(r => r.status === "rejected"); + assert.ok(rejected?.status === "rejected" && rejected.reason instanceof ValidationError); + assert.match(rejected.reason.message, /Ciclo de dependência/); + assert.equal((await db.query("SELECT count(*)::int AS n FROM pbi_relacao WHERE pbi_origem_id=ANY($1::uuid[])", [ids])).rows[0].n, 3); + } finally { + release?.(); + await Promise.allSettled(pending); + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [projects]); + await db.query("DELETE FROM usuario WHERE id=$1", [user]); + await db.end(); + } +}); diff --git a/backend/src/modules/pbis/pbis.relations.repository.ts b/backend/src/modules/pbis/pbis.relations.repository.ts index 5a30497..a4e2c79 100644 --- a/backend/src/modules/pbis/pbis.relations.repository.ts +++ b/backend/src/modules/pbis/pbis.relations.repository.ts @@ -4,6 +4,7 @@ import { withTransaction } from "../../database/transaction.js"; import { ConflictError, NotFoundError, ValidationError } from "../../shared/errors.js"; import { auditService } from "../audit/audit.service.js"; import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; +import { projectAccessSql } from "../projects/project-access.js"; export type PbiRelationType = "depende_de" | "similar_a" | "substitui"; @@ -58,7 +59,7 @@ export class PbiRelationsRepository { return res.rows[0] ?? null; } - async listRelations(pbiId: string): Promise { + async listRelations(pbiId: string, userId: string): Promise { const pbi = await this.getPbiSummary(pbiId); if (!pbi) throw new NotFoundError("PBI não encontrado."); @@ -89,9 +90,12 @@ export class PbiRelationsRepository { p.prioridade FROM pbi_relacao r JOIN pbi p ON p.id = (CASE WHEN r.pbi_origem_id = $1 THEN r.pbi_destino_id ELSE r.pbi_origem_id END) - WHERE r.pbi_origem_id = $1 OR r.pbi_destino_id = $1 + JOIN feature related_feature ON related_feature.id = p.feature_id + JOIN epico related_epic ON related_epic.id = related_feature.epico_id + WHERE (r.pbi_origem_id = $1 OR r.pbi_destino_id = $1) + AND ${projectAccessSql("related_epic.projeto_id", "$2")} ORDER BY r.created_at DESC`, - [pbiId], + [pbiId, userId], ); return res.rows.map((row) => { @@ -135,8 +139,7 @@ export class PbiRelationsRepository { SELECT pbi_origem_id, pbi_destino_id, - ARRAY[pbi_origem_id::text, pbi_destino_id::text] AS path, - 1 AS depth + ARRAY[pbi_origem_id::text, pbi_destino_id::text] AS path FROM pbi_relacao WHERE pbi_origem_id = $1 AND tipo_relacao = 'depende_de' @@ -145,13 +148,11 @@ export class PbiRelationsRepository { SELECT r.pbi_origem_id, r.pbi_destino_id, - dp.path || r.pbi_destino_id::text, - dp.depth + 1 + dp.path || r.pbi_destino_id::text FROM pbi_relacao r JOIN dep_path dp ON r.pbi_origem_id = dp.pbi_destino_id::uuid WHERE r.tipo_relacao = 'depende_de' AND NOT (r.pbi_destino_id::text = ANY(dp.path)) - AND dp.depth < 50 ) SELECT path FROM dep_path WHERE pbi_destino_id = $2 LIMIT 1`, [destinoId, origemId], @@ -206,6 +207,10 @@ export class PbiRelationsRepository { } return withTransaction(this.db, async (client) => { + if (tipo === "depende_de") { + // Uma cadeia pode atravessar projetos sem compartilhar as pontas da nova relação. + await client.query("SELECT pg_advisory_xact_lock(hashtextextended('pbi_relacao:depende_de', 0))"); + } // Valida existência e busca detalhes dos dois PBIs const [origem, destino] = await Promise.all([ this.getPbiSummary(origemId, client), @@ -215,17 +220,16 @@ export class PbiRelationsRepository { if (!origem) throw new NotFoundError("PBI de origem não encontrado."); if (!destino) throw new NotFoundError("PBI de destino não encontrado."); - // Trava e valida gravabilidade nos projetos envolvidos - await lockHierarchy(client, "projeto", origem.projeto_id); - await assertWritable(client, "projeto", origem.projeto_id); - if (destino.projeto_id !== origem.projeto_id) { - await lockHierarchy(client, "projeto", destino.projeto_id); - await assertWritable(client, "projeto", destino.projeto_id); + // A ordem dos projetos é a mesma para relações em ambas as direções. + for (const projectId of [...new Set([origem.projeto_id, destino.projeto_id])].sort()) { + await lockHierarchy(client, "projeto", projectId); } + await assertWritable(client, "pbi", origemId); + await assertWritable(client, "pbi", destinoId); // 1. Verificação de duplicidade existente const existingSame = await client.query( - `SELECT id, tipo_relacao FROM pbi_relacao + `SELECT id, tipo_relacao, pbi_origem_id, pbi_destino_id FROM pbi_relacao WHERE (pbi_origem_id = $1 AND pbi_destino_id = $2) OR (pbi_origem_id = $2 AND pbi_destino_id = $1)`, [origemId, destinoId], @@ -342,11 +346,15 @@ export class PbiRelationsRepository { throw new NotFoundError("Relação não pertence ao PBI informado."); } - const pbi = await this.getPbiSummary(pbiId, client); - if (pbi) { - await lockHierarchy(client, "projeto", pbi.projeto_id); - await assertWritable(client, "projeto", pbi.projeto_id); + const endpoints = await Promise.all([ + this.getPbiSummary(relation.pbi_origem_id, client), + this.getPbiSummary(relation.pbi_destino_id, client), + ]); + for (const projectId of [...new Set(endpoints.filter(p => p !== null).map(p => p.projeto_id))].sort()) { + await lockHierarchy(client, "projeto", projectId); } + await assertWritable(client, "pbi", relation.pbi_origem_id); + await assertWritable(client, "pbi", relation.pbi_destino_id); await client.query("DELETE FROM pbi_relacao WHERE id = $1", [relationId]); diff --git a/backend/src/modules/pbis/pbis.relations.service.ts b/backend/src/modules/pbis/pbis.relations.service.ts index 79ef9b6..5643750 100644 --- a/backend/src/modules/pbis/pbis.relations.service.ts +++ b/backend/src/modules/pbis/pbis.relations.service.ts @@ -25,9 +25,9 @@ export const createRelationSchema = z export class PbiRelationsService { constructor(private readonly repository: PbiRelationsRepository = pbiRelationsRepository) {} - async list(pbiId: string): Promise<{ items: PbiRelationView[]; total: number }> { + async list(pbiId: string, userId: string): Promise<{ items: PbiRelationView[]; total: number }> { validateUuid(pbiId, "ID do PBI"); - const items = await this.repository.listRelations(pbiId); + const items = await this.repository.listRelations(pbiId, userId); return { items, total: items.length }; } diff --git a/docs/QA-MAIN-2026-10-22.md b/docs/QA-MAIN-2026-10-22.md new file mode 100644 index 0000000..a7a1f3f --- /dev/null +++ b/docs/QA-MAIN-2026-10-22.md @@ -0,0 +1,192 @@ +# QA da main — 22/10/2026 + +Revisão da main remota **506de2a9dfdac49e3ca9a9f7c978ca5dbb934959**, após o merge da PR #57. A árvore de aplicação local era idêntica à main no início da revisão. Os achados abaixo foram corrigidos posteriormente na cópia local. + +Foram confirmadas três falhas nas relações entre PBIs (S2-15), usando API real e PostgreSQL temporário. O banco e os arquivos temporários foram removidos ao final; os dados de desenvolvimento foram preservados. + +## 1. Alta — listagem de relações revela dados de projeto sem acesso + +**Código:** `backend/src/modules/pbis/pbis.relations.repository.ts:92` (`listRelations`). + +A guarda global verifica acesso ao PBI da URL. A consulta das relações retorna código, título, status e prioridade do PBI relacionado sem verificar se o usuário pode acessar o projeto desse outro PBI. + +**Reprodução:** criar uma relação entre PBIs de projetos A e B com PO; alocar o desenvolvedor somente em A; consultar o PBI de B e depois as relações do PBI de A com a sessão desse desenvolvedor. + +**Evidência:** + +```json +{"case":"isolamento_relacoes","create":201,"direct_target":404,"list":200,"exposes_other_project":true} +``` + +O acesso direto a B é recusado, mas seu título é retornado pelas relações de A. O filtro de autorização deve ser aplicado também à entidade relacionada, não apenas à origem da rota. + +## 2. Média — API permite escrever relações em PBI arquivado + +**Código:** `backend/src/modules/pbis/pbis.relations.repository.ts:220` e `:348`. + +A criação e a remoção verificam apenas o status do projeto. O PBI, sua feature ou seu épico podem estar arquivados e ainda receber alterações nas relações pela API. + +**Reprodução confirmada:** arquivar um PBI mantendo o projeto ativo; enviar `POST /api/v1/pbis/{id}/relations` com uma sessão PO e um destino ativo. + +```json +{"case":"escrita_em_pbi_arquivado","actual":201,"expected":409} +``` + +A verificação deve usar os PBIs envolvidos e seus ancestrais, sob os bloqueios de transação apropriados. A remoção merece a mesma validação, pois usa a mesma checagem incompleta. + +## 3. Média — limite da consulta permite dependência circular + +**Código:** `backend/src/modules/pbis/pbis.relations.repository.ts:154`. + +A busca recursiva para detectar ciclos para em profundidade 50 e trata ausência de resultado como ausência de ciclo. Um caminho existente com 51 arestas não é considerado. + +**Reprodução:** criar 52 PBIs e a cadeia P0 → P1 → … → P51; enviar a relação `P51 depende_de P0`. + +```json +{"case":"ciclo_com_51_dependencias","actual":201,"expected":400,"cycle_created":true} +``` + +É necessário percorrer o grafo com controle de visitados ou rejeitar explicitamente consultas truncadas, sem considerar o limite atingido como confirmação de que não existe ciclo. + +## Verificações complementares + +- Tipagem do backend aprovada. +- Suíte backend: 24 aprovados, 16 condicionais ignorados. +- Build da interface aprovado; suíte da interface com 20 testes aprovados. +- As três reproduções acima exercitam casos que não estavam cobertos pelos testes atuais. Checks verdes não contradizem esses defeitos. + +## Correções e regressões + +- A listagem recebe o usuário autenticado e aplica a política de acesso ao projeto relacionado, incluindo a validade da alocação. +- Criação e remoção validam ambos os PBIs e seus ancestrais após bloquear os projetos em ordem estável. +- A consulta de dependências percorre caminhos sem repetir nós, sem truncar silenciosamente em 50 níveis. +- A consulta de duplicidade também passou a selecionar as duas pontas usadas na validação. +- Testes PostgreSQL em `backend/src/modules/pbis/pbis.relations.db.test.ts` cobrem visibilidade com alocação ativa/encerrada, escrita nas duas pontas e ancestrais arquivados e ciclo com 51 arestas. +- A suíte integrada de indexação executa essas regressões automaticamente. + +## Segunda revisão da main + +A referência remota continua em `506de2a`. Os dois casos abaixo foram reproduzidos com uma cópia exata do repositório de relações dessa referência, em PostgreSQL temporário. + +### 4. Média — dependências circulares por operações concorrentes + +**Código:** `backend/src/modules/pbis/pbis.relations.repository.ts`, criação de relações e chamada de `checkDependencyCycle`. + +Com quatro PBIs de projetos distintos e relações A → B e C → D, executar simultaneamente B → C e D → A permite que ambas as transações validem o grafo antes das inserções. Os bloqueios cobrem apenas os projetos das duas pontas de cada chamada; essas chamadas têm conjuntos distintos de bloqueios. + +A reprodução sincronizou as duas chamadas depois de consultar os ciclos e antes de inserir. Ambas concluíram, formando A → B → C → D → A: + +```json +{"case":"concurrent_cycle","results":["fulfilled","fulfilled"],"edges":4,"cycle_created":true} +``` + +**Corrigido localmente:** a criação de dependências adquire um advisory lock transacional único antes dos bloqueios de projeto e da leitura/validação. Assim, alterações do grafo entre projetos também aguardam a transação anterior. O bloqueio é liberado no commit ou rollback. Criações de dependências ficam serializadas; similaridades e substituições não usam esse bloqueio global. + +### 5. Baixa — mensagem incorreta ao repetir relação dirigida + +**Código:** consulta `existingSame` em `pbis.relations.repository.ts` e tratamento de `23505` em `backend/src/middleware/errorHandler.ts`. + +A consulta da main seleciona apenas `id` e `tipo_relacao`, mas a validação usa `pbi_origem_id` e `pbi_destino_id`. Repetir a mesma dependência ignora o conflito de domínio e chega à restrição única do PostgreSQL: + +```json +{"case":"duplicate_directed_relation","actual_error":"23505","expected":"CONFLICT"} +``` + +O middleware traduz esse erro para HTTP 409 com a mensagem incorreta “Já existe um projeto ativo com este nome.” A duplicata não é gravada. **Já corrigido localmente** pela seleção das duas pontas na consulta; ainda não enviado à main. + +Os arquivos temporários de reprodução e o banco isolado foram removidos. Nenhum código de aplicação foi alterado durante esta segunda revisão. + +## Validação final das correções + +- Tipagem do backend aprovada. +- Suíte padrão: 24 testes aprovados; 18 testes condicionais ignorados sem banco configurado. +- Suíte PostgreSQL isolada: 33 testes de indexação aprovados e 1 ignorado por exigir IA real; 15 testes de relações/sugestões aprovados. +- O teste concorrente usa quatro projetos com bloqueios de pontas distintos. A primeira chamada pausa após a consulta de ciclos; a segunda aguarda o bloqueio do grafo. Após liberar a primeira, apenas uma inserção conclui; a outra retorna `ValidationError` de ciclo e restam três arestas. +- A repetição de `depende_de` e `substitui` retorna `ConflictError` 409 com mensagem de relação existente. +- O banco temporário foi removido; os dados locais de desenvolvimento foram preservados. +- Todas as correções permanecem locais, sem commit ou envio ao GitHub. + +## Terceira revisão — excluindo os cinco achados anteriores + +Foram inspecionados os caminhos de critérios, documentos, busca/chat, arquivamento e expurgo. Os arquivos envolvidos na nova reprodução não diferem da main. Esta revisão não constitui cobertura exaustiva de todas as funcionalidades. + +### 6. Média — ingestão entre serviços é bloqueada pela autenticação de usuário + +**Código:** `backend/src/app.ts:60`, `:112` e `:114`; contrato em `backend/src/modules/documents/chunks.routes.ts:12`. + +A rota `POST /api/v1/projects/{projectId}/documents/{documentId}/chunks` documenta autenticação pelo token de ingestão, sem sessão humana. Porém, a guarda global de projeto exige `requireAuth` antes de chegar ao roteador de chunks. Existem também montagens anteriores de roteadores autenticados que abrangem esse caminho. + +**Reprodução:** criar projeto e documento em banco temporário; iniciar a aplicação real; enviar lote válido de chunks com embedding de 1024 dimensões, usando o token esperado pela configuração e sem cookie de sessão. + +```json +{"case":"server_to_server_ingestion","actual_status":401,"body":{"error":"Sessão inválida ou expirada.","code":"UNAUTHORIZED"}} +``` + +O lote é rejeitado antes de `requireIngestToken`. Isso quebra esse callback de ingestão entre serviços; não significa que o worker interno de documentos tenha parado. + +**Correção indicada:** montar esse endpoint específico com sua autenticação de serviço antes das guardas de sessão, preservando a validação do vínculo entre documento/projeto e do arquivamento. As demais rotas de documentos devem continuar exigindo sessão e autorização de projeto. + +**Verificação sem falha:** criar três critérios, reordenar o terceiro para cima e excluir o primeiro concluiu corretamente, restando dois critérios. O indício de conflito de ordenação não se confirmou e não foi classificado como defeito. + +O banco e os dois arquivos temporários usados nesta revisão foram removidos. Nenhuma nova correção de aplicação foi feita durante a revisão. + +### Correção do callback de ingestão + +- O roteador específico de chunks passou a ser montado antes das guardas de sessão. Apenas seu POST com token de ingestão recebe esse tratamento; as rotas humanas continuam sob autenticação e acesso ao projeto. +- A validação transacional do documento, de seu projeto e do arquivamento permanece no serviço. +- O serviço preserva `ArchiveConflict` para retornar 409 em projeto arquivado, em vez de convertê-lo em 503. +- Regressões HTTP com a aplicação real cobrem token ausente/inválido, token válido sem cookie, envio idempotente, projeto incorreto, projeto arquivado e ausência de acesso às rotas humanas com token de serviço. +- Validação final: tipagem aprovada, 24 testes padrão aprovados e 54 testes na integração PostgreSQL aprovados (33 de indexação e 21 de documentos/relações/sugestões). Um teste integrado permanece ignorado por exigir IA real; a suíte padrão ignora 19 condicionais sem banco. Nenhuma falha na execução final. + +## Quarta revisão — ciclo de vida do callback e expurgo + +### 7. Alta — callback antigo republica documento após expurgo + +**Código:** `backend/src/modules/documents/chunks.service.ts`, método `persist`, em conjunto com `AdminRepository.purgeProjectContext`. + +Reprodução HTTP com a aplicação atual, incluindo a correção local de autenticação, em PostgreSQL temporário: + +1. Enviar chunks com o token de serviço e confirmar HTTP 200. +2. Expurgar o contexto pela implementação real do repositório administrativo. +3. Confirmar zero chunks e documento cancelado. +4. Reenviar o mesmo lote anterior, simulando uma resposta atrasada ou repetição da entrega. + +```json +{"case":"stale_callback_after_purge","after_purge":0,"callback_status":200,"chunks_restored":1,"document_status":"processado"} +``` + +O expurgo limpa a reserva do worker e retira o documento da fila, mas `ChunksService.persist` não exige reserva, geração ou estado compatível. Ele sobrescreve o índice e marca o documento como processado mesmo após o cancelamento. + +**Impacto:** o conteúdo removido do acervo volta a ficar disponível sem nova tentativa autorizada. É uma falha de ciclo de vida distinta do problema já corrigido de autenticação. + +**Corrigido localmente:** o callback exige `processamento_lease_id` emitido pelo backend ao reivindicar o documento. Na mesma transação da gravação, valida estado processando, identidade da reserva e prazo pelo relógio do PostgreSQL. O expurgo já limpa a reserva e passa a invalidar também esse callback. Uma nova tentativa recebe outra reserva; resultados antigos retornam 409. + +O banco e os arquivos temporários da reprodução foram removidos; nenhuma nova correção foi aplicada nesta revisão. A reprodução confirma o defeito na cópia local atual. O endpoint da main ainda é bloqueado pela sessão, conforme o achado anterior. + +### Validação da correção do expurgo + +- O resultado concluído registra a reserva nos metadados dos chunks. Repetir essa reserva retorna o resultado salvo sem alterar conteúdo nem IDs; uma reserva de outra execução é recusada. +- O teste HTTP usa expurgo real, nova tentativa real e claim real. Verifica zero chunks após callback cancelado, rejeição de reserva expirada, sucesso da nova reserva e preservação do conteúdo novo ao receber respostas antigas. +- O contrato OpenAPI exige a reserva no corpo. Integrações opcionais antigas precisam encaminhar esse campo; requisições apenas com token e chunks são rejeitadas. O worker interno continua usando seu contrato vigente. +- Resultado final: tipagem e leitura do OpenAPI aprovadas; 24 testes padrão e 56 testes integrados aprovados (33 de indexação e 23 de documentos/relações/sugestões). A suíte integrada ignora um caso que exige IA real. Nenhuma falha na execução final; banco temporário removido. + +## Conferência antes do envio + +Após atualizar as referências remotas, a main continua em `506de2a`. Seu conteúdo base é idêntico ao HEAD local antes das correções, sem divergência de arquivos para incorporar. + +Checks executados novamente, com sucesso: + +- Build e testes do backend: 24 aprovados; 19 condicionais ignorados sem banco. +- Build da interface e 20 testes aprovados. +- Scripts de desenvolvimento Windows. +- PostgreSQL isolado: 56 aprovados e 1 caso de IA real ignorado. +- Navegador autenticado: 4 testes aprovados. +- Build Docker da IA e compilação Python. +- Configuração do Compose com variáveis de validação. +- Validação dos quatro workflows n8n em contêiner descartável. +- Harness integrado e copiloto no navegador, com FastAPI e modelo Ollama simulado. +- Verificação de espaços/conflitos do diff. + +Não houve bloqueio reproduzido para o envio do conjunto atual. O CI remoto ainda deverá validar o commit enviado; a execução local foi no Windows, com serviços de IA/PostgreSQL em Linux. O teste do harness usa modelo simulado e não mede qualidade de um modelo local real. + +Capturas regeneradas foram restauradas e logs temporários removidos. As correções e seus dois novos arquivos de testes devem entrar no mesmo commit; os três documentos preexistentes não relacionados à correção permanecem preservados fora do escopo. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a9e1478..c4f9fd4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1297,7 +1297,7 @@ paths: get: operationId: listPbiRelations summary: Listar relações bidirecionais do PBI (PBI-01.5.3 / S2-15) - description: Lista todas as dependências, similaridades e substituições conectadas ao PBI (como origem ou destino). + description: Lista dependências, similaridades e substituições conectadas ao PBI (como origem ou destino), retornando apenas itens de projetos acessíveis ao usuário autenticado. security: - cookieAuth: [] parameters: @@ -1328,7 +1328,7 @@ paths: post: operationId: createPbiRelation summary: Criar relação entre PBIs com detecção de ciclo (PBI-01.5.3 / S2-15) - description: Registra uma relação (depende_de, similar_a ou substitui) com outro PBI e impede ciclos de dependência. + description: Registra uma relação (depende_de, similar_a ou substitui) com outro PBI. Impede ciclos de dependência inclusive em solicitações simultâneas entre projetos. Itens ou ancestrais arquivados são somente leitura; relações repetidas retornam conflito. security: - cookieAuth: [] parameters: @@ -3149,7 +3149,12 @@ paths: O fluxo canônico (worker) chama o ai-service direto e persiste pelos métodos internos do repository. Esta rota aceita `Authorization: Bearer ` separado da sessão de usuário. Cada embedding deve ter 1024 dimensões - (bge-m3). Substitui qualquer chunk anterior do mesmo documento. + (bge-m3). Exige processamento_lease_id da reserva emitida pelo backend + ao reivindicar o documento. Só grava com reserva vigente e estado processando. + Reservas expiradas, canceladas pelo expurgo ou substituídas retornam 409. + Repetir uma entrega concluída da mesma reserva retorna o resultado já salvo, + sem substituir os chunks. Chamadores antigos precisam enviar a reserva; + não há fallback para envio apenas com token de serviço. operationId: persistDocumentChunks security: - bearerAuth: [] @@ -3168,8 +3173,12 @@ paths: application/json: schema: type: object - required: [chunks] + required: [chunks, processamento_lease_id] properties: + processamento_lease_id: + type: string + format: uuid + description: Reserva recebida do backend ao reivindicar o processamento do documento. chunks: type: array items: @@ -3189,7 +3198,7 @@ paths: '400': {$ref: '#/components/responses/ValidationError'} '401': {description: Token de ingestão ausente ou inválido} '404': {description: Projeto ou documento não encontrados} - '409': {description: Projeto arquivado é somente leitura (ARCHIVE_CONFLICT)} + '409': {description: Projeto arquivado (ARCHIVE_CONFLICT) ou reserva expirada, cancelada ou substituída (CONFLICT)} /webhook/sinapse-document-removed: servers: