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..ccb77dc 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
+
+
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/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..45cefd3 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();
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/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/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/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/CriarJobControllerTests.java b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java
index 2b3e167..1e15e1b 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,7 +57,7 @@ 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(),
@@ -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..20e0320 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();
}
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/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..3c99f6f 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/']
@@ -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,24 @@ 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}
+ ports:
+ - '${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
+ networks:
+ - synapse-net
+ restart: unless-stopped
+
alloy:
image: grafana/alloy:latest
container_name: synapse-alloy
@@ -257,6 +283,7 @@ volumes:
rabbitmq-data:
name: synapse-rabbitmq-data
alloy-data:
+ keycloak-data:
networks:
synapse-net:
diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md
new file mode 100644
index 0000000..aff79fd
--- /dev/null
+++ b/deploy/keycloak/README.md
@@ -0,0 +1,104 @@
+# 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`. Crie os
+usuários em **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.
+
+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.
+
+## Preparação para staging
+
+A promoção manual para staging exige configurar o ambiente público. O realm
+versionado aceita apenas a origem `http://localhost:5173`; mudar as variáveis do
+frontend não altera os retornos permitidos no Keycloak.
+
+- Configure uma URL pública acessível pelo navegador para o Keycloak. O Caddyfile
+ versionado ainda não publica uma rota para esse serviço.
+- Use essa URL em `KEYCLOAK_PUBLIC_URL` e `VITE_KEYCLOAK_URL`; configure
+ `KEYCLOAK_ISSUER_URI` como a mesma URL seguida de `/realms/synapse`.
+- Mantenha `KEYCLOAK_JWK_SET_URI` acessível à API pela rede Docker.
+- Ajuste `Valid Redirect URIs`, `Valid Post Logout Redirect URIs` e `Web Origins`
+ do cliente para a origem pública do frontend, além de `CORS_ALLOWED_ORIGINS`
+ da API.
+- Reconstrua o frontend após alterar `VITE_*`, pois esses valores são incorporados
+ ao bundle durante o build.
+
+Em realms já existentes, aplique os ajustes pelo console: a importação de
+inicialização não substitui o realm persistido. O serviço versionado usa
+`start-dev`; a configuração do ambiente de staging precisa considerar esse modo
+de execução antes da promoção.
+
+A validação pelo navegador deve cobrir login, recuperação da sessão após recarga,
+renovação após a expiração do access token, recebimento de progresso por SSE e
+logout. Os testes automatizados não substituem essa validação no endereço 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..4b47670
--- /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/*"],
+ "attributes": {
+ "post.logout.redirect.uris": "http://localhost:5173/*"
+ },
+ "webOrigins": ["http://localhost:5173"]
+ }
+ ]
+}
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..950d7fb
--- /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: 667px; 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/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/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/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/src/components/TheProcessHeader.vue b/frontend/src/components/TheProcessHeader.vue
index 1ede4b7..131ec08 100644
--- a/frontend/src/components/TheProcessHeader.vue
+++ b/frontend/src/components/TheProcessHeader.vue
@@ -1,3 +1,20 @@
+
+
@@ -44,9 +61,9 @@
Perfil do usuário
-
A saída da conta será conectada ao login.
-
diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts
index ddbd603..132ed62 100644
--- a/frontend/src/config/env.ts
+++ b/frontend/src/config/env.ts
@@ -1,4 +1,7 @@
export const config = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
baseUrl: import.meta.env.BASE_URL,
+ keycloakClientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'synapse-frontend',
+ keycloakRealm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'synapse',
+ keycloakUrl: import.meta.env.VITE_KEYCLOAK_URL ?? 'http://localhost:8081',
}
diff --git a/frontend/src/features/bootstrap/routes.ts b/frontend/src/features/bootstrap/routes.ts
index cce36dc..9362117 100644
--- a/frontend/src/features/bootstrap/routes.ts
+++ b/frontend/src/features/bootstrap/routes.ts
@@ -4,7 +4,6 @@ export default [
{
path: '/',
name: 'bootstrap',
- component: () => import('./views/BootstrapView.vue'),
- meta: { layout: 'blank' },
+ redirect: '/nova-regra',
},
] satisfies RouteRecordRaw[]
diff --git a/frontend/src/features/formulario-regra/views/FormularioRegraView.spec.ts b/frontend/src/features/formulario-regra/views/FormularioRegraView.spec.ts
index 144c779..82c08a1 100644
--- a/frontend/src/features/formulario-regra/views/FormularioRegraView.spec.ts
+++ b/frontend/src/features/formulario-regra/views/FormularioRegraView.spec.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
import FormularioRegraView from './FormularioRegraView.vue'
vi.mock('@/services/api', () => ({
@@ -38,6 +39,7 @@ vi.mock('vue-router', () => ({
const opcoesDeMontagem = {
global: {
+ plugins: [createPinia()],
stubs: {
RouterLink: {
props: ['to'],
diff --git a/frontend/src/features/historico-jobs/views/HistoricoJobsView.spec.ts b/frontend/src/features/historico-jobs/views/HistoricoJobsView.spec.ts
index 8aeebe1..39fda16 100644
--- a/frontend/src/features/historico-jobs/views/HistoricoJobsView.spec.ts
+++ b/frontend/src/features/historico-jobs/views/HistoricoJobsView.spec.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
import type { JobResumo } from '@/types/api'
import HistoricoJobsView from './HistoricoJobsView.vue'
@@ -28,6 +29,7 @@ function criarResumoDoJob(numero: number, status: JobResumo['status'] = 'liberad
const opcoesDeMontagem = {
global: {
+ plugins: [createPinia()],
stubs: {
RouterLink: {
props: ['to'],
diff --git a/frontend/src/features/simulate/views/SimulateView.integration.spec.ts b/frontend/src/features/simulate/views/SimulateView.integration.spec.ts
index a984cc4..ac1b175 100644
--- a/frontend/src/features/simulate/views/SimulateView.integration.spec.ts
+++ b/frontend/src/features/simulate/views/SimulateView.integration.spec.ts
@@ -6,20 +6,30 @@ import { jobFixture, regraFixture } from '@/services/job.fixtures'
import type { Job } from '@/types/api'
import SimulateView from './SimulateView.vue'
-class Stream extends EventTarget {
+vi.mock('@/services/keycloak', () => ({
+ obterTokenDeAcesso: vi.fn<() => Promise>().mockResolvedValue('token-da-sessao'),
+ iniciarLogin: vi.fn<() => Promise>().mockResolvedValue(),
+ limparTokenDoKeycloak: vi.fn<() => void>(),
+}))
+
+class Stream {
static atual: Stream
- onopen: (() => void) | null = null
- onerror: (() => void) | null = null
+ private controlador!: ReadableStreamDefaultController
+ readonly corpo = new ReadableStream({
+ start: (controlador) => { this.controlador = controlador },
+ })
fechado = false
- constructor(readonly url: string) {
- super()
+
+ constructor(readonly url: string, signal: AbortSignal) {
Stream.atual = this
+ signal.addEventListener('abort', () => {
+ this.fechado = true
+ this.controlador.error(new DOMException('Cancelado', 'AbortError'))
+ }, { once: true })
}
- close() {
- this.fechado = true
- }
+
emitir(tipo: string, dados: unknown) {
- this.dispatchEvent(new MessageEvent(tipo, { data: JSON.stringify(dados) }))
+ this.controlador.enqueue(new TextEncoder().encode(`event: ${tipo}\ndata: ${JSON.stringify(dados)}\n\n`))
}
}
@@ -29,16 +39,17 @@ afterEach(() => {
})
async function montar(consultar: () => Job) {
- vi.stubGlobal('EventSource', Stream)
- const fetchMock = vi
- .spyOn(globalThis, 'fetch')
- .mockImplementation(
- async () =>
- new Response(JSON.stringify(consultar()), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- }),
- )
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
+ if (String(url).endsWith('/events')) {
+ if (!init?.signal) throw new Error('O stream precisa de um sinal de cancelamento')
+ const stream = new Stream(String(url), init.signal)
+ return new Response(stream.corpo, { headers: { 'content-type': 'text/event-stream' } })
+ }
+ return new Response(JSON.stringify(consultar()), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ })
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -64,7 +75,10 @@ describe('integração da tela com HTTP e SSE', () => {
recente.representacao.nucleo.loja = ['Loja mais recente']
let resposta = jobFixture({ status: 'gerando_regra', regras: [recente, regraFixture()] })
const { wrapper, fetchMock } = await montar(() => resposta)
- expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/jobs/job-1')
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual(expect.arrayContaining(['/api/jobs/job-1', '/api/jobs/job-1/events']))
+ for (const [, init] of fetchMock.mock.calls) {
+ expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer token-da-sessao')
+ }
expect(Stream.atual.url).toBe('/api/jobs/job-1/events')
expect(wrapper.get('#loja').element).toHaveProperty('value', 'Loja mais recente')
expect(wrapper.get('#totalComissionamento').element).toHaveProperty('value', '')
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 328e3e0..109cd1d 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -9,6 +9,7 @@ import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'
import App from './App.vue'
import roteador from './router'
import { instalarRegistroDeErrosDoNavegador } from './observability/browserErrorLogging'
+import { usarStoreSessao } from './stores/session'
import './assets/main.css'
@@ -17,9 +18,11 @@ library.add(faCircleCheck)
library.add(faCircleExclamation)
const aplicacao = createApp(App)
+const pinia = createPinia()
aplicacao.component('font-awesome-icon', FontAwesomeIcon)
-aplicacao.use(createPinia())
+aplicacao.use(pinia)
+await usarStoreSessao(pinia).inicializar()
aplicacao.use(roteador)
instalarRegistroDeErrosDoNavegador(aplicacao)
diff --git a/frontend/src/router/index.spec.ts b/frontend/src/router/index.spec.ts
index e378b72..b45c71c 100644
--- a/frontend/src/router/index.spec.ts
+++ b/frontend/src/router/index.spec.ts
@@ -1,8 +1,28 @@
-import { describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
import roteador from './index'
+import { usarStoreSessao } from '@/stores/session'
describe('roteador', () => {
+ beforeEach(() => setActivePinia(createPinia()))
+
it('resolve a raiz para a view de bootstrap', () => {
expect(roteador.resolve('/').name).toBe('bootstrap')
})
+
+ it('inicia o login do Keycloak ao acessar uma rota de negócio sem sessão', async () => {
+ const entrar = vi.spyOn(usarStoreSessao(), 'entrar').mockResolvedValue()
+
+ await roteador.push('/nova-regra')
+
+ expect(entrar).toHaveBeenCalledWith('/nova-regra')
+ })
+
+ it('permite uma rota de negócio para um usuário autenticado', async () => {
+ usarStoreSessao().estaAutenticado = true
+
+ await roteador.push('/nova-regra')
+
+ expect(roteador.currentRoute.value.name).toBe('nova-regra')
+ })
})
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 8150a8f..d7b13e8 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { config } from '@/config/env'
+import { usarStoreSessao } from '@/stores/session'
const modules = import.meta.glob<{ default: RouteRecordRaw[] }>('../features/*/routes.ts', {
eager: true,
@@ -22,4 +23,13 @@ const router = createRouter({
],
})
+router.beforeEach((destino) => {
+ const sessao = usarStoreSessao()
+
+ if (sessao.estaAutenticado) return true
+
+ void sessao.entrar(destino.fullPath)
+ return false
+})
+
export default router
diff --git a/frontend/src/services/README.md b/frontend/src/services/README.md
index 73e3242..468f321 100644
--- a/frontend/src/services/README.md
+++ b/frontend/src/services/README.md
@@ -4,19 +4,20 @@
## How it works
-Every REST call goes through `http`, so base URL, headers and error handling stay in one place. Authentication is intentionally absent in Sprint 1.
+REST and SSE requests go through `http`, which obtains the current Keycloak token after awaiting `updateToken(30)` and sends it in the `Authorization` header. Tokens remain in memory. A temporary refresh failure stops the request without clearing the session; a 401 response starts login again.
```ts
export const http = {
get: (path, signal?) => ...,
post: (path, body?, signal?) => ...,
+ stream: (url, signal) => ...,
url: (path) => ...,
}
```
A non-2xx response becomes an `HttpError`. Validation errors expose `fieldErrors` with a readable field name and message. Server and connection failures use user-facing messages and never expose a raw HTTP status as display text.
-`api.ts` maps the operations from `contracts/http/openapi.yaml` to typed calls. The `acompanharJob` entry only returns the typed stream URL; opening and consuming the SSE connection belongs to T-072.
+`api.ts` maps the operations from `contracts/http/openapi.yaml` to typed calls. The `acompanharJob` entry returns the stream URL. `jobEvents.ts` opens it through `http.stream`, consumes the SSE frames and manages reconnection, cancellation and result deduplication. Each reconnection obtains a current token.
## Do
diff --git a/frontend/src/services/http.spec.ts b/frontend/src/services/http.spec.ts
index da3952b..6f42646 100644
--- a/frontend/src/services/http.spec.ts
+++ b/frontend/src/services/http.spec.ts
@@ -1,14 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
+const adaptadorKeycloak = vi.hoisted(() => ({
+ iniciarLogin: vi.fn<() => Promise>().mockResolvedValue(),
+ limparTokenDoKeycloak: vi.fn<() => void>(),
+ obterTokenDeAcesso: vi.fn<() => Promise>(),
+}))
+
+vi.mock('./keycloak', () => adaptadorKeycloak)
+
import { http } from './http'
afterEach(() => {
- vi.restoreAllMocks()
+ vi.restoreAllMocks()
+ adaptadorKeycloak.obterTokenDeAcesso.mockReset().mockResolvedValue(undefined)
+ adaptadorKeycloak.limparTokenDoKeycloak.mockReset()
+ adaptadorKeycloak.iniciarLogin.mockClear()
})
describe('http', () => {
- it('não envia token de autenticação', async () => {
- const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ it('anexa o token de autenticação à chamada', async () => {
+ adaptadorKeycloak.obterTokenDeAcesso.mockResolvedValue('token-de-teste')
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ id: 'job-1' }), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -17,11 +29,23 @@ describe('http', () => {
await http.get<{ id: string }>('/jobs/job-1')
- const request = fetchMock.mock.calls[0]
- const init = request?.[1]
- const headers = new Headers(init?.headers)
- expect(headers.has('Authorization')).toBe(false)
- })
+ const request = fetchMock.mock.calls[0]
+ const init = request?.[1]
+ const headers = new Headers(init?.headers)
+ expect(headers.get('Authorization')).toBe('Bearer token-de-teste')
+ })
+
+ it('limpa o token e reinicia o login ao receber 401', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 401 }))
+
+ await Promise.all([
+ expect(http.get('/jobs')).rejects.toMatchObject({ status: 401 }),
+ expect(http.stream('/api/jobs/job-1/events', new AbortController().signal)).rejects.toMatchObject({ status: 401 }),
+ ])
+
+ expect(adaptadorKeycloak.limparTokenDoKeycloak).toHaveBeenCalledTimes(2)
+ await vi.waitFor(() => expect(adaptadorKeycloak.iniciarLogin).toHaveBeenCalledOnce())
+ })
it('transforma erro de validação em mensagem com o nome do campo', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
@@ -69,3 +93,79 @@ describe('http', () => {
expect(init).not.toHaveProperty('body')
})
})
+
+
+describe('transporte autenticado', () => {
+ it('aguarda o token renovado antes de enviar o formulário', async () => {
+ let concluirRenovacao!: (valor: string | undefined) => void
+ const renovacao = new Promise((resolve) => { concluirRenovacao = resolve })
+ adaptadorKeycloak.obterTokenDeAcesso.mockReturnValue(renovacao)
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'))
+
+ const resposta = http.post('/jobs', { origem: 'formulario' })
+ expect(fetchMock).not.toHaveBeenCalled()
+ concluirRenovacao('token-renovado')
+ await resposta
+
+ const init = fetchMock.mock.calls[0]?.[1]
+ expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer token-renovado')
+ expect(init?.body).toBe(JSON.stringify({ origem: 'formulario' }))
+ expect(adaptadorKeycloak.iniciarLogin).not.toHaveBeenCalled()
+ })
+
+ it('envia Bearer também no SSE e obtém o token atual a cada abertura', async () => {
+ adaptadorKeycloak.obterTokenDeAcesso.mockResolvedValueOnce('token-1').mockResolvedValueOnce('token-2')
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(': heartbeat\n\n', {
+ headers: { 'content-type': 'text/event-stream;charset=UTF-8' },
+ }))
+ const controle = new AbortController()
+
+ await (await http.stream('/api/jobs/job-1/events', controle.signal)).cancel()
+ await (await http.stream('/api/jobs/job-1/events', controle.signal)).cancel()
+
+ expect(fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('Authorization')))
+ .toEqual(['Bearer token-1', 'Bearer token-2'])
+ const [url, init] = fetchMock.mock.calls[0]!
+ expect(url).toBe('/api/jobs/job-1/events')
+ expect(init?.signal).toBe(controle.signal)
+ expect(new Headers(init?.headers).get('Accept')).toBe('text/event-stream')
+ })
+
+ it('não envia requisição nem descarta sessão em uma falha transitória da renovação', async () => {
+ adaptadorKeycloak.obterTokenDeAcesso.mockRejectedValue(new Error('Falha de rede'))
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+
+ await expect(http.post('/jobs', {})).rejects.toMatchObject({
+ status: 0,
+ message: 'Não foi possível renovar sua sessão. Tente novamente.',
+ })
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ expect(adaptadorKeycloak.limparTokenDoKeycloak).not.toHaveBeenCalled()
+ expect(adaptadorKeycloak.iniciarLogin).not.toHaveBeenCalled()
+ })
+
+ it('não abre o SSE quando a tela é fechada durante a renovação', async () => {
+ let concluirRenovacao!: (valor: string | undefined) => void
+ const renovacao = new Promise((resolve) => { concluirRenovacao = resolve })
+ adaptadorKeycloak.obterTokenDeAcesso.mockReturnValue(renovacao)
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ const controle = new AbortController()
+ const stream = http.stream('/api/jobs/job-1/events', controle.signal)
+
+ controle.abort()
+ concluirRenovacao('token-renovado')
+
+ await expect(stream).rejects.toMatchObject({ name: 'AbortError' })
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('rejeita uma resposta que não seja um stream SSE', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Login', {
+ headers: { 'content-type': 'text/html' },
+ }))
+
+ await expect(http.stream('/api/jobs/job-1/events', new AbortController().signal))
+ .rejects.toMatchObject({ status: 0 })
+ })
+})
diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts
index 5032f28..29fe3de 100644
--- a/frontend/src/services/http.ts
+++ b/frontend/src/services/http.ts
@@ -1,5 +1,8 @@
import { config } from '@/config/env'
import type { ApiError, CodigoErro, ElementoErro } from '@/types/api'
+import { iniciarLogin, limparTokenDoKeycloak, obterTokenDeAcesso } from './keycloak'
+
+let redirecionandoPorSessaoInvalida = false
const mensagensPorCodigo: Record = {
requisicao_invalida: 'Não foi possível enviar os dados. Revise as informações e tente novamente.',
@@ -115,6 +118,40 @@ function construirUrl(path: string): string {
return `${base}${normalizedPath}`
}
+async function enviar(url: string, requestInit: RequestInit): Promise {
+ requestInit.signal?.throwIfAborted()
+ let token: string | undefined
+ try {
+ token = await obterTokenDeAcesso()
+ } catch {
+ requestInit.signal?.throwIfAborted()
+ throw new HttpError(0, 'Não foi possível renovar sua sessão. Tente novamente.')
+ }
+ requestInit.signal?.throwIfAborted()
+ const headers = new Headers(requestInit.headers)
+ if (token) headers.set('Authorization', `Bearer ${token}`)
+
+ let response: Response
+ try {
+ response = await fetch(url, { ...requestInit, headers })
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') throw error
+ throw new HttpError(0, 'Não foi possível se conectar ao serviço. Verifique sua conexão e tente novamente.')
+ }
+
+ if (response.status === 401) {
+ limparTokenDoKeycloak()
+ if (!redirecionandoPorSessaoInvalida) {
+ redirecionandoPorSessaoInvalida = true
+ void iniciarLogin().catch(() => undefined)
+ }
+ }
+
+ if (!response.ok) throw await criarErro(response)
+
+ return response
+}
+
async function request(
method: 'GET' | 'POST',
path: string,
@@ -139,15 +176,7 @@ async function request(
signal: options?.signal,
}
- let response: Response
- try {
- response = await fetch(construirUrl(path), requestInit)
- } catch (error) {
- if (error instanceof DOMException && error.name === 'AbortError') throw error
- throw new HttpError(0, 'Não foi possível se conectar ao serviço. Verifique sua conexão e tente novamente.')
- }
-
- if (!response.ok) throw await criarErro(response)
+ const response = await enviar(construirUrl(path), requestInit)
if (response.status === 204) return undefined as TResponse
return (await response.json()) as TResponse
@@ -160,6 +189,20 @@ export const http = {
post(path: string, body?: TBody, signal?: AbortSignal) {
return request('POST', path, { body, signal })
},
+ async stream(url: string, signal: AbortSignal): Promise> {
+ const response = await enviar(url, {
+ method: 'GET',
+ headers: { Accept: 'text/event-stream' },
+ cache: 'no-store',
+ signal,
+ })
+ const contentType = response.headers.get('content-type')?.split(';')[0]?.trim()
+ if (contentType !== 'text/event-stream' || !response.body) {
+ await response.body?.cancel()
+ throw new HttpError(0, 'Não foi possível acompanhar o processamento. Tente novamente.')
+ }
+ return response.body
+ },
url(path: string) {
return construirUrl(path)
},
diff --git a/frontend/src/services/jobEvents.spec.ts b/frontend/src/services/jobEvents.spec.ts
index 1f1e7b8..a7bf89e 100644
--- a/frontend/src/services/jobEvents.spec.ts
+++ b/frontend/src/services/jobEvents.spec.ts
@@ -1,6 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { abrirAcompanhamentoJob } from './jobEvents'
import { apiClient } from './api'
+import { http } from './http'
+import { flushPromises } from '@vue/test-utils'
import type { EventoEtapa, EventoEstado, EventoResultado } from '@/types/api'
vi.mock('./api', () => ({
@@ -9,47 +11,60 @@ vi.mock('./api', () => ({
},
}))
-class FakeEventSource {
- static instances: FakeEventSource[] = []
+vi.mock('./http', () => ({
+ http: { stream: vi.fn<(url: string, signal: AbortSignal) => Promise>>() },
+}))
- url: string
- listeners = new Map void>>()
- onopen: (() => void) | null = null
- onerror: (() => void) | null = null
+class StreamSimulado {
+ static instances: StreamSimulado[] = []
+ private resolver!: (corpo: ReadableStream) => void
+ private rejeitar!: (erro: Error) => void
+ readonly resposta = new Promise>((resolve, reject) => {
+ this.resolver = resolve
+ this.rejeitar = reject
+ })
+ private controlador!: ReadableStreamDefaultController
+ private corpo = new ReadableStream({
+ start: (controlador) => { this.controlador = controlador },
+ })
fechada = false
- constructor(url: string) {
- this.url = url
- FakeEventSource.instances.push(this)
- }
-
- addEventListener(evento: string, handler: (event: MessageEvent) => void) {
- if (!this.listeners.has(evento)) {
- this.listeners.set(evento, new Set())
- }
- this.listeners.get(evento)!.add(handler)
+ constructor(readonly url: string, signal: AbortSignal) {
+ StreamSimulado.instances.push(this)
+ signal.addEventListener('abort', () => {
+ this.fechada = true
+ const erro = new DOMException('Cancelado', 'AbortError')
+ this.rejeitar(erro)
+ this.controlador.error(erro)
+ }, { once: true })
}
- emitir(evento: string, dados: unknown) {
- const mensagem = new MessageEvent(evento, { data: JSON.stringify(dados) })
- this.listeners.get(evento)?.forEach((handler) => handler(mensagem))
+ async emitir(evento: string, dados: unknown) {
+ await this.emitirBruto(evento, JSON.stringify(dados))
}
- emitirBruto(evento: string, dataCru: string) {
- const mensagem = new MessageEvent(evento, { data: dataCru })
- this.listeners.get(evento)?.forEach((handler) => handler(mensagem))
+ async emitirBruto(evento: string, dados: string) {
+ if (this.fechada) return
+ await this.abrir()
+ this.controlador.enqueue(new TextEncoder().encode(`event: ${evento}\ndata: ${dados}\n\n`))
+ await flushPromises()
}
- abrir() {
- this.onopen?.()
+ async abrir() {
+ this.resolver(this.corpo)
+ await flushPromises()
}
- errar() {
- this.onerror?.()
+ async errar() {
+ const erro = new Error('Conexão interrompida')
+ this.rejeitar(erro)
+ this.controlador.error(erro)
+ await flushPromises()
}
- close() {
- this.fechada = true
+ async concluir() {
+ this.controlador.close()
+ await flushPromises()
}
}
@@ -65,8 +80,8 @@ function novosHandlers() {
describe('jobEvents', () => {
beforeEach(() => {
- FakeEventSource.instances = []
- vi.stubGlobal('EventSource', FakeEventSource as unknown as typeof EventSource)
+ StreamSimulado.instances = []
+ vi.mocked(http.stream).mockImplementation((url, signal) => new StreamSimulado(url, signal).resposta)
vi.useFakeTimers()
})
@@ -76,20 +91,20 @@ describe('jobEvents', () => {
vi.clearAllMocks()
})
- function instanciaAtual(): FakeEventSource {
- const instancia = FakeEventSource.instances[FakeEventSource.instances.length - 1]
- if (!instancia) throw new Error('Nenhuma instância de EventSource foi criada')
+ function instanciaAtual(): StreamSimulado {
+ const instancia = StreamSimulado.instances[StreamSimulado.instances.length - 1]
+ if (!instancia) throw new Error('Nenhum stream foi criado')
return instancia
}
- it('abre o stream com a URL de apiClient.acompanharJob', () => {
+ it('abre o stream com a URL de apiClient.acompanharJob', async () => {
abrirAcompanhamentoJob('job-123', novosHandlers())
expect(apiClient.acompanharJob).toHaveBeenCalledWith('job-123')
expect(instanciaAtual().url).toBe('/api/jobs/job-123/events')
})
- it('sinaliza conectando na primeira abertura, sem reconciliar', () => {
+ it('sinaliza conectando na primeira abertura, sem reconciliar', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
@@ -98,16 +113,16 @@ describe('jobEvents', () => {
expect(handlers.onReconciliar).not.toHaveBeenCalled()
})
- it('sinaliza aberta quando a conexão nativa abre', () => {
+ it('sinaliza aberta quando o servidor abre o stream', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
- instanciaAtual().abrir()
+ await instanciaAtual().abrir()
expect(handlers.onStatusConexao).toHaveBeenLastCalledWith('aberta')
})
- it('despacha o evento estado desserializado para o handler', () => {
+ it('despacha o evento estado desserializado para o handler', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
@@ -116,22 +131,22 @@ describe('jobEvents', () => {
status: 'simulando',
status_anterior: 'gerando_regra',
}
- instanciaAtual().emitir('estado', evento)
+ await instanciaAtual().emitir('estado', evento)
expect(handlers.onEstado).toHaveBeenCalledWith(evento)
})
- it('despacha o evento etapa desserializado para o handler', () => {
+ it('despacha o evento etapa desserializado para o handler', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
const evento: EventoEtapa = { job_id: 'job-123', etapa: 'geracao_codigo', status: 'iniciada' }
- instanciaAtual().emitir('etapa', evento)
+ await instanciaAtual().emitir('etapa', evento)
expect(handlers.onEtapa).toHaveBeenCalledWith(evento)
})
- it('despacha o evento resultado desserializado para o handler', () => {
+ it('despacha o evento resultado desserializado para o handler', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
@@ -141,12 +156,12 @@ describe('jobEvents', () => {
status: 'sucesso',
veredito: 'viavel',
}
- instanciaAtual().emitir('resultado', evento)
+ await instanciaAtual().emitir('resultado', evento)
expect(handlers.onResultado).toHaveBeenCalledWith(evento)
})
- it('deduplica resultado repetido com o mesmo simulacao_id', () => {
+ it('deduplica resultado repetido com o mesmo simulacao_id', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
@@ -157,22 +172,22 @@ describe('jobEvents', () => {
veredito: 'viavel',
}
- instanciaAtual().emitir('resultado', evento)
- instanciaAtual().emitir('resultado', evento)
+ await instanciaAtual().emitir('resultado', evento)
+ await instanciaAtual().emitir('resultado', evento)
expect(handlers.onResultado).toHaveBeenCalledTimes(1)
})
- it('não deduplica resultados de simulacoes distintas', () => {
+ it('não deduplica resultados de simulacoes distintas', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
- instanciaAtual().emitir('resultado', {
+ await instanciaAtual().emitir('resultado', {
job_id: 'job-123',
simulacao_id: 'sim-1',
status: 'sucesso',
} satisfies EventoResultado)
- instanciaAtual().emitir('resultado', {
+ await instanciaAtual().emitir('resultado', {
job_id: 'job-123',
simulacao_id: 'sim-2',
status: 'sucesso',
@@ -181,21 +196,21 @@ describe('jobEvents', () => {
expect(handlers.onResultado).toHaveBeenCalledTimes(2)
})
- it('ignora evento com data que não é JSON válido, sem lançar e sem chamar o handler', () => {
+ it('ignora evento com data que não é JSON válido, sem lançar e sem chamar o handler', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
- expect(() => instanciaAtual().emitirBruto('estado', 'não é JSON')).not.toThrow()
+ await instanciaAtual().emitirBruto('estado', 'não é JSON')
expect(handlers.onEstado).not.toHaveBeenCalled()
})
- it('ao cair a conexão, fecha a instância nativa e sinaliza reconectando imediatamente', () => {
+ it('ao cair a conexão, fecha a requisição e sinaliza reconectando imediatamente', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
- instanciaAtual().abrir()
+ await instanciaAtual().abrir()
const instanciaCaida = instanciaAtual()
- instanciaCaida.errar()
+ await instanciaCaida.errar()
expect(instanciaCaida.fechada).toBe(true)
expect(handlers.onStatusConexao).toHaveBeenLastCalledWith('reconectando')
@@ -211,16 +226,16 @@ describe('jobEvents', () => {
it('reabre uma nova conexão após o delay de backoff', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
- instanciaAtual().abrir()
- instanciaAtual().errar()
+ await instanciaAtual().abrir()
+ await instanciaAtual().errar()
- expect(FakeEventSource.instances).toHaveLength(1)
+ expect(StreamSimulado.instances).toHaveLength(1)
await vi.advanceTimersByTimeAsync(ANTES_DO_MINIMO_TENTATIVA_1)
- expect(FakeEventSource.instances).toHaveLength(1)
+ expect(StreamSimulado.instances).toHaveLength(1)
await vi.advanceTimersByTimeAsync(DEPOIS_DO_MAXIMO_TENTATIVA_1 - ANTES_DO_MINIMO_TENTATIVA_1)
- expect(FakeEventSource.instances).toHaveLength(2)
+ expect(StreamSimulado.instances).toHaveLength(2)
})
it('cresce o delay exponencialmente em erros consecutivos sem sucesso entre eles', async () => {
@@ -228,18 +243,18 @@ describe('jobEvents', () => {
abrirAcompanhamentoJob('job-123', handlers)
// 1º erro (antes de qualquer onopen): delay base ~1000ms
- instanciaAtual().errar()
+ await instanciaAtual().errar()
await vi.advanceTimersByTimeAsync(ANTES_DO_MINIMO_TENTATIVA_1)
- expect(FakeEventSource.instances).toHaveLength(1)
+ expect(StreamSimulado.instances).toHaveLength(1)
await vi.advanceTimersByTimeAsync(DEPOIS_DO_MAXIMO_TENTATIVA_1 - ANTES_DO_MINIMO_TENTATIVA_1)
- expect(FakeEventSource.instances).toHaveLength(2)
+ expect(StreamSimulado.instances).toHaveLength(2)
// 2º erro consecutivo, sem onopen no meio: delay deve dobrar para ~2000ms
- instanciaAtual().errar()
+ await instanciaAtual().errar()
await vi.advanceTimersByTimeAsync(ANTES_DO_MINIMO_TENTATIVA_2)
- expect(FakeEventSource.instances).toHaveLength(2)
+ expect(StreamSimulado.instances).toHaveLength(2)
await vi.advanceTimersByTimeAsync(DEPOIS_DO_MAXIMO_TENTATIVA_2 - ANTES_DO_MINIMO_TENTATIVA_2)
- expect(FakeEventSource.instances).toHaveLength(3)
+ expect(StreamSimulado.instances).toHaveLength(3)
})
it('reseta o backoff para o valor base após uma reconexão bem-sucedida', async () => {
@@ -247,28 +262,28 @@ describe('jobEvents', () => {
abrirAcompanhamentoJob('job-123', handlers)
// 1º erro, reconecta após o delay base
- instanciaAtual().errar()
+ await instanciaAtual().errar()
await vi.advanceTimersByTimeAsync(DEPOIS_DO_MAXIMO_TENTATIVA_1)
- expect(FakeEventSource.instances).toHaveLength(2)
+ expect(StreamSimulado.instances).toHaveLength(2)
// A nova conexão abre com sucesso, resetando o backoff
- instanciaAtual().abrir()
- instanciaAtual().errar()
+ await instanciaAtual().abrir()
+ await instanciaAtual().errar()
// Se o backoff não tivesse resetado, o delay teria dobrado (mínimo ~2000ms) e não
// dispararia ainda em 1500ms; reconectar aqui prova que voltou ao delay base (~1000-1200ms).
await vi.advanceTimersByTimeAsync(1500)
- expect(FakeEventSource.instances).toHaveLength(3)
+ expect(StreamSimulado.instances).toHaveLength(3)
})
it('solicita reconciliação ao reabrir a conexão', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
expect(handlers.onReconciliar).not.toHaveBeenCalled()
- instanciaAtual().abrir()
- instanciaAtual().errar()
+ await instanciaAtual().abrir()
+ await instanciaAtual().errar()
await vi.advanceTimersByTimeAsync(1250)
- expect(FakeEventSource.instances).toHaveLength(2)
+ expect(StreamSimulado.instances).toHaveLength(2)
expect(handlers.onReconciliar).toHaveBeenCalledOnce()
})
@@ -277,25 +292,25 @@ describe('jobEvents', () => {
const { fechar } = abrirAcompanhamentoJob('job-123', handlers)
const antiga = instanciaAtual()
fechar()
- antiga.emitir('estado', { job_id: 'job-123', status: 'simulando' })
- antiga.errar()
+ await antiga.emitir('estado', { job_id: 'job-123', status: 'simulando' })
+ await antiga.errar()
await vi.advanceTimersByTimeAsync(60000)
expect(handlers.onEstado).not.toHaveBeenCalled()
- expect(FakeEventSource.instances).toHaveLength(1)
+ expect(StreamSimulado.instances).toHaveLength(1)
})
- it('fechar() encerra a conexão nativa e cancela o timer de reconexão pendente', async () => {
+ it('fechar() encerra a requisição e cancela o timer de reconexão pendente', async () => {
const handlers = novosHandlers()
const { fechar } = abrirAcompanhamentoJob('job-123', handlers)
- instanciaAtual().abrir()
- instanciaAtual().errar()
+ await instanciaAtual().abrir()
+ await instanciaAtual().errar()
fechar()
expect(instanciaAtual().fechada).toBe(true)
await vi.advanceTimersByTimeAsync(5000)
- expect(FakeEventSource.instances).toHaveLength(1)
+ expect(StreamSimulado.instances).toHaveLength(1)
})
describe('encerramento pelo servidor em estado terminal', () => {
@@ -304,48 +319,48 @@ describe('jobEvents', () => {
async (statusTerminal) => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
- instanciaAtual().abrir()
+ await instanciaAtual().abrir()
// Fotografia final: o contrato diz que o servidor manda o estado terminal e fecha.
- instanciaAtual().emitir('estado', {
+ await instanciaAtual().emitir('estado', {
job_id: 'job-123',
status: statusTerminal,
} satisfies EventoEstado)
- instanciaAtual().errar()
+ await instanciaAtual().concluir()
// Sem o fix, isso reabriria indefinidamente. Avança bem além de qualquer backoff possível.
await vi.advanceTimersByTimeAsync(60000)
- expect(FakeEventSource.instances).toHaveLength(1)
+ expect(StreamSimulado.instances).toHaveLength(1)
},
)
it('continua reconectando normalmente após um estado não-terminal seguido de erro', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
- instanciaAtual().abrir()
+ await instanciaAtual().abrir()
- instanciaAtual().emitir('estado', {
+ await instanciaAtual().emitir('estado', {
job_id: 'job-123',
status: 'simulando',
} satisfies EventoEstado)
- instanciaAtual().errar()
+ await instanciaAtual().errar()
await vi.advanceTimersByTimeAsync(DEPOIS_DO_MAXIMO_TENTATIVA_1)
- expect(FakeEventSource.instances).toHaveLength(2)
+ expect(StreamSimulado.instances).toHaveLength(2)
})
- it('não emite reconectando quando o fechamento é o desfecho esperado do estado terminal', () => {
+ it('não emite reconectando quando o fechamento é o desfecho esperado do estado terminal', async () => {
const handlers = novosHandlers()
abrirAcompanhamentoJob('job-123', handlers)
- instanciaAtual().abrir()
+ await instanciaAtual().abrir()
- instanciaAtual().emitir('estado', {
+ await instanciaAtual().emitir('estado', {
job_id: 'job-123',
status: 'liberado',
} satisfies EventoEstado)
- instanciaAtual().errar()
+ await instanciaAtual().errar()
expect(handlers.onStatusConexao).not.toHaveBeenCalledWith('reconectando')
})
diff --git a/frontend/src/services/jobEvents.ts b/frontend/src/services/jobEvents.ts
index 7506a0a..bd85c17 100644
--- a/frontend/src/services/jobEvents.ts
+++ b/frontend/src/services/jobEvents.ts
@@ -1,4 +1,6 @@
import { apiClient } from '@/services/api'
+import { http } from '@/services/http'
+import { consumirEventosSse } from '@/services/sse'
import type { EventoEtapa, EventoEstado, EventoResultado, StatusJob } from '@/types/api'
interface HandlersAcompanhamento {
@@ -17,7 +19,7 @@ const STATUS_TERMINAIS: ReadonlySet = new Set([
])
export function abrirAcompanhamentoJob(idJob: string, handlers: HandlersAcompanhamento) {
- let eventSource: EventSource | null = null
+ let conexaoAtual: AbortController | null = null
let timerReconexao: ReturnType | null = null
let tentativasBackoff = 0
let jaConectouAlgumaVez = false
@@ -25,72 +27,76 @@ export function abrirAcompanhamentoJob(idJob: string, handlers: HandlersAcompanh
let jobEmEstadoTerminal = false
let fechado = false
- function abrirConexao() {
+ function receberEvento(evento: string, dados: string) {
+ try {
+ switch (evento) {
+ case 'estado': {
+ const estado = JSON.parse(dados) as EventoEstado
+ if (estado.job_id !== idJob) return
+ jobEmEstadoTerminal = STATUS_TERMINAIS.has(estado.status)
+ handlers.onEstado(estado)
+ break
+ }
+ case 'etapa': {
+ const etapa = JSON.parse(dados) as EventoEtapa
+ if (etapa.job_id === idJob) handlers.onEtapa(etapa)
+ break
+ }
+ case 'resultado': {
+ const resultado = JSON.parse(dados) as EventoResultado
+ if (resultado.job_id === idJob && resultado.simulacao_id !== ultimaSimulacaoIdProcessada) {
+ ultimaSimulacaoIdProcessada = resultado.simulacao_id
+ handlers.onResultado(resultado)
+ }
+ break
+ }
+ }
+ } catch {
+ // Um evento malformado não deve interromper o acompanhamento.
+ }
+ }
+
+ async function abrirConexao() {
if (fechado) return
if (jaConectouAlgumaVez) handlers.onReconciliar()
else handlers.onStatusConexao('conectando')
- const conexao = new EventSource(apiClient.acompanharJob(idJob))
- eventSource = conexao
- const ativa = () => !fechado && eventSource === conexao
+ const conexao = new AbortController()
+ conexaoAtual = conexao
+ const ativa = () => !fechado && conexaoAtual === conexao
- conexao.addEventListener('estado', (evento: MessageEvent) => {
- if (!ativa()) return
- try {
- const dados = JSON.parse(evento.data) as EventoEstado
- if (dados.job_id !== idJob) return
- jobEmEstadoTerminal = STATUS_TERMINAIS.has(dados.status)
- handlers.onEstado(dados)
- } catch {
- // Um evento malformado não deve interromper o acompanhamento.
- }
- })
- conexao.addEventListener('etapa', (evento: MessageEvent) => {
- if (!ativa()) return
- try {
- const dados = JSON.parse(evento.data) as EventoEtapa
- if (dados.job_id === idJob) handlers.onEtapa(dados)
- } catch {
- // Um evento malformado não deve interromper o acompanhamento.
- }
- })
- conexao.addEventListener('resultado', (evento: MessageEvent) => {
- if (!ativa()) return
- try {
- const dados = JSON.parse(evento.data) as EventoResultado
- if (dados.job_id === idJob && ultimaSimulacaoIdProcessada !== dados.simulacao_id) {
- ultimaSimulacaoIdProcessada = dados.simulacao_id
- handlers.onResultado(dados)
- }
- } catch {
- // Um evento malformado não deve interromper o acompanhamento.
+ try {
+ const corpo = await http.stream(apiClient.acompanharJob(idJob), conexao.signal)
+ if (!ativa()) {
+ await corpo.cancel()
+ return
}
- })
- conexao.onopen = () => {
- if (!ativa()) return
jaConectouAlgumaVez = true
tentativasBackoff = 0
handlers.onStatusConexao('aberta')
+ await consumirEventosSse(corpo, (evento, dados) => {
+ if (ativa()) receberEvento(evento, dados)
+ })
+ } catch {
+ // A reconexão também recupera quedas durante a leitura do corpo da resposta.
}
- conexao.onerror = () => {
- if (!ativa()) return
- conexao.close()
- eventSource = null
- // O fechamento pelo servidor após um estado terminal também dispara onerror.
- if (jobEmEstadoTerminal) return
- handlers.onStatusConexao('reconectando')
- const delay = Math.min(1000 * Math.pow(2, tentativasBackoff++), 30000) + Math.random() * 200
- timerReconexao = setTimeout(abrirConexao, delay)
- }
+
+ if (!ativa()) return
+ conexao.abort()
+ conexaoAtual = null
+ if (jobEmEstadoTerminal) return
+ handlers.onStatusConexao('reconectando')
+ const delay = Math.min(1000 * Math.pow(2, tentativasBackoff++), 30000) + Math.random() * 200
+ timerReconexao = setTimeout(() => void abrirConexao(), delay)
}
function fechar() {
fechado = true
if (timerReconexao) clearTimeout(timerReconexao)
- eventSource?.close()
- eventSource = null
+ conexaoAtual?.abort()
+ conexaoAtual = null
}
- abrirConexao()
+ void abrirConexao()
return { fechar }
}
diff --git a/frontend/src/services/keycloak.spec.ts b/frontend/src/services/keycloak.spec.ts
new file mode 100644
index 0000000..96b0a73
--- /dev/null
+++ b/frontend/src/services/keycloak.spec.ts
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { obterTokenDeAcesso } from './keycloak'
+
+const cliente = vi.hoisted(() => ({
+ token: undefined as string | undefined,
+ updateToken: vi.fn<(minValidity: number) => Promise>(),
+}))
+
+vi.mock('keycloak-js', () => ({
+ default: vi.fn<() => typeof cliente>(function () { return cliente }),
+}))
+
+beforeEach(() => {
+ cliente.token = 'token-atual'
+ cliente.updateToken.mockReset().mockResolvedValue(false)
+})
+
+describe('renovação do token', () => {
+ it('aguarda a renovação e devolve o novo token', async () => {
+ let concluirRenovacao!: (valor: boolean) => void
+ const renovacao = new Promise((resolve) => { concluirRenovacao = resolve })
+ cliente.updateToken.mockReturnValue(renovacao)
+
+ const token = obterTokenDeAcesso()
+ expect(cliente.updateToken).toHaveBeenCalledWith(30)
+ cliente.token = 'token-renovado'
+ concluirRenovacao(true)
+
+ await expect(token).resolves.toBe('token-renovado')
+ })
+
+ it('reutiliza o token quando o adaptador confirma sua validade', async () => {
+ await expect(obterTokenDeAcesso()).resolves.toBe('token-atual')
+ })
+
+ it('não tenta renovar antes do login', async () => {
+ cliente.token = undefined
+ await expect(obterTokenDeAcesso()).resolves.toBeUndefined()
+ expect(cliente.updateToken).not.toHaveBeenCalled()
+ })
+
+ it('não devolve token antigo quando a renovação falha por indisponibilidade', async () => {
+ cliente.updateToken.mockRejectedValue(new Error('Indisponível'))
+ await expect(obterTokenDeAcesso()).rejects.toThrow('Indisponível')
+ expect(cliente.token).toBe('token-atual')
+ })
+
+ it('devolve sessão ausente quando o adaptador invalida o refresh token', async () => {
+ cliente.updateToken.mockImplementation(async () => {
+ cliente.token = undefined
+ throw new Error('Sessão expirada')
+ })
+ await expect(obterTokenDeAcesso()).resolves.toBeUndefined()
+ })
+})
diff --git a/frontend/src/services/keycloak.ts b/frontend/src/services/keycloak.ts
new file mode 100644
index 0000000..af0a763
--- /dev/null
+++ b/frontend/src/services/keycloak.ts
@@ -0,0 +1,58 @@
+import Keycloak from 'keycloak-js'
+import { config } from '@/config/env'
+
+let clienteKeycloak: Keycloak | undefined
+
+function obterClienteKeycloak(): Keycloak {
+ clienteKeycloak ??= new Keycloak({
+ url: config.keycloakUrl,
+ realm: config.keycloakRealm,
+ clientId: config.keycloakClientId,
+ })
+ return clienteKeycloak
+}
+
+function urlDeRetorno(caminhoDeRetorno?: string): string {
+ const origemDaAplicacao = new URL(config.baseUrl, window.location.origin)
+
+ if (!caminhoDeRetorno?.startsWith('/')) return origemDaAplicacao.toString()
+
+ return new URL(caminhoDeRetorno, origemDaAplicacao.origin).toString()
+}
+
+export async function inicializarKeycloak(): Promise {
+ return obterClienteKeycloak().init({
+ onLoad: 'check-sso',
+ checkLoginIframe: false,
+ pkceMethod: 'S256',
+ silentCheckSsoRedirectUri: new URL(`${config.baseUrl}silent-check-sso.html`, window.location.origin).toString(),
+ })
+}
+
+export async function obterTokenDeAcesso(): Promise {
+ const cliente = obterClienteKeycloak()
+ if (!cliente.token) return undefined
+
+ try {
+ await cliente.updateToken(30)
+ } catch (error) {
+ // O adaptador limpa o token quando o refresh é rejeitado por sessão inválida.
+ if (!cliente.token) return undefined
+ throw error
+ }
+ return cliente.token
+}
+
+export async function iniciarLogin(caminhoDeRetorno?: string): Promise {
+ await obterClienteKeycloak().login({ redirectUri: urlDeRetorno(caminhoDeRetorno) })
+}
+
+export async function iniciarLogout(): Promise {
+ const cliente = obterClienteKeycloak()
+ cliente.clearToken()
+ await cliente.logout({ redirectUri: urlDeRetorno() })
+}
+
+export function limparTokenDoKeycloak(): void {
+ obterClienteKeycloak().clearToken()
+}
diff --git a/frontend/src/services/sse.spec.ts b/frontend/src/services/sse.spec.ts
new file mode 100644
index 0000000..59d67ef
--- /dev/null
+++ b/frontend/src/services/sse.spec.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it, vi } from 'vitest'
+import { consumirEventosSse } from './sse'
+
+function corpo(fragmentos: Uint8Array[]) {
+ return new ReadableStream({
+ start(controlador) {
+ fragmentos.forEach((fragmento) => controlador.enqueue(fragmento))
+ controlador.close()
+ },
+ })
+}
+
+describe('leitura de SSE', () => {
+ it.each(['\n', '\r\n', '\r'])('lê UTF-8, comentários e várias linhas data com separador %j', async (separador) => {
+ const texto = [
+ '\uFEFF: heartbeat', '',
+ 'event: estado', 'id: 1', 'data: {"mensagem":', 'data: "geração"}', '',
+ ': heartbeat', '',
+ 'event: etapa', 'data: {"etapa":"simulação"}', '', '',
+ ].join(separador)
+ const bytes = new TextEncoder().encode(texto)
+ const receber = vi.fn<(evento: string, dados: string) => void>()
+
+ await consumirEventosSse(corpo(Array.from(bytes, (byte) => new Uint8Array([byte]))), receber)
+
+ expect(receber.mock.calls).toEqual([
+ ['estado', '{"mensagem":\n"geração"}'],
+ ['etapa', '{"etapa":"simulação"}'],
+ ])
+ })
+
+ it('não publica um evento incompleto no fim do stream', async () => {
+ const receber = vi.fn<(evento: string, dados: string) => void>()
+ await consumirEventosSse(corpo([new TextEncoder().encode('event: estado\ndata: {}\n')]), receber)
+ expect(receber).not.toHaveBeenCalled()
+ })
+
+ it('lê vários eventos no mesmo fragmento e reinicia o nome após cada bloco', async () => {
+ const receber = vi.fn<(evento: string, dados: string) => void>()
+ await consumirEventosSse(corpo([new TextEncoder().encode('event: estado\ndata: um\n\ndata: dois\n\n')]), receber)
+ expect(receber.mock.calls).toEqual([['estado', 'um'], ['message', 'dois']])
+ })
+})
diff --git a/frontend/src/services/sse.ts b/frontend/src/services/sse.ts
new file mode 100644
index 0000000..33cb3ed
--- /dev/null
+++ b/frontend/src/services/sse.ts
@@ -0,0 +1,53 @@
+export async function consumirEventosSse(
+ corpo: ReadableStream,
+ receber: (evento: string, dados: string) => void,
+): Promise {
+ const leitor = corpo.getReader()
+ const decodificador = new TextDecoder()
+ let linha = ''
+ let ignorarLf = false
+ let evento = ''
+ let dados: string[] = []
+
+ function processarLinha() {
+ if (linha === '') {
+ if (dados.length > 0) receber(evento || 'message', dados.join('\n'))
+ evento = ''
+ dados = []
+ return
+ }
+ if (linha.startsWith(':')) return
+
+ const separador = linha.indexOf(':')
+ const campo = separador === -1 ? linha : linha.slice(0, separador)
+ let valor = separador === -1 ? '' : linha.slice(separador + 1)
+ if (valor.startsWith(' ')) valor = valor.slice(1)
+ if (campo === 'event') evento = valor
+ if (campo === 'data') dados.push(valor)
+ }
+
+ try {
+ while (true) {
+ const { done, value } = await leitor.read()
+ if (done) break
+
+ for (const caractere of decodificador.decode(value, { stream: true })) {
+ if (ignorarLf && caractere === '\n') {
+ ignorarLf = false
+ continue
+ }
+ ignorarLf = false
+ if (caractere === '\r' || caractere === '\n') {
+ processarLinha()
+ linha = ''
+ ignorarLf = caractere === '\r'
+ } else {
+ linha += caractere
+ }
+ }
+ }
+ } finally {
+ await leitor.cancel().catch(() => undefined)
+ leitor.releaseLock()
+ }
+}
diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts
index 05d2c89..ae27f2e 100644
--- a/frontend/src/stores/session.ts
+++ b/frontend/src/stores/session.ts
@@ -1,9 +1,38 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
+import { iniciarLogin, iniciarLogout, inicializarKeycloak, limparTokenDoKeycloak } from '@/services/keycloak'
-/* Esboço do estado global de sessão. A autenticação será introduzida na T-071. */
-export const usarStoreSessao = defineStore('session', () => {
+export const usarStoreSessao = defineStore('sessao', () => {
const estaAutenticado = ref(false)
+ const inicializando = ref(false)
+ const indisponivel = ref(false)
- return { estaAutenticado }
+ async function inicializar(): Promise {
+ inicializando.value = true
+ indisponivel.value = false
+ try {
+ estaAutenticado.value = await inicializarKeycloak()
+ } catch {
+ estaAutenticado.value = false
+ indisponivel.value = true
+ } finally {
+ inicializando.value = false
+ }
+ }
+
+ async function entrar(caminhoDeRetorno?: string): Promise {
+ await iniciarLogin(caminhoDeRetorno)
+ }
+
+ async function sair(): Promise {
+ estaAutenticado.value = false
+ await iniciarLogout()
+ }
+
+ function encerrarPorSessaoInvalida(): void {
+ limparTokenDoKeycloak()
+ estaAutenticado.value = false
+ }
+
+ return { estaAutenticado, inicializando, indisponivel, inicializar, entrar, sair, encerrarPorSessaoInvalida }
})
diff --git a/frontend/src/types/router.d.ts b/frontend/src/types/router.d.ts
index 0f9d7f3..a52e9fa 100644
--- a/frontend/src/types/router.d.ts
+++ b/frontend/src/types/router.d.ts
@@ -9,5 +9,7 @@ declare module 'vue-router' {
navLabel?: string
/** Position in the menu (lower first). */
navOrder?: number
+ /** Rotas públicas não exigem uma sessão ativa. */
+ publica?: boolean
}
}