diff --git a/api/src/main/java/synapse/api/auditoria/AcessoAuditoria.java b/api/src/main/java/synapse/api/auditoria/AcessoAuditoria.java new file mode 100644 index 0000000..e224893 --- /dev/null +++ b/api/src/main/java/synapse/api/auditoria/AcessoAuditoria.java @@ -0,0 +1,83 @@ +package synapse.api.auditoria; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import synapse.api.core.logging.CorrelationContext; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.PapelDoUsuario; +import synapse.api.core.security.UsuarioAtual; + +@Configuration +class AcessoAuditoriaConfig implements WebMvcConfigurer { + + private final AcessoAuditoriaInterceptor interceptor; + + AcessoAuditoriaConfig(AcessoAuditoriaInterceptor interceptor) { + this.interceptor = interceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(this.interceptor).addPathPatterns("/auditoria/**"); + } + +} + +/** + * Política das rotas de auditoria: só o papel de auditor passa, sem conferir posse. Roda + * antes de interpretar os parâmetros e de ler qualquer dado, então uma recusa nunca chega + * ao controller. Caminho sem rota segue para o 404 do Spring. + */ +@Component +class AcessoAuditoriaInterceptor implements HandlerInterceptor { + + static final String ACESSO = "acessoAutorizadoAAuditoria"; + + private static final Logger log = LoggerFactory.getLogger(AcessoAuditoriaInterceptor.class); + + private final UsuarioAtual usuarioAtual; + + private final CorrelationContext correlacao; + + AcessoAuditoriaInterceptor(UsuarioAtual usuarioAtual, CorrelationContext correlacao) { + this.usuarioAtual = usuarioAtual; + this.correlacao = correlacao; + } + + @Override + public boolean preHandle(HttpServletRequest requisicao, HttpServletResponse resposta, Object handler) { + if (CorsUtils.isPreFlightRequest(requisicao) || !(handler instanceof HandlerMethod)) { + return true; + } + AcessoDoUsuario acesso; + try { + acesso = this.usuarioAtual.obter(); + } + catch (AccessDeniedException ex) { + // A mensagem do UsuarioAtual fala do token e da conta; fica fora do log. + log.atWarn().addKeyValue("motivo", "identidade_recusada").log("acesso à auditoria recusado"); + throw new SemPermissaoNaAuditoriaException(); + } + if (acesso.papel() != PapelDoUsuario.AUDITOR) { + try (var escopo = this.correlacao.abrir(null, acesso.usuarioId().toString())) { + log.atWarn().addKeyValue("motivo", "papel_sem_acesso").log("acesso à auditoria recusado"); + } + throw new SemPermissaoNaAuditoriaException(); + } + requisicao.setAttribute(ACESSO, acesso); + return true; + } + +} diff --git a/api/src/main/java/synapse/api/auditoria/AuditoriaAdvice.java b/api/src/main/java/synapse/api/auditoria/AuditoriaAdvice.java new file mode 100644 index 0000000..65237ae --- /dev/null +++ b/api/src/main/java/synapse/api/auditoria/AuditoriaAdvice.java @@ -0,0 +1,83 @@ +package synapse.api.auditoria; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import synapse.api.core.logging.CorrelationContext; +import synapse.api.core.security.AcessoDoUsuario; + +@RestControllerAdvice(basePackageClasses = AuditoriaAdvice.class) +@Order(Ordered.HIGHEST_PRECEDENCE) +class AuditoriaAdvice { + + private static final Logger log = LoggerFactory.getLogger(AuditoriaAdvice.class); + + private final CorrelationContext correlacao; + + AuditoriaAdvice(CorrelationContext correlacao) { + this.correlacao = correlacao; + } + + @ExceptionHandler(SemPermissaoNaAuditoriaException.class) + ResponseEntity semPermissao() { + return erro(HttpStatus.FORBIDDEN, "sem_permissao", "Você não tem permissão para esta ação."); + } + + @ExceptionHandler(JobNaoEncontradoNaAuditoriaException.class) + ResponseEntity jobNaoEncontrado() { + return erro(HttpStatus.NOT_FOUND, "job_nao_encontrado", "Job não encontrado."); + } + + @ExceptionHandler(RequisicaoInvalidaNaAuditoriaException.class) + ResponseEntity requisicaoInvalida(RequisicaoInvalidaNaAuditoriaException ex) { + return erro(HttpStatus.BAD_REQUEST, "requisicao_invalida", String.valueOf(ex.getMessage())); + } + + /** + * O Spring recusa o tipo antes do controller, que então não registra o desfecho. O + * log leva só o nome do parâmetro, nunca o valor recebido. + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + ResponseEntity parametroComTipoInvalido(MethodArgumentTypeMismatchException ex, + HttpServletRequest requisicao) { + boolean consulta = ex.getName().equals("id"); + String usuario = requisicao.getAttribute(AcessoAuditoriaInterceptor.ACESSO) instanceof AcessoDoUsuario acesso + ? acesso.usuarioId().toString() : null; + try (var escopo = this.correlacao.abrir(null, usuario)) { + log.atWarn() + .addKeyValue("operacao", consulta ? "consultar_job_auditoria" : "listar_jobs_auditoria") + .addKeyValue("resultado", "requisicao_invalida") + .addKeyValue("parametro", ex.getName()) + .log(consulta ? "consulta de auditoria encerrada" : "listagem de auditoria encerrada"); + } + return requisicaoInvalida(switch (ex.getName()) { + case "id" -> RequisicaoInvalidaNaAuditoriaException.idDoJob(); + case "tamanho" -> RequisicaoInvalidaNaAuditoriaException.tamanho(); + default -> RequisicaoInvalidaNaAuditoriaException.pagina(); + }); + } + + /** O corpo vazio não expõe a mensagem do driver. */ + @ExceptionHandler(DataAccessException.class) + ResponseEntity falhaDePersistencia() { + return ResponseEntity.internalServerError().build(); + } + + private static ResponseEntity erro(HttpStatus status, String codigo, String mensagem) { + return ResponseEntity.status(status) + .contentType(MediaType.APPLICATION_JSON) + .body(new ErroAuditoriaDto(codigo, mensagem)); + } + +} diff --git a/api/src/main/java/synapse/api/auditoria/AuditoriaController.java b/api/src/main/java/synapse/api/auditoria/AuditoriaController.java new file mode 100644 index 0000000..6ae1609 --- /dev/null +++ b/api/src/main/java/synapse/api/auditoria/AuditoriaController.java @@ -0,0 +1,99 @@ +package synapse.api.auditoria; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import synapse.api.core.logging.CorrelationContext; +import synapse.api.core.security.AcessoDoUsuario; + +/** + * Leitura investigativa dos jobs de qualquer dono. O acesso já foi decidido por + * {@link AcessoAuditoriaInterceptor}. Os logs levam o desfecho e as referências, nunca o + * conteúdo devolvido. + */ +@RestController +class AuditoriaController { + + private static final Logger log = LoggerFactory.getLogger(AuditoriaController.class); + + private final AuditoriaService service; + + private final CorrelationContext correlacao; + + AuditoriaController(AuditoriaService service, CorrelationContext correlacao) { + this.service = service; + this.correlacao = correlacao; + } + + @GetMapping(path = "/auditoria/jobs", produces = "application/json") + PaginaJobsAuditoriaDto listar(@RequestParam(name = "pagina", defaultValue = "0") int pagina, + @RequestParam(name = "tamanho", defaultValue = "20") int tamanho, + @RequestAttribute(AcessoAuditoriaInterceptor.ACESSO) AcessoDoUsuario acesso) { + try (var escopo = this.correlacao.abrir(null, acesso.usuarioId().toString())) { + long inicio = System.nanoTime(); + String resultado = "falha"; + try { + PaginaJobsAuditoriaDto resposta = this.service.listar(new PaginaAuditoria(pagina, tamanho)); + resultado = "sucesso"; + return resposta; + } + catch (RequisicaoInvalidaNaAuditoriaException ex) { + resultado = "requisicao_invalida"; + throw ex; + } + finally { + log.atLevel(nivel(resultado)) + .addKeyValue("operacao", "listar_jobs_auditoria") + .addKeyValue("resultado", resultado) + .addKeyValue("pagina", pagina) + .addKeyValue("tamanho", tamanho) + .addKeyValue("duracao_ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - inicio)) + .log("listagem de auditoria encerrada"); + } + } + } + + @GetMapping(path = "/auditoria/jobs/{id}", produces = "application/json") + RegistroAuditoriaDto consultar(@PathVariable UUID id, + @RequestAttribute(AcessoAuditoriaInterceptor.ACESSO) AcessoDoUsuario acesso) { + try (var escopo = this.correlacao.abrir(id.toString(), acesso.usuarioId().toString())) { + long inicio = System.nanoTime(); + String resultado = "falha"; + try { + RegistroAuditoriaDto registro = this.service.consultar(id); + resultado = "sucesso"; + return registro; + } + catch (JobNaoEncontradoNaAuditoriaException ex) { + resultado = "job_nao_encontrado"; + throw ex; + } + finally { + log.atLevel(nivel(resultado)) + .addKeyValue("operacao", "consultar_job_auditoria") + .addKeyValue("resultado", resultado) + .addKeyValue("duracao_ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - inicio)) + .log("consulta de auditoria encerrada"); + } + } + } + + private static Level nivel(String resultado) { + return switch (resultado) { + case "sucesso" -> Level.INFO; + case "falha" -> Level.ERROR; + default -> Level.WARN; + }; + } + +} diff --git a/api/src/main/java/synapse/api/auditoria/AuditoriaRepository.java b/api/src/main/java/synapse/api/auditoria/AuditoriaRepository.java new file mode 100644 index 0000000..f7666b1 --- /dev/null +++ b/api/src/main/java/synapse/api/auditoria/AuditoriaRepository.java @@ -0,0 +1,297 @@ +package synapse.api.auditoria; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import org.jspecify.annotations.Nullable; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * Leitura das tabelas do job para o auditor, sem escrita e sem as classes da fatia + * {@code job}. Nenhuma consulta lê {@code submissoes.binario} nem + * {@code resultados_simulacao.linhas}. Cada seção do registro é uma consulta filtrada + * pelo job e em ordem cronológica, com o id como desempate. + */ +@Repository +class AuditoriaRepository { + + private static final String JOB = """ + SELECT j.id, j.status, + CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem, + j.competencias, j.orcamento, j.meta_venda, j.criado_em, j.iniciado_em, j.finalizado_em, + j.job_origem_id, u.login, u.nome AS usuario_nome + FROM jobs j + JOIN usuarios u ON u.id = j.usuario_id + LEFT JOIN submissoes s ON s.id = j.submissao_id + """; + + private final JdbcTemplate jdbc; + + AuditoriaRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + long contarJobs() { + return Objects.requireNonNull(this.jdbc.queryForObject("SELECT count(*) FROM jobs", Long.class)); + } + + List listarJobs(int tamanho, long deslocamento) { + return this.jdbc.query(JOB + "ORDER BY j.criado_em DESC, j.id DESC LIMIT ? OFFSET ?", + (linha, numero) -> job(linha), tamanho, deslocamento); + } + + List buscarJob(UUID jobId) { + return this.jdbc.query(JOB + "WHERE j.id = ?", (linha, numero) -> job(linha), jobId); + } + + /** + * A submissão inicial do job e as das correções enviadas nas rodadas. O job + * reprocessado não tem submissão própria. + */ + List listarEntradas(UUID jobId) { + return this.jdbc.query(""" + SELECT s.id, s.tipo, + CASE WHEN s.id = j.submissao_id THEN 'entrada_inicial' ELSE 'correcao' END AS finalidade, + s.criado_em, s.conteudo::text AS conteudo, s.transcricao, s.formato, s.transcrito_em + FROM jobs j + JOIN submissoes s ON s.id = j.submissao_id + OR s.id IN (SELECT r.submissao_correcao_id FROM rodadas_correcao r WHERE r.job_id = j.id) + WHERE j.id = ? + ORDER BY s.criado_em, s.id + """, + (linha, numero) -> new EntradaAuditoriaDto(uuid(linha, "id"), texto(linha, "tipo"), + texto(linha, "finalidade"), momento(linha, "criado_em"), linha.getString("conteudo"), + linha.getString("transcricao"), linha.getString("formato"), instante(linha, "transcrito_em")), + jobId); + } + + /** + * {@code representacao} é o artefato completo quando a versão o tem, e o núcleo com + * as especificações nas versões anteriores à coluna. + */ + List listarVersoes(UUID jobId) { + return this.jdbc.query( + """ + SELECT id, versao, origem, regra_origem_id, hash, tentativa, criada_em, parametros::text AS parametros, + COALESCE(representacao, + jsonb_build_object('nucleo', nucleo, 'especificacoes', especificacoes))::text AS representacao + FROM regras + WHERE job_id = ? + ORDER BY criada_em, versao + """, + (linha, numero) -> new VersaoAuditoriaDto(uuid(linha, "id"), linha.getInt("versao"), + texto(linha, "origem"), texto(linha, "representacao"), momento(linha, "criada_em"), + linha.getObject("regra_origem_id", UUID.class), texto(linha, "hash"), + linha.getObject("tentativa", Integer.class), linha.getString("parametros")), + jobId); + } + + /** + * A simulação vem da linha quando ela a traz e, senão, do código do resultado + * apontado, porque {@code simulacoes.codigo_gerado_id} é único. A submissão da + * extração inicial é a da extração gravada com a resposta do prompt da linha. A da + * reextração é a correção da rodada que analisou a versão da linha, a última enviada + * até a conclusão: depois de uma reextração que falhou, a rodada seguinte analisa a + * mesma versão. + */ + List listarEtapas(UUID jobId) { + return this.jdbc.query(""" + SELECT t.id, t.no, t.concluido_em, t.conclusao::text AS conclusao, t.regra_id, t.resultado_id, + t.prompt_id, t.codigo_gerado_id, t.explicacao_id, + COALESCE(t.simulacao_id, ( + SELECT s.id FROM resultados_simulacao rs + JOIN simulacoes s ON s.codigo_gerado_id = rs.codigo_gerado_id AND s.job_id = rs.job_id + WHERE rs.id = t.resultado_id AND rs.job_id = t.job_id + )) AS simulacao_id, + CASE WHEN t.no = 'extracao_parametros' THEN COALESCE(( + SELECT e.submissao_id FROM respostas_modelo rm + JOIN extracoes_regras e ON e.resposta_id = rm.id AND e.job_id = rm.job_id + WHERE rm.prompt_id = t.prompt_id AND rm.job_id = t.job_id + ORDER BY e.criado_em, e.id + LIMIT 1 + ), ( + SELECT r.submissao_correcao_id FROM rodadas_correcao r + JOIN submissoes s ON s.id = r.submissao_correcao_id + WHERE r.job_id = t.job_id AND r.regra_analisada_id = t.regra_id + AND s.criado_em <= t.concluido_em + ORDER BY s.criado_em DESC, s.id DESC + LIMIT 1 + )) END AS submissao_id + FROM trilhas_auditoria t + WHERE t.job_id = ? + ORDER BY t.concluido_em, t.id + """, (linha, numero) -> new EtapaAuditoriaDto(uuid(linha, "id"), texto(linha, "no"), + momento(linha, "concluido_em"), texto(linha, "conclusao"), linha.getObject("regra_id", UUID.class), + linha.getObject("resultado_id", UUID.class), linha.getObject("prompt_id", UUID.class), + linha.getObject("codigo_gerado_id", UUID.class), linha.getObject("explicacao_id", UUID.class), + linha.getObject("simulacao_id", UUID.class), linha.getObject("submissao_id", UUID.class)), jobId); + } + + List listarChamadasModelo(UUID jobId) { + return this.jdbc.query(""" + SELECT p.id, p.no, p.modelo::text AS modelo, p.conteudo AS prompt, p.criado_em, + rm.id AS resposta_id, rm.conteudo AS resposta, rm.consumo_tokens::text AS consumo_tokens, + rm.criado_em AS respondido_em + FROM prompts p + LEFT JOIN respostas_modelo rm ON rm.prompt_id = p.id AND rm.job_id = p.job_id + WHERE p.job_id = ? + ORDER BY p.criado_em, p.id + """, (linha, numero) -> { + UUID respostaId = linha.getObject("resposta_id", UUID.class); + RespostaModeloAuditoriaDto resposta = respostaId == null ? null : new RespostaModeloAuditoriaDto(respostaId, + texto(linha, "resposta"), linha.getString("consumo_tokens"), momento(linha, "respondido_em")); + return new ChamadaModeloAuditoriaDto(uuid(linha, "id"), texto(linha, "no"), texto(linha, "modelo"), + texto(linha, "prompt"), momento(linha, "criado_em"), resposta); + }, jobId); + } + + List listarCodigos(UUID jobId) { + return this.jdbc.query(""" + SELECT id, regra_id, linguagem, fonte, prompt_id, criado_em + FROM codigos_gerados + WHERE job_id = ? + ORDER BY criado_em, id + """, + (linha, numero) -> new CodigoAuditoriaDto(uuid(linha, "id"), uuid(linha, "regra_id"), + texto(linha, "linguagem"), texto(linha, "fonte"), uuid(linha, "prompt_id"), + momento(linha, "criado_em")), + jobId); + } + + /** Inclui as execuções candidatas da busca da meta, que não têm simulação própria. */ + List listarResultados(UUID jobId) { + return this.jdbc.query(""" + SELECT id, codigo_gerado_id, status, proposito, veredito, meta_venda, criado_em, + totais::text AS totais, assercoes::text AS assercoes, decomposicao::text AS decomposicao, + diagnostico::text AS diagnostico + FROM resultados_simulacao + WHERE job_id = ? + ORDER BY criado_em, id + """, + (linha, numero) -> new ResultadoAuditoriaDto(uuid(linha, "id"), uuid(linha, "codigo_gerado_id"), + texto(linha, "status"), texto(linha, "proposito"), linha.getString("veredito"), + linha.getBigDecimal("meta_venda"), linha.getString("totais"), texto(linha, "assercoes"), + linha.getString("decomposicao"), linha.getString("diagnostico"), momento(linha, "criado_em")), + jobId); + } + + List listarSimulacoes(UUID jobId) { + return this.jdbc.query(""" + SELECT id, regra_id, codigo_gerado_id, resultado_id, flag_baixa_rastreabilidade, criado_em + FROM simulacoes + WHERE job_id = ? + ORDER BY criado_em, id + """, + (linha, numero) -> new SimulacaoAuditoriaDto(uuid(linha, "id"), uuid(linha, "regra_id"), + uuid(linha, "codigo_gerado_id"), linha.getObject("resultado_id", UUID.class), + linha.getBoolean("flag_baixa_rastreabilidade"), momento(linha, "criado_em")), + jobId); + } + + List listarTransicoes(UUID jobId) { + return this.jdbc.query(""" + SELECT id, status_anterior, status_novo, ocorrido_em, ator, motivo + FROM job_transicoes + WHERE job_id = ? + ORDER BY ocorrido_em, id + """, + (linha, numero) -> new TransicaoAuditoriaDto(uuid(linha, "id"), linha.getString("status_anterior"), + texto(linha, "status_novo"), momento(linha, "ocorrido_em"), texto(linha, "ator"), + linha.getString("motivo")), + jobId); + } + + List listarAcoes(UUID jobId) { + return this.jdbc.query(""" + SELECT id, acao, executado_em + FROM job_acoes + WHERE job_id = ? + ORDER BY executado_em, id + """, (linha, numero) -> new AcaoAuditoriaDto(uuid(linha, "id"), texto(linha, "acao"), + momento(linha, "executado_em")), jobId); + } + + List listarRodadas(UUID jobId) { + return this.jdbc.query(""" + SELECT r.id, r.rodada_anterior_id, r.regra_analisada_id, r.conflitos::text AS conflitos, r.estado, + r.regra_resultante_id, r.criada_em, s.id AS submissao_id, s.tipo, s.transcricao, + s.criado_em AS enviada_em + FROM rodadas_correcao r + LEFT JOIN submissoes s ON s.id = r.submissao_correcao_id + WHERE r.job_id = ? + ORDER BY r.criada_em, r.id + """, (linha, numero) -> { + CorrecaoAuditoriaDto correcao = linha.getObject("submissao_id", UUID.class) == null ? null + : new CorrecaoAuditoriaDto(texto(linha, "tipo"), linha.getString("transcricao"), + momento(linha, "enviada_em")); + return new RodadaAuditoriaDto(uuid(linha, "id"), linha.getObject("rodada_anterior_id", UUID.class), + uuid(linha, "regra_analisada_id"), texto(linha, "conflitos"), correcao, + linha.getObject("regra_resultante_id", UUID.class), texto(linha, "estado"), + momento(linha, "criada_em")); + }, jobId); + } + + List listarSugestoesMeta(UUID jobId) { + return this.jdbc.query(""" + SELECT regra_id, resultado_id, resultado_anterior_id, motivo_sem_solucao, criada_em, aceita_em + FROM sugestoes_meta_venda + WHERE job_id = ? + ORDER BY criada_em, regra_id + """, (linha, numero) -> new SugestaoMetaAuditoriaDto(uuid(linha, "regra_id"), + linha.getObject("resultado_id", UUID.class), linha.getObject("resultado_anterior_id", UUID.class), + linha.getString("motivo_sem_solucao"), momento(linha, "criada_em"), instante(linha, "aceita_em")), + jobId); + } + + /** Conferidas e reprovadas: só o auditor vê a narrativa reprovada. */ + List listarExplicacoes(UUID jobId) { + return this.jdbc.query(""" + SELECT e.id, e.resultado_id, e.texto, e.aderencia_conferida, e.criado_em + FROM explicacoes e + JOIN resultados_simulacao rs ON rs.id = e.resultado_id + WHERE rs.job_id = ? + ORDER BY e.criado_em, e.id + """, + (linha, numero) -> new ExplicacaoAuditoriaDto(uuid(linha, "id"), uuid(linha, "resultado_id"), + texto(linha, "texto"), linha.getBoolean("aderencia_conferida"), momento(linha, "criado_em")), + jobId); + } + + private static JobLido job(ResultSet linha) throws SQLException { + JobAuditoriaDto job = new JobAuditoriaDto(uuid(linha, "id"), null, texto(linha, "status"), + texto(linha, "origem"), List.of((String[]) linha.getArray("competencias").getArray()), + linha.getBigDecimal("orcamento"), linha.getBigDecimal("meta_venda"), momento(linha, "criado_em"), + instante(linha, "finalizado_em"), linha.getObject("job_origem_id", UUID.class), + new UsuarioAuditoriaDto(texto(linha, "login"), texto(linha, "usuario_nome"))); + return new JobLido(job, instante(linha, "iniciado_em")); + } + + private static UUID uuid(ResultSet linha, String coluna) throws SQLException { + return Objects.requireNonNull(linha.getObject(coluna, UUID.class)); + } + + private static String texto(ResultSet linha, String coluna) throws SQLException { + return Objects.requireNonNull(linha.getString(coluna)); + } + + private static Instant momento(ResultSet linha, String coluna) throws SQLException { + return Objects.requireNonNull(instante(linha, coluna)); + } + + private static @Nullable Instant instante(ResultSet linha, String coluna) throws SQLException { + OffsetDateTime valor = linha.getObject(coluna, OffsetDateTime.class); + return valor == null ? null : valor.toInstant(); + } + + /** {@code iniciadoEm} só sai no registro do job, não na listagem. */ + record JobLido(JobAuditoriaDto job, @Nullable Instant iniciadoEm) { + } + +} diff --git a/api/src/main/java/synapse/api/auditoria/AuditoriaService.java b/api/src/main/java/synapse/api/auditoria/AuditoriaService.java new file mode 100644 index 0000000..6ecf157 --- /dev/null +++ b/api/src/main/java/synapse/api/auditoria/AuditoriaService.java @@ -0,0 +1,60 @@ +package synapse.api.auditoria; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import synapse.api.auditoria.AuditoriaRepository.JobLido; +import synapse.api.job.NomesDeJobs; + +/** + * As consultas de uma resposta rodam numa transação só de leitura e com um único + * instantâneo do banco, para que uma seção não aponte o que outra, lida antes, ainda não + * tinha enquanto o job avança. + */ +@Service +class AuditoriaService { + + private final AuditoriaRepository repository; + + private final NomesDeJobs nomes; + + AuditoriaService(AuditoriaRepository repository, NomesDeJobs nomes) { + this.repository = repository; + this.nomes = nomes; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + PaginaJobsAuditoriaDto listar(PaginaAuditoria pagina) { + long total = this.repository.contarJobs(); + List jobs = this.repository.listarJobs(pagina.tamanho(), pagina.deslocamento()) + .stream() + .map(JobLido::job) + .toList(); + Map nomesResolvidos = this.nomes.resolver(jobs.stream().map(JobAuditoriaDto::id).toList()); + List itens = jobs.stream().map(job -> job.comNome(nomesResolvidos.get(job.id()))).toList(); + return new PaginaJobsAuditoriaDto(itens, pagina.pagina(), pagina.tamanho(), total); + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + RegistroAuditoriaDto consultar(UUID jobId) { + List encontrados = this.repository.buscarJob(jobId); + if (encontrados.isEmpty()) { + throw new JobNaoEncontradoNaAuditoriaException(); + } + JobLido lido = encontrados.getFirst(); + JobAuditoriaDto job = lido.job().comNome(this.nomes.resolver(List.of(jobId)).get(jobId)); + return new RegistroAuditoriaDto(new JobRegistradoDto(job, lido.iniciadoEm()), + this.repository.listarEntradas(jobId), this.repository.listarVersoes(jobId), + this.repository.listarEtapas(jobId), this.repository.listarChamadasModelo(jobId), + this.repository.listarCodigos(jobId), this.repository.listarResultados(jobId), + this.repository.listarSimulacoes(jobId), this.repository.listarTransicoes(jobId), + this.repository.listarAcoes(jobId), this.repository.listarRodadas(jobId), + this.repository.listarSugestoesMeta(jobId), this.repository.listarExplicacoes(jobId)); + } + +} diff --git a/api/src/main/java/synapse/api/auditoria/ExcecoesAuditoria.java b/api/src/main/java/synapse/api/auditoria/ExcecoesAuditoria.java new file mode 100644 index 0000000..3ec1b80 --- /dev/null +++ b/api/src/main/java/synapse/api/auditoria/ExcecoesAuditoria.java @@ -0,0 +1,32 @@ +package synapse.api.auditoria; + +/** O papel da sessão não é o de auditor. */ +class SemPermissaoNaAuditoriaException extends RuntimeException { + +} + +class JobNaoEncontradoNaAuditoriaException extends RuntimeException { + +} + +/** A mensagem é a exposta ao cliente, a mesma de {@code GET /jobs}. */ +class RequisicaoInvalidaNaAuditoriaException extends RuntimeException { + + private RequisicaoInvalidaNaAuditoriaException(String mensagem) { + super(mensagem); + } + + static RequisicaoInvalidaNaAuditoriaException pagina() { + return new RequisicaoInvalidaNaAuditoriaException( + "O parâmetro pagina precisa ser um inteiro maior ou igual a zero."); + } + + static RequisicaoInvalidaNaAuditoriaException tamanho() { + return new RequisicaoInvalidaNaAuditoriaException("O parâmetro tamanho precisa ser um inteiro entre 1 e 100."); + } + + static RequisicaoInvalidaNaAuditoriaException idDoJob() { + return new RequisicaoInvalidaNaAuditoriaException("O identificador do job precisa ser um UUID."); + } + +} diff --git a/api/src/main/java/synapse/api/auditoria/PaginaAuditoria.java b/api/src/main/java/synapse/api/auditoria/PaginaAuditoria.java new file mode 100644 index 0000000..90327c7 --- /dev/null +++ b/api/src/main/java/synapse/api/auditoria/PaginaAuditoria.java @@ -0,0 +1,21 @@ +package synapse.api.auditoria; + +/** Paginação de {@code GET /auditoria/jobs}, com os limites de {@code GET /jobs}. */ +record PaginaAuditoria(int pagina, int tamanho) { + + static final int TAMANHO_MAXIMO = 100; + + PaginaAuditoria { + if (pagina < 0) { + throw RequisicaoInvalidaNaAuditoriaException.pagina(); + } + if (tamanho < 1 || tamanho > TAMANHO_MAXIMO) { + throw RequisicaoInvalidaNaAuditoriaException.tamanho(); + } + } + + long deslocamento() { + return (long) this.pagina * this.tamanho; + } + +} diff --git a/api/src/main/java/synapse/api/auditoria/RespostasAuditoria.java b/api/src/main/java/synapse/api/auditoria/RespostasAuditoria.java new file mode 100644 index 0000000..6159602 --- /dev/null +++ b/api/src/main/java/synapse/api/auditoria/RespostasAuditoria.java @@ -0,0 +1,124 @@ +package synapse.api.auditoria; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import org.jspecify.annotations.Nullable; + +/* + * Formas de PaginaJobsAuditoria e RegistroAuditoria no contrato HTTP. Opcional sem valor + * fica ausente do JSON. Os campos jsonb saem como o banco os devolve, sem passar por + * objetos Java. + */ + +record PaginaJobsAuditoriaDto(List itens, int pagina, int tamanho, long total) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record JobAuditoriaDto(UUID id, @Nullable String nome, String status, String origem, List competencias, + @Nullable BigDecimal orcamento, @Nullable BigDecimal meta_venda, Instant criado_em, + @Nullable Instant finalizado_em, @Nullable UUID job_origem_id, UsuarioAuditoriaDto usuario) { + + JobAuditoriaDto comNome(@Nullable String nomeResolvido) { + return new JobAuditoriaDto(this.id, nomeResolvido, this.status, this.origem, this.competencias, this.orcamento, + this.meta_venda, this.criado_em, this.finalizado_em, this.job_origem_id, this.usuario); + } + +} + +record UsuarioAuditoriaDto(String login, String nome) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record JobRegistradoDto(@JsonUnwrapped JobAuditoriaDto job, @Nullable Instant iniciado_em) { +} + +record RegistroAuditoriaDto(JobRegistradoDto job, List entradas, List versoes, + List etapas, List chamadas_modelo, + List codigos, List resultados, + List simulacoes, List transicoes, List acoes, + List rodadas, List sugestoes_meta, + List explicacoes) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record EntradaAuditoriaDto(UUID id, String tipo, String finalidade, Instant criado_em, + @JsonRawValue @Nullable String conteudo, @Nullable String texto, @Nullable String formato, + @Nullable Instant transcrito_em) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record VersaoAuditoriaDto(UUID id, int versao, String origem, @JsonRawValue String representacao, Instant criada_em, + @Nullable UUID regra_origem_id, String hash, @Nullable Integer tentativa, + @JsonRawValue @Nullable String parametros) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record EtapaAuditoriaDto(UUID id, String no, Instant concluido_em, @JsonRawValue String conclusao, + @Nullable UUID regra_id, @Nullable UUID resultado_id, @Nullable UUID prompt_id, @Nullable UUID codigo_gerado_id, + @Nullable UUID explicacao_id, @Nullable UUID simulacao_id, @Nullable UUID submissao_id) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record ChamadaModeloAuditoriaDto(UUID id, String no, @JsonRawValue String modelo, String prompt, Instant criado_em, + @Nullable RespostaModeloAuditoriaDto resposta) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record RespostaModeloAuditoriaDto(UUID id, String texto, @JsonRawValue @Nullable String consumo_tokens, + Instant criado_em) { +} + +record CodigoAuditoriaDto(UUID id, UUID regra_id, String linguagem, String fonte, UUID prompt_id, Instant criado_em) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record ResultadoAuditoriaDto(UUID id, UUID codigo_gerado_id, String status, String proposito, @Nullable String veredito, + @Nullable BigDecimal meta_venda, @JsonRawValue @Nullable String totais, @JsonRawValue String assercoes, + @JsonRawValue @Nullable String decomposicao, @JsonRawValue @Nullable String diagnostico, Instant criado_em) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record SimulacaoAuditoriaDto(UUID id, UUID regra_id, UUID codigo_gerado_id, @Nullable UUID resultado_id, + boolean flag_baixa_rastreabilidade, Instant criado_em) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record TransicaoAuditoriaDto(UUID id, @Nullable String status_anterior, String status_novo, Instant ocorrido_em, + String ator, @Nullable String motivo) { +} + +record AcaoAuditoriaDto(UUID id, String acao, Instant executado_em) { +} + +/** + * Forma de {@code Rodada}, que exige {@code rodada_anterior_id}, {@code correcao} e + * {@code regra_resultante_id} presentes mesmo nulos. + */ +record RodadaAuditoriaDto(UUID id, @Nullable UUID rodada_anterior_id, UUID regra_analisada_id, + @JsonRawValue String conflitos, @Nullable CorrecaoAuditoriaDto correcao, @Nullable UUID regra_resultante_id, + String estado, Instant criada_em) { +} + +/** + * {@code texto} sai como {@code null} enquanto a correção por voz não tem transcrição. + */ +record CorrecaoAuditoriaDto(String tipo, @Nullable String texto, Instant enviada_em) { +} + +@JsonInclude(JsonInclude.Include.NON_NULL) +record SugestaoMetaAuditoriaDto(UUID regra_id, @Nullable UUID resultado_id, @Nullable UUID resultado_anterior_id, + @Nullable String motivo_sem_solucao, Instant criada_em, @Nullable Instant aceita_em) { +} + +record ExplicacaoAuditoriaDto(UUID id, UUID resultado_id, String texto, boolean aderencia_conferida, + Instant criado_em) { +} + +record ErroAuditoriaDto(String codigo, String mensagem) { +} diff --git a/api/src/main/java/synapse/api/job/JobRepository.java b/api/src/main/java/synapse/api/job/JobRepository.java index f06bcef..0d95cbd 100644 --- a/api/src/main/java/synapse/api/job/JobRepository.java +++ b/api/src/main/java/synapse/api/job/JobRepository.java @@ -8,6 +8,8 @@ import java.time.Instant; import java.time.OffsetDateTime; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -20,6 +22,7 @@ import tools.jackson.databind.json.JsonMapper; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowCallbackHandler; import org.springframework.jdbc.support.SqlArrayValue; import org.springframework.stereotype.Repository; @@ -121,6 +124,24 @@ private static List competencias(Array coluna) throws SQLException { return valor == null ? null : valor.toInstant(); } + /** Só os jobs com nome resolvido entram no mapa. */ + Map resolverNomes(Collection jobIds) { + Map nomes = new HashMap<>(); + this.jdbc.query(""" + SELECT j.id, + """ + NOME_RESOLVIDO + """ + FROM jobs j + """ + JUNCAO_TEXTO_INICIAL + """ + WHERE j.id = ANY(?) + """, (RowCallbackHandler) linha -> { + String nome = linha.getString("nome"); + if (nome != null) { + nomes.put(Objects.requireNonNull(linha.getObject("id", UUID.class)), nome); + } + }, new SqlArrayValue("uuid", jobIds.toArray())); + return Map.copyOf(nomes); + } + List buscarProprietario(UUID jobId) { return this.jdbc.queryForList("SELECT usuario_id FROM jobs WHERE id = ?", UUID.class, jobId); } diff --git a/api/src/main/java/synapse/api/job/NomesDeJobs.java b/api/src/main/java/synapse/api/job/NomesDeJobs.java new file mode 100644 index 0000000..a146a9d --- /dev/null +++ b/api/src/main/java/synapse/api/job/NomesDeJobs.java @@ -0,0 +1,34 @@ +package synapse.api.job; + +import java.util.Collection; +import java.util.Map; +import java.util.UUID; + +import org.springframework.stereotype.Service; + +/** + * O nome de exibição dos jobs pela mesma regra de {@code GET /jobs} e {@code GET + * /jobs/{id}}: o definido pelo usuário, senão o padrão tirado do texto inicial. Job sem + * nome resolvido fica fora do mapa. + */ +public interface NomesDeJobs { + + Map resolver(Collection jobIds); + +} + +@Service +class NomesDeJobsService implements NomesDeJobs { + + private final JobRepository repository; + + NomesDeJobsService(JobRepository repository) { + this.repository = repository; + } + + @Override + public Map resolver(Collection jobIds) { + return jobIds.isEmpty() ? Map.of() : this.repository.resolverNomes(jobIds); + } + +} diff --git a/api/src/test/java/synapse/api/auditoria/AcessoAuditoriaTests.java b/api/src/test/java/synapse/api/auditoria/AcessoAuditoriaTests.java new file mode 100644 index 0000000..a6688e4 --- /dev/null +++ b/api/src/test/java/synapse/api/auditoria/AcessoAuditoriaTests.java @@ -0,0 +1,286 @@ +package synapse.api.auditoria; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.MDC; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import synapse.api.core.config.AppProperties; +import synapse.api.core.logging.CorrelationContext; +import synapse.api.core.security.UsuarioAtual; +import synapse.api.job.CapturaDeLog; +import synapse.api.job.ContratoDeEvento; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * A política das rotas de auditoria, com o {@link UsuarioAtual} real sobre um banco + * simulado: a recusa acontece antes do controller, sem ler dado de job. + */ +class AcessoAuditoriaTests { + + private static final UUID USUARIO = UUID.randomUUID(); + + private static final UUID JOB = UUID.randomUUID(); + + private final AuditoriaService service = mock(AuditoriaService.class); + + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + + private MockMvc mvc; + + @BeforeEach + void preparar() { + when(this.jdbc.queryForList(contains("FROM usuarios"), eq(UUID.class), eq("subject-a"), eq("login-a"))) + .thenReturn(List.of(USUARIO)); + when(this.jdbc.update(contains("UPDATE usuarios"), any(), any(), any(), eq(USUARIO))).thenReturn(1); + when(this.service.listar(any())).thenReturn(new PaginaJobsAuditoriaDto(List.of(), 0, 20, 0)); + CorrelationContext correlacao = new CorrelationContext(); + UsuarioAtual usuario = new UsuarioAtual(this.jdbc, mock(AppProperties.class)); + this.mvc = MockMvcBuilders.standaloneSetup(new AuditoriaController(this.service, correlacao)) + .addInterceptors(new AcessoAuditoriaInterceptor(usuario, correlacao)) + .setControllerAdvice(new AuditoriaAdvice(correlacao)) + .build(); + } + + @AfterEach + void limparSessao() { + SecurityContextHolder.clearContext(); + MDC.clear(); + } + + static Stream papeisRecusados() { + return Stream + .of(List.of("profissional-rh"), List.of("offline_access", "profissional-rh"), List.of(), + List.of("offline_access"), List.of("profissional-rh", "auditor")) + .flatMap(papeis -> Stream.of("listar", "consultar").map(rota -> Arguments.of(rota, papeis))); + } + + @ParameterizedTest(name = "{0} recusada para {1}") + @MethodSource("papeisRecusados") + void papelDiferenteDeAuditorRecebe403SemChegarAoServico(String rota, List papeis) throws Exception { + autenticar(papeis); + this.mvc.perform(pedido(rota)) + .andExpect(status().isForbidden()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.codigo").value("sem_permissao")) + .andExpect(jsonPath("$.mensagem").value("Você não tem permissão para esta ação.")); + verifyNoInteractions(this.service); + if (!papeis.contains("profissional-rh") || papeis.contains("auditor")) { + verifyNoInteractions(this.jdbc); + } + } + + @ParameterizedTest + @ValueSource(strings = { "listar", "consultar" }) + void auditorPassaSemConferirPosse(String rota) throws Exception { + autenticar(List.of("default-roles-synapse", "auditor")); + this.mvc.perform(pedido(rota)).andExpect(status().isOk()); + if (rota.equals("listar")) { + verify(this.service).listar(new PaginaAuditoria(0, 20)); + } + else { + verify(this.service).consultar(JOB); + } + } + + @Test + void recusaPrecedeALeituraDoIdMalformado() throws Exception { + autenticar(List.of("profissional-rh")); + this.mvc.perform(get("/auditoria/jobs/{id}", "nao-e-uuid")).andExpect(status().isForbidden()); + autenticar(List.of("auditor")); + this.mvc.perform(get("/auditoria/jobs/{id}", "nao-e-uuid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.codigo").value("requisicao_invalida")) + .andExpect(jsonPath("$.mensagem").value("O identificador do job precisa ser um UUID.")); + verifyNoInteractions(this.service); + } + + @Test + void jobInexistenteRecebe404() throws Exception { + autenticar(List.of("auditor")); + when(this.service.consultar(JOB)).thenThrow(new JobNaoEncontradoNaAuditoriaException()); + this.mvc.perform(pedido("consultar")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.codigo").value("job_nao_encontrado")) + .andExpect(jsonPath("$.mensagem").value("Job não encontrado.")); + } + + static Stream paginacaoInvalida() { + String pagina = "O parâmetro pagina precisa ser um inteiro maior ou igual a zero."; + String tamanho = "O parâmetro tamanho precisa ser um inteiro entre 1 e 100."; + return Stream.of(Arguments.of("pagina", "-1", pagina), Arguments.of("pagina", "um", pagina), + Arguments.of("tamanho", "0", tamanho), Arguments.of("tamanho", "101", tamanho), + Arguments.of("tamanho", "vinte", tamanho)); + } + + @ParameterizedTest(name = "{0}={1}") + @MethodSource("paginacaoInvalida") + void paginacaoInvalidaRecebe400ComAMensagemDaListagemDeJobs(String parametro, String valor, String mensagem) + throws Exception { + autenticar(List.of("auditor")); + this.mvc.perform(get("/auditoria/jobs").param(parametro, valor)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.codigo").value("requisicao_invalida")) + .andExpect(jsonPath("$.mensagem").value(mensagem)); + verifyNoInteractions(this.service); + } + + static Stream tiposInvalidos() { + return Stream.of(Arguments.of("/auditoria/jobs?pagina=um", "pagina", "listar_jobs_auditoria"), + Arguments.of("/auditoria/jobs?tamanho=vinte", "tamanho", "listar_jobs_auditoria"), + Arguments.of("/auditoria/jobs/nao-e-uuid", "id", "consultar_job_auditoria")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("tiposInvalidos") + void tipoInvalidoLogaWarnComOParametroESemOValorRecebido(String caminho, String parametro, String operacao) + throws Exception { + autenticar(List.of("auditor")); + List eventos; + try (CapturaDeLog captura = new CapturaDeLog(AuditoriaAdvice.class)) { + this.mvc.perform(get(caminho)).andExpect(status().isBadRequest()); + eventos = captura.eventos(); + } + assertThat(eventos).hasSize(1); + String linha = CapturaDeLog.emJson(eventos.getFirst()); + ContratoDeEvento.validarLog(linha); + JsonNode json = new JsonMapper().readTree(linha); + assertThat(json.path("level").asString()).isEqualTo("WARN"); + assertThat(json.path("message").asString()).isIn("listagem de auditoria encerrada", + "consulta de auditoria encerrada"); + assertThat(json.path("user_id").asString()).isEqualTo(USUARIO.toString()); + assertThat(json.has("job_id")).isFalse(); + assertThat(json.path("extra").path("operacao").asString()).isEqualTo(operacao); + assertThat(json.path("extra").path("resultado").asString()).isEqualTo("requisicao_invalida"); + assertThat(json.path("extra").path("parametro").asString()).isEqualTo(parametro); + assertThat(linha).doesNotContain("nao-e-uuid", "vinte", "pagina=um", "subject-a", "login-a"); + assertThat(MDC.get(CorrelationContext.USER_ID_KEY)).isNull(); + } + + @ParameterizedTest(name = "pagina={0} tamanho={1}") + @CsvSource({ "-1, 20", "0, 0", "0, 101" }) + void paginacaoForaDoLimiteLogaWarnComPaginaETamanho(int pagina, int tamanho) throws Exception { + autenticar(List.of("auditor")); + List eventos; + try (CapturaDeLog captura = new CapturaDeLog(AuditoriaController.class)) { + this.mvc + .perform(get("/auditoria/jobs").param("pagina", Integer.toString(pagina)) + .param("tamanho", Integer.toString(tamanho))) + .andExpect(status().isBadRequest()); + eventos = captura.eventos(); + } + assertThat(eventos).hasSize(1); + String linha = CapturaDeLog.emJson(eventos.getFirst()); + ContratoDeEvento.validarLog(linha); + JsonNode json = new JsonMapper().readTree(linha); + assertThat(json.path("level").asString()).isEqualTo("WARN"); + assertThat(json.path("message").asString()).isEqualTo("listagem de auditoria encerrada"); + assertThat(json.path("user_id").asString()).isEqualTo(USUARIO.toString()); + assertThat(json.path("extra").path("resultado").asString()).isEqualTo("requisicao_invalida"); + assertThat(json.path("extra").path("pagina").asInt()).isEqualTo(pagina); + assertThat(json.path("extra").path("tamanho").asInt()).isEqualTo(tamanho); + assertThat(MDC.get(CorrelationContext.USER_ID_KEY)).isNull(); + } + + @Test + void recusaPorPapelLogaWarnComOUsuarioLocalESemDadosDoToken() throws Exception { + autenticar(List.of("profissional-rh")); + List eventos; + try (CapturaDeLog captura = new CapturaDeLog(AcessoAuditoriaInterceptor.class)) { + this.mvc.perform(pedido("consultar")).andExpect(status().isForbidden()); + eventos = captura.eventos(); + } + assertThat(eventos).hasSize(1); + String linha = CapturaDeLog.emJson(eventos.getFirst()); + ContratoDeEvento.validarLog(linha); + Map json = new JsonMapper().readValue(linha, Map.class); + assertThat(json.get("level")).isEqualTo("WARN"); + assertThat(json.get("message")).isEqualTo("acesso à auditoria recusado"); + assertThat(json.get("user_id")).isEqualTo(USUARIO.toString()); + assertThat(json.containsKey("job_id")).isFalse(); + assertThat(linha).doesNotContain("subject-a", "login-a", "profissional-rh"); + assertThat(MDC.get(CorrelationContext.USER_ID_KEY)).isNull(); + } + + @Test + void identidadeRecusadaLogaWarnSemUsuario() throws Exception { + autenticar(List.of("profissional-rh", "auditor")); + List eventos; + try (CapturaDeLog captura = new CapturaDeLog(AcessoAuditoriaInterceptor.class)) { + this.mvc.perform(pedido("listar")).andExpect(status().isForbidden()); + eventos = captura.eventos(); + } + assertThat(eventos).hasSize(1); + String linha = CapturaDeLog.emJson(eventos.getFirst()); + ContratoDeEvento.validarLog(linha); + assertThat(linha).contains("\"identidade_recusada\"").doesNotContain("user_id", "subject-a", "login-a"); + } + + @Test + void falhaInesperadaLogaErroEFechaACorrelacao() throws Exception { + autenticar(List.of("auditor")); + when(this.service.consultar(JOB)).thenThrow(new org.springframework.dao.DataAccessResourceFailureException( + "SELECT conteudo FROM prompts WHERE job_id = ?")); + List eventos; + try (CapturaDeLog captura = new CapturaDeLog(AuditoriaController.class)) { + this.mvc.perform(pedido("consultar")).andExpect(status().isInternalServerError()); + eventos = captura.eventos(); + } + assertThat(eventos).hasSize(1); + String linha = CapturaDeLog.emJson(eventos.getFirst()); + ContratoDeEvento.validarLog(linha); + assertThat(linha).contains("\"ERROR\"", "\"falha\"", JOB.toString(), USUARIO.toString()) + .doesNotContain("SELECT", "prompts"); + assertThat(MDC.get(CorrelationContext.JOB_ID_KEY)).isNull(); + assertThat(MDC.get(CorrelationContext.USER_ID_KEY)).isNull(); + } + + private static MockHttpServletRequestBuilder pedido(String rota) { + return rota.equals("listar") ? get("/auditoria/jobs") : get("/auditoria/jobs/{id}", JOB); + } + + private static void autenticar(List papeis) { + Jwt token = Jwt.withTokenValue("teste") + .header("alg", "RS256") + .subject("subject-a") + .claim("preferred_username", "login-a") + .claim("realm_access", Map.of("roles", papeis)) + .build(); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(token)); + } + +} diff --git a/api/src/test/java/synapse/api/auditoria/AuditoriaPersistenciaTests.java b/api/src/test/java/synapse/api/auditoria/AuditoriaPersistenciaTests.java new file mode 100644 index 0000000..261db6c --- /dev/null +++ b/api/src/test/java/synapse/api/auditoria/AuditoriaPersistenciaTests.java @@ -0,0 +1,728 @@ +package synapse.api.auditoria; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.StreamSupport; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import synapse.api.ApiApplication; +import synapse.api.job.CapturaDeLog; +import synapse.api.job.ContratoDeEvento; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * As duas rotas de auditoria pela cadeia real: token assinado, Postgres com as migrations + * e o usuário de banco da api. O job do registro é semeado direto nas tabelas, como o + * codegen, o worker e a api o deixariam. + */ +@EnabledIf("dockerIsAvailable") +class AuditoriaPersistenciaTests { + + private static final UUID A = UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + + private static final UUID B = UUID.fromString("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + + private static final UUID AUDITOR = UUID.fromString("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); + + private static final String ISSUER = "https://emissor-de-teste.invalid/realms/synapse"; + + private static final Instant T0 = Instant.parse("2026-01-01T10:00:00Z"); + + private static final String NUCLEO = """ + {"vigencia":{"inicio":"2025-11","fim":"2025-11"},"loja":["13"],"marca":["10"],"cargo":["100"]}"""; + + private static final String NUCLEO_CORRIGIDO = """ + {"vigencia":{"inicio":"2025-11","fim":"2025-11"},"loja":["13"],"marca":["10"],"cargo":["100"], + "percentual":0.025}"""; + + private static final String NUCLEO_SUGERIDO = """ + {"vigencia":{"inicio":"2025-11","fim":"2025-11"},"loja":["13"],"marca":["10"],"cargo":["100"], + "percentual":0.02}"""; + + private static final String MODELO = """ + {"provedor":"openai","modelo":"gpt-4.1","versao":"2025-04-14","parametros":{"temperature":0}}"""; + + private static final String TOTAIS = """ + {"baseline":529520.10,"simulado":541900.0,"diferenca_abs":12379.90,"diferenca_pct":0.023400, + "orcamento":600000.0,"vendas_historicas":23583194.87}"""; + + private static final String ASSERCOES = """ + [{"nome":"sem_comissao_negativa","resultado":"ok","detalhe":null}]"""; + + private static final String DECOMPOSICAO = """ + {"elemento":{"nucleo.percentual":12380},"loja":{"13":12380},"marca":{"10":12380},"cargo":{"100":12380}, + "competencia":{"2025-11":12380}}"""; + + private static final String LINHAS_SECRETAS = """ + {"2025-11":{"MATRIC-SECRETA":{"cod_loja":"13","cod_marca":"10","cod_cargo":"100","baseline":10, + "simulado":12,"diferenca":2}}}"""; + + private static final String TEXTO_INICIAL = " Comissão de 2%\n\tna loja 13 para o cargo 100 da marca 10 em novembro de 2025 "; + + private static final String PROMPT_SECRETO = "PROMPT-CONFIDENCIAL-DA-EXTRACAO"; + + private static final String CODIGO_SECRETO = "def aplicar_regra(): return 'CODIGO-CONFIDENCIAL'"; + + private static final String TRANSCRICAO_SECRETA = "O percentual é 2,5% TRANSCRICAO-CONFIDENCIAL"; + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + private static final JsonMapper JSON = new JsonMapper(); + + private static PostgreSQLContainer postgres; + + private static ConfigurableApplicationContext contexto; + + private static HttpServer jwks; + + private static RSAKey chave; + + private static JdbcTemplate dono; + + private static String base; + + static boolean dockerIsAvailable() { + return DockerClientFactory.instance().isDockerAvailable(); + } + + @BeforeAll + static void iniciar() throws Exception { + chave = new RSAKeyGenerator(2048).keyID("teste").generate(); + jwks = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + jwks.createContext("/jwks", exchange -> { + byte[] corpo = new JWKSet(chave.toPublicJWK()).toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, corpo.length); + try (var saida = exchange.getResponseBody()) { + saida.write(corpo); + } + }); + jwks.start(); + postgres = new PostgreSQLContainer("postgres:18-alpine"); + postgres.start(); + contexto = new SpringApplicationBuilder(ApiApplication.class).web(WebApplicationType.SERVLET) + .run("--server.port=0", "--app.keycloak.enabled=true", "--app.keycloak.issuer-uri=" + ISSUER, + "--app.keycloak.jwk-set-uri=http://127.0.0.1:" + jwks.getAddress().getPort() + "/jwks", + "--app.postgres.host=" + postgres.getHost(), "--app.postgres.port=" + postgres.getMappedPort(5432), + "--app.postgres.database=" + postgres.getDatabaseName(), + "--app.postgres.owner.user=" + postgres.getUsername(), + "--app.postgres.owner.password=" + postgres.getPassword(), "--app.postgres.user=synapse_api", + "--app.postgres.password=senha-de-teste", "--spring.rabbitmq.dynamic=false", + "--spring.rabbitmq.listener.simple.auto-startup=false", "--management.health.rabbit.enabled=false", + "--management.health.db.enabled=false", "--app.outbox.enabled=false", + "--app.transcription.processor.enabled=false"); + dono = new JdbcTemplate( + new DriverManagerDataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())); + dono.execute("ALTER ROLE synapse_api WITH PASSWORD 'senha-de-teste'"); + base = "http://localhost:" + contexto.getEnvironment().getProperty("local.server.port"); + } + + @AfterAll + static void encerrar() { + if (contexto != null) { + contexto.close(); + } + if (postgres != null) { + postgres.stop(); + } + if (jwks != null) { + jwks.stop(0); + } + } + + @BeforeEach + void prepararUsuarios() { + dono.execute("TRUNCATE usuarios CASCADE"); + dono.update(""" + INSERT INTO usuarios (id, login, nome, papel, keycloak_sub, criado_em) + VALUES (?, 'a', 'Profissional A', 'profissional_rh', 'subject-a', now()), + (?, 'b', 'Profissional B', 'profissional_rh', 'subject-b', now()), + (?, 'auditoria', 'Auditor', 'auditor', 'subject-auditor', now()) + """, A, B, AUDITOR); + } + + @Test + void auditorListaJobsDeTodosOsDonosComONomeDaListagemDoRh() throws Exception { + UUID deA = jobDeTexto(A, TEXTO_INICIAL, T0); + UUID deB = jobDeFormulario(B, " Regra nomeada por B ", T0.plusSeconds(60)); + UUID reprocessado = jobReprocessado(A, deA, T0.plusSeconds(120)); + + HttpResponse resposta = chamar("/auditoria/jobs", auditor()); + assertThat(resposta.statusCode()).isEqualTo(200); + ContratoDeEvento.validarRespostaHttp("PaginaJobsAuditoria", resposta.body()); + JsonNode pagina = JSON.readTree(resposta.body()); + assertThat(pagina.path("total").asLong()).isEqualTo(3); + assertThat(pagina.path("pagina").asInt()).isZero(); + assertThat(pagina.path("tamanho").asInt()).isEqualTo(20); + assertThat(ids(pagina.path("itens"))).containsExactly(reprocessado, deB, deA); + JsonNode itemDeB = item(pagina, deB); + assertThat(itemDeB.path("usuario").path("login").asString()).isEqualTo("b"); + assertThat(itemDeB.path("usuario").path("nome").asString()).isEqualTo("Profissional B"); + assertThat(itemDeB.path("origem").asString()).isEqualTo("formulario"); + assertThat(item(pagina, reprocessado).path("origem").asString()).isEqualTo("reprocessamento"); + assertThat(item(pagina, reprocessado).path("job_origem_id").asString()).isEqualTo(deA.toString()); + assertThat(item(pagina, deA).path("usuario").path("login").asString()).isEqualTo("a"); + + JsonNode historicoDeA = JSON.readTree(chamar("/jobs", rh("subject-a")).body()); + JsonNode historicoDeB = JSON.readTree(chamar("/jobs", rh("subject-b")).body()); + assertThat(item(pagina, deA).path("nome").asString()).isEqualTo(item(historicoDeA, deA).path("nome").asString()) + .isEqualTo("Comissão de 2% na loja 13 para o cargo 100 da marc"); + assertThat(item(pagina, reprocessado).path("nome").asString()) + .isEqualTo(item(historicoDeA, reprocessado).path("nome").asString()); + assertThat(itemDeB.path("nome").asString()).isEqualTo(item(historicoDeB, deB).path("nome").asString()); + + JsonNode segunda = JSON.readTree(chamar("/auditoria/jobs?pagina=1&tamanho=2", auditor()).body()); + assertThat(ids(segunda.path("itens"))).containsExactly(deA); + assertThat(segunda.path("total").asLong()).isEqualTo(3); + } + + @ParameterizedTest + @ValueSource(strings = { "pagina=-1", "pagina=um", "tamanho=0", "tamanho=101", "tamanho=vinte" }) + void paginacaoInvalidaRespondeComoAListagemDeJobs(String parametro) throws Exception { + HttpResponse doHistorico = chamar("/jobs?" + parametro, rh("subject-a")); + HttpResponse daAuditoria = chamar("/auditoria/jobs?" + parametro, auditor()); + assertThat(daAuditoria.statusCode()).isEqualTo(doHistorico.statusCode()).isEqualTo(400); + assertThat(JSON.readTree(daAuditoria.body())).isEqualTo(JSON.readTree(doHistorico.body())); + } + + @Test + void somenteOAuditorConsultaESemSessaoRecebe401() throws Exception { + UUID job = jobDeTexto(A, TEXTO_INICIAL, T0); + for (String rota : List.of("/auditoria/jobs", "/auditoria/jobs/" + job)) { + HttpResponse doDono = chamar(rota, rh("subject-a")); + assertThat(doDono.statusCode()).isEqualTo(403); + assertThat(JSON.readTree(doDono.body()).path("codigo").asString()).isEqualTo("sem_permissao"); + assertThat(chamar(rota, rh("subject-b")).statusCode()).isEqualTo(403); + assertThat(chamar(rota, "").statusCode()).isEqualTo(401); + assertThat(chamar(rota, auditor()).statusCode()).isEqualTo(200); + } + HttpResponse inexistente = chamar("/auditoria/jobs/" + UUID.randomUUID(), auditor()); + assertThat(inexistente.statusCode()).isEqualTo(404); + assertThat(JSON.readTree(inexistente.body()).path("codigo").asString()).isEqualTo("job_nao_encontrado"); + HttpResponse malformado = chamar("/auditoria/jobs/nao-e-uuid", auditor()); + assertThat(malformado.statusCode()).isEqualTo(400); + assertThat(JSON.readTree(malformado.body()).path("codigo").asString()).isEqualTo("requisicao_invalida"); + } + + @Test + void registroLigaEtapasAResultadosSimulacoesESubmissoes() throws Exception { + JobCompleto j = jobCompleto(); + String antes = snapshotNegocio(); + + HttpResponse resposta = chamar("/auditoria/jobs/" + j.id(), auditor()); + + assertThat(resposta.statusCode()).isEqualTo(200); + ContratoDeEvento.validarRespostaHttp("RegistroAuditoria", resposta.body()); + assertThat(snapshotNegocio()).isEqualTo(antes); + JsonNode registro = JSON.readTree(resposta.body()); + + JsonNode job = registro.path("job"); + assertThat(job.path("id").asString()).isEqualTo(j.id().toString()); + assertThat(job.path("nome").asString()).isEqualTo("Comissão de 2% na loja 13 para o cargo 100 da marc"); + assertThat(job.path("usuario").path("login").asString()).isEqualTo("a"); + assertThat(job.path("origem").asString()).isEqualTo("texto"); + assertThat(job.path("iniciado_em").asString()).isEqualTo("2026-01-01T10:01:00Z"); + + JsonNode etapas = registro.path("etapas"); + assertThat(ids(etapas)).containsExactlyElementsOf(j.etapas()); + assertEtapa(etapas, j.extracaoInicial(), null, null, j.entradaInicial()); + assertEtapa(etapas, j.reextracao(), null, null, j.correcaoAceita()); + assertEtapa(etapas, j.geracao(), null, j.simulacaoDoPrimeiroCodigo(), null); + assertEtapa(etapas, j.correcaoDoCodigo(), j.resultadoComErro(), j.simulacaoDoCodigoCorrigido(), null); + assertEtapa(etapas, j.decisaoInviavel(), j.resultadoInviavel(), j.simulacaoDoCodigoCorrigido(), null); + assertEtapa(etapas, j.sugestao(), j.resultadoInviavel(), j.simulacaoDoCodigoCorrigido(), null); + assertEtapa(etapas, j.geracaoDerivada(), j.resultadoInviavel(), j.simulacaoDaSugestao(), null); + assertEtapa(etapas, j.decisaoViavel(), j.resultadoViavel(), j.simulacaoDaSugestao(), null); + assertEtapa(etapas, j.fimDaBusca(), j.candidataVencedora(), j.simulacaoDaSugestao(), null); + assertThat(etapa(etapas, j.explicacao()).path("explicacao_id").asString()) + .isEqualTo(j.explicacaoId().toString()); + + assertThat(ids(registro.path("resultados"))).containsExactly(j.resultadoComErro(), j.resultadoInviavel(), + j.resultadoViavel(), j.candidataVencedora(), j.candidataInviavel()); + assertThat(StreamSupport.stream(registro.path("resultados").spliterator(), false) + .filter(r -> r.path("proposito").asString().equals("busca_meta")) + .map(r -> UUID.fromString(r.path("id").asString()))) + .containsExactly(j.candidataVencedora(), j.candidataInviavel()); + assertThat(ids(registro.path("simulacoes"))).containsExactly(j.simulacaoDoPrimeiroCodigo(), + j.simulacaoDoCodigoCorrigido(), j.simulacaoDaSugestao()); + + JsonNode entradas = registro.path("entradas"); + assertThat(ids(entradas)).containsExactly(j.entradaInicial(), j.correcaoRecusada(), j.correcaoAceita()); + assertThat(entradas.path(0).path("finalidade").asString()).isEqualTo("entrada_inicial"); + assertThat(entradas.path(0).path("texto").asString()).isEqualTo(TEXTO_INICIAL); + assertThat(entradas.path(2).path("finalidade").asString()).isEqualTo("correcao"); + assertThat(entradas.path(2).path("tipo").asString()).isEqualTo("voz"); + assertThat(entradas.path(2).path("formato").asString()).isEqualTo("ogg"); + assertThat(entradas.path(2).has("transcrito_em")).isTrue(); + + JsonNode rodadas = registro.path("rodadas"); + assertThat(rodadas.path(0).has("regra_resultante_id")).isTrue(); + assertThat(rodadas.path(0).path("regra_resultante_id").isNull()).isTrue(); + assertThat(rodadas.path(0).path("rodada_anterior_id").isNull()).isTrue(); + assertThat(rodadas.path(1).path("correcao").path("tipo").asString()).isEqualTo("voz"); + assertThat(rodadas.path(1).path("regra_resultante_id").asString()).isEqualTo(j.versaoCorrigida().toString()); + + assertThat(ids(registro.path("versoes"))).containsExactly(j.versaoExtraida(), j.versaoCorrigida(), + j.versaoSugerida()); + assertThat(registro.path("versoes").path(1).path("representacao").path("extensao_da_raiz").asString()) + .isEqualTo("preservada"); + assertThat(registro.path("versoes").path(2).path("tentativa").asInt()).isEqualTo(1); + assertThat(registro.path("chamadas_modelo").size()).isEqualTo(5); + assertThat(registro.path("chamadas_modelo").path(0).path("resposta").path("consumo_tokens").has("tokens_in")) + .isTrue(); + assertThat(registro.path("codigos").size()).isEqualTo(3); + assertThat(registro.path("transicoes").size()).isEqualTo(3); + assertThat(registro.path("acoes").path(0).path("acao").asString()).isEqualTo("aceitar_meta"); + JsonNode sugestao = registro.path("sugestoes_meta").path(0); + assertThat(sugestao.path("resultado_id").asString()).isEqualTo(j.candidataVencedora().toString()); + assertThat(sugestao.path("resultado_anterior_id").asString()).isEqualTo(j.resultadoViavel().toString()); + assertThat(registro.path("explicacoes").path(0).path("aderencia_conferida").asBoolean()).isFalse(); + + assertThat(resposta.body()).doesNotContain("\"binario\"", "\"linhas\"", "MATRIC-SECRETA") + .contains("0.023400", "529520.10", "28000000.00"); + } + + @Test + void registroDeJobDeFormularioSemArtefatosTemListasVazias() throws Exception { + UUID job = jobDeFormulario(B, null, T0); + + HttpResponse resposta = chamar("/auditoria/jobs/" + job, auditor()); + + assertThat(resposta.statusCode()).isEqualTo(200); + ContratoDeEvento.validarRespostaHttp("RegistroAuditoria", resposta.body()); + JsonNode registro = JSON.readTree(resposta.body()); + assertThat(registro.path("job").has("nome")).isFalse(); + assertThat(registro.path("job").has("iniciado_em")).isFalse(); + assertThat(registro.path("entradas").path(0).path("tipo").asString()).isEqualTo("formulario"); + assertThat(registro.path("entradas").path(0).path("conteudo").path("nucleo").path("loja").path(0).asString()) + .isEqualTo("13"); + assertThat(registro.path("entradas").path(0).has("texto")).isFalse(); + for (String secao : List.of("etapas", "chamadas_modelo", "codigos", "resultados", "simulacoes", "acoes", + "rodadas", "sugestoes_meta", "explicacoes")) { + assertThat(registro.path(secao).isArray()).as(secao).isTrue(); + assertThat(registro.path(secao).isEmpty()).as(secao).isTrue(); + } + } + + @Test + void logsLevamUsuarioEJobSemConteudo() throws Exception { + JobCompleto j = jobCompleto(); + List eventos; + try (CapturaDeLog captura = new CapturaDeLog(AuditoriaController.class)) { + assertThat(chamar("/auditoria/jobs/" + j.id(), auditor()).statusCode()).isEqualTo(200); + assertThat(chamar("/auditoria/jobs?pagina=0&tamanho=5", auditor()).statusCode()).isEqualTo(200); + assertThat(chamar("/auditoria/jobs/" + UUID.randomUUID(), auditor()).statusCode()).isEqualTo(404); + eventos = captura.eventos(); + } + assertThat(eventos).hasSize(3); + List linhas = new ArrayList<>(); + for (ILoggingEvent evento : eventos) { + String linha = CapturaDeLog.emJson(evento); + ContratoDeEvento.validarLog(linha); + assertThat(linha).doesNotContain(PROMPT_SECRETO, CODIGO_SECRETO, TRANSCRICAO_SECRETA, "Comissão de 2%", + "Correção recebida", "subject-auditor", "MATRIC-SECRETA"); + linhas.add(JSON.readTree(linha)); + } + JsonNode consulta = linhas.get(0); + assertThat(consulta.path("level").asString()).isEqualTo("INFO"); + assertThat(consulta.path("message").asString()).isEqualTo("consulta de auditoria encerrada"); + assertThat(consulta.path("job_id").asString()).isEqualTo(j.id().toString()); + assertThat(consulta.path("user_id").asString()).isEqualTo(AUDITOR.toString()); + assertThat(consulta.path("extra").path("resultado").asString()).isEqualTo("sucesso"); + JsonNode listagem = linhas.get(1); + assertThat(listagem.path("level").asString()).isEqualTo("INFO"); + assertThat(listagem.path("message").asString()).isEqualTo("listagem de auditoria encerrada"); + assertThat(listagem.has("job_id")).isFalse(); + assertThat(listagem.path("user_id").asString()).isEqualTo(AUDITOR.toString()); + assertThat(listagem.path("extra").path("pagina").asInt()).isZero(); + assertThat(listagem.path("extra").path("tamanho").asInt()).isEqualTo(5); + JsonNode inexistente = linhas.get(2); + assertThat(inexistente.path("level").asString()).isEqualTo("WARN"); + assertThat(inexistente.path("extra").path("resultado").asString()).isEqualTo("job_nao_encontrado"); + } + + @Test + void metricsExpoeAsRotasDeAuditoriaPeloTemplate() throws Exception { + UUID job = jobDeTexto(A, TEXTO_INICIAL, T0); + assertThat(chamar("/auditoria/jobs", auditor()).statusCode()).isEqualTo(200); + assertThat(chamar("/auditoria/jobs/" + job, auditor()).statusCode()).isEqualTo(200); + assertThat(chamar("/auditoria/jobs/" + job, rh("subject-a")).statusCode()).isEqualTo(403); + + String metricas = chamar("/metrics", "").body(); + + List contagens = metricas.lines() + .filter(linha -> linha.startsWith("http_server_requests_seconds_count{")) + .toList(); + assertThat(contagens).anyMatch(linha -> linha.contains("uri=\"/auditoria/jobs\"") + && linha.contains("status=\"200\"") && linha.contains("method=\"GET\"")); + assertThat(contagens) + .anyMatch(linha -> linha.contains("uri=\"/auditoria/jobs/{id}\"") && linha.contains("status=\"200\"")); + assertThat(contagens) + .anyMatch(linha -> linha.contains("uri=\"/auditoria/jobs/{id}\"") && linha.contains("status=\"403\"")); + assertThat(metricas).doesNotContain(job.toString()); + } + + private static void assertEtapa(JsonNode etapas, UUID etapaId, @Nullable UUID resultado, @Nullable UUID simulacao, + @Nullable UUID submissao) { + JsonNode etapa = etapa(etapas, etapaId); + assertCampo(etapa, "resultado_id", resultado); + assertCampo(etapa, "simulacao_id", simulacao); + assertCampo(etapa, "submissao_id", submissao); + } + + private static void assertCampo(JsonNode etapa, String campo, @Nullable UUID esperado) { + if (esperado == null) { + assertThat(etapa.has(campo)).as("%s ausente em %s", campo, etapa).isFalse(); + } + else { + assertThat(etapa.path(campo).asString()).as("%s em %s", campo, etapa).isEqualTo(esperado.toString()); + } + } + + private static JsonNode etapa(JsonNode etapas, UUID id) { + return StreamSupport.stream(etapas.spliterator(), false) + .filter(e -> e.path("id").asString().equals(id.toString())) + .findFirst() + .orElseThrow(); + } + + private static JsonNode item(JsonNode pagina, UUID id) { + return etapa(pagina.path("itens"), id); + } + + private static List ids(JsonNode lista) { + return StreamSupport.stream(lista.spliterator(), false) + .map(item -> UUID.fromString(item.path("id").asString())) + .toList(); + } + + private static UUID jobDeTexto(UUID usuario, String texto, Instant criadoEm) { + UUID submissao = inserir(""" + INSERT INTO submissoes (usuario_id, tipo, transcricao, criado_em) VALUES (?, 'texto', ?, ?) RETURNING id + """, usuario, texto, ts(criadoEm)); + return inserir(""" + INSERT INTO jobs (status, usuario_id, submissao_id, competencias, orcamento, meta_venda, criado_em, + iniciado_em) + VALUES ('aguardando_decisao_usuario', ?, ?, '{2025-11}', 600000, 28000000.00, ?, ?) RETURNING id + """, usuario, submissao, ts(criadoEm), ts(criadoEm.plusSeconds(60))); + } + + private static UUID jobDeFormulario(UUID usuario, @Nullable String nome, Instant criadoEm) { + UUID submissao = inserir(""" + INSERT INTO submissoes (usuario_id, tipo, conteudo, criado_em) + VALUES (?, 'formulario', ?::jsonb, ?) RETURNING id + """, usuario, + "{\"nucleo\":" + NUCLEO.replace("]}", "],\"percentual\":0.02}") + ",\"texto_livre\":null}", + ts(criadoEm)); + return inserir(""" + INSERT INTO jobs (status, usuario_id, submissao_id, competencias, orcamento, nome, criado_em) + VALUES ('gerando_regra', ?, ?, '{2025-11}', 485000, ?, ?) RETURNING id + """, usuario, submissao, nome == null ? null : nome.strip(), ts(criadoEm)); + } + + private static UUID jobReprocessado(UUID usuario, UUID origem, Instant criadoEm) { + return inserir(""" + INSERT INTO jobs (status, usuario_id, job_origem_id, competencias, orcamento, criado_em) + VALUES ('aguardando_confirmacao_parametros', ?, ?, '{2025-11}', 600000, ?) RETURNING id + """, usuario, origem, ts(criadoEm)); + } + + /** + * Um job de texto que passou por todos os ramos do registro: extração, rodada cuja + * reextração falhou, rodada corrigida por voz, correção do código, sugestão de + * adaptação, busca da meta com duas candidatas, meta aceita e explicação reprovada. + */ + private static JobCompleto jobCompleto() { + List etapas = new ArrayList<>(); + UUID job = jobDeTexto(A, TEXTO_INICIAL, T0); + UUID entradaInicial = Objects + .requireNonNull(dono.queryForObject("SELECT submissao_id FROM jobs WHERE id = ?", UUID.class, job)); + UUID promptExtracao = prompt(job, "extracao_parametros", PROMPT_SECRETO, 1); + UUID respostaExtracao = resposta(job, promptExtracao, 2); + dono.update(""" + INSERT INTO extracoes_regras (id, job_id, submissao_id, resposta_id, representacao, rebaixamentos, + parametros, criado_em) + VALUES (?, ?, ?, ?, ?::jsonb, '[]'::jsonb, '{}'::jsonb, ?) + """, UUID.randomUUID(), job, entradaInicial, respostaExtracao, + "{\"nucleo\":" + NUCLEO + ",\"especificacoes\":[]}", ts(minuto(2))); + UUID extracaoInicial = trilha(etapas, job, "extracao_parametros", 2, null, promptExtracao, null, null, null, + null, "{\"resumo\":\"Regra extraída.\"}"); + UUID versaoExtraida = inserir(""" + INSERT INTO regras (job_id, versao, origem, nucleo, especificacoes, parametros, hash, criada_em) + VALUES (?, 1, 'extracao', ?::jsonb, '[]'::jsonb, '{"orcamento":600000}'::jsonb, ?, ?) RETURNING id + """, job, NUCLEO, "1".repeat(64), ts(minuto(3))); + trilha(etapas, job, "validacao_dominio", 3, versaoExtraida, null, null, null, null, null, + "{\"resumo\":\"Percentual ausente.\"}"); + UUID correcaoRecusada = inserir(""" + INSERT INTO submissoes (usuario_id, tipo, transcricao, criado_em) VALUES (?, 'texto', 'Corrija', ?) + RETURNING id + """, A, ts(minuto(4))); + UUID rodadaRecusada = inserir(""" + INSERT INTO rodadas_correcao (job_id, regra_analisada_id, conflitos, estado, submissao_correcao_id, + criada_em, atualizada_em) + VALUES (?, ?, '[{"elementos":["nucleo.percentual"],"motivo":"percentual ausente"}]'::jsonb, + 'reextracao_falhou', ?, ?, ?) RETURNING id + """, job, versaoExtraida, correcaoRecusada, ts(minuto(3)), ts(minuto(5))); + UUID correcaoAceita = inserir(""" + INSERT INTO submissoes (usuario_id, tipo, binario, formato, transcricao, transcrito_em, criado_em) + VALUES (?, 'voz', ?, 'ogg', ?, ?, ?) RETURNING id + """, A, "AUDIO-BINARIO".getBytes(StandardCharsets.UTF_8), TRANSCRICAO_SECRETA, ts(minuto(6)), + ts(minuto(5))); + UUID promptReextracao = prompt(job, "extracao_parametros", "Reextraia a regra corrigida.", 6); + resposta(job, promptReextracao, 7); + UUID reextracao = trilha(etapas, job, "extracao_parametros", 7, versaoExtraida, promptReextracao, null, null, + null, null, "{\"resumo\":\"Correção recebida e reextraída.\"}"); + UUID versaoCorrigida = inserir(""" + INSERT INTO regras (job_id, versao, origem, regra_origem_id, representacao, nucleo, especificacoes, + parametros, hash, criada_em) + VALUES (?, 2, 'correcao', ?, ?::jsonb, ?::jsonb, '[]'::jsonb, '{}'::jsonb, ?, ?) RETURNING id + """, job, versaoExtraida, + "{\"nucleo\":" + NUCLEO_CORRIGIDO + ",\"especificacoes\":[],\"extensao_da_raiz\":\"preservada\"}", + NUCLEO_CORRIGIDO, "2".repeat(64), ts(minuto(7))); + dono.update(""" + INSERT INTO rodadas_correcao (job_id, regra_analisada_id, conflitos, estado, submissao_correcao_id, + regra_resultante_id, rodada_anterior_id, criada_em, atualizada_em) + VALUES (?, ?, '[{"elementos":["nucleo.percentual"],"motivo":"percentual ausente"}]'::jsonb, + 'corrigida', ?, ?, ?, ?, ?) + """, job, versaoExtraida, correcaoAceita, versaoCorrigida, rodadaRecusada, ts(minuto(5)), + ts(minuto(7))); + + UUID promptCodigo = prompt(job, "geracao_codigo", "Gere o código.", 8); + resposta(job, promptCodigo, 8); + UUID primeiroCodigo = codigo(job, versaoCorrigida, promptCodigo, CODIGO_SECRETO, 8); + UUID simulacaoDoPrimeiroCodigo = simulacao(job, versaoCorrigida, primeiroCodigo, 8); + UUID geracao = trilha(etapas, job, "geracao_codigo", 8, versaoCorrigida, promptCodigo, primeiroCodigo, + simulacaoDoPrimeiroCodigo, null, null, "{\"resumo\":\"Código gerado.\",\"modo_geracao\":\"gerado\"}"); + UUID resultadoComErro = inserir( + """ + INSERT INTO resultados_simulacao (job_id, codigo_gerado_id, status, assercoes, diagnostico, criado_em) + VALUES (?, ?, 'erro_codigo', '[]'::jsonb, '{"causa":"excecao","falha":{"tipo":"KeyError","mensagem":"vlr","traceback":"linha 3"}}'::jsonb, ?) RETURNING id + """, + job, primeiroCodigo, ts(minuto(9))); + vincular(simulacaoDoPrimeiroCodigo, resultadoComErro); + + UUID promptCorrecao = prompt(job, "geracao_codigo", "Corrija o código.", 10); + resposta(job, promptCorrecao, 10); + UUID codigoCorrigido = codigo(job, versaoCorrigida, promptCorrecao, "def aplicar_regra(): pass", 10); + UUID simulacaoDoCodigoCorrigido = simulacao(job, versaoCorrigida, codigoCorrigido, 10); + UUID correcaoDoCodigo = trilha(etapas, job, "geracao_codigo", 10, versaoCorrigida, promptCorrecao, + codigoCorrigido, simulacaoDoCodigoCorrigido, resultadoComErro, null, + "{\"resumo\":\"Código corrigido.\",\"modo_geracao\":\"correcao\"}"); + UUID resultadoInviavel = resultado(job, codigoCorrigido, "simulacao", "inviavel", "28000000.00", 11); + vincular(simulacaoDoCodigoCorrigido, resultadoInviavel); + UUID decisaoInviavel = trilha(etapas, job, "decisao", 12, versaoCorrigida, null, null, null, resultadoInviavel, + null, "{\"resumo\":\"Fora do orçamento.\"}"); + UUID sugestao = trilha(etapas, job, "sugestao_adaptacao", 13, versaoCorrigida, null, null, null, + resultadoInviavel, null, "{\"resumo\":\"Taxa menor proposta.\",\"motivo_sugestao\":\"proposta\"}"); + + UUID versaoSugerida = inserir(""" + INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, tentativa, + criada_em) + VALUES (?, 3, 'sugestao_adaptacao', ?, ?::jsonb, '[]'::jsonb, ?, 1, ?) RETURNING id + """, job, versaoCorrigida, NUCLEO_SUGERIDO, "3".repeat(64), ts(minuto(13))); + UUID promptDerivado = prompt(job, "geracao_codigo", "Derive o código.", 14); + resposta(job, promptDerivado, 14); + UUID codigoDerivado = codigo(job, versaoSugerida, promptDerivado, "def aplicar_regra(): return 1", 14); + UUID simulacaoDaSugestao = simulacao(job, versaoSugerida, codigoDerivado, 14); + UUID geracaoDerivada = trilha(etapas, job, "geracao_codigo", 14, versaoSugerida, promptDerivado, codigoDerivado, + simulacaoDaSugestao, resultadoInviavel, null, + "{\"resumo\":\"Código derivado.\",\"modo_geracao\":\"derivado\"}"); + UUID resultadoViavel = resultado(job, codigoDerivado, "simulacao", "viavel", "28000000.00", 15); + vincular(simulacaoDaSugestao, resultadoViavel); + UUID decisaoViavel = trilha(etapas, job, "decisao", 16, versaoSugerida, null, null, null, resultadoViavel, null, + "{\"resumo\":\"Dentro do orçamento.\",\"encaminhamento\":\"busca_meta\"}"); + UUID candidataVencedora = resultado(job, codigoDerivado, "busca_meta", "viavel", "28500000", 17); + UUID candidataInviavel = resultado(job, codigoDerivado, "busca_meta", "inviavel", "29000000", 18); + UUID fimDaBusca = trilha(etapas, job, "decisao", 19, versaoSugerida, null, null, null, candidataVencedora, null, + "{\"resumo\":\"Meta maior encontrada.\",\"desfecho_busca\":\"encontrada\",\"candidatas_simuladas\":2}"); + dono.update(""" + INSERT INTO sugestoes_meta_venda (job_id, regra_id, resultado_id, resultado_anterior_id, criada_em, + aceita_em) + VALUES (?, ?, ?, ?, ?, ?) + """, job, versaoSugerida, candidataVencedora, resultadoViavel, ts(minuto(19)), ts(minuto(21))); + vincular(simulacaoDaSugestao, candidataVencedora); + dono.update("INSERT INTO job_acoes (job_id, acao, executado_em) VALUES (?, 'aceitar_meta', ?)", job, + ts(minuto(21))); + + UUID explicacaoId = inserir(""" + INSERT INTO explicacoes (resultado_id, texto, aderencia_conferida, criado_em) + VALUES (?, 'A comissão de R$ 1 cabe no orçamento.', false, ?) RETURNING id + """, resultadoViavel, ts(minuto(20))); + UUID explicacao = trilha(etapas, job, "explicacao", 20, versaoSugerida, null, null, null, resultadoViavel, + explicacaoId, "{\"resumo\":\"Narrativa reprovada.\",\"desfecho_explicacao\":\"reprovada\"}"); + + dono.update(""" + INSERT INTO job_transicoes (job_id, status_anterior, status_novo, ocorrido_em, ator, motivo) + VALUES (?, NULL, 'gerando_regra', ?, 'usuario', NULL), + (?, 'gerando_regra', 'simulando', ?, 'evento', NULL), + (?, 'simulando', 'aguardando_decisao_usuario', ?, 'evento', NULL) + """, job, ts(T0), job, ts(minuto(8)), job, ts(minuto(12))); + return new JobCompleto(job, entradaInicial, correcaoRecusada, correcaoAceita, versaoExtraida, versaoCorrigida, + versaoSugerida, List.copyOf(etapas), extracaoInicial, reextracao, geracao, correcaoDoCodigo, + decisaoInviavel, sugestao, geracaoDerivada, decisaoViavel, fimDaBusca, explicacao, explicacaoId, + simulacaoDoPrimeiroCodigo, simulacaoDoCodigoCorrigido, simulacaoDaSugestao, resultadoComErro, + resultadoInviavel, resultadoViavel, candidataVencedora, candidataInviavel); + } + + private static void vincular(UUID simulacao, UUID resultado) { + dono.update("UPDATE simulacoes SET resultado_id = ? WHERE id = ?", resultado, simulacao); + } + + private static UUID prompt(UUID job, String no, String conteudo, int minuto) { + return inserir(""" + INSERT INTO prompts (job_id, no, conteudo, modelo, criado_em) VALUES (?, ?, ?, ?::jsonb, ?) RETURNING id + """, job, no, conteudo, MODELO, ts(minuto(minuto))); + } + + private static UUID resposta(UUID job, UUID prompt, int minuto) { + return inserir(""" + INSERT INTO respostas_modelo (job_id, prompt_id, conteudo, consumo_tokens, criado_em) + VALUES (?, ?, 'resposta do modelo', '{"tokens_in":3120,"tokens_out":480}'::jsonb, ?) RETURNING id + """, job, prompt, ts(minuto(minuto))); + } + + private static UUID codigo(UUID job, UUID regra, UUID prompt, String fonte, int minuto) { + return inserir(""" + INSERT INTO codigos_gerados (job_id, regra_id, linguagem, fonte, prompt_id, criado_em) + VALUES (?, ?, 'python', ?, ?, ?) RETURNING id + """, job, regra, fonte, prompt, ts(minuto(minuto))); + } + + private static UUID simulacao(UUID job, UUID regra, UUID codigo, int minuto) { + return inserir(""" + INSERT INTO simulacoes (job_id, regra_id, codigo_gerado_id, criado_em) VALUES (?, ?, ?, ?) RETURNING id + """, job, regra, codigo, ts(minuto(minuto))); + } + + private static UUID resultado(UUID job, UUID codigo, String proposito, String veredito, String meta, int minuto) { + return inserir(""" + INSERT INTO resultados_simulacao (job_id, codigo_gerado_id, status, proposito, veredito, meta_venda, + totais, assercoes, decomposicao, linhas, criado_em) + VALUES (?, ?, 'sucesso', ?, ?, ?::numeric, ?::jsonb, ?::jsonb, ?::jsonb, ?::jsonb, ?) RETURNING id + """, job, codigo, proposito, veredito, meta, TOTAIS, ASSERCOES, DECOMPOSICAO, LINHAS_SECRETAS, + ts(minuto(minuto))); + } + + private static UUID trilha(List etapas, UUID job, String no, int minuto, @Nullable UUID regra, + @Nullable UUID prompt, @Nullable UUID codigo, @Nullable UUID simulacao, @Nullable UUID resultado, + @Nullable UUID explicacao, String conclusao) { + UUID id = inserir(""" + INSERT INTO trilhas_auditoria (evento_id, job_id, simulacao_id, no, concluido_em, regra_id, conclusao, + prompt_id, codigo_gerado_id, explicacao_id, resultado_id) + VALUES (?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?, ?, ?) RETURNING id + """, UUID.randomUUID(), job, simulacao, no, ts(minuto(minuto)), regra, conclusao, prompt, codigo, + explicacao, resultado); + etapas.add(id); + return id; + } + + private static UUID inserir(String sql, @Nullable Object... parametros) { + return Objects.requireNonNull(dono.queryForObject(sql, UUID.class, parametros)); + } + + private static Instant minuto(int minutos) { + return T0.plusSeconds(60L * minutos); + } + + private static Timestamp ts(Instant instante) { + return Timestamp.from(instante); + } + + private static String snapshotNegocio() { + return Objects.requireNonNull(dono.queryForObject(""" + SELECT jsonb_build_object( + 'jobs', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM jobs t), + 'submissoes', (SELECT jsonb_agg(to_jsonb(t) - 'binario' ORDER BY id) FROM submissoes t), + 'regras', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM regras t), + 'trilhas', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM trilhas_auditoria t), + 'transicoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM job_transicoes t), + 'acoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM job_acoes t), + 'rodadas', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM rodadas_correcao t), + 'simulacoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM simulacoes t), + 'sugestoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY regra_id) FROM sugestoes_meta_venda t), + 'outbox', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM outbox_events t) + )::text + """, String.class)); + } + + private static HttpResponse chamar(String caminho, String token) throws Exception { + HttpRequest.Builder pedido = HttpRequest.newBuilder(URI.create(base + caminho)).GET(); + if (!token.isEmpty()) { + pedido.header("Authorization", "Bearer " + token); + } + return HTTP.send(pedido.build(), HttpResponse.BodyHandlers.ofString()); + } + + private static String auditor() throws Exception { + return token("subject-auditor", "auditoria", List.of("default-roles-synapse", "auditor")); + } + + private static String rh(String sub) throws Exception { + return token(sub, sub.equals("subject-a") ? "a" : "b", List.of("profissional-rh")); + } + + private static String token(String sub, String login, List papeis) throws Exception { + JWTClaimsSet claims = new JWTClaimsSet.Builder().subject(sub) + .issuer(ISSUER) + .issueTime(Date.from(Instant.now().minusSeconds(60))) + .expirationTime(Date.from(Instant.now().plusSeconds(300))) + .claim("preferred_username", login) + .claim("realm_access", Map.of("roles", papeis)) + .build(); + SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("teste").build(), claims); + jwt.sign(new RSASSASigner(chave)); + return jwt.serialize(); + } + + /** Os ids do job semeado, para as asserções localizarem cada linha. */ + private record JobCompleto(UUID id, UUID entradaInicial, UUID correcaoRecusada, UUID correcaoAceita, + UUID versaoExtraida, UUID versaoCorrigida, UUID versaoSugerida, List etapas, UUID extracaoInicial, + UUID reextracao, UUID geracao, UUID correcaoDoCodigo, UUID decisaoInviavel, UUID sugestao, + UUID geracaoDerivada, UUID decisaoViavel, UUID fimDaBusca, UUID explicacao, UUID explicacaoId, + UUID simulacaoDoPrimeiroCodigo, UUID simulacaoDoCodigoCorrigido, UUID simulacaoDaSugestao, + UUID resultadoComErro, UUID resultadoInviavel, UUID resultadoViavel, UUID candidataVencedora, + UUID candidataInviavel) { + } + +} diff --git a/api/src/test/java/synapse/api/auditoria/AuditoriaSnapshotPersistenciaTests.java b/api/src/test/java/synapse/api/auditoria/AuditoriaSnapshotPersistenciaTests.java new file mode 100644 index 0000000..f09121a --- /dev/null +++ b/api/src/test/java/synapse/api/auditoria/AuditoriaSnapshotPersistenciaTests.java @@ -0,0 +1,182 @@ +package synapse.api.auditoria; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.Objects; +import java.util.UUID; + +import javax.sql.DataSource; + +import liquibase.Contexts; +import liquibase.Liquibase; +import liquibase.database.DatabaseFactory; +import liquibase.database.jvm.JdbcConnection; +import liquibase.resource.ClassLoaderResourceAccessor; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import synapse.api.job.NomesDeJobs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; + +/** + * Executa {@link AuditoriaService} como {@code synapse_api} num Postgres real e grava + * como dono do banco no meio da leitura: as consultas de uma resposta precisam correr + * numa transação somente leitura com um único instantâneo, e o que entra depois da + * primeira consulta não aparece nas seguintes. + */ +@EnabledIf("dockerIsAvailable") +class AuditoriaSnapshotPersistenciaTests { + + private static final UUID USUARIO = UUID.fromString("22222222-2222-4222-8222-222222222222"); + + private static PostgreSQLContainer postgres; + + private static AnnotationConfigApplicationContext contexto; + + private static JdbcTemplate jdbc; + + private static JdbcTemplate dono; + + private static AuditoriaService service; + + static boolean dockerIsAvailable() { + return DockerClientFactory.instance().isDockerAvailable(); + } + + @BeforeAll + static void preparar() throws Exception { + postgres = new PostgreSQLContainer("postgres:18-alpine"); + postgres.start(); + try (Connection conexao = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), + postgres.getPassword()); + Liquibase liquibase = new Liquibase("db/changelog/changelog.yaml", new ClassLoaderResourceAccessor(), + DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(conexao)))) { + liquibase.getChangeLogParameters().set("usuario_api", "synapse_api"); + liquibase.getChangeLogParameters().set("usuario_codegen", "synapse_codegen"); + liquibase.getChangeLogParameters().set("usuario_worker", "synapse_worker"); + liquibase.update(new Contexts()); + } + dono = new JdbcTemplate( + new DriverManagerDataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())); + dono.execute("ALTER ROLE synapse_api WITH PASSWORD 'senha-de-teste'"); + DataSource dataSource = new DriverManagerDataSource(postgres.getJdbcUrl(), "synapse_api", "senha-de-teste"); + jdbc = new JdbcTemplate(dataSource); + contexto = new AnnotationConfigApplicationContext(); + contexto.registerBean(DataSource.class, () -> dataSource); + contexto.registerBean(JdbcTemplate.class, () -> jdbc); + contexto.registerBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(dataSource)); + contexto.registerBean(AuditoriaRepository.class, () -> spy(new AuditoriaRepository(jdbc))); + contexto.registerBean(NomesDeJobs.class, () -> mock(NomesDeJobs.class)); + contexto.register(Config.class); + contexto.refresh(); + service = contexto.getBean(AuditoriaService.class); + } + + @AfterAll + static void encerrar() { + if (contexto != null) { + contexto.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + @BeforeEach + void prepararUsuario() { + dono.execute("TRUNCATE usuarios CASCADE"); + dono.update(""" + INSERT INTO usuarios (id, login, nome, papel, criado_em) + VALUES (?, 'rh', 'RH', 'profissional_rh', now()) + """, USUARIO); + } + + @TestConfiguration + @EnableTransactionManagement + @Import(AuditoriaService.class) + static class Config { + + } + + @Test + void contagemEPaginaDaListagemUsamOMesmoSnapshotMesmoComCriacaoConcorrente() { + UUID existente = criarJob(); + AuditoriaRepository repository = contexto.getBean(AuditoriaRepository.class); + reset(repository); + doAnswer(chamada -> { + assertTransacaoDeLeituraComSnapshot(); + Object total = chamada.callRealMethod(); + criarJob(); + return total; + }).when(repository).contarJobs(); + try { + PaginaJobsAuditoriaDto pagina = service.listar(new PaginaAuditoria(0, 20)); + + assertThat(pagina.total()).isEqualTo(1); + assertThat(pagina.itens()).extracting(JobAuditoriaDto::id).containsExactly(existente); + assertThat(dono.queryForObject("SELECT count(*) FROM jobs", Integer.class)).isEqualTo(2); + } + finally { + reset(repository); + } + } + + @Test + void secoesDoRegistroUsamOMesmoSnapshotMesmoComEscritaConcorrente() { + UUID job = criarJob(); + AuditoriaRepository repository = contexto.getBean(AuditoriaRepository.class); + reset(repository); + doAnswer(chamada -> { + assertTransacaoDeLeituraComSnapshot(); + Object entradas = chamada.callRealMethod(); + dono.update("INSERT INTO job_acoes (job_id, acao, executado_em) VALUES (?, 'salvar', now())", job); + return entradas; + }).when(repository).listarEntradas(job); + try { + RegistroAuditoriaDto registro = service.consultar(job); + + assertThat(registro.acoes()).isEmpty(); + assertThat(dono.queryForObject("SELECT count(*) FROM job_acoes WHERE job_id = ?", Integer.class, job)) + .isEqualTo(1); + } + finally { + reset(repository); + } + } + + private static void assertTransacaoDeLeituraComSnapshot() { + assertThat(jdbc.queryForObject("SHOW transaction_isolation", String.class)).isEqualTo("repeatable read"); + assertThat(jdbc.queryForObject("SHOW transaction_read_only", String.class)).isEqualTo("on"); + } + + private static UUID criarJob() { + UUID submissao = Objects.requireNonNull(dono.queryForObject(""" + INSERT INTO submissoes (usuario_id, tipo, transcricao, criado_em) + VALUES (?, 'texto', 'Comissão de 2% na loja 13', now()) RETURNING id + """, UUID.class, USUARIO)); + return Objects.requireNonNull(dono.queryForObject(""" + INSERT INTO jobs (status, usuario_id, submissao_id, competencias, orcamento, criado_em) + VALUES ('gerando_regra', ?, ?, ARRAY['2025-11'], 485000, now()) RETURNING id + """, UUID.class, USUARIO, submissao)); + } + +}