From 2aaf23b5e7e1cdb9fd341bcc5a8134fbfed26564 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:15:29 +0000 Subject: [PATCH 1/2] feat(EM-39): implement Spring Security foundation and authentication configuration - Add spring-security-test, jjwt deps to version catalog (libs.versions.toml) - Include ftgo-security-lib in settings.gradle with projectDir mapping - Wire SecurityExceptionHandler into FtgoSecurityConfiguration for JSON 401/403 responses - Update JwtTokenProvider to use jjwt 0.12.x API (parser/verifyWith/parseSignedClaims) - Add test dependencies (spring-boot-starter-web, spring-security-test) for integration tests - Create comprehensive integration tests: - FtgoSecurityConfigurationIntegrationTest (actuator, CORS, CSRF, session, auth) - SecurityExceptionHandlerTest (401/403 JSON responses) - FtgoCorsConfigurationSourceTest (origins, methods, headers, credentials) - JwtTokenProviderTest (generate, validate, parse, reject invalid) - JwtAuthenticationFilterTest (auth flow, missing/invalid tokens) Co-Authored-By: mason.batchelor@cognition.ai --- gradle/platform/libs.versions.toml | 11 ++ libs/ftgo-security-lib/build.gradle | 7 +- .../security/FtgoSecurityConfiguration.java | 7 ++ .../ftgo/security/jwt/JwtTokenProvider.java | 19 ++- .../FtgoCorsConfigurationSourceTest.java | 72 ++++++++++++ ...oSecurityConfigurationIntegrationTest.java | 110 ++++++++++++++++++ .../SecurityExceptionHandlerTest.java | 48 ++++++++ .../jwt/JwtAuthenticationFilterTest.java | 94 +++++++++++++++ .../security/jwt/JwtTokenProviderTest.java | 87 ++++++++++++++ .../src/test/resources/application.properties | 6 + settings.gradle | 4 + 11 files changed, 452 insertions(+), 13 deletions(-) create mode 100644 libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/FtgoCorsConfigurationSourceTest.java create mode 100644 libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/FtgoSecurityConfigurationIntegrationTest.java create mode 100644 libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/SecurityExceptionHandlerTest.java create mode 100644 libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/jwt/JwtAuthenticationFilterTest.java create mode 100644 libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/jwt/JwtTokenProviderTest.java create mode 100644 libs/ftgo-security-lib/src/test/resources/application.properties diff --git a/gradle/platform/libs.versions.toml b/gradle/platform/libs.versions.toml index ef64b238..ee15a6df 100644 --- a/gradle/platform/libs.versions.toml +++ b/gradle/platform/libs.versions.toml @@ -20,6 +20,10 @@ rest-assured = "5.4.0" mockito = "5.8.0" testcontainers = "1.19.3" +# Security +jjwt = "0.12.3" +spring-security = "6.2.1" + # Observability micrometer = "1.12.2" micrometer-tracing = "1.2.2" @@ -44,6 +48,12 @@ spring-boot-starter-security = { module = "org.springframework.boot:spring-boot- spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" } +# Security +spring-security-test = { module = "org.springframework.security:spring-security-test", version.ref = "spring-security" } +jjwt-api = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwt" } +jjwt-impl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwt" } +jjwt-jackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jjwt" } + # Database flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } flyway-mysql = { module = "org.flywaydb:flyway-mysql", version.ref = "flyway" } @@ -80,6 +90,7 @@ mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.re spring-web = ["spring-boot-starter-web", "spring-boot-starter-actuator", "spring-boot-starter-validation"] spring-data = ["spring-boot-starter-data-jpa", "flyway-core", "flyway-mysql"] spring-test = ["spring-boot-starter-test", "junit-jupiter", "mockito-core", "mockito-junit"] +spring-security = ["spring-boot-starter-security", "jjwt-api"] observability = ["micrometer-registry-prometheus", "micrometer-tracing-bridge-brave"] [plugins] diff --git a/libs/ftgo-security-lib/build.gradle b/libs/ftgo-security-lib/build.gradle index 59634525..92495153 100644 --- a/libs/ftgo-security-lib/build.gradle +++ b/libs/ftgo-security-lib/build.gradle @@ -28,12 +28,13 @@ dependencies { compileOnly 'org.springframework.boot:spring-boot-autoconfigure:3.2.0' // Testing - testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' + testImplementation 'org.springframework.boot:spring-boot-starter-web:3.2.0' testImplementation 'org.springframework.boot:spring-boot-starter-test:3.2.0' - testImplementation 'org.springframework.security:spring-security-test:6.2.0' + testImplementation 'org.springframework.security:spring-security-test:6.2.1' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' } -tasks.named('test') { +test { useJUnitPlatform() } diff --git a/libs/ftgo-security-lib/src/main/java/net/chrisrichardson/ftgo/security/FtgoSecurityConfiguration.java b/libs/ftgo-security-lib/src/main/java/net/chrisrichardson/ftgo/security/FtgoSecurityConfiguration.java index 06a431a2..f497ea35 100644 --- a/libs/ftgo-security-lib/src/main/java/net/chrisrichardson/ftgo/security/FtgoSecurityConfiguration.java +++ b/libs/ftgo-security-lib/src/main/java/net/chrisrichardson/ftgo/security/FtgoSecurityConfiguration.java @@ -19,6 +19,8 @@ @EnableWebSecurity public class FtgoSecurityConfiguration { + private final SecurityExceptionHandler securityExceptionHandler = new SecurityExceptionHandler(); + @Bean public SecurityFilterChain ftgoSecurityFilterChain(HttpSecurity http) throws Exception { http @@ -44,6 +46,11 @@ public SecurityFilterChain ftgoSecurityFilterChain(HttpSecurity http) throws Exc // All other endpoints require authentication .anyRequest().authenticated()) + // Exception handling with JSON responses + .exceptionHandling(ex -> ex + .authenticationEntryPoint(securityExceptionHandler) + .accessDeniedHandler(securityExceptionHandler)) + // CORS configuration .cors(cors -> cors.configurationSource(new FtgoCorsConfigurationSource())); diff --git a/libs/ftgo-security-lib/src/main/java/net/chrisrichardson/ftgo/security/jwt/JwtTokenProvider.java b/libs/ftgo-security-lib/src/main/java/net/chrisrichardson/ftgo/security/jwt/JwtTokenProvider.java index 087df6f0..1693299e 100644 --- a/libs/ftgo-security-lib/src/main/java/net/chrisrichardson/ftgo/security/jwt/JwtTokenProvider.java +++ b/libs/ftgo-security-lib/src/main/java/net/chrisrichardson/ftgo/security/jwt/JwtTokenProvider.java @@ -3,7 +3,6 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -49,25 +48,25 @@ private String generateToken(String subject, List roles, long expiration Date expiry = new Date(now.getTime() + expirationMs); var builder = Jwts.builder() - .setSubject(subject) - .setIssuer(issuer) - .setIssuedAt(now) - .setExpiration(expiry) + .subject(subject) + .issuer(issuer) + .issuedAt(now) + .expiration(expiry) .claim("type", tokenType); if (roles != null) { builder.claim("roles", roles); } - return builder.signWith(signingKey, SignatureAlgorithm.HS256).compact(); + return builder.signWith(signingKey).compact(); } public Claims parseToken(String token) { - return Jwts.parserBuilder() - .setSigningKey(signingKey) + return Jwts.parser() + .verifyWith(signingKey) .build() - .parseClaimsJws(token) - .getBody(); + .parseSignedClaims(token) + .getPayload(); } public boolean validateToken(String token) { diff --git a/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/FtgoCorsConfigurationSourceTest.java b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/FtgoCorsConfigurationSourceTest.java new file mode 100644 index 00000000..16e26d2b --- /dev/null +++ b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/FtgoCorsConfigurationSourceTest.java @@ -0,0 +1,72 @@ +package net.chrisrichardson.ftgo.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.cors.CorsConfiguration; + +import static org.junit.jupiter.api.Assertions.*; + +class FtgoCorsConfigurationSourceTest { + + private final FtgoCorsConfigurationSource source = new FtgoCorsConfigurationSource(); + + @Test + void shouldAllowAllOriginPatterns() { + MockHttpServletRequest request = new MockHttpServletRequest(); + CorsConfiguration config = source.getCorsConfiguration(request); + + assertNotNull(config); + assertNotNull(config.getAllowedOriginPatterns()); + assertTrue(config.getAllowedOriginPatterns().contains("*")); + } + + @Test + void shouldAllowStandardHttpMethods() { + MockHttpServletRequest request = new MockHttpServletRequest(); + CorsConfiguration config = source.getCorsConfiguration(request); + + assertNotNull(config.getAllowedMethods()); + assertTrue(config.getAllowedMethods().contains("GET")); + assertTrue(config.getAllowedMethods().contains("POST")); + assertTrue(config.getAllowedMethods().contains("PUT")); + assertTrue(config.getAllowedMethods().contains("PATCH")); + assertTrue(config.getAllowedMethods().contains("DELETE")); + assertTrue(config.getAllowedMethods().contains("OPTIONS")); + } + + @Test + void shouldAllowRequiredHeaders() { + MockHttpServletRequest request = new MockHttpServletRequest(); + CorsConfiguration config = source.getCorsConfiguration(request); + + assertNotNull(config.getAllowedHeaders()); + assertTrue(config.getAllowedHeaders().contains("Authorization")); + assertTrue(config.getAllowedHeaders().contains("Content-Type")); + } + + @Test + void shouldExposeAuthorizationHeader() { + MockHttpServletRequest request = new MockHttpServletRequest(); + CorsConfiguration config = source.getCorsConfiguration(request); + + assertNotNull(config.getExposedHeaders()); + assertTrue(config.getExposedHeaders().contains("Authorization")); + assertTrue(config.getExposedHeaders().contains("X-Request-Id")); + } + + @Test + void shouldAllowCredentials() { + MockHttpServletRequest request = new MockHttpServletRequest(); + CorsConfiguration config = source.getCorsConfiguration(request); + + assertTrue(config.getAllowCredentials()); + } + + @Test + void shouldSetMaxAge() { + MockHttpServletRequest request = new MockHttpServletRequest(); + CorsConfiguration config = source.getCorsConfiguration(request); + + assertEquals(3600L, config.getMaxAge()); + } +} diff --git a/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/FtgoSecurityConfigurationIntegrationTest.java b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/FtgoSecurityConfigurationIntegrationTest.java new file mode 100644 index 00000000..aa812267 --- /dev/null +++ b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/FtgoSecurityConfigurationIntegrationTest.java @@ -0,0 +1,110 @@ +package net.chrisrichardson.ftgo.security; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +class FtgoSecurityConfigurationIntegrationTest { + + @SpringBootApplication + @RestController + static class TestApp { + @GetMapping("/api/test") + public String securedEndpoint() { + return "secured"; + } + } + + @Autowired + private MockMvc mockMvc; + + @Test + void actuatorHealthEndpointShouldBePublic() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()); + } + + @Test + void actuatorInfoEndpointShouldBePublic() throws Exception { + mockMvc.perform(get("/actuator/info")) + .andExpect(status().isOk()); + } + + @Test + void securedEndpointShouldReturn401WithoutAuth() throws Exception { + mockMvc.perform(get("/api/test")) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value(401)) + .andExpect(jsonPath("$.error").value("UNAUTHORIZED")) + .andExpect(jsonPath("$.message").exists()) + .andExpect(jsonPath("$.path").value("/api/test")) + .andExpect(jsonPath("$.timestamp").exists()); + } + + @Test + void swaggerUiEndpointShouldNotRequireAuth() throws Exception { + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isNotFound()); + } + + @Test + void csrfShouldBeDisabledForStatelessApis() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(cookie().doesNotExist("XSRF-TOKEN")); + } + + @Test + void sessionShouldNotBeCreated() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist("Set-Cookie")); + } + + @Test + void corsPreflightRequestShouldSucceed() throws Exception { + mockMvc.perform(options("/api/test") + .header("Origin", "http://localhost:3000") + .header("Access-Control-Request-Method", "GET") + .header("Access-Control-Request-Headers", "Authorization")) + .andExpect(status().isOk()) + .andExpect(header().exists("Access-Control-Allow-Origin")) + .andExpect(header().exists("Access-Control-Allow-Methods")); + } + + @Test + void corsResponseShouldIncludeExpectedHeaders() throws Exception { + mockMvc.perform(options("/api/test") + .header("Origin", "http://localhost:3000") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "Content-Type")) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:3000")) + .andExpect(header().string("Access-Control-Allow-Credentials", "true")); + } + + @Test + void otherActuatorEndpointsShouldRequireAuth() throws Exception { + mockMvc.perform(get("/actuator/env")) + .andExpect(status().isUnauthorized()); + } + + @Test + void prometheusEndpointShouldNotRequireAuth() throws Exception { + mockMvc.perform(get("/actuator/prometheus")) + .andExpect(status().isNotFound()); + } +} diff --git a/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/SecurityExceptionHandlerTest.java b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/SecurityExceptionHandlerTest.java new file mode 100644 index 00000000..61139b24 --- /dev/null +++ b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/SecurityExceptionHandlerTest.java @@ -0,0 +1,48 @@ +package net.chrisrichardson.ftgo.security; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; + +import static org.junit.jupiter.api.Assertions.*; + +class SecurityExceptionHandlerTest { + + private final SecurityExceptionHandler handler = new SecurityExceptionHandler(); + + @Test + void commenceShouldReturn401WithJsonBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/orders"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.commence(request, response, new BadCredentialsException("Bad credentials")); + + assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); + assertEquals("application/json", response.getContentType()); + String body = response.getContentAsString(); + assertTrue(body.contains("\"status\":401")); + assertTrue(body.contains("\"error\":\"UNAUTHORIZED\"")); + assertTrue(body.contains("\"path\":\"/api/orders\"")); + assertTrue(body.contains("\"timestamp\"")); + } + + @Test + void handleShouldReturn403WithJsonBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/admin"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.handle(request, response, new AccessDeniedException("Access denied")); + + assertEquals(HttpStatus.FORBIDDEN.value(), response.getStatus()); + assertEquals("application/json", response.getContentType()); + String body = response.getContentAsString(); + assertTrue(body.contains("\"status\":403")); + assertTrue(body.contains("\"error\":\"FORBIDDEN\"")); + assertTrue(body.contains("\"path\":\"/api/admin\"")); + } +} diff --git a/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/jwt/JwtAuthenticationFilterTest.java b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/jwt/JwtAuthenticationFilterTest.java new file mode 100644 index 00000000..96e30d97 --- /dev/null +++ b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/jwt/JwtAuthenticationFilterTest.java @@ -0,0 +1,94 @@ +package net.chrisrichardson.ftgo.security.jwt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class JwtAuthenticationFilterTest { + + private JwtTokenProvider tokenProvider; + private JwtAuthenticationFilter filter; + + @BeforeEach + void setUp() { + tokenProvider = new JwtTokenProvider( + "test-secret-key-must-be-at-least-256-bits-long-for-hs256", + 900000, + 86400000, + "ftgo-test" + ); + filter = new JwtAuthenticationFilter(tokenProvider); + SecurityContextHolder.clearContext(); + } + + @Test + void shouldSetAuthenticationForValidToken() throws Exception { + String token = tokenProvider.generateAccessToken("user1", List.of("ADMIN")); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + var authentication = SecurityContextHolder.getContext().getAuthentication(); + assertNotNull(authentication); + assertEquals("user1", authentication.getPrincipal()); + assertTrue(authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))); + } + + @Test + void shouldNotSetAuthenticationWithoutToken() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void shouldNotSetAuthenticationForInvalidToken() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer invalid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void shouldIgnoreNonBearerAuthorizationHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Basic dXNlcjpwYXNz"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void shouldContinueFilterChainRegardlessOfAuth() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertNotNull(chain.getRequest()); + assertNotNull(chain.getResponse()); + } +} diff --git a/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/jwt/JwtTokenProviderTest.java b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/jwt/JwtTokenProviderTest.java new file mode 100644 index 00000000..0bb7425b --- /dev/null +++ b/libs/ftgo-security-lib/src/test/java/net/chrisrichardson/ftgo/security/jwt/JwtTokenProviderTest.java @@ -0,0 +1,87 @@ +package net.chrisrichardson.ftgo.security.jwt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class JwtTokenProviderTest { + + private JwtTokenProvider tokenProvider; + + @BeforeEach + void setUp() { + tokenProvider = new JwtTokenProvider( + "test-secret-key-must-be-at-least-256-bits-long-for-hs256", + 900000, + 86400000, + "ftgo-test" + ); + } + + @Test + void shouldGenerateAndValidateAccessToken() { + String token = tokenProvider.generateAccessToken("user1", List.of("ADMIN", "CONSUMER")); + + assertTrue(tokenProvider.validateToken(token)); + assertEquals("user1", tokenProvider.getSubject(token)); + } + + @Test + void shouldExtractRolesFromAccessToken() { + List roles = List.of("ADMIN", "CONSUMER"); + String token = tokenProvider.generateAccessToken("user1", roles); + + List extractedRoles = tokenProvider.getRoles(token); + + assertNotNull(extractedRoles); + assertEquals(2, extractedRoles.size()); + assertTrue(extractedRoles.contains("ADMIN")); + assertTrue(extractedRoles.contains("CONSUMER")); + } + + @Test + void shouldGenerateRefreshToken() { + String token = tokenProvider.generateRefreshToken("user1"); + + assertTrue(tokenProvider.validateToken(token)); + assertEquals("user1", tokenProvider.getSubject(token)); + } + + @Test + void shouldRejectInvalidToken() { + assertFalse(tokenProvider.validateToken("invalid-token")); + } + + @Test + void shouldRejectNullToken() { + assertFalse(tokenProvider.validateToken(null)); + } + + @Test + void shouldRejectEmptyToken() { + assertFalse(tokenProvider.validateToken("")); + } + + @Test + void shouldRejectTamperedToken() { + String token = tokenProvider.generateAccessToken("user1", List.of("ADMIN")); + String tamperedToken = token + "tampered"; + + assertFalse(tokenProvider.validateToken(tamperedToken)); + } + + @Test + void shouldParseTokenClaims() { + String token = tokenProvider.generateAccessToken("user1", List.of("ADMIN")); + var claims = tokenProvider.parseToken(token); + + assertEquals("user1", claims.getSubject()); + assertEquals("ftgo-test", claims.getIssuer()); + assertEquals("access", claims.get("type", String.class)); + assertNotNull(claims.getIssuedAt()); + assertNotNull(claims.getExpiration()); + } +} diff --git a/libs/ftgo-security-lib/src/test/resources/application.properties b/libs/ftgo-security-lib/src/test/resources/application.properties new file mode 100644 index 00000000..ae1aa54b --- /dev/null +++ b/libs/ftgo-security-lib/src/test/resources/application.properties @@ -0,0 +1,6 @@ +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=always +ftgo.jwt.secret=test-secret-key-must-be-at-least-256-bits-long-for-hs256 +ftgo.jwt.access-token-expiration-ms=900000 +ftgo.jwt.refresh-token-expiration-ms=86400000 +ftgo.jwt.issuer=ftgo-test diff --git a/settings.gradle b/settings.gradle index d895be03..3a2799c1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,6 +34,10 @@ include "ftgo-common-jpa" include "ftgo-domain" +// --- New Shared Libraries (microservices migration) --- +include "ftgo-security-lib" +project(":ftgo-security-lib").projectDir = file("libs/ftgo-security-lib") + // --- Database Migrations --- include "ftgo-flyway" From 57fc7aa73086c39583c1a2f35aef65d522a7923d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:19:18 +0000 Subject: [PATCH 2/2] fix(EM-39): remove security-lib from settings.gradle, add JDK 17 CI job - Remove ftgo-security-lib from settings.gradle to avoid JDK 8 compilation failure in monolith CI (consistent with other new libs pattern) - Add dedicated build-ftgo-security-lib job in shared-libs-ci.yml using JDK 17 - Add ftgo-security-lib path filter to detect-changes job Co-Authored-By: mason.batchelor@cognition.ai --- .github/workflows/shared-libs-ci.yml | 35 ++++++++++++++++++++++++++++ settings.gradle | 4 ---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/shared-libs-ci.yml b/.github/workflows/shared-libs-ci.yml index 813e0b77..2818fba0 100644 --- a/.github/workflows/shared-libs-ci.yml +++ b/.github/workflows/shared-libs-ci.yml @@ -24,6 +24,7 @@ jobs: ftgo-common-lib: ${{ steps.filter.outputs.ftgo-common-lib }} ftgo-common-jpa: ${{ steps.filter.outputs.ftgo-common-jpa }} ftgo-domain: ${{ steps.filter.outputs.ftgo-domain }} + ftgo-security-lib: ${{ steps.filter.outputs.ftgo-security-lib }} platform: ${{ steps.filter.outputs.platform }} steps: - uses: actions/checkout@v4 @@ -40,6 +41,8 @@ jobs: ftgo-domain: - 'libs/ftgo-domain/**' - 'libs/ftgo-domain-lib/**' + ftgo-security-lib: + - 'libs/ftgo-security-lib/**' platform: - 'buildSrc-platform/**' - 'gradle/libs.versions.toml' @@ -77,6 +80,38 @@ jobs: path: 'libs/ftgo-common-lib/build/reports/tests/' retention-days: 14 + build-ftgo-security-lib: + name: Build ftgo-security-lib + needs: detect-changes + if: needs.detect-changes.outputs.ftgo-security-lib == 'true' || needs.detect-changes.outputs.platform == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-security-lib-${{ hashFiles('libs/ftgo-security-lib/**/*.gradle', 'gradle/platform/libs.versions.toml') }} + restore-keys: ${{ runner.os }}-gradle-security-lib- + - name: Build and test + run: | + chmod +x gradlew + cd libs/ftgo-security-lib && ../../gradlew compileJava test + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ftgo-security-lib-test-reports + path: 'libs/ftgo-security-lib/build/reports/tests/' + retention-days: 14 + build-monolith-regression: name: Monolith Regression Check needs: detect-changes diff --git a/settings.gradle b/settings.gradle index 3a2799c1..d895be03 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,10 +34,6 @@ include "ftgo-common-jpa" include "ftgo-domain" -// --- New Shared Libraries (microservices migration) --- -include "ftgo-security-lib" -project(":ftgo-security-lib").projectDir = file("libs/ftgo-security-lib") - // --- Database Migrations --- include "ftgo-flyway"