From 6d1f5f452b7981ef7e000c8c0526c4a2a0affe63 Mon Sep 17 00:00:00 2001 From: Moretto <72614882+Giomoret@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:15:21 -0300 Subject: [PATCH 1/2] fix: validar entradas e limites antes de acessar o banco --- backend/src/modules/admin/admin.service.ts | 3 +- backend/src/modules/auth/auth.types.ts | 9 ++- .../backlog-search/backlog-search.service.ts | 2 + .../src/modules/criteria/criteria.types.ts | 7 ++- .../src/modules/decisions/decisions.types.ts | 3 +- .../documents/documents.cursor.test.ts | 27 +++++++++ .../modules/documents/documents.service.ts | 8 ++- backend/src/modules/epics/epics.types.ts | 8 ++- .../src/modules/features/features.types.ts | 8 ++- backend/src/modules/pbis/pbis.types.ts | 8 ++- .../src/modules/projects/hierarchy-archive.ts | 3 +- .../modules/projects/projects.controller.ts | 5 +- .../src/modules/projects/projects.types.ts | 21 +++++-- .../quality/organization-quality.types.ts | 2 + backend/src/modules/search/search.service.ts | 2 + .../modules/suggestions/suggestions.types.ts | 6 +- .../suggestions.validation.test.ts | 14 +++++ backend/src/shared/text-validation.test.ts | 55 +++++++++++++++++++ backend/src/shared/text-validation.ts | 28 ++++++++++ docs/qa/CORRECOES-VALIDACAO-API.md | 7 +++ frontend/src/views/auth/AuthView.tsx | 4 +- 21 files changed, 202 insertions(+), 28 deletions(-) create mode 100644 backend/src/modules/documents/documents.cursor.test.ts create mode 100644 backend/src/modules/suggestions/suggestions.validation.test.ts create mode 100644 backend/src/shared/text-validation.test.ts create mode 100644 backend/src/shared/text-validation.ts create mode 100644 docs/qa/CORRECOES-VALIDACAO-API.md diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts index 4047739..854a289 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { validateTextFields } from '../../shared/text-validation.js'; import { ValidationError, validateUuid } from "../../shared/errors.js"; import { AdminRepository, type PurgeProjectResult } from "./admin.repository.js"; @@ -13,7 +14,7 @@ const purgeProjectSchema = z .refine((data) => !!(data.confirmacao_nome || data.nome_confirmacao), { message: "Informe o nome do projeto para confirmar o expurgo.", path: ["confirmacao_nome"], - }); + }).superRefine(validateTextFields); export class AdminService { constructor( diff --git a/backend/src/modules/auth/auth.types.ts b/backend/src/modules/auth/auth.types.ts index 615a78c..8e5ed7b 100644 --- a/backend/src/modules/auth/auth.types.ts +++ b/backend/src/modules/auth/auth.types.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { validDatabaseText } from '../../shared/text-validation.js'; export const USER_ROLES = ["admin", "po", "dev"] as const; @@ -9,6 +10,7 @@ export const loginSchema = z.object({ .string({ required_error: "O e-mail é obrigatório." }) .trim() .email("E-mail inválido.") + .max(255, "O e-mail deve ter no máximo 255 caracteres.") .transform((value) => value.toLowerCase()), password: z @@ -22,12 +24,15 @@ export const registerSchema = z.object({ nome: z .string({ required_error: "O nome é obrigatório." }) .trim() - .min(2, "O nome deve ter no mínimo 2 caracteres."), + .min(2, "O nome deve ter no mínimo 2 caracteres.") + .max(255, "O nome deve ter no máximo 255 caracteres.") + .refine(validDatabaseText, 'Use texto Unicode válido, sem caractere nulo.'), email: z .string({ required_error: "O e-mail é obrigatório." }) .trim() .email("E-mail inválido.") + .max(255, "O e-mail deve ter no máximo 255 caracteres.") .transform((value) => value.toLowerCase()), password: z @@ -76,4 +81,4 @@ export interface SessionWithUser extends SessionRecord { email: string; role: UserRole; ativo: boolean; -} \ No newline at end of file +} diff --git a/backend/src/modules/backlog-search/backlog-search.service.ts b/backend/src/modules/backlog-search/backlog-search.service.ts index 3efb428..4f91c3d 100644 --- a/backend/src/modules/backlog-search/backlog-search.service.ts +++ b/backend/src/modules/backlog-search/backlog-search.service.ts @@ -1,4 +1,5 @@ import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { validDatabaseText } from '../../shared/text-validation.js'; import { ProjectsRepository } from "../projects/projects.repository.js"; import { BacklogSearchRepository } from "./backlog-search.repository.js"; import { likePatterns, makeSnippet, parseTerms } from "./backlog-search.text.js"; @@ -44,6 +45,7 @@ export class BacklogSearchService { async search(input: BacklogSearchInput): Promise { validateUuid(input.projetoId, "ID do projeto"); const query = (input.q ?? "").replace(/\s+/g, " ").trim(); + if (!validDatabaseText(query)) throw new ValidationError('Use texto Unicode válido, sem caractere nulo.'); if (query.length < MIN_QUERY_LENGTH) throw new ValidationError(`Digite ao menos ${MIN_QUERY_LENGTH} caracteres para buscar.`); if (query.length > MAX_QUERY_LENGTH) throw new ValidationError(`A busca pode ter no máximo ${MAX_QUERY_LENGTH} caracteres.`); const terms = parseTerms(query); diff --git a/backend/src/modules/criteria/criteria.types.ts b/backend/src/modules/criteria/criteria.types.ts index 010dca9..e3d2667 100644 --- a/backend/src/modules/criteria/criteria.types.ts +++ b/backend/src/modules/criteria/criteria.types.ts @@ -1,3 +1,4 @@ +import { validateTextFields } from '../../shared/text-validation.js'; import { z } from "zod"; export const CRITERION_ENTITY_TYPES = ["epico", "feature", "pbi"] as const; @@ -19,7 +20,7 @@ const justificationShape = { .optional(), }; -export const criterionJustificationSchema = z.object(justificationShape); +export const criterionJustificationSchema = z.object(justificationShape).superRefine(validateTextFields); const epicoCriterionSchema = z.object({ entidade_tipo: z.literal("epico"), @@ -53,7 +54,7 @@ export const createCriterionSchema = z.discriminatedUnion("entidade_tipo", [ epicoCriterionSchema, featureCriterionSchema, pbiCriterionSchema, -]); +]).superRefine(validateTextFields); export type CreateCriterionDTO = z.infer; @@ -67,7 +68,7 @@ export type CriterionQueryDTO = z.infer; export const moveCriterionSchema = z.object({ direction: z.enum(["up", "down"], { required_error: "A direção do movimento é obrigatória." }), justificativa: justificationShape.justificativa, -}); +}).superRefine(validateTextFields); export type MoveCriterionDTO = z.infer; diff --git a/backend/src/modules/decisions/decisions.types.ts b/backend/src/modules/decisions/decisions.types.ts index abdcb58..8cfefc4 100644 --- a/backend/src/modules/decisions/decisions.types.ts +++ b/backend/src/modules/decisions/decisions.types.ts @@ -1,3 +1,4 @@ +import { validateTextFields } from '../../shared/text-validation.js'; import { z } from "zod"; export type DecisionEntityType = "projeto" | "epico" | "feature" | "pbi"; @@ -72,6 +73,6 @@ export const createDecisionSchema = z .nullish() .transform((value) => (value ? value : null)), }) - .strict(); + .strict().superRefine(validateTextFields); export type CreateDecisionBody = z.infer; diff --git a/backend/src/modules/documents/documents.cursor.test.ts b/backend/src/modules/documents/documents.cursor.test.ts new file mode 100644 index 0000000..a750fc6 --- /dev/null +++ b/backend/src/modules/documents/documents.cursor.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { DocumentsService } from './documents.service.js'; +import { ValidationError } from '../../shared/errors.js'; + +test('cursor de documentos recusa datas inválidas antes de consultar o banco', async () => { + let calls = 0; + const service = new DocumentsService({ listByProject: async () => { calls++; return { items: [], hasMore: false }; } } as any, + {} as any, {} as any, { findById: async () => ({ id: randomUUID(), status: 'ativo' }) }); + for (const date of ['0', '2026-02-30T12:00:00Z', '2026-10-25T12:00:00+23:00', '2026-10-25', '2026-10-25T12:00:00-16:00']) { + const cursor = Buffer.from(JSON.stringify({ created_at: date, id: randomUUID() })).toString('base64url'); + await assert.rejects(() => service.list(randomUUID(), cursor), ValidationError); + } + assert.equal(calls, 0); +}); + +test('cursor de documentos mantém os microssegundos do formato produzido pelo PostgreSQL', async () => { + const received: unknown[] = []; + const service = new DocumentsService({ listByProject: async (_id: string, cursor: unknown) => { received.push(cursor); return { items: [], hasMore: false }; } } as any, + {} as any, {} as any, { findById: async () => ({ id: randomUUID(), status: 'ativo' }) }); + const id = randomUUID(); + for (const timestamp of ['2026-10-25 12:00:00.000123+00', '2026-10-25T12:00:00.000123Z', '2026-10-25 12:00:00.000123-03:00']) { + await service.list(randomUUID(), Buffer.from(JSON.stringify({ created_at: timestamp, id })).toString('base64url')); + } + assert.deepEqual(received, [{ createdAt: '2026-10-25T12:00:00.000123+00:00', id }, { createdAt: '2026-10-25T12:00:00.000123Z', id }, { createdAt: '2026-10-25T12:00:00.000123-03:00', id }]); +}); diff --git a/backend/src/modules/documents/documents.service.ts b/backend/src/modules/documents/documents.service.ts index 005e4eb..0ce6e2a 100644 --- a/backend/src/modules/documents/documents.service.ts +++ b/backend/src/modules/documents/documents.service.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { validProjectDate } from '../../shared/text-validation.js'; import { env } from "../../config/env.js"; import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; import { ArchiveConflict } from "../projects/archive.types.js"; @@ -41,11 +42,14 @@ function decodeCursor(value: string | undefined): { createdAt: string; id: strin throw new Error("invalid cursor"); } const candidate = parsed as { created_at: unknown; id: unknown }; - if (typeof candidate.created_at !== "string" || !Number.isFinite(Date.parse(candidate.created_at)) || typeof candidate.id !== "string") { + if (typeof candidate.created_at !== "string" || typeof candidate.id !== "string") { throw new Error("invalid cursor"); } + // O PostgreSQL devolve microssegundos e fuso +00; mantém essa precisão na leitura. + const timestamp = candidate.created_at.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00'); + if (timestamp.length <= 10 || !validProjectDate(timestamp)) throw new Error('invalid cursor'); validateUuid(candidate.id, "Cursor de documentos"); - return { createdAt: candidate.created_at, id: candidate.id }; + return { createdAt: timestamp, id: candidate.id }; } catch { throw new ValidationError("Cursor de documentos inválido."); } diff --git a/backend/src/modules/epics/epics.types.ts b/backend/src/modules/epics/epics.types.ts index e21b584..baaf169 100644 --- a/backend/src/modules/epics/epics.types.ts +++ b/backend/src/modules/epics/epics.types.ts @@ -1,3 +1,5 @@ +import { backlogCursorSchema } from '../../shared/backlog-cursor.js'; +import { validateTextFields } from '../../shared/text-validation.js'; import { z } from "zod"; // Historical ativo remains writable; only dedicated operations complete or archive items. @@ -25,7 +27,7 @@ export const createEpicSchema = z.object({ tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), prioridade: z.enum(EPIC_PRIORITIES).default("Must"), status: z.literal("rascunho").optional(), -}); +}).superRefine(validateTextFields); export type CreateEpicDTO = z.infer; @@ -39,7 +41,7 @@ export const updateEpicSchema = z.object({ justificativa: z.string().trim().optional().nullable(), tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), status: z.never().optional(), -}); +}).superRefine(validateTextFields); export type UpdateEpicDTO = z.infer; @@ -47,6 +49,7 @@ export const epicQuerySchema = z.object({ projeto_id: z.string().uuid().optional(), status: z.enum([...EPIC_STATUSES, "todos"]).optional(), limit: z.coerce.number().int().min(1).max(100).default(50), + before: backlogCursorSchema.optional(), offset: z.coerce.number().int().min(0).default(0), }); @@ -79,4 +82,5 @@ export interface PaginatedEpics { total: number; limit: number; offset: number; + next_cursor?: string | null; } diff --git a/backend/src/modules/features/features.types.ts b/backend/src/modules/features/features.types.ts index d2e83a5..222a532 100644 --- a/backend/src/modules/features/features.types.ts +++ b/backend/src/modules/features/features.types.ts @@ -1,3 +1,5 @@ +import { backlogCursorSchema } from '../../shared/backlog-cursor.js'; +import { validateTextFields } from '../../shared/text-validation.js'; import { z } from "zod"; export const FEATURE_STATUSES = ["rascunho", "concluido", "arquivado"] as const; @@ -19,7 +21,7 @@ export const createFeatureSchema = z.object({ objetivo: z.string().trim().optional().nullable(), tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), prioridade: z.enum(FEATURE_PRIORITIES).default("Must"), -}); +}).superRefine(validateTextFields); export type CreateFeatureDTO = z.infer; @@ -30,7 +32,7 @@ export const updateFeatureSchema = z.object({ prioridade: z.enum(FEATURE_PRIORITIES).optional(), justificativa: z.string().trim().optional().nullable(), tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), -}); +}).superRefine(validateTextFields); export type UpdateFeatureDTO = z.infer; @@ -38,6 +40,7 @@ export const featureQuerySchema = z.object({ epico_id: z.string().uuid().optional(), status: z.enum([...FEATURE_STATUSES, "todos"]).optional(), limit: z.coerce.number().int().min(1).max(100).default(50), + before: backlogCursorSchema.optional(), offset: z.coerce.number().int().min(0).default(0), }); @@ -70,4 +73,5 @@ export interface PaginatedFeatures { total: number; limit: number; offset: number; + next_cursor?: string | null; } diff --git a/backend/src/modules/pbis/pbis.types.ts b/backend/src/modules/pbis/pbis.types.ts index df26fb9..9ca00ba 100644 --- a/backend/src/modules/pbis/pbis.types.ts +++ b/backend/src/modules/pbis/pbis.types.ts @@ -1,3 +1,5 @@ +import { backlogCursorSchema } from '../../shared/backlog-cursor.js'; +import { validateTextFields } from '../../shared/text-validation.js'; import { z } from "zod"; export const PBI_STATUSES = ["rascunho", "concluido", "arquivado"] as const; @@ -30,7 +32,7 @@ export const createPbiSchema = z.object({ prioridade: z.enum(PBI_PRIORITIES).default("Must"), requer_interface: z.boolean().default(false), tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), -}); +}).superRefine(validateTextFields); export type CreatePbiDTO = z.infer; @@ -45,7 +47,7 @@ export const updatePbiSchema = z.object({ requer_interface: z.boolean().optional(), justificativa: z.string().trim().optional().nullable(), tecnologias_ids: z.array(z.string().uuid()).max(50).optional(), -}); +}).superRefine(validateTextFields); export type UpdatePbiDTO = z.infer; @@ -53,6 +55,7 @@ export const pbiQuerySchema = z.object({ feature_id: z.string().uuid().optional(), status: z.enum([...PBI_STATUSES, "todos"]).optional(), limit: z.coerce.number().int().min(1).max(100).default(50), + before: backlogCursorSchema.optional(), offset: z.coerce.number().int().min(0).default(0), }); @@ -94,4 +97,5 @@ export interface PaginatedPbis { total: number; limit: number; offset: number; + next_cursor?: string | null; } diff --git a/backend/src/modules/projects/hierarchy-archive.ts b/backend/src/modules/projects/hierarchy-archive.ts index 072ee47..4ac6e0c 100644 --- a/backend/src/modules/projects/hierarchy-archive.ts +++ b/backend/src/modules/projects/hierarchy-archive.ts @@ -5,6 +5,7 @@ import { pool } from "../../database/db.js"; import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; import { ArchiveConflict } from "./archive.types.js"; import { auditService } from "../audit/audit.service.js"; +import { validateTextFields } from '../../shared/text-validation.js'; type Kind = "epico" | "feature" | "pbi"; const scope = { @@ -17,7 +18,7 @@ export const hierarchyArchiveSchema = z.object({ confirmado: z.literal(true), impacto: z.object({ epicos: count, features: count, pbis: count }).strict(), justificativa: z.string().trim().max(2000).optional(), -}); +}).superRefine(validateTextFields); // The same lock order is used by archive and ordinary hierarchy writers. export async function lockHierarchy(client: PoolClient, kind: Kind | "projeto", id: string) { diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts index 30a0690..3cd4df8 100644 --- a/backend/src/modules/projects/projects.controller.ts +++ b/backend/src/modules/projects/projects.controller.ts @@ -9,6 +9,7 @@ import { } from "./projects.service.js"; import { ValidationError } from "../../shared/errors.js"; import { z } from "zod"; +import { validateTextFields } from '../../shared/text-validation.js'; const confirmation = z.object({ confirmado: z.literal(true), @@ -43,7 +44,7 @@ const confirmation = z.object({ .trim() .max(2000) .optional(), -}); +}).superRefine(validateTextFields); function getParamId( param: string | string[] | undefined, @@ -219,4 +220,4 @@ export class ProjectsController { } export const projectsController = - new ProjectsController(); \ No newline at end of file + new ProjectsController(); diff --git a/backend/src/modules/projects/projects.types.ts b/backend/src/modules/projects/projects.types.ts index 69cebdd..2921442 100644 --- a/backend/src/modules/projects/projects.types.ts +++ b/backend/src/modules/projects/projects.types.ts @@ -1,4 +1,13 @@ import { z } from "zod"; +import { validDatabaseText, validProjectDate } from '../../shared/text-validation.js'; + +function validateProjectText(data: Record, ctx: z.RefinementCtx) { + for (const [field, value] of Object.entries(data)) { + if (typeof value === 'string' && !validDatabaseText(value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: 'Use texto Unicode válido, sem caractere nulo.' }); + } + } +} export const PROJECT_STATUSES = [ "ativo", @@ -58,7 +67,7 @@ export const createProjectSchema = z.object({ data_inicio: z .string() .refine( - (val) => !isNaN(Date.parse(val)), + validProjectDate, { message: "Data de início deve ser uma data válida.", @@ -66,7 +75,7 @@ export const createProjectSchema = z.object({ ) .optional() .nullable(), -}); +}).superRefine(validateProjectText); export type CreateProjectDTO = z.infer; @@ -115,7 +124,7 @@ export const updateProjectSchema = z.object({ data_inicio: z .string() .refine( - (val) => !isNaN(Date.parse(val)), + validProjectDate, { message: "Data de início deve ser uma data válida.", @@ -129,7 +138,7 @@ export const updateProjectSchema = z.object({ .trim() .optional() .nullable(), -}); +}).superRefine(validateProjectText); export type UpdateProjectDTO = z.infer; @@ -168,7 +177,7 @@ export const projectQuerySchema = z.object({ "nome_desc", ]) .default("created_at_desc"), -}); +}).superRefine(validateProjectText); export type ProjectQueryDTO = z.infer; @@ -235,4 +244,4 @@ export interface ProjectBacklogTree { epics: BacklogEpicNode[]; technologies: BacklogTechnology[]; -} \ No newline at end of file +} diff --git a/backend/src/modules/quality/organization-quality.types.ts b/backend/src/modules/quality/organization-quality.types.ts index c1a0ebd..f4e6557 100644 --- a/backend/src/modules/quality/organization-quality.types.ts +++ b/backend/src/modules/quality/organization-quality.types.ts @@ -1,10 +1,12 @@ import { z } from 'zod'; +import { validDatabaseText } from '../../shared/text-validation.js'; import { TERMOS_VAGOS_PADRAO } from './quality.rules.js'; export const organizationChecksSchema = z.object({ campos_obrigatorios: z.boolean(), criterios_aceitacao: z.boolean(), termos_vagos: z.boolean(), }).strict(); export const vagueTermSchema = z.string().trim().min(1).max(80) + .refine(validDatabaseText, 'Use texto Unicode válido, sem caractere nulo.') .refine(term=>!/[\r\n\u2028\u2029]/u.test(term),'Cada termo deve ocupar uma única linha.') .transform(term=>term.normalize('NFC')); export const organizationConfigurationSchema = z.object({ diff --git a/backend/src/modules/search/search.service.ts b/backend/src/modules/search/search.service.ts index 0eefdec..00e5c32 100644 --- a/backend/src/modules/search/search.service.ts +++ b/backend/src/modules/search/search.service.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { validDatabaseText } from '../../shared/text-validation.js'; import { env } from "../../config/env.js"; import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; import { serviceHeaders } from "../../shared/service-auth.js"; @@ -35,6 +36,7 @@ export class SearchService { async search(input: HybridSearchInput): Promise { const query = input.query.trim(); + if (!validDatabaseText(query)) throw new ValidationError('Use texto Unicode válido, sem caractere nulo.'); if (query.length < 3) throw new ValidationError("A busca deve ter pelo menos 3 caracteres."); if (query.length > 200) throw new ValidationError("A busca pode ter no máximo 200 caracteres."); validateUuid(input.projectId, "ID do projeto"); diff --git a/backend/src/modules/suggestions/suggestions.types.ts b/backend/src/modules/suggestions/suggestions.types.ts index 029e33f..4d3310f 100644 --- a/backend/src/modules/suggestions/suggestions.types.ts +++ b/backend/src/modules/suggestions/suggestions.types.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { validateTextFields } from '../../shared/text-validation.js'; import { maxLengthFor, SUGGESTIBLE_FIELDS, type SuggestibleEntityType } from "../provenance/provenance.js"; export type { SuggestibleEntityType } from "../provenance/provenance.js"; @@ -65,6 +66,7 @@ export function createSuggestionSchema(kind: SuggestibleEntityType) { .min(1, "O valor sugerido é obrigatório."), origem: z.string().trim().max(100).optional(), }).strict().superRefine((data, ctx) => { + validateTextFields(data, ctx); const max = maxLengthFor(data.campo); if (data.valor_sugerido.length > max) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["valor_sugerido"], message: `O valor sugerido pode ter no máximo ${max} caracteres.` }); @@ -76,7 +78,7 @@ export const resolveDiscardSchema = z.object({}).strict(); export const resolveAcceptSchema = z.object({ justificativa: z.string().trim().max(2000).optional().nullable(), -}).strict(); +}).strict().superRefine(validateTextFields); export function resolveEditSchema(campo: string) { const max = maxLengthFor(campo); @@ -87,5 +89,5 @@ export function resolveEditSchema(campo: string) { .min(1, "Informe o valor editado.") .max(max, `O valor editado pode ter no máximo ${max} caracteres.`), justificativa: z.string().trim().max(2000).optional().nullable(), - }).strict(); + }).strict().superRefine(validateTextFields); } diff --git a/backend/src/modules/suggestions/suggestions.validation.test.ts b/backend/src/modules/suggestions/suggestions.validation.test.ts new file mode 100644 index 0000000..f3c604e --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.validation.test.ts @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSuggestionSchema, resolveAcceptSchema, resolveEditSchema } from './suggestions.types.js'; + +test('sugestões recusam texto inválido em valores, origem e justificativas', () => { + for (const invalid of ['QA\u0000', 'QA\ud800', 'QA\udfff']) { + assert.equal(createSuggestionSchema('epico').safeParse({ campo: 'descricao', valor_sugerido: invalid }).success, false); + assert.equal(createSuggestionSchema('feature').safeParse({ campo: 'descricao', valor_sugerido: 'Válido', origem: invalid }).success, false); + assert.equal(resolveEditSchema('titulo').safeParse({ valor: invalid }).success, false); + assert.equal(resolveEditSchema('titulo').safeParse({ valor: 'Válido', justificativa: invalid }).success, false); + assert.equal(resolveAcceptSchema.safeParse({ justificativa: invalid }).success, false); + } + assert.equal(createSuggestionSchema('pbi').safeParse({ campo: 'titulo', valor_sugerido: 'Consultar histórico 😀' }).success, true); +}); diff --git a/backend/src/shared/text-validation.test.ts b/backend/src/shared/text-validation.test.ts new file mode 100644 index 0000000..f4008fe --- /dev/null +++ b/backend/src/shared/text-validation.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createProjectSchema, updateProjectSchema } from '../modules/projects/projects.types.js'; +import { registerSchema } from '../modules/auth/auth.types.js'; +import { vagueTermSchema } from '../modules/quality/organization-quality.types.js'; +import { validDatabaseText, validProjectDate } from './text-validation.js'; +import { createEpicSchema, updateEpicSchema } from '../modules/epics/epics.types.js'; +import { createFeatureSchema, updateFeatureSchema } from '../modules/features/features.types.js'; +import { createPbiSchema, updatePbiSchema } from '../modules/pbis/pbis.types.js'; +import { createCriterionSchema } from '../modules/criteria/criteria.types.js'; +import { randomUUID } from 'node:crypto'; + +test('texto persistido recusa NUL e substitutos isolados, mas preserva emojis', () => { + for (const invalid of ['x\u0000', 'x\ud800', 'x\udfff']) { + assert.equal(validDatabaseText(invalid), false); + assert.equal(vagueTermSchema.safeParse(invalid).success, false); + assert.equal(createProjectSchema.safeParse({ nome: invalid, cliente: 'Cliente' }).success, false); + assert.equal(updateProjectSchema.safeParse({ descricao: invalid }).success, false); + } + assert.equal(validDatabaseText('Projeto 😀 ação'), true); + assert.equal(vagueTermSchema.parse(' ação 😀 '), 'ação 😀'); +}); + +test('datas de projeto respeitam formato, calendário e anos bissextos', () => { + for (const date of ['0', '2026-02-30', '2025-02-29', '1900-02-29', '2026-04-31', '2026-00-10', '0000-01-01', '2026-01-01T99:00:00Z', '2026-10-25T12:00:00+23:00', '2026-10-25T12:00:00-16:00', '2026-10-25T24:00:00Z', '2026-10-25T12:00:00+15:60']) { + assert.equal(validProjectDate(date), false, date); + assert.equal(createProjectSchema.safeParse({ nome: 'Projeto', cliente: 'Cliente', data_inicio: date }).success, false); + assert.equal(updateProjectSchema.safeParse({ data_inicio: date }).success, false); + } + for (const date of ['2024-02-29', '2000-02-29', '2026-10-25', '2026-10-25T14:30:00-03:00', '2026-10-25T12:00:00+15:59', '2026-10-25T12:00:00-15:59']) assert.equal(validProjectDate(date), true, date); +}); + +test('e-mail respeita o limite do banco após trim', () => { + const input = { nome: 'Pessoa QA', password: 'valid-password' }; + assert.equal(registerSchema.safeParse({ ...input, email: 'a'.repeat(242) + '@example.test' }).success, true); + assert.equal(registerSchema.safeParse({ ...input, email: 'a'.repeat(244) + '@example.test' }).success, false); +}); + +test('todos os níveis do backlog e seus critérios rejeitam texto incompatível com o banco', () => { + const id = randomUUID(); + for (const invalid of ['QA\u0000', 'QA\ud800', 'QA\udfff']) { + assert.equal(createEpicSchema.safeParse({ projeto_id: id, titulo: invalid }).success, false); + assert.equal(createFeatureSchema.safeParse({ epico_id: id, titulo: invalid }).success, false); + assert.equal(createPbiSchema.safeParse({ feature_id: id, titulo: invalid, historia_como_um: 'PO', historia_eu_quero: 'Testar', historia_para_que: 'Validar' }).success, false); + for (const schema of [updateEpicSchema, updateFeatureSchema, updatePbiSchema]) assert.equal(schema.safeParse({ titulo: invalid }).success, false); + assert.equal(createCriterionSchema.safeParse({ entidade_tipo: 'epico', entidade_id: id, texto: invalid }).success, false); + assert.equal(createCriterionSchema.safeParse({ entidade_tipo: 'pbi', entidade_id: id, nome: 'Teste', dado: 'Condição', quando: 'Ação', entao: invalid }).success, false); + } +}); + +test('cadastro limita nome a 255 caracteres e rejeita texto inválido', () => { + const input = { email: 'qa@example.test', password: 'valid-password' }; + assert.equal(registerSchema.safeParse({ ...input, nome: 'a'.repeat(255) }).success, true); + for (const nome of ['a'.repeat(256), 'QA\u0000', 'QA\ud800']) assert.equal(registerSchema.safeParse({ ...input, nome }).success, false); +}); diff --git a/backend/src/shared/text-validation.ts b/backend/src/shared/text-validation.ts new file mode 100644 index 0000000..829642a --- /dev/null +++ b/backend/src/shared/text-validation.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const validDatabaseText = (value: string): boolean => !/[\u0000\uD800-\uDFFF]/u.test(value); + +export function validateTextFields(value: unknown, ctx: z.RefinementCtx, path: (string | number)[] = []): void { + if (typeof value === 'string' && !validDatabaseText(value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path, message: 'Use texto Unicode válido, sem caractere nulo.' }); + } else if (Array.isArray(value)) { + value.forEach((item, index) => validateTextFields(item, ctx, [...path, index])); + } else if (value && typeof value === 'object') { + Object.entries(value).forEach(([key, item]) => validateTextFields(item, ctx, [...path, key])); + } +} + +export function validProjectDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2}))?$/.test(value)) return false; + const [year, month, day] = value.slice(0, 10).split('-').map(Number); + if (year < 1 || month < 1 || month > 12 || day < 1) return false; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (value.length > 10) { + const hour = Number(value.slice(11, 13)), minute = Number(value.slice(14, 16)), second = Number(value.slice(17, 19)); + if (hour > 23 || minute > 59 || second > 59) return false; + const offset = /[+-](\d{2}):(\d{2})$/.exec(value); + if (offset && (Number(offset[1]) > 15 || Number(offset[2]) > 59)) return false; + } + return day <= days[month - 1] && Number.isFinite(Date.parse(value)); +} diff --git a/docs/qa/CORRECOES-VALIDACAO-API.md b/docs/qa/CORRECOES-VALIDACAO-API.md new file mode 100644 index 0000000..350fbc4 --- /dev/null +++ b/docs/qa/CORRECOES-VALIDACAO-API.md @@ -0,0 +1,7 @@ +# Validação das entradas da API + +Datas de projetos passam a validar calendário, horário e fuso. Cadastro e login respeitam os limites de nome e e-mail do banco. Os campos de projetos, backlog, decisões, sugestões e configurações recusam Unicode inválido e caractere nulo antes de gravar. + +Justificativas de arquivamento/expurgo e termos das buscas recebem a mesma proteção. Cursores de documentos recusam datas inválidas sem perder microssegundos. + +Os testes de texto, cadastro, sugestões e cursores acompanham a alteração. A integração HTTP consolidada é acrescentada na última branch da sequência, depois das correções de acesso e paginação que ela também verifica. diff --git a/frontend/src/views/auth/AuthView.tsx b/frontend/src/views/auth/AuthView.tsx index f444f43..bf5bd7f 100644 --- a/frontend/src/views/auth/AuthView.tsx +++ b/frontend/src/views/auth/AuthView.tsx @@ -387,10 +387,10 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { } }}> - setNome(e.target.value)} disabled={busy} placeholder="Seu nome" /> + setNome(e.target.value)} disabled={busy} placeholder="Seu nome" /> - setEmail(e.target.value)} disabled={busy} placeholder="seu.email@empresa.com" /> + setEmail(e.target.value)} disabled={busy} placeholder="seu.email@empresa.com" />

Novas contas recebem o perfil Desenvolvedor. O acesso de PO é concedido por um administrador.

From 87d263772a3b1fa12aa4d7317f83791500b799e4 Mon Sep 17 00:00:00 2001 From: Moretto <72614882+Giomoret@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:20:16 -0300 Subject: [PATCH 2/2] fix: incluir validador de cursor compartilhado nos schemas --- backend/src/shared/backlog-cursor.test.ts | 19 +++++++++++++++++++ backend/src/shared/backlog-cursor.ts | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 backend/src/shared/backlog-cursor.test.ts create mode 100644 backend/src/shared/backlog-cursor.ts diff --git a/backend/src/shared/backlog-cursor.test.ts b/backend/src/shared/backlog-cursor.test.ts new file mode 100644 index 0000000..bf9d459 --- /dev/null +++ b/backend/src/shared/backlog-cursor.test.ts @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { backlogCursorSchema, backlogPage } from './backlog-cursor.js'; + +test('cursor preserva microssegundos e funciona sem depender da existência da linha de origem', () => { + const row = { id: randomUUID(), pagination_at: '2026-01-01T12:00:00.000123Z', titulo: 'Item' }; + const page = backlogPage([row], 1); + assert.deepEqual(backlogCursorSchema.parse(page.next_cursor), { id: row.id, at: row.pagination_at }); + assert.equal('pagination_at' in page.items[0], false); + assert.equal(backlogPage([], 100).next_cursor, null); + assert.equal(backlogPage([row], 100).next_cursor, null); +}); + +test('cursor malformado é recusado antes de chegar ao SQL', () => { + for (const cursor of ['invalid', '%%%', Buffer.from(JSON.stringify({ at: '2026-02-30T00:00:00.000000Z', id: randomUUID() })).toString('base64url'), Buffer.from(JSON.stringify({ at: '2026-01-01T00:00:00.000001Z', id: 'x' })).toString('base64url')]) { + assert.equal(backlogCursorSchema.safeParse(cursor).success, false); + } +}); diff --git a/backend/src/shared/backlog-cursor.ts b/backend/src/shared/backlog-cursor.ts new file mode 100644 index 0000000..ae2ba2e --- /dev/null +++ b/backend/src/shared/backlog-cursor.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +const position = z.object({ at: z.string().datetime({ precision: 6 }), id: z.string().uuid() }).strict(); +export const backlogCursorSchema = z.string().max(300).transform((value, ctx) => { + try { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('Formato inválido'); + const parsed = position.safeParse(JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))); + if (parsed.success) return parsed.data; + } catch { /* O cursor não contém dados privilegiados; é apenas uma posição validada. */ } + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Cursor de paginação inválido.' }); + return z.NEVER; +}); + +export function backlogPage(rows: T[], limit: number) { + const last = rows.at(-1); + const next_cursor = rows.length === limit && last + ? Buffer.from(JSON.stringify({ at: last.pagination_at, id: last.id })).toString('base64url') : null; + return { items: rows.map(({ pagination_at: _, ...item }) => item), next_cursor }; +} + +export function cursorTimestamp(alias: string): string { + return `to_char(${alias}.created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS pagination_at`; +}