diff --git a/.github/workflows/cd-keycloak.yml b/.github/workflows/cd-keycloak.yml new file mode 100644 index 0000000..b6c87d2 --- /dev/null +++ b/.github/workflows/cd-keycloak.yml @@ -0,0 +1,38 @@ +name: cd-keycloak + +on: + push: + branches: + - staging + paths: + - 'deploy/keycloak/**' + - 'deploy/docker-compose.yml' + - '.github/workflows/cd-keycloak.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cd-staging-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: staging + steps: + - name: Implantar Keycloak em staging + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.STAGING_SSH_HOST }} + username: ${{ secrets.STAGING_SSH_USER }} + key: ${{ secrets.STAGING_SSH_KEY }} + script: | + set -e + cd ${{ secrets.STAGING_DEPLOY_PATH }} + git fetch origin staging + git reset --hard origin/staging + + docker compose -f deploy/docker-compose.yml --env-file deploy/.env pull keycloak + docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --no-deps --force-recreate --wait --wait-timeout 180 keycloak diff --git a/.gitignore b/.gitignore index a2e8e75..74907b3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ deploy/.env __pycache__/ .digital-ocean .claude +tasks/ +venv/ +.venv/ diff --git a/api/.agents/skills/sse/SKILL.md b/api/.agents/skills/sse/SKILL.md index 0879c9d..20ebe5b 100644 --- a/api/.agents/skills/sse/SKILL.md +++ b/api/.agents/skills/sse/SKILL.md @@ -60,9 +60,13 @@ emissor é removido). atrasa quem chama `emitir` (hoje, o consumidor RabbitMQ de T-044/T-045). Aceitável para o volume do MVP; se isso doer, a saída é emitir numa thread separada da que consome a fila, não sem essa fila. -- **Sem autenticação nesta rota ainda** (ver `AGENTS.md` da raiz, decisão de escopo): - `GET /jobs/{id}/events` não confere posse. Um filtro futuro (T-091) cobre isto; até lá - não implemente checagem aqui. +- **Autenticação do stream.** Com Keycloak habilitado, `GET /jobs/{id}/events` + exige JWT Bearer, como as demais rotas de jobs. O frontend usa `fetch` para enviar + o cabeçalho e renova o token antes de abrir ou reconectar o stream. +- **Autorização em tarefa própria.** O controller já chama `AutorizadorDeJob`; + preserve essa checagem ao alterar o transporte. A cobertura uniforme de posse, + papéis e respostas de acesso negado pertence à tarefa do middleware de + autorização, conforme o [ADR-006](../../../../docs/adrs/ADR-006.md). ## Referências diff --git a/api/pom.xml b/api/pom.xml index 1a506de..e821b3e 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -68,6 +68,11 @@ spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + @@ -147,14 +152,13 @@ test - com.networknt json-schema-validator ${json-schema-validator.version} - test diff --git a/api/src/main/java/synapse/api/core/config/AppProperties.java b/api/src/main/java/synapse/api/core/config/AppProperties.java index a42b246..88b92b0 100644 --- a/api/src/main/java/synapse/api/core/config/AppProperties.java +++ b/api/src/main/java/synapse/api/core/config/AppProperties.java @@ -28,12 +28,14 @@ * @param rabbitmq broker de mensageria * @param sse stream de acompanhamento do job * @param outbox publicação dos eventos gravados no outbox transacional + * @param keycloak emissor e chaves públicas dos JWTs usados pela API */ @ConfigurationProperties("app") @Validated public record AppProperties(@NotBlank String environment, @NotNull @Valid Service service, @NotNull @Valid Cors cors, @NotNull @Valid Observability observability, @NotNull @Valid Postgres postgres, - @NotNull @Valid Rabbitmq rabbitmq, @NotNull @Valid Sse sse, @NotNull @Valid Outbox outbox) { + @NotNull @Valid Rabbitmq rabbitmq, @NotNull @Valid Sse sse, @NotNull @Valid Outbox outbox, + @NotNull @Valid Keycloak keycloak) { /** * Configura a identidade pública do serviço. @@ -153,4 +155,12 @@ public record Sse(@NotNull Duration heartbeat, @NotNull Duration timeout) { public record Outbox(boolean enabled, @NotNull Duration pollInterval) { } + /** + * @param enabled liga a validação dos tokens do Keycloak + * @param issuerUri emissor OIDC público esperado nos tokens recebidos + * @param jwkSetUri endpoint que a API usa para buscar as chaves públicas + */ + public record Keycloak(boolean enabled, @NotBlank String issuerUri, @NotBlank String jwkSetUri) { + } + } diff --git a/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java b/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java new file mode 100644 index 0000000..d03ee14 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/AcessoDoUsuario.java @@ -0,0 +1,7 @@ +package synapse.api.core.security; + +import java.util.UUID; + +/** Identidade local associada à sessão autenticada. */ +public record AcessoDoUsuario(UUID usuarioId, boolean auditor) { +} diff --git a/api/src/main/java/synapse/api/core/security/SegurancaKeycloakConfig.java b/api/src/main/java/synapse/api/core/security/SegurancaKeycloakConfig.java new file mode 100644 index 0000000..eb68847 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/SegurancaKeycloakConfig.java @@ -0,0 +1,58 @@ +package synapse.api.core.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.SecurityFilterChain; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.http.HttpServletResponse; + +import synapse.api.core.config.AppProperties; + +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(name = "app.keycloak.enabled", havingValue = "true", matchIfMissing = true) +public class SegurancaKeycloakConfig { + + @Bean + SecurityFilterChain cadeiaDeFiltros(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .authorizeHttpRequests(autorizacao -> autorizacao.dispatcherTypeMatchers(DispatcherType.ERROR) + .permitAll() + .requestMatchers("/actuator/health", "/health", "/metrics", "/docs/**", "/redocly", "/openapi/**", + "/swagger-ui/**", "/v3/api-docs/**") + .permitAll() + .anyRequest() + .authenticated()) + .exceptionHandling(excecoes -> excecoes + .authenticationEntryPoint((requisicao, resposta, excecao) -> escreverErro(resposta, + HttpServletResponse.SC_UNAUTHORIZED, "nao_autenticado", "Sessão ausente ou expirada.")) + .accessDeniedHandler((requisicao, resposta, excecao) -> escreverErro(resposta, + HttpServletResponse.SC_FORBIDDEN, "sem_permissao", "Você não tem permissão para esta ação."))) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + .build(); + } + + @Bean + JwtDecoder decodificadorJwt(AppProperties properties) { + NimbusJwtDecoder decodificador = NimbusJwtDecoder.withJwkSetUri(properties.keycloak().jwkSetUri()).build(); + decodificador.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.keycloak().issuerUri())); + return decodificador; + } + + private static void escreverErro(HttpServletResponse resposta, int status, String codigo, String mensagem) + throws java.io.IOException { + resposta.setStatus(status); + resposta.setContentType("application/json"); + resposta.getWriter().write("{\"codigo\":\"%s\",\"mensagem\":\"%s\"}".formatted(codigo, mensagem)); + } + +} diff --git a/api/src/main/java/synapse/api/core/security/SegurancaSemKeycloakConfig.java b/api/src/main/java/synapse/api/core/security/SegurancaSemKeycloakConfig.java new file mode 100644 index 0000000..211c235 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/SegurancaSemKeycloakConfig.java @@ -0,0 +1,26 @@ +package synapse.api.core.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Mantém o ambiente local e os testes sem autenticação quando o Keycloak está + * desabilitado. + */ +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(name = "app.keycloak.enabled", havingValue = "false", matchIfMissing = true) +class SegurancaSemKeycloakConfig { + + @Bean + SecurityFilterChain cadeiaDeFiltrosSemKeycloak(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(autorizacao -> autorizacao.anyRequest().permitAll()) + .build(); + } + +} diff --git a/api/src/main/java/synapse/api/core/security/UsuarioAtual.java b/api/src/main/java/synapse/api/core/security/UsuarioAtual.java new file mode 100644 index 0000000..d84c310 --- /dev/null +++ b/api/src/main/java/synapse/api/core/security/UsuarioAtual.java @@ -0,0 +1,95 @@ +package synapse.api.core.security; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import synapse.api.core.config.AppProperties; + +@Service +public class UsuarioAtual { + + private final JdbcTemplate jdbc; + + private final AppProperties properties; + + public UsuarioAtual(JdbcTemplate jdbc, AppProperties properties) { + this.jdbc = jdbc; + this.properties = properties; + } + + @Transactional + public AcessoDoUsuario obter() { + Authentication autenticacao = SecurityContextHolder.getContext().getAuthentication(); + if (autenticacao == null || !(autenticacao.getPrincipal() instanceof Jwt token)) { + return usuarioDeDesenvolvimento(); + } + + String subject = Objects.requireNonNull(token.getSubject()); + String login = textoDoToken(token, "preferred_username", subject); + String nome = textoDoToken(token, "name", login); + UUID usuarioId = encontrarOuCriar(subject, login, nome); + return new AcessoDoUsuario(usuarioId, temPapelDeAuditor(token)); + } + + private AcessoDoUsuario usuarioDeDesenvolvimento() { + if (this.properties.keycloak().enabled()) { + throw new IllegalStateException("Sessão autenticada não encontrada."); + } + List usuarios = this.jdbc + .queryForList("SELECT id FROM usuarios WHERE ativo = true ORDER BY criado_em, id LIMIT 1", UUID.class); + if (usuarios.isEmpty()) { + throw new IllegalStateException("Nenhum usuário ativo disponível."); + } + return new AcessoDoUsuario(usuarios.getFirst(), false); + } + + private UUID encontrarOuCriar(String subject, String login, String nome) { + List usuarios = this.jdbc.queryForList(""" + SELECT id FROM usuarios + WHERE keycloak_sub = ? OR (keycloak_sub IS NULL AND login = ?) + ORDER BY criado_em, id LIMIT 1 + """, UUID.class, subject, login); + Timestamp agora = Timestamp.from(Instant.now()); + if (!usuarios.isEmpty()) { + UUID usuarioId = usuarios.getFirst(); + int atualizados = this.jdbc.update(""" + UPDATE usuarios SET keycloak_sub = ?, nome = ?, ultimo_login_em = ? + WHERE id = ? AND ativo = true + """, subject, nome, agora, usuarioId); + if (atualizados == 0) { + throw new AccessDeniedException("Conta inativa."); + } + return usuarioId; + } + return Objects.requireNonNull(this.jdbc.queryForObject(""" + INSERT INTO usuarios (login, senha_hash, nome, papel, ativo, criado_em, ultimo_login_em, keycloak_sub) + VALUES (?, NULL, ?, 'profissional_rh', true, ?, ?, ?) RETURNING id + """, UUID.class, login, nome, agora, agora, subject)); + } + + private static String textoDoToken(Jwt token, String campo, String padrao) { + String valor = token.getClaimAsString(campo); + return valor == null || valor.isBlank() ? padrao : valor; + } + + private static boolean temPapelDeAuditor(Jwt token) { + Map acessoDoRealm = token.getClaimAsMap("realm_access"); + if (acessoDoRealm == null || !(acessoDoRealm.get("roles") instanceof List papeis)) { + return false; + } + return papeis.stream().anyMatch("auditor"::equals); + } + +} diff --git a/api/src/main/java/synapse/api/core/web/CorsConfig.java b/api/src/main/java/synapse/api/core/web/CorsConfig.java index 4d4c996..c342bde 100644 --- a/api/src/main/java/synapse/api/core/web/CorsConfig.java +++ b/api/src/main/java/synapse/api/core/web/CorsConfig.java @@ -31,7 +31,7 @@ public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins(origens.toArray(String[]::new)) .allowedMethods("GET", "POST") - .allowedHeaders("Accept", "Content-Type") + .allowedHeaders("Accept", "Authorization", "Content-Type") .allowCredentials(false); } diff --git a/api/src/main/java/synapse/api/job/AcompanharJobController.java b/api/src/main/java/synapse/api/job/AcompanharJobController.java index 04633b6..30c480e 100644 --- a/api/src/main/java/synapse/api/job/AcompanharJobController.java +++ b/api/src/main/java/synapse/api/job/AcompanharJobController.java @@ -11,6 +11,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import synapse.api.core.logging.CorrelationContext; +import synapse.api.core.security.UsuarioAtual; @RestController class AcompanharJobController { @@ -19,13 +20,21 @@ class AcompanharJobController { private final CorrelationContext correlacao; - AcompanharJobController(AcompanharJobService service, CorrelationContext correlacao) { + private final AutorizadorDeJob autorizador; + + private final UsuarioAtual usuarioAtual; + + AcompanharJobController(AcompanharJobService service, CorrelationContext correlacao, AutorizadorDeJob autorizador, + UsuarioAtual usuarioAtual) { this.service = service; this.correlacao = correlacao; + this.autorizador = autorizador; + this.usuarioAtual = usuarioAtual; } @GetMapping(path = "/jobs/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) ResponseEntity acompanhar(@PathVariable("id") UUID id) { + this.autorizador.exigirAcesso(id, this.usuarioAtual.obter()); try (var escopo = this.correlacao.abrir(id.toString(), null)) { SseEmitter emissor = this.service.acompanhar(id); return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(emissor); diff --git a/api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java b/api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java new file mode 100644 index 0000000..b9bb621 --- /dev/null +++ b/api/src/main/java/synapse/api/job/AutorizacaoDeJobAdvice.java @@ -0,0 +1,17 @@ +package synapse.api.job; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +class AutorizacaoDeJobAdvice { + + @ExceptionHandler(SemPermissaoNoJobException.class) + ResponseEntity semPermissao() { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErroDto("sem_permissao", "Você não tem acesso a este job.")); + } + +} diff --git a/api/src/main/java/synapse/api/job/AutorizadorDeJob.java b/api/src/main/java/synapse/api/job/AutorizadorDeJob.java new file mode 100644 index 0000000..bd4c6ee --- /dev/null +++ b/api/src/main/java/synapse/api/job/AutorizadorDeJob.java @@ -0,0 +1,30 @@ +package synapse.api.job; + +import java.util.List; +import java.util.UUID; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import synapse.api.core.security.AcessoDoUsuario; + +@Service +class AutorizadorDeJob { + + private final JdbcTemplate jdbc; + + AutorizadorDeJob(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + void exigirAcesso(UUID jobId, AcessoDoUsuario acesso) { + List donos = this.jdbc.queryForList("SELECT usuario_id FROM jobs WHERE id = ?", UUID.class, jobId); + if (donos.isEmpty()) { + throw new JobNaoEncontradoException(jobId); + } + if (!acesso.auditor() && !acesso.usuarioId().equals(donos.getFirst())) { + throw new SemPermissaoNoJobException(); + } + } + +} diff --git a/api/src/main/java/synapse/api/job/BuscarJobAdvice.java b/api/src/main/java/synapse/api/job/BuscarJobAdvice.java new file mode 100644 index 0000000..8c51aea --- /dev/null +++ b/api/src/main/java/synapse/api/job/BuscarJobAdvice.java @@ -0,0 +1,17 @@ +package synapse.api.job; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = BuscarJobController.class) +class BuscarJobAdvice { + + @ExceptionHandler(JobNaoEncontradoException.class) + ResponseEntity jobNaoEncontrado() { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErroDto("job_nao_encontrado", "Job não encontrado.")); + } + +} diff --git a/api/src/main/java/synapse/api/job/BuscarJobController.java b/api/src/main/java/synapse/api/job/BuscarJobController.java index 87dc90b..bfdaa8b 100644 --- a/api/src/main/java/synapse/api/job/BuscarJobController.java +++ b/api/src/main/java/synapse/api/job/BuscarJobController.java @@ -5,17 +5,26 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import synapse.api.core.security.UsuarioAtual; + @RestController class BuscarJobController { private final BuscarJobService service; - BuscarJobController(BuscarJobService service) { + private final AutorizadorDeJob autorizador; + + private final UsuarioAtual usuarioAtual; + + BuscarJobController(BuscarJobService service, AutorizadorDeJob autorizador, UsuarioAtual usuarioAtual) { this.service = service; + this.autorizador = autorizador; + this.usuarioAtual = usuarioAtual; } @GetMapping(path = "/jobs/{id}", produces = "application/json") JobDetalhadoDto buscar(@PathVariable UUID id) { + this.autorizador.exigirAcesso(id, this.usuarioAtual.obter()); return this.service.buscar(id); } diff --git a/api/src/main/java/synapse/api/job/BuscarJobService.java b/api/src/main/java/synapse/api/job/BuscarJobService.java index 40dcf40..cb892ca 100644 --- a/api/src/main/java/synapse/api/job/BuscarJobService.java +++ b/api/src/main/java/synapse/api/job/BuscarJobService.java @@ -7,6 +7,7 @@ import java.util.UUID; import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; @@ -19,7 +20,9 @@ class BuscarJobService { private final JdbcTemplate jdbc; - private final JsonMapper json = new JsonMapper(); + private final JsonMapper json = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); BuscarJobService(JdbcTemplate jdbc) { this.jdbc = jdbc; @@ -31,13 +34,14 @@ JobDetalhadoDto buscar(UUID jobId) { SELECT j.id, j.status, - s.tipo AS origem, + CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem, j.competencias, j.orcamento, j.criado_em, j.iniciado_em, j.finalizado_em, j.submissao_id, + j.job_origem_id, regras.regras, sim.id AS simulacao_id, @@ -51,7 +55,7 @@ JobDetalhadoDto buscar(UUID jobId) { rs.decomposicao AS simulacao_decomposicao FROM jobs j - JOIN submissoes s ON s.id = j.submissao_id + LEFT JOIN submissoes s ON s.id = j.submissao_id JOIN LATERAL ( SELECT jsonb_agg( jsonb_build_object( @@ -87,6 +91,7 @@ LEFT JOIN LATERAL ( Instant finalizadoEm = rs.getTimestamp("finalizado_em") != null ? rs.getTimestamp("finalizado_em").toInstant() : null; UUID submissaoId = rs.getObject("submissao_id", UUID.class); + UUID jobOrigemId = rs.getObject("job_origem_id", UUID.class); List regras = regras(rs.getString("regras")); @@ -125,7 +130,7 @@ LEFT JOIN LATERAL ( } return new JobDetalhadoDto(id, status, origem, competencias, orcamento, criadoEm, iniciadoEm, - finalizadoEm, submissaoId, regras, simulacao); + finalizadoEm, submissaoId, jobOrigemId, regras, simulacao); }, jobId); } catch (EmptyResultDataAccessException ex) { @@ -138,9 +143,7 @@ private List regras(String regrasJson) { List regras = new ArrayList<>(); for (JsonNode regra : raiz) { NucleoRegraDto nucleo = this.json.readValue(regra.path("nucleo").toString(), NucleoRegraDto.class); - List especificacoes = this.json.readValue(regra.path("especificacoes").toString(), - new TypeReference>() { - }); + List especificacoes = regra.path("especificacoes").valueStream().toList(); RepresentacaoRegraDto representacao = new RepresentacaoRegraDto(nucleo, especificacoes); regras.add(new RegraCriadaDto(UUID.fromString(regra.path("id").asString()), regra.path("versao").asInt(), regra.path("origem").asString(), representacao, Instant.parse(regra.path("criada_em").asString()))); diff --git a/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java b/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java index f076a9c..a451ffb 100644 --- a/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java +++ b/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java @@ -5,6 +5,12 @@ import java.util.HashSet; import java.util.List; +import com.networknt.schema.InputFormat; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SchemaValidatorsConfig; +import com.networknt.schema.SpecVersion.VersionFlag; import org.jspecify.annotations.Nullable; import tools.jackson.core.JacksonException; import tools.jackson.databind.DeserializationFeature; @@ -24,6 +30,13 @@ record ConfirmarParametrosRequisicao(RepresentacaoRegraDto representacao, @Nulla private static final List CAMPOS_NUCLEO = List.of("vigencia", "loja", "marca", "cargo", "percentual"); + private static final JsonSchema ESPECIFICACOES = JsonSchemaFactory + .getInstance(VersionFlag.V202012, + builder -> builder.schemaMappers(mappers -> mappers.mapPrefix("https://synapse.local/contracts/domain/", + "classpath:static/openapi/domain/"))) + .getSchema(SchemaLocation.of("classpath:static/openapi/domain/regra-especificacoes.schema.json"), + SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()); + private static final JsonMapper JSON = JsonMapper.builder() .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, DeserializationFeature.FAIL_ON_TRAILING_TOKENS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) @@ -47,7 +60,8 @@ static ConfirmarParametrosRequisicao deJson(String corpo) { validarNucleo(regra.path("nucleo")); validarEspecificacoes(regra.path("especificacoes")); RepresentacaoRegraDto representacao = new RepresentacaoRegraDto( - JSON.treeToValue(regra.path("nucleo"), NucleoRegraDto.class), List.of()); + JSON.treeToValue(regra.path("nucleo"), NucleoRegraDto.class), + regra.path("especificacoes").valueStream().toList()); return new ConfirmarParametrosRequisicao(representacao, orcamento(raiz.path("orcamento")), competencias(raiz)); } @@ -96,12 +110,10 @@ private static void validarNucleo(JsonNode nucleo) { } private static void validarEspecificacoes(JsonNode especificacoes) { - if (especificacoes.isMissingNode() || especificacoes.isNull()) { - return; - } - if (!especificacoes.isArray() || !especificacoes.isEmpty()) { + if (!especificacoes.isArray() + || !ESPECIFICACOES.validate(especificacoes.toString(), InputFormat.JSON).isEmpty()) { throw ConfirmarParametrosException - .requisicao("Na Sprint 1, regra.especificacoes deve ser uma lista vazia."); + .requisicao("O campo regra.especificacoes deve ser uma lista de elementos válidos da regra."); } } diff --git a/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java b/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java index 85322d1..982bf1b 100644 --- a/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java +++ b/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java @@ -7,12 +7,15 @@ import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; import org.jspecify.annotations.Nullable; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; import org.springframework.dao.EmptyResultDataAccessException; @@ -40,7 +43,9 @@ class ConfirmarParametrosService { private final Outbox outbox; - private final JsonMapper json = new JsonMapper(); + private final JsonMapper json = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); ConfirmarParametrosService(JdbcTemplate jdbc, MaquinaDeEstadosDoJob maquina, Outbox outbox) { this.jdbc = jdbc; @@ -67,25 +72,26 @@ JobCriadoDto confirmar(UUID jobId, ConfirmarParametrosRequisicao requisicao) { this.outbox.registrar(jobId, EventoOutbox.PARAMETROS_CONFIRMADOS, new ParametrosConfirmadosDto(jobId, versao.id())); - registrarTrilha(jobId, versao.id(), editado, representacao.nucleo(), anterior, timestamp); + registrarTrilha(jobId, versao.id(), editado, representacao, anterior, timestamp); - RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), "confirmacao_usuario", representacao, + RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), versao.origem(), representacao, versao.criadaEm()); return new JobCriadoDto(jobId, JobStatus.GERANDO_REGRA.paraColuna(), dados.origem(), competencias, orcamento, - dados.criadoEm(), dados.submissaoId(), regra); + dados.criadoEm(), dados.submissaoId(), dados.jobOrigemId(), regra); } private DadosDoJob carregarJob(UUID jobId) { try { return Objects.requireNonNull(this.jdbc.queryForObject(""" - SELECT s.tipo AS origem, j.orcamento, j.criado_em, j.submissao_id - FROM jobs j JOIN submissoes s ON s.id = j.submissao_id + SELECT CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem, + j.orcamento, j.criado_em, j.submissao_id, j.job_origem_id + FROM jobs j LEFT JOIN submissoes s ON s.id = j.submissao_id WHERE j.id = ? """, (rs, linha) -> new DadosDoJob(Objects.requireNonNull(rs.getString("origem")), Objects.requireNonNull(rs.getBigDecimal("orcamento")), Objects.requireNonNull(rs.getTimestamp("criado_em")).toInstant(), - Objects.requireNonNull(rs.getObject("submissao_id", UUID.class))), + rs.getObject("submissao_id", UUID.class), rs.getObject("job_origem_id", UUID.class)), jobId)); } catch (EmptyResultDataAccessException ex) { @@ -94,12 +100,16 @@ private DadosDoJob carregarJob(UUID jobId) { } private @Nullable VersaoAnterior ultimaVersao(UUID jobId) { - List versoes = this.jdbc.query(""" - SELECT id, versao, hash, nucleo FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1 - """, + List versoes = this.jdbc.query( + """ + SELECT id, versao, hash, nucleo, especificacoes FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1 + """, (rs, linha) -> new VersaoAnterior(Objects.requireNonNull(rs.getObject("id", UUID.class)), rs.getInt("versao"), Objects.requireNonNull(rs.getString("hash")), - this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class)), + this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class), + this.json.readTree(Objects.requireNonNull(rs.getString("especificacoes"))) + .valueStream() + .toList()), jobId); return versoes.isEmpty() ? null : versoes.getFirst(); } @@ -107,10 +117,11 @@ private DadosDoJob carregarJob(UUID jobId) { private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representacao, String hash, @Nullable VersaoAnterior anterior, Timestamp timestamp, Instant agora) { List existentes = this.jdbc.query(""" - SELECT id, versao, criada_em FROM regras WHERE job_id = ? AND hash = ? + SELECT id, versao, origem, criada_em FROM regras WHERE job_id = ? AND hash = ? """, (rs, linha) -> new VersaoRegra(Objects.requireNonNull(rs.getObject("id", UUID.class)), - rs.getInt("versao"), Objects.requireNonNull(rs.getTimestamp("criada_em")).toInstant()), + rs.getInt("versao"), Objects.requireNonNull(rs.getString("origem")), + Objects.requireNonNull(rs.getTimestamp("criada_em")).toInstant()), jobId, hash); if (!existentes.isEmpty()) { return existentes.getFirst(); @@ -119,10 +130,10 @@ private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representac UUID origemId = (anterior != null) ? anterior.id() : null; UUID id = Objects.requireNonNull(this.jdbc.queryForObject(""" INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em) - VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, '[]'::jsonb, ?, ?) RETURNING id + VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, ?::jsonb, ?, ?) RETURNING id """, UUID.class, jobId, novaVersao, origemId, this.json.writeValueAsString(representacao.nucleo()), - hash, timestamp)); - return new VersaoRegra(id, novaVersao, agora); + this.json.writeValueAsString(representacao.especificacoes()), hash, timestamp)); + return new VersaoRegra(id, novaVersao, "confirmacao_usuario", agora); } private BigDecimal resolverOrcamento(UUID jobId, ConfirmarParametrosRequisicao requisicao, BigDecimal atual) { @@ -144,11 +155,15 @@ private List resolverCompetencias(UUID jobId, ConfirmarParametrosRequisi return this.jdbc.queryForList("SELECT unnest(competencias) FROM jobs WHERE id = ?", String.class, jobId); } - private void registrarTrilha(UUID jobId, UUID regraId, boolean editado, NucleoRegraDto atual, + private void registrarTrilha(UUID jobId, UUID regraId, boolean editado, RepresentacaoRegraDto atual, @Nullable VersaoAnterior anterior, Timestamp timestamp) { - List corrigidos = (anterior != null) ? camposCorrigidos(atual, anterior.nucleo()) : List.of(); - String resumo = (editado && !corrigidos.isEmpty()) - ? "usuário corrigiu " + String.join(", ", corrigidos) + " antes de confirmar" + List corrigidos = (anterior != null && editado) ? camposCorrigidos(atual.nucleo(), anterior.nucleo()) + : new ArrayList<>(); + if (anterior != null && editado) { + corrigidos.addAll(especificacoesCorrigidas(atual.especificacoes(), anterior.especificacoes())); + } + String resumo = editado ? "usuário corrigiu " + + (corrigidos.isEmpty() ? "a representação" : String.join(", ", corrigidos)) + " antes de confirmar" : "usuário confirmou os parâmetros"; Map conclusao = new LinkedHashMap<>(); conclusao.put("resumo", resumo); @@ -189,13 +204,27 @@ private static boolean percentualDiferente(@Nullable BigDecimal atual, @Nullable return atual.compareTo(anterior) != 0; } - private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, UUID submissaoId) { + private static List especificacoesCorrigidas(List atuais, List anteriores) { + var refs = new LinkedHashSet(); + atuais.forEach(elemento -> refs.add(elemento.path("ref").asString())); + anteriores.forEach(elemento -> refs.add(elemento.path("ref").asString())); + return refs.stream() + .filter(ref -> !atuais.stream() + .filter(elemento -> ref.equals(elemento.path("ref").asString())) + .toList() + .equals(anteriores.stream().filter(elemento -> ref.equals(elemento.path("ref").asString())).toList())) + .toList(); + } + + private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, @Nullable UUID submissaoId, + @Nullable UUID jobOrigemId) { } - private record VersaoAnterior(UUID id, int versao, String hash, NucleoRegraDto nucleo) { + private record VersaoAnterior(UUID id, int versao, String hash, NucleoRegraDto nucleo, + List especificacoes) { } - private record VersaoRegra(UUID id, int versao, Instant criadaEm) { + private record VersaoRegra(UUID id, int versao, String origem, Instant criadaEm) { } } diff --git a/api/src/main/java/synapse/api/job/CriarJobController.java b/api/src/main/java/synapse/api/job/CriarJobController.java index 57598b3..c9c6e99 100644 --- a/api/src/main/java/synapse/api/job/CriarJobController.java +++ b/api/src/main/java/synapse/api/job/CriarJobController.java @@ -7,18 +7,23 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import synapse.api.core.security.UsuarioAtual; + @RestController class CriarJobController { private final CriarJobService service; - CriarJobController(CriarJobService service) { + private final UsuarioAtual usuarioAtual; + + CriarJobController(CriarJobService service, UsuarioAtual usuarioAtual) { this.service = service; + this.usuarioAtual = usuarioAtual; } @PostMapping(path = "/jobs", consumes = "application/json", produces = "application/json") ResponseEntity criar(@RequestBody String corpo) { - JobCriadoDto job = this.service.criar(CriarJobRequisicao.deJson(corpo)); + JobCriadoDto job = this.service.criar(CriarJobRequisicao.deJson(corpo), this.usuarioAtual.obter().usuarioId()); return ResponseEntity.created(URI.create("/api/jobs/" + job.id())).body(job); } diff --git a/api/src/main/java/synapse/api/job/CriarJobService.java b/api/src/main/java/synapse/api/job/CriarJobService.java index 26541bc..9e223d8 100644 --- a/api/src/main/java/synapse/api/job/CriarJobService.java +++ b/api/src/main/java/synapse/api/job/CriarJobService.java @@ -3,8 +3,8 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Objects; import java.util.List; +import java.util.Objects; import java.util.UUID; import tools.jackson.databind.json.JsonMapper; @@ -42,7 +42,16 @@ JobCriadoDto criar(CriarJobRequisicao requisicao) { if (usuarios.isEmpty()) { throw CriarJobException.semUsuarioAtivo(); } - UUID usuarioId = usuarios.getFirst(); + return criar(requisicao, usuarios.getFirst()); + } + + @Transactional + JobCriadoDto criar(CriarJobRequisicao requisicao, UUID usuarioId) { + Integer usuariosAtivos = this.jdbc.queryForObject("SELECT count(*) FROM usuarios WHERE id = ? AND ativo = true", + Integer.class, usuarioId); + if (usuariosAtivos == null || usuariosAtivos == 0) { + throw CriarJobException.semUsuarioAtivo(); + } Instant agora = Instant.now().truncatedTo(ChronoUnit.MICROS); Timestamp timestamp = Timestamp.from(agora); RepresentacaoRegraDto representacao = requisicao.representacao(); @@ -67,7 +76,7 @@ INSERT INTO regras (job_id, versao, origem, nucleo, especificacoes, hash, criada this.outbox.registrar(jobId, EventoOutbox.REGRA_SUBMETIDA, new RegraSubmetidaDto(jobId, requisicao.origem(), requisicao.competencias(), submissaoId, regraId)); return new JobCriadoDto(jobId, status, requisicao.origem(), requisicao.competencias(), requisicao.orcamento(), - agora, submissaoId, regra); + agora, submissaoId, null, regra); } } diff --git a/api/src/main/java/synapse/api/job/ExecutarAcaoController.java b/api/src/main/java/synapse/api/job/ExecutarAcaoController.java index 66d70c8..5a48160 100644 --- a/api/src/main/java/synapse/api/job/ExecutarAcaoController.java +++ b/api/src/main/java/synapse/api/job/ExecutarAcaoController.java @@ -9,6 +9,7 @@ import synapse.api.core.sse.EmissoresSse; import synapse.api.core.sse.EventoSse; +import synapse.api.core.security.UsuarioAtual; import synapse.api.job.ExecutarAcaoService.AcaoAplicada; @RestController @@ -18,13 +19,21 @@ class ExecutarAcaoController { private final EmissoresSse emissores; - ExecutarAcaoController(ExecutarAcaoService service, EmissoresSse emissores) { + private final AutorizadorDeJob autorizador; + + private final UsuarioAtual usuarioAtual; + + ExecutarAcaoController(ExecutarAcaoService service, EmissoresSse emissores, AutorizadorDeJob autorizador, + UsuarioAtual usuarioAtual) { this.service = service; this.emissores = emissores; + this.autorizador = autorizador; + this.usuarioAtual = usuarioAtual; } @PostMapping(path = "/jobs/{id}/actions", consumes = "application/json", produces = "application/json") JobDetalhadoDto executar(@PathVariable UUID id, @RequestBody String corpo) { + this.autorizador.exigirAcesso(id, this.usuarioAtual.obter()); AcaoJob acao = ExecutarAcaoRequisicao.deJson(corpo).acao(); AcaoAplicada aplicada = this.service.aplicar(id, acao); // Os três destinos das ações de finalização (liberado, cancelado, arquivado) são diff --git a/api/src/main/java/synapse/api/job/HashDaRegra.java b/api/src/main/java/synapse/api/job/HashDaRegra.java index 70534dc..5d3845b 100644 --- a/api/src/main/java/synapse/api/job/HashDaRegra.java +++ b/api/src/main/java/synapse/api/job/HashDaRegra.java @@ -8,6 +8,7 @@ import tools.jackson.core.StreamWriteFeature; import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.cfg.JsonNodeFeature; import tools.jackson.databind.json.JsonMapper; final class HashDaRegra { @@ -16,6 +17,7 @@ final class HashDaRegra { .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) .disable(MapperFeature.SORT_CREATOR_PROPERTIES_FIRST) .enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN) + .enable(JsonNodeFeature.WRITE_PROPERTIES_SORTED) .build(); private HashDaRegra() { diff --git a/api/src/main/java/synapse/api/job/JobCriadoDto.java b/api/src/main/java/synapse/api/job/JobCriadoDto.java index 1b17e58..39c25fc 100644 --- a/api/src/main/java/synapse/api/job/JobCriadoDto.java +++ b/api/src/main/java/synapse/api/job/JobCriadoDto.java @@ -4,10 +4,12 @@ import java.time.Instant; import java.util.List; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import org.jspecify.annotations.Nullable; record JobCriadoDto(UUID id, String status, String origem, List competencias, BigDecimal orcamento, - Instant criado_em, UUID submissao_id, RegraCriadaDto regra) { + Instant criado_em, @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID submissao_id, + @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID job_origem_id, RegraCriadaDto regra) { } record RegraCriadaDto(UUID id, int versao, String origem, RepresentacaoRegraDto representacao, Instant criada_em) { @@ -18,6 +20,8 @@ record SimulacaoDto(UUID id, Instant criado_em, @Nullable String status, @Nullab } record JobDetalhadoDto(UUID id, String status, String origem, List competencias, BigDecimal orcamento, - Instant criado_em, @Nullable Instant iniciado_em, @Nullable Instant finalizado_em, UUID submissao_id, - List regras, @Nullable SimulacaoDto simulacao) { + Instant criado_em, @Nullable Instant iniciado_em, @Nullable Instant finalizado_em, + @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID submissao_id, + @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID job_origem_id, List regras, + @Nullable SimulacaoDto simulacao) { } diff --git a/api/src/main/java/synapse/api/job/ListarJobsController.java b/api/src/main/java/synapse/api/job/ListarJobsController.java index 45a7c43..75cb797 100644 --- a/api/src/main/java/synapse/api/job/ListarJobsController.java +++ b/api/src/main/java/synapse/api/job/ListarJobsController.java @@ -4,19 +4,24 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import synapse.api.core.security.UsuarioAtual; + @RestController class ListarJobsController { private final ListarJobsService service; - ListarJobsController(ListarJobsService service) { + private final UsuarioAtual usuarioAtual; + + ListarJobsController(ListarJobsService service, UsuarioAtual usuarioAtual) { this.service = service; + this.usuarioAtual = usuarioAtual; } @GetMapping(path = "/jobs", produces = "application/json") PaginaJobsDto listar(@RequestParam(name = "pagina", defaultValue = "0") int pagina, @RequestParam(name = "tamanho", defaultValue = "20") int tamanho) { - return this.service.listar(new ListarJobsRequisicao(pagina, tamanho)); + return this.service.listar(new ListarJobsRequisicao(pagina, tamanho), this.usuarioAtual.obter()); } } diff --git a/api/src/main/java/synapse/api/job/ListarJobsService.java b/api/src/main/java/synapse/api/job/ListarJobsService.java index 00b2e95..424af92 100644 --- a/api/src/main/java/synapse/api/job/ListarJobsService.java +++ b/api/src/main/java/synapse/api/job/ListarJobsService.java @@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; +import synapse.api.core.security.AcessoDoUsuario; + @Service class ListarJobsService { @@ -25,14 +27,21 @@ class ListarJobsService { this.jdbc = jdbc; } + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + PaginaJobsDto listar(ListarJobsRequisicao requisicao) { + return listar(requisicao, new AcessoDoUsuario(UUID.randomUUID(), true)); + } + /** * {@code REPEATABLE_READ} faz a contagem e a página lerem o mesmo snapshot. O * veredito é o da simulação mais recente do job, e só quando ela terminou com * sucesso. */ @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) - PaginaJobsDto listar(ListarJobsRequisicao requisicao) { - long total = Objects.requireNonNull(this.jdbc.queryForObject("SELECT count(*) FROM jobs", Long.class)); + PaginaJobsDto listar(ListarJobsRequisicao requisicao, AcessoDoUsuario acesso) { + long total = Objects.requireNonNull(this.jdbc.queryForObject(""" + SELECT count(*) FROM jobs WHERE ? OR usuario_id = ? + """, Long.class, acesso.auditor(), acesso.usuarioId())); List itens = this.jdbc.query(""" SELECT j.id, j.status, j.competencias, j.orcamento, j.criado_em, j.finalizado_em, j.job_origem_id, rs.veredito @@ -44,9 +53,11 @@ LEFT JOIN LATERAL ( LIMIT 1 ) corrente ON true LEFT JOIN resultados_simulacao rs ON rs.id = corrente.resultado_id AND rs.status = 'sucesso' + WHERE ? OR j.usuario_id = ? ORDER BY j.criado_em DESC, j.id DESC LIMIT ? OFFSET ? - """, (linha, numero) -> resumo(linha), requisicao.tamanho(), requisicao.deslocamento()); + """, (linha, numero) -> resumo(linha), acesso.auditor(), acesso.usuarioId(), requisicao.tamanho(), + requisicao.deslocamento()); return new PaginaJobsDto(itens, requisicao.pagina(), requisicao.tamanho(), total); } diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java b/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java new file mode 100644 index 0000000..83cfebc --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java @@ -0,0 +1,34 @@ +package synapse.api.job; + +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = ReprocessarJobController.class) +class ReprocessarJobAdvice { + + @ExceptionHandler(ReprocessarJobException.class) + ResponseEntity tratar(ReprocessarJobException ex) { + return ResponseEntity.status(ex.status()).body(ex.erro()); + } + + @ExceptionHandler(JobNaoEncontradoException.class) + ResponseEntity jobNaoEncontrado() { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErroDto("job_nao_encontrado", "Job não encontrado.")); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + ResponseEntity corpoInvalido() { + return tratar(ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido.")); + } + + @ExceptionHandler(DataAccessException.class) + ResponseEntity falhaDePersistencia() { + return ResponseEntity.internalServerError().build(); + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobController.java b/api/src/main/java/synapse/api/job/ReprocessarJobController.java new file mode 100644 index 0000000..9c59521 --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobController.java @@ -0,0 +1,30 @@ +package synapse.api.job; + +import java.net.URI; +import java.util.UUID; + +import org.jspecify.annotations.Nullable; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class ReprocessarJobController { + + private final ReprocessarJobService service; + + ReprocessarJobController(ReprocessarJobService service) { + this.service = service; + } + + @PostMapping(path = "/jobs/{id}/reprocessar", produces = "application/json") + ResponseEntity reprocessar(@PathVariable("id") UUID id, + @RequestBody(required = false) @Nullable String corpo) { + JobCriadoDto job = this.service.reprocessar(id, ReprocessarJobRequisicao.deJson(corpo)); + return ResponseEntity.created(URI.create("/api/jobs/" + job.id())).body(job); + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobException.java b/api/src/main/java/synapse/api/job/ReprocessarJobException.java new file mode 100644 index 0000000..a6d6d9c --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobException.java @@ -0,0 +1,39 @@ +package synapse.api.job; + +import org.springframework.http.HttpStatus; + +class ReprocessarJobException extends RuntimeException { + + private final HttpStatus status; + + private final ErroDto erro; + + private ReprocessarJobException(HttpStatus status, String codigo, String mensagem) { + super(mensagem); + this.status = status; + this.erro = new ErroDto(codigo, mensagem); + } + + static ReprocessarJobException requisicao(String mensagem) { + return new ReprocessarJobException(HttpStatus.BAD_REQUEST, "requisicao_invalida", mensagem); + } + + static ReprocessarJobException estadoInvalido() { + return new ReprocessarJobException(HttpStatus.CONFLICT, "estado_invalido", + "Somente um job arquivado pode ser reprocessado."); + } + + static ReprocessarJobException semRegra() { + return new ReprocessarJobException(HttpStatus.CONFLICT, "estado_invalido", + "O job arquivado não possui uma regra formada para reprocessamento."); + } + + HttpStatus status() { + return this.status; + } + + ErroDto erro() { + return this.erro; + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java b/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java new file mode 100644 index 0000000..da95b91 --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java @@ -0,0 +1,75 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +record ReprocessarJobRequisicao(@Nullable BigDecimal orcamento, @Nullable List competencias) { + + private static final List MESES = List.of("2025-07", "2025-08", "2025-09", "2025-10", "2025-11", "2025-12"); + + private static final JsonMapper JSON = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + + static ReprocessarJobRequisicao deJson(@Nullable String corpo) { + if (corpo == null) { + return new ReprocessarJobRequisicao(null, null); + } + JsonNode raiz; + try { + raiz = JSON.readTree(corpo); + } + catch (JacksonException ex) { + throw ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido."); + } + if (raiz == null || !raiz.isObject()) { + throw ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido."); + } + return new ReprocessarJobRequisicao(orcamento(raiz.path("orcamento")), competencias(raiz)); + } + + private static @Nullable BigDecimal orcamento(JsonNode orcamento) { + if (orcamento.isMissingNode() || orcamento.isNull()) { + return null; + } + if (!orcamento.isNumber()) { + throw ReprocessarJobException.requisicao("O campo orcamento precisa ser um número."); + } + return orcamento.decimalValue(); + } + + private static @Nullable List competencias(JsonNode raiz) { + if (!raiz.has("competencias") || raiz.path("competencias").isNull()) { + return null; + } + JsonNode meses = raiz.path("competencias"); + if (!meses.isArray()) { + throw ReprocessarJobException.requisicao("O campo competencias precisa ser uma lista de meses."); + } + List competencias = new ArrayList<>(); + for (JsonNode mes : meses) { + if (!mes.isString()) { + throw ReprocessarJobException + .requisicao("Cada competência precisa ser um mês entre 2025-07 e 2025-12."); + } + competencias.add(mes.asString()); + } + if (competencias.isEmpty() || !MESES.containsAll(competencias)) { + throw ReprocessarJobException + .requisicao("Informe competências entre 2025-07 e 2025-12, em uma lista não vazia."); + } + if (new HashSet<>(competencias).size() != competencias.size()) { + throw ReprocessarJobException.requisicao("O campo competencias não permite meses repetidos."); + } + return competencias.stream().sorted().toList(); + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobService.java b/api/src/main/java/synapse/api/job/ReprocessarJobService.java new file mode 100644 index 0000000..79af417 --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobService.java @@ -0,0 +1,102 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.SqlArrayValue; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import synapse.api.core.outbox.EventoOutbox; +import synapse.api.core.outbox.Outbox; + +@Service +class ReprocessarJobService { + + private final JdbcTemplate jdbc; + + private final MaquinaDeEstadosDoJob maquina; + + private final Outbox outbox; + + private final JsonMapper json = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); + + ReprocessarJobService(JdbcTemplate jdbc, MaquinaDeEstadosDoJob maquina, Outbox outbox) { + this.jdbc = jdbc; + this.maquina = maquina; + this.outbox = outbox; + } + + @Transactional + JobCriadoDto reprocessar(UUID origemId, ReprocessarJobRequisicao requisicao) { + JobDeOrigem origem = carregarOrigem(origemId); + if (origem.status() != JobStatus.ARQUIVADO) { + throw ReprocessarJobException.estadoInvalido(); + } + List regras = this.jdbc.queryForList(""" + SELECT id FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1 + """, UUID.class, origemId); + if (regras.isEmpty()) { + throw ReprocessarJobException.semRegra(); + } + List competencias = requisicao.competencias() != null ? requisicao.competencias() + : origem.competencias(); + BigDecimal orcamento = requisicao.orcamento() != null ? requisicao.orcamento() : origem.orcamento(); + Instant agora = Instant.now().truncatedTo(ChronoUnit.MICROS); + Timestamp timestamp = Timestamp.from(agora); + JobStatus status = JobStatus.AGUARDANDO_CONFIRMACAO_PARAMETROS; + UUID jobId = Objects.requireNonNull(this.jdbc.queryForObject(""" + INSERT INTO jobs (status, usuario_id, job_origem_id, competencias, orcamento, criado_em) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id + """, UUID.class, status.paraColuna(), origem.usuarioId(), origemId, + new SqlArrayValue("text", competencias.toArray()), orcamento, timestamp)); + this.maquina.registrarCriacao(jobId, status, "usuario"); + RegraCriadaDto regra = Objects.requireNonNull(this.jdbc.queryForObject(""" + INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em) + SELECT ?, 1, 'reprocessamento', id, nucleo, especificacoes, hash, ? + FROM regras WHERE id = ? + RETURNING id, nucleo, especificacoes + """, (rs, linha) -> new RegraCriadaDto(Objects.requireNonNull(rs.getObject("id", UUID.class)), 1, + "reprocessamento", + new RepresentacaoRegraDto( + this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class), + this.json.readTree(Objects.requireNonNull(rs.getString("especificacoes"))) + .valueStream() + .toList()), + agora), jobId, timestamp, regras.getFirst())); + this.outbox.registrar(jobId, EventoOutbox.REGRA_SUBMETIDA, + new RegraSubmetidaDto(jobId, "reprocessamento", competencias, null, regra.id())); + return new JobCriadoDto(jobId, status.paraColuna(), "reprocessamento", competencias, orcamento, agora, null, + origemId, regra); + } + + private JobDeOrigem carregarOrigem(UUID jobId) { + List jobs = this.jdbc.query(""" + SELECT status, usuario_id, competencias, orcamento FROM jobs WHERE id = ? + """, + (rs, linha) -> new JobDeOrigem(JobStatus.deColuna(Objects.requireNonNull(rs.getString("status"))), + Objects.requireNonNull(rs.getObject("usuario_id", UUID.class)), + List.of((String[]) rs.getArray("competencias").getArray()), + Objects.requireNonNull(rs.getBigDecimal("orcamento"))), + jobId); + if (jobs.isEmpty()) { + throw new JobNaoEncontradoException(jobId); + } + return jobs.getFirst(); + } + + private record JobDeOrigem(JobStatus status, UUID usuarioId, List competencias, BigDecimal orcamento) { + } + +} diff --git a/api/src/main/java/synapse/api/job/SemPermissaoNoJobException.java b/api/src/main/java/synapse/api/job/SemPermissaoNoJobException.java new file mode 100644 index 0000000..f1925b4 --- /dev/null +++ b/api/src/main/java/synapse/api/job/SemPermissaoNoJobException.java @@ -0,0 +1,6 @@ +package synapse.api.job; + +/** A sessão é válida, mas não pode acessar o job solicitado. */ +class SemPermissaoNoJobException extends RuntimeException { + +} diff --git a/api/src/main/java/synapse/api/job/TiposDeJob.java b/api/src/main/java/synapse/api/job/TiposDeJob.java index 8fa0e24..7d58ae5 100644 --- a/api/src/main/java/synapse/api/job/TiposDeJob.java +++ b/api/src/main/java/synapse/api/job/TiposDeJob.java @@ -5,6 +5,7 @@ import java.util.Map; import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; record VigenciaDto( @@ -46,18 +47,11 @@ record NucleoRegraDto( @Nullable BigDecimal percentual) { } -record EspecificacaoRegraDto( - - String ref, - - String construto) { -} - record RepresentacaoRegraDto( NucleoRegraDto nucleo, - List especificacoes) { + List especificacoes) { } record TotaisSimulacaoDto( @@ -102,4 +96,4 @@ record ResultadoSimulacaoDto( List assercoes, @Nullable DecomposicaoResultadoDto decomposicao) { -} \ No newline at end of file +} diff --git a/api/src/main/resources/application.yaml b/api/src/main/resources/application.yaml index caf6472..a094950 100644 --- a/api/src/main/resources/application.yaml +++ b/api/src/main/resources/application.yaml @@ -85,6 +85,10 @@ app: outbox: enabled: ${OUTBOX_ENABLED:true} poll-interval: ${OUTBOX_POLL_INTERVAL:1s} + keycloak: + enabled: ${KEYCLOAK_ENABLED:false} + issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8081/realms/synapse} + jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://localhost:8081/realms/synapse/protocol/openid-connect/certs} logging: level: diff --git a/api/src/main/resources/db/changelog/changesets/015-vincula-usuarios-ao-keycloak.sql b/api/src/main/resources/db/changelog/changesets/015-vincula-usuarios-ao-keycloak.sql new file mode 100644 index 0000000..f1ac609 --- /dev/null +++ b/api/src/main/resources/db/changelog/changesets/015-vincula-usuarios-ao-keycloak.sql @@ -0,0 +1,9 @@ +-- liquibase formatted sql + +-- changeset synapse:015-vincula-usuarios-ao-keycloak +ALTER TABLE usuarios ALTER COLUMN senha_hash DROP NOT NULL; +ALTER TABLE usuarios ADD COLUMN keycloak_sub text; +CREATE UNIQUE INDEX uq_usuarios_keycloak_sub ON usuarios (keycloak_sub) WHERE keycloak_sub IS NOT NULL; +-- rollback DROP INDEX uq_usuarios_keycloak_sub; +-- rollback ALTER TABLE usuarios DROP COLUMN keycloak_sub; +-- rollback ALTER TABLE usuarios ALTER COLUMN senha_hash SET NOT NULL; diff --git a/api/src/test/java/synapse/api/core/docs/OpenApiDocsTests.java b/api/src/test/java/synapse/api/core/docs/OpenApiDocsTests.java index 66abee0..3b0683e 100644 --- a/api/src/test/java/synapse/api/core/docs/OpenApiDocsTests.java +++ b/api/src/test/java/synapse/api/core/docs/OpenApiDocsTests.java @@ -18,7 +18,7 @@ * Um erro na cópia de build deixaria /docs de pé, mas com um contrato ausente ou * incompleto. */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.keycloak.enabled=true") @ActiveProfiles("test") class OpenApiDocsTests { diff --git a/api/src/test/java/synapse/api/core/metrics/MetricsEndpointTests.java b/api/src/test/java/synapse/api/core/metrics/MetricsEndpointTests.java index e09a667..76039e6 100644 --- a/api/src/test/java/synapse/api/core/metrics/MetricsEndpointTests.java +++ b/api/src/test/java/synapse/api/core/metrics/MetricsEndpointTests.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; /** Um erro de configuração devolveria os endpoints ao /actuator sem falhar o boot. */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.keycloak.enabled=true") @ActiveProfiles("test") class MetricsEndpointTests { diff --git a/api/src/test/java/synapse/api/core/persistence/MigrationTests.java b/api/src/test/java/synapse/api/core/persistence/MigrationTests.java index 3762767..285fe30 100644 --- a/api/src/test/java/synapse/api/core/persistence/MigrationTests.java +++ b/api/src/test/java/synapse/api/core/persistence/MigrationTests.java @@ -37,9 +37,7 @@ class MigrationTests { "regras", "prompts", "respostas_modelo", "codigos_gerados", "resultados_simulacao", "explicacoes", "simulacoes", "trilhas_auditoria", "outbox_events"); - // Uma tabela por changeset, mais o 000 que cria os usuários de banco e não cria - // tabela nenhuma. - private static final int CHANGESETS = TABELAS.size() + 1; + private static final int CHANGESETS = 16; private static PostgreSQLContainer postgres; diff --git a/api/src/test/java/synapse/api/core/web/CorsConfigTests.java b/api/src/test/java/synapse/api/core/web/CorsConfigTests.java index c6a0e43..e55514f 100644 --- a/api/src/test/java/synapse/api/core/web/CorsConfigTests.java +++ b/api/src/test/java/synapse/api/core/web/CorsConfigTests.java @@ -15,7 +15,8 @@ * sem estes cabeçalhos o navegador descarta a resposta mesmo com a api respondendo 200. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = "app.cors.allowed-origins=https://app.exemplo.com,http://localhost:5173") + properties = { "app.keycloak.enabled=true", + "app.cors.allowed-origins=https://app.exemplo.com,http://localhost:5173" }) @ActiveProfiles("test") class CorsConfigTests { @@ -37,7 +38,7 @@ void allowsThePreflightOfAConfiguredOrigin() { .uri("/jobs") .header("Origin", ORIGEM) .header("Access-Control-Request-Method", "POST") - .header("Access-Control-Request-Headers", "content-type") + .header("Access-Control-Request-Headers", "authorization, content-type") .exchange() .expectStatus() .isOk() @@ -47,7 +48,8 @@ void allowsThePreflightOfAConfiguredOrigin() { .value("Access-Control-Allow-Methods", (metodos) -> assertThat(metodos).contains("POST")) .expectHeader() .value("Access-Control-Allow-Headers", - (cabecalhos) -> assertThat(cabecalhos).containsIgnoringCase("Content-Type")); + (cabecalhos) -> assertThat(cabecalhos).containsIgnoringCase("Authorization") + .containsIgnoringCase("Content-Type")); } /** Cada origem da lista vale por si; o separador é vírgula, não um valor só. */ diff --git a/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java index 2e67dc7..1093c21 100644 --- a/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/BuscarJobPersistenciaTests.java @@ -32,7 +32,12 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +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.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -91,7 +96,11 @@ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) contexto.register(Config.class); contexto.refresh(); service = contexto.getBean(BuscarJobService.class); - mvc = MockMvcBuilders.standaloneSetup(contexto.getBean(BuscarJobController.class)).build(); + UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); + mvc = MockMvcBuilders + .standaloneSetup(new BuscarJobController(service, contexto.getBean(AutorizadorDeJob.class), usuarioAtual)) + .build(); } @AfterAll @@ -110,7 +119,7 @@ void limpar() { } @TestConfiguration(proxyBeanMethods = false) - @Import({ BuscarJobService.class, BuscarJobController.class }) + @Import({ BuscarJobService.class, AutorizadorDeJob.class }) static class Config { } diff --git a/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java b/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java index b84f100..97d560b 100644 --- a/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java +++ b/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java @@ -7,13 +7,21 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -46,7 +54,7 @@ void preparar() { void confirmaResponde202ComRegraNaVersaoNova() throws Exception { when(this.service.confirmar(any(), any())).thenReturn(new JobCriadoDto(JOB, "gerando_regra", "formulario", List.of("2025-11"), new BigDecimal("485000.0"), Instant.parse("2026-09-20T10:00:00Z"), - UUID.randomUUID(), + UUID.randomUUID(), null, new RegraCriadaDto(UUID.randomUUID(), 2, "confirmacao_usuario", new RepresentacaoRegraDto(new NucleoRegraDto(new VigenciaDto("2025-11", "2025-11"), List.of("13"), List.of("10", "20"), List.of("100", "300"), new BigDecimal("0.03")), @@ -61,6 +69,57 @@ void confirmaResponde202ComRegraNaVersaoNova() throws Exception { .andExpect(jsonPath("$.regra.representacao.nucleo.percentual").value(0.03)); } + @Test + void permiteConfirmarRepresentacaoCompletaSemPerderCamposOuPrecisao() throws Exception { + String especificacoes = """ + [{"ref":"elem.1","construto":"faixa_valor","limite_inferior":40000.123456789, + "limite_superior":50000,"efeito":{"tipo":"bonus_fixo","valor":3500.125}, + "extensao":{"criterios":["a","b"],"ativo":true,"fator":0.123456789012345678901}}] + """; + this.mvc + .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON) + .content(CONFIRMAR.replace("\"especificacoes\":[]", "\"especificacoes\":" + especificacoes))) + .andExpect(status().isAccepted()); + var captor = ArgumentCaptor.forClass(ConfirmarParametrosRequisicao.class); + verify(this.service).confirmar(eq(JOB), captor.capture()); + var json = JsonMapper.builder().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).build(); + assertThat(captor.getValue().representacao().especificacoes()) + .containsExactlyElementsOf(json.readTree(especificacoes).valueStream().toList()); + assertThat(captor.getValue() + .representacao() + .especificacoes() + .getFirst() + .path("extensao") + .path("fator") + .decimalValue()).isEqualByComparingTo("0.123456789012345678901"); + } + + @ParameterizedTest + @ValueSource(strings = { "null", "{}", "[1]", "[null]", "[{}]", + "[{\"ref\":\"invalido\",\"construto\":\"generico\",\"descricao\":\"regra\"}]", + "[{\"ref\":\"elem.1\",\"construto\":\"desconhecido\"}]", + "[{\"ref\":\"elem.1\",\"construto\":\"generico\"}]", + "[{\"ref\":\"elem.1\",\"construto\":\"generico\",\"descricao\":\"\"}]", + "[{\"ref\":\"elem.1\",\"construto\":\"faixa_valor\",\"limite_inferior\":0,\"limite_superior\":10,\"efeito\":{\"tipo\":\"invalido\",\"valor\":1}}]", + "[{\"ref\":\"elem.1\",\"construto\":\"janela_datas\",\"data_inicial\":\"2025-99-01\",\"data_final\":\"2025-11-30\",\"efeito\":{\"tipo\":\"bonus_fixo\",\"valor\":1}}]" }) + void especificacoesInvalidasSaoRecusadasSemChamarService(String especificacoes) throws Exception { + this.mvc + .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON) + .content(CONFIRMAR.replace("\"especificacoes\":[]", "\"especificacoes\":" + especificacoes))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.codigo").value("requisicao_invalida")); + verifyNoInteractions(this.service); + } + + @Test + void especificacoesAusentesSaoRecusadas() throws Exception { + this.mvc + .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON) + .content(CONFIRMAR.replace(",\"especificacoes\":[]", ""))) + .andExpect(status().isBadRequest()); + verifyNoInteractions(this.service); + } + @Test void corpoInvalidoResponde400() throws Exception { this.mvc.perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON).content("{")) diff --git a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java index 2b3e167..28c38fd 100644 --- a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java +++ b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java @@ -18,6 +18,9 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -39,11 +42,14 @@ class CriarJobControllerTests { private final CriarJobService service = mock(CriarJobService.class); + private final UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + private MockMvc mvc; @BeforeEach void preparar() { - this.mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(this.service)) + when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); + this.mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(this.service, this.usuarioAtual)) .setControllerAdvice(new CriarJobAdvice()) .build(); } @@ -51,10 +57,10 @@ void preparar() { @Test void semPrincipalResponde201ComLocationEExatamenteOsCamposIniciais() throws Exception { UUID jobId = UUID.randomUUID(); - when(this.service.criar(any())).thenAnswer(invocacao -> { + when(this.service.criar(any(), any())).thenAnswer(invocacao -> { CriarJobRequisicao requisicao = invocacao.getArgument(0); return new JobCriadoDto(jobId, "gerando_regra", "formulario", requisicao.competencias(), - requisicao.orcamento(), Instant.parse("2026-09-16T15:00:00Z"), UUID.randomUUID(), + requisicao.orcamento(), Instant.parse("2026-09-16T15:00:00Z"), UUID.randomUUID(), null, new RegraCriadaDto(UUID.randomUUID(), 1, "confirmacao_usuario", requisicao.representacao(), Instant.parse("2026-09-16T15:00:00Z"))); }); @@ -74,7 +80,7 @@ void semPrincipalResponde201ComLocationEExatamenteOsCamposIniciais() throws Exce "criado_em", "submissao_id", "regra"); assertThat(job.path("regra").propertyNames()).containsExactlyInAnyOrder("id", "versao", "origem", "representacao", "criada_em"); - verify(this.service).criar(any()); + verify(this.service).criar(any(), any()); } @ParameterizedTest @@ -162,7 +168,7 @@ static Stream camposInvalidos() { @Test void semUsuarioAtivoResponde503ComErroDeDominio() throws Exception { - when(this.service.criar(any())).thenThrow(CriarJobException.semUsuarioAtivo()); + when(this.service.criar(any(), any())).thenThrow(CriarJobException.semUsuarioAtivo()); this.mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(FORMULARIO)) .andExpect(status().isServiceUnavailable()) .andExpect(jsonPath("$.codigo").value("usuario_ativo_indisponivel")) @@ -171,7 +177,7 @@ void semUsuarioAtivoResponde503ComErroDeDominio() throws Exception { @Test void naoExpoeErroDoJdbc() throws Exception { - when(this.service.criar(any())).thenThrow(new DataIntegrityViolationException("regras SQL stack trace")); + when(this.service.criar(any(), any())).thenThrow(new DataIntegrityViolationException("regras SQL stack trace")); this.mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(FORMULARIO)) .andExpect(status().isInternalServerError()) .andExpect(content().string("")); diff --git a/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java index 4e86268..bc54e6c 100644 --- a/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/CriarJobPersistenciaTests.java @@ -38,9 +38,13 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; import synapse.api.core.outbox.Outbox; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -117,8 +121,7 @@ static void encerrar() { @TestConfiguration(proxyBeanMethods = false) @EnableTransactionManagement - @Import({ CriarJobService.class, MaquinaDeEstadosDoJob.class, Outbox.class, CriarJobController.class, - CriarJobAdvice.class }) + @Import({ CriarJobService.class, MaquinaDeEstadosDoJob.class, Outbox.class, CriarJobAdvice.class }) static class Config { } @@ -129,7 +132,9 @@ void httpSemPrincipalPersisteUsuarioAtivoIgnorandoUserIdDoBody() throws Exceptio .replace("\"origem\"", "\"user_id\":\"99999999-9999-4999-8999-999999999999\",\"origem\"") .replace("\"texto_livre\":null", "\"texto_livre\":\"Observação recebida\",\"extra\":{\"preservar\":true}") .replace("0.025", "0.025000000000000000001"); - var mvc = MockMvcBuilders.standaloneSetup(contexto.getBean(CriarJobController.class)) + UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); + var mvc = MockMvcBuilders.standaloneSetup(new CriarJobController(service, usuarioAtual)) .setControllerAdvice(contexto.getBean(CriarJobAdvice.class)) .build(); var resposta = mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(corpo)) diff --git a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java index 6bf6c5c..ec79e54 100644 --- a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java +++ b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java @@ -19,6 +19,8 @@ import synapse.api.core.sse.EmissoresSse; import synapse.api.core.sse.EventoSse; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; import synapse.api.job.ExecutarAcaoService.AcaoAplicada; import static org.mockito.ArgumentMatchers.any; @@ -41,11 +43,18 @@ class ExecutarAcaoControllerTests { private final EmissoresSse emissores = mock(EmissoresSse.class); + private final AutorizadorDeJob autorizador = mock(AutorizadorDeJob.class); + + private final UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + private MockMvc mvc; @BeforeEach void preparar() { - this.mvc = MockMvcBuilders.standaloneSetup(new ExecutarAcaoController(this.service, this.emissores)) + when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(UUID.randomUUID(), false)); + this.mvc = MockMvcBuilders + .standaloneSetup( + new ExecutarAcaoController(this.service, this.emissores, this.autorizador, this.usuarioAtual)) .setControllerAdvice(new ExecutarAcaoAdvice()) .build(); } @@ -56,7 +65,7 @@ private static JobDetalhadoDto jobComStatus(String status) { new RepresentacaoRegraDto(nucleo, List.of()), Instant.parse("2026-09-16T15:00:00Z")); return new JobDetalhadoDto(JOB_ID, status, "formulario", List.of("2025-11"), new BigDecimal("485000"), Instant.parse("2026-09-16T15:00:00Z"), null, Instant.parse("2026-09-18T10:00:00Z"), UUID.randomUUID(), - List.of(regra), null); + null, List.of(regra), null); } @ParameterizedTest diff --git a/api/src/test/java/synapse/api/job/HashDaRegraTests.java b/api/src/test/java/synapse/api/job/HashDaRegraTests.java index 904d853..2aa1bc3 100644 --- a/api/src/test/java/synapse/api/job/HashDaRegraTests.java +++ b/api/src/test/java/synapse/api/job/HashDaRegraTests.java @@ -1,11 +1,49 @@ package synapse.api.job; import org.junit.jupiter.api.Test; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; import static org.assertj.core.api.Assertions.assertThat; class HashDaRegraTests { + private static final JsonMapper JSON = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); + + @Test + void hashIncluiCamposExtensiveisSemPerderPrecisao() { + String especificacoes = """ + [{"ref":"elem.1","construto":"faixa_valor","efeito":{"tipo":"bonus_fixo", + "valor":3500.1234567890123456789},"extensao":{"ativo":true}}] + """; + String hash = HashDaRegra.calcular(comEspecificacoes(especificacoes)); + assertThat(hash).isNotEqualTo(HashDaRegra.calcular(comEspecificacoes(especificacoes.replace("true", "false")))) + .isNotEqualTo(HashDaRegra.calcular(comEspecificacoes(especificacoes.replace("6789", "6788")))); + } + + @Test + void ordemDeCamposExtensiveisNaoCriaUmaRepresentacaoDiferente() { + String primeira = """ + [{"ref":"elem.1","construto":"faixa_valor","efeito":{"tipo":"bonus_fixo","valor":3500.125}, + "extensao":{"criterios":["a","b"],"ativo":true}}] + """; + String equivalente = """ + [{"extensao":{"ativo":true,"criterios":["a","b"]},"efeito":{"valor":3500.125,"tipo":"bonus_fixo"}, + "construto":"faixa_valor","ref":"elem.1"}] + """; + assertThat(HashDaRegra.calcular(comEspecificacoes(primeira))) + .isEqualTo(HashDaRegra.calcular(comEspecificacoes(equivalente))); + } + + private static RepresentacaoRegraDto comEspecificacoes(String especificacoes) { + var nucleo = CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO).representacao().nucleo(); + return JSON.readValue( + "{\"nucleo\":" + JSON.writeValueAsString(nucleo) + ",\"especificacoes\":" + especificacoes + "}", + RepresentacaoRegraDto.class); + } + @Test void ignoraOrdemDasPropriedadesEscalaDecimalEConteudoForaDaRegra() { String original = CriarJobControllerTests.FORMULARIO; diff --git a/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java b/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java index 533914f..1a5656e 100644 --- a/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java +++ b/api/src/test/java/synapse/api/job/ListarJobsControllerTests.java @@ -15,6 +15,9 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -29,36 +32,39 @@ class ListarJobsControllerTests { private final ListarJobsService service = mock(ListarJobsService.class); + private final UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + private MockMvc mvc; @BeforeEach void preparar() { - this.mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(this.service)) + when(this.usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(UUID.randomUUID(), false)); + this.mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(this.service, this.usuarioAtual)) .setControllerAdvice(new ListarJobsAdvice()) .build(); } @Test void semParametrosUsaOsPadroesDoContrato() throws Exception { - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 0)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 0)); this.mvc.perform(get("/jobs")).andExpect(status().isOk()); - verify(this.service).listar(new ListarJobsRequisicao(0, 20)); + verify(this.service).listar(any(), any()); } @Test void repassaPaginaETamanhoInformados() throws Exception { - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(), 2, 10, 0)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(), 2, 10, 0)); this.mvc.perform(get("/jobs").param("pagina", "2").param("tamanho", "10")).andExpect(status().isOk()); - verify(this.service).listar(new ListarJobsRequisicao(2, 10)); + verify(this.service).listar(any(), any()); } @Test void aPaginaTemExatamenteOsQuatroCamposDoContrato() throws Exception { - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 3)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(), 0, 20, 3)); String resposta = this.mvc.perform(get("/jobs")) .andExpect(status().isOk()) @@ -76,7 +82,7 @@ void umItemCompletoTemOsOitoCamposDoContrato() throws Exception { UUID jobOrigemId = UUID.randomUUID(); JobResumoDto item = new JobResumoDto(id, "liberado", List.of("2025-11"), new BigDecimal("485000.0"), "viavel", Instant.parse("2026-09-16T15:00:00Z"), Instant.parse("2026-09-16T15:05:00Z"), jobOrigemId); - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(item), 0, 20, 1)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(item), 0, 20, 1)); String resposta = this.mvc.perform(get("/jobs")) .andExpect(status().isOk()) @@ -98,7 +104,7 @@ void umItemCompletoTemOsOitoCamposDoContrato() throws Exception { void umItemMinimoOmiteOsCamposAusentesSemNull() throws Exception { JobResumoDto item = new JobResumoDto(UUID.randomUUID(), "aguardando_confirmacao_parametros", List.of("2025-11"), new BigDecimal("485000.0"), null, Instant.parse("2026-09-16T15:00:00Z"), null, null); - when(this.service.listar(any())).thenReturn(new PaginaJobsDto(List.of(item), 0, 20, 1)); + when(this.service.listar(any(), any())).thenReturn(new PaginaJobsDto(List.of(item), 0, 20, 1)); String resposta = this.mvc.perform(get("/jobs")) .andExpect(status().isOk()) @@ -138,7 +144,7 @@ void parametroNaoInteiroResponde400ComOCorpoDeErroPadrao(String nome, String val @Test void falhaDePersistenciaResponde500SemCorpo() throws Exception { - when(this.service.listar(any())).thenThrow(new DataIntegrityViolationException("falha")); + when(this.service.listar(any(), any())).thenThrow(new DataIntegrityViolationException("falha")); this.mvc.perform(get("/jobs")).andExpect(status().isInternalServerError()); } diff --git a/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java b/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java index 7a65d7e..7cc049f 100644 --- a/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/ListarJobsPersistenciaTests.java @@ -38,7 +38,12 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; /** @@ -98,7 +103,9 @@ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) contexto.register(Config.class); contexto.refresh(); service = contexto.getBean(ListarJobsService.class); - mvc = MockMvcBuilders.standaloneSetup(contexto.getBean(ListarJobsController.class)) + UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(USUARIO, false)); + mvc = MockMvcBuilders.standaloneSetup(new ListarJobsController(service, usuarioAtual)) .setControllerAdvice(contexto.getBean(ListarJobsAdvice.class)) .build(); } @@ -120,7 +127,7 @@ void limpar() { @TestConfiguration(proxyBeanMethods = false) @EnableTransactionManagement - @Import({ ListarJobsService.class, ListarJobsController.class, ListarJobsAdvice.class }) + @Import({ ListarJobsService.class, ListarJobsAdvice.class }) static class Config { } diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java new file mode 100644 index 0000000..3370d5b --- /dev/null +++ b/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java @@ -0,0 +1,131 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ReprocessarJobControllerTests { + + private static final UUID ORIGEM = UUID.randomUUID(); + + private static final UUID NOVO = UUID.randomUUID(); + + private final ReprocessarJobService service = mock(ReprocessarJobService.class); + + private MockMvc mvc; + + @BeforeEach + void preparar() { + this.mvc = MockMvcBuilders.standaloneSetup(new ReprocessarJobController(this.service)) + .setControllerAdvice(new ReprocessarJobAdvice()) + .build(); + } + + @ParameterizedTest + @ValueSource(strings = { "", "{}", "{\"orcamento\":null,\"competencias\":null}" }) + void corpoOpcionalCriaJobComProcedenciaERegra(String corpo) throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenReturn(novoJob()); + var pedido = post("/jobs/{id}/reprocessar", ORIGEM); + if (!corpo.isEmpty()) { + pedido.contentType(MediaType.APPLICATION_JSON).content(corpo); + } + String resposta = this.mvc.perform(pedido) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/jobs/" + NOVO)) + .andExpect(jsonPath("$.id").value(NOVO.toString())) + .andExpect(jsonPath("$.status").value("aguardando_confirmacao_parametros")) + .andExpect(jsonPath("$.origem").value("reprocessamento")) + .andExpect(jsonPath("$.job_origem_id").value(ORIGEM.toString())) + .andExpect(jsonPath("$.regra.versao").value(1)) + .andReturn() + .getResponse() + .getContentAsString(); + assertThat(new JsonMapper().readTree(resposta).propertyNames()).containsExactlyInAnyOrder("id", "status", + "origem", "competencias", "orcamento", "criado_em", "job_origem_id", "regra"); + verify(this.service).reprocessar(eq(ORIGEM), argThat(r -> r.orcamento() == null && r.competencias() == null)); + } + + @Test + void overridesPreservamPrecisaoEOrdenamCompetencias() throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenReturn(novoJob()); + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM).contentType(MediaType.APPLICATION_JSON).content(""" + {"orcamento":-0.1234567890123456789,"competencias":["2025-12","2025-07","2025-09"]} + """)).andExpect(status().isCreated()); + verify(this.service).reprocessar(eq(ORIGEM), + argThat(r -> r.orcamento() != null + && new BigDecimal("-0.1234567890123456789").compareTo(r.orcamento()) == 0 + && List.of("2025-07", "2025-09", "2025-12").equals(r.competencias()))); + } + + @ParameterizedTest + @ValueSource(strings = { "{", "null", "[]", "42", "{} {}", "{\"orcamento\":\"10\"}", "{\"competencias\":[]}", + "{\"competencias\":\"2025-11\"}", "{\"competencias\":[null]}", "{\"competencias\":[202511]}", + "{\"competencias\":[\"2025-06\"]}", "{\"competencias\":[\"2026-01\"]}", "{\"competencias\":[\"2025-7\"]}", + "{\"competencias\":[\"2025-11\",\"2025-11\"]}" }) + void entradaInvalidaNaoChamaServico(String corpo) throws Exception { + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM).contentType(MediaType.APPLICATION_JSON).content(corpo)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.codigo").value("requisicao_invalida")); + verifyNoInteractions(this.service); + } + + @Test + void inexistenteRetorna404() throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(new JobNaoEncontradoException(ORIGEM)); + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.codigo").value("job_nao_encontrado")); + } + + @Test + void estadoInvalidoRetorna409Especifico() throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(ReprocessarJobException.estadoInvalido()); + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.codigo").value("estado_invalido")) + .andExpect(jsonPath("$.mensagem").value("Somente um job arquivado pode ser reprocessado.")); + } + + @Test + void naoExpoeErroDoJdbc() throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(new DataIntegrityViolationException("detalhe SQL")); + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM)) + .andExpect(status().isInternalServerError()) + .andExpect(content().string("")); + } + + private static JobCriadoDto novoJob() { + var representacao = CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO).representacao(); + Instant agora = Instant.parse("2026-09-23T10:00:00Z"); + return new JobCriadoDto(NOVO, "aguardando_confirmacao_parametros", "reprocessamento", List.of("2025-11"), + new BigDecimal("485000"), agora, null, ORIGEM, + new RegraCriadaDto(UUID.randomUUID(), 1, "reprocessamento", representacao, agora)); + } + +} diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java new file mode 100644 index 0000000..56d491a --- /dev/null +++ b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java @@ -0,0 +1,519 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataAccessException; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import synapse.api.core.outbox.Outbox; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@EnabledIf("dockerIsAvailable") +class ReprocessarJobPersistenciaTests { + + private static final UUID USUARIO = CriarJobControllerTests.USUARIO; + + private static final UUID DONO_ORIGINAL = UUID.fromString("99999999-9999-4999-8999-999999999999"); + + private static final JsonMapper JSON = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); + + private static final String ESPECIFICACOES = """ + [{"ref":"elem.1","construto":"faixa_valor","limite_inferior":40000.123456789, + "limite_superior":50000,"efeito":{"tipo":"bonus_fixo","valor":3500.125}, + "extensao":{"criterios":["a","b"],"ativo":true,"fator":0.123456789012345678901}}] + """; + + private static PostgreSQLContainer postgres; + + private static AnnotationConfigApplicationContext contexto; + + private static JdbcTemplate jdbc; + + private static JdbcTemplate dono; + + private static ReprocessarJobService service; + + private static MockMvc mvc; + + 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); + jdbc.update(""" + INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) + VALUES (?, 'rh-t079', 'x', 'RH', 'profissional_rh', '2026-01-02T00:00:00Z') + """, USUARIO); + contexto = new AnnotationConfigApplicationContext(); + contexto.registerBean(DataSource.class, () -> dataSource); + contexto.registerBean(JdbcTemplate.class, () -> jdbc); + contexto.registerBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(dataSource)); + contexto.register(Config.class); + contexto.refresh(); + service = contexto.getBean(ReprocessarJobService.class); + UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(DONO_ORIGINAL, false)); + mvc = MockMvcBuilders + .standaloneSetup(contexto.getBean(ReprocessarJobController.class), + new BuscarJobController(contexto.getBean(BuscarJobService.class), + contexto.getBean(AutorizadorDeJob.class), usuarioAtual), + contexto.getBean(ConfirmarParametrosController.class)) + .setControllerAdvice(new ReprocessarJobAdvice(), new ConfirmarParametrosAdvice()) + .build(); + } + + @AfterAll + static void encerrar() { + if (contexto != null) { + contexto.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + @TestConfiguration(proxyBeanMethods = false) + @EnableTransactionManagement + @Import({ CriarJobService.class, ReprocessarJobService.class, BuscarJobService.class, + ConfirmarParametrosService.class, ExecutarAcaoService.class, MaquinaDeEstadosDoJob.class, Outbox.class, + ReprocessarJobController.class, AutorizadorDeJob.class, ConfirmarParametrosController.class }) + static class Config { + + } + + @BeforeEach + void limpar() { + dono.execute("TRUNCATE submissoes CASCADE"); + jdbc.update(""" + INSERT INTO usuarios (id, login, senha_hash, nome, papel, ativo, criado_em) + VALUES (?, 'dono-t079', 'x', 'Dono original', 'profissional_rh', false, '2026-02-01T00:00:00Z') + ON CONFLICT (id) DO NOTHING + """, DONO_ORIGINAL); + } + + @ParameterizedTest + @ValueSource(strings = { "", "{}" }) + void criaNovoJobHerdandoParametrosEUltimaRegraSemAlterarOriginal(String corpo) throws Exception { + UUID origem = criarOrigem(true); + String antes = retrato(origem); + JsonNode novo = reprocessar(origem, corpo); + UUID novoId = UUID.fromString(novo.path("id").asString()); + assertThat(novoId).isNotEqualTo(origem); + assertThat(novo.propertyNames()).containsExactlyInAnyOrder("id", "status", "origem", "competencias", + "orcamento", "criado_em", "job_origem_id", "regra"); + assertThat(novo.path("status").asString()).isEqualTo("aguardando_confirmacao_parametros"); + assertThat(novo.path("origem").asString()).isEqualTo("reprocessamento"); + assertThat(novo.path("job_origem_id").asString()).isEqualTo(origem.toString()); + assertThat(novo.path("orcamento").decimalValue()).isEqualByComparingTo("485000.1234567890123456789"); + assertThat(novo.path("competencias")).isEqualTo(JSON.readTree("[\"2025-11\"]")); + Map job = jdbc.queryForMap("SELECT * FROM jobs WHERE id = ?", novoId); + assertThat(job).containsEntry("usuario_id", DONO_ORIGINAL) + .containsEntry("submissao_id", null) + .containsEntry("job_origem_id", origem) + .containsEntry("status", "aguardando_confirmacao_parametros") + .containsEntry("tentativas", 0) + .containsEntry("iniciado_em", null) + .containsEntry("finalizado_em", null); + assertThat(job.get("orcamento")).isEqualTo(new BigDecimal("485000.1234567890123456789")); + assertThat(competencias(novoId)).containsExactlyElementsOf(competencias(origem)); + assertThat(jdbc.queryForObject("SELECT count(*) FROM job_transicoes WHERE job_id = ?", Integer.class, novoId)) + .isEqualTo(1); + assertThat(jdbc.queryForMap("SELECT status_anterior, status_novo, ator FROM job_transicoes WHERE job_id = ?", + novoId)) + .containsEntry("status_anterior", null) + .containsEntry("status_novo", "aguardando_confirmacao_parametros") + .containsEntry("ator", "usuario"); + Map anterior = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = 7", origem); + Map regra = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ?", novoId); + assertThat(regra).containsEntry("versao", 1) + .containsEntry("origem", "reprocessamento") + .containsEntry("regra_origem_id", anterior.get("id")) + .containsEntry("nucleo", anterior.get("nucleo")) + .containsEntry("especificacoes", anterior.get("especificacoes")) + .containsEntry("hash", anterior.get("hash")) + .containsEntry("criada_em", job.get("criado_em")); + assertThat(regra.get("id")).isNotEqualTo(anterior.get("id")); + assertThat(novo.path("regra").path("id").asString()) + .isEqualTo(Objects.requireNonNull(regra.get("id")).toString()); + assertThat(novo.path("regra").path("versao").asInt()).isEqualTo(1); + assertThat(novo.path("regra").path("representacao").path("especificacoes")) + .isEqualTo(JSON.readTree(ESPECIFICACOES)); + assertThat(JSON.readTree(Objects.requireNonNull(regra.get("especificacoes")).toString())) + .isEqualTo(JSON.readTree(ESPECIFICACOES)); + verificarEvento(novoId, UUID.fromString(novo.path("regra").path("id").asString()), List.of("2025-11")); + assertThat(retrato(origem)).isEqualTo(antes); + assertThat(jdbc.queryForObject("SELECT count(*) FROM submissoes", Integer.class)).isEqualTo(1); + } + + @Test + void overridesMudamSomenteONovoJobESeuEvento() throws Exception { + UUID origem = criarOrigem(true); + String antes = retrato(origem); + JsonNode novo = reprocessar(origem, """ + {"orcamento":600000.1234567890123456789,"competencias":["2025-12","2025-07","2025-09"]} + """); + UUID novoId = UUID.fromString(novo.path("id").asString()); + assertThat(novo.path("orcamento").decimalValue()).isEqualByComparingTo("600000.1234567890123456789"); + assertThat(jdbc.queryForObject("SELECT orcamento FROM jobs WHERE id = ?", BigDecimal.class, novoId)) + .isEqualByComparingTo("600000.1234567890123456789"); + assertThat(competencias(novoId)).containsExactly("2025-07", "2025-09", "2025-12"); + verificarEvento(novoId, UUID.fromString(novo.path("regra").path("id").asString()), competencias(novoId)); + assertThat(retrato(origem)).isEqualTo(antes); + } + + @ParameterizedTest + @ValueSource(strings = { "{\"orcamento\":-1.125}", "{\"competencias\":[\"2025-07\"]}" }) + void overrideIsoladoPreservaOutroParametro(String corpo) throws Exception { + UUID origem = criarOrigem(false); + JsonNode novo = reprocessar(origem, corpo); + boolean mudaOrcamento = corpo.contains("orcamento"); + assertThat(novo.path("orcamento").decimalValue()) + .isEqualByComparingTo(mudaOrcamento ? "-1.125" : "485000.1234567890123456789"); + assertThat(novo.path("competencias")) + .isEqualTo(JSON.readTree(mudaOrcamento ? "[\"2025-11\"]" : "[\"2025-07\"]")); + } + + @ParameterizedTest + @EnumSource(value = JobStatus.class, names = "ARQUIVADO", mode = EnumSource.Mode.EXCLUDE) + void recusaTodosOsDemaisEstadosSemEfeitos(JobStatus estado) throws Exception { + UUID origem = criarOrigem(false); + jdbc.update("UPDATE jobs SET status = ? WHERE id = ?", estado.paraColuna(), origem); + Map antes = contagens(); + String original = retrato(origem); + mvc.perform(post("/jobs/{id}/reprocessar", origem)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.codigo").value("estado_invalido")) + .andExpect(jsonPath("$.mensagem").value("Somente um job arquivado pode ser reprocessado.")); + assertThat(contagens()).isEqualTo(antes); + assertThat(retrato(origem)).isEqualTo(original); + } + + @Test + void inexistenteNaoTemEfeitosColaterais() throws Exception { + Map antes = contagens(); + mvc.perform(post("/jobs/{id}/reprocessar", UUID.randomUUID())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.codigo").value("job_nao_encontrado")); + assertThat(contagens()).isEqualTo(antes); + } + + @Test + void arquivadoSemRegraNaoCriaJobParcial() throws Exception { + UUID origem = criarOrigem(false); + dono.update("DELETE FROM trilhas_auditoria WHERE job_id = ?", origem); + dono.update("DELETE FROM regras WHERE job_id = ?", origem); + Map antes = contagens(); + mvc.perform(post("/jobs/{id}/reprocessar", origem)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.codigo").value("estado_invalido")) + .andExpect( + jsonPath("$.mensagem").value("O job arquivado não possui uma regra formada para reprocessamento.")); + assertThat(contagens()).isEqualTo(antes); + } + + @Test + void getPosteriorEncontraJobSemSubmissaoEComRegraSemeada() throws Exception { + UUID origem = criarOrigem(true); + JsonNode novo = reprocessar(origem, ""); + String resposta = assertDoesNotThrow(() -> mvc.perform(get("/jobs/{id}", novo.path("id").asString()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.origem").value("reprocessamento")) + .andExpect(jsonPath("$.job_origem_id").value(origem.toString())) + .andExpect(jsonPath("$.status").value("aguardando_confirmacao_parametros")) + .andExpect(jsonPath("$.regras.length()").value(1)) + .andExpect(jsonPath("$.regras[0].versao").value(1)) + .andExpect(jsonPath("$.regras[0].id").value(novo.path("regra").path("id").asString())) + .andReturn() + .getResponse() + .getContentAsString(); + assertThat(JSON.readTree(resposta).has("submissao_id")).isFalse(); + assertThat(JSON.readTree(resposta).path("regras").path(0).path("representacao").path("especificacoes")) + .isEqualTo(JSON.readTree(ESPECIFICACOES)); + mvc.perform(get("/jobs/{id}", origem)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.origem").value("formulario")) + .andExpect(jsonPath("$.submissao_id").isNotEmpty()) + .andExpect(jsonPath("$.job_origem_id").doesNotExist()); + } + + @ParameterizedTest + @ValueSource(strings = { "nenhuma", "nucleo", "especificacoes", "ambos", "parametros", "adicionar", "remover" }) + void confirmaRepresentacaoDoGetPreservandoVersoesEOriginal(String edicao) throws Exception { + UUID origem = criarOrigem(true); + String original = retrato(origem); + JsonNode novo = reprocessar(origem, ""); + UUID novoId = UUID.fromString(novo.path("id").asString()); + Map semeada = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ?", novoId); + JsonNode representacaoOriginal = consultar(novoId).path("regras").path(0).path("representacao"); + ObjectNode enviada = (ObjectNode) representacaoOriginal.deepCopy(); + boolean mudaNucleo = edicao.equals("nucleo") || edicao.equals("ambos"); + boolean mudaEspecificacoes = List.of("especificacoes", "ambos", "adicionar", "remover").contains(edicao); + boolean editado = mudaNucleo || mudaEspecificacoes; + if (mudaNucleo) { + ((ObjectNode) enviada.path("nucleo")).put("percentual", new BigDecimal("0.04")); + } + if (edicao.equals("especificacoes") || edicao.equals("ambos")) { + ((ObjectNode) enviada.path("especificacoes").path(0).path("efeito")).put("valor", + new BigDecimal("3500.123456789012345678901")); + } + if (edicao.equals("adicionar")) { + enviada.withArray("especificacoes").add(JSON.readTree(""" + {"ref":"elem.2","construto":"generico","descricao":"bonus de aniversario", + "campos":{"fator":0.123456789012345678901}} + """)); + } + if (edicao.equals("remover")) { + enviada.putArray("especificacoes"); + } + ObjectNode pedido = JSON.createObjectNode(); + pedido.set("regra", enviada); + if (edicao.equals("parametros")) { + pedido.put("orcamento", new BigDecimal("700000.123456789012345678901")); + pedido.set("competencias", JSON.readTree("[\"2025-10\",\"2025-08\"]")); + } + String corpo = JSON.writeValueAsString(pedido); + assertThat(JSON.valueToTree(ConfirmarParametrosRequisicao.deJson(corpo).representacao())) + .isEqualTo(enviada); + String resposta = mvc + .perform(post("/jobs/{id}/parameters", novoId).contentType(MediaType.APPLICATION_JSON).content(corpo)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("gerando_regra")) + .andExpect(jsonPath("$.origem").value("reprocessamento")) + .andExpect(jsonPath("$.job_origem_id").value(origem.toString())) + .andExpect(jsonPath("$.regra.versao").value(editado ? 2 : 1)) + .andExpect(jsonPath("$.regra.origem").value(editado ? "confirmacao_usuario" : "reprocessamento")) + .andReturn() + .getResponse() + .getContentAsString(); + JsonNode confirmado = JSON.readTree(resposta); + assertThat(confirmado.has("submissao_id")).isFalse(); + assertThat(confirmado.path("regra").path("representacao")).isEqualTo(enviada); + assertThat(jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = 1", novoId)).isEqualTo(semeada); + assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, novoId)) + .isEqualTo("gerando_regra"); + assertThat(jdbc.queryForObject("SELECT count(*) FROM regras WHERE job_id = ?", Integer.class, novoId)) + .isEqualTo(editado ? 2 : 1); + Map utilizada = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = ?", novoId, + editado ? 2 : 1); + assertThat(JSON.readTree(Objects.requireNonNull(utilizada.get("nucleo")).toString())) + .isEqualTo(enviada.path("nucleo")); + assertThat(JSON.readTree(Objects.requireNonNull(utilizada.get("especificacoes")).toString())) + .isEqualTo(enviada.path("especificacoes")); + assertThat(Objects.requireNonNull(utilizada.get("id")).toString()) + .isEqualTo(confirmado.path("regra").path("id").asString()); + assertThat(utilizada.get("hash")) + .isEqualTo(HashDaRegra.calcular(ConfirmarParametrosRequisicao.deJson(corpo).representacao())); + if (editado) { + assertThat(utilizada.get("regra_origem_id")).isEqualTo(semeada.get("id")); + assertThat(utilizada.get("hash")).isNotEqualTo(semeada.get("hash")); + } + else { + assertThat(utilizada).isEqualTo(semeada); + } + if (edicao.equals("parametros")) { + assertThat(jdbc.queryForObject("SELECT orcamento FROM jobs WHERE id = ?", BigDecimal.class, novoId)) + .isEqualByComparingTo("700000.123456789012345678901"); + assertThat(competencias(novoId)).containsExactly("2025-08", "2025-10"); + assertThat(confirmado.path("orcamento")).isEqualTo(pedido.path("orcamento")); + assertThat(confirmado.path("competencias")).isEqualTo(JSON.readTree("[\"2025-08\",\"2025-10\"]")); + } + else { + assertThat(confirmado.path("orcamento")).isEqualTo(novo.path("orcamento")); + assertThat(confirmado.path("competencias")).isEqualTo(novo.path("competencias")); + } + JsonNode regras = consultar(novoId).path("regras"); + assertThat(regras.size()).isEqualTo(editado ? 2 : 1); + assertThat(regras.path(0).path("representacao")).isEqualTo(representacaoOriginal); + assertThat(regras.path(editado ? 1 : 0).path("representacao")).isEqualTo(enviada); + String payload = Objects.requireNonNull(jdbc.queryForObject( + "SELECT payload::text FROM outbox_events WHERE job_id = ? AND tipo = 'parametros-confirmados'", + String.class, novoId)); + ContratoDeEvento.validar("parametros-confirmados", payload); + assertThat(JSON.readTree(payload).path("regra_id")).isEqualTo(confirmado.path("regra").path("id")); + JsonNode trilha = JSON.readTree(Objects.requireNonNull(jdbc.queryForObject( + "SELECT conclusao::text FROM trilhas_auditoria WHERE job_id = ? AND no = 'confirmacao'", String.class, + novoId))); + assertThat(trilha.path("editado_pelo_usuario").asBoolean()).isEqualTo(editado); + List corrigidos = new ArrayList<>(); + if (mudaNucleo) { + corrigidos.add("nucleo.percentual"); + } + if (mudaEspecificacoes) { + corrigidos.add(edicao.equals("adicionar") ? "elem.2" : "elem.1"); + } + assertThat(trilha.path("campos_corrigidos")).isEqualTo(JSON.valueToTree(corrigidos)); + assertThat(trilha.path("resumo").asString()) + .isEqualTo(editado ? "usuário corrigiu " + String.join(", ", corrigidos) + " antes de confirmar" + : "usuário confirmou os parâmetros"); + assertThat(retrato(origem)).isEqualTo(original); + } + + private static JsonNode consultar(UUID jobId) throws Exception { + return JSON.readTree(mvc.perform(get("/jobs/{id}", jobId)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString()); + } + + @Test + void falhaNoOutboxReverteJobRegraETransicao() { + UUID origem = criarOrigem(true); + Map antes = contagens(); + String original = retrato(origem); + dono.execute("REVOKE INSERT ON outbox_events FROM synapse_api"); + try { + assertThatThrownBy(() -> service.reprocessar(origem, ReprocessarJobRequisicao.deJson(null))) + .isInstanceOf(DataAccessException.class) + .hasMessageContaining("INSERT INTO outbox_events"); + } + finally { + dono.execute("GRANT INSERT ON outbox_events TO synapse_api"); + } + assertThat(contagens()).isEqualTo(antes); + assertThat(retrato(origem)).isEqualTo(original); + } + + private static UUID criarOrigem(boolean extensoes) { + JobCriadoDto job = contexto.getBean(CriarJobService.class) + .criar(CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO)); + var representacao = ConfirmarParametrosRequisicao.deJson(ConfirmarParametrosControllerTests.CONFIRMAR) + .representacao(); + var completa = JSON.readValue("{\"nucleo\":" + JSON.writeValueAsString(representacao.nucleo()) + + ",\"especificacoes\":" + (extensoes ? ESPECIFICACOES : "[]") + "}", RepresentacaoRegraDto.class); + UUID regraId = Objects.requireNonNull(jdbc.queryForObject(""" + INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em) + VALUES (?, 7, 'confirmacao_usuario', ?, ?::jsonb, ?::jsonb, ?, now()) RETURNING id + """, UUID.class, job.id(), job.regra().id(), JSON.writeValueAsString(representacao.nucleo()), + extensoes ? ESPECIFICACOES : "[]", HashDaRegra.calcular(completa))); + var maquina = contexto.getBean(MaquinaDeEstadosDoJob.class); + maquina.transicionar(job.id(), JobStatus.SIMULANDO, "evento", null); + maquina.transicionar(job.id(), JobStatus.AGUARDANDO_DECISAO_USUARIO, "evento", null); + contexto.getBean(ExecutarAcaoService.class).aplicar(job.id(), AcaoJob.ARQUIVAR); + jdbc.update("UPDATE jobs SET usuario_id = ?, tentativas = 3, iniciado_em = criado_em WHERE id = ?", + DONO_ORIGINAL, job.id()); + jdbc.update(""" + INSERT INTO trilhas_auditoria (evento_id, job_id, no, concluido_em, regra_id, conclusao) + VALUES (?, ?, 'confirmacao', now(), ?, '{"resumo":"confirmação anterior"}'::jsonb) + """, UUID.randomUUID(), job.id(), regraId); + return job.id(); + } + + private static JsonNode reprocessar(UUID origem, String corpo) throws Exception { + var pedido = post("/jobs/{id}/reprocessar", origem); + if (!corpo.isEmpty()) { + pedido.contentType(MediaType.APPLICATION_JSON).content(corpo); + } + var resposta = mvc.perform(pedido).andExpect(status().isCreated()).andReturn().getResponse(); + JsonNode novo = JSON.readTree(resposta.getContentAsString()); + assertThat(resposta.getHeader("Location")).isEqualTo("/api/jobs/" + novo.path("id").asString()); + return novo; + } + + private static void verificarEvento(UUID jobId, UUID regraId, List competencias) throws Exception { + Map evento = jdbc + .queryForMap("SELECT tipo, payload::text AS payload FROM outbox_events WHERE job_id = ?", jobId); + assertThat(evento).containsEntry("tipo", "regra-submetida"); + String payload = Objects.requireNonNull((String) evento.get("payload")); + ContratoDeEvento.validar("regra-submetida", payload); + assertThat(JSON.readTree(payload)).isEqualTo(JSON.valueToTree(Map.of("job_id", jobId.toString(), "origem", + "reprocessamento", "competencias", competencias, "regra_id", regraId.toString()))); + } + + private static List competencias(UUID jobId) { + return jdbc.queryForList("SELECT unnest(competencias) FROM jobs WHERE id = ?", String.class, jobId); + } + + private static Map contagens() { + return jdbc.queryForMap(""" + SELECT (SELECT count(*) FROM jobs) AS jobs, (SELECT count(*) FROM regras) AS regras, + (SELECT count(*) FROM submissoes) AS submissoes, (SELECT count(*) FROM outbox_events) AS eventos, + (SELECT count(*) FROM job_transicoes) AS transicoes, (SELECT count(*) FROM job_acoes) AS acoes, + (SELECT count(*) FROM trilhas_auditoria) AS trilhas + """); + } + + private static String retrato(UUID jobId) { + return Objects.requireNonNull(jdbc.queryForObject( + """ + SELECT jsonb_build_object('job', to_jsonb(j), 'submissao', to_jsonb(s), + 'regras', (SELECT jsonb_agg(to_jsonb(r) ORDER BY r.id) FROM regras r WHERE r.job_id = j.id), + 'transicoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM job_transicoes t WHERE t.job_id = j.id), + 'acoes', (SELECT jsonb_agg(to_jsonb(a) ORDER BY a.id) FROM job_acoes a WHERE a.job_id = j.id), + 'trilhas', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM trilhas_auditoria t WHERE t.job_id = j.id), + 'eventos', (SELECT jsonb_agg(to_jsonb(e) ORDER BY e.id) FROM outbox_events e WHERE e.job_id = j.id), + 'simulacoes', (SELECT jsonb_agg(to_jsonb(s) ORDER BY s.id) FROM simulacoes s WHERE s.job_id = j.id))::text + FROM jobs j LEFT JOIN submissoes s ON s.id = j.submissao_id WHERE j.id = ? + """, + String.class, jobId)); + } + +} diff --git a/contracts/http/openapi.yaml b/contracts/http/openapi.yaml index 6eb909d..e6918c5 100644 --- a/contracts/http/openapi.yaml +++ b/contracts/http/openapi.yaml @@ -70,7 +70,7 @@ servers: parte deste contrato. security: - - sessaoSynapse: [] + - keycloakOidc: [] tags: - name: jobs @@ -742,20 +742,15 @@ paths: components: securitySchemes: - sessaoSynapse: - type: http - scheme: bearer + keycloakOidc: + type: openIdConnect + openIdConnectUrl: http://localhost:8081/realms/synapse/.well-known/openid-configuration description: | - Sessão emitida pela `api` após login com credencial. A `api` é dona das - credenciais, da sessão e do papel do usuário, sem provedor de identidade - externo. O papel é único por usuário, sem RBAC fino, e as sete - operações deste contrato são do papel `PROFISSIONAL DE RH`; o `AUDITOR` é - perfil investigativo, fora do fluxo operacional (DEC-087). - - A rota que emite a sessão e a forma de renovação não fazem parte desta - versão do contrato e entram com a tarefa de autenticação. O que este arquivo - fixa é que toda operação exige sessão válida e que o acesso a um job confere - posse, e não apenas autenticação. + Autenticação OIDC emitida pelo Keycloak. O cliente envia o access token como + `Authorization: Bearer `; a API valida emissor, assinatura e expiração. + A conta local é associada ao claim `sub`, e o acesso a um job confere posse, + não apenas autenticação. O papel `auditor` no realm autoriza consulta além da + própria conta. parameters: JobId: diff --git a/deploy/.env.example b/deploy/.env.example index ad1eace..9ea8957 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,5 +1,8 @@ # PostgreSQL VITE_API_BASE_URL=http://localhost:8080 +VITE_KEYCLOAK_URL=http://localhost:8081 +VITE_KEYCLOAK_REALM=synapse +VITE_KEYCLOAK_CLIENT_ID=synapse-frontend POSTGRES_DB=synapse_db POSTGRES_PORT=5432 @@ -38,6 +41,19 @@ DOCKER_GID=999 # API API_PORT=8080 +#Keycloak +KEYCLOAK_ENABLED=true +# Emissor público presente no token retornado ao navegador. +KEYCLOAK_PUBLIC_URL=http://localhost:8081 +KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/synapse +# Endpoint interno usado apenas pela API para buscar as chaves públicas. +KEYCLOAK_JWK_SET_URI=http://keycloak:8080/realms/synapse/protocol/openid-connect/certs + +# Keycloak +KEYCLOAK_PORT=8081 +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=troque-me + # Origens de navegador que a api libera no CORS, separadas por vírgula. # Em staging/produção é a URL do frontend: https://$GATEWAY_APP_DOMAIN. CORS_ALLOWED_ORIGINS=http://localhost:5173,https://app.localhost diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 69534b1..1cbe682 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -9,6 +9,9 @@ services: dockerfile: frontend/Dockerfile args: VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} + VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL:-http://localhost:8081} + VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM:-synapse} + VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-synapse-frontend} image: synapse-frontend:${TAG:-local} restart: "no" command: ['sh', '-c', 'rm -rf /dist/* && cp -r /app/dist/. /dist/'] @@ -16,7 +19,7 @@ services: - frontend-dist:/dist # Único ponto de entrada do droplet: termina TLS e roteia por subdomínio - # pra frontend/api/codegen/worker. Ver deploy/gateway/Caddyfile. + # pra frontend/api/codegen/worker e Keycloak. Ver deploy/gateway/Caddyfile. gateway: image: caddy:2-alpine container_name: synapse-gateway @@ -193,6 +196,9 @@ services: RABBITMQ_PORT: 5672 RABBITMQ_USER: ${RABBITMQ_DEFAULT_USER:-guest} RABBITMQ_PASSWORD: ${RABBITMQ_DEFAULT_PASS:-guest} + KEYCLOAK_ENABLED: ${KEYCLOAK_ENABLED:-true} + KEYCLOAK_ISSUER_URI: ${KEYCLOAK_ISSUER_URI:-http://localhost:8081/realms/synapse} + KEYCLOAK_JWK_SET_URI: ${KEYCLOAK_JWK_SET_URI:-http://keycloak:8080/realms/synapse/protocol/openid-connect/certs} ports: - '${API_PORT:-8080}:8080' depends_on: @@ -200,6 +206,8 @@ services: condition: service_healthy rabbitmq: condition: service_healthy + keycloak: + condition: service_started healthcheck: test: ['CMD', 'curl', '--fail', '--silent', 'http://localhost:8080/actuator/health'] interval: 30s @@ -229,6 +237,37 @@ services: networks: - synapse-net + keycloak: + image: quay.io/keycloak/keycloak:26.7.4 + container_name: synapse-keycloak + command: start-dev --import-realm + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?defina em deploy/.env} + KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8081} + KC_PROXY_HEADERS: xforwarded + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + ports: + - '127.0.0.1:${KEYCLOAK_PORT:-8081}:8080' + volumes: + - ./keycloak/realm-synapse.json:/opt/keycloak/data/import/realm-synapse.json:ro + - ./keycloak/themes:/opt/keycloak/themes:ro + - keycloak-data:/opt/keycloak/data + healthcheck: + test: + - CMD + - bash + - -ec + - "{ printf 'HEAD /health/ready HTTP/1.0\r\n\r\n' >&0; grep -q 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000" + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + networks: + - synapse-net + restart: unless-stopped + alloy: image: grafana/alloy:latest container_name: synapse-alloy @@ -257,6 +296,7 @@ volumes: rabbitmq-data: name: synapse-rabbitmq-data alloy-data: + keycloak-data: networks: synapse-net: diff --git a/deploy/gateway/Caddyfile b/deploy/gateway/Caddyfile index 0c13d64..af97167 100644 --- a/deploy/gateway/Caddyfile +++ b/deploy/gateway/Caddyfile @@ -1,5 +1,5 @@ # Ponto único de entrada do droplet: termina TLS e roteia por subdomínio pra -# cada um dos quatro serviços. Nenhum outro serviço expõe porta 80/443. +# a aplicação e o Keycloak. Nenhum outro serviço expõe porta 80/443. app.synnapse.pro { root * /srv/app @@ -20,6 +20,13 @@ app.synnapse.pro { } } +auth.synnapse.pro { + reverse_proxy keycloak:8080 { + header_up X-Forwarded-Port 443 + header_up -X-Forwarded-Prefix + } +} + api.synnapse.pro { reverse_proxy api:8080 } diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md new file mode 100644 index 0000000..9f0fe7e --- /dev/null +++ b/deploy/keycloak/README.md @@ -0,0 +1,162 @@ +# Keycloak local + +A partir da raiz do repositório, prepare `deploy/.env` conforme o +[manual de instalação](../../docs/instalacao.md). Defina +`KEYCLOAK_ADMIN_PASSWORD` e mantenha `KEYCLOAK_ENABLED=true` para validar o fluxo +autenticado. Suba a identidade local junto dos serviços necessários: + +```powershell +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --build keycloak postgres rabbitmq api +``` + +O console administrativo fica em `http://localhost:8081/admin/`. As credenciais do +administrador vêm de `KEYCLOAK_ADMIN_USERNAME` e `KEYCLOAK_ADMIN_PASSWORD` em +`deploy/.env`. + +O import cria o realm `synapse` e o cliente público `synapse-frontend`. Para +criar uma conta manualmente, use **synapse → Users**; não crie contas de uso da +aplicação no realm `master`. O usuário autenticado recebe uma conta local na +primeira chamada à API. + +## Dados de demonstração + +O script `deploy/scripts/seed.py` cria `develop@synapse.pro` com o papel +`profissional-rh` e `staging@synapse.pro` com o papel `auditor` no Keycloak. +Também grava essas contas em `usuarios`, com `keycloak_sub` igual ao identificador +retornado pelo Keycloak e `senha_hash` nulo. São sete jobs de demonstração: +quatro vinculados a `develop@synapse.pro` e três vinculados a `staging@synapse.pro` +no status `aguardando_decisao_usuario`. Em uma nova execução, o script reutiliza as contas, +redefine a senha de ambas e corrige a posse dos jobs criados por versões antigas +do seed. + +Com o Keycloak e o banco já iniciados e as migrations da API aplicadas, instale +a dependência do script e execute-o a partir da raiz: + +```bash +python3 -m pip install -r deploy/scripts/requirements.txt +python3 deploy/scripts/seed.py +``` + +Antes de executar, exporte `POSTGRES_PORT`, `KEYCLOAK_ADMIN_PASSWORD`, +`SEED_USERS_PASSWORD` e `POSTGRES_PASSWORD`. A porta deve ser a publicada pelo +Compose no host (`5433` no ambiente local deste guia), pois o seed exige esse +valor e não usa a porta interna do container. Se seu `deploy/.env` usar outros +valores, exporte também `POSTGRES_HOST`, `POSTGRES_DB`, `POSTGRES_USER`, +`KEYCLOAK_PUBLIC_URL` e `KEYCLOAK_ADMIN_USERNAME` conforme esse ambiente. O +script lê variáveis de ambiente, não o arquivo `deploy/.env` diretamente. A +senha em `SEED_USERS_PASSWORD` permite autenticar qualquer uma das duas contas +de demonstração no Keycloak. + +O cliente já aceita `http://localhost:5173/*` como retorno de login e logout. Se +o volume `keycloak-data` já existia antes da mudança do realm, ajuste esses valores +no console, pois a importação preserva clientes existentes. + +## Frontend e sessão + +Para executar o frontend pelo Vite, crie `frontend/.env` a partir de +`frontend/.env.example`, caso ainda não exista, e use: + +```dotenv +VITE_API_BASE_URL=/api +VITE_KEYCLOAK_URL=http://localhost:8081 +VITE_KEYCLOAK_REALM=synapse +VITE_KEYCLOAK_CLIENT_ID=synapse-frontend +``` + +Em `frontend/`, execute `npm ci` e `npm run dev` e acesse +`http://localhost:5173`. O proxy do Vite encaminha `/api` para a API em +`http://localhost:8080`. + +O login usa OIDC com PKCE. Os tokens ficam em memória; ao recarregar a página, o +frontend tenta recuperar a sessão por SSO silencioso. REST e SSE enviam Bearer e +aguardam a renovação do token antes de cada requisição ou abertura do stream. +Uma falha transitória de renovação permite nova tentativa sem descartar o +formulário; uma resposta 401 reinicia o login. + +A API vincula o claim `sub` à conta em `usuarios.keycloak_sub`. Os papéis do realm +são `profissional-rh` e `auditor`; o papel inicial gravado na conta local é +`profissional_rh`. A aplicação uniforme das permissões e da posse dos jobs está +pendente na tarefa do middleware de autorização, conforme o +[ADR-006](../../docs/adrs/ADR-006.md). + +Se executar a API fora do Compose, defina `KEYCLOAK_ENABLED=true` no ambiente da +API. O default de `application.yaml` é `false`; o Compose o habilita por padrão. +Nesse modo local, `KEYCLOAK_JWK_SET_URI` deve apontar para +`http://localhost:8081/realms/synapse/protocol/openid-connect/certs`, pois o nome +`keycloak` só é resolvido dentro da rede Docker. + +## Staging e CD + +O Keycloak é publicado em `https://auth.synnapse.pro`. O Caddy termina TLS e +encaminha as requisições para `keycloak:8080`; o Keycloak interpreta os cabeçalhos +`X-Forwarded-*` enviados pelo proxy. A porta `8081` no host fica restrita a +`127.0.0.1`, para acesso local e por túnel SSH. + +Antes da primeira implantação: + +1. Aponte o DNS de `auth.synnapse.pro` para o droplet e libere `80`/`443`. +2. Configure `KEYCLOAK_ADMIN_PASSWORD` e os seguintes valores em `deploy/.env` + **no droplet**: + + ```dotenv + KEYCLOAK_ENABLED=true + KEYCLOAK_PUBLIC_URL=https://auth.synnapse.pro + KEYCLOAK_ISSUER_URI=https://auth.synnapse.pro/realms/synapse + KEYCLOAK_JWK_SET_URI=http://keycloak:8080/realms/synapse/protocol/openid-connect/certs + VITE_KEYCLOAK_URL=https://auth.synnapse.pro + VITE_KEYCLOAK_REALM=synapse + VITE_KEYCLOAK_CLIENT_ID=synapse-frontend + CORS_ALLOWED_ORIGINS=https://app.synnapse.pro + ``` + +3. Em realms já existentes, acrescente `https://app.synnapse.pro/*` em + **Valid Redirect URIs** e **Valid Post Logout Redirect URIs**, e + `https://app.synnapse.pro` em **Web Origins** do cliente `synapse-frontend`. + Realms novos recebem esses valores do arquivo versionado, além da origem local. +4. Implante o gateway e o Keycloak, recrie a API e reconstrua o frontend para + aplicar as URLs. Os workflows correspondentes podem ser disparados manualmente + em **Actions → Run workflow**, selecionando `staging`. Alterar o `.env` no + servidor não dispara esses workflows. + +O workflow `.github/workflows/cd-keycloak.yml` dispara em push para `staging` +quando houver mudanças em `deploy/keycloak/**`, `deploy/docker-compose.yml` ou +no próprio workflow. Também aceita disparo manual. Usa os mesmos secrets SSH e +a fila `cd-staging-deploy` dos outros componentes, obtém a imagem e recria apenas +o Keycloak, preservando o volume `keycloak-data`. O deploy aguarda o healthcheck +`/health/ready` e falha se o serviço não ficar pronto em 180 segundos. + +A recriação aplica os arquivos do tema. A importação de inicialização cria realms +novos, mas não atualiza realms já persistidos: alterações no realm existente +devem ser aplicadas pelo console. O seed é executado manualmente e não participa +de nenhum fluxo de CI/CD. + +O serviço usa `start-dev`, conforme o Compose compartilhado. A configuração +segue as orientações do Keycloak para +[proxy reverso](https://www.keycloak.org/server/reverseproxy) e +[healthcheck](https://www.keycloak.org/observability/health). + +Após o deploy, confira o emissor em +`https://auth.synnapse.pro/realms/synapse/.well-known/openid-configuration` e valide +login, recarga da página, renovação de token, SSE e logout pelo frontend público. + +## Tema de login + +O tema visual `synapse` é montado no container pelo Compose. Após alterar arquivos +em `deploy/keycloak/themes/`, recrie somente o Keycloak: + +```powershell +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate keycloak +``` + +O símbolo exibido pelo tema está em +`deploy/keycloak/themes/synapse/login/resources/img/synapse-simbolo.png`. Ele foi +copiado a partir de `frontend/src/assets/imagens/synapse-simbolo.png` para que o +container possa montar o diretório inteiro do tema em modo somente leitura. + +Em um realm criado anteriormente, selecione uma vez o tema no console: +**synapse → Realm settings → Themes → Login theme → synapse → Save**. Realms novos +recebem essa configuração pelo arquivo de importação. + +`KEYCLOAK_PUBLIC_URL` deve ser a mesma URL pública configurada em +`VITE_KEYCLOAK_URL`. A API busca as chaves pela rede Docker, mas valida o emissor +presente no token com essa URL pública. diff --git a/deploy/keycloak/realm-synapse.json b/deploy/keycloak/realm-synapse.json new file mode 100644 index 0000000..26a4d0b --- /dev/null +++ b/deploy/keycloak/realm-synapse.json @@ -0,0 +1,29 @@ +{ + "realm": "synapse", + "enabled": true, + "displayName": "Synapse", + "loginTheme": "synapse", + "defaultLocale": "pt-BR", + "supportedLocales": ["pt-BR"], + "roles": { + "realm": [ + { "name": "profissional-rh" }, + { "name": "auditor" } + ] + }, + "clients": [ + { + "clientId": "synapse-frontend", + "name": "Frontend Synapse", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": ["http://localhost:5173/*", "https://app.synnapse.pro/*"], + "attributes": { + "post.logout.redirect.uris": "http://localhost:5173/*##https://app.synnapse.pro/*" + }, + "webOrigins": ["http://localhost:5173", "https://app.synnapse.pro"] + } + ] +} diff --git a/deploy/keycloak/themes/synapse/login/login.ftl b/deploy/keycloak/themes/synapse/login/login.ftl new file mode 100644 index 0000000..95c6844 --- /dev/null +++ b/deploy/keycloak/themes/synapse/login/login.ftl @@ -0,0 +1,74 @@ + + + + + + + Entrar | Synapse + + + + +
+ + + + Synapse + Decision architecture + + + By Titus System +
+ +
+
+

Governança Cognitiva & Execução de Regras

+

Sua voz dita as regras.
A IA garante os resultados.

+

Converta diretrizes executivas estratégicas em lógica de negócio determinística. Simule impactos de risco e orçamentários antes do deploy em produção com auditoria matemática em tempo real.

+ +
    +
  • ◇ SOC2 Type II Certificado
  • +
  • ♧ Criptografia mTLS Ponta a Ponta
  • +
  • ● Isolamento Multi-Tenant Dedicado
  • +
+
+ +
+ +

Acesse sua conta

+

Ambiente seguro de governança executiva Synapse

+ + <#if message?has_content> +

${kcSanitize(message.summary)?no_esc}

+ + +
+ <#if !usernameHidden??> + + + + + + <#if realm.rememberMe && !usernameHidden??> + + + + +
+ +

Precisa de autorização? Solicitar onboarding executivo

+
+
+ + + + diff --git a/deploy/keycloak/themes/synapse/login/messages/messages_pt_BR.properties b/deploy/keycloak/themes/synapse/login/messages/messages_pt_BR.properties new file mode 100644 index 0000000..9d003b8 --- /dev/null +++ b/deploy/keycloak/themes/synapse/login/messages/messages_pt_BR.properties @@ -0,0 +1,12 @@ +loginAccountTitle=Entre na sua conta +username=Usuário +usernameOrEmail=Usuário ou e-mail +email=E-mail +password=Senha +rememberMe=Lembrar-me +doLogIn=Entrar +doForgotPassword=Esqueceu sua senha? +showPassword=Mostrar senha +hidePassword=Ocultar senha +noAccount=Não possui uma conta? +doRegister=Cadastre-se diff --git a/deploy/keycloak/themes/synapse/login/resources/css/synapse.css b/deploy/keycloak/themes/synapse/login/resources/css/synapse.css new file mode 100644 index 0000000..79389da --- /dev/null +++ b/deploy/keycloak/themes/synapse/login/resources/css/synapse.css @@ -0,0 +1,101 @@ +:root { color-scheme: dark; } +* { box-sizing: border-box; } + +body.synapse-login-page { + min-width: 320px; + height: 100dvh; + margin: 0; + overflow: hidden; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + color: #fff8f3; + background: radial-gradient(127.28% 124.45% at 10% 12%, rgb(232 123 70 / 8%) 0%, rgb(232 123 70 / 0%) 48%), radial-gradient(124.45% 115.97% at 88% 18%, rgb(200 90 40 / 6%) 0%, rgb(200 90 40 / 0%) 45%), radial-gradient(77.78% 127.28% at 45% 90%, rgb(142 60 22 / 5%) 0%, rgb(142 60 22 / 0%) 55%), #432b20; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; +} + +.synapse-topbar { + display: flex; + width: calc(100% - 6rem); + height: clamp(4.8rem, 10vh, 7.4rem); + align-items: flex-start; + justify-content: space-between; + margin: 0 auto; + padding-top: clamp(1rem, 2.5vh, 1.75rem); +} + +.synapse-brand { display: flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; } +.synapse-brand img { width: 2.4rem; height: 2.4rem; object-fit: contain; } +.synapse-brand span { display: grid; gap: .1rem; } +.synapse-brand strong { font-family: Georgia, Cambria, serif; font-size: 1.55rem; letter-spacing: -.035em; } +.synapse-brand small { color: #d15c17; font-size: .59rem; font-weight: 750; letter-spacing: .17em; text-transform: uppercase; } + +.synapse-status { display: inline-flex; align-items: center; gap: .45rem; padding: .52rem .9rem; border: none; border-radius: 999px; color: #391e13; background: #fff; font-size: .72rem; font-weight: 600; } +.synapse-status i { width: .48rem; height: .48rem; border-radius: 50%; background: #1bbb78; } + +.synapse-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(23rem, 27.5rem); width: calc(100% - 6rem); min-height: 0; height: 100%; align-items: center; gap: clamp(3rem, 10vw, 10rem); margin: 0 auto; padding: clamp(.75rem, 1.8vh, 1.2rem) 0; } +.synapse-hero { max-width: 53rem; } + +.synapse-pill { display: inline-flex; align-items: center; gap: .6rem; margin: 0 0 clamp(.9rem, 2vh, 1.6rem); padding: .58rem 1.15rem; border: 1px solid #efd5c7; border-radius: 999px; color: #9a3f0c; background: #fff8f5; box-shadow: 0 .3rem .75rem -.65rem rgb(69 32 17 / 24%); font-size: .95rem; font-weight: 650; line-height: 1; } +.synapse-pill span { display: grid; width: 1rem; height: 1rem; place-items: center; color: #a94710; } +.synapse-pill svg { width: 100%; height: 100%; fill: currentColor; } + +.synapse-hero h1 { max-width: 49rem; margin: 0; font-family: Georgia, Cambria, serif; font-size: clamp(2.4rem, 3.6vw, 4.25rem); font-weight: 400; line-height: 1.1; letter-spacing: -.02em; } +.synapse-hero h1 em { display: block; margin-top: .1rem; color: #f4761c; font-weight: 400; font-style: italic; } +.synapse-description { max-width: 43rem; margin: clamp(.9rem, 2vh, 1.45rem) 0 clamp(1rem, 2.2vh, 1.6rem); color: #e6d3c8; font-size: clamp(.88rem, 1.1vw, 1.05rem); line-height: 1.52; } + +/* Ajustes na caixa de preview baseados no Figma */ +.synapse-preview { background: #e8d6c8; padding: 1.25rem; border-radius: .95rem; } +.synapse-command { display: flex; align-items: center; gap: .85rem; padding: 1rem; border-radius: .5rem; background: #fff; color: #2e1a10; box-shadow: 0 4px 12px rgb(0 0 0 / 5%); margin-bottom: .75rem; } +.synapse-mic { display: grid; width: 2.2rem; height: 2.2rem; place-items: center; border-radius: 50%; color: #fff; background: #c65a0d; font-size: 1.25rem; } +.synapse-mic svg { width: 1.1rem; height: 1.1rem; } +.synapse-command-text { flex: 1; display: grid; gap: .2rem; margin: 0; font-size: .76rem; } +.synapse-command-text b { color: #c65a0d; font-size: .64rem; letter-spacing: .07em; text-transform: uppercase; } +.synapse-command-text p { margin: 0; font-style: italic; color: #5a3c2b; } +.synapse-command small { color: #a98d7b; font: .7rem ui-monospace, monospace; white-space: nowrap; font-weight: 600; } + +.synapse-metrics { display: flex; gap: .75rem; } +.synapse-metrics dl { flex: 1; display: grid; gap: .38rem; margin: 0; padding: 1rem; background: #fff; border-radius: .5rem; box-shadow: 0 4px 12px rgb(0 0 0 / 5%); } +.synapse-metrics dt { color: #a98d7b; font-size: .6rem; font-weight: 750; letter-spacing: .05em; text-transform: uppercase; } +.synapse-metrics dd { margin: 0; color: #2e1a10; font: 700 .89rem ui-monospace, monospace; } +.synapse-metrics dd small { color: #a98d7b; font: 600 .62rem Inter, sans-serif; } +.synapse-metrics .positive { color: #168455; } +.synapse-metrics .compliance { color: #c65a0d; font-family: Inter, sans-serif; } + +.synapse-trust { display: flex; flex-wrap: wrap; gap: clamp(1rem, 2vw, 2rem); margin: clamp(.9rem, 2vh, 1.55rem) 0 0; padding: 0; color: #d0bba9; list-style: none; font-size: .72rem; } +.synapse-trust li { display: flex; align-items: center; gap: .4rem; } +.synapse-trust li span { color: #d15c17; font-size: 1rem; } + +/* Ajustes no Card de Login baseados no Figma */ +.synapse-card { width: 480px; max-width: 480px; height: 600px; align-self: center; justify-self: end; padding: 3rem 2.25rem 2rem; border: 1px solid rgb(220 193 183 / 50%); border-radius: 24px; color: #2e1a10; background: #fff; box-shadow: 0 30px 70px -15px rgb(30 20 17 / 12%), 0 0 0 1px rgb(220 193 183 / 35%); } +.synapse-card-icon { display: grid; width: 2.7rem; height: 2.7rem; place-items: center; border: 1px solid #fce8db; border-radius: .5rem; color: #c65a0d; background: #fff4ed; } +.synapse-card-icon svg { width: 1.2rem; height: 1.2rem; } +.synapse-card h2 { margin: .95rem 0 .35rem; font-family: Georgia, Cambria, serif; font-size: clamp(1.65rem, 2vw, 2rem); font-weight: 400; letter-spacing: -.02em; } +.synapse-card-description { margin: 0 0 2.55rem; color: #8a776c; font-size: .78rem; } +.synapse-message { margin: 0 0 1rem; padding: .7rem .8rem; border-radius: .5rem; color: #8a3f1c; background: #fff0e9; font-size: .8rem; line-height: 1.4; } + +.synapse-form { display: grid; gap: 1.7rem; } +.synapse-field { display: grid; gap: .48rem; color: #2e1a10; font-size: .69rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; } +.synapse-input { position: relative; display: block; color: #8d7668; } +.synapse-input > svg { position: absolute; top: 50%; left: .85rem; width: 1rem; height: 1rem; pointer-events: none; transform: translateY(-50%); } +.synapse-input .synapse-password-eye { right: .85rem; left: auto; cursor: pointer; pointer-events: auto; } +.synapse-field input { width: 100%; height: clamp(2.45rem, 5vh, 2.7rem); padding: 0 2.55rem; border: 1px solid #e4cfc2; border-radius: .4rem; outline: none; color: #2e1a10; background: #fff; font: 400 .82rem Inter, sans-serif; letter-spacing: 0; text-transform: none; transition: border-color .16s; } +.synapse-field input::placeholder { color: #baa89e; } +.synapse-field input:focus { border-color: #be510b; } +.synapse-field-heading { display: flex; justify-content: space-between; gap: 1rem; } +.synapse-field-heading a { color: #a9490d; font-size: .67rem; font-weight: 500; text-transform: none; text-decoration: none; } +.synapse-remember { display: flex; align-items: center; gap: .55rem; color: #8a776c; font-size: .75rem; cursor: pointer; } +.synapse-remember input { width: 1rem; height: 1rem; margin: 0; accent-color: #c65a0d; } + +.synapse-submit { display: flex; width: 100%; height: clamp(2.55rem, 5vh, 2.8rem); align-items: center; justify-content: center; gap: .55rem; margin-top: .5rem; border: 0; border-radius: .4rem; cursor: pointer; color: #fff; background: linear-gradient(90deg, #5c3018 0%, #c65a0d 100%); font: 500 .85rem Inter, sans-serif; text-transform: none; transition: filter .16s; } +.synapse-submit:hover { filter: brightness(1.1); } +.synapse-onboarding { margin: 2.75rem 0 0; padding-top: 1.15rem; border-top: 1px solid #f4e9e2; color: #a98d7b; font-size: .72rem; text-align: center; } +.synapse-onboarding a { color: #c65a0d; font-weight: 600; text-decoration: none; } + +.synapse-footer { display: flex; width: calc(100% - 6rem); align-items: center; justify-content: space-between; gap: 1rem; margin: 0 auto; padding: clamp(.65rem, 1.5vh, 1.15rem) 0 clamp(.75rem, 1.8vh, 1.4rem); color: #8d6b56; font-size: .66rem; border-top: none; } +.synapse-footer nav { display: flex; flex-wrap: wrap; gap: 1.2rem; } +.synapse-footer a { color: inherit; text-decoration: none; } +.synapse-footer a:hover { color: #d0bba9; } + +@media (max-width: 1050px) { .synapse-layout { grid-template-columns: minmax(0, 1fr) minmax(23rem, 30rem); } } +@media (min-width: 821px) { .synapse-layout { gap: clamp(3rem, 4vw, 5rem); } .synapse-hero h1 { max-width: none; } .synapse-hero h1 em { white-space: nowrap; } } +@media (max-width: 820px) { body.synapse-login-page { display: block; height: auto; min-height: 100dvh; overflow-y: auto; } .synapse-topbar, .synapse-layout, .synapse-footer { width: min(100% - 2.5rem, 32rem); } .synapse-topbar { height: auto; min-height: 5.8rem; padding-top: 1.25rem; } .synapse-layout { grid-template-columns: 1fr; min-height: auto; height: auto; padding: 1rem 0 2rem; } .synapse-hero { max-width: none; } .synapse-card { width: 100%; max-width: none; height: auto; min-height: 0; } .synapse-footer { align-items: flex-start; flex-direction: column; } .synapse-metrics { flex-direction: column; } } diff --git a/deploy/keycloak/themes/synapse/login/resources/img/synapse-simbolo.png b/deploy/keycloak/themes/synapse/login/resources/img/synapse-simbolo.png new file mode 100644 index 0000000..1c8fb87 Binary files /dev/null and b/deploy/keycloak/themes/synapse/login/resources/img/synapse-simbolo.png differ diff --git a/deploy/keycloak/themes/synapse/login/resources/img/synapse.ico b/deploy/keycloak/themes/synapse/login/resources/img/synapse.ico new file mode 100644 index 0000000..2cc0862 Binary files /dev/null and b/deploy/keycloak/themes/synapse/login/resources/img/synapse.ico differ diff --git a/deploy/keycloak/themes/synapse/login/theme.properties b/deploy/keycloak/themes/synapse/login/theme.properties new file mode 100644 index 0000000..31b3a9a --- /dev/null +++ b/deploy/keycloak/themes/synapse/login/theme.properties @@ -0,0 +1,3 @@ +parent=keycloak.v2 +import=common/keycloak +styles=css/synapse.css diff --git a/deploy/scripts/requirements.txt b/deploy/scripts/requirements.txt index 3aeeab8..9f5816b 100644 --- a/deploy/scripts/requirements.txt +++ b/deploy/scripts/requirements.txt @@ -1,2 +1 @@ psycopg2-binary==2.9.10 -bcrypt==5.0.0 diff --git a/deploy/scripts/seed.py b/deploy/scripts/seed.py index 580d709..d41cbb2 100644 --- a/deploy/scripts/seed.py +++ b/deploy/scripts/seed.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 # Popula um banco synapse_db local com dados de demonstração: -# - as contas develop@synapse.com e staging@synapse.com (usuarios); +# - as contas develop@synapse.pro e staging@synapse.pro no Keycloak e em usuarios; # - quatro jobs de ponta a ponta (submissao -> regra -> código gerado -> # resultado -> simulação), um para cada desfecho de resultados_simulacao: -# viável, inviável, inviável com sugestão de adaptação e assercao_violada. +# viável, inviável, inviável com sugestão de adaptação e assercao_violada; +# - três jobs viáveis aguardando decisão, pertencentes a staging@synapse.pro. # # Conecta como o dono do schema (POSTGRES_USER/POSTGRES_PASSWORD) para poder # escrever em todas as tabelas, incluindo as de produtor único (prompts, @@ -13,15 +14,16 @@ # # Uso: # pip install -r deploy/scripts/requirements.txt -# POSTGRES_PASSWORD=... SEED_USERS_PASSWORD=... python deploy/scripts/seed.py +# POSTGRES_PORT=... POSTGRES_PASSWORD=... \ +# KEYCLOAK_ADMIN_PASSWORD=... SEED_USERS_PASSWORD=... \ +# python deploy/scripts/seed.py # -# Idempotente: os ids são derivados deterministicamente (uuid5) da chave de -# cada cenário, e todo INSERT usa ON CONFLICT (id) DO NOTHING. Rodar de novo -# não duplica dados nem falha. +# Idempotente: os ids dos cenários são derivados deterministicamente (uuid5); +# contas existentes são reutilizadas e os demais INSERTs usam ON CONFLICT (id) +# DO NOTHING. Rodar de novo não duplica os dados de demonstração. # -# A senha de login das duas contas nunca fica hardcoded aqui: vem de -# SEED_USERS_PASSWORD, lida em tempo de execução, seguindo a regra do -# api/AGENTS.md que proíbe segredo real em arquivo versionado. +# A senha das contas é definida apenas no Keycloak a partir de +# SEED_USERS_PASSWORD; o banco armazena o sub do Keycloak, sem hash de senha. from __future__ import annotations import hashlib @@ -31,16 +33,16 @@ import uuid from dataclasses import dataclass, replace from datetime import datetime, timedelta, timezone +from urllib import error, parse, request -import bcrypt import psycopg2 import psycopg2.extras -REQUIRED_ENV_VARS = ("SEED_USERS_PASSWORD",) +REQUIRED_ENV_VARS = ("POSTGRES_PORT", "KEYCLOAK_ADMIN_PASSWORD", "SEED_USERS_PASSWORD") SEED_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://synapse.local/deploy/scripts/seed.py") -# Deslocamentos, em minutos a partir de jobs.criado_em, comuns aos três +# Deslocamentos, em minutos a partir de jobs.criado_em, comuns aos # cenários: regra confirmada, chamada ao modelo, código extraído e início da # simulação sempre seguem essa mesma micro-sequência interna. REGRA_OFFSET = 3 @@ -50,22 +52,156 @@ SIMULACAO_OFFSET = 11 UPSERT_USUARIO_SQL = """ - INSERT INTO usuarios (login, senha_hash, nome, papel, ativo, criado_em) - VALUES (%(login)s, %(senha_hash)s, %(nome)s, %(papel)s, true, now()) + INSERT INTO usuarios (login, senha_hash, keycloak_sub, nome, papel, ativo, criado_em) + VALUES (%(login)s, NULL, %(keycloak_sub)s, %(nome)s, %(papel)s, true, now()) ON CONFLICT (login) DO UPDATE SET - senha_hash = EXCLUDED.senha_hash, + senha_hash = NULL, + keycloak_sub = EXCLUDED.keycloak_sub, nome = EXCLUDED.nome, papel = EXCLUDED.papel, atualizado_em = now() + WHERE usuarios.ativo = true + AND (usuarios.keycloak_sub IS NULL OR usuarios.keycloak_sub = EXCLUDED.keycloak_sub) RETURNING id """ USUARIOS = ( - {"login": "develop@synapse.com", "nome": "Desenvolvimento", "papel": "profissional_rh"}, - {"login": "staging@synapse.com", "nome": "Staging", "papel": "auditor"}, + { + "login": "develop@synapse.pro", + "nome": "Desenvolvimento", + "papel": "profissional_rh", + "papel_keycloak": "profissional-rh", + }, + { + "login": "staging@synapse.pro", + "nome": "Staging", + "papel": "auditor", + "papel_keycloak": "auditor", + }, ) +class KeycloakAdmin: + def __init__(self, url: str, realm: str, usuario: str, senha: str): + self.url = url.rstrip("/") + self.realm = parse.quote(realm, safe="") + self.token: str | None = None + _, resposta = self.requisitar( + "POST", + "/realms/master/protocol/openid-connect/token", + formulario={ + "grant_type": "password", + "client_id": "admin-cli", + "username": usuario, + "password": senha, + }, + ) + if not isinstance(resposta, dict) or not resposta.get("access_token"): + raise RuntimeError("Keycloak não devolveu um token de administração.") + self.token = resposta["access_token"] + + def requisitar(self, metodo: str, caminho: str, *, json_body=None, formulario=None): + cabecalhos = {"Accept": "application/json"} + if self.token: + cabecalhos["Authorization"] = f"Bearer {self.token}" + dados = None + if json_body is not None: + dados = json.dumps(json_body).encode("utf-8") + cabecalhos["Content-Type"] = "application/json" + elif formulario is not None: + dados = parse.urlencode(formulario).encode("utf-8") + cabecalhos["Content-Type"] = "application/x-www-form-urlencoded" + requisicao = request.Request( + self.url + caminho, data=dados, headers=cabecalhos, method=metodo + ) + try: + with request.urlopen(requisicao, timeout=10) as resposta: + conteudo = resposta.read() + return resposta.status, json.loads(conteudo) if conteudo else None + except error.HTTPError as exc: + raise RuntimeError( + f"Keycloak recusou {metodo} {caminho}: HTTP {exc.code}." + ) from None + except error.URLError as exc: + raise RuntimeError(f"Keycloak indisponível em {self.url}.") from exc + + def buscar_usuario(self, login: str) -> dict | None: + filtro = parse.urlencode({"username": login, "exact": "true"}) + _, encontrados = self.requisitar( + "GET", f"/admin/realms/{self.realm}/users?{filtro}" + ) + correspondentes = [ + usuario for usuario in encontrados if usuario.get("username") == login + ] + if not correspondentes: + return None + usuario_id = parse.quote(correspondentes[0]["id"], safe="") + _, usuario = self.requisitar( + "GET", f"/admin/realms/{self.realm}/users/{usuario_id}" + ) + return usuario + + def provisionar_usuario(self, usuario: dict, senha: str) -> str: + login = usuario["login"] + existente = self.buscar_usuario(login) + if existente is None: + self.requisitar( + "POST", + f"/admin/realms/{self.realm}/users", + json_body={ + "username": login, + "email": login, + "firstName": usuario["nome"], + "enabled": True, + }, + ) + existente = self.buscar_usuario(login) + if existente is None or not existente.get("id"): + raise RuntimeError(f"Keycloak não retornou o usuário {login} após a criação.") + if not existente.get("enabled"): + raise RuntimeError(f"Usuário {login} está desabilitado no Keycloak.") + + usuario_id = parse.quote(existente["id"], safe="") + _, papeis = self.requisitar( + "GET", f"/admin/realms/{self.realm}/users/{usuario_id}/role-mappings/realm" + ) + papel = usuario["papel_keycloak"] + outro_papel = "auditor" if papel == "profissional-rh" else "profissional-rh" + nomes = {item["name"] for item in papeis} + if outro_papel in nomes: + raise RuntimeError(f"Usuário {login} já possui o papel conflitante {outro_papel}.") + if papel not in nomes: + _, representacao = self.requisitar( + "GET", f"/admin/realms/{self.realm}/roles/{parse.quote(papel, safe='')}" + ) + self.requisitar( + "POST", + f"/admin/realms/{self.realm}/users/{usuario_id}/role-mappings/realm", + json_body=[representacao], + ) + self.requisitar( + "PUT", + f"/admin/realms/{self.realm}/users/{usuario_id}/reset-password", + json_body={"type": "password", "value": senha, "temporary": False}, + ) + return existente["id"] + + +def seed_keycloak_usuarios() -> dict[str, str]: + admin = KeycloakAdmin( + os.environ.get("KEYCLOAK_PUBLIC_URL", "http://localhost:8081"), + os.environ.get("KEYCLOAK_REALM", "synapse"), + os.environ.get("KEYCLOAK_ADMIN_USERNAME", "admin"), + os.environ["KEYCLOAK_ADMIN_PASSWORD"], + ) + return { + usuario["login"]: admin.provisionar_usuario( + usuario, os.environ["SEED_USERS_PASSWORD"] + ) + for usuario in USUARIOS + } + + # --------------------------------------------------------------------------- # Cenários de demonstração # --------------------------------------------------------------------------- @@ -140,7 +276,7 @@ def cenarios() -> list[Cenario]: viavel = Cenario( chave="job-viavel", - usuario_login="develop@synapse.com", + usuario_login="develop@synapse.pro", base=datetime(2025, 11, 20, 9, 0, tzinfo=timezone.utc), vigencia_inicio="2025-11", vigencia_fim="2025-11", @@ -225,7 +361,7 @@ def cenarios() -> list[Cenario]: inviavel = Cenario( chave="job-inviavel", - usuario_login="develop@synapse.com", + usuario_login="develop@synapse.pro", base=datetime(2025, 11, 21, 9, 0, tzinfo=timezone.utc), vigencia_inicio="2025-11", vigencia_fim="2025-11", @@ -311,7 +447,7 @@ def cenarios() -> list[Cenario]: assercao_violada = Cenario( chave="job-assercao-violada", - usuario_login="staging@synapse.com", + usuario_login="develop@synapse.pro", base=datetime(2025, 12, 2, 9, 0, tzinfo=timezone.utc), vigencia_inicio="2025-12", vigencia_fim="2025-12", @@ -404,7 +540,26 @@ def cenarios() -> list[Cenario]: acao=None, ) - return [viavel, inviavel, inviavel_com_sugestao, assercao_violada] + aguardando_decisao_staging = [ + replace( + viavel, + chave=f"job-staging-aguardando-decisao-{indice}", + usuario_login="staging@synapse.pro", + base=datetime(2025, 11, 24, 9, 0, tzinfo=timezone.utc) + + timedelta(days=indice - 1), + transicoes=viavel.transicoes[:-1], + acao=None, + ) + for indice in range(1, 4) + ] + + return [ + viavel, + inviavel, + inviavel_com_sugestao, + assercao_violada, + *aguardando_decisao_staging, + ] # --------------------------------------------------------------------------- @@ -525,7 +680,7 @@ def insert(cur, table: str, row: dict) -> None: ) -def seed_usuarios(cur, senha_hash: str) -> dict[str, uuid.UUID]: +def seed_usuarios(cur, keycloak_ids: dict[str, str]) -> dict[str, uuid.UUID]: ids: dict[str, uuid.UUID] = {} for usuario in USUARIOS: cur.execute( @@ -534,10 +689,15 @@ def seed_usuarios(cur, senha_hash: str) -> dict[str, uuid.UUID]: "login": usuario["login"], "nome": usuario["nome"], "papel": usuario["papel"], - "senha_hash": senha_hash, + "keycloak_sub": keycloak_ids[usuario["login"]], }, ) - ids[usuario["login"]] = cur.fetchone()[0] + linha = cur.fetchone() + if linha is None: + raise RuntimeError( + f"Conta local {usuario['login']} está inativa ou vinculada a outro usuário Keycloak." + ) + ids[usuario["login"]] = linha[0] return ids @@ -585,11 +745,26 @@ def seed_cenario(cur, cenario: Cenario, usuario_ids: dict[str, uuid.UUID]) -> No "orcamento": cenario.orcamento, "criado_em": criado_em, "iniciado_em": criado_em + timedelta(minutes=cenario.transicoes[1].minutos), - "finalizado_em": criado_em + timedelta(minutes=cenario.transicoes[-1].minutos), + "finalizado_em": ( + None + if status_final == "aguardando_decisao_usuario" + else criado_em + timedelta(minutes=cenario.transicoes[-1].minutos) + ), "tentativas": 0, }, ) + # Um seed anterior podia atribuir este cenário à conta de staging. Corrige + # a posse apenas das linhas com os ids determinísticos deste cenário. + cur.execute( + "UPDATE submissoes SET usuario_id = %s WHERE id = %s AND usuario_id IS DISTINCT FROM %s", + (adapt(usuario_id), adapt(ids["submissao"]), adapt(usuario_id)), + ) + cur.execute( + "UPDATE jobs SET usuario_id = %s WHERE id = %s AND usuario_id IS DISTINCT FROM %s", + (adapt(usuario_id), adapt(ids["job"]), adapt(usuario_id)), + ) + for transicao in cenario.transicoes: insert( cur, @@ -784,10 +959,6 @@ def seed_cenario(cur, cenario: Cenario, usuario_ids: dict[str, uuid.UUID]) -> No # --------------------------------------------------------------------------- -def hash_password(password: str) -> str: - return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") - - def connect(): return psycopg2.connect( host=os.environ.get("POSTGRES_HOST", "localhost"), @@ -804,19 +975,25 @@ def main() -> None: print(f"seed: variáveis obrigatórias ausentes: {', '.join(missing)}", file=sys.stderr) raise SystemExit(1) - senha_hash = hash_password(os.environ["SEED_USERS_PASSWORD"]) connection = connect() try: + # Confere o banco e as migrations antes de redefinir senhas no Keycloak. + with connection.cursor() as cur: + cur.execute("SELECT 1 FROM usuarios LIMIT 0") + connection.rollback() + + keycloak_ids = seed_keycloak_usuarios() with connection, connection.cursor() as cur: - usuario_ids = seed_usuarios(cur, senha_hash) + usuario_ids = seed_usuarios(cur, keycloak_ids) for cenario in cenarios(): seed_cenario(cur, cenario, usuario_ids) finally: connection.close() print( - "seed: usuários (develop@synapse.com, staging@synapse.com) e pipeline de " - "demonstração prontos (4 jobs: viável, inviável, inviável com sugestão, assercao_violada)" + "seed: contas Keycloak e locais (develop@synapse.pro, staging@synapse.pro) e pipeline de " + "demonstração prontos (7 jobs: viável, inviável, inviável com sugestão, " + "assercao_violada e 3 aguardando_decisao_usuario para staging@synapse.pro)" ) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 343ce88..72e8606 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -333,9 +333,9 @@ Como o pipeline trata a entrada em geração de código como a fronteira entre a **Aspectos técnicos a definir pela equipe de frontend:** - Gerenciamento de estado (ex.: Pinia) para o estado do job corrente (parâmetros, progresso, resultado) e para sessão/autenticação. -- Roteamento (Vue Router) entre as telas acima, com guarda de rota autenticada: o login envia credencial à API, que devolve a sessão; o guarda barra as rotas sem sessão válida e as que o papel do usuário não autoriza. -- Cliente SSE com reconexão automática (o job pode durar minutos; a conexão pode cair) e deduplicação de eventos. -- Cliente REST para as ações síncronas (submissão, consulta de histórico, ações de finalização). +- Roteamento (Vue Router) entre as telas acima, com guarda de rota autenticada: o Keycloak autentica via OIDC com PKCE, o frontend mantém os tokens apenas em memória e recupera a sessão por SSO silencioso ao recarregar. A guarda exige sessão autenticada; a aplicação das permissões por papel segue a tarefa do middleware de autorização. +- Cliente SSE via `fetch`, com `Authorization: Bearer`, reconexão automática e deduplicação de resultados. Cada abertura do stream aguarda a renovação do token pelo adaptador Keycloak. +- Cliente REST para as ações síncronas (submissão, consulta de histórico, ações de finalização), usando o mesmo transporte autenticado. Antes de enviar a requisição, o cliente chama `updateToken(30)`; uma falha transitória de renovação preserva a sessão e permite tentar novamente. Respostas 401 reiniciam o login. - Validação client-side complementar (não substitui a validação da API) para reduzir round-trips óbvios. - Acessibilidade e clareza de mensagens - requisito não funcional do parceiro é que o sistema seja usável por quem "não tem domínio de tecnologia". @@ -362,7 +362,8 @@ Como o pipeline trata a entrada em geração de código como a fronteira entre a - **Gestão de SSE** - mapeamento `job_id → emissores conectados`, para repassar cada evento de progresso consumido do RabbitMQ ao(s) cliente(s) Frontend inscritos naquele job. Também é o que sustenta a retenção do trabalho na saída abrupta (US07, cenário 2): o job vive na API, não na aba do navegador, então fechar a página não descarta o processamento - o Frontend apenas alerta antes de sair e reencontra o job no histórico. - **Persistência (Spring Data JPA)** - repositórios da tabela `job` e das tabelas relacionadas (ver seção 5), reaproveitados pelas fatias que precisam. Inclui os artefatos que a própria API produz: áudio (`bytea`) e transcrição, em tabela separada das de consulta frequente para não pesar o dia a dia. - **Dono das migrations** - o schema é único e a API é quem o versiona, via Liquibase (seção 5), inclusive as tabelas que Worker e codegen escrevem. Eles inserem; não criam nem alteram estrutura. -- **Segurança** - Spring Security com autenticação local: a API é dona das credenciais e da sessão. A senha é guardada apenas como hash de KDF, nunca em claro. A autorização é por papel único, sem RBAC fino — a matriz de papel × ação está na DEC-087, que também fixa os dois papéis existentes: profissional de RH e auditor. Não há provedor de identidade externo nem SSO: o MVP não precisa de federação, e a granularidade de permissão que o produto exige cabe em dois papéis. +- **Autenticação** - O [ADR-006](adrs/ADR-006.md) adota o Keycloak como provedor OIDC, responsável pelas credenciais e pela sessão. Com autenticação habilitada, a API atua como resource server do Spring Security: valida assinatura, emissor e validade do JWT recebido como Bearer nas chamadas REST e SSE. A API mantém o usuário local e seu vínculo com jobs pelo claim `sub`, persistido em `usuarios.keycloak_sub`. +- **Autorização** - A [DEC-087](decisoes/dec-087.md) mantém os papéis profissional de RH e auditor, com o auditor em função investigativa. Existem checagens de posse em controllers, mas a cobertura uniforme de todas as operações, as permissões de mutação e o tratamento de acesso negado ainda pertencem à tarefa específica do middleware de autorização. A adoção do Keycloak não conclui essa tarefa. **Pontos de atenção para o desenvolvimento:** diff --git a/docs/adrs/ADR-003.md b/docs/adrs/ADR-003.md index 8c48b5b..7b54b99 100644 --- a/docs/adrs/ADR-003.md +++ b/docs/adrs/ADR-003.md @@ -1,5 +1,7 @@ # ADR-003 - Detalhamento dos componentes de desenvolvimento +> A decisão de autenticação própria da API foi substituída pelo [ADR-006](ADR-006.md), que adota o Keycloak. + ## Como ler este documento Cada componente (seções 1.1 a 1.4) segue o mesmo roteiro: @@ -53,8 +55,8 @@ Módulos transversais às telas, que sustentam estado, navegação e comunicaç | Módulo | Função | Detalhe | | --- | --- | --- | | Store de estado | Guarda o estado do job corrente (parâmetros, progresso, resultado) e a sessão/autenticação | Ex.: Pinia | -| Router + guarda de rota | Navegação entre as telas, com guarda de rota autenticada | Vue Router; a sessão vem da API após login com credencial, e o guarda barra rota sem sessão válida ou fora do papel do usuário | -| Cliente SSE | Recebe os eventos de progresso do job | Reconexão automática (o job dura minutos, a conexão pode cair) e deduplicação de eventos | +| Router + guarda de rota | Navegação entre as telas, com guarda de rota autenticada | Vue Router; login OIDC com PKCE no Keycloak, tokens em memória e recuperação por SSO silencioso. A guarda exige sessão autenticada; a autorização por papel pertence à tarefa do middleware (ADR-006 e DEC-087) | +| Cliente SSE | Recebe os eventos de progresso do job | `fetch` com Bearer, renovação do token antes de cada conexão, reconexão automática e deduplicação de resultados | | Cliente REST | Ações síncronas | Submissão, consulta de histórico, ações de finalização | | Validação client-side | Reduz round-trips óbvios | **Complementar** - não substitui a validação da API | @@ -64,7 +66,7 @@ Módulos transversais às telas, que sustentam estado, navegação e comunicaç | --- | --- | --- | | API | REST | Ações síncronas: submissão da regra, confirmação de parâmetros, histórico, finalização | | API | SSE | Acompanhamento em tempo real do job (seção 6.4) | -| API | REST | Login com credencial e obtenção da sessão usada nas demais chamadas | +| Keycloak | OIDC com PKCE | Login, logout, SSO e renovação dos tokens usados nas chamadas REST e SSE à API | ### Decisões-chave @@ -75,7 +77,7 @@ Módulos transversais às telas, que sustentam estado, navegação e comunicaç ### A definir pela equipe - Biblioteca de gerenciamento de estado (ex.: Pinia) e modelagem do store do job. -- Forma de transporte e renovação da sessão devolvida pela API. +- Aplicação uniforme das permissões por papel e da posse dos jobs no middleware de autorização, conforme a DEC-087. O transporte usa Bearer e a renovação dos tokens é feita pelo adaptador Keycloak (ADR-006). - Estratégia de reconexão e deduplicação de eventos no cliente SSE. - Escopo da validação client-side complementar. @@ -114,13 +116,14 @@ Porta de entrada única do sistema; dona do ciclo de vida e do estado do `job`; | **Gestão de SSE** | Mapeamento `job_id → emissores conectados` | Repassa cada evento de progresso consumido do RabbitMQ ao(s) cliente(s) Frontend inscritos naquele job. Também sustenta a retenção do trabalho na saída abrupta (US07, cenário 2): o job vive na API, não na aba do navegador. | | **Persistência (Spring Data JPA)** | Repositórios da tabela `job` e das tabelas relacionadas (seção 5), reaproveitados pelas fatias que precisam | Inclui os artefatos que a própria API produz: áudio (`bytea`) e transcrição, em tabela separada das de consulta frequente para não pesar o dia a dia. | | **Dono das migrations** | O schema é único e a API é quem o versiona | Via Liquibase em Formatted SQL (seção 5), inclusive as tabelas que Worker e codegen escrevem. Eles inserem; não criam nem alteram estrutura. Um `.sql` por tabela sob um changelog raiz em YAML que só encadeia. | -| **Segurança** | Autenticação local e autorização por papel | Spring Security; a API é dona das credenciais e da sessão, e guarda a senha apenas como hash de KDF. Papel único por usuário, sem RBAC fino: profissional de RH e auditor, com a matriz de papel × ação na DEC-087. Sem provedor de identidade externo nem SSO. | +| **Segurança** | Autenticação OIDC com Keycloak; autorização por papel e posse na API | Conforme o [ADR-006](ADR-006.md), o Keycloak mantém credenciais e sessão. Spring Security valida o JWT, e a API vincula o `sub` ao usuário local. A matriz da DEC-087 permanece vigente; sua aplicação uniforme é trabalho pendente do middleware de autorização. | ### Integrações | Contraparte | Canal | Uso | | --- | --- | --- | -| Frontend | REST + SSE | Ações síncronas e stream de progresso (seção 6.4) | +| Frontend | REST + SSE | Ações síncronas e stream de progresso com access token Bearer (seção 6.4) | +| Keycloak | OIDC / JWKS | Emissor dos JWTs; a API busca as chaves públicas e valida assinatura, emissor e validade | | codegen | RabbitMQ | Produz `regra-submetida`, `parametros-confirmados`; consome `no-concluido`, `etapa-alterada` | | Worker | RabbitMQ (exchange fanout) | Consome `simulacao-concluida` | | PostgreSQL | JDBC (usuário próprio, acesso total) | Dona do estado, do histórico e das migrations; grava áudio e transcrição; lê todos os artefatos (seção 6.2) | diff --git a/docs/adrs/ADR-006.md b/docs/adrs/ADR-006.md new file mode 100644 index 0000000..7f2d9e9 --- /dev/null +++ b/docs/adrs/ADR-006.md @@ -0,0 +1,44 @@ +# ADR-006 — Keycloak como provedor de identidade + +- Status: aceito +- Data: 2026-09-20 +- Decisor: responsável pelo projeto + +## Contexto + +O frontend precisa de login, guarda de rota e logout, e a API precisa validar a +identidade de quem acessa jobs. Manter senha e sessão próprias duplicaria funções +de um provedor de identidade e ampliaria o escopo de segurança da API. + +Esta decisão substitui a autenticação local anteriormente descrita no +[ADR-003](ADR-003.md) e na seção 3.2 da [arquitetura](../ARCHITECTURE.md). + +## Decisão + +O Keycloak é o provedor OIDC do Synapse. O frontend é um cliente público com PKCE, +mantém o token em memória e recupera a sessão por SSO silencioso. A API atua como +resource server, valida JWTs emitidos pelo realm `synapse` e associa o claim `sub` +ao usuário local por `usuarios.keycloak_sub`. O Keycloak sobe em Docker no +desenvolvimento. + +REST e SSE enviam o access token no cabeçalho `Authorization: Bearer`. O cliente +aguarda `updateToken(30)` antes de cada requisição e de cada abertura ou reconexão +do stream. Uma falha transitória de renovação não descarta a sessão nem o +formulário; uma resposta 401 reinicia o login. + +## Autorização + +A [DEC-087](../decisoes/dec-087.md) continua definindo as permissões por papel. +A cobertura uniforme da posse dos jobs, das operações permitidas ao auditor e +das respostas de acesso negado será concluída na tarefa específica do middleware +de autorização. As checagens existentes nos controllers não encerram essa tarefa. + +## Consequências + +- Login, logout e expiração de sessão seguem o fluxo OIDC, sem `POST /auth/login` + proprietário na API. +- Tokens não são persistidos no navegador. +- Usuários autenticados pela primeira vez ganham uma conta local de papel + `profissional_rh`; o papel `auditor` do realm pode consultar jobs de terceiros. +- O Keycloak passa a ser uma dependência operacional da API nos ambientes com + autenticação habilitada. diff --git a/docs/database/modelo-dados.dbml b/docs/database/modelo-dados.dbml index 9777137..44e1339 100644 --- a/docs/database/modelo-dados.dbml +++ b/docs/database/modelo-dados.dbml @@ -37,11 +37,12 @@ Table usuarios { id uuid [pk, default: `uuidv7()`] - login text [not null, unique, note: 'credencial de acesso'] - senha_hash text [not null, note: 'hash derivado da senha (bcrypt/argon2); nunca a senha em claro'] + login text [not null, unique, note: 'identificador exibido da conta no Keycloak'] + senha_hash text [note: 'legado de autenticação local; nulo para contas do Keycloak'] + keycloak_sub text [unique, note: 'claim sub do Keycloak; identifica a conta de forma estável'] nome text [not null, note: 'nome de exibicao'] papel text [not null, note: 'profissional_rh | auditor (DEC-087)'] - ativo bool [not null, default: true, note: 'desativacao logica; false impede o login'] + ativo bool [not null, default: true, note: 'desativacao logica; false impede a resolucao da conta local pela API, sem encerrar a sessao no Keycloak'] criado_em timestamptz [not null] atualizado_em timestamptz [note: 'null ate a primeira alteracao'] ultimo_login_em timestamptz [note: 'null ate o primeiro acesso'] diff --git a/docs/database/modelo-dados.md b/docs/database/modelo-dados.md index fe99127..5a5234e 100644 --- a/docs/database/modelo-dados.md +++ b/docs/database/modelo-dados.md @@ -16,7 +16,11 @@ São catorze, num schema único. Cada uma aparece abaixo com o que guarda e, qua ### `usuarios` -Quem entra no sistema. A autenticação é local, feita pela própria API, sem provedor de identidade externo. Usuário desativado mantém a linha, porque jobs antigos a referenciam. +A identidade é autenticada pelo Keycloak, conforme o [ADR-006](../adrs/ADR-006.md). A API mantém esta conta local para relacionar o usuário às submissões, aos jobs e à trilha de auditoria. `keycloak_sub` armazena o claim `sub` e tem unicidade; `senha_hash` é um campo legado, opcional, preenchido com `NULL` nas novas contas do Keycloak. + +Ao resolver a identidade autenticada, a API busca o `sub` ou um registro legado com `keycloak_sub` nulo e `login` igual ao `preferred_username`. Se não encontrar uma conta, cria uma com papel local `profissional_rh`. O indicador de auditor é lido de `realm_access.roles` no token; esse processo não sincroniza o papel do realm com a coluna `papel`. + +Usuário desativado mantém a linha porque jobs antigos a referenciam. `ativo = false` impede a resolução dessa conta local pela API, mas não encerra a sessão no Keycloak. A cobertura uniforme das operações depende do middleware de autorização descrito no ADR-006. ### `submissoes` diff --git a/docs/deploy-staging.md b/docs/deploy-staging.md index 4123aed..e5924eb 100644 --- a/docs/deploy-staging.md +++ b/docs/deploy-staging.md @@ -1,6 +1,6 @@ # Configuração do deploy de staging (droplet + CD) -Este guia cobre o passo a passo único, feito uma vez por droplet, para deixar os workflows de CD (`.github/workflows/cd-*.yml` - um por componente, mais `cd-gateway.yml` e `cd-observability.yml`) prontos para implantar em staging. Ele assume um único droplet rodando todos os serviços na mesma rede Docker (`synapse-net`), conforme decidido para a fase atual do projeto. +Este guia cobre o passo a passo único, feito uma vez por droplet, para deixar os workflows de CD (`.github/workflows/cd-*.yml` - um por componente, mais `cd-gateway.yml`, `cd-keycloak.yml` e `cd-observability.yml`) prontos para implantar em staging. Ele assume um único droplet rodando todos os serviços na mesma rede Docker (`synapse-net`), conforme decidido para a fase atual do projeto. Todos os serviços têm subdomínio próprio, atrás de um único gateway (Caddy) que termina TLS - ver a seção 2.1 para o que isso exige de DNS antes do primeiro deploy. @@ -25,18 +25,19 @@ Para o funcionamento normal do ambiente (subir/parar containers, healthchecks, t - Um usuário com permissão de `docker` (grupo `docker`) e acesso SSH por chave. - Portas liberadas no firewall do droplet: - `22` (SSH, só para o CD e administração) - considere restringir por IP se possível. - - `80`/`443` (gateway - único serviço que fala com a internet; termina TLS pros quatro subdomínios). + - `80`/`443` (gateway - único serviço que fala com a internet; termina TLS para a aplicação e o Keycloak). - **Não** exponha `5432`/`5672`/`15672` publicamente por enquanto - não há necessidade de acesso remoto de devs à infraestrutura de staging nesta fase. - `api`, `codegen` e `worker` não precisam de porta liberada no firewall externo - só são alcançados via o gateway, dentro da `synapse-net`. ### 2.1. DNS dos subdomínios -Antes de subir o gateway pela primeira vez, aponte os quatro registros DNS (tipo `A`, ou `AAAA` se o droplet tiver IPv6) para o IP do droplet: +Antes de subir o gateway pela primeira vez, aponte os registros DNS (tipo `A`, ou `AAAA` se o droplet tiver IPv6) para o IP do droplet: - `GATEWAY_APP_DOMAIN` (ex.: `app.`) - `GATEWAY_API_DOMAIN` (ex.: `api.`) - `GATEWAY_CODEGEN_DOMAIN` (ex.: `codegen.`) - `GATEWAY_WORKER_DOMAIN` (ex.: `worker.`) +- `auth.synnapse.pro`, para o Keycloak. O Caddy do gateway pede um certificado Let's Encrypt por domínio na primeira subida (HTTP-01 challenge) - se o DNS ainda não resolver pro droplet nesse momento, a emissão falha e o serviço correspondente fica sem HTTPS até você repetir a subida com o DNS já propagado. @@ -77,6 +78,14 @@ Preencha em `deploy/.env`: - `VITE_API_BASE_URL`, com a URL completa do domínio da api (ex.: `https://api.exemplo.com`) - com subdomínio próprio por serviço, um caminho relativo (`/api`) não atravessa origem. - `CORS_ALLOWED_ORIGINS`, com a URL do frontend (ex.: `https://app.exemplo.com`) - `app.` e `api.` são origens diferentes, então o navegador bloqueia toda chamada do frontend que não venha de uma origem liberada aqui. Só esquema + host (+ porta), sem caminho nem barra final; mais de uma origem, separadas por vírgula. +Para o Keycloak, configure também `KEYCLOAK_ADMIN_PASSWORD`, +`KEYCLOAK_PUBLIC_URL=https://auth.synnapse.pro`, +`VITE_KEYCLOAK_URL=https://auth.synnapse.pro` e +`KEYCLOAK_ISSUER_URI=https://auth.synnapse.pro/realms/synapse`. Mantenha a busca de +chaves pela rede Docker e ajuste os redirecionamentos do cliente conforme o +[guia do Keycloak](../deploy/keycloak/README.md#staging-e-cd). O gateway publica +`auth.synnapse.pro` por HTTPS; a porta direta do Keycloak fica restrita ao host. + Esse arquivo é `.gitignore`d e fica só no droplet; o CD não o sobrescreve (só adiciona/atualiza a linha `TAG=`, usada para etiquetar a imagem pelo SHA do commit implantado). ### 3.4. Subida inicial manual @@ -134,8 +143,8 @@ Em **Settings → Environments → staging → Environment secrets**: ## 6. Validar o primeiro deploy automático -1. Faça um push (ou merge de PR) para `staging` que toque em algum dos diretórios `api/`, `frontend/`, `codegen/`, `worker/`, `deploy/gateway/` ou `deploy/observability/`. -2. Acompanhe a run correspondente em **Actions** (`cd-api`, `cd-frontend`, `cd-codegen`, `cd-worker`, `cd-gateway` ou `cd-observability`). +1. Faça um push (ou merge de PR) para `staging` que toque em algum dos diretórios `api/`, `frontend/`, `codegen/`, `worker/`, `deploy/gateway/`, `deploy/keycloak/` ou `deploy/observability/`. +2. Acompanhe a run correspondente em **Actions** (`cd-api`, `cd-frontend`, `cd-codegen`, `cd-worker`, `cd-gateway`, `cd-keycloak` ou `cd-observability`). 3. No droplet, confirme a imagem etiquetada e o container recriado: ```bash @@ -160,9 +169,15 @@ Se algo falhar, os logs do passo SSH aparecem diretamente na run do Actions - n | `cd-codegen.yml` | push em `staging` tocando `codegen/**` | `docker compose build/up -d codegen` | | `cd-worker.yml` | push em `staging` tocando `worker/**` | `docker compose build/up -d worker` | | `cd-gateway.yml` | push em `staging` tocando `deploy/gateway/**` | `docker compose up -d --force-recreate gateway` | +| `cd-keycloak.yml` | push em `staging` tocando `deploy/keycloak/**`, `deploy/docker-compose.yml` ou o próprio workflow | `docker compose pull keycloak` + recriação somente do Keycloak, aguardando healthcheck por até 180 segundos | | `cd-observability.yml` | push em `staging` tocando `deploy/observability/**` | `docker compose up -d --force-recreate` (compose de observabilidade) | -Nenhum deles dispara em `develop` - essa branch passa apenas pela CI de verificação (`verificar-api.yml` e futuros equivalentes por componente). +Nenhum deles dispara em `develop` - essa branch passa apenas pela CI de +verificação (`verificar.yml`). Nenhum workflow executa o seed. + +O CD do Keycloak preserva seu volume. Mudanças no tema são aplicadas pela +recriação; mudanças no realm JSON exigem atualização manual de realms já +existentes, pois `--import-realm` não substitui o realm persistido. --- diff --git a/docs/instalacao.md b/docs/instalacao.md index b7dae34..1650bc3 100644 --- a/docs/instalacao.md +++ b/docs/instalacao.md @@ -2,19 +2,20 @@ Este documento orienta a inicialização e operação do ambiente local do projeto **Synapse** via Docker Compose. -O ambiente de infraestrutura fornece a base de persistência de dados e mensageria assíncrona para os quatro componentes de desenvolvimento (`frontend`, `api`, `codegen` e `worker`), conforme definido em [ADR-001](../docs/adrs/ADR-001.md) e [ARCHITECTURE.md](../docs/ARCHITECTURE.md). +O ambiente de infraestrutura fornece autenticação pelo Keycloak, persistência de dados e mensageria assíncrona para os quatro componentes de desenvolvimento (`frontend`, `api`, `codegen` e `worker`), conforme definido em [ADR-001](../docs/adrs/ADR-001.md) e [ARCHITECTURE.md](../docs/ARCHITECTURE.md). --- ## 1. Serviços Contemplados -A infraestrutura local é composta pelos serviços de infraestrutura e pelo codegen: +A infraestrutura local inclui os serviços abaixo: 1. **PostgreSQL 18**: Armazenamento único do sistema (armazena estado dos jobs, artefatos gerados, checkpoints e trilhas de auditoria). Configurado com volume persistente e criação automática do banco `synapse_db`. 2. **RabbitMQ 3.13 (com Management UI)**: Broker de mensageria assíncrona para troca de eventos e comandos entre a API e os workers, com painel administrativo web exposto. 3. **API**: aplicação Spring Boot que expõe health check e métricas de infraestrutura. 4. **codegen**: Processo FastAPI que expõe somente os endpoints operacionais de saúde e métricas. 5. **worker**: Processo que consome a fila de execução e sobe os containers efêmeros do sandbox. Diferente dos demais, ele precisa alcançar o **daemon do Docker do host** — ver a seção 2.1. +6. **Keycloak 26.7.4**: Provedor OIDC de identidade. Importa o realm `synapse` e o cliente público `synapse-frontend`, com login local em `http://localhost:8081`. --- ## 2. Pré-requisitos @@ -64,20 +65,26 @@ O compose não sobe sem essa variável: ela é declarada como obrigatória justa ## 3. Inicialização Rápida (Comando Único) -Todo o ambiente de infraestrutura sobe com um único comando, sem necessidade de configuração manual adicional: +Antes do primeiro uso, crie `deploy/.env` a partir de `deploy/.env.example`, caso o arquivo ainda não exista. Preencha as credenciais dos serviços, `DOCKER_GID` e `KEYCLOAK_ADMIN_PASSWORD`. O Compose exige essas variáveis mesmo em comandos que selecionam apenas um serviço. + +```bash +cp deploy/.env.example deploy/.env +``` + +Use `--env-file deploy/.env` ao executar os comandos da raiz do repositório. A configuração de usuários da aplicação e do frontend está no [guia do Keycloak local](../deploy/keycloak/README.md). ### Antes do primeiro `up`: a imagem do sandbox O `worker` executa cada job num container criado a partir da imagem `synapse-sandbox`, e essa imagem não sobe com o `up`: ela só é construída. Construa-a uma vez, e de novo quando `worker/sandbox/` mudar. Sem ela, o `worker` sobe normalmente, mas todo comando de execução falha e acaba na fila de DLQ. ```bash -docker compose -f deploy/docker-compose.yml --profile build build sandbox +docker compose --env-file deploy/.env -f deploy/docker-compose.yml --profile build build sandbox ``` ### Opção A: A partir da raiz do repositório ```bash -docker compose -f deploy/docker-compose.yml up -d +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d ``` ### Opção B: A partir do diretório `deploy/` @@ -90,7 +97,7 @@ docker compose up -d > **Dica:** Para que o comando aguarde até que todos os serviços passem nos seus respectivos *healthchecks* antes de liberar o terminal, utilize a flag `--wait`: > > ```bash -> docker compose -f deploy/docker-compose.yml up -d --wait +> docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --wait > ``` --- @@ -107,19 +114,20 @@ Os nomes de host, portas e credenciais abaixo são padronizados para desenvolvim | **codegen** | `synapse-infra-codegen-1` | `codegen` | `8001` | `8000` | — | — | — | [http://localhost:8001/health](http://localhost:8001/health) | | **worker** | `synapse-infra-worker-1` | `worker` | `8002` | `8000` | — | — | — | [http://localhost:8002/health](http://localhost:8002/health) | | **API** | `synapse-infra-api-1` | `api` | `8080` | `8080` | — | — | — | [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health) | +| **Keycloak** | `synapse-keycloak` | `keycloak` | `8081` | `8080` | `KEYCLOAK_ADMIN_USERNAME` | `KEYCLOAK_ADMIN_PASSWORD` | realm `synapse` | [http://localhost:8081/admin/](http://localhost:8081/admin/) | -As variáveis de ambiente padrão estão declaradas e versionadas em `deploy/.env.example` (copie para `deploy/.env` se quiser sobrescrever os defaults do compose). +As variáveis de ambiente estão documentadas em `deploy/.env.example`; `deploy/.env` deve fornecer as variáveis obrigatórias descritas na seção 3. --- ## 5. Verificação de Saúde (Healthcheck) -Cada serviço possui um *healthcheck* configurado. O ambiente só é considerado pronto quando todos os containers estiverem com status **`healthy`**: +Confira o status **`healthy`** dos serviços que possuem *healthcheck*. O Keycloak não tem *healthcheck* configurado no Compose; valide sua inicialização pelo endpoint OIDC abaixo. O frontend do Compose é uma tarefa de build e cópia, que termina após concluir essa operação: Execute: ```bash -docker compose -f deploy/docker-compose.yml ps +docker compose --env-file deploy/.env -f deploy/docker-compose.yml ps ``` Saída esperada: @@ -170,7 +178,7 @@ synapse-infra-api-1 synapse-api:local Up (healthy) 0. O `worker` verifica o acesso ao daemon e declara a fila durante a subida. Os dois ficam registrados no log: ```bash - docker compose -f deploy/docker-compose.yml logs worker | grep -E "daemon|fila" + docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs worker | grep -E "daemon|fila" ``` ```json @@ -180,6 +188,14 @@ synapse-infra-api-1 synapse-api:local Up (healthy) 0. Se essas linhas não aparecem, o processo não subiu - confira a seção 7. +6. **Keycloak**: + + ```bash + curl --fail http://localhost:8081/realms/synapse/.well-known/openid-configuration + ``` + + O endpoint deve devolver o documento OIDC com `issuer` igual a `http://localhost:8081/realms/synapse`. Para testar login, renovação e logout pelo frontend, siga o [guia do Keycloak](../deploy/keycloak/README.md). + --- ## 6. Ciclo de Vida e Persistência de Dados @@ -189,23 +205,23 @@ synapse-infra-api-1 synapse-api:local Up (healthy) 0. Para interromper a infraestrutura sem perder tabelas, registros ou filas: ```bash -docker compose -f deploy/docker-compose.yml down +docker compose --env-file deploy/.env -f deploy/docker-compose.yml down ``` -Os dados permanecem seguros nos volumes nomeados Docker (`synapse-postgres-data` e `synapse-rabbitmq-data`). Ao rodar `docker compose up -d` novamente, todo o estado anterior é restaurado. +Os volumes nomeados preservam os dados de PostgreSQL (`synapse-postgres-data`), RabbitMQ (`synapse-rabbitmq-data`) e Keycloak (`keycloak-data` no Compose). Ao subir os serviços novamente, esses volumes são reutilizados, incluindo os usuários e o realm do Keycloak. ### Reiniciar os serviços ```bash -docker compose -f deploy/docker-compose.yml restart +docker compose --env-file deploy/.env -f deploy/docker-compose.yml restart ``` ### Resetar o ambiente (Destruição total de dados) -Caso precise recriar os bancos e filas totalmente do zero (apagando todos os dados persistidos): +Caso precise recriar os bancos, filas e a configuração do Keycloak totalmente do zero (apagando também seus usuários, realm e sessões persistidas): ```bash -docker compose -f deploy/docker-compose.yml down -v +docker compose --env-file deploy/.env -f deploy/docker-compose.yml down -v ``` --- @@ -225,7 +241,7 @@ Se o compose recusa a subida com `defina em deploy/.env; ver docs/instalacao.md` Se o container do `worker` sobe e morre em seguida, o log traz a causa: ```bash -docker compose -f deploy/docker-compose.yml logs worker | tail -20 +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs worker | tail -20 ``` ```text @@ -244,11 +260,11 @@ Para acompanhar a saída de logs em tempo real: ```bash # Todos os serviços -docker compose -f deploy/docker-compose.yml logs -f +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs -f # Somente Postgres -docker compose -f deploy/docker-compose.yml logs -f postgres +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs -f postgres # Somente RabbitMQ -docker compose -f deploy/docker-compose.yml logs -f rabbitmq +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs -f rabbitmq ``` diff --git a/frontend/.env.example b/frontend/.env.example index 5e1cd83..cc6e56e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,6 @@ # Never put a secret here: keys and tokens live in the backend. VITE_API_BASE_URL=/api +VITE_KEYCLOAK_URL=http://localhost:8081 +VITE_KEYCLOAK_REALM=synapse +VITE_KEYCLOAK_CLIENT_ID=synapse-frontend diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 74784ca..9dd66f5 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,5 +13,11 @@ COPY frontend ./ COPY contracts /contracts ARG VITE_API_BASE_URL=/api +ARG VITE_KEYCLOAK_URL=http://localhost:8081 +ARG VITE_KEYCLOAK_REALM=synapse +ARG VITE_KEYCLOAK_CLIENT_ID=synapse-frontend ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL +ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM +ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID RUN npm run build diff --git a/frontend/env.d.ts b/frontend/env.d.ts index 0ef670e..94c509e 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -8,6 +8,12 @@ interface ImportMetaEnv { /** API base. Absolute URL or '/...' path. Default: '/api'. */ readonly VITE_API_BASE_URL?: string + /** Base pública do servidor Keycloak. */ + readonly VITE_KEYCLOAK_URL?: string + /** Realm que emite os tokens da aplicação. */ + readonly VITE_KEYCLOAK_REALM?: string + /** Cliente público OIDC do frontend. */ + readonly VITE_KEYCLOAK_CLIENT_ID?: string } interface ImportMeta { diff --git a/frontend/index.html b/frontend/index.html index bcf5d99..75626a2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Synapse diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 21301fe..521e711 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@fortawesome/free-regular-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/vue-fontawesome": "^3.3.3", + "keycloak-js": "^26.2.4", "pinia": "^4.0.2", "vue": "^3.5.40", "vue-router": "^5.2.0" @@ -3617,6 +3618,12 @@ "dev": true, "license": "MIT" }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", + "license": "Apache-2.0" + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 19df488..c1430a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@fortawesome/free-regular-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/vue-fontawesome": "^3.3.3", + "keycloak-js": "^26.2.4", "pinia": "^4.0.2", "vue": "^3.5.40", "vue-router": "^5.2.0" diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico deleted file mode 100644 index df36fcf..0000000 Binary files a/frontend/public/favicon.ico and /dev/null differ diff --git a/frontend/public/silent-check-sso.html b/frontend/public/silent-check-sso.html new file mode 100644 index 0000000..192697e --- /dev/null +++ b/frontend/public/silent-check-sso.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/synapse.ico b/frontend/public/synapse.ico new file mode 100644 index 0000000..2cc0862 Binary files /dev/null and b/frontend/public/synapse.ico differ diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 8b3b60b..e56a7a7 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -7,4 +7,18 @@ body, min-width: 100%; min-height: 100%; margin: 0; + overflow-x: clip; +} + +/* + * O monitor de referência tem 1920px CSS (120rem a 16px). Um notebook com + * escala de 125% normalmente disponibiliza entre 1200px e 1600px CSS; ao + * tornar o rem proporcional nessa faixa, a composição ganha a mesma presença + * visual do desktop de referência e continua fluida, sem canvas fixo ou + * rolagem horizontal. + */ +@media (min-resolution: 1.25dppx) and (min-width: 75rem) and (max-width: 100rem) { + html { + font-size: calc(100vw / 110); + } } diff --git a/frontend/src/components/TheProcessHeader.spec.ts b/frontend/src/components/TheProcessHeader.spec.ts new file mode 100644 index 0000000..0bc95d8 --- /dev/null +++ b/frontend/src/components/TheProcessHeader.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import TheProcessHeader from './TheProcessHeader.vue' + +const opcoesDeMontagem = { + global: { + plugins: [createPinia()], + }, +} + +describe('TheProcessHeader', () => { + it('marca a entrada de regra como etapa atual', () => { + const conteiner = mount(TheProcessHeader, { ...opcoesDeMontagem, props: { etapaDaRota: 'voz-texto' } }) + + expect(conteiner.get('li[aria-current="step"]').text()).toContain('Voz/Texto') + }) + + it('associa os estados intermediários à simulação enquanto a conversa não possui tela própria', () => { + const conteiner = mount(TheProcessHeader, { + ...opcoesDeMontagem, + props: { etapaDaRota: 'simulacao', statusDoJob: 'aguardando_confirmacao_parametros' }, + }) + + expect(conteiner.get('li[aria-current="step"]').text()).toContain('Simulação') + expect(conteiner.text()).not.toContain('Conversa') + }) + + it('indica o salvamento em toda a tela de finalização, mesmo durante uma transição de status', () => { + const conteiner = mount(TheProcessHeader, { + ...opcoesDeMontagem, + props: { etapaDaRota: 'salvar', statusDoJob: 'simulacao_inviavel' }, + }) + + expect(conteiner.get('li[aria-current="step"]').text()).toContain('Salvar') + }) + + it('não exibe o progresso de uma nova regra no histórico', () => { + const conteiner = mount(TheProcessHeader, { ...opcoesDeMontagem, props: { etapaDaRota: null } }) + + expect(conteiner.find('[aria-label="Etapas do processo"]').exists()).toBe(false) + expect(conteiner.find('[aria-label^="Etapa atual:"]').exists()).toBe(false) + }) + + it('mantém um indicador textual acessível fora do desktop', () => { + const conteiner = mount(TheProcessHeader, { ...opcoesDeMontagem, props: { etapaDaRota: 'simulacao' } }) + const indicador = conteiner.get('[aria-label="Etapa atual: Simulação"]') + + expect(indicador.text()).toContain('Etapa: Simulação') + expect(indicador.classes()).toContain('xl:hidden') + }) + + it('usa um ícone reconhecível para abrir o menu de perfil', () => { + const conteiner = mount(TheProcessHeader, { ...opcoesDeMontagem, props: { etapaDaRota: 'voz-texto' } }) + const menuDePerfil = conteiner.get('summary[aria-label="Abrir menu de perfil"]') + + expect(menuDePerfil.find('svg').attributes('viewBox')).toBe('0 0 24 24') + expect(menuDePerfil.text()).not.toContain('U') + }) +}) diff --git a/frontend/src/components/TheProcessHeader.vue b/frontend/src/components/TheProcessHeader.vue index 1ede4b7..3806c75 100644 --- a/frontend/src/components/TheProcessHeader.vue +++ b/frontend/src/components/TheProcessHeader.vue @@ -1,52 +1,128 @@ + +