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
3 changes: 2 additions & 1 deletion backend/scripts/validate-s105.mts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ try {
);
const criteriaRepo = new CriteriaRepository(pool);
const concurrentDeletes = await Promise.allSettled(
insertedCriteria.rows.map(({ id }) => criteriaRepo.delete(id)),
insertedCriteria.rows.map(({ id }) =>
criteriaRepo.delete(id, null, "Remoção concorrente para validar o último critério")),
);
assert.equal(concurrentDeletes.filter((result) => result.status === "fulfilled").length, 1);
const rejected = concurrentDeletes.find((result) => result.status === "rejected");
Expand Down
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { searchRouter } from "./modules/search/search.routes.js";
import { chatRouter } from "./modules/chat/chat.routes.js";
import { developersRouter } from "./modules/developers/developers.routes.js";
import { adminRouter } from "./modules/admin/admin.routes.js";
import { auditRouter } from "./modules/audit/audit.routes.js";
import { errorHandler } from "./middleware/errorHandler.js";
import { requireAuth } from "./middleware/requireAuth.js";

Expand Down Expand Up @@ -57,6 +58,7 @@ app.use("/api/v1/features", requireAuth, featuresRouter);
app.use("/api/v1/pbis", requireAuth, pbisRouter);
app.use("/api/v1/criteria", requireAuth, criteriaRouter);
app.use("/api/v1/quality", qualityRouter);
app.use("/api/v1/audit", auditRouter);

// Documentos do projeto
app.use("/api/v1/projects/:projectId/documents", documentsRouter);
Expand Down
235 changes: 235 additions & 0 deletions backend/src/modules/audit/audit.justificativa.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EpicsService } from "../epics/epics.service.js";
import { FeaturesService } from "../features/features.service.js";
import { PbisService } from "../pbis/pbis.service.js";
import { ValidationError } from "../../shared/errors.js";
import { PbiQualityConfigurationRecord } from "../quality/quality.types.js";

const PROJETO_ID = "d0000000-0000-4000-8000-000000000001";
const EPICO_ID = "a0000000-0000-4000-8000-000000000001";
const FEATURE_ID = "f0000000-0000-4000-8000-000000000001";
const PBI_ID = "b0000000-0000-4000-8000-000000000001";

class StubQualityConfigRepo {
public config: PbiQualityConfigurationRecord = {
rule_version: "pbi-quality-v1",
checks: {
titulo_infinitivo: true,
historia_completa: true,
cenario_estruturado: true,
termos_vagos: true,
prototipo_vinculado: true,
},
vague_terms: ["adequado"],
exigir_justificativa_item_concluido: true,
updated_at: new Date().toISOString(),
updated_by: null,
};

async getPbiConfiguration() {
return this.config;
}
}

class StubProjectsRepo {
async findById(id: string) {
if (id === PROJETO_ID) {
return { id: PROJETO_ID, status: "ativo" } as any;
}
return null;
}
}

class StubEpicsRepo {
public epic: any = {
id: EPICO_ID,
projeto_id: PROJETO_ID,
titulo: "Título Épico",
descricao: "Descrição",
objetivo: "Objetivo",
escopo_macro: "Escopo",
resultado_esperado: "Resultado",
status: "rascunho",
projeto_status: "ativo",
};

async findById(id: string) {
if (id === EPICO_ID) return this.epic;
return null;
}

async update(id: string, data: any) {
this.epic = { ...this.epic, ...data };
return this.epic;
}
}

class StubFeaturesRepo {
public feature: any = {
id: FEATURE_ID,
epico_id: EPICO_ID,
projeto_id: PROJETO_ID,
titulo: "Título Feature",
descricao: "Descrição",
objetivo: "Objetivo",
status: "rascunho",
projeto_status: "ativo",
};

async findById(id: string) {
if (id === FEATURE_ID) return this.feature;
return null;
}

async update(id: string, data: any) {
this.feature = { ...this.feature, ...data };
return this.feature;
}
}

class StubPbisRepo {
public pbi: any = {
id: PBI_ID,
feature_id: FEATURE_ID,
projeto_id: PROJETO_ID,
titulo: "Cadastrar item",
historia_como_um: "PO",
historia_eu_quero: "Cadastrar",
historia_para_que: "Manter",
status: "rascunho",
projeto_status: "ativo",
};

async findById(id: string) {
if (id === PBI_ID) return this.pbi;
return null;
}

async update(id: string, data: any) {
this.pbi = { ...this.pbi, ...data };
return this.pbi;
}
}

class StubQualityService {
async validatePbi() {
return { score_completude: 100 } as any;
}
}

test("S1-24: Rascunhos salvam sem exigir justificativa", async () => {
const qualityConfigRepo = new StubQualityConfigRepo();
const projectsRepo = new StubProjectsRepo();
const epicsRepo = new StubEpicsRepo();
const featuresRepo = new StubFeaturesRepo();
const pbisRepo = new StubPbisRepo();
const qualityService = new StubQualityService();

const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any);
const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any);
const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any);

// Status is rascunho
epicsRepo.epic.status = "rascunho";
featuresRepo.feature.status = "rascunho";
pbisRepo.pbi.status = "rascunho";

// Updating without justification should succeed for rascunhos
const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Título Épico Alterado" });
assert.equal(epicUpdated.titulo, "Título Épico Alterado");

const featureUpdated = await featuresService.update(FEATURE_ID, { titulo: "Título Feature Alterado" });
assert.equal(featureUpdated.titulo, "Título Feature Alterado");

const pbiUpdated = await pbisService.update(PBI_ID, { titulo: "Cadastrar novo item" });
assert.equal(pbiUpdated.titulo, "Cadastrar novo item");
});

test("S1-24: Política ativa bloqueia alteração de item concluído sem justificativa", async () => {
const qualityConfigRepo = new StubQualityConfigRepo();
qualityConfigRepo.config.exigir_justificativa_item_concluido = true;

const projectsRepo = new StubProjectsRepo();
const epicsRepo = new StubEpicsRepo();
const featuresRepo = new StubFeaturesRepo();
const pbisRepo = new StubPbisRepo();
const qualityService = new StubQualityService();

const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any);
const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any);
const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any);

// Set status to concluido
epicsRepo.epic.status = "concluido";
featuresRepo.feature.status = "concluido";
pbisRepo.pbi.status = "concluido";

// Attempting to update without justification must throw ValidationError
await assert.rejects(
() => epicsService.update(EPICO_ID, { titulo: "Novo Épico Concluído" }),
(err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"),
);

await assert.rejects(
() => featuresService.update(FEATURE_ID, { titulo: "Nova Feature Concluída" }),
(err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"),
);

await assert.rejects(
() => pbisService.update(PBI_ID, { titulo: "Consultar item cadastrado" }),
(err: any) => err instanceof ValidationError && err.message.includes("justificativa é obrigatória"),
);
});

test("S1-24: Permitir salvar alteração de item concluído quando justificativa é fornecida", async () => {
const qualityConfigRepo = new StubQualityConfigRepo();
qualityConfigRepo.config.exigir_justificativa_item_concluido = true;

const projectsRepo = new StubProjectsRepo();
const epicsRepo = new StubEpicsRepo();
const featuresRepo = new StubFeaturesRepo();
const pbisRepo = new StubPbisRepo();
const qualityService = new StubQualityService();

const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any);
const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any);
const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any);

epicsRepo.epic.status = "concluido";
featuresRepo.feature.status = "concluido";
pbisRepo.pbi.status = "concluido";

// Updating WITH justification must succeed
const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Épico Reajustado", justificativa: "Ajuste solicitado pelo cliente" });
assert.equal(epicUpdated.titulo, "Épico Reajustado");

const featureUpdated = await featuresService.update(FEATURE_ID, { titulo: "Feature Reajustada", justificativa: "Mudança no escopo de integração" });
assert.equal(featureUpdated.titulo, "Feature Reajustada");

const pbiUpdated = await pbisService.update(PBI_ID, { titulo: "Alterar item concluído", justificativa: "Correção ortográfica" });
assert.equal(pbiUpdated.titulo, "Alterar item concluído");
});

test("S1-24: Política desativada permite salvar item concluído sem justificativa", async () => {
const qualityConfigRepo = new StubQualityConfigRepo();
qualityConfigRepo.config.exigir_justificativa_item_concluido = false;

const projectsRepo = new StubProjectsRepo();
const epicsRepo = new StubEpicsRepo();
const featuresRepo = new StubFeaturesRepo();
const pbisRepo = new StubPbisRepo();
const qualityService = new StubQualityService();

const epicsService = new EpicsService(epicsRepo as any, projectsRepo as any, qualityConfigRepo as any);
const featuresService = new FeaturesService(featuresRepo as any, epicsRepo as any, qualityConfigRepo as any);
const pbisService = new PbisService(pbisRepo as any, featuresRepo as any, qualityService as any, qualityConfigRepo as any);

epicsRepo.epic.status = "concluido";
featuresRepo.feature.status = "concluido";
pbisRepo.pbi.status = "concluido";

// When policy is disabled, saving without justification passes
const epicUpdated = await epicsService.update(EPICO_ID, { titulo: "Épico Sem Justificativa Obrigatória" });
assert.equal(epicUpdated.titulo, "Épico Sem Justificativa Obrigatória");
});
21 changes: 21 additions & 0 deletions backend/src/modules/audit/audit.payloads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Builds a backward-compatible audit payload without duplicating justification. */
export function buildAuditChangeData<Before extends object, After extends object>(
beforeValue: Before,
afterValue: After,
requestedChanges: Record<string, unknown>,
): Record<string, unknown> {
const before = beforeValue as Record<string, unknown>;
const after = afterValue as Record<string, unknown>;
const fields = Object.keys(requestedChanges).filter(
(field) => field !== "justificativa" && field in before && field in after,
);
const pick = (source: Record<string, unknown>) => Object.fromEntries(
fields.map((field) => [field, source[field]]),
);

return {
alteracoes: pick(after),
anterior: pick(before),
novo: pick(after),
};
}
48 changes: 48 additions & 0 deletions backend/src/modules/audit/audit.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Router, Request, Response, NextFunction } from "express";
import { auditService } from "./audit.service.js";
import { requireAuth } from "../../middleware/requireAuth.js";
import { validateUuid, ValidationError } from "../../shared/errors.js";
import { z } from "zod";
import { ENTITY_TYPES } from "../quality/quality.types.js";
import { decodeAuditCursor } from "./audit.service.js";

export const auditRouter = Router();

const historyQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(25),
cursor: z.string().optional(),
}).strict();

auditRouter.use(requireAuth);

function getStringParam(param: string | string[] | undefined): string {
if (Array.isArray(param)) return param[0] ?? "";
return param ?? "";
}

auditRouter.get("/:entidade_tipo/:entidade_id/history", async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const entidadeTipo = getStringParam(req.params.entidade_tipo);
const entidadeId = getStringParam(req.params.entidade_id);
if (!ENTITY_TYPES.includes(entidadeTipo as (typeof ENTITY_TYPES)[number])) {
throw new ValidationError("Tipo de entidade inválido.");
}
validateUuid(entidadeId, "ID da entidade");
const parsedQuery = historyQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
throw new ValidationError("Parâmetros de paginação inválidos.", parsedQuery.error.format());
}
const cursor = parsedQuery.data.cursor
? decodeAuditCursor(parsedQuery.data.cursor)
: undefined;
const history = await auditService.getHistory(
entidadeTipo as (typeof ENTITY_TYPES)[number],
entidadeId,
parsedQuery.data.limit,
cursor,
);
res.status(200).json(history);
} catch (error) {
next(error);
}
});
Loading
Loading