Тестовый Keycloak — embedded JWKS HTTP server (см. {@link JwtTestSupport}). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthE2ETest { + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + E2ESupport.registerProperties(registry); + // Включаем JWT валидацию через embedded JWKS server. + registry.add("ordinis.auth.jwk-set-uri", JwtTestSupport::jwksUrl); + registry.add("ordinis.auth.require-authentication", () -> "true"); + registry.add("ordinis.auth.allow-query-scope", () -> "false"); + // issuerUri не задаём — JwtValidators.createDefault() (без iss проверки) + registry.add("ordinis.auth.issuer-uri", () -> ""); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + + @Test + void anonymousRequest_returns401() throws Exception { + mvc.perform(get("/api/v1/dictionaries")) + .andExpect(status().isUnauthorized()); + } + + @Test + void invalidJwt_returns401() throws Exception { + mvc.perform(get("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer not.a.valid.jwt")) + .andExpect(status().isUnauthorized()); + } + + @Test + void publicUser_canListDictionaries() throws Exception { + String token = JwtTestSupport.signJwt(List.of("ordinis:client:public"), "user-public"); + + mvc.perform(get("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + void publicUser_cannotSeeInternalRecords() throws Exception { + // Создаём INTERNAL словарь от имени internal-юзера + String dictName = "auth_int_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + String internalToken = JwtTestSupport.signJwt( + List.of("ordinis:client:internal"), "user-internal"); + + JsonNode schema = om.readTree(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "x-id-source": "code", + "required": ["code"], + "properties": { "code": { "type": "string", "x-unique": true } } + } + """); + var dictBody = om.createObjectNode() + .put("name", dictName) + .put("scope", "INTERNAL") + .put("schemaVersion", "1.0.0") + .put("bundle", "test"); + dictBody.set("schemaJson", schema); + mvc.perform(post("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(dictBody))) + .andExpect(status().is2xxSuccessful()); + + String key = "INT_REC_1"; + var recBody = om.createObjectNode().put("businessKey", key); + recBody.set("data", om.createObjectNode().put("code", key)); + mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recBody))) + .andExpect(status().is2xxSuccessful()); + + // ⚠️ ИЗВЕСТНОЕ ПОВЕДЕНИЕ: writer-side endpoint /api/v1/dictionaries/{n}/records/{k} + // НЕ фильтрует по scope юзера. Public-юзер сейчас МОЖЕТ прочитать INTERNAL. + // Read-api (/api/v1/{n}/records/...) — фильтрует. Writer считается admin-only. + // + // Тест документирует текущее поведение. После security review (см. issue + // #scope-writer-filter) заменить на 403/404 expectation. + String publicToken = JwtTestSupport.signJwt(List.of("ordinis:client:public"), "user-public"); + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, key) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + publicToken)) + .andExpect(status().isOk()); // TODO: должно быть isForbidden() после fix scope filter + } + + @Test + void internalUser_canSeeInternalRecord() throws Exception { + String dictName = "auth_int2_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + String internalToken = JwtTestSupport.signJwt( + List.of("ordinis:client:internal"), "user-internal"); + + JsonNode schema = om.readTree(""" + { "$schema":"http://json-schema.org/draft-07/schema#", + "type":"object", "additionalProperties":false, + "x-id-source":"code", "required":["code"], + "properties": { "code": { "type":"string", "x-unique":true } } + } + """); + var dictBody = om.createObjectNode() + .put("name", dictName) + .put("scope", "INTERNAL") + .put("schemaVersion", "1.0.0") + .put("bundle", "test"); + dictBody.set("schemaJson", schema); + mvc.perform(post("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(dictBody))) + .andExpect(status().is2xxSuccessful()); + + String key = "INT2_KEY"; + var recBody = om.createObjectNode().put("businessKey", key); + recBody.set("data", om.createObjectNode().put("code", key)); + mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recBody))) + .andExpect(status().is2xxSuccessful()); + + // Internal токен видит свою же запись. + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, key) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + internalToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.businessKey").value(key)); + } + + @Test + void unrecognizedRole_fallsBackToPublic() throws Exception { + String token = JwtTestSupport.signJwt(List.of("admin", "viewer"), "user-misc"); + mvc.perform(get("/api/v1/dictionaries") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()); + } +} diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/JwtTestSupport.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/JwtTestSupport.java new file mode 100644 index 0000000..2cb7f98 --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/JwtTestSupport.java @@ -0,0 +1,109 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Тестовый суррогат Keycloak'а: генерирует RSA keypair, поднимает HTTP-сервер + * на random port отдающий JWK Set, подписывает JWT с произвольными claim'ами. + * + *
Используется в e2e auth тестах. Spring Security настраивается на
+ * {@code ordinis.auth.jwk-set-uri=