From 66c687b3816fac2b38260aab11314e2ecd16cab9 Mon Sep 17 00:00:00 2001 From: Moretto <72614882+Giomoret@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:30:19 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20refor=C3=A7ar=20permiss=C3=B5es,=20cadas?= =?UTF-8?q?tro=20e=20valida=C3=A7=C3=A3o=20da=20ingest=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/scripts/test-indexing-db.mts | 2 +- backend/src/modules/auth/auth.controller.ts | 10 +- .../modules/auth/auth.registration.db.test.ts | 123 +++++++++++++ backend/src/modules/auth/auth.repository.ts | 23 ++- backend/src/modules/auth/auth.routes.ts | 5 +- backend/src/modules/auth/auth.service.ts | 22 ++- .../modules/documents/chunks.http.db.test.ts | 6 + .../src/modules/documents/chunks.service.ts | 3 + .../documents/document-ingestion.test.ts | 37 ++++ .../modules/documents/document-ingestion.ts | 3 +- .../repo-analyses.routes.test.ts | 22 +++ .../repo-analyses/repo-analyses.routes.ts | 10 +- .../modules/search/search.access.db.test.ts | 125 +++++++++++++ backend/src/modules/search/search.routes.ts | 4 +- docs/QA-MAIN-POS-MERGE-2026-10-22.md | 170 ++++++++++++++++++ docs/api/openapi.yaml | 17 +- 16 files changed, 553 insertions(+), 29 deletions(-) create mode 100644 backend/src/modules/auth/auth.registration.db.test.ts create mode 100644 backend/src/modules/documents/document-ingestion.test.ts create mode 100644 backend/src/modules/search/search.access.db.test.ts create mode 100644 docs/QA-MAIN-POS-MERGE-2026-10-22.md diff --git a/backend/scripts/test-indexing-db.mts b/backend/scripts/test-indexing-db.mts index ba47ba3..8d67ce7 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", "src/modules/pbis/pbis.relations.db.test.ts", "src/modules/documents/chunks.http.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", "src/modules/search/search.access.db.test.ts", "src/modules/auth/auth.registration.db.test.ts"], { env: { ...process.env, NODE_ENV: "test", DATABASE_URL: url, ARCHIVE_TEST_DATABASE_URL: url }, stdio: "inherit", }); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 1f42260..033d276 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -40,9 +40,9 @@ export class AuthController { return; } + const token = extractSessionToken(req); + const session = token ? await this.sessions.validateSession(token) : null; if (parsed.data.role !== "dev") { - const token = extractSessionToken(req); - const session = token ? await this.sessions.validateSession(token) : null; if (!session?.valid || session.user.role !== "admin") { res.status(403).json({ error: "Somente administradores podem criar contas de PO ou administrador.", @@ -52,7 +52,7 @@ export class AuthController { } } - const result = await this.service.register(parsed.data); + const result = await this.service.register(parsed.data, !session?.valid); if (!result.success) { if (result.reason === "email_exists") { @@ -63,7 +63,7 @@ export class AuthController { return; } } else { - res.cookie(SESSION_COOKIE_NAME, result.token, { + if (result.token) res.cookie(SESSION_COOKIE_NAME, result.token, { httpOnly: true, secure: env.NODE_ENV === "production" && !env.COOKIE_INSECURE_DEV, sameSite: "lax", @@ -182,4 +182,4 @@ export class AuthController { }; } -export const authController = new AuthController(); \ No newline at end of file +export const authController = new AuthController(); diff --git a/backend/src/modules/auth/auth.registration.db.test.ts b/backend/src/modules/auth/auth.registration.db.test.ts new file mode 100644 index 0000000..e8de486 --- /dev/null +++ b/backend/src/modules/auth/auth.registration.db.test.ts @@ -0,0 +1,123 @@ +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"; + +test("Cadastro preserva sessões existentes e trata concorrência", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async t => { + const { app } = await import("../../app.js"); + const { pool } = await import("../../database/db.js"); + const { sessionService } = await import("./session.service.js"); + const { authRepository } = await import("./auth.repository.js"); + const db = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const [admin, dev] = [randomUUID(), randomUUID()]; + const emails: string[] = []; + 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"; + const data = (role = "dev", email = randomUUID() + "@registration.test") => { + emails.push(email); + return { nome: "Conta QA", email, password: randomUUID(), role }; + }; + const register = (body: unknown, cookie?: string) => fetch(base + "/auth/register", { + method: "POST", headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, + body: JSON.stringify(body), + }); + try { + for (const [id, role] of [[admin,"admin"],[dev,"dev"]]) { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'QA',$2,'h',$3)", [id,id + "@registration.test",role]); + } + const adminCookie = "sinapse_session=" + (await sessionService.createSession(admin)).token; + const devCookie = "sinapse_session=" + (await sessionService.createSession(dev)).token; + await t.test("admin cria PO, admin e dev sem trocar sessão ou emitir token da outra conta", async () => { + for (const role of ["po","admin","dev"]) { + const response = await register(data(role), adminCookie); + assert.equal(response.status, 201); + assert.equal(response.headers.get("set-cookie"), null); + const result = await response.json() as { user: { id: string; role: string }; token?: string }; + assert.equal(result.user.role, role); + assert.equal(result.token, undefined); + assert.equal((await db.query("SELECT count(*)::int AS n FROM sessao WHERE usuario_id=$1", [result.user.id])).rows[0].n, 0); + } + const me = await fetch(base + "/auth/me", {headers:{Cookie:adminCookie}}); + assert.equal((await me.json() as {user:{role:string}}).user.role, "admin"); + assert.equal((await fetch(base + "/admin/stats", {headers:{Cookie:adminCookie}})).status,200); + }); + await t.test("cadastro público de dev mantém login automático", async () => { + const response = await register(data()); + assert.equal(response.status, 201); + const result = await response.json() as {token:string;user:{id:string}}; + assert.ok(result.token); + assert.match(response.headers.get("set-cookie") ?? "", /sinapse_session=/); + assert.equal((await db.query("SELECT count(*)::int AS n FROM sessao WHERE usuario_id=$1", [result.user.id])).rows[0].n, 1); + }); + await t.test("visitante e dev não cadastram PO ou admin", async () => { + for (const cookie of [undefined,devCookie]) { + for (const role of ["po","admin"]) { + const body = data(role); + assert.equal((await register(body,cookie)).status,403); + assert.equal((await db.query("SELECT count(*)::int AS n FROM usuario WHERE email=$1", [body.email])).rows[0].n,0); + } + } + }); + await t.test("cadastros simultâneos do mesmo e-mail retornam EMAIL_EXISTS sem mensagem de projeto", async st => { + const body = data(); + const original = authRepository.findUserByEmail.bind(authRepository); + let arrivals = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + st.mock.method(authRepository, "findUserByEmail", async (email: string) => { + const result = await original(email); + if (email === body.email) { + if (++arrivals === 2) release(); + await gate; + } + return result; + }); + const responses = await Promise.all([register(body),register(body)]); + assert.deepEqual(responses.map(r => r.status).sort(), [201,409]); + const duplicate = responses.find(r => r.status === 409)!; + const error = await duplicate.json() as {code:string;error:string}; + assert.equal(error.code,"EMAIL_EXISTS"); + assert.doesNotMatch(error.error,/projeto/i); + assert.equal((await db.query("SELECT count(*)::int AS n FROM usuario WHERE email=$1",[body.email])).rows[0].n,1); + }); + await t.test("falha ao gravar a sessão desfaz a conta e permite repetir o cadastro", async () => { + const body = data(); + try { + await db.query("CREATE TABLE qa_registration_failure(email text PRIMARY KEY)"); + await db.query("INSERT INTO qa_registration_failure(email) VALUES ($1)", [body.email]); + await db.query(`CREATE FUNCTION qa_registration_failure() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF EXISTS (SELECT 1 FROM usuario u JOIN qa_registration_failure q ON q.email=u.email WHERE u.id=NEW.usuario_id) + THEN RAISE EXCEPTION 'QA_SESSION_FAILURE'; END IF; + RETURN NEW; + END $$`); + await db.query("CREATE TRIGGER qa_registration_failure BEFORE INSERT ON sessao FOR EACH ROW EXECUTE FUNCTION qa_registration_failure()"); + const failed = await register(body); + assert.equal(failed.status, 500); + assert.equal(failed.headers.get("set-cookie"), null); + assert.equal((await db.query("SELECT count(*)::int AS n FROM usuario WHERE email=$1", [body.email])).rows[0].n, 0); + await db.query("DROP TRIGGER qa_registration_failure ON sessao"); + const retried = await register(body); + assert.equal(retried.status, 201); + const result = await retried.json() as { token: string; user: { id: string } }; + assert.ok(result.token); + assert.equal((await db.query("SELECT count(*)::int AS n FROM usuario WHERE email=$1", [body.email])).rows[0].n, 1); + assert.equal((await db.query("SELECT count(*)::int AS n FROM sessao WHERE usuario_id=$1", [result.user.id])).rows[0].n, 1); + const me = await fetch(base + "/auth/me", { headers: { Authorization: "Bearer " + result.token } }); + assert.equal(me.status, 200); + } finally { + await db.query("DROP TRIGGER IF EXISTS qa_registration_failure ON sessao"); + await db.query("DROP FUNCTION IF EXISTS qa_registration_failure()"); + await db.query("DROP TABLE IF EXISTS qa_registration_failure"); + } + }); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await db.query("DELETE FROM usuario WHERE id=ANY($1::uuid[]) OR email=ANY($2::text[])",[[admin,dev],emails]); + await db.end(); + await pool.end(); + } +}); diff --git a/backend/src/modules/auth/auth.repository.ts b/backend/src/modules/auth/auth.repository.ts index 7f9f379..9802f0e 100644 --- a/backend/src/modules/auth/auth.repository.ts +++ b/backend/src/modules/auth/auth.repository.ts @@ -1,4 +1,5 @@ -import { Pool } from "pg"; +import { Pool, type PoolClient } from "pg"; +import { withTransaction } from "../../database/transaction.js"; import { pool } from "../../database/db.js"; import { @@ -20,8 +21,8 @@ export class AuthRepository { email: string; senha_hash: string; role?: UserRole; - }): Promise { - const result = await this.pool.query( + }, executor: Pool | PoolClient = this.pool): Promise { + const result = await executor.query( ` INSERT INTO usuario ( nome, @@ -48,6 +49,19 @@ export class AuthRepository { return result.rows[0]; } + async createRegistration(data: { + nome: string; + email: string; + senha_hash: string; + role?: UserRole; + }, tokenHash?: string): Promise { + return withTransaction(this.pool, async client => { + const user = await this.createUser(data, client); + if (tokenHash) await this.createSession(user.id, tokenHash, client); + return user; + }); + } + async findUserByEmail(email: string): Promise { const result = await this.pool.query( ` @@ -124,8 +138,9 @@ export class AuthRepository { async createSession( userId: string, tokenHash: string, + executor: Pool | PoolClient = this.pool, ): Promise { - const result = await this.pool.query( + const result = await executor.query( ` INSERT INTO sessao ( usuario_id, diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts index ed0f206..434bd3d 100644 --- a/backend/src/modules/auth/auth.routes.ts +++ b/backend/src/modules/auth/auth.routes.ts @@ -9,7 +9,8 @@ export const authRouter = Router(); * @swagger * /api/v1/auth/register: * post: - * summary: Registrar novo usuário + * summary: Registrar novo usuário preservando a sessão existente + * description: Cadastro público de dev inicia sessão; cadastro com sessão válida não substitui cookie nem retorna token da nova conta. PO e admin exigem administrador. * tags: [Auth] * requestBody: * required: true @@ -141,4 +142,4 @@ authRouter.post("/logout", requireAuth, authController.logout); * 401: * description: Não autenticado */ -authRouter.get("/me", requireAuth, authController.me); \ No newline at end of file +authRouter.get("/me", requireAuth, authController.me); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 17a81c5..c13b719 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,4 +1,5 @@ import { env } from "../../config/env.js"; +import { createHash, randomBytes } from "node:crypto"; import { authRepository } from "./auth.repository.js"; import { hashPassword, @@ -14,12 +15,12 @@ import { } from "./auth.types.js"; interface AuthRepositoryPort { - createUser(data: { + createRegistration(data: { nome: string; email: string; senha_hash: string; role?: UserRole; - }): Promise; + }, tokenHash?: string): Promise; findUserByEmail(email: string): Promise; @@ -64,7 +65,7 @@ export type LoginResult = export type RegisterResult = | { success: true; - token: string; + token?: string; user: { id: string; nome: string; @@ -192,7 +193,7 @@ export class AuthService { }; } - async register(data: RegisterDTO): Promise { + async register(data: RegisterDTO, autoLogin = true): Promise { const email = data.email.trim().toLowerCase(); const existingUser = await this.repository.findUserByEmail(email); @@ -206,14 +207,19 @@ export class AuthService { const senha_hash = await hashPassword(data.password); - const user = await this.repository.createUser({ + const token = autoLogin ? randomBytes(32).toString("hex") : undefined; + const tokenHash = token ? createHash("sha256").update(token).digest("hex") : undefined; + const user = await this.repository.createRegistration({ nome: data.nome, email, senha_hash, role: data.role, + }, tokenHash).catch((error: unknown) => { + const duplicate = error as { code?: string; constraint?: string }; + if (duplicate?.code === "23505" && duplicate.constraint === "usuario_email_key") return null; + throw error; }); - - const { token } = await this.sessions.createSession(user.id); + if (!user) return { success: false, reason: "email_exists" }; return { success: true, @@ -228,4 +234,4 @@ export class AuthService { } } -export const authService = new AuthService(); \ No newline at end of file +export const authService = new AuthService(); diff --git a/backend/src/modules/documents/chunks.http.db.test.ts b/backend/src/modules/documents/chunks.http.db.test.ts index 4a06c48..4a954db 100644 --- a/backend/src/modules/documents/chunks.http.db.test.ts +++ b/backend/src/modules/documents/chunks.http.db.test.ts @@ -58,6 +58,12 @@ test("Callback de chunks autentica o serviço e preserva as guardas de documento 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("callback rejeita embedding nulo sem alterar o conteúdo salvo", async () => { + const before = (await db.query("SELECT id,texto FROM chunk WHERE entidade_id=$1", [document])).rows; + const response = await send(endpoint(), token, { ...payload, chunks: [{ ...payload.chunks[0], embedding: Array(1024).fill(0) }] }); + assert.equal(response.status, 400); + assert.deepEqual((await db.query("SELECT id,texto FROM chunk WHERE entidade_id=$1", [document])).rows, before); + }); await t.test("não permite associar documento ao projeto errado", async () => { const response = await send(endpoint(otherProject)); assert.equal(response.status, 404); diff --git a/backend/src/modules/documents/chunks.service.ts b/backend/src/modules/documents/chunks.service.ts index 1ce66ed..b8b04df 100644 --- a/backend/src/modules/documents/chunks.service.ts +++ b/backend/src/modules/documents/chunks.service.ts @@ -48,6 +48,9 @@ function assertChunk(chunk: unknown, position: number): asserts chunk is Incomin throw new ValidationError(`Chunk ${position} tem valor não numérico no embedding.`); } } + if (candidate.embedding.every(value => value === 0)) { + throw new ValidationError(`Chunk ${position} com embedding nulo.`); + } } function toVectorLiteral(embedding: number[]): string { diff --git a/backend/src/modules/documents/document-ingestion.test.ts b/backend/src/modules/documents/document-ingestion.test.ts new file mode 100644 index 0000000..905403c --- /dev/null +++ b/backend/src/modules/documents/document-ingestion.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import axios from "axios"; +import { HttpDocumentIngestionClient } from "./document-ingestion.js"; +import { AppError } from "../../shared/errors.js"; + +const source = { id: "10000000-0000-4000-8000-000000000001", projeto_id: "20000000-0000-4000-8000-000000000001", nome: "unicode.txt" }; +const cases = [ + { name: "ASCII no limite", text: "a".repeat(1000), valid: true }, + { name: "emojis no limite de pontos de código", text: "😀".repeat(1000), valid: true }, + { name: "texto misto no limite", text: ("texto 😀 ").repeat(125), valid: true }, + { name: "ASCII acima do limite", text: "a".repeat(1001), valid: false }, + { name: "Unicode acima do limite", text: "😀".repeat(1001), valid: false }, + { name: "embedding nulo", text: "Documento válido", valid: false, zero: true }, +]; +for (const item of cases) { + test("Ingestão: " + item.name, async t => { + const embedding = Array(1024).fill(item.zero ? 0 : 0.1); + t.mock.method(axios, "post", async () => ({ data: { + document_id: source.id, project_id: source.projeto_id, + chunks: [{ chunk_index: 0, text: item.text, embedding, + metadata: { document_id: source.id, project_id: source.projeto_id, source_name: source.nome } }], + } })); + const client = new HttpDocumentIngestionClient("http://test", "x".repeat(32)); + if (item.valid) { + const chunks = await client.process(source, Buffer.from(item.text)); + assert.equal(chunks[0]?.text, item.text); + assert.deepEqual(chunks[0]?.embedding, embedding); + } else { + await assert.rejects(client.process(source, Buffer.from(item.text)), error => { + assert.ok(error instanceof AppError); + assert.equal(error.code, "DOCUMENT_PROCESSING_INVALID"); + return true; + }); + } + }); +} diff --git a/backend/src/modules/documents/document-ingestion.ts b/backend/src/modules/documents/document-ingestion.ts index badb4cc..f397434 100644 --- a/backend/src/modules/documents/document-ingestion.ts +++ b/backend/src/modules/documents/document-ingestion.ts @@ -40,9 +40,10 @@ export class HttpDocumentIngestionClient implements DocumentIngestionClient { if (typeof chunk !== "object" || chunk === null) throw new Error("malformed chunk"); const candidate = chunk as Record; const metadata = candidate.metadata as Record | null; - if (candidate.chunk_index !== index || typeof candidate.text !== "string" || !candidate.text.trim() || candidate.text.length > 1000 + if (candidate.chunk_index !== index || typeof candidate.text !== "string" || !candidate.text.trim() || [...candidate.text].length > 1000 || !Array.isArray(candidate.embedding) || candidate.embedding.length !== 1024 || candidate.embedding.some((value) => typeof value !== "number" || !Number.isFinite(value)) + || candidate.embedding.every(value => value === 0) || typeof metadata !== "object" || metadata === null || metadata.document_id !== document.id || metadata.project_id !== document.projeto_id || metadata.source_name !== document.nome) throw new Error("invalid chunk data"); diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts index 7a165b9..25a2719 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts @@ -160,6 +160,28 @@ test('pausa, retoma e cancela uma análise autenticada e vinculada ao projeto', assert.equal((await call(`/${ARCHIVED}/repo-analyses/${ANALYSIS}/resume`, { method: 'POST', json: {} })).status, 409); }); +test('início e controles exigem PO/admin; desenvolvedor mantém leitura', async () => { + for (const role of ['admin', 'po', 'dev']) { + const headers = { 'Content-Type': 'application/json', 'x-test-user': 'ana', 'x-test-role': role }; + const started = service.started.length; + const controlled = service.controls.length; + const response = await fetch(baseUrl + `/${ACTIVE}/repo-analyses`, { + method: 'POST', headers, body: JSON.stringify({ repositorio_url: 'https://github.com/acme/api' }), + }); + assert.equal(response.status, role === 'dev' ? 403 : 201); + for (const action of ['pause', 'resume', 'cancel']) { + const result = await fetch(baseUrl + `/${ACTIVE}/repo-analyses/${ANALYSIS}/${action}`, { method: 'POST', headers }); + assert.equal(result.status, role === 'dev' ? 403 : 200); + } + if (role === 'dev') { + assert.equal(service.started.length, started); + assert.equal(service.controls.length, controlled); + } + assert.equal((await fetch(baseUrl + `/${ACTIVE}/repo-analyses`, { headers })).status, 200); + assert.equal((await fetch(baseUrl + `/${ACTIVE}/repo-analyses/${ANALYSIS}`, { headers })).status, 200); + } +}); + test('S2-23: reprocessamento exige autenticacao, escrita, projeto ativo e origem correta', async () => { const path = `/${ACTIVE}/repo-analyses/${ANALYSIS}/indexing/retry`; assert.equal((await call(path, { method: 'POST', json: {}, auth: false })).status, 401); diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.ts index 9dc17c7..3217d9b 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.routes.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.ts @@ -1,5 +1,6 @@ import { Router, type NextFunction, type Request, type RequestHandler, type Response } from 'express'; import { requireAuth } from '../../middleware/requireAuth'; +import { requireRole } from '../../middleware/requireRole'; import { AppError, NotFoundError, ValidationError, validateUuid } from '../../shared/errors'; import { ArchiveConflict } from '../projects/archive.types'; import { ProjectsRepository } from '../projects/projects.repository'; @@ -15,6 +16,7 @@ export function createRepoAnalysesRouter( authentication: RequestHandler = requireAuth, ): Router { const router = Router({ mergeParams: true }); + const canWrite = requireRole('admin', 'po'); const loadProject = async (req: Request, res: Response, next: NextFunction) => { try { @@ -51,7 +53,7 @@ export function createRepoAnalysesRouter( * 503: * description: Motor de análise indisponível */ - router.post('/', async (req: Request, res: Response, next: NextFunction) => { + router.post('/', canWrite, async (req: Request, res: Response, next: NextFunction) => { try { if (res.locals.projectStatus === 'arquivado') { throw new ArchiveConflict('Projeto arquivado é somente leitura e não recebe novas análises.'); @@ -86,9 +88,9 @@ export function createRepoAnalysesRouter( } }; - router.post('/:id/pause', control('pause')); - router.post('/:id/resume', control('resume')); - router.post('/:id/cancel', control('cancel')); + router.post('/:id/pause', canWrite, control('pause')); + router.post('/:id/resume', canWrite, control('resume')); + router.post('/:id/cancel', canWrite, control('cancel')); router.post('/:id/report/approve', async (req: Request, res: Response, next: NextFunction) => { try { const id = String(req.params.id); diff --git a/backend/src/modules/search/search.access.db.test.ts b/backend/src/modules/search/search.access.db.test.ts new file mode 100644 index 0000000..5eb1f3a --- /dev/null +++ b/backend/src/modules/search/search.access.db.test.ts @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; + +test("Busca e leituras relacionadas respeitam o acesso ao projeto", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async t => { + let embeddingCalls = 0; + const model = createServer(async (req, res) => { + embeddingCalls++; + for await (const _ of req) { /* Consome a requisição local de teste. */ } + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ embedding: Array(1024).fill(0.1) })); + }); + await new Promise(resolve => model.listen(0, "127.0.0.1", resolve)); + const originalAiUrl = process.env.AI_SERVICE_URL; + process.env.AI_SERVICE_URL = "http://127.0.0.1:" + (model.address() as { port: number }).port; + const { app } = await import("../../app.js"); + const { pool } = await import("../../database/db.js"); + const { sessionService } = await import("../auth/session.service.js"); + const db = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const [projectA, projectB, document, chunk, epic, feature, pbi] = Array.from({ length: 7 }, () => randomUUID()); + const users = Array.from({ length: 6 }, () => randomUUID()); + const tokens: string[] = []; + 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"; + const request = (path: string, token?: string, method = "GET", body?: unknown) => fetch(base + path, { + method, headers: { ...(token ? { Authorization: "Bearer " + token } : {}), ...(body ? { "Content-Type": "application/json" } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const search = (project: string, alias = "projeto_id") => "/search?q=confidencial&" + alias + "=" + project; + try { + for (const project of [projectA, projectB]) { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES ($1,$2,'QA','ativo')", [project, project]); + } + for (const [i, user] of users.entries()) { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'QA',$2,'h',$3)", [user, user + "@search.test", i === 0 ? "admin" : i === 1 ? "po" : "dev"]); + if (i >= 2) { + const developer = randomUUID(); + await db.query("INSERT INTO desenvolvedor(id,usuario_id) VALUES ($1,$2)", [developer, user]); + const start = i === 5 ? new Date(Date.now() + 86400000) : new Date(Date.now() - 86400000); + const end = i === 4 ? new Date(Date.now() - 1000) : null; + await db.query("INSERT INTO alocacao(desenvolvedor_id,projeto_id,data_inicio,data_fim) VALUES ($1,$2,$3,$4)", + [developer, i === 3 ? projectA : projectB, start, end]); + } + tokens.push((await sessionService.createSession(user)).token); + } + await db.query("INSERT INTO epico(id,projeto_id,titulo) VALUES ($1,$2,'Épico restrito')", [epic, projectB]); + await db.query("INSERT INTO feature(id,epico_id,titulo) VALUES ($1,$2,'Feature restrita')", [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 confidencial','PO','x','y')", [pbi, feature, pbi]); + await db.query("INSERT INTO documento(id,projeto_id,nome,caminho,status_processamento) VALUES ($1,$2,'Restrito.txt','qa.txt','processado')", [document, projectB]); + await db.query("INSERT INTO chunk(id,projeto_id,entidade_tipo,entidade_id,texto,metadados_json,embedding) VALUES ($1,$2,'documento',$3,'Conteúdo confidencial',$4::jsonb,$5::vector)", + [chunk, projectB, document, JSON.stringify({ chunk_index: 0 }), JSON.stringify(Array(1024).fill(0.1))]); + await t.test("admin, PO e desenvolvedor alocado recuperam o documento", async () => { + for (const token of tokens.slice(0, 3)) { + const response = await request(search(projectB), token); + assert.equal(response.status, 200); + const result = await response.json() as { items: Array<{ id: string }> }; + assert.ok(result.items.some(row => row.id === chunk)); + } + }); + await t.test("sem alocação, alocação encerrada e futura não consultam embeddings nem conteúdo", async () => { + const previous = embeddingCalls; + for (const token of tokens.slice(3)) { + for (const alias of ["projeto_id", "projectId"]) { + const response = await request(search(projectB, alias), token); + assert.equal(response.status, 404); + assert.doesNotMatch(await response.text(), /Conteúdo confidencial/); + } + } + assert.equal(embeddingCalls, previous); + }); + await t.test("desenvolvedor lê análises, mas não inicia nem altera uma análise do PO", async () => { + const path = "/projects/" + projectB + "/repo-analyses"; + const started = await request(path, tokens[1], "POST", { repositorio_url: "https://github.com/example/repository" }); + assert.equal(started.status, 201); + const analysis = await started.json() as { id: string }; + assert.equal((await request(path, tokens[2], "POST", { repositorio_url: "https://github.com/example/repository" })).status, 403); + for (const action of ["pause", "resume", "cancel"]) { + assert.equal((await request(path + "/" + analysis.id + "/" + action, tokens[2], "POST")).status, 403); + } + assert.equal((await db.query("SELECT status FROM analise_repositorio WHERE id=$1", [analysis.id])).rows[0].status, "iniciado"); + assert.equal((await db.query("SELECT count(*)::int AS n FROM analise_repositorio WHERE projeto_id=$1", [projectB])).rows[0].n, 1); + assert.equal((await request(path, tokens[2])).status, 200); + assert.equal((await request(path + "/" + analysis.id, tokens[2])).status, 200); + assert.equal((await request(path + "/" + analysis.id + "/cancel", tokens[1], "POST")).status, 200); + }); + await t.test("retirar a alocação revoga a busca na mesma sessão", async () => { + await db.query("UPDATE alocacao SET data_fim=CURRENT_TIMESTAMP-INTERVAL '1 second' WHERE desenvolvedor_id IN (SELECT id FROM desenvolvedor WHERE usuario_id=$1)", [users[2]]); + assert.equal((await request(search(projectB), tokens[2])).status, 404); + }); + await t.test("sessão ausente, projeto inexistente e parâmetro inválido são recusados", async () => { + assert.equal((await request(search(projectB))).status, 401); + assert.equal((await request(search(randomUUID()), tokens[0])).status, 404); + assert.equal((await request(search("invalido"), tokens[0])).status, 400); + }); + await t.test("documentos, histórico, qualidade e rotas legadas também bloqueiam outro projeto", async () => { + for (const path of [ + "/projects/" + projectB, + "/projects/" + projectB + "/documents", + "/projects/" + projectB + "/documents/" + document + "/excerpts?chunk_id=" + chunk, + "/audit/pbi/" + pbi + "/history", + "/quality/pbis/" + pbi + "/quality", + "/epicos/" + epic, + "/projects/" + projectB + "/epicos", + ]) { + assert.equal((await request(path, tokens[3])).status, 404, path); + } + assert.equal((await request("/chat/conversations", tokens[3], "POST", { projeto_id: projectB })).status, 404); + }); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + model.closeAllConnections(); + await new Promise(resolve => model.close(() => resolve())); + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [[projectA, projectB]]); + await db.query("DELETE FROM usuario WHERE id=ANY($1::uuid[])", [users]); + await db.end(); + await pool.end(); + if (originalAiUrl === undefined) delete process.env.AI_SERVICE_URL; + else process.env.AI_SERVICE_URL = originalAiUrl; + } +}); diff --git a/backend/src/modules/search/search.routes.ts b/backend/src/modules/search/search.routes.ts index 9c3ef01..16d821a 100644 --- a/backend/src/modules/search/search.routes.ts +++ b/backend/src/modules/search/search.routes.ts @@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction, type RequestHandler } from "ex import { requireAuth } from "../../middleware/requireAuth.js"; import { ValidationError, validateUuid } from "../../shared/errors.js"; import { parseSearchLevel, SearchService } from "./search.service.js"; +import { ProjectAccess } from "../projects/project-access.js"; const textQuery = (value: unknown, field: string): string | undefined => { if (value === undefined) return undefined; @@ -9,7 +10,7 @@ const textQuery = (value: unknown, field: string): string | undefined => { return value; }; -export function createSearchRouter(service: SearchService = new SearchService(), auth: RequestHandler = requireAuth) { +export function createSearchRouter(service: SearchService = new SearchService(), auth: RequestHandler = requireAuth, access = new ProjectAccess()) { const router = Router(); router.use(auth); @@ -29,6 +30,7 @@ export function createSearchRouter(service: SearchService = new SearchService(), const parsedLimit = limitValue === undefined ? 10 : (/^(?:[1-9]|[1-4]\d|50)$/.test(limitValue) ? Number(limitValue) : Number.NaN); if (!Number.isInteger(parsedLimit)) throw new ValidationError("O limite deve ser um inteiro entre 1 e 50."); + await access.assertEntity(req.auth!.id, "projeto", projectId); const result = await service.search({ query, projectId, diff --git a/docs/QA-MAIN-POS-MERGE-2026-10-22.md b/docs/QA-MAIN-POS-MERGE-2026-10-22.md new file mode 100644 index 0000000..79776db --- /dev/null +++ b/docs/QA-MAIN-POS-MERGE-2026-10-22.md @@ -0,0 +1,170 @@ +# QA após merge da PR #58 + +Referência: main `34a80ad82d091e688c51f804332e9b1c6ec37047`. O código local revisado tem o mesmo conteúdo dessa referência. As correções anteriores foram preservadas. + +## Alta — busca híbrida não autoriza acesso ao projeto consultado + +**Código:** `backend/src/modules/search/search.routes.ts:32` e `backend/src/modules/search/search.service.ts:46`. + +A rota exige sessão, mas não encaminha o usuário autenticado à consulta. O serviço verifica apenas se o projeto existe. A guarda global de entidades protege URLs de projeto, porém não a rota `/search`, cujo projeto é informado por query string. + +O filtro SQL por projeto evita misturar resultados de projetos diferentes, mas não verifica se o usuário pode consultar aquele projeto. + +### Reprodução confirmada + +1. Criar dois projetos A e B. +2. Alocar um desenvolvedor apenas em A. +3. Inserir documento processado e chunk indexado em B. +4. Criar sessão real do desenvolvedor. +5. Consultar `GET /api/v1/projects/{B}`: HTTP 404. +6. Consultar `GET /api/v1/search?q=confidencial&projeto_id={B}`: HTTP 200, incluindo o chunk restrito de B. + +```json +{"case":"search_cross_project","direct_status":404,"search_status":200,"returns_restricted_chunk":true} +``` + +Reproduzido com a aplicação real, PostgreSQL/pgvector temporário e um serviço de embeddings determinístico. O mesmo comportamento foi confirmado inicialmente com chunk demonstrativo e depois com documento processado. Os dados são sintéticos. + +**Impacto:** qualquer usuário autenticado que conheça o ID de outro projeto pode consultar seu acervo por essa rota, mesmo sem alocação. + +**Correção indicada:** validar a autorização do projeto antes de chamar embeddings ou consultar chunks, usando a política existente `ProjectAccess`. Cobrir PO/admin, desenvolvedor alocado, sem alocação e alocação encerrada. A interface não substitui a autorização da API. + +**Estado:** corrigido localmente após a revisão. A rota usa `ProjectAccess.assertEntity` antes de chamar o serviço de busca. Banco e arquivos temporários da reprodução removidos; dados locais preservados. + +Esta revisão não comprova ausência de outros defeitos. Os checks verdes anteriores não cobrem esse cenário de acesso ao endpoint de busca. + +## Correção e revisão complementar + +- A busca aplica a mesma política usada pelas rotas de projeto, inclusive pelos aliases `projeto_id` e `projectId`. +- A regressão HTTP cobre admin/PO, desenvolvedor alocado, alocação encerrada, alocação futura, ausência de alocação, revogação na mesma sessão, sessão ausente e projeto inválido/inexistente. +- As recusas por falta de acesso acontecem antes de qualquer chamada de embeddings. +- Foram exercitadas também leituras de projeto/documentos/trechos, histórico, qualidade, épicos legados e criação de conversa. Todos os caminhos testados recusaram o projeto sem acesso. +- A inspeção complementar das listagens de épicos/features/PBIs, critérios, decisões, cadastro de usuários e administração não confirmou outra falha nesta rodada. Cadastro privilegiado já exige sessão de administrador; não houve mudança nesse fluxo. +- A suíte PostgreSQL executou 62 testes com sucesso (33 de indexação e 29 de documentos/relações/busca/sugestões); um caso que exige IA real foi ignorado. +- Tipagem e suíte padrão do backend aprovadas: 24 testes passaram, 20 condicionais foram ignorados sem banco. +- Os quatro testes de navegador autenticado passaram; o OpenAPI foi validado. Bancos e logs temporários foram removidos. +- As correções ainda não foram commitadas ou enviadas ao GitHub. + +## Nova revisão — caracteres Unicode no processamento de documentos + +### Média — trechos válidos com emojis são rejeitados pelo backend + +**Código:** `backend/src/modules/documents/document-ingestion.ts`, validação `candidate.text.length > 1000`; `ai-service/services/chunker.py`, divisão em trechos de 1000 caracteres. + +O Python conta pontos de código Unicode. JavaScript `String.length` conta unidades UTF-16; emojis e outros caracteres fora do plano básico ocupam duas unidades. Assim, um trecho que respeita o limite Python pode ultrapassar o limite JavaScript. + +**Reprodução:** executar o chunker Python real da imagem de IA sobre texto com emojis e encaminhar sua saída ao cliente real de ingestão do backend, com escopo, metadados e vetor válidos. Apenas o transporte HTTP e os embeddings foram simulados. + +```json +{"case":"unicode_document","python_codepoints":1000,"js_length":1125,"actual_error":"DOCUMENT_PROCESSING_INVALID","ascii_control":"passed"} +``` + +Um controle com 1000 caracteres ASCII passou. O trecho Unicode válido foi recusado. No worker, essa recusa deixa o documento em falha em vez de concluir sua indexação. + +**Correção indicada:** alinhar a unidade de contagem do limite no backend ao chunker Python, preservando o limite de 1000 pontos de código e os demais checks. Adicionar controles para textos Unicode válidos e trechos realmente acima do limite. + +**Estado:** corrigido localmente. A validação agora conta pontos de código Unicode, como o Python. O script e o servidor temporários foram removidos; nenhum dado de desenvolvimento foi alterado. A correção de autorização da busca permanece local e preservada. + +### Média — ingestão de documentos aceitava embeddings nulos + +O cliente de ingestão verificava dimensão e valores finitos, mas aceitava um vetor de 1024 zeros. O callback de chunks também não rejeitava esse vetor. Um vetor nulo não possui direção útil para a similaridade por cosseno usada na busca. + +**Reprodução antes da correção:** uma resposta de processamento com texto, metadados e 1024 zeros foi aceita pelo cliente; a regressão que esperava rejeição falhou com `Missing expected rejection`. A proteção existente no worker de relatórios não abrangia o caminho de documentos. + +**Corrigido localmente:** o cliente e o callback rejeitam vetores inteiramente zerados. A regressão HTTP comprova resposta 400 e preservação dos chunks anteriores. + +## Validação das novas correções + +- Seis regressões de ingestão: ASCII/Unicode no limite, texto misto, ASCII/Unicode acima do limite e embedding nulo. +- As regressões demonstraram três falhas antes da correção (dois textos Unicode válidos rejeitados e vetor nulo aceito); passaram depois. +- Tipagem aprovada; suíte padrão com 30 testes aprovados e 20 condicionais ignorados sem banco. +- PostgreSQL isolado: 63 testes aprovados (33 de indexação e 30 de documentos/relações/busca/sugestões), um caso ignorado por exigir IA real. +- A inspeção complementar de chunking, limites de upload/processamento e worker de relatórios não confirmou outra falha nesta rodada. Não é uma garantia de ausência total de defeitos. +- Banco e logs temporários removidos. Sem commit ou envio ao GitHub nesta etapa. + +## Revisão de cadastro administrativo + +### Média — criar PO substitui a sessão do administrador + +**Código:** `backend/src/modules/auth/auth.service.ts:216` e `backend/src/modules/auth/auth.controller.ts:66`. + +O cadastro privilegiado corretamente exige administrador, mas o fluxo de registro cria uma sessão para a nova conta e a devolve no mesmo cookie usado pela sessão atual. Isso autentica o navegador como o usuário recém-criado. + +**Reprodução HTTP real em PostgreSQL temporário:** autenticar administrador; confirmar acesso a estatísticas; cadastrar um PO enviando o cookie do administrador; aplicar o Set-Cookie da resposta, como o navegador faz; consultar a sessão e o painel administrativo. + +```json +{"case":"admin_registers_po","before":200,"registration":201,"session_role_after":"po","admin_panel_after":403} +``` + +**Impacto:** a criação termina com sucesso, mas o administrador passa a navegar como o novo PO e perde o acesso administrativo até autenticar novamente. A conta original não foi rebaixada; o problema é a troca involuntária da sessão. + +**Correção indicada:** separar provisionamento administrativo de login automático no cadastro. Criar outra conta não deve emitir cookie que substitua a sessão do administrador. Manter os controles de papel já existentes. + +O achado também constava no documento local anterior `REVISAO-CONSOLIDADA-2026-09-28.md`; nesta rodada foi confirmado contra a implementação atual por HTTP, em vez de assumir que o relato antigo continuava válido. + +**Estado:** corrigido localmente. Cadastro com sessão válida não emite sessão/token para a nova conta e preserva o cookie atual. Cadastro público de dev mantém login automático. Banco, contas e scripts temporários removidos. As correções locais anteriores foram preservadas. + +### Baixa — cadastros concorrentes retornavam erro de projeto duplicado + +Dois pedidos simultâneos para o mesmo e-mail podiam passar pela consulta inicial antes da inserção. A restrição única impedia a duplicata, mas o erro era tratado pelo middleware genérico como `UNIQUE_VIOLATION` com mensagem de projeto duplicado. + +**Reprodução:** duas requisições reais com barreira na consulta inicial de e-mail. Uma criou a conta; a outra retornou 409 com código incorreto. O teste de `EMAIL_EXISTS` falhou antes da correção. + +**Corrigido localmente:** o serviço trata especificamente a restrição `usuario_email_key` como conflito de cadastro. Outros erros de banco continuam sendo propagados. A segunda tentativa retorna `EMAIL_EXISTS`; apenas uma conta é criada. + +### Regressões do cadastro + +- Administrador cria PO/admin/dev sem trocar sessão, sem retornar token da outra conta e sem criar sessão desnecessária para ela. +- A sessão original continua administradora e acessa o painel após cadastrar as contas. +- Visitantes e desenvolvedores continuam impedidos de criar PO/admin. +- Cadastro público de dev continua emitindo cookie/token para login automático. +- Cadastros simultâneos do mesmo e-mail retornam 201 e 409/EMAIL_EXISTS. +- Validação final: tipagem e OpenAPI aprovados; 30 testes padrão do backend, 68 integrados com PostgreSQL e quatro de navegador aprovados. Um caso integrado que exige IA real permanece ignorado. Dados e logs temporários removidos; alterações ainda locais, sem commit/envio. + +## Revisão de falhas parciais do cadastro + +### Média — erro na criação da sessão deixa cadastro parcialmente concluído + +**Código:** `backend/src/modules/auth/auth.service.ts`, método `register`: `createUser` e `createSession` são gravações independentes. + +**Reprodução:** no PostgreSQL temporário, instalar um trigger de teste que recusa inserções em `sessao`; enviar cadastro público válido; remover a falha simulada; repetir o mesmo cadastro. + +```json +{"case":"partial_registration","first_status":500,"user_saved":1,"retry_status":409,"retry_code":"EMAIL_EXISTS"} +``` + +A primeira resposta informa erro interno, mas a conta já foi persistida. Repetir o cadastro após recuperar o serviço não conclui o fluxo: o e-mail já está ocupado pela tentativa anterior. O usuário pode usar o login quando a criação de sessão voltar a funcionar; não houve perda da conta. + +**Correção indicada:** tornar criação da conta e sessão inicial atômicas, ou tratar explicitamente o sucesso do cadastro com falha de autenticação e orientar o login. Não remover contas por compensação insegura em operações concorrentes. + +**Estado:** corrigido localmente. O repositório grava a conta e a sessão inicial na mesma transação/conexão. O serviço gera o token aleatório e encaminha somente seu hash para persistência. Cadastros administrativos continuam sem criar sessão para a nova conta. + +**Regressão:** um trigger PostgreSQL recusa a sessão apenas para o e-mail de teste. A API retorna erro sem Set-Cookie e não deixa a conta salva. Depois de remover o trigger, a mesma solicitação retorna 201, com uma conta, uma sessão e token aceito por `/auth/me`. Não há exclusão compensatória de contas. + +O banco, o trigger, a função e os arquivos temporários foram removidos. A regressão permanece na suíte automática de cadastro. As correções locais anteriores foram preservadas. + +## Revisão de permissões do RepoAnalyzer + +### Média — API permite ações de desenvolvedor bloqueadas pela interface + +**Código:** `backend/src/modules/repo-analyses/repo-analyses.routes.ts`, criação e controles pause/resume/cancel; `frontend/src/views/projects/ProjectDetail.tsx`, permissão `canStart={canCreate && !archived}`. + +A interface restringe início e controles aos papéis de escrita. A API valida sessão e acesso ao projeto, mas não aplica a mesma restrição de papel nessas ações. Aprovação e reprocessamento do relatório já exigem PO/admin. + +**Reprodução HTTP real:** criar PO e desenvolvedor alocado no mesmo projeto; iniciar uma análise com o PO, ainda na fila; cancelar essa análise com a sessão do desenvolvedor; iniciar outra análise com o desenvolvedor. + +```json +{"case":"analysis_role_mismatch","dev_cancel_po_analysis":200,"cancelled_state":"cancelada","dev_start_analysis":201} +``` + +As operações alteraram o banco, sem chamar o serviço externo. Isso confirma a divergência de autorização entre interface e API; não houve acesso entre projetos distintos. + +**Correção indicada:** alinhar a API à restrição PO/admin da interface para criação, pausa, retomada e cancelamento. Se o produto optar por permitir controles a desenvolvedores, documentar essa decisão e alinhar a interface; não manter políticas divergentes. + +**Estado:** corrigido localmente. O middleware compartilhado `requireRole('admin','po')` protege início, pausa, retomada e cancelamento antes da chamada ao serviço. + +**Regressões:** matriz de papéis com testes HTTP de rotas confirma PO/admin autorizados, dev com 403 sem chamada de escrita, e leitura preservada para dev. Na aplicação real com PostgreSQL, o dev alocado não cria outra análise nem altera a análise enfileirada do PO; o status e a quantidade de registros permanecem intactos. O PO ainda consegue cancelar e o dev ainda consulta a análise. + +Dados, banco e scripts temporários removidos. As correções locais anteriores foram preservadas. + +Validação final desta correção: tipagem e OpenAPI aprovados; 31 testes padrão do backend e 70 integrados com PostgreSQL aprovados. Um caso integrado que exige IA real permanece ignorado. Sem commit ou envio nesta etapa. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c4f9fd4..a67dfa7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2450,7 +2450,8 @@ paths: /api/v1/auth/register: post: - summary: Cria uma conta (PO ou dev); administrador exige sessão de administrador + summary: Cria uma conta; PO e administrador exigem sessão de administrador + description: Cadastro público cria dev e sua sessão na mesma transação; falha ao gravar a sessão desfaz a conta e permite nova tentativa. Com sessão válida existente, cria a conta sem emitir novo token ou substituir o cookie atual. Cadastros concorrentes do mesmo e-mail retornam EMAIL_EXISTS para a tentativa duplicada. operationId: registerUser security: [] requestBody: @@ -2467,11 +2468,11 @@ paths: role: {type: string, enum: [po, dev, admin], default: dev, description: "po e admin só são aceitos com sessão de administrador"} responses: '201': - description: Conta criada e sessão iniciada + description: Conta criada. Sessão iniciada apenas no cadastro sem sessão válida anterior. '400': $ref: '#/components/responses/ValidationError' '403': - description: Tentativa de criar administrador sem sessão de administrador + description: Tentativa de criar PO ou administrador sem sessão de administrador '409': description: E-mail já cadastrado @@ -2617,6 +2618,7 @@ paths: description: Projeto não encontrado post: summary: Inicia uma análise de repositório público do GitHub + description: Apenas PO e administrador podem iniciar análises. Desenvolvedores com acesso ao projeto podem consultar as análises e os relatórios. operationId: startRepoAnalysis security: [{cookieAuth: []}] parameters: @@ -2642,6 +2644,8 @@ paths: responses: '201': description: Solicitação persistida na fila; despacho assíncrono e idempotente + '403': + description: Apenas PO e administrador podem iniciar análises '400': $ref: '#/components/responses/ValidationError' '401': @@ -2674,6 +2678,7 @@ paths: /api/v1/projects/{projectId}/repo-analyses/{id}/pause: post: summary: Pausar após concluir o arquivo atual e salvar checkpoint + description: Requer papel PO ou administrador e acesso ao projeto. operationId: pauseRepoAnalysis security: [{cookieAuth: []}] parameters: @@ -2681,6 +2686,7 @@ paths: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '200': {description: Análise pausada ou aguardando o ponto seguro de pausa} + '403': {description: Apenas PO e administrador podem controlar análises} '401': {$ref: '#/components/responses/Unauthorized'} '404': {description: Projeto ou análise não encontrados} '409': {description: Estado da análise não permite pausa} @@ -2688,6 +2694,7 @@ paths: /api/v1/projects/{projectId}/repo-analyses/{id}/resume: post: summary: Retomar análise a partir do checkpoint salvo + description: Requer papel PO ou administrador e acesso ao projeto. operationId: resumeRepoAnalysis security: [{cookieAuth: []}] parameters: @@ -2695,6 +2702,7 @@ paths: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '200': {description: Retomada registrada na fila} + '403': {description: Apenas PO e administrador podem controlar análises} '401': {$ref: '#/components/responses/Unauthorized'} '404': {description: Projeto ou análise não encontrados} '409': {description: Estado da análise não permite retomada} @@ -2702,6 +2710,7 @@ paths: /api/v1/projects/{projectId}/repo-analyses/{id}/cancel: post: summary: Cancelar definitivamente a análise + description: Requer papel PO ou administrador e acesso ao projeto. operationId: cancelRepoAnalysis security: [{cookieAuth: []}] parameters: @@ -2709,6 +2718,7 @@ paths: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '200': {description: Análise cancelada ou aguardando o ponto seguro de cancelamento} + '403': {description: Apenas PO e administrador podem controlar análises} '401': {$ref: '#/components/responses/Unauthorized'} '404': {description: Projeto ou análise não encontrados} '409': {description: Estado da análise não permite cancelamento} @@ -2756,6 +2766,7 @@ paths: /api/v1/search: get: summary: Busca híbrida por texto e significado no acervo, sempre isolada por projeto + description: Valida o acesso do usuário autenticado ao projeto antes de gerar embeddings ou consultar o acervo. PO e administrador seguem a política global; desenvolvedores precisam de alocação vigente. Projeto inexistente ou sem acesso retorna 404. operationId: searchKnowledge security: [{cookieAuth: []}] parameters: