diff --git a/build.gradle.kts b/build.gradle.kts index ecd7bd8..786a6a8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ import com.google.protobuf.gradle.id plugins { - id("org.springframework.boot") version "3.5.8" + id("org.springframework.boot") version "4.0.0" id("io.spring.dependency-management") version "1.1.7" id("org.graalvm.buildtools.native") version "0.11.1" id("org.jetbrains.kotlin.jvm") version "2.2.21" @@ -26,22 +26,21 @@ repositories { mavenCentral() } -extra["testcontainersVersion"] = "2.0.2" -extra["jjwtVersion"] = "0.12.6" +extra["testcontainersVersion"] = "1.21.3" +extra["jjwtVersion"] = "0.13.0" dependencies { // Spring Boot Starters - implementation("org.springframework.boot:spring-boot-starter-web") { - exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat") - } - implementation("org.springframework.boot:spring-boot-starter-undertow") + implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14") + implementation("org.springframework.boot:spring-boot-starter-jackson") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0") // Database + implementation("org.springframework.boot:spring-boot-starter-flyway") runtimeOnly("org.postgresql:postgresql:42.7.8") runtimeOnly("org.flywaydb:flyway-database-postgresql:11.11.1") @@ -56,31 +55,28 @@ dependencies { developmentOnly("org.springframework.boot:spring-boot-docker-compose") // Testing - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(group = "junit", module = "junit") - } + testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.springframework.boot:spring-boot-starter-webflux-test") // For WebTestClient + testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.junit.jupiter:junit-jupiter:5.13.3") testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:postgresql") - testImplementation("io.rest-assured:rest-assured:5.5.6") - testImplementation("io.rest-assured:json-path:5.5.6") // Swagger implementation("io.swagger.core.v3:swagger-models:2.2.34") implementation("io.swagger.core.v3:swagger-core:2.2.34") - implementation("javax.xml.bind:jaxb-api:2.3.1") - implementation("com.sun.xml.bind:jaxb-impl:2.3.9") + implementation("jakarta.xml.bind:jakarta.xml.bind-api:4.0.2") + runtimeOnly("org.glassfish.jaxb:jaxb-runtime:4.0.5") // gRPC and Protobuf implementation("io.grpc:grpc-netty-shaded:1.77.0") implementation("io.grpc:grpc-protobuf:1.77.0") implementation("io.grpc:grpc-stub:1.77.0") implementation("com.google.protobuf:protobuf-java:4.33.1") - // Because protobuf is still using javax annotations - implementation("javax.annotation:javax.annotation-api:1.3.2") - implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE") + implementation("jakarta.annotation:jakarta.annotation-api:3.0.0") + implementation("org.springframework.grpc:spring-grpc-spring-boot-starter:1.0.0") } dependencyManagement { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2544cb9..bb600f0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,7 +2,17 @@ Move `@EnableJpaAuditing` to a separate configuration class `JpaAuditingConfig.java` so that we can exclude it from testings. Otherwise it will throw errors when testing controllers: + ``` Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty at org.springframework.util.Assert.notEmpty(Assert.java:398) ``` + +## Rest Assured - not support Spring Boot 4 + +Rest Assured 5.5.6 doesn't work with Groovy 5 (still use Groovy 4), which is not compatible with Spring Boot 4(uses +Groovy 5). +We need to replace Rest Assured with `WebTestClient` before Rest Assured's upgrade. + +- [Remove integration for REST Docs' REST Assured support until REST Assured supports Groovy 5](https://github.com/spring-projects/spring-boot/issues/47685) +- [Drop support for REST Assured until it supports Groovy 5](https://github.com/spring-projects/spring-restdocs/issues/1000) \ No newline at end of file diff --git a/gradle/jacoco.gradle.kts b/gradle/jacoco.gradle.kts index fdb9a60..4c95c3f 100644 --- a/gradle/jacoco.gradle.kts +++ b/gradle/jacoco.gradle.kts @@ -61,7 +61,7 @@ val strictCoverageClasses = listOf( ) tasks.named("jacocoTestReport") { - dependsOn(tasks.named("test")) + dependsOn(tasks.named("test"), tasks.named("processResources"), tasks.named("compileJava")) reports { xml.required.set(true) // For CI/CD integration html.required.set(true) // For human-readable reports diff --git a/src/main/java/org/nkcoder/infrastructure/config/ObservabilityConfig.java b/src/main/java/org/nkcoder/infrastructure/config/ObservabilityConfig.java index 32ce9ed..703510a 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/ObservabilityConfig.java +++ b/src/main/java/org/nkcoder/infrastructure/config/ObservabilityConfig.java @@ -3,7 +3,7 @@ import io.micrometer.core.aop.TimedAspect; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.config.MeterFilter; -import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer; +import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java b/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java index 8d02ce0..d983a24 100644 --- a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java +++ b/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java @@ -1,6 +1,5 @@ package org.nkcoder.infrastructure.security; -import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -13,6 +12,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6a87094..6742fa3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,29 +27,23 @@ spring: fetch_size: 100 generate_statistics: false - security: - oauth2: - resourceserver: - jwt: - issuer-uri: http://localhost:3001 - - jackson: - serialization: - write-dates-as-timestamps: false - time-zone: UTC - property-naming-strategy: LOWER_CAMEL_CASE - - autoconfigure: - exclude: - - org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration - docker: compose: enabled: false + threads: virtual: enabled: true + # gRPC configuration + grpc: + server: + port: 9090 + max-inbound-message-size: 4MB + max-inbound-metadata-size: 8KB + reflection: + enabled: true + # JWT Configuration jwt: secret: @@ -68,10 +62,6 @@ cors: allow-credentials: true max-age: 3600 -# Rate Limiting Configuration -rate-limit: - requests-per-window: 100 - window-size-minutes: 15 # Actuator Configuration management: @@ -117,12 +107,4 @@ info: version: '@project.version@' description: '@project.description@' java-version: '@java.version@' - spring-boot-version: '@parent.version@' - -# grpc configuration -grpc: - server: - port: 9090 - max-inbound-message-size: 4194304 # 4MB - max-inbound-metadata-size: 8192 # 8KB - reflection-service-enabled: true \ No newline at end of file + spring-boot-version: '@parent.version@' \ No newline at end of file diff --git a/src/test/java/org/nkcoder/infrastructure/config/DataJpaIntegrationTest.java b/src/test/java/org/nkcoder/infrastructure/config/DataJpaIntegrationTest.java index 9995873..a27eeee 100644 --- a/src/test/java/org/nkcoder/infrastructure/config/DataJpaIntegrationTest.java +++ b/src/test/java/org/nkcoder/infrastructure/config/DataJpaIntegrationTest.java @@ -4,8 +4,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; import org.springframework.context.annotation.Import; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.test.context.ActiveProfiles; diff --git a/src/test/java/org/nkcoder/integration/AdminUserControllerIntegrationTest.java b/src/test/java/org/nkcoder/integration/AdminUserControllerIntegrationTest.java index 119b00a..fe831b8 100644 --- a/src/test/java/org/nkcoder/integration/AdminUserControllerIntegrationTest.java +++ b/src/test/java/org/nkcoder/integration/AdminUserControllerIntegrationTest.java @@ -1,38 +1,29 @@ package org.nkcoder.integration; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; - -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import io.restassured.response.Response; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.nkcoder.infrastructure.config.TestContainersConfiguration; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient @Import(TestContainersConfiguration.class) @ActiveProfiles("test") @DisplayName("AdminUserController Integration Tests") @Disabled("Tests need fixing - response structure mismatch") class AdminUserControllerIntegrationTest { - @LocalServerPort - private int port; - - @BeforeEach - void setupRestAssured() { - RestAssured.port = port; - RestAssured.basePath = ""; - } + @Autowired + private WebTestClient webTestClient; @Nested @DisplayName("GET /api/admin/users - Get All Users") @@ -41,7 +32,12 @@ class GetAllUsers { @Test @DisplayName("returns 401 without authentication") void returns401WithoutAuth() { - given().when().get("/api/admin/users").then().statusCode(401); + webTestClient + .get() + .uri("/api/admin/users") + .exchange() + .expectStatus() + .isUnauthorized(); } @Test @@ -49,13 +45,18 @@ void returns401WithoutAuth() { void returnsUserListForAdmin() { String adminToken = registerAndGetToken("admin@example.com", "Password123", "Admin User", "ADMIN"); - given().header("Authorization", "Bearer " + adminToken) - .when() - .get("/api/admin/users") - .then() - .statusCode(200) - .body("data", notNullValue()) - .body("data", isA(java.util.List.class)); + webTestClient + .get() + .uri("/api/admin/users") + .header("Authorization", "Bearer " + adminToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data") + .isNotEmpty() + .jsonPath("$.data") + .isArray(); } } @@ -67,7 +68,12 @@ class GetUserById { @DisplayName("returns 401 without authentication") void returns401WithoutAuth() { String userId = "123e4567-e89b-12d3-a456-426614174000"; - given().when().get("/api/admin/users/{userId}", userId).then().statusCode(401); + webTestClient + .get() + .uri("/api/admin/users/{userId}", userId) + .exchange() + .expectStatus() + .isUnauthorized(); } @Test @@ -79,13 +85,18 @@ void returnsUserDetailsForAdmin() { // Register admin String adminToken = registerAndGetToken("admin2@example.com", "Password123", "Admin User", "ADMIN"); - given().header("Authorization", "Bearer " + adminToken) - .when() - .get("/api/admin/users/{userId}", targetUserId) - .then() - .statusCode(200) - .body("data.email", equalTo("target@example.com")) - .body("data.name", equalTo("Target User")); + webTestClient + .get() + .uri("/api/admin/users/{userId}", targetUserId) + .header("Authorization", "Bearer " + adminToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.email") + .isEqualTo("target@example.com") + .jsonPath("$.data.name") + .isEqualTo("Target User"); } @Test @@ -94,11 +105,13 @@ void returns404ForNonExistentUser() { String adminToken = registerAndGetToken("admin3@example.com", "Password123", "Admin User", "ADMIN"); String nonExistentUserId = "00000000-0000-0000-0000-000000000000"; - given().header("Authorization", "Bearer " + adminToken) - .when() - .get("/api/admin/users/{userId}", nonExistentUserId) - .then() - .statusCode(404); + webTestClient + .get() + .uri("/api/admin/users/{userId}", nonExistentUserId) + .header("Authorization", "Bearer " + adminToken) + .exchange() + .expectStatus() + .isNotFound(); } } @@ -110,12 +123,14 @@ class UpdateUser { @DisplayName("returns 401 without authentication") void returns401WithoutAuth() { String userId = "123e4567-e89b-12d3-a456-426614174000"; - given().contentType(ContentType.JSON) - .body("{\"name\": \"Updated Name\"}") - .when() - .patch("/api/admin/users/{userId}", userId) - .then() - .statusCode(401); + webTestClient + .patch() + .uri("/api/admin/users/{userId}", userId) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{\"name\": \"Updated Name\"}") + .exchange() + .expectStatus() + .isUnauthorized(); } @Test @@ -127,22 +142,28 @@ void updatesUserSuccessfully() { // Register admin String adminToken = registerAndGetToken("admin4@example.com", "Password123", "Admin User", "ADMIN"); - given().header("Authorization", "Bearer " + adminToken) - .contentType(ContentType.JSON) - .body(""" - { - "name": "Admin Updated Name", - "emailVerified": true, - "role": "ADMIN" - } - """) - .when() - .patch("/api/admin/users/{userId}", targetUserId) - .then() - .statusCode(200) - .body("data.name", equalTo("Admin Updated Name")) - .body("data.emailVerified", equalTo(true)) - .body("data.role", equalTo("ADMIN")); + webTestClient + .patch() + .uri("/api/admin/users/{userId}", targetUserId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "name": "Admin Updated Name", + "emailVerified": true, + "role": "ADMIN" + } + """) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.name") + .isEqualTo("Admin Updated Name") + .jsonPath("$.data.emailVerified") + .isEqualTo(true) + .jsonPath("$.data.role") + .isEqualTo("ADMIN"); } } @@ -154,12 +175,14 @@ class ResetPassword { @DisplayName("returns 401 without authentication") void returns401WithoutAuth() { String userId = "123e4567-e89b-12d3-a456-426614174000"; - given().contentType(ContentType.JSON) - .body("{\"newPassword\": \"NewPassword123\"}") - .when() - .patch("/api/admin/users/{userId}/password", userId) - .then() - .statusCode(401); + webTestClient + .patch() + .uri("/api/admin/users/{userId}/password", userId) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{\"newPassword\": \"NewPassword123\"}") + .exchange() + .expectStatus() + .isUnauthorized(); } @Test @@ -173,66 +196,60 @@ void resetsPasswordSuccessfully() { String adminToken = registerAndGetToken("admin5@example.com", "Password123", "Admin User", "ADMIN"); // Reset password - given().header("Authorization", "Bearer " + adminToken) - .contentType(ContentType.JSON) - .body(""" - { - "newPassword": "NewPassword123" - } - """) - .when() - .patch("/api/admin/users/{userId}/password", targetUserId) - .then() - .statusCode(200); + webTestClient + .patch() + .uri("/api/admin/users/{userId}/password", targetUserId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "newPassword": "NewPassword123" + } + """) + .exchange() + .expectStatus() + .isOk(); // Verify new password works - given().contentType(ContentType.JSON) - .body(""" - { - "email": "target3@example.com", - "password": "NewPassword123" - } - """) - .when() - .post("/api/auth/login") - .then() - .statusCode(200); + webTestClient + .post() + .uri("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "target3@example.com", + "password": "NewPassword123" + } + """) + .exchange() + .expectStatus() + .isOk(); // Verify old password doesn't work - given().contentType(ContentType.JSON) - .body(""" - { - "email": "target3@example.com", - "password": "OldPassword123" - } - """) - .when() - .post("/api/auth/login") - .then() - .statusCode(401); + webTestClient + .post() + .uri("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "target3@example.com", + "password": "OldPassword123" + } + """) + .exchange() + .expectStatus() + .isUnauthorized(); } } // Helper methods - private void registerUser(String email, String password, String name, String role) { - given().contentType(ContentType.JSON) - .body(""" - { - "email": "%s", - "password": "%s", - "name": "%s", - "role": "%s" - } - """.formatted(email, password, name, role)) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))); - } private String registerAndGetToken(String email, String password, String name, String role) { - Response response = given().contentType(ContentType.JSON) - .body(""" + var response = webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" { "email": "%s", "password": "%s", @@ -240,19 +257,22 @@ private String registerAndGetToken(String email, String password, String name, S "role": "%s" } """.formatted(email, password, name, role)) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .extract() - .response(); - - return response.jsonPath().getString("data.tokens.accessToken"); + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + return extractJsonValue(responseBody, "data.tokens.accessToken"); } private String registerAndGetUserId(String email, String password, String name, String role) { - Response response = given().contentType(ContentType.JSON) - .body(""" + var response = webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" { "email": "%s", "password": "%s", @@ -260,13 +280,44 @@ private String registerAndGetUserId(String email, String password, String name, "role": "%s" } """.formatted(email, password, name, role)) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .extract() - .response(); - - return response.jsonPath().getString("data.user.id"); + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + return extractJsonValue(responseBody, "data.user.id"); + } + + /** Simple JSON value extractor for dot-notation paths. */ + private String extractJsonValue(String json, String path) { + String[] parts = path.split("\\."); + String current = json; + + for (String part : parts) { + int keyIndex = current.indexOf("\"" + part + "\""); + if (keyIndex == -1) { + return null; + } + current = current.substring(keyIndex + part.length() + 2); + int colonIndex = current.indexOf(":"); + current = current.substring(colonIndex + 1).trim(); + + if (current.startsWith("\"")) { + // String value + int endQuote = current.indexOf("\"", 1); + if (endQuote == -1) { + return null; + } + if (parts[parts.length - 1].equals(part)) { + return current.substring(1, endQuote); + } + } else if (current.startsWith("{")) { + // Object - continue to next part + continue; + } + } + return null; } } diff --git a/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java b/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java index ab9f0f7..768cda1 100644 --- a/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java +++ b/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java @@ -18,7 +18,7 @@ import org.nkcoder.user.domain.model.UserRole; import org.nkcoder.user.domain.service.TokenGenerator; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -57,13 +57,13 @@ void registerIsPublic() throws Exception { mockMvc.perform(post("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(""" - { - "email": "test@example.com", - "password": "Password123", - "name": "Test User", - "role": "MEMBER" - } - """)) + { + "email": "test@example.com", + "password": "Password123", + "name": "Test User", + "role": "MEMBER" + } + """)) .andExpect(status().isCreated()); } @@ -76,11 +76,11 @@ void loginIsPublic() throws Exception { mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - { - "email": "test@example.com", - "password": "Password123" - } - """)) + { + "email": "test@example.com", + "password": "Password123" + } + """)) .andExpect(status().isOk()); } @@ -93,10 +93,10 @@ void refreshIsPublic() throws Exception { mockMvc.perform(post("/api/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .content(""" - { - "refreshToken": "some-refresh-token" - } - """)) + { + "refreshToken": "some-refresh-token" + } + """)) .andExpect(status().isOk()); } } @@ -117,8 +117,8 @@ void updateMeRequiresAuth() throws Exception { mockMvc.perform(patch("/api/users/me") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"name": "New Name"} - """)) + {"name": "New Name"} + """)) .andExpect(status().isUnauthorized()); } @@ -128,12 +128,12 @@ void changePasswordRequiresAuth() throws Exception { mockMvc.perform(patch("/api/users/me/password") .contentType(MediaType.APPLICATION_JSON) .content(""" - { - "currentPassword": "Old123", - "newPassword": "New123", - "confirmPassword": "New123" - } - """)) + { + "currentPassword": "Old123", + "newPassword": "New123", + "confirmPassword": "New123" + } + """)) .andExpect(status().isUnauthorized()); } } @@ -154,8 +154,8 @@ void updateUserRequiresAuth() throws Exception { mockMvc.perform(patch("/api/admin/users/{userId}", UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) .content(""" - {"name": "Admin Update"} - """)) + {"name": "Admin Update"} + """)) .andExpect(status().isUnauthorized()); } } diff --git a/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java b/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java index c2d35b2..8255dde 100644 --- a/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java +++ b/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java @@ -1,36 +1,27 @@ package org.nkcoder.integration; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; - -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import io.restassured.response.Response; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.nkcoder.infrastructure.config.TestContainersConfiguration; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient @Import(TestContainersConfiguration.class) @ActiveProfiles("test") @DisplayName("Auth Flow Integration Tests") class AuthFlowIntegrationTest { - @LocalServerPort - private int port; - - @BeforeEach - void setupRestAssured() { - RestAssured.port = port; - RestAssured.basePath = ""; - } + @Autowired + private WebTestClient webTestClient; @Nested @DisplayName("Complete Authentication Flow") @@ -40,99 +31,123 @@ class CompleteAuthFlow { @DisplayName("register → login → access protected → refresh → logout") void fullAuthenticationFlow() { // Step 1: Register - Response registerResponse = given().contentType(ContentType.JSON) - .body(""" - { - "email": "flow@example.com", - "password": "Password123", - "name": "Flow Test User", - "role": "MEMBER" - } - """) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .body("data.user.email", equalTo("flow@example.com")) - .body("data.tokens.accessToken", notNullValue()) - .body("data.tokens.refreshToken", notNullValue()) - .extract() - .response(); - - String accessToken = registerResponse.jsonPath().getString("data.tokens.accessToken"); - String refreshToken = registerResponse.jsonPath().getString("data.tokens.refreshToken"); + var registerResponse = webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "flow@example.com", + "password": "Password123", + "name": "Flow Test User", + "role": "MEMBER" + } + """) + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .jsonPath("$.data.user.email") + .isEqualTo("flow@example.com") + .jsonPath("$.data.tokens.accessToken") + .isNotEmpty() + .jsonPath("$.data.tokens.refreshToken") + .isNotEmpty() + .returnResult(); + + String responseBody = new String(registerResponse.getResponseBody()); + String accessToken = extractJsonValue(responseBody, "data.tokens.accessToken"); + String refreshToken = extractJsonValue(responseBody, "data.tokens.refreshToken"); // Step 2: Access protected endpoint - given().header("Authorization", "Bearer " + accessToken) - .when() - .get("/api/users/me") - .then() - .statusCode(200) - .body("data.email", equalTo("flow@example.com")) - .body("data.name", equalTo("Flow Test User")); + webTestClient + .get() + .uri("/api/users/me") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.email") + .isEqualTo("flow@example.com") + .jsonPath("$.data.name") + .isEqualTo("Flow Test User"); // Step 3: Refresh tokens - Response refreshResponse = given().contentType(ContentType.JSON) - .body(""" - { - "refreshToken": "%s" - } - """.formatted(refreshToken)) - .when() - .post("/api/auth/refresh") - .then() - .statusCode(200) - .body("data.tokens.accessToken", notNullValue()) - .body("data.tokens.refreshToken", notNullValue()) - .extract() - .response(); - - String newAccessToken = refreshResponse.jsonPath().getString("data.tokens.accessToken"); - String newRefreshToken = refreshResponse.jsonPath().getString("data.tokens.refreshToken"); + var refreshResponse = webTestClient + .post() + .uri("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "refreshToken": "%s" + } + """.formatted(refreshToken)) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.tokens.accessToken") + .isNotEmpty() + .jsonPath("$.data.tokens.refreshToken") + .isNotEmpty() + .returnResult(); + + String refreshResponseBody = new String(refreshResponse.getResponseBody()); + String newAccessToken = extractJsonValue(refreshResponseBody, "data.tokens.accessToken"); + String newRefreshToken = extractJsonValue(refreshResponseBody, "data.tokens.refreshToken"); // Step 4: Old refresh token should be invalid - given().contentType(ContentType.JSON) - .body(""" - { - "refreshToken": "%s" - } - """.formatted(refreshToken)) - .when() - .post("/api/auth/refresh") - .then() - .statusCode(401); + webTestClient + .post() + .uri("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "refreshToken": "%s" + } + """.formatted(refreshToken)) + .exchange() + .expectStatus() + .isUnauthorized(); // Step 5: New access token works - given().header("Authorization", "Bearer " + newAccessToken) - .when() - .get("/api/users/me") - .then() - .statusCode(200); + webTestClient + .get() + .uri("/api/users/me") + .header("Authorization", "Bearer " + newAccessToken) + .exchange() + .expectStatus() + .isOk(); // Step 6: Logout - given().header("Authorization", "Bearer " + newAccessToken) - .contentType(ContentType.JSON) - .body(""" - { - "refreshToken": "%s" - } - """.formatted(newRefreshToken)) - .when() - .post("/api/auth/logout") - .then() - .statusCode(200); + webTestClient + .post() + .uri("/api/auth/logout") + .header("Authorization", "Bearer " + newAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "refreshToken": "%s" + } + """.formatted(newRefreshToken)) + .exchange() + .expectStatus() + .isOk(); // Step 7: Refresh token invalid after logout - given().contentType(ContentType.JSON) - .body(""" - { - "refreshToken": "%s" - } - """.formatted(newRefreshToken)) - .when() - .post("/api/auth/refresh") - .then() - .statusCode(401); + webTestClient + .post() + .uri("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "refreshToken": "%s" + } + """.formatted(newRefreshToken)) + .exchange() + .expectStatus() + .isUnauthorized(); } } @@ -143,76 +158,92 @@ class Registration { @Test @DisplayName("registers new user successfully") void registersNewUser() { - given().contentType(ContentType.JSON) - .body(""" - { - "email": "newuser@example.com", - "password": "Password123", - "name": "New User", - "role": "MEMBER" - } - """) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .body("data.user.email", equalTo("newuser@example.com")) - .body("data.tokens.accessToken", notNullValue()) - .body("data.tokens.refreshToken", notNullValue()); + webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "newuser@example.com", + "password": "Password123", + "name": "New User", + "role": "MEMBER" + } + """) + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .jsonPath("$.data.user.email") + .isEqualTo("newuser@example.com") + .jsonPath("$.data.tokens.accessToken") + .isNotEmpty() + .jsonPath("$.data.tokens.refreshToken") + .isNotEmpty(); } @Test @DisplayName("rejects duplicate email") void rejectsDuplicateEmail() { // First registration - given().contentType(ContentType.JSON) - .body(""" - { - "email": "duplicate@example.com", - "password": "Password123", - "name": "First User", - "role": "MEMBER" - } - """) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))); + webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "duplicate@example.com", + "password": "Password123", + "name": "First User", + "role": "MEMBER" + } + """) + .exchange() + .expectStatus() + .is2xxSuccessful(); // Second registration with same email - given().contentType(ContentType.JSON) - .body(""" - { - "email": "duplicate@example.com", - "password": "Password123", - "name": "Second User", - "role": "MEMBER" - } - """) - .when() - .post("/api/auth/register") - .then() - .statusCode(400) - .body("message", equalTo("User already exists")); + webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "duplicate@example.com", + "password": "Password123", + "name": "Second User", + "role": "MEMBER" + } + """) + .exchange() + .expectStatus() + .isBadRequest() + .expectBody() + .jsonPath("$.message") + .isEqualTo("User already exists"); } @Test @DisplayName("normalizes email to lowercase") void normalizesEmail() { - given().contentType(ContentType.JSON) - .body(""" - { - "email": "UPPERCASE@EXAMPLE.COM", - "password": "Password123", - "name": "Uppercase Email", - "role": "MEMBER" - } - """) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .body("data.user.email", equalTo("uppercase@example.com")); + webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "UPPERCASE@EXAMPLE.COM", + "password": "Password123", + "name": "Uppercase Email", + "role": "MEMBER" + } + """) + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .jsonPath("$.data.user.email") + .isEqualTo("uppercase@example.com"); } } @@ -227,19 +258,24 @@ void logsInSuccessfully() { registerUser("login@example.com", "Password1234", "Login User"); // Login - given().contentType(ContentType.JSON) - .body(""" - { - "email": "login@example.com", - "password": "Password1234" - } - """) - .when() - .post("/api/auth/login") - .then() - .statusCode(200) - .body("data.user.email", equalTo("login@example.com")) - .body("data.tokens.accessToken", notNullValue()); + webTestClient + .post() + .uri("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "login@example.com", + "password": "Password1234" + } + """) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.user.email") + .isEqualTo("login@example.com") + .jsonPath("$.data.tokens.accessToken") + .isNotEmpty(); } @Test @@ -247,35 +283,43 @@ void logsInSuccessfully() { void rejectsWrongPassword() { registerUser("wrongpass@example.com", "Password123", "Wrong Pass User"); - given().contentType(ContentType.JSON) - .body(""" - { - "email": "wrongpass@example.com", - "password": "WrongPassword123" - } - """) - .when() - .post("/api/auth/login") - .then() - .statusCode(401) - .body("message", equalTo("Invalid email or password")); + webTestClient + .post() + .uri("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "wrongpass@example.com", + "password": "WrongPassword123" + } + """) + .exchange() + .expectStatus() + .isUnauthorized() + .expectBody() + .jsonPath("$.message") + .isEqualTo("Invalid email or password"); } @Test @DisplayName("rejects non-existent email") void rejectsNonExistentEmail() { - given().contentType(ContentType.JSON) - .body(""" - { - "email": "nonexistent@example.com", - "password": "Password123" - } - """) - .when() - .post("/api/auth/login") - .then() - .statusCode(401) - .body("message", equalTo("Invalid email or password")); + webTestClient + .post() + .uri("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "nonexistent@example.com", + "password": "Password123" + } + """) + .exchange() + .expectStatus() + .isUnauthorized() + .expectBody() + .jsonPath("$.message") + .isEqualTo("Invalid email or password"); } } @@ -288,18 +332,22 @@ class ProfileOperations { void updatesProfile() { String accessToken = registerAndGetToken("profile@example.com", "Password123", "Original Name"); - given().header("Authorization", "Bearer " + accessToken) - .contentType(ContentType.JSON) - .body(""" - { - "name": "Updated Name" - } - """) - .when() - .patch("/api/users/me") - .then() - .statusCode(200) - .body("data.name", equalTo("Updated Name")); + webTestClient + .patch() + .uri("/api/users/me") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "name": "Updated Name" + } + """) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.name") + .isEqualTo("Updated Name"); } @Test @@ -308,45 +356,51 @@ void changesPassword() { String accessToken = registerAndGetToken("password@example.com", "OldPassword123", "Password User"); // Change password - given().header("Authorization", "Bearer " + accessToken) - .contentType(ContentType.JSON) - .body(""" - { - "currentPassword": "OldPassword123", - "newPassword": "NewPassword123", - "confirmPassword": "NewPassword123" - } - """) - .when() - .patch("/api/users/me/password") - .then() - .statusCode(200); + webTestClient + .patch() + .uri("/api/users/me/password") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "currentPassword": "OldPassword123", + "newPassword": "NewPassword123", + "confirmPassword": "NewPassword123" + } + """) + .exchange() + .expectStatus() + .isOk(); // Login with new password works - given().contentType(ContentType.JSON) - .body(""" - { - "email": "password@example.com", - "password": "NewPassword123" - } - """) - .when() - .post("/api/auth/login") - .then() - .statusCode(200); + webTestClient + .post() + .uri("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "password@example.com", + "password": "NewPassword123" + } + """) + .exchange() + .expectStatus() + .isOk(); // Login with old password fails - given().contentType(ContentType.JSON) - .body(""" - { - "email": "password@example.com", - "password": "OldPassword123" - } - """) - .when() - .post("/api/auth/login") - .then() - .statusCode(401); + webTestClient + .post() + .uri("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "password@example.com", + "password": "OldPassword123" + } + """) + .exchange() + .expectStatus() + .isUnauthorized(); } } @@ -357,40 +411,48 @@ class TokenSecurity { @Test @DisplayName("rejects expired/invalid access token") void rejectsInvalidToken() { - given().header("Authorization", "Bearer invalid.token.here") - .when() - .get("/api/users/me") - .then() - .statusCode(401); + webTestClient + .get() + .uri("/api/users/me") + .header("Authorization", "Bearer invalid.token.here") + .exchange() + .expectStatus() + .isUnauthorized(); } @Test @DisplayName("rejects request without token") void rejectsNoToken() { - given().when().get("/api/users/me").then().statusCode(401); + webTestClient.get().uri("/api/users/me").exchange().expectStatus().isUnauthorized(); } } // Helper methods + private void registerUser(String email, String password, String name) { - given().contentType(ContentType.JSON) - .body(""" - { - "email": "%s", - "password": "%s", - "name": "%s", - "role": "MEMBER" - } - """.formatted(email, password, name)) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))); + webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s", + "password": "%s", + "name": "%s", + "role": "MEMBER" + } + """.formatted(email, password, name)) + .exchange() + .expectStatus() + .is2xxSuccessful(); } private String registerAndGetToken(String email, String password, String name) { - Response response = given().contentType(ContentType.JSON) - .body(""" + var response = webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" { "email": "%s", "password": "%s", @@ -398,13 +460,44 @@ private String registerAndGetToken(String email, String password, String name) { "role": "MEMBER" } """.formatted(email, password, name)) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .extract() - .response(); - - return response.jsonPath().getString("data.tokens.accessToken"); + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + return extractJsonValue(responseBody, "data.tokens.accessToken"); + } + + /** Simple JSON value extractor for dot-notation paths. Works for simple cases like "data.tokens.accessToken". */ + private String extractJsonValue(String json, String path) { + String[] parts = path.split("\\."); + String current = json; + + for (String part : parts) { + int keyIndex = current.indexOf("\"" + part + "\""); + if (keyIndex == -1) { + return null; + } + current = current.substring(keyIndex + part.length() + 2); + int colonIndex = current.indexOf(":"); + current = current.substring(colonIndex + 1).trim(); + + if (current.startsWith("\"")) { + // String value + int endQuote = current.indexOf("\"", 1); + if (endQuote == -1) { + return null; + } + if (parts[parts.length - 1].equals(part)) { + return current.substring(1, endQuote); + } + } else if (current.startsWith("{")) { + // Object - continue to next part + continue; + } + } + return null; } }