Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/scripts/test-indexing-db.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
10 changes: 5 additions & 5 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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") {
Expand All @@ -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",
Expand Down Expand Up @@ -182,4 +182,4 @@ export class AuthController {
};
}

export const authController = new AuthController();
export const authController = new AuthController();
123 changes: 123 additions & 0 deletions backend/src/modules/auth/auth.registration.db.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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<void>(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<void>(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();
}
});
23 changes: 19 additions & 4 deletions backend/src/modules/auth/auth.repository.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -20,8 +21,8 @@ export class AuthRepository {
email: string;
senha_hash: string;
role?: UserRole;
}): Promise<UserRecord> {
const result = await this.pool.query<UserRecord>(
}, executor: Pool | PoolClient = this.pool): Promise<UserRecord> {
const result = await executor.query<UserRecord>(
`
INSERT INTO usuario (
nome,
Expand All @@ -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<UserRecord> {
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<UserRecord | null> {
const result = await this.pool.query<UserRecord>(
`
Expand Down Expand Up @@ -124,8 +138,9 @@ export class AuthRepository {
async createSession(
userId: string,
tokenHash: string,
executor: Pool | PoolClient = this.pool,
): Promise<SessionRecord> {
const result = await this.pool.query<SessionRecord>(
const result = await executor.query<SessionRecord>(
`
INSERT INTO sessao (
usuario_id,
Expand Down
5 changes: 3 additions & 2 deletions backend/src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,4 +142,4 @@ authRouter.post("/logout", requireAuth, authController.logout);
* 401:
* description: Não autenticado
*/
authRouter.get("/me", requireAuth, authController.me);
authRouter.get("/me", requireAuth, authController.me);
22 changes: 14 additions & 8 deletions backend/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { env } from "../../config/env.js";
import { createHash, randomBytes } from "node:crypto";
import { authRepository } from "./auth.repository.js";
import {
hashPassword,
Expand All @@ -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<UserRecord>;
}, tokenHash?: string): Promise<UserRecord>;

findUserByEmail(email: string): Promise<UserRecord | null>;

Expand Down Expand Up @@ -64,7 +65,7 @@ export type LoginResult =
export type RegisterResult =
| {
success: true;
token: string;
token?: string;
user: {
id: string;
nome: string;
Expand Down Expand Up @@ -192,7 +193,7 @@ export class AuthService {
};
}

async register(data: RegisterDTO): Promise<RegisterResult> {
async register(data: RegisterDTO, autoLogin = true): Promise<RegisterResult> {
const email = data.email.trim().toLowerCase();

const existingUser = await this.repository.findUserByEmail(email);
Expand All @@ -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,
Expand All @@ -228,4 +234,4 @@ export class AuthService {
}
}

export const authService = new AuthService();
export const authService = new AuthService();
6 changes: 6 additions & 0 deletions backend/src/modules/documents/chunks.http.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions backend/src/modules/documents/chunks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions backend/src/modules/documents/document-ingestion.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
}
});
}
3 changes: 2 additions & 1 deletion backend/src/modules/documents/document-ingestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const metadata = candidate.metadata as Record<string, unknown> | 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");
Expand Down
Loading
Loading