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.networkntjson-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.
+
+
+
+
+
+
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 @@
+
+
-
-