From 41da04ccaeff367c1a903433d5bf36237ec74611 Mon Sep 17 00:00:00 2001 From: lVentus Date: Mon, 8 Sep 2025 23:16:48 +0200 Subject: [PATCH 01/10] Add SettingServiceClient --- api.md | 446 ------------------ .../user_service/client/QueryDefinitions.java | 31 ++ .../client/SettingsServiceClient.java | 93 ++++ .../UserServiceConnectionException.java | 48 ++ 4 files changed, 172 insertions(+), 446 deletions(-) delete mode 100644 api.md create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java diff --git a/api.md b/api.md deleted file mode 100644 index b521e65..0000000 --- a/api.md +++ /dev/null @@ -1,446 +0,0 @@ -# User Service API - -
- Table of Contents - - * [Query](#query) - * [Objects](#objects) - * [PaginationInfo](#paginationinfo) - * [PublicUserInfo](#publicuserinfo) - * [UserInfo](#userinfo) - * [Inputs](#inputs) - * [DateTimeFilter](#datetimefilter) - * [IntFilter](#intfilter) - * [Pagination](#pagination) - * [StringFilter](#stringfilter) - * [Enums](#enums) - * [GlobalUserRole](#globaluserrole) - * [SortDirection](#sortdirection) - * [Scalars](#scalars) - * [Boolean](#boolean) - * [Date](#date) - * [DateTime](#datetime) - * [Int](#int) - * [LocalTime](#localtime) - * [String](#string) - * [Time](#time) - * [UUID](#uuid) - * [Url](#url) - -
- -## Query - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
findPublicUserInfos[PublicUserInfo]! - -Gets the publicly available information for a list of users with the specified IDs. -If a user does not exist, null is returned for that user. - -
ids[UUID!]!
currentUserInfoUserInfo! - -Gets the user information of the currently authorized user. - -
findUserInfos[UserInfo]! - -Gets all of the users' information for a list of users with the specified IDs. -Only available to privileged users. -If a user does not exist, null is returned for that user. - -
ids[UUID!]!
- -## Objects - -### PaginationInfo - -Return type for information about paginated results. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
pageInt! - -The current page number. - -
sizeInt! - -The number of elements per page. - -
totalElementsInt! - -The total number of elements across all pages. - -
totalPagesInt! - -The total number of pages. - -
hasNextBoolean! - -Whether there is a next page. - -
- -### PublicUserInfo - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
idUUID!
userNameString!
- -### UserInfo - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
idUUID!
userNameString!
firstNameString!
lastNameString!
realmRoles[GlobalUserRole!]!
- -## Inputs - -### DateTimeFilter - -Filter for date values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
afterDateTime - -If specified, filters for dates after the specified value. - -
beforeDateTime - -If specified, filters for dates before the specified value. - -
- -### IntFilter - -Filter for integer values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
equalsInt - -An integer value to match exactly. - -
greaterThanInt - -If specified, filters for values greater than to the specified value. - -
lessThanInt - -If specified, filters for values less than to the specified value. - -
- -### Pagination - -Specifies the page size and page number for paginated results. - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
pageInt! - -The page number, starting at 0. -If not specified, the default value is 0. -For values greater than 0, the page size must be specified. -If this value is larger than the number of pages, an empty page is returned. - -
sizeInt! - -The number of elements per page. - -
- -### StringFilter - -Filter for string values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
equalsString - -A string value to match exactly. - -
containsString - -A string value that must be contained in the field that is being filtered. - -
ignoreCaseBoolean! - -If true, the filter is case-insensitive. - -
- -## Enums - -### GlobalUserRole - - - - - - - - - - - - - - - - -
ValueDescription
SUPER_USER
COURSE_CREATOR
- -### SortDirection - -Specifies the sort direction, either ascending or descending. - - - - - - - - - - - - - - - - -
ValueDescription
ASC
DESC
- -## Scalars - -### Boolean - -The `Boolean` scalar type represents `true` or `false`. - -### Date - -### DateTime - -### Int - -The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) -and 2^31 - 1. - -### LocalTime - -### String - -The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most -often used by GraphQL to represent free-form human-readable text. - -### Time - -### UUID - -### Url - diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java new file mode 100644 index 0000000..ea2a2de --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java @@ -0,0 +1,31 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + +import lombok.NoArgsConstructor; + +/** + * GraphQL documents used by SettingsServiceClient. + */ +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public final class QueryDefinitions { + + public static final String FIND_USER_SETTINGS_QUERY_NAME = "findUserSettings"; + public static final String FIND_USERS_SETTINGS_QUERY_NAME = "findUsersSettings"; + + public static final String FIND_USER_SETTINGS_QUERY = """ + query($userId: UUID!) { + findUserSettings(userId: $userId) { + gamification + notification { gamification lecture } + } + } + """; + + public static final String FIND_USERS_SETTINGS_QUERY = """ + query($usersIds: [UUID!]!) { + findUsersSettings(usersIds: $usersIds) { + gamification + notification { gamification lecture } + } + } + """; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java new file mode 100644 index 0000000..2b6af14 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java @@ -0,0 +1,93 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + +import de.unistuttgart.iste.meitrex.generated.dto.Settings; +import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.graphql.client.ClientGraphQlResponse; +import org.springframework.graphql.client.GraphQlClient; +import reactor.core.publisher.SynchronousSink; + +import java.util.*; +import java.util.UUID; + +/** + * Client for the user-service to query user settings over GraphQL. + */ +@Slf4j +public class SettingsServiceClient { + + private static final long RETRY_COUNT = 3; + private final GraphQlClient graphQlClient; + + public SettingsServiceClient(final GraphQlClient graphQlClient) { + this.graphQlClient = graphQlClient; + } + + /** + * Queries user-service for the settings of a single user. + * + * @param userId the user id + * @return settings of the user + * @throws UserServiceConnectionException if the request fails or the response is invalid + */ + public Settings queryUserSettings(final UUID userId) throws UserServiceConnectionException { + try { + return graphQlClient.document(QueryDefinitions.FIND_USER_SETTINGS_QUERY) + .variable("userId", userId) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink sink) -> { + if (!result.isValid()) { + sink.error(new UserServiceConnectionException( + "Invalid response from user-service.", + result.getErrors())); + return; + } + final Settings s = result.field(QueryDefinitions.FIND_USER_SETTINGS_QUERY_NAME) + .toEntity(Settings.class); + sink.next(s); + sink.complete(); + }) + .retry(RETRY_COUNT) + .block(); + } catch (final RuntimeException e) { + UserServiceConnectionException.unwrapAndThrow(e); + return null; // unreachable + } + } + + /** + * Queries user-service for the settings of multiple users. + * + * @param userIds list of user ids + * @return list of settings + * @throws UserServiceConnectionException if the request fails or the response is invalid + */ + public List queryUsersSettings(final List userIds) throws UserServiceConnectionException { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + try { + return graphQlClient.document(QueryDefinitions.FIND_USERS_SETTINGS_QUERY) + .variable("usersIds", userIds) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink> sink) -> { + if (!result.isValid()) { + sink.error(new UserServiceConnectionException( + "Invalid response from user-service.", + result.getErrors())); + return; + } + final List list = result + .field(QueryDefinitions.FIND_USERS_SETTINGS_QUERY_NAME) + .toEntityList(Settings.class); + sink.next(list != null ? list : List.of()); + sink.complete(); + }) + .retry(RETRY_COUNT) + .block(); + } catch (final RuntimeException e) { + UserServiceConnectionException.unwrapAndThrow(e); + return List.of(); // unreachable + } + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java new file mode 100644 index 0000000..3c077da --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java @@ -0,0 +1,48 @@ +package de.unistuttgart.iste.meitrex.user_service.exception; + +import org.springframework.graphql.ResponseError; + +import java.util.List; + +/** + * Exception thrown when the connection to the user-service fails or returns an invalid response. + */ +public class UserServiceConnectionException extends Exception { + + private final String message; + + public UserServiceConnectionException(final String message) { + super(message); + this.message = message; + } + + public UserServiceConnectionException(final String message, final List errors) { + super(withErrors(message, errors)); + this.message = withErrors(message, errors); + } + + private static String withErrors(final String base, final List errors) { + if (errors == null || errors.isEmpty()) return base; + final StringBuilder sb = new StringBuilder(base).append('\n') + .append("GraphQL Response Errors:\n"); + for (final ResponseError err : errors) { + sb.append(err.getMessage()).append(" at path ").append(err.getPath()).append('\n'); + } + return sb.toString(); + } + + @Override + public String getMessage() { + return this.message; + } + + /** + * Unwraps a RuntimeException thrown by reactive pipelines and rethrows as UserServiceConnectionException when possible. + */ + public static void unwrapAndThrow(final RuntimeException e) throws UserServiceConnectionException { + if (e.getCause() instanceof final UserServiceConnectionException ue) { + throw ue; + } + throw e; + } +} From 400305902230882fa608406b1eac26be7a9d2fb6 Mon Sep 17 00:00:00 2001 From: lVentus Date: Mon, 8 Sep 2025 23:18:49 +0200 Subject: [PATCH 02/10] remove bom --- .../iste/meitrex/user_service/client/QueryDefinitions.java | 2 +- .../iste/meitrex/user_service/client/SettingsServiceClient.java | 2 +- .../user_service/exception/UserServiceConnectionException.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java index ea2a2de..e8542eb 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java @@ -1,4 +1,4 @@ -package de.unistuttgart.iste.meitrex.user_service.client; +package de.unistuttgart.iste.meitrex.user_service.client; import lombok.NoArgsConstructor; diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java index 2b6af14..7197194 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java @@ -1,4 +1,4 @@ -package de.unistuttgart.iste.meitrex.user_service.client; +package de.unistuttgart.iste.meitrex.user_service.client; import de.unistuttgart.iste.meitrex.generated.dto.Settings; import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java index 3c077da..370f14b 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java @@ -1,4 +1,4 @@ -package de.unistuttgart.iste.meitrex.user_service.exception; +package de.unistuttgart.iste.meitrex.user_service.exception; import org.springframework.graphql.ResponseError; From 2916ed4aad9df7ded30002a16c5293722911c406 Mon Sep 17 00:00:00 2001 From: lVentus Date: Tue, 9 Sep 2025 00:10:38 +0200 Subject: [PATCH 03/10] Add Test --- api.md | 636 ++++++++++++++++++ build.gradle | 4 +- .../client/SettingsServiceClient.java | 54 +- .../client/SettingsServiceClientTest.java | 132 ++++ 4 files changed, 807 insertions(+), 19 deletions(-) create mode 100644 api.md create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java diff --git a/api.md b/api.md new file mode 100644 index 0000000..e58e2e5 --- /dev/null +++ b/api.md @@ -0,0 +1,636 @@ +# User Service API + +
+ Table of Contents + + * [Query](#query) + * [Mutation](#mutation) + * [Objects](#objects) + * [Notification](#notification) + * [PaginationInfo](#paginationinfo) + * [PublicUserInfo](#publicuserinfo) + * [Settings](#settings) + * [UserInfo](#userinfo) + * [Inputs](#inputs) + * [DateTimeFilter](#datetimefilter) + * [IntFilter](#intfilter) + * [NotificationInput](#notificationinput) + * [Pagination](#pagination) + * [SettingsInput](#settingsinput) + * [StringFilter](#stringfilter) + * [Enums](#enums) + * [Gamification](#gamification) + * [GlobalUserRole](#globaluserrole) + * [SortDirection](#sortdirection) + * [Scalars](#scalars) + * [Boolean](#boolean) + * [Date](#date) + * [DateTime](#datetime) + * [Int](#int) + * [LocalTime](#localtime) + * [String](#string) + * [Time](#time) + * [UUID](#uuid) + * [Url](#url) + +
+ +## Query + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
findPublicUserInfos[PublicUserInfo]! + +Gets the publicly available information for a list of users with the specified IDs. +If a user does not exist, null is returned for that user. + +
ids[UUID!]!
currentUserInfoUserInfo! + +Gets the user information of the currently authorized user. + +
findUserInfos[UserInfo]! + +Gets all of the users' information for a list of users with the specified IDs. +Only available to privileged users. +If a user does not exist, null is returned for that user. + +
ids[UUID!]!
findUserSettingsSettings!
userIdUUID
findUsersSettings[Settings]!
usersIds[UUID]!
+ +## Mutation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
updateSettingsSettings!
userIdUUID!
inputSettingsInput!
defaultSettingsSettings!
userIdUUID!
+ +## Objects + +### Notification + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
gamificationBoolean
lectureBoolean
+ +### PaginationInfo + +Return type for information about paginated results. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
pageInt! + +The current page number. + +
sizeInt! + +The number of elements per page. + +
totalElementsInt! + +The total number of elements across all pages. + +
totalPagesInt! + +The total number of pages. + +
hasNextBoolean! + +Whether there is a next page. + +
+ +### PublicUserInfo + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
idUUID!
userNameString!
+ +### Settings + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
gamificationGamification
notificationNotification
+ +### UserInfo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
idUUID!
userNameString!
firstNameString!
lastNameString!
realmRoles[GlobalUserRole!]!
+ +## Inputs + +### DateTimeFilter + +Filter for date values. +If multiple filters are specified, they are combined with AND. + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
afterDateTime + +If specified, filters for dates after the specified value. + +
beforeDateTime + +If specified, filters for dates before the specified value. + +
+ +### IntFilter + +Filter for integer values. +If multiple filters are specified, they are combined with AND. + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
equalsInt + +An integer value to match exactly. + +
greaterThanInt + +If specified, filters for values greater than to the specified value. + +
lessThanInt + +If specified, filters for values less than to the specified value. + +
+ +### NotificationInput + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
gamificationBoolean
lectureBoolean
+ +### Pagination + +Specifies the page size and page number for paginated results. + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
pageInt! + +The page number, starting at 0. +If not specified, the default value is 0. +For values greater than 0, the page size must be specified. +If this value is larger than the number of pages, an empty page is returned. + +
sizeInt! + +The number of elements per page. + +
+ +### SettingsInput + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
gamificationGamification
notificationNotificationInput
+ +### StringFilter + +Filter for string values. +If multiple filters are specified, they are combined with AND. + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
equalsString + +A string value to match exactly. + +
containsString + +A string value that must be contained in the field that is being filtered. + +
ignoreCaseBoolean! + +If true, the filter is case-insensitive. + +
+ +## Enums + +### Gamification + + + + + + + + + + + + + + + + + + + + + + +
ValueDescription
GAMIFICATION_ENABLED
ADAPTIVE_GAMIFICATION_ENABLED
ALL_GAMIFICATION_DISABLED
+ +### GlobalUserRole + + + + + + + + + + + + + + + + + + +
ValueDescription
SUPER_USER
COURSE_CREATOR
+ +### SortDirection + +Specifies the sort direction, either ascending or descending. + + + + + + + + + + + + + + + + + + +
ValueDescription
ASC
DESC
+ +## Scalars + +### Boolean + +The `Boolean` scalar type represents `true` or `false`. + +### Date + +### DateTime + +### Int + +The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. + +### LocalTime + +### String + +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. + +### Time + +### UUID + +### Url + diff --git a/build.gradle b/build.gradle index d4be657..8cd8a05 100644 --- a/build.gradle +++ b/build.gradle @@ -109,6 +109,8 @@ repositories { dependencies { implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.2.6' + implementation 'org.springframework.boot:spring-boot-starter-graphql' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -132,7 +134,7 @@ dependencies { testImplementation "org.mockito:mockito-core:5.+" testImplementation 'org.hamcrest:hamcrest:2.+' testImplementation "org.testcontainers:postgresql:1.+" - testImplementation "org.testcontainers:junit-jupiter:1.+" + testImplementation "org.testcontainers:junit-jupiter:1.18.3" } tasks.named('test') { diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java index 7197194..7072f7c 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java @@ -24,11 +24,11 @@ public SettingsServiceClient(final GraphQlClient graphQlClient) { } /** - * Queries user-service for the settings of a single user. + * Query settings for a single user. * * @param userId the user id - * @return settings of the user - * @throws UserServiceConnectionException if the request fails or the response is invalid + * @return settings of the user (never null on success) + * @throws UserServiceConnectionException if the response has GraphQL errors, the field is missing/null, or mapping fails */ public Settings queryUserSettings(final UUID userId) throws UserServiceConnectionException { try { @@ -38,29 +38,41 @@ public Settings queryUserSettings(final UUID userId) throws UserServiceConnectio .handle((ClientGraphQlResponse result, SynchronousSink sink) -> { if (!result.isValid()) { sink.error(new UserServiceConnectionException( - "Invalid response from user-service.", + "Invalid response from user-service (findUserSettings).", result.getErrors())); return; } - final Settings s = result.field(QueryDefinitions.FIND_USER_SETTINGS_QUERY_NAME) - .toEntity(Settings.class); - sink.next(s); - sink.complete(); + try { + final Settings s = result + .field(QueryDefinitions.FIND_USER_SETTINGS_QUERY_NAME) + .toEntity(Settings.class); + if (s == null) { + sink.error(new UserServiceConnectionException( + "Missing field 'findUserSettings' in user-service response.")); + return; + } + sink.next(s); + sink.complete(); + } catch (Exception mappingEx) { + sink.error(new UserServiceConnectionException( + "Failed to map 'findUserSettings' from user-service response: " + + mappingEx.getMessage())); + } }) .retry(RETRY_COUNT) .block(); } catch (final RuntimeException e) { UserServiceConnectionException.unwrapAndThrow(e); - return null; // unreachable + return null; } } /** - * Queries user-service for the settings of multiple users. + * Query settings for multiple users. * * @param userIds list of user ids - * @return list of settings - * @throws UserServiceConnectionException if the request fails or the response is invalid + * @return list of settings (never null; may be empty) + * @throws UserServiceConnectionException if the response has GraphQL errors or mapping fails */ public List queryUsersSettings(final List userIds) throws UserServiceConnectionException { if (userIds == null || userIds.isEmpty()) { @@ -73,15 +85,21 @@ public List queryUsersSettings(final List userIds) throws UserSe .handle((ClientGraphQlResponse result, SynchronousSink> sink) -> { if (!result.isValid()) { sink.error(new UserServiceConnectionException( - "Invalid response from user-service.", + "Invalid response from user-service (findUsersSettings).", result.getErrors())); return; } - final List list = result - .field(QueryDefinitions.FIND_USERS_SETTINGS_QUERY_NAME) - .toEntityList(Settings.class); - sink.next(list != null ? list : List.of()); - sink.complete(); + try { + final List list = result + .field(QueryDefinitions.FIND_USERS_SETTINGS_QUERY_NAME) + .toEntityList(Settings.class); + sink.next(list != null ? list : List.of()); + sink.complete(); + } catch (Exception mappingEx) { + sink.error(new UserServiceConnectionException( + "Failed to map 'findUsersSettings' from user-service response: " + + mappingEx.getMessage())); + } }) .retry(RETRY_COUNT) .block(); diff --git a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java new file mode 100644 index 0000000..e991a94 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java @@ -0,0 +1,132 @@ +package de.unistuttgart.iste.meitrex.client; + +import de.unistuttgart.iste.meitrex.generated.dto.Gamification; +import de.unistuttgart.iste.meitrex.generated.dto.Settings; +import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient; +import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; +import org.junit.jupiter.api.Test; +import org.springframework.graphql.client.FieldAccessException; +import org.springframework.graphql.client.GraphQlClient; +import org.springframework.graphql.client.HttpGraphQlClient; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for SettingsServiceClient + */ +class SettingsServiceClientTest { + + @Test + void queryUserSettings_success() throws Exception { + String json = """ + { + "data": { + "findUserSettings": { + "gamification": "GAMIFICATION_ENABLED", + "notification": { "gamification": true, "lecture": false } + } + } + } + """; + + SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json)); + + UUID userId = UUID.randomUUID(); + Settings s = client.queryUserSettings(userId); + + assertNotNull(s); + assertEquals(Gamification.GAMIFICATION_ENABLED, s.getGamification()); + assertNotNull(s.getNotification()); + assertEquals(Boolean.TRUE, s.getNotification().getGamification()); + assertEquals(Boolean.FALSE, s.getNotification().getLecture()); + } + + @Test + void queryUsersSettings_success() throws Exception { + String json = """ + { + "data": { + "findUsersSettings": [ + { + "gamification": "GAMIFICATION_ENABLED", + "notification": { "gamification": true, "lecture": false } + }, + { + "gamification": "ALL_GAMIFICATION_DISABLED", + "notification": { "gamification": false, "lecture": true } + } + ] + } + } + """; + + SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json)); + + var u1 = UUID.randomUUID(); + var u2 = UUID.randomUUID(); + List list = client.queryUsersSettings(List.of(u1, u2)); + + assertNotNull(list); + assertEquals(2, list.size()); + + assertEquals(Gamification.GAMIFICATION_ENABLED, list.get(0).getGamification()); + assertEquals(Boolean.TRUE, list.get(0).getNotification().getGamification()); + assertEquals(Boolean.FALSE, list.get(0).getNotification().getLecture()); + + assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, list.get(1).getGamification()); + assertEquals(Boolean.FALSE, list.get(1).getNotification().getGamification()); + assertEquals(Boolean.TRUE, list.get(1).getNotification().getLecture()); + } + + @Test + void queryUserSettings_graphQlErrors_throws() { + String json = """ + { + "data": { "findUserSettings": null }, + "errors": [ { "message": "boom", "path": ["findUserSettings"] } ] + } + """; + + ExchangeFunction fx = req -> { + ClientResponse resp = ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(json) + .build(); + return Mono.just(resp); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + GraphQlClient gql = HttpGraphQlClient.builder(webClient).build(); + + SettingsServiceClient client = new SettingsServiceClient(gql); + + assertThrows(UserServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + /** + * Creates a GraphQlClient backed by a WebClient that always returns the given JSON. + * No server, no extra deps. + */ + private static GraphQlClient mockGraphQl(String jsonResponse) { + ExchangeFunction fx = request -> { + ClientResponse resp = ClientResponse + .create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(jsonResponse) + .build(); + return Mono.just(resp); + }; + WebClient webClient = WebClient.builder() + .exchangeFunction(fx) + .build(); + return HttpGraphQlClient.builder(webClient).build(); + } +} From 4158fe528c40e5752b4c52a6550b126e22ee0934 Mon Sep 17 00:00:00 2001 From: lVentus Date: Tue, 9 Sep 2025 00:22:40 +0200 Subject: [PATCH 04/10] Add more test --- .../client/SettingsServiceClientTest.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java index e991a94..7576a4a 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; @@ -111,6 +112,62 @@ void queryUserSettings_graphQlErrors_throws() { () -> client.queryUserSettings(UUID.randomUUID())); } + @Test + void queryUserSettings_fieldMissing_throws() { + String json = "{ \"data\": { } }"; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + assertThrows(UserServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + + @Test + void queryUserSettings_retriesThenSuccess() throws Exception { + String err = "{ \"data\": { \"findUserSettings\": null }, \"errors\": [ { \"message\": \"e\" } ] }"; + String ok = """ + { "data": { "findUserSettings": { + "gamification": "ALL_GAMIFICATION_DISABLED", + "notification": { "gamification": false, "lecture": true } + } } } + """; + SettingsServiceClient client = new SettingsServiceClient(gqlWithSequence(err, err, ok)); + + Settings s = client.queryUserSettings(UUID.randomUUID()); + + assertNotNull(s); + assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, s.getGamification()); + assertEquals(Boolean.FALSE, s.getNotification().getGamification()); + assertEquals(Boolean.TRUE, s.getNotification().getLecture()); + } + + @Test + void queryUsersSettings_emptyInput_noHttpCall() { + AtomicInteger calls = new AtomicInteger(0); + SettingsServiceClient client = new SettingsServiceClient(gqlCounting(calls)); + + try { + List list = client.queryUsersSettings(List.of()); + assertNotNull(list); + assertTrue(list.isEmpty()); + assertEquals(0, calls.get(), "No HTTP call should be made for empty input"); + } catch (UserServiceConnectionException e) { + fail("Unexpected exception: " + e.getMessage()); + } + } + + + + @Test + void queryUsersSettings_mappingError_throws() { + String json = "{ \"data\": { \"findUsersSettings\": { \"not\": \"a list\" } } }"; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + assertThrows(UserServiceConnectionException.class, + () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); + } + + /** * Creates a GraphQlClient backed by a WebClient that always returns the given JSON. * No server, no extra deps. @@ -129,4 +186,41 @@ private static GraphQlClient mockGraphQl(String jsonResponse) { .build(); return HttpGraphQlClient.builder(webClient).build(); } + + private static GraphQlClient gqlWithJson(String json) { + ExchangeFunction fx = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(json) + .build() + ); + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlWithSequence(String... jsonResponses) { + AtomicInteger idx = new AtomicInteger(0); + ExchangeFunction fx = req -> { + String body = jsonResponses[Math.min(idx.getAndIncrement(), jsonResponses.length - 1)]; + return Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(body) + .build()); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlCounting(AtomicInteger counter) { + ExchangeFunction fx = req -> { + counter.incrementAndGet(); + return Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body("{ \"data\": { \"findUsersSettings\": [] } }") + .build()); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + } From 38fd72efafc6169eab5efcffa01b92f33d9ed436 Mon Sep 17 00:00:00 2001 From: lVentus Date: Tue, 9 Sep 2025 00:32:06 +0200 Subject: [PATCH 05/10] Add more test for code coverage --- .../UserServiceConnectionException.java | 8 +++-- .../client/SettingsServiceClientTest.java | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java index 370f14b..cef8a87 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java @@ -40,8 +40,12 @@ public String getMessage() { * Unwraps a RuntimeException thrown by reactive pipelines and rethrows as UserServiceConnectionException when possible. */ public static void unwrapAndThrow(final RuntimeException e) throws UserServiceConnectionException { - if (e.getCause() instanceof final UserServiceConnectionException ue) { - throw ue; + Throwable t = e; + while (t != null) { + if (t instanceof UserServiceConnectionException uce) { + throw uce; + } + t = t.getCause(); } throw e; } diff --git a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java index 7576a4a..7c9421c 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java @@ -223,4 +223,39 @@ private static GraphQlClient gqlCounting(AtomicInteger counter) { return HttpGraphQlClient.builder(webClient).build(); } + private static GraphQlClient gqlErrorWrapped(String message) { + ExchangeFunction fx = req -> { + return Mono.error(new RuntimeException( + new UserServiceConnectionException(message) + )); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + @Test + void queryUserSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { + SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); + assertThrows(UserServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + @Test + void queryUsersSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { + SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); + assertThrows(UserServiceConnectionException.class, + () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); + } + + @Test + void queryUsersSettings_nullList_normalizedToEmpty() throws UserServiceConnectionException { + String json = """ + { "data": { "findUsersSettings": null } } + """; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + List list = client.queryUsersSettings(List.of(UUID.randomUUID())); + org.junit.jupiter.api.Assertions.assertNotNull(list); + org.junit.jupiter.api.Assertions.assertTrue(list.isEmpty()); + } } From bece17cf500fb63c66eafa2c33be091a8fc7c1e7 Mon Sep 17 00:00:00 2001 From: lVentus Date: Tue, 9 Sep 2025 00:44:57 +0200 Subject: [PATCH 06/10] Add more tests --- .../client/SettingsServiceClientTest.java | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java index 7c9421c..a01ed83 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java @@ -4,8 +4,15 @@ import de.unistuttgart.iste.meitrex.generated.dto.Settings; import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient; import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; +import graphql.ErrorType; import org.junit.jupiter.api.Test; -import org.springframework.graphql.client.FieldAccessException; +import org.springframework.graphql.ResponseError; + +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; import org.springframework.graphql.client.GraphQlClient; import org.springframework.graphql.client.HttpGraphQlClient; import org.springframework.http.HttpStatus; @@ -14,7 +21,6 @@ import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; -import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; @@ -258,4 +264,76 @@ void queryUsersSettings_nullList_normalizedToEmpty() throws UserServiceConnectio org.junit.jupiter.api.Assertions.assertNotNull(list); org.junit.jupiter.api.Assertions.assertTrue(list.isEmpty()); } + + private static ResponseError responseError(String message) { + return (ResponseError) Proxy.newProxyInstance( + ResponseError.class.getClassLoader(), + new Class[]{ResponseError.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("getMessage".equals(name)) { + return message; + } + if ("toSpecification".equals(name)) { + return Map.of("message", message); + } + return null; + } + ); + } + + + @Test + void constructor_withMessage_keepsMessage() { + var ex = new UserServiceConnectionException("plain"); + assertEquals("plain", ex.getMessage()); + } + + @Test + void constructor_withErrors_formatsMessage() { + var base = "Invalid response"; + var ex = new UserServiceConnectionException(base, java.util.List.of( + responseError("boom1"), + responseError("boom2") + )); + + String msg = ex.getMessage(); + assertTrue(msg.contains(base)); + assertTrue(msg.contains("boom1")); + assertTrue(msg.contains("boom2")); + } + + @Test + void constructor_withNullErrors_fallsBackToBaseMessage() { + var ex = new UserServiceConnectionException("only-base", null); + assertEquals("only-base", ex.getMessage()); + } + + @Test + void unwrapAndThrow_directWrapped_throwsInnerUserEx() { + var inner = new UserServiceConnectionException("inner"); + var outer = new RuntimeException(inner); + + var thrown = assertThrows(UserServiceConnectionException.class, + () -> UserServiceConnectionException.unwrapAndThrow(outer)); + assertEquals("inner", thrown.getMessage()); + } + + @Test + void unwrapAndThrow_deeplyNested_throwsInnerUserEx() { + var inner = new UserServiceConnectionException("deep"); + var outer = new RuntimeException(new IllegalStateException(inner)); + + var thrown = assertThrows(UserServiceConnectionException.class, + () -> UserServiceConnectionException.unwrapAndThrow(outer)); + assertEquals("deep", thrown.getMessage()); + } + + @Test + void unwrapAndThrow_noUserEx_rethrowsOriginalRuntime() { + var outer = new RuntimeException(new IllegalArgumentException("no-user-ex")); + var rethrown = assertThrows(RuntimeException.class, + () -> UserServiceConnectionException.unwrapAndThrow(outer)); + assertSame(outer, rethrown); + } } From ddc4c4b747c6240844934378d87b8f52a20478e5 Mon Sep 17 00:00:00 2001 From: lVentus Date: Tue, 9 Sep 2025 11:45:46 +0200 Subject: [PATCH 07/10] Rename exception --- build.gradle | 5 +-- .../client/SettingsServiceClient.java | 24 +++++------ ...=> SettingServiceConnectionException.java} | 10 ++--- .../client/SettingsServiceClientTest.java | 41 +++++++++---------- 4 files changed, 38 insertions(+), 42 deletions(-) rename src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/{UserServiceConnectionException.java => SettingServiceConnectionException.java} (79%) diff --git a/build.gradle b/build.gradle index 8cd8a05..40ea0ed 100644 --- a/build.gradle +++ b/build.gradle @@ -108,9 +108,8 @@ repositories { } dependencies { - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.2.6' - implementation 'org.springframework.boot:spring-boot-starter-graphql' - implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation("com.google.code.gson:gson:2.10.1") + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.6' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java index 7072f7c..8b8f06f 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java @@ -1,7 +1,7 @@ package de.unistuttgart.iste.meitrex.user_service.client; import de.unistuttgart.iste.meitrex.generated.dto.Settings; -import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; +import de.unistuttgart.iste.meitrex.user_service.exception.SettingServiceConnectionException; import lombok.extern.slf4j.Slf4j; import org.springframework.graphql.client.ClientGraphQlResponse; import org.springframework.graphql.client.GraphQlClient; @@ -28,16 +28,16 @@ public SettingsServiceClient(final GraphQlClient graphQlClient) { * * @param userId the user id * @return settings of the user (never null on success) - * @throws UserServiceConnectionException if the response has GraphQL errors, the field is missing/null, or mapping fails + * @throws SettingServiceConnectionException if the response has GraphQL errors, the field is missing/null, or mapping fails */ - public Settings queryUserSettings(final UUID userId) throws UserServiceConnectionException { + public Settings queryUserSettings(final UUID userId) throws SettingServiceConnectionException { try { return graphQlClient.document(QueryDefinitions.FIND_USER_SETTINGS_QUERY) .variable("userId", userId) .execute() .handle((ClientGraphQlResponse result, SynchronousSink sink) -> { if (!result.isValid()) { - sink.error(new UserServiceConnectionException( + sink.error(new SettingServiceConnectionException( "Invalid response from user-service (findUserSettings).", result.getErrors())); return; @@ -47,14 +47,14 @@ public Settings queryUserSettings(final UUID userId) throws UserServiceConnectio .field(QueryDefinitions.FIND_USER_SETTINGS_QUERY_NAME) .toEntity(Settings.class); if (s == null) { - sink.error(new UserServiceConnectionException( + sink.error(new SettingServiceConnectionException( "Missing field 'findUserSettings' in user-service response.")); return; } sink.next(s); sink.complete(); } catch (Exception mappingEx) { - sink.error(new UserServiceConnectionException( + sink.error(new SettingServiceConnectionException( "Failed to map 'findUserSettings' from user-service response: " + mappingEx.getMessage())); } @@ -62,7 +62,7 @@ public Settings queryUserSettings(final UUID userId) throws UserServiceConnectio .retry(RETRY_COUNT) .block(); } catch (final RuntimeException e) { - UserServiceConnectionException.unwrapAndThrow(e); + SettingServiceConnectionException.unwrapAndThrow(e); return null; } } @@ -72,9 +72,9 @@ public Settings queryUserSettings(final UUID userId) throws UserServiceConnectio * * @param userIds list of user ids * @return list of settings (never null; may be empty) - * @throws UserServiceConnectionException if the response has GraphQL errors or mapping fails + * @throws SettingServiceConnectionException if the response has GraphQL errors or mapping fails */ - public List queryUsersSettings(final List userIds) throws UserServiceConnectionException { + public List queryUsersSettings(final List userIds) throws SettingServiceConnectionException { if (userIds == null || userIds.isEmpty()) { return List.of(); } @@ -84,7 +84,7 @@ public List queryUsersSettings(final List userIds) throws UserSe .execute() .handle((ClientGraphQlResponse result, SynchronousSink> sink) -> { if (!result.isValid()) { - sink.error(new UserServiceConnectionException( + sink.error(new SettingServiceConnectionException( "Invalid response from user-service (findUsersSettings).", result.getErrors())); return; @@ -96,7 +96,7 @@ public List queryUsersSettings(final List userIds) throws UserSe sink.next(list != null ? list : List.of()); sink.complete(); } catch (Exception mappingEx) { - sink.error(new UserServiceConnectionException( + sink.error(new SettingServiceConnectionException( "Failed to map 'findUsersSettings' from user-service response: " + mappingEx.getMessage())); } @@ -104,7 +104,7 @@ public List queryUsersSettings(final List userIds) throws UserSe .retry(RETRY_COUNT) .block(); } catch (final RuntimeException e) { - UserServiceConnectionException.unwrapAndThrow(e); + SettingServiceConnectionException.unwrapAndThrow(e); return List.of(); // unreachable } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java similarity index 79% rename from src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java rename to src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java index cef8a87..d05aeae 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java @@ -7,16 +7,16 @@ /** * Exception thrown when the connection to the user-service fails or returns an invalid response. */ -public class UserServiceConnectionException extends Exception { +public class SettingServiceConnectionException extends Exception { private final String message; - public UserServiceConnectionException(final String message) { + public SettingServiceConnectionException(final String message) { super(message); this.message = message; } - public UserServiceConnectionException(final String message, final List errors) { + public SettingServiceConnectionException(final String message, final List errors) { super(withErrors(message, errors)); this.message = withErrors(message, errors); } @@ -39,10 +39,10 @@ public String getMessage() { /** * Unwraps a RuntimeException thrown by reactive pipelines and rethrows as UserServiceConnectionException when possible. */ - public static void unwrapAndThrow(final RuntimeException e) throws UserServiceConnectionException { + public static void unwrapAndThrow(final RuntimeException e) throws SettingServiceConnectionException { Throwable t = e; while (t != null) { - if (t instanceof UserServiceConnectionException uce) { + if (t instanceof SettingServiceConnectionException uce) { throw uce; } t = t.getCause(); diff --git a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java index a01ed83..ec84b02 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java @@ -3,8 +3,7 @@ import de.unistuttgart.iste.meitrex.generated.dto.Gamification; import de.unistuttgart.iste.meitrex.generated.dto.Settings; import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient; -import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; -import graphql.ErrorType; +import de.unistuttgart.iste.meitrex.user_service.exception.SettingServiceConnectionException; import org.junit.jupiter.api.Test; import org.springframework.graphql.ResponseError; @@ -24,8 +23,6 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; -import static org.junit.jupiter.api.Assertions.*; - /** * Tests for SettingsServiceClient */ @@ -114,7 +111,7 @@ void queryUserSettings_graphQlErrors_throws() { SettingsServiceClient client = new SettingsServiceClient(gql); - assertThrows(UserServiceConnectionException.class, + assertThrows(SettingServiceConnectionException.class, () -> client.queryUserSettings(UUID.randomUUID())); } @@ -123,7 +120,7 @@ void queryUserSettings_fieldMissing_throws() { String json = "{ \"data\": { } }"; SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); - assertThrows(UserServiceConnectionException.class, + assertThrows(SettingServiceConnectionException.class, () -> client.queryUserSettings(UUID.randomUUID())); } @@ -157,7 +154,7 @@ void queryUsersSettings_emptyInput_noHttpCall() { assertNotNull(list); assertTrue(list.isEmpty()); assertEquals(0, calls.get(), "No HTTP call should be made for empty input"); - } catch (UserServiceConnectionException e) { + } catch (SettingServiceConnectionException e) { fail("Unexpected exception: " + e.getMessage()); } } @@ -169,7 +166,7 @@ void queryUsersSettings_mappingError_throws() { String json = "{ \"data\": { \"findUsersSettings\": { \"not\": \"a list\" } } }"; SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); - assertThrows(UserServiceConnectionException.class, + assertThrows(SettingServiceConnectionException.class, () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); } @@ -232,7 +229,7 @@ private static GraphQlClient gqlCounting(AtomicInteger counter) { private static GraphQlClient gqlErrorWrapped(String message) { ExchangeFunction fx = req -> { return Mono.error(new RuntimeException( - new UserServiceConnectionException(message) + new SettingServiceConnectionException(message) )); }; WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); @@ -242,19 +239,19 @@ private static GraphQlClient gqlErrorWrapped(String message) { @Test void queryUserSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); - assertThrows(UserServiceConnectionException.class, + assertThrows(SettingServiceConnectionException.class, () -> client.queryUserSettings(UUID.randomUUID())); } @Test void queryUsersSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); - assertThrows(UserServiceConnectionException.class, + assertThrows(SettingServiceConnectionException.class, () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); } @Test - void queryUsersSettings_nullList_normalizedToEmpty() throws UserServiceConnectionException { + void queryUsersSettings_nullList_normalizedToEmpty() throws SettingServiceConnectionException { String json = """ { "data": { "findUsersSettings": null } } """; @@ -285,14 +282,14 @@ private static ResponseError responseError(String message) { @Test void constructor_withMessage_keepsMessage() { - var ex = new UserServiceConnectionException("plain"); + var ex = new SettingServiceConnectionException("plain"); assertEquals("plain", ex.getMessage()); } @Test void constructor_withErrors_formatsMessage() { var base = "Invalid response"; - var ex = new UserServiceConnectionException(base, java.util.List.of( + var ex = new SettingServiceConnectionException(base, java.util.List.of( responseError("boom1"), responseError("boom2") )); @@ -305,27 +302,27 @@ void constructor_withErrors_formatsMessage() { @Test void constructor_withNullErrors_fallsBackToBaseMessage() { - var ex = new UserServiceConnectionException("only-base", null); + var ex = new SettingServiceConnectionException("only-base", null); assertEquals("only-base", ex.getMessage()); } @Test void unwrapAndThrow_directWrapped_throwsInnerUserEx() { - var inner = new UserServiceConnectionException("inner"); + var inner = new SettingServiceConnectionException("inner"); var outer = new RuntimeException(inner); - var thrown = assertThrows(UserServiceConnectionException.class, - () -> UserServiceConnectionException.unwrapAndThrow(outer)); + var thrown = assertThrows(SettingServiceConnectionException.class, + () -> SettingServiceConnectionException.unwrapAndThrow(outer)); assertEquals("inner", thrown.getMessage()); } @Test void unwrapAndThrow_deeplyNested_throwsInnerUserEx() { - var inner = new UserServiceConnectionException("deep"); + var inner = new SettingServiceConnectionException("deep"); var outer = new RuntimeException(new IllegalStateException(inner)); - var thrown = assertThrows(UserServiceConnectionException.class, - () -> UserServiceConnectionException.unwrapAndThrow(outer)); + var thrown = assertThrows(SettingServiceConnectionException.class, + () -> SettingServiceConnectionException.unwrapAndThrow(outer)); assertEquals("deep", thrown.getMessage()); } @@ -333,7 +330,7 @@ void unwrapAndThrow_deeplyNested_throwsInnerUserEx() { void unwrapAndThrow_noUserEx_rethrowsOriginalRuntime() { var outer = new RuntimeException(new IllegalArgumentException("no-user-ex")); var rethrown = assertThrows(RuntimeException.class, - () -> UserServiceConnectionException.unwrapAndThrow(outer)); + () -> SettingServiceConnectionException.unwrapAndThrow(outer)); assertSame(outer, rethrown); } } From c195a60486413831433935e413cf5d8b89d6245f Mon Sep 17 00:00:00 2001 From: lVentus Date: Tue, 9 Sep 2025 12:06:31 +0200 Subject: [PATCH 08/10] Merge Main --- README.md | 50 ++- api.md | 174 ++++++++ build.gradle | 5 +- docker-compose.yml | 6 +- pg-init-scripts/create-multiple-databases.sh | 2 +- .../user_service/UserServiceApplication.java | 2 - .../user_service/client/QueryDefinitions.java | 31 -- .../client/SettingsServiceClient.java | 111 ----- .../client/UserServiceClient.java | 176 ++++++++ ...phQlScalarsAndValidationConfiguration.java | 35 -- .../access_token/AccessTokenResponse.java | 22 + .../ExternalServiceProviderConfiguration.java | 38 ++ .../ExternalServiceProviderInfo.java | 26 ++ .../access_token/HttpClientConfiguration.java | 16 + .../{ => user}/KeycloakConfiguration.java | 2 +- .../config/{ => user}/KeycloakWrapper.java | 2 +- .../controller/AccessTokenController.java | 47 +++ .../controller/UserController.java | 13 + .../SettingServiceConnectionException.java | 52 --- .../UserServiceConnectionException.java | 7 + .../persistence/entity/AccessTokenEntity.java | 62 +++ .../persistence/entity/AccessTokenPk.java | 16 + .../entity/ExternalServiceProvider.java | 5 + .../persistence/entity/UserEntity.java | 22 + .../repository/AccessTokenRepository.java | 21 + .../repository/UserRepository.java | 11 + .../service/AccessTokenService.java | 189 +++++++++ .../user_service/service/UserService.java | 19 +- .../service/oauth/ExternalOAuthClient.java | 53 +++ .../service/oauth/ExternalOAuthStrategy.java | 59 +++ .../service/oauth/GitHubOAuthStrategy.java | 164 ++++++++ src/main/resources/application-dev.properties | 12 +- .../resources/application-prod.properties | 11 +- src/main/resources/application.properties | 13 +- .../graphql/common/directives.graphqls | 23 -- .../resources/graphql/common/scalars.graphqls | 12 - .../common/sortFilterPagination.graphqls | 104 ----- .../graphql/service/mutation.graphqls | 23 ++ .../resources/graphql/service/query.graphqls | 31 ++ .../graphql/service/settings.graphqls | 5 - .../graphql/service/userInfo.graphqls | 11 + .../client/SettingsServiceClientTest.java | 336 --------------- .../api/MutationSetNicknameTest.java | 60 +++ .../api/QueryAccessTokenTest.java | 172 ++++++++ .../api/QueryUserNicknameByUserId.java | 67 +++ .../client/UserServiceClientTest.java | 182 +++++++++ .../client/WebTestClientTransport.java | 61 +++ .../AccessTokenRepositoryTest.java | 44 ++ .../service/AccessTokenServiceTest.java | 381 ++++++++++++++++++ .../service/ExternalOAuthClientTest.java | 71 ++++ .../service/GitHubOAuthStrategyTest.java | 151 +++++++ .../MockAccessTokenServiceConfiguration.java | 51 +++ .../MockKeycloakConfiguration.java | 2 +- src/test/resources/application.properties | 7 +- 54 files changed, 2513 insertions(+), 755 deletions(-) delete mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java delete mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/client/UserServiceClient.java delete mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/config/GraphQlScalarsAndValidationConfiguration.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/AccessTokenResponse.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/ExternalServiceProviderConfiguration.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/ExternalServiceProviderInfo.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/HttpClientConfiguration.java rename src/main/java/de/unistuttgart/iste/meitrex/user_service/config/{ => user}/KeycloakConfiguration.java (92%) rename src/main/java/de/unistuttgart/iste/meitrex/user_service/config/{ => user}/KeycloakWrapper.java (80%) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/controller/AccessTokenController.java delete mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/AccessTokenEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/AccessTokenPk.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/ExternalServiceProvider.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/UserEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/repository/AccessTokenRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/repository/UserRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/service/AccessTokenService.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/ExternalOAuthClient.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/ExternalOAuthStrategy.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/GitHubOAuthStrategy.java delete mode 100644 src/main/resources/graphql/common/directives.graphqls delete mode 100644 src/main/resources/graphql/common/scalars.graphqls delete mode 100644 src/main/resources/graphql/common/sortFilterPagination.graphqls create mode 100644 src/main/resources/graphql/service/mutation.graphqls delete mode 100644 src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/api/MutationSetNicknameTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/api/QueryAccessTokenTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/api/QueryUserNicknameByUserId.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/client/UserServiceClientTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/client/WebTestClientTransport.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/persistence/AccessTokenRepositoryTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/service/AccessTokenServiceTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/service/ExternalOAuthClientTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/service/GitHubOAuthStrategyTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/user_service/test_config/MockAccessTokenServiceConfiguration.java diff --git a/README.md b/README.md index d4196e0..414a77a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ # User Service -The User Service has one role in our system: +The User Service has two roles in our system: **GraphQL Interface for User Metadata:** The service has a GraphQL interface that makes it simple to get user information stored in Keycloak. +**OAuth2 Access Token Management:** Handles generation, storage, refresh, and retrieval of OAuth2 access tokens for external service providers (e.g., GitHub). This enables other services to securely interact with external APIs on behalf of users. + + ## Purpose **GraphQL Interface for User Metadata Retrieval:** @@ -12,25 +15,36 @@ The User Service has one role in our system: ## Environment variables ### Relevant for deployment -| Name | Description | Value in Dev Environment | Value in Prod Environment | -|----------------------------|------------------------------------|-----------------------------------------------|----------------------------------------------------------------| -| DAPR_HTTP_PORT | Dapr HTTP Port | 5000 | 3500 | -| server.port | Port on which the application runs | 5001 | 5001 | -| KEYCLOAK_URL | Keycloak URL | http://localhost:9009/ | http://keycloak/keycloak | -| KEYCLOAK_PASSWORD | Keycloak admin password | admin | *secret* | -| keycloak.clientId | Keycloak client ID | admin-cli | admin-cli | -| keycloak.masterRealm | Keycloak master realm | master | master | -| keycloak.realm | Keycloak realm for the application | GITS | GITS | -| keycloak.username | Keycloak admin username | admin | admin | +| Name | Description | Value in Dev Environment | Value in Prod Environment | +|------------------------------------------|------------------------------------------------------------|--------------------------|---------------------------| +| DAPR_HTTP_PORT | Dapr HTTP Port | 5000 | 3500 | +| server.port | Port on which the application runs | 5001 | 5001 | +| KEYCLOAK_URL | Keycloak URL | http://localhost:9009/ | http://keycloak/keycloak | +| KEYCLOAK_PASSWORD | Keycloak admin password | admin | *secret* | +| keycloak.clientId | Keycloak client ID | admin-cli | admin-cli | +| keycloak.masterRealm | Keycloak master realm | master | master | +| keycloak.realm | Keycloak realm for the application | GITS | GITS | +| keycloak.username | Keycloak admin username | admin | admin | +| thirdparty.providers.github.clientId | GitHub app client ID used to manage user access tokens | Iv23liynxdcJafLw0ptQ | Iv23liNIRTUsh31DAv4u | +| thirdparty.providers.github.clientSecret | GitHub app client secret used to manage user access tokens | *dev secret* | *secret* | ### Other properties -| Name | Description | Value in Dev Environment | Value in Prod Environment | -|-----------------------------------------|-------------------------------------------|-----------------------------------------|-----------------------------------------| -| spring.graphql.graphiql.enabled | Enable GraphiQL web interface for GraphQL | true | true | -| spring.graphql.graphiql.path | Path for GraphiQL when enabled | /graphiql | /graphiql | -| spring.profiles.active | Active Spring profile | dev | prod | -| DAPR_GRPC_PORT | Dapr gRPC Port | - | 50001 | - +| Name | Description | Value in Dev Environment | Value in Prod Environment | +|-----------------------------------------------|----------------------------------------------------------------------------|---------------------------------------------|-----------------------------------------| +| spring.graphql.graphiql.enabled | Enable GraphiQL web interface for GraphQL | true | true | +| spring.graphql.graphiql.path | Path for GraphiQL when enabled | /graphiql | /graphiql | +| spring.profiles.active | Active Spring profile | dev | prod | +| DAPR_GRPC_PORT | Dapr gRPC Port | - | 50001 | +| spring.jpa.properties.hibernate.dialect | Hibernate dialect for PostgreSQL | org.hibernate.dialect.PostgreSQLDialect | org.hibernate.dialect.PostgreSQLDialect | +| spring.datasource.driver-class-name | JDBC driver class | org.postgresql.Driver | org.postgresql.Driver | +| spring.sql.init.mode | SQL initialization mode | always | always | +| spring.jpa.show-sql | Show SQL queries in logs | true | false | +| spring.sql.init.continue-on-error | Continue on SQL init error | true | true | +| spring.jpa.hibernate.ddl-auto | Hibernate DDL auto strategy | create | update | +| logging.level.root | Logging level for root logger | DEBUG | - | +| DAPR_GRPC_PORT | Dapr gRPC Port | - | 50001 | +| thirdparty.providers.github.tokenRequestUrl | GitHub endpoint to exchange auth codes and refresh tokens | https://github.com/login/oauth/access_token | same | +| thirdparty.providers.github.externalUserIdUrl | GitHub endpoint to fetch a user's GitHub username using their access token | https://api.github.com/user | same | ## API description The GraphQL API is described in the [api.md file](api.md). diff --git a/api.md b/api.md index e58e2e5..de30479 100644 --- a/api.md +++ b/api.md @@ -6,6 +6,8 @@ * [Query](#query) * [Mutation](#mutation) * [Objects](#objects) + * [AccessToken](#accesstoken) + * [ExternalUserIdWithUser](#externaluseridwithuser) * [Notification](#notification) * [PaginationInfo](#paginationinfo) * [PublicUserInfo](#publicuserinfo) @@ -13,12 +15,14 @@ * [UserInfo](#userinfo) * [Inputs](#inputs) * [DateTimeFilter](#datetimefilter) + * [GenerateAccessTokenInput](#generateaccesstokeninput) * [IntFilter](#intfilter) * [NotificationInput](#notificationinput) * [Pagination](#pagination) * [SettingsInput](#settingsinput) * [StringFilter](#stringfilter) * [Enums](#enums) + * [ExternalServiceProviderDto](#externalserviceproviderdto) * [Gamification](#gamification) * [GlobalUserRole](#globaluserrole) * [SortDirection](#sortdirection) @@ -87,6 +91,69 @@ If a user does not exist, null is returned for that user. +isAccessTokenAvailable +Boolean! + + +Checks whether an access token for a given third-party provider exists and is still valid for the currently authenticated user. +Returns `true` if: +- The access token exists and is not expired, OR +- The refresh token exists and is not expired. + + + + +provider +ExternalServiceProviderDto! + + + +_internal_noauth_getAccessToken +AccessToken! + + +Retrieves the access token of the specified user for a specified third-party provider. + +- If the access token is expired but a valid refresh token is available, the system will attempt to generate a new access token. +- Throws an exception if neither a valid access token nor a valid refresh token is available. + +⚠️ This query is **only accessible internally**. ⚠️ + + + + +userId +UUID! + + + +provider +ExternalServiceProviderDto! + + + +_internal_noauth_getExternalUserIds +[ExternalUserIdWithUser!]! + + +Retrieves the external user IDs for a list of users with the specified IDs. +If a user does not exist, null is returned for that user. +If the user does not have an external user ID for the specified provider, null is returned for that user. +⚠️ This query is **only accessible internally** and the caller must be at least a tutor of a course. The permission must be validated by the caller method. ⚠️ + + + + +userIds +[UUID!]! + + + +provider +ExternalServiceProviderDto + + + findUserSettings Settings! @@ -121,6 +188,22 @@ If a user does not exist, null is returned for that user. +generateAccessToken +Boolean! + + +Generates an access token for the given provider using an authorization code obtained from the OAuth flow. +This should be called **only after** the user completes authorization and the frontend retrieves the auth code. +After the access token is generated, the user is redirected to the redirect URI. + + + + +input +GenerateAccessTokenInput! + + + updateSettings Settings! @@ -150,6 +233,56 @@ If a user does not exist, null is returned for that user. ## Objects +### AccessToken + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
accessTokenString!
externalUserIdString
+ +### ExternalUserIdWithUser + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
userIdUUID!
externalUserIdString!
+ ### Notification @@ -364,6 +497,30 @@ If specified, filters for dates before the specified value.
+### GenerateAccessTokenInput + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
providerExternalServiceProviderDto!
authorizationCodeString!
+ ### IntFilter Filter for integer values. @@ -539,6 +696,23 @@ If true, the filter is case-insensitive. ## Enums +### ExternalServiceProviderDto + + + + + + + + + + + + + + +
ValueDescription
GITHUB
+ ### Gamification diff --git a/build.gradle b/build.gradle index 40ea0ed..ef929da 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,7 @@ graphqlCodegen { packageName = "de.unistuttgart.iste.meitrex.generated.dto" generatedAnnotation = "jakarta.annotation.Generated" modelValidationAnnotation = "jakarta.validation.constraints.NotNull" + generateJacksonTypeIdResolver = true // allows graphql .toEntity() conversion with Jackson for interfaces and unions generateApis = false // set to false as the generator does not support spring boot graphQL customTypesMapping = [ "DateTime" : "java.time.OffsetDateTime", @@ -126,14 +127,14 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.2.6' + testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.6' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.graphql:spring-graphql-test' testImplementation "org.mockito:mockito-core:5.+" testImplementation 'org.hamcrest:hamcrest:2.+' testImplementation "org.testcontainers:postgresql:1.+" - testImplementation "org.testcontainers:junit-jupiter:1.18.3" + testImplementation "org.testcontainers:junit-jupiter:1.+" } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index d49383f..125b45b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ version: "3" services: database: image: pgvector/pgvector:pg16 + command: -c 'max_connections=500' restart: unless-stopped expose: - 5432 @@ -10,7 +11,7 @@ services: - "5432:5432" volumes: - dbdata:/var/lib/postgresql/data - - ./../gamification_service/pg-init-scripts:/docker-entrypoint-initdb.d + - ./../user_service/pg-init-scripts:/docker-entrypoint-initdb.d environment: - POSTGRES_USER=root - POSTGRES_PASSWORD=root @@ -29,10 +30,13 @@ services: - "5001:5001" depends_on: - database + links: + - database environment: SPRING_DATASOURCE_URL: jdbc:postgresql://database:5432/user_service SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: root + KEYCLOAK_URL: http://keycloak:8080/ dapr-user: image: "daprio/daprd" command: [ diff --git a/pg-init-scripts/create-multiple-databases.sh b/pg-init-scripts/create-multiple-databases.sh index 9cec938..0825ca4 100755 --- a/pg-init-scripts/create-multiple-databases.sh +++ b/pg-init-scripts/create-multiple-databases.sh @@ -8,7 +8,7 @@ set -u function create_user_and_database() { local database=$1 echo " Creating user and database '$database'" - psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + psql -d postgres -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL CREATE DATABASE "$database"; GRANT ALL PRIVILEGES ON DATABASE "$database" TO "$POSTGRES_USER"; EOSQL diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/UserServiceApplication.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/UserServiceApplication.java index 03de7e4..2e7977e 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/UserServiceApplication.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/UserServiceApplication.java @@ -3,8 +3,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; import java.util.Arrays; diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java deleted file mode 100644 index e8542eb..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java +++ /dev/null @@ -1,31 +0,0 @@ -package de.unistuttgart.iste.meitrex.user_service.client; - -import lombok.NoArgsConstructor; - -/** - * GraphQL documents used by SettingsServiceClient. - */ -@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) -public final class QueryDefinitions { - - public static final String FIND_USER_SETTINGS_QUERY_NAME = "findUserSettings"; - public static final String FIND_USERS_SETTINGS_QUERY_NAME = "findUsersSettings"; - - public static final String FIND_USER_SETTINGS_QUERY = """ - query($userId: UUID!) { - findUserSettings(userId: $userId) { - gamification - notification { gamification lecture } - } - } - """; - - public static final String FIND_USERS_SETTINGS_QUERY = """ - query($usersIds: [UUID!]!) { - findUsersSettings(usersIds: $usersIds) { - gamification - notification { gamification lecture } - } - } - """; -} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java deleted file mode 100644 index 8b8f06f..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java +++ /dev/null @@ -1,111 +0,0 @@ -package de.unistuttgart.iste.meitrex.user_service.client; - -import de.unistuttgart.iste.meitrex.generated.dto.Settings; -import de.unistuttgart.iste.meitrex.user_service.exception.SettingServiceConnectionException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.graphql.client.ClientGraphQlResponse; -import org.springframework.graphql.client.GraphQlClient; -import reactor.core.publisher.SynchronousSink; - -import java.util.*; -import java.util.UUID; - -/** - * Client for the user-service to query user settings over GraphQL. - */ -@Slf4j -public class SettingsServiceClient { - - private static final long RETRY_COUNT = 3; - private final GraphQlClient graphQlClient; - - public SettingsServiceClient(final GraphQlClient graphQlClient) { - this.graphQlClient = graphQlClient; - } - - /** - * Query settings for a single user. - * - * @param userId the user id - * @return settings of the user (never null on success) - * @throws SettingServiceConnectionException if the response has GraphQL errors, the field is missing/null, or mapping fails - */ - public Settings queryUserSettings(final UUID userId) throws SettingServiceConnectionException { - try { - return graphQlClient.document(QueryDefinitions.FIND_USER_SETTINGS_QUERY) - .variable("userId", userId) - .execute() - .handle((ClientGraphQlResponse result, SynchronousSink sink) -> { - if (!result.isValid()) { - sink.error(new SettingServiceConnectionException( - "Invalid response from user-service (findUserSettings).", - result.getErrors())); - return; - } - try { - final Settings s = result - .field(QueryDefinitions.FIND_USER_SETTINGS_QUERY_NAME) - .toEntity(Settings.class); - if (s == null) { - sink.error(new SettingServiceConnectionException( - "Missing field 'findUserSettings' in user-service response.")); - return; - } - sink.next(s); - sink.complete(); - } catch (Exception mappingEx) { - sink.error(new SettingServiceConnectionException( - "Failed to map 'findUserSettings' from user-service response: " - + mappingEx.getMessage())); - } - }) - .retry(RETRY_COUNT) - .block(); - } catch (final RuntimeException e) { - SettingServiceConnectionException.unwrapAndThrow(e); - return null; - } - } - - /** - * Query settings for multiple users. - * - * @param userIds list of user ids - * @return list of settings (never null; may be empty) - * @throws SettingServiceConnectionException if the response has GraphQL errors or mapping fails - */ - public List queryUsersSettings(final List userIds) throws SettingServiceConnectionException { - if (userIds == null || userIds.isEmpty()) { - return List.of(); - } - try { - return graphQlClient.document(QueryDefinitions.FIND_USERS_SETTINGS_QUERY) - .variable("usersIds", userIds) - .execute() - .handle((ClientGraphQlResponse result, SynchronousSink> sink) -> { - if (!result.isValid()) { - sink.error(new SettingServiceConnectionException( - "Invalid response from user-service (findUsersSettings).", - result.getErrors())); - return; - } - try { - final List list = result - .field(QueryDefinitions.FIND_USERS_SETTINGS_QUERY_NAME) - .toEntityList(Settings.class); - sink.next(list != null ? list : List.of()); - sink.complete(); - } catch (Exception mappingEx) { - sink.error(new SettingServiceConnectionException( - "Failed to map 'findUsersSettings' from user-service response: " - + mappingEx.getMessage())); - } - }) - .retry(RETRY_COUNT) - .block(); - } catch (final RuntimeException e) { - SettingServiceConnectionException.unwrapAndThrow(e); - return List.of(); // unreachable - } - } -} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/UserServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/UserServiceClient.java new file mode 100644 index 0000000..c6e662c --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/UserServiceClient.java @@ -0,0 +1,176 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + + +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.AccessToken; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalServiceProviderDto; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalUserIdWithUser; +import de.unistuttgart.iste.meitrex.generated.dto.UserInfo; +import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; +import org.springframework.graphql.client.ClientGraphQlResponse; +import org.springframework.graphql.client.FieldAccessException; +import org.springframework.graphql.client.GraphQlClient; +import reactor.core.publisher.SynchronousSink; + +import java.util.List; +import java.util.UUID; + +/* +Client allowing to query user info. + */ + +public class UserServiceClient { + + private static final long RETRY_COUNT = 3; + private final GraphQlClient graphQlClient; + + public UserServiceClient(GraphQlClient graphQlClient) { + this.graphQlClient = graphQlClient; + } + + public List queryUserInfos(final List userIds) throws UserServiceConnectionException { + final String query = """ + query($userIds: [UUID!]!) { + findUserInfos(ids: $userIds) { + id, + userName, + firstName, + lastName + } + } + """; + String queryName = "findUserInfos"; + + List meitrexStudentInfoList = null; + + try { + meitrexStudentInfoList = graphQlClient.document(query) + .variable("userIds", userIds) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink> sink) + -> handleGraphQlResponse(result, sink, queryName)) + .retry(RETRY_COUNT) + .block(); + } catch (RuntimeException e) { + unwrapUserServiceConnectionException(e); + } + + if (meitrexStudentInfoList == null) { + throw new UserServiceConnectionException("Error fetching userInfo from UserService"); + } + + return meitrexStudentInfoList; + } + + private void handleGraphQlResponse(final ClientGraphQlResponse result, final SynchronousSink> sink, final String queryName) { + if (!result.isValid()) { + sink.error(new UserServiceConnectionException(result.getErrors().toString())); + return; + } + + List retrievedUserInfos; + try { + retrievedUserInfos = result.field(queryName).toEntityList(UserInfo.class); + } catch (FieldAccessException e) { + sink.error(new UserServiceConnectionException(e.toString())); + return; + } + + // retrievedUserInfos == null is always false, therefore no check + if (retrievedUserInfos.isEmpty()) { + sink.error(new UserServiceConnectionException("Error fetching userInfo from UserService: UserInfo List is empty.")); + return; + } + + sink.next(retrievedUserInfos); + } + + public AccessToken queryAccessToken(LoggedInUser currentUser, ExternalServiceProviderDto provider) throws UserServiceConnectionException { + final String query = """ + query($userId: UUID!, $provider: ExternalServiceProviderDto!) { + _internal_noauth_getAccessToken(userId: $userId, provider: $provider) { + accessToken + externalUserId + } + } + """; + final String queryName = "_internal_noauth_getAccessToken"; + + try { + return graphQlClient.document(query) + .variable("provider", provider) + .variable("userId", currentUser.getId()) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink sink) -> { + if (!result.isValid()) { + sink.error(new UserServiceConnectionException(result.getErrors().toString())); + return; + } + + try { + AccessToken accessToken = result.field(queryName).toEntity(AccessToken.class); + if (accessToken == null || accessToken.getAccessToken().isBlank()) { + sink.error(new UserServiceConnectionException("Access token is empty or null.")); + } else { + sink.next(accessToken); + } + } catch (FieldAccessException e) { + sink.error(new UserServiceConnectionException("Failed to extract access token: " + e.getMessage())); + } + }) + .retry(RETRY_COUNT) + .block(); + } catch (RuntimeException e) { + unwrapUserServiceConnectionException(e); + return null; + } + } + + private static void unwrapUserServiceConnectionException(final RuntimeException e) throws UserServiceConnectionException { + // block wraps exceptions in a RuntimeException, so we need to unwrap them + if (e.getCause() instanceof final UserServiceConnectionException userServiceConnectionException) { + throw userServiceConnectionException; + } + // if the exception is not a UserServiceConnectionException, we don't know how to handle it + throw e; + } + + public List queryExternalUserIds(ExternalServiceProviderDto provider, List userIds) throws UserServiceConnectionException { + final String query = """ + query($provider: ExternalServiceProviderDto!, $userIds: [UUID!]!) { + _internal_noauth_getExternalUserIds(provider: $provider, userIds: $userIds) { + userId + externalUserId + } + } + """; + final String queryName = "_internal_noauth_getExternalUserIds"; + + try { + return graphQlClient.document(query) + .variable("provider", provider) + .variable("userIds", userIds) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink> sink) -> { + if (!result.isValid()) { + sink.error(new UserServiceConnectionException(result.getErrors().toString())); + return; + } + + try { + List list = result.field(queryName).toEntityList(ExternalUserIdWithUser.class); + sink.next(list); + } catch (FieldAccessException e) { + sink.error(new UserServiceConnectionException("Failed to extract userId-externalId map: " + e.getMessage())); + } + }) + .retry(RETRY_COUNT) + .block(); + } catch (RuntimeException e) { + unwrapUserServiceConnectionException(e); + return List.of(); + } + } + + +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/GraphQlScalarsAndValidationConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/GraphQlScalarsAndValidationConfiguration.java deleted file mode 100644 index e0f1a64..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/GraphQlScalarsAndValidationConfiguration.java +++ /dev/null @@ -1,35 +0,0 @@ -package de.unistuttgart.iste.meitrex.user_service.config; - -import graphql.scalars.ExtendedScalars; -import graphql.validation.rules.OnValidationErrorStrategy; -import graphql.validation.rules.ValidationRules; -import graphql.validation.schemawiring.ValidationSchemaWiring; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.graphql.execution.RuntimeWiringConfigurer; - -/** - * This class sets up the validation rules for the GraphQL schema and the scalar types. - */ -@Configuration -public class GraphQlScalarsAndValidationConfiguration { - - @Bean - public RuntimeWiringConfigurer runtimeWiringConfigurer() { - - ValidationRules validationRules = ValidationRules.newValidationRules() - .onValidationErrorStrategy(OnValidationErrorStrategy.RETURN_NULL) - .build(); - - ValidationSchemaWiring schemaWiring = new ValidationSchemaWiring(validationRules); - - return wiringBuilder -> wiringBuilder - .directiveWiring(schemaWiring) - .scalar(ExtendedScalars.DateTime) - .scalar(ExtendedScalars.Date) - .scalar(ExtendedScalars.Time) - .scalar(ExtendedScalars.LocalTime) - .scalar(ExtendedScalars.UUID) - .scalar(ExtendedScalars.Url); - } -} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/AccessTokenResponse.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/AccessTokenResponse.java new file mode 100644 index 0000000..8990d07 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/AccessTokenResponse.java @@ -0,0 +1,22 @@ +package de.unistuttgart.iste.meitrex.user_service.config.access_token; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO representing an OAuth2 access token response. + * Used to encapsulate token details received from an external service provider. + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AccessTokenResponse { + private String accessToken; + private Integer expiresIn; + private String refreshToken; + private Integer refreshTokenExpiresIn; +} \ No newline at end of file diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/ExternalServiceProviderConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/ExternalServiceProviderConfiguration.java new file mode 100644 index 0000000..64d40df --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/ExternalServiceProviderConfiguration.java @@ -0,0 +1,38 @@ +package de.unistuttgart.iste.meitrex.user_service.config.access_token; + +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +/** + * Configuration holder for OAuth2 credentials and endpoints of third-party service providers. + *

+ * This class is populated from application properties using the {@code thirdparty.providers} prefix. + * Each {@link ExternalServiceProvider} maps to a set of credentials and endpoint URLs defined in + * {@link ExternalServiceProviderInfo}. + *

+ * + *

Example configuration (in application.properties):

+ *
+ * thirdparty.providers.github.clientId=fefw2HFfnk2
+ * thirdparty.providers.github.clientSecret=a9d1frewffrhg3a9d1frewffrhg3
+ * thirdparty.providers.github.tokenRequestUrl=https://github.com/login/oauth/access_token
+ * thirdparty.providers.github.externalUserIdUrl=https://api.github.com/user
+ * 
+ * + *

Note: Each provider key (e.g. {@code github}) must match the name of a value + * from the {@link ExternalServiceProvider} enum.

+ * + * @see ExternalServiceProviderInfo + * @see ExternalServiceProvider + */ + +@Data +@Configuration +@ConfigurationProperties(prefix = "thirdparty") +public class ExternalServiceProviderConfiguration { + private Map providers; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/ExternalServiceProviderInfo.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/ExternalServiceProviderInfo.java new file mode 100644 index 0000000..40d2e65 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/ExternalServiceProviderInfo.java @@ -0,0 +1,26 @@ +package de.unistuttgart.iste.meitrex.user_service.config.access_token; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * Configuration class for an external service provider's OAuth2 authentication details. + * This class holds credentials and endpoint URLs required for obtaining and refreshing access tokens. + */ +@Data +@Builder +public class ExternalServiceProviderInfo { + @NonNull + private final String clientId; + + @NonNull + private final String clientSecret; + + @NonNull + private final String tokenRequestUrl; + + @NonNull + private final String externalUserIdUrl; +} + diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/HttpClientConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/HttpClientConfiguration.java new file mode 100644 index 0000000..cde6e36 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/access_token/HttpClientConfiguration.java @@ -0,0 +1,16 @@ +package de.unistuttgart.iste.meitrex.user_service.config.access_token; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.net.http.HttpClient; + +@Configuration +@RequiredArgsConstructor +public class HttpClientConfiguration { + @Bean + public HttpClient getHttpClient() { + return HttpClient.newHttpClient(); + } +} \ No newline at end of file diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/KeycloakConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/user/KeycloakConfiguration.java similarity index 92% rename from src/main/java/de/unistuttgart/iste/meitrex/user_service/config/KeycloakConfiguration.java rename to src/main/java/de/unistuttgart/iste/meitrex/user_service/config/user/KeycloakConfiguration.java index 92148fa..bb4fe9a 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/KeycloakConfiguration.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/user/KeycloakConfiguration.java @@ -1,4 +1,4 @@ -package de.unistuttgart.iste.meitrex.user_service.config; +package de.unistuttgart.iste.meitrex.user_service.config.user; import lombok.RequiredArgsConstructor; import org.keycloak.admin.client.Keycloak; diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/KeycloakWrapper.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/user/KeycloakWrapper.java similarity index 80% rename from src/main/java/de/unistuttgart/iste/meitrex/user_service/config/KeycloakWrapper.java rename to src/main/java/de/unistuttgart/iste/meitrex/user_service/config/user/KeycloakWrapper.java index 1568e41..df479cc 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/KeycloakWrapper.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/config/user/KeycloakWrapper.java @@ -1,4 +1,4 @@ -package de.unistuttgart.iste.meitrex.user_service.config; +package de.unistuttgart.iste.meitrex.user_service.config.user; import lombok.RequiredArgsConstructor; import org.keycloak.admin.client.resource.RealmResource; diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/controller/AccessTokenController.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/controller/AccessTokenController.java new file mode 100644 index 0000000..dbf75e5 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/controller/AccessTokenController.java @@ -0,0 +1,47 @@ +package de.unistuttgart.iste.meitrex.user_service.controller; + +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalServiceProviderDto; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalUserIdWithUser; +import de.unistuttgart.iste.meitrex.generated.dto.GenerateAccessTokenInput; +import de.unistuttgart.iste.meitrex.user_service.service.AccessTokenService; +import lombok.RequiredArgsConstructor; +import de.unistuttgart.iste.meitrex.generated.dto.AccessToken; +import lombok.extern.java.Log; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.ContextValue; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +import java.util.List; +import java.util.UUID; + +import static de.unistuttgart.iste.meitrex.common.user_handling.UserCourseAccessValidator.validateUserHasAccessToCourse; + +@Controller +@RequiredArgsConstructor +public class AccessTokenController { + + private final AccessTokenService accessTokenService; + + @QueryMapping + public boolean isAccessTokenAvailable(@ContextValue LoggedInUser currentUser, @Argument ExternalServiceProviderDto provider) { + return accessTokenService.isAccessTokenAvailable(currentUser, provider); + } + + @QueryMapping(name="_internal_noauth_getAccessToken") + public AccessToken getAccessToken(@Argument UUID userId, @Argument ExternalServiceProviderDto provider) { + return accessTokenService.getAccessToken(userId, provider); + } + + @QueryMapping(name="_internal_noauth_getExternalUserIds") + public List getExternalUserIds(@Argument ExternalServiceProviderDto provider, @Argument List userIds) { + return accessTokenService.getExternalUserIds(provider, userIds); + } + + @MutationMapping + public boolean generateAccessToken(@ContextValue LoggedInUser currentUser, @Argument GenerateAccessTokenInput input) { + return accessTokenService.generateAccessToken(currentUser, input); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/controller/UserController.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/controller/UserController.java index ef43a2c..f9c69ef 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/controller/UserController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/controller/UserController.java @@ -5,6 +5,7 @@ import de.unistuttgart.iste.meitrex.generated.dto.UserInfo; import de.unistuttgart.iste.meitrex.user_service.service.UserService; import graphql.schema.DataFetchingEnvironment; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.graphql.data.method.annotation.*; import org.springframework.stereotype.Controller; @@ -32,4 +33,16 @@ public UserInfo currentUserInfo(@ContextValue LoggedInUser currentUser) { public List findUserInfos(@Argument List ids, DataFetchingEnvironment env) { return userService.findUserInfos(ids, env); } + + @QueryMapping(name="_internal_noauth_userNicknameByUserId") + public UserInfo getUserInfo(@Argument UUID userId) { + return userService.findUserInfo(userId).orElseThrow(() -> + new EntityNotFoundException("Could not find user with id " + userId)); + } + + @MutationMapping + public UserInfo setNickname(@Argument String nickname, + @ContextValue LoggedInUser currentUser) { + return userService.setNickname(currentUser.getId(), nickname); + } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java deleted file mode 100644 index d05aeae..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java +++ /dev/null @@ -1,52 +0,0 @@ -package de.unistuttgart.iste.meitrex.user_service.exception; - -import org.springframework.graphql.ResponseError; - -import java.util.List; - -/** - * Exception thrown when the connection to the user-service fails or returns an invalid response. - */ -public class SettingServiceConnectionException extends Exception { - - private final String message; - - public SettingServiceConnectionException(final String message) { - super(message); - this.message = message; - } - - public SettingServiceConnectionException(final String message, final List errors) { - super(withErrors(message, errors)); - this.message = withErrors(message, errors); - } - - private static String withErrors(final String base, final List errors) { - if (errors == null || errors.isEmpty()) return base; - final StringBuilder sb = new StringBuilder(base).append('\n') - .append("GraphQL Response Errors:\n"); - for (final ResponseError err : errors) { - sb.append(err.getMessage()).append(" at path ").append(err.getPath()).append('\n'); - } - return sb.toString(); - } - - @Override - public String getMessage() { - return this.message; - } - - /** - * Unwraps a RuntimeException thrown by reactive pipelines and rethrows as UserServiceConnectionException when possible. - */ - public static void unwrapAndThrow(final RuntimeException e) throws SettingServiceConnectionException { - Throwable t = e; - while (t != null) { - if (t instanceof SettingServiceConnectionException uce) { - throw uce; - } - t = t.getCause(); - } - throw e; - } -} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java new file mode 100644 index 0000000..3b94714 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java @@ -0,0 +1,7 @@ +package de.unistuttgart.iste.meitrex.user_service.exception; + +public class UserServiceConnectionException extends Exception { + public UserServiceConnectionException(String message) { + super(message); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/AccessTokenEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/AccessTokenEntity.java new file mode 100644 index 0000000..8bce89b --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/AccessTokenEntity.java @@ -0,0 +1,62 @@ +package de.unistuttgart.iste.meitrex.user_service.persistence.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Represents an access token entity for storing authentication tokens + * associated with a user and an external service provider (e.g., GitHub). + *

+ * This entity maintains access and refresh tokens, along with their expiration times. + * The combination of {@code userId} and {@code provider} serves as the primary key. + *

+ */ +@Entity(name = "AccessToken") +@IdClass(AccessTokenPk.class) +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AccessTokenEntity { + @Id + @Column(nullable = false) + private UUID userId; + + @Id + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ExternalServiceProvider provider; + + // Used to query repositories and grades of the user from external code assessment provider + private String externalUserId; + + /** + * The access token used for authentication with the external service provider. + *

+ * This token is required for making authenticated API requests on behalf of the user. + * It has a maximum length of 4096 characters and can expire after a set duration. + * Once expired, a new access token can be obtained using a refresh token (if available). + *

+ */ + @Column(nullable = false, length = 4096) + private String accessToken; + + + /** + * If available the refresh token is used to obtain a new access token when it expires. + *

+ * The token is optional and stored as a string with a maximum length of 4096 characters. + *

+ */ + @Column(length = 4096) + private String refreshToken; + + @Column(nullable = false) + private OffsetDateTime accessTokenExpiresAt; + + @Column + private OffsetDateTime refreshTokenExpiresAt; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/AccessTokenPk.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/AccessTokenPk.java new file mode 100644 index 0000000..b6cff6c --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/AccessTokenPk.java @@ -0,0 +1,16 @@ +package de.unistuttgart.iste.meitrex.user_service.persistence.entity; + +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.io.Serializable; +import java.util.UUID; + +@Embeddable +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AccessTokenPk implements Serializable { + private UUID userId; + private ExternalServiceProvider provider; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/ExternalServiceProvider.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/ExternalServiceProvider.java new file mode 100644 index 0000000..21b2a2d --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/ExternalServiceProvider.java @@ -0,0 +1,5 @@ +package de.unistuttgart.iste.meitrex.user_service.persistence.entity; + +public enum ExternalServiceProvider { + GITHUB +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/UserEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/UserEntity.java new file mode 100644 index 0000000..6c471f4 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/entity/UserEntity.java @@ -0,0 +1,22 @@ +package de.unistuttgart.iste.meitrex.user_service.persistence.entity; + +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; +import jakarta.persistence.Access; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserEntity implements IWithId { + @Id + UUID id; + + String nickname; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/repository/AccessTokenRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/repository/AccessTokenRepository.java new file mode 100644 index 0000000..8e7c1c7 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/repository/AccessTokenRepository.java @@ -0,0 +1,21 @@ +package de.unistuttgart.iste.meitrex.user_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.*; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +import java.util.UUID; + +@Repository +public interface AccessTokenRepository extends JpaRepository { + /** + * Finds a third-party access token entity by the user ID and provider. + * + * @param userId The UUID of the user. + * @param provider The third-party provider (e.g., GITHUB). + * @return An {@link Optional} containing the {@link AccessTokenEntity} if found, + * otherwise an empty {@link Optional}. + */ + Optional findByUserIdAndProvider(UUID userId, ExternalServiceProvider provider); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/repository/UserRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/repository/UserRepository.java new file mode 100644 index 0000000..02152ae --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/persistence/repository/UserRepository.java @@ -0,0 +1,11 @@ +package de.unistuttgart.iste.meitrex.user_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.UserEntity; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface UserRepository extends MeitrexRepository { +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/AccessTokenService.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/AccessTokenService.java new file mode 100644 index 0000000..97a542b --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/AccessTokenService.java @@ -0,0 +1,189 @@ +package de.unistuttgart.iste.meitrex.user_service.service; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.ExternalServiceProviderInfo; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.ExternalServiceProviderConfiguration; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.AccessTokenEntity; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; +import de.unistuttgart.iste.meitrex.user_service.persistence.repository.AccessTokenRepository; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.AccessTokenResponse; +import de.unistuttgart.iste.meitrex.user_service.service.oauth.ExternalOAuthClient; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + + +/** + * Service class responsible for handling OAuth2 access tokens for external service providers. + * This includes checking token availability, retrieving stored tokens, refreshing expired tokens, + * and generating new tokens. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AccessTokenService { + + private final UserService userService; + + private final AccessTokenRepository accessTokenRepository; + + private final ModelMapper modelMapper; + + private final ExternalOAuthClient externalOAuthClient; + + /** + * Checks if a valid access token is available for a given user and provider. + * + * @param currentUser the currently logged-in user. + * @param providerDto the external service provider name. + * @return {@code true} if a valid token exists, otherwise {@code false}. + */ + public boolean isAccessTokenAvailable(LoggedInUser currentUser, ExternalServiceProviderDto providerDto) { + final ExternalServiceProvider provider = modelMapper.map(providerDto, ExternalServiceProvider.class); + + final UserInfo currentUserInfo = userService.findUserInfoInHeader(currentUser); + final Optional accessTokenOptional = accessTokenRepository.findByUserIdAndProvider(currentUserInfo.getId(), provider); + + if (accessTokenOptional.isEmpty()) { + return false; + } + + AccessTokenEntity accessToken = accessTokenOptional.get(); + OffsetDateTime now = OffsetDateTime.now(); + + // Access tokens can be non-expiring + if (accessToken.getAccessTokenExpiresAt() == null || accessToken.getAccessTokenExpiresAt().isAfter(now)) { + return true; + } + + // If access token expired, check if the refresh token is expired and try to refresh it + // There's a bug in GH Api that returns 200 error response because it thinks there is a problem with the refresh token (there is not), so we check here if the refresh works. + // if it does, then refreshAccessToken returns true and a new access token is saved, so getAccessToken can return it (without needing to refresh). + // if it doesn't, then returns false and the user will be prompted to re-authorize GitHub. (using generateAccessToken) + return accessToken.getRefreshTokenExpiresAt().isAfter(now) && refreshAccessToken(accessToken, provider) != null; + } + + /** + * Retrieves the access token for a given user and provider. + * If the token is expired and a valid refresh token exists, the token is refreshed. + * + * @param currentUserId ID of currently logged-in user. + * @param providerDto the external service provider name. + * @return the active access token. + * @throws EntityNotFoundException if no valid token is found. + */ + public AccessToken getAccessToken(UUID currentUserId, ExternalServiceProviderDto providerDto) { + final ExternalServiceProvider provider = modelMapper.map(providerDto, ExternalServiceProvider.class); + + final UserInfo currentUserInfo = userService.findUserInfo(currentUserId).orElseThrow(() -> new EntityNotFoundException("User not found with ID: " + currentUserId)); + final Optional accessTokenOptional = accessTokenRepository.findByUserIdAndProvider(currentUserInfo.getId(), provider); + + if (accessTokenOptional.isEmpty()) { + throw new EntityNotFoundException("Access token not found for user " + currentUserInfo.getId() + " and provider " + provider); + } + + AccessTokenEntity accessToken = accessTokenOptional.get(); + OffsetDateTime now = OffsetDateTime.now(); + + + if (accessToken.getAccessTokenExpiresAt() == null || accessToken.getAccessTokenExpiresAt().isAfter(now)) { + return new AccessToken(accessToken.getAccessToken(), accessToken.getExternalUserId()); + } + + // the lines below won't run in current setup, since if we call getAccessToken, we already refreshed the access token if needed in isAccessTokenAvailable. + // but if Github resolve their problem, the normal workflow should be used instead, i.e. in isAccessTokenAvailable, we only check if the access token is expired without refreshing. + // The refresh will be done below then (check the isAccessTokenAvailable method for more details) + if (!accessToken.getRefreshTokenExpiresAt().isAfter(now)) { + throw new EntityNotFoundException("Access token expired and refresh token expired for user " + currentUserInfo.getId() + " and provider " + provider); + } + + return refreshAccessToken(accessToken, provider); + } + + private AccessToken refreshAccessToken(AccessTokenEntity accessToken, ExternalServiceProvider provider) { + try { + AccessTokenResponse tokenResponse = externalOAuthClient.refreshAccessToken(accessToken.getRefreshToken(), provider); + + if (tokenResponse == null || tokenResponse.getAccessToken() == null) { + log.error("Failed to refresh access token for user {} and provider {}", accessToken.getUserId(), provider); + return null; + } + + accessToken.setAccessToken(tokenResponse.getAccessToken()); + accessToken.setAccessTokenExpiresAt(OffsetDateTime.now().plusSeconds(tokenResponse.getExpiresIn())); + accessToken.setRefreshToken(tokenResponse.getRefreshToken()); + accessToken.setRefreshTokenExpiresAt(OffsetDateTime.now().plusSeconds(tokenResponse.getRefreshTokenExpiresIn())); + + accessTokenRepository.save(accessToken); + return new AccessToken(accessToken.getAccessToken(), accessToken.getExternalUserId()); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + log.error("Failed to refresh access token for user {} and provider {}", accessToken.getUserId(), provider); + } + return null; + } + + /** + * Generates a new access token for the given user and external service provider. + * This method exchanges an authorization code for an access token. + * + * @param currentUser the currently logged-in user. + * @param input the input containing the authorization code and provider. + * @return {@code true} if the access token was successfully generated and stored, otherwise {@code false}. + */ + public boolean generateAccessToken(LoggedInUser currentUser, GenerateAccessTokenInput input) { + final ExternalServiceProvider provider = modelMapper.map(input.getProvider(), ExternalServiceProvider.class); + final UserInfo currentUserInfo = userService.findUserInfoInHeader(currentUser); + + try { + AccessTokenResponse tokenResponse = externalOAuthClient.exchangeCodeForAccessToken(input.getAuthorizationCode(), provider); + + if (tokenResponse != null && tokenResponse.getAccessToken() != null) { + String externalUserId = externalOAuthClient.fetchExternalUserId(tokenResponse.getAccessToken(), provider); + + AccessTokenEntity accessTokenEntity = AccessTokenEntity.builder() + .userId(currentUserInfo.getId()) + .provider(provider) + .externalUserId(externalUserId) + .accessToken(tokenResponse.getAccessToken()) + .accessTokenExpiresAt(OffsetDateTime.now().plusSeconds(tokenResponse.getExpiresIn())) + .refreshToken(tokenResponse.getRefreshToken()) + .refreshTokenExpiresAt(OffsetDateTime.now().plusSeconds(tokenResponse.getRefreshTokenExpiresIn())) + .build(); + + accessTokenRepository.save(accessTokenEntity); + return tokenResponse.getAccessToken() != null; + } + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + log.error("Failed to generate access token for user {} and provider {}", currentUserInfo.getId(), provider); + } + return false; + } + + public List getExternalUserIds(ExternalServiceProviderDto providerDto, List userIds) { + final ExternalServiceProvider provider = modelMapper.map(providerDto, ExternalServiceProvider.class); + + final List externalUserIds = new ArrayList<>(); + for (UUID userId : userIds) { + final Optional accessTokenOptional = accessTokenRepository.findByUserIdAndProvider(userId, provider); + if (accessTokenOptional.isPresent()) { + AccessTokenEntity accessToken = accessTokenOptional.get(); + externalUserIds.add(new ExternalUserIdWithUser(userId, accessToken.getExternalUserId())); + } + } + return externalUserIds; + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/UserService.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/UserService.java index 8530d4e..ad8a53d 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/UserService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/UserService.java @@ -3,10 +3,13 @@ import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; import de.unistuttgart.iste.meitrex.generated.dto.PublicUserInfo; import de.unistuttgart.iste.meitrex.generated.dto.UserInfo; -import de.unistuttgart.iste.meitrex.user_service.config.KeycloakWrapper; +import de.unistuttgart.iste.meitrex.user_service.config.user.KeycloakWrapper; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.UserEntity; import de.unistuttgart.iste.meitrex.user_service.persistence.mapper.RealmMapper; +import de.unistuttgart.iste.meitrex.user_service.persistence.repository.UserRepository; import graphql.schema.DataFetchingEnvironment; import graphql.schema.SelectedField; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.keycloak.representations.idm.UserRepresentation; @@ -22,6 +25,7 @@ public class UserService { private final KeycloakWrapper keycloak; private final RealmMapper realmMapper; + private final UserRepository userRepository; /** * Find public user infos by ids. @@ -85,6 +89,7 @@ public UserInfo findUserInfoInHeader(LoggedInUser currentUser){ currentUser.getUserName(), currentUser.getFirstName(), currentUser.getLastName(), + currentUser.getNickname(), realmMapper.internalRolesToGraphQlRoles(currentUser.getRealmRoles()) ); } @@ -96,16 +101,28 @@ public UserInfo findUserInfoInHeader(LoggedInUser currentUser){ * @return an optional of the user info or empty if the user could not be retrieved. */ public Optional findUserInfo(UUID id) { + UserEntity userEntity = userRepository.findById(id).orElseGet(() -> new UserEntity(id, "")); return findUser(id) .map(user -> new UserInfo( id, user.getUsername(), user.getFirstName(), user.getLastName(), + userEntity.getNickname(), realmMapper.keycloakRolesToGraphQlRoles(user.getRealmRoles()) )); } + public UserInfo setNickname(UUID userId, String nickname) { + UserInfo userInfo = findUserInfo(userId).orElseThrow(() -> + new EntityNotFoundException("User with the id " + userId +" not found")); + UserEntity userEntity = userRepository.findById(userId).orElseGet(() -> new UserEntity(userId, "")); + userEntity.setNickname(nickname); + userRepository.save(userEntity); + userInfo.setNickname(nickname); + return userInfo; + } + private Optional findUser(UUID id) { try { return Optional.of(keycloak.getRealm().users().get(id.toString()).toRepresentation()); diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/ExternalOAuthClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/ExternalOAuthClient.java new file mode 100644 index 0000000..8abd838 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/ExternalOAuthClient.java @@ -0,0 +1,53 @@ +package de.unistuttgart.iste.meitrex.user_service.service.oauth; + +import de.unistuttgart.iste.meitrex.user_service.config.access_token.AccessTokenResponse; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; + +/** + * Central client for handling OAuth2 operations by delegating to provider-specific strategies. + *

+ * This component abstracts the logic for determining which {@link ExternalOAuthStrategy} to use + * based on the {@link ExternalServiceProvider}, enabling support for multiple providers like GitHub. + *

+ * + *

Each method delegates to the appropriate strategy that supports the specified provider.

+ */ +@Component +@RequiredArgsConstructor +public class ExternalOAuthClient { + + private final List strategies; + + private ExternalOAuthStrategy getStrategy(ExternalServiceProvider providerName) { + return strategies.stream() + .filter(s -> s.supports(providerName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No OAuth strategy found for provider: " + providerName)); + } + + /** + * Delegates authorization code exchange to the provider-specific strategy. + */ + public AccessTokenResponse exchangeCodeForAccessToken(String code, ExternalServiceProvider provider) throws IOException, InterruptedException { + return getStrategy(provider).exchangeCodeForAccessToken(code); + } + + /** + * Delegates access token refresh to the provider-specific strategy. + */ + public AccessTokenResponse refreshAccessToken(String refreshToken, ExternalServiceProvider provider) throws IOException, InterruptedException { + return getStrategy(provider).refreshAccessToken(refreshToken); + } + + /** + * Delegates external user ID fetch to the provider-specific strategy. + */ + public String fetchExternalUserId(String accessToken, ExternalServiceProvider provider) throws IOException, InterruptedException { + return getStrategy(provider).fetchExternalUserId(accessToken); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/ExternalOAuthStrategy.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/ExternalOAuthStrategy.java new file mode 100644 index 0000000..ba48365 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/ExternalOAuthStrategy.java @@ -0,0 +1,59 @@ +package de.unistuttgart.iste.meitrex.user_service.service.oauth; + +import de.unistuttgart.iste.meitrex.user_service.config.access_token.AccessTokenResponse; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; + +import java.io.IOException; + +/** + * Interface representing an OAuth2 strategy for a specific external service provider. + *

+ * Implementations of this interface encapsulate provider-specific behavior + * for handling OAuth2 flows, such as GitHub etc. + *

+ * + *

This strategy interface enables support for multiple providers using a plug-and-play pattern, + * ensuring the application remains scalable and maintainable.

+ * + *

Implemented by classes like {@link de.unistuttgart.iste.meitrex.user_service.service.oauth.GitHubOAuthStrategy}.

+ */ +public interface ExternalOAuthStrategy { + + /** + * Checks if this strategy supports a given external service provider. + * + * @param providerName the provider to check, e.g., GITHUB. + * @return {@code true} if this strategy supports the provider, otherwise {@code false}. + */ + boolean supports(ExternalServiceProvider providerName); + + /** + * Exchanges the authorization code for an access token using the provider's token endpoint. + * + * @param code the authorization code received from the provider after user authorization. + * @return {@link AccessTokenResponse} containing access token and optionally refresh token. + * @throws IOException if a network or JSON parsing error occurs. + * @throws InterruptedException if the thread is interrupted during the request. + */ + AccessTokenResponse exchangeCodeForAccessToken(String code) throws IOException, InterruptedException; + + /** + * Refreshes an access token using the given refresh token. + * + * @param refreshToken the refresh token previously received from the provider. + * @return {@link AccessTokenResponse} with new access and refresh token details. + * @throws IOException if a network or JSON parsing error occurs. + * @throws InterruptedException if the thread is interrupted during the request. + */ + AccessTokenResponse refreshAccessToken(String refreshToken) throws IOException, InterruptedException; + + /** + * Retrieves the external user ID (e.g., GitHub username) from the provider using the access token. + * + * @param accessToken the access token to authenticate the request. + * @return the external user ID as a {@link String}, or {@code null} if the request fails. + * @throws IOException if a network or JSON parsing error occurs. + * @throws InterruptedException if the thread is interrupted during the request. + */ + String fetchExternalUserId(String accessToken) throws IOException, InterruptedException; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/GitHubOAuthStrategy.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/GitHubOAuthStrategy.java new file mode 100644 index 0000000..828bded --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/service/oauth/GitHubOAuthStrategy.java @@ -0,0 +1,164 @@ +package de.unistuttgart.iste.meitrex.user_service.service.oauth; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.AccessTokenResponse; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.ExternalServiceProviderConfiguration; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.ExternalServiceProviderInfo; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** + * OAuth2 strategy implementation for GitHub. + *

+ * This class encapsulates all provider-specific logic required to interact with GitHub’s OAuth2 API, + * including: + *

    + *
  • Exchanging authorization codes for access tokens
  • + *
  • Refreshing expired access tokens (if enabled)
  • + *
  • Fetching the authenticated user's GitHub username
  • + *
+ *

+ * Configuration details (such as client credentials and endpoint URLs) are retrieved from + * {@link ExternalServiceProviderConfiguration}, and must be defined under the + * {@code thirdparty.providers.github} prefix in {@code application.properties}. + *

+ * + *

Example usage:

+ * This strategy is selected by {@link ExternalOAuthClient} based on the + * {@link ExternalServiceProvider#GITHUB} enum value. + * + *

Example configuration:

+ *
+ * thirdparty.providers.github.clientId=...
+ * thirdparty.providers.github.clientSecret=...
+ * thirdparty.providers.github.tokenRequestUrl=https://github.com/login/oauth/access_token
+ * thirdparty.providers.github.externalUserIdUrl=https://api.github.com/user
+ * 
+ * + * @see ExternalOAuthStrategy + * @see ExternalOAuthClient + * @see ExternalServiceProviderConfiguration + * @see ExternalServiceProviderInfo + */ + +@Component +@RequiredArgsConstructor +@Slf4j +public class GitHubOAuthStrategy implements ExternalOAuthStrategy { + + private final HttpClient client; + + private final ExternalServiceProviderConfiguration providersConfig; + + private ExternalServiceProviderInfo githubInfo() { + return providersConfig.getProviders().get(ExternalServiceProvider.GITHUB); + } + + + @Override + public boolean supports(ExternalServiceProvider providerName) { + return providerName.equals(ExternalServiceProvider.GITHUB); + } + + @Override + public AccessTokenResponse exchangeCodeForAccessToken(String code) throws IOException, InterruptedException { + String requestBody = "client_id=" + githubInfo().getClientId() + + "&client_secret=" + githubInfo().getClientSecret() + + "&code=" + code; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(githubInfo().getTokenRequestUrl())) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + log.error("Failed to exchange code. HTTP {} Body: {}", response.statusCode(), response.body()); + return null; + } + + try { + return parseTokenResponse(response.body()); + } catch (Exception ex) { + log.error("Non-JSON or malformed token response body: {}", response.body()); + return null; + } + } + + @Override + public AccessTokenResponse refreshAccessToken(String refreshToken) throws IOException, InterruptedException { + String requestBody = "client_id=" + githubInfo().getClientId() + + "&client_secret=" + githubInfo().getClientSecret() + + "&grant_type=refresh_token" + + "&refresh_token=" + refreshToken; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(githubInfo().getTokenRequestUrl())) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + log.error("Failed to refresh token. HTTP {} Body: {}", response.statusCode(), response.body()); + return null; + } + + try { + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + if (json.has("error")) { + log.error("GitHub refresh error: {}", json); + return null; + } + return parseTokenResponse(response.body()); + } catch (Exception ex) { + log.error("Non-JSON or malformed refresh response body: {}", response.body()); + return null; + } + } + + + public String fetchExternalUserId(String accessToken) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(githubInfo().getExternalUserIdUrl())) + .header("Accept", "application/vnd.github+json") + .header("Authorization", "Bearer " + accessToken) + .header("X-GitHub-Api-Version", "2022-11-28") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + JsonObject user = JsonParser.parseString(response.body()).getAsJsonObject(); + return user.get("login").getAsString(); + } + + return null; + } + + private AccessTokenResponse parseTokenResponse(String responseBody) { + JsonObject jsonResponse = JsonParser.parseString(responseBody).getAsJsonObject(); + + // If access token is non-expiring, there are no expires_in, refresh_token, or refresh_token_expires_in fields in the response body + return AccessTokenResponse.builder() + .accessToken(jsonResponse.get("access_token").getAsString()) + .expiresIn(jsonResponse.has("expires_in") ? jsonResponse.get("expires_in").getAsInt() : null) + .refreshToken(jsonResponse.has("refresh_token") ? jsonResponse.get("refresh_token").getAsString() : null) + .refreshTokenExpiresIn(jsonResponse.has("refresh_token_expires_in") ? jsonResponse.get("refresh_token_expires_in").getAsInt() : null) + .build(); + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 5baede8..da6e398 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -6,12 +6,8 @@ spring.datasource.password=root spring.sql.init.mode=always spring.jpa.show-sql=true spring.sql.init.continue-on-error=true -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=create -#Keycloak -keycloak.url=http://localhost:9009/ -keycloak.username=admin -keycloak.password=admin -keycloak.clientId=admin-cli -keycloak.masterRealm=master -keycloak.realm=GITS \ No newline at end of file +# External service providers +thirdparty.providers.github.clientId=Iv23liynxdcJafLw0ptQ +thirdparty.providers.github.clientSecret=PUT_YOUR_CLIENT_SECRET_FOR_DEVELOPMENT_HERE \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 16d700b..b80dd03 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -1,10 +1,13 @@ # Database spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -#TODO set port for service database -spring.datasource.url=jdbc:postgresql://localhost:1032/${database_name} +spring.datasource.url=jdbc:postgresql://localhost:5032/${database_name} spring.datasource.username=${db_username} spring.datasource.password=${db_password} spring.sql.init.mode=always -spring.jpa.show-sql=true +spring.jpa.show-sql=false spring.sql.init.continue-on-error=true -spring.jpa.hibernate.ddl-auto=update \ No newline at end of file +spring.jpa.hibernate.ddl-auto=update + +# External service providers +thirdparty.providers.github.clientId=${NEXT_PUBLIC_GITHUB_CLIENT_ID:Iv23liynxdcJafLw0ptQ} +thirdparty.providers.github.clientSecret=${GITHUB_CLIENT_SECRET:PUT_YOUR_CLIENT_SECRET_FOR_DEVELOPMENT_HERE} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 66852ab..a144a4f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,9 +17,16 @@ server.port=5001 dapr.appId=user_service dapr.port=5000 -keycloak.url=http://keycloak:8080/ +keycloak.url=${KEYCLOAK_URL:http://localhost:9009/} keycloak.username=admin -keycloak.password=admin +keycloak.password=${KEYCLOAK_PASSWORD:admin} keycloak.clientId=admin-cli keycloak.masterRealm=master -keycloak.realm=GITS \ No newline at end of file +keycloak.realm=GITS + +spring.graphql.schema.locations=classpath*:graphql/common/**/,classpath:graphql/** + +# URL to exchange the code for a token and refresh token +thirdparty.providers.github.tokenRequestUrl=https://github.com/login/oauth/access_token +# URL to get the user information from GitHub based on the token +thirdparty.providers.github.externalUserIdUrl=https://api.github.com/user diff --git a/src/main/resources/graphql/common/directives.graphqls b/src/main/resources/graphql/common/directives.graphqls deleted file mode 100644 index 5b85062..0000000 --- a/src/main/resources/graphql/common/directives.graphqls +++ /dev/null @@ -1,23 +0,0 @@ -# see also https://github.com/graphql-java/graphql-java-extended-validation/blob/master/README.md - -directive @DecimalMax(value : String!, inclusive : Boolean! = true, message : String = "graphql.validation.DecimalMax.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @DecimalMin(value : String!, inclusive : Boolean! = true, message : String = "graphql.validation.DecimalMin.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @Max(value : Int! = 2147483647, message : String = "graphql.validation.Max.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @Min(value : Int! = 0, message : String = "graphql.validation.Min.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @Negative(message : String = "graphql.validation.Negative.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @NegativeOrZero(message : String = "graphql.validation.NegativeOrZero.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @NotBlank(message : String = "graphql.validation.NotBlank.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @NotEmpty(message : String = "graphql.validation.NotEmpty.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @ContainerNotEmpty(message : String = "graphql.validation.ContainerNotEmpty.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @Pattern(regexp : String! =".*", message : String = "graphql.validation.Pattern.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @Positive(message : String = "graphql.validation.Positive.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @PositiveOrZero(message : String = "graphql.validation.PositiveOrZero.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @Range(min : Int = 0, max : Int = 2147483647, message : String = "graphql.validation.Range.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @Size(min : Int = 0, max : Int = 2147483647, message : String = "graphql.validation.Size.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION -directive @ContainerSize(min : Int = 0, max : Int = 2147483647, message : String = "graphql.validation.ContainerSize.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION - -""" -The @OnDemand directive is used to mark fields that are only internally resolved when requested. -Implementation Note: This will cause the code generator to omit the field from the generated DTOs. -""" -directive @OnDemand on FIELD_DEFINITION \ No newline at end of file diff --git a/src/main/resources/graphql/common/scalars.graphqls b/src/main/resources/graphql/common/scalars.graphqls deleted file mode 100644 index d5da038..0000000 --- a/src/main/resources/graphql/common/scalars.graphqls +++ /dev/null @@ -1,12 +0,0 @@ -# will be mapped to OffsetDateTime in Java -scalar DateTime @specifiedBy(url: "https://scalars.graphql.org/andimarek/date-time.html") -# will be mapped to LocalDate in Java -scalar Date @specifiedBy(url:"https://tools.ietf.org/html/rfc3339") -# will be mapped to OffsetTime in Java -scalar Time @specifiedBy(url:"https://tools.ietf.org/html/rfc3339") -# will be mapped to LocalTime in Java -scalar LocalTime -# will be mapped to UUID in Java -scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122") -# will be mapped to URL in Java -scalar Url @specifiedBy(url:"https://www.w3.org/Addressing/URL/url-spec.txt") \ No newline at end of file diff --git a/src/main/resources/graphql/common/sortFilterPagination.graphqls b/src/main/resources/graphql/common/sortFilterPagination.graphqls deleted file mode 100644 index 67e1d26..0000000 --- a/src/main/resources/graphql/common/sortFilterPagination.graphqls +++ /dev/null @@ -1,104 +0,0 @@ -""" -Specifies the sort direction, either ascending or descending. -""" -enum SortDirection { - ASC - DESC -} - -""" -Specifies the page size and page number for paginated results. -""" -input Pagination { - """ - The page number, starting at 0. - If not specified, the default value is 0. - For values greater than 0, the page size must be specified. - If this value is larger than the number of pages, an empty page is returned. - """ - page: Int! = 0 @PositiveOrZero - """ - The number of elements per page. - """ - size: Int! @Positive -} - -""" -Return type for information about paginated results. -""" -type PaginationInfo { - """ - The current page number. - """ - page: Int! - """ - The number of elements per page. - """ - size: Int! - """ - The total number of elements across all pages. - """ - totalElements: Int! - """ - The total number of pages. - """ - totalPages: Int! - """ - Whether there is a next page. - """ - hasNext: Boolean! -} - -""" -Filter for string values. -If multiple filters are specified, they are combined with AND. -""" -input StringFilter { - """ - A string value to match exactly. - """ - equals: String - """ - A string value that must be contained in the field that is being filtered. - """ - contains: String - """ - If true, the filter is case-insensitive. - """ - ignoreCase: Boolean! = false -} - -""" -Filter for integer values. -If multiple filters are specified, they are combined with AND. -""" -input IntFilter { - """ - An integer value to match exactly. - """ - equals: Int - """ - If specified, filters for values greater than to the specified value. - """ - greaterThan: Int - """ - If specified, filters for values less than to the specified value. - """ - lessThan: Int -} - -""" -Filter for date values. -If multiple filters are specified, they are combined with AND. -""" -input DateTimeFilter { - """ - If specified, filters for dates after the specified value. - """ - after: DateTime - """ - If specified, filters for dates before the specified value. - """ - before: DateTime -} - diff --git a/src/main/resources/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls new file mode 100644 index 0000000..570300f --- /dev/null +++ b/src/main/resources/graphql/service/mutation.graphqls @@ -0,0 +1,23 @@ +type Mutation { + """ + Generates an access token for the given provider using an authorization code obtained from the OAuth flow. + This should be called **only after** the user completes authorization and the frontend retrieves the auth code. + After the access token is generated, the user is redirected to the redirect URI. + """ + generateAccessToken(input: GenerateAccessTokenInput!): Boolean! + + updateSettings(userId: UUID!, input: SettingsInput!): Settings! + + defaultSettings(userId: UUID!): Settings! + + setNickname(nickname: String!): UserInfo! +} + +input GenerateAccessTokenInput { + provider: ExternalServiceProviderDto! + authorizationCode: String! +} + +enum ExternalServiceProviderDto { + GITHUB +} \ No newline at end of file diff --git a/src/main/resources/graphql/service/query.graphqls b/src/main/resources/graphql/service/query.graphqls index 50dd508..ca74b9c 100644 --- a/src/main/resources/graphql/service/query.graphqls +++ b/src/main/resources/graphql/service/query.graphqls @@ -14,6 +14,37 @@ type Query { If a user does not exist, null is returned for that user. """ findUserInfos(ids: [UUID!]!): [UserInfo]! + """ + Checks whether an access token for a given third-party provider exists and is still valid for the currently authenticated user. + Returns `true` if: + - The access token exists and is not expired, OR + - The refresh token exists and is not expired. + """ + isAccessTokenAvailable(provider: ExternalServiceProviderDto!): Boolean! + + """ + Retrieves the access token of the specified user for a specified third-party provider. + + - If the access token is expired but a valid refresh token is available, the system will attempt to generate a new access token. + - Throws an exception if neither a valid access token nor a valid refresh token is available. + + ⚠️ This query is **only accessible internally**. ⚠️ + """ + _internal_noauth_getAccessToken(userId: UUID!, provider: ExternalServiceProviderDto!): AccessToken! + + """ + Retrieves the external user IDs for a list of users with the specified IDs. + If a user does not exist, null is returned for that user. + If the user does not have an external user ID for the specified provider, null is returned for that user. + ⚠️ This query is **only accessible internally** and the caller must be at least a tutor of a course. The permission must be validated by the caller method. ⚠️ + """ + _internal_noauth_getExternalUserIds(userIds: [UUID!]!, provider: ExternalServiceProviderDto): [ExternalUserIdWithUser!]! + + """ + Retrieves the userInfo for the given userId + ⚠️ This query is **only accessible internally** and the caller must be at least a tutor of a course. The permission must be validated by the caller method. ⚠️ + """ + _internal_noauth_userNicknameByUserId(userId: UUID!): UserInfo! findUserSettings(userId: UUID): Settings! diff --git a/src/main/resources/graphql/service/settings.graphqls b/src/main/resources/graphql/service/settings.graphqls index 928055e..55b6c36 100644 --- a/src/main/resources/graphql/service/settings.graphqls +++ b/src/main/resources/graphql/service/settings.graphqls @@ -22,9 +22,4 @@ input SettingsInput { type Settings { gamification: Gamification, notification: Notification, -} - -type Mutation { - updateSettings(userId: UUID!, input: SettingsInput!): Settings! - defaultSettings(userId: UUID!): Settings! } \ No newline at end of file diff --git a/src/main/resources/graphql/service/userInfo.graphqls b/src/main/resources/graphql/service/userInfo.graphqls index b15dbc6..4cf1f5e 100644 --- a/src/main/resources/graphql/service/userInfo.graphqls +++ b/src/main/resources/graphql/service/userInfo.graphqls @@ -12,5 +12,16 @@ type UserInfo { userName: String! firstName: String! lastName: String! + nickname: String! realmRoles: [GlobalUserRole!]! +} + +type AccessToken { + accessToken: String! + externalUserId: String +} + +type ExternalUserIdWithUser { + userId: UUID! + externalUserId: String! } \ No newline at end of file diff --git a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java deleted file mode 100644 index ec84b02..0000000 --- a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java +++ /dev/null @@ -1,336 +0,0 @@ -package de.unistuttgart.iste.meitrex.client; - -import de.unistuttgart.iste.meitrex.generated.dto.Gamification; -import de.unistuttgart.iste.meitrex.generated.dto.Settings; -import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient; -import de.unistuttgart.iste.meitrex.user_service.exception.SettingServiceConnectionException; -import org.junit.jupiter.api.Test; -import org.springframework.graphql.ResponseError; - -import java.lang.reflect.Proxy; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import org.springframework.graphql.client.GraphQlClient; -import org.springframework.graphql.client.HttpGraphQlClient; -import org.springframework.http.HttpStatus; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.ExchangeFunction; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Tests for SettingsServiceClient - */ -class SettingsServiceClientTest { - - @Test - void queryUserSettings_success() throws Exception { - String json = """ - { - "data": { - "findUserSettings": { - "gamification": "GAMIFICATION_ENABLED", - "notification": { "gamification": true, "lecture": false } - } - } - } - """; - - SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json)); - - UUID userId = UUID.randomUUID(); - Settings s = client.queryUserSettings(userId); - - assertNotNull(s); - assertEquals(Gamification.GAMIFICATION_ENABLED, s.getGamification()); - assertNotNull(s.getNotification()); - assertEquals(Boolean.TRUE, s.getNotification().getGamification()); - assertEquals(Boolean.FALSE, s.getNotification().getLecture()); - } - - @Test - void queryUsersSettings_success() throws Exception { - String json = """ - { - "data": { - "findUsersSettings": [ - { - "gamification": "GAMIFICATION_ENABLED", - "notification": { "gamification": true, "lecture": false } - }, - { - "gamification": "ALL_GAMIFICATION_DISABLED", - "notification": { "gamification": false, "lecture": true } - } - ] - } - } - """; - - SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json)); - - var u1 = UUID.randomUUID(); - var u2 = UUID.randomUUID(); - List list = client.queryUsersSettings(List.of(u1, u2)); - - assertNotNull(list); - assertEquals(2, list.size()); - - assertEquals(Gamification.GAMIFICATION_ENABLED, list.get(0).getGamification()); - assertEquals(Boolean.TRUE, list.get(0).getNotification().getGamification()); - assertEquals(Boolean.FALSE, list.get(0).getNotification().getLecture()); - - assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, list.get(1).getGamification()); - assertEquals(Boolean.FALSE, list.get(1).getNotification().getGamification()); - assertEquals(Boolean.TRUE, list.get(1).getNotification().getLecture()); - } - - @Test - void queryUserSettings_graphQlErrors_throws() { - String json = """ - { - "data": { "findUserSettings": null }, - "errors": [ { "message": "boom", "path": ["findUserSettings"] } ] - } - """; - - ExchangeFunction fx = req -> { - ClientResponse resp = ClientResponse.create(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(json) - .build(); - return Mono.just(resp); - }; - WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); - GraphQlClient gql = HttpGraphQlClient.builder(webClient).build(); - - SettingsServiceClient client = new SettingsServiceClient(gql); - - assertThrows(SettingServiceConnectionException.class, - () -> client.queryUserSettings(UUID.randomUUID())); - } - - @Test - void queryUserSettings_fieldMissing_throws() { - String json = "{ \"data\": { } }"; - SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); - - assertThrows(SettingServiceConnectionException.class, - () -> client.queryUserSettings(UUID.randomUUID())); - } - - - @Test - void queryUserSettings_retriesThenSuccess() throws Exception { - String err = "{ \"data\": { \"findUserSettings\": null }, \"errors\": [ { \"message\": \"e\" } ] }"; - String ok = """ - { "data": { "findUserSettings": { - "gamification": "ALL_GAMIFICATION_DISABLED", - "notification": { "gamification": false, "lecture": true } - } } } - """; - SettingsServiceClient client = new SettingsServiceClient(gqlWithSequence(err, err, ok)); - - Settings s = client.queryUserSettings(UUID.randomUUID()); - - assertNotNull(s); - assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, s.getGamification()); - assertEquals(Boolean.FALSE, s.getNotification().getGamification()); - assertEquals(Boolean.TRUE, s.getNotification().getLecture()); - } - - @Test - void queryUsersSettings_emptyInput_noHttpCall() { - AtomicInteger calls = new AtomicInteger(0); - SettingsServiceClient client = new SettingsServiceClient(gqlCounting(calls)); - - try { - List list = client.queryUsersSettings(List.of()); - assertNotNull(list); - assertTrue(list.isEmpty()); - assertEquals(0, calls.get(), "No HTTP call should be made for empty input"); - } catch (SettingServiceConnectionException e) { - fail("Unexpected exception: " + e.getMessage()); - } - } - - - - @Test - void queryUsersSettings_mappingError_throws() { - String json = "{ \"data\": { \"findUsersSettings\": { \"not\": \"a list\" } } }"; - SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); - - assertThrows(SettingServiceConnectionException.class, - () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); - } - - - /** - * Creates a GraphQlClient backed by a WebClient that always returns the given JSON. - * No server, no extra deps. - */ - private static GraphQlClient mockGraphQl(String jsonResponse) { - ExchangeFunction fx = request -> { - ClientResponse resp = ClientResponse - .create(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(jsonResponse) - .build(); - return Mono.just(resp); - }; - WebClient webClient = WebClient.builder() - .exchangeFunction(fx) - .build(); - return HttpGraphQlClient.builder(webClient).build(); - } - - private static GraphQlClient gqlWithJson(String json) { - ExchangeFunction fx = req -> Mono.just( - ClientResponse.create(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(json) - .build() - ); - WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); - return HttpGraphQlClient.builder(webClient).build(); - } - - private static GraphQlClient gqlWithSequence(String... jsonResponses) { - AtomicInteger idx = new AtomicInteger(0); - ExchangeFunction fx = req -> { - String body = jsonResponses[Math.min(idx.getAndIncrement(), jsonResponses.length - 1)]; - return Mono.just(ClientResponse.create(HttpStatus.OK) - .header("Content-Type", "application/json") - .body(body) - .build()); - }; - WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); - return HttpGraphQlClient.builder(webClient).build(); - } - - private static GraphQlClient gqlCounting(AtomicInteger counter) { - ExchangeFunction fx = req -> { - counter.incrementAndGet(); - return Mono.just(ClientResponse.create(HttpStatus.OK) - .header("Content-Type", "application/json") - .body("{ \"data\": { \"findUsersSettings\": [] } }") - .build()); - }; - WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); - return HttpGraphQlClient.builder(webClient).build(); - } - - private static GraphQlClient gqlErrorWrapped(String message) { - ExchangeFunction fx = req -> { - return Mono.error(new RuntimeException( - new SettingServiceConnectionException(message) - )); - }; - WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); - return HttpGraphQlClient.builder(webClient).build(); - } - - @Test - void queryUserSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { - SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); - assertThrows(SettingServiceConnectionException.class, - () -> client.queryUserSettings(UUID.randomUUID())); - } - - @Test - void queryUsersSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { - SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); - assertThrows(SettingServiceConnectionException.class, - () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); - } - - @Test - void queryUsersSettings_nullList_normalizedToEmpty() throws SettingServiceConnectionException { - String json = """ - { "data": { "findUsersSettings": null } } - """; - SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); - - List list = client.queryUsersSettings(List.of(UUID.randomUUID())); - org.junit.jupiter.api.Assertions.assertNotNull(list); - org.junit.jupiter.api.Assertions.assertTrue(list.isEmpty()); - } - - private static ResponseError responseError(String message) { - return (ResponseError) Proxy.newProxyInstance( - ResponseError.class.getClassLoader(), - new Class[]{ResponseError.class}, - (proxy, method, args) -> { - String name = method.getName(); - if ("getMessage".equals(name)) { - return message; - } - if ("toSpecification".equals(name)) { - return Map.of("message", message); - } - return null; - } - ); - } - - - @Test - void constructor_withMessage_keepsMessage() { - var ex = new SettingServiceConnectionException("plain"); - assertEquals("plain", ex.getMessage()); - } - - @Test - void constructor_withErrors_formatsMessage() { - var base = "Invalid response"; - var ex = new SettingServiceConnectionException(base, java.util.List.of( - responseError("boom1"), - responseError("boom2") - )); - - String msg = ex.getMessage(); - assertTrue(msg.contains(base)); - assertTrue(msg.contains("boom1")); - assertTrue(msg.contains("boom2")); - } - - @Test - void constructor_withNullErrors_fallsBackToBaseMessage() { - var ex = new SettingServiceConnectionException("only-base", null); - assertEquals("only-base", ex.getMessage()); - } - - @Test - void unwrapAndThrow_directWrapped_throwsInnerUserEx() { - var inner = new SettingServiceConnectionException("inner"); - var outer = new RuntimeException(inner); - - var thrown = assertThrows(SettingServiceConnectionException.class, - () -> SettingServiceConnectionException.unwrapAndThrow(outer)); - assertEquals("inner", thrown.getMessage()); - } - - @Test - void unwrapAndThrow_deeplyNested_throwsInnerUserEx() { - var inner = new SettingServiceConnectionException("deep"); - var outer = new RuntimeException(new IllegalStateException(inner)); - - var thrown = assertThrows(SettingServiceConnectionException.class, - () -> SettingServiceConnectionException.unwrapAndThrow(outer)); - assertEquals("deep", thrown.getMessage()); - } - - @Test - void unwrapAndThrow_noUserEx_rethrowsOriginalRuntime() { - var outer = new RuntimeException(new IllegalArgumentException("no-user-ex")); - var rethrown = assertThrows(RuntimeException.class, - () -> SettingServiceConnectionException.unwrapAndThrow(outer)); - assertSame(outer, rethrown); - } -} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/MutationSetNicknameTest.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/MutationSetNicknameTest.java new file mode 100644 index 0000000..fa35dc0 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/MutationSetNicknameTest.java @@ -0,0 +1,60 @@ +package de.unistuttgart.iste.meitrex.user_service.api; + +import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest; +import de.unistuttgart.iste.meitrex.common.testutil.InjectCurrentUserHeader; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.UserInfo; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockAccessTokenServiceConfiguration; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockKeycloakConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.graphql.test.tester.WebGraphQlTester; +import org.springframework.test.context.ContextConfiguration; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; + +import static de.unistuttgart.iste.meitrex.common.testutil.HeaderUtils.addCurrentUserHeader; +import static de.unistuttgart.iste.meitrex.common.testutil.TestUsers.userWithMembershipInCourseWithId; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +@GraphQlApiTest +@ContextConfiguration(classes = { + MockKeycloakConfiguration.class, + MockAccessTokenServiceConfiguration.class +}) +public class MutationSetNicknameTest { + + UUID courseId = UUID.randomUUID(); + + private final LoggedInUser user = LoggedInUser.builder() + .id(MockKeycloakConfiguration.firstUserId) + .userName("firstuser") + .firstName("First") + .lastName("User") + .courseMemberships(Collections.emptyList()) + .realmRoles(Set.of()) + .build(); + + @Test + void setNickname(WebGraphQlTester tester) { + final String query = """ + mutation { + setNickname(nickname: "TestNickname"){ + id + nickname + firstName + } + } + """; + tester = addCurrentUserHeader(tester, user); + + UserInfo userInfo = tester.document(query) + .execute() + .path("setNickname") + .entity(UserInfo.class).get(); + assertThat(userInfo.getNickname(), is("TestNickname")); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/QueryAccessTokenTest.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/QueryAccessTokenTest.java new file mode 100644 index 0000000..6320b52 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/QueryAccessTokenTest.java @@ -0,0 +1,172 @@ +package de.unistuttgart.iste.meitrex.user_service.api; + +import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.AccessToken; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalServiceProviderDto; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalUserIdWithUser; +import de.unistuttgart.iste.meitrex.generated.dto.GenerateAccessTokenInput; +import de.unistuttgart.iste.meitrex.user_service.service.AccessTokenService; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockKeycloakConfiguration; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockAccessTokenServiceConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.graphql.test.tester.WebGraphQlTester; +import org.springframework.test.context.ContextConfiguration; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.List; + +import static de.unistuttgart.iste.meitrex.common.testutil.HeaderUtils.addCurrentUserHeader; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@GraphQlApiTest +@ContextConfiguration(classes = { + MockKeycloakConfiguration.class, + MockAccessTokenServiceConfiguration.class +}) +class QueryAccessTokenTest { + + @Autowired + private AccessTokenService accessTokenService; + + private final LoggedInUser user = LoggedInUser.builder() + .id(MockKeycloakConfiguration.firstUserId) + .userName("firstuser") + .firstName("First") + .lastName("User") + .courseMemberships(Collections.emptyList()) + .realmRoles(Set.of()) + .build(); + + @Test + void testIsAccessTokenAvailable(WebGraphQlTester tester) { + when(accessTokenService.isAccessTokenAvailable(any(), eq(ExternalServiceProviderDto.GITHUB))) + .thenReturn(true); + + String query = """ + query($provider: ExternalServiceProviderDto!) { + isAccessTokenAvailable(provider: $provider) + } + """; + + tester = addCurrentUserHeader(tester, user); + + tester.document(query) + .variable("provider", ExternalServiceProviderDto.GITHUB) + .execute() + .path("isAccessTokenAvailable").entity(Boolean.class).isEqualTo(true); + } + + @Test + void testInternalGetAccessToken(WebGraphQlTester tester) { + String token = "mocked-access-token"; + when(accessTokenService.getAccessToken(any(), eq(ExternalServiceProviderDto.GITHUB))) + .thenReturn(new AccessToken(token, null)); + + String query = """ + query($userId: UUID!, $provider: ExternalServiceProviderDto!) { + _internal_noauth_getAccessToken(userId: $userId, provider: $provider) { + accessToken + } + } + """; + + tester.document(query) + .variable("userId", user.getId()) + .variable("provider", ExternalServiceProviderDto.GITHUB) + .execute() + .path("_internal_noauth_getAccessToken.accessToken") + .entity(String.class) + .isEqualTo(token); + } + + @Test + void testGenerateAccessToken(WebGraphQlTester tester) { + when(accessTokenService.generateAccessToken(any(), any())) + .thenReturn(true); + + String mutation = """ + mutation($input: GenerateAccessTokenInput!) { + generateAccessToken(input: $input) + } + """; + + GenerateAccessTokenInput input = new GenerateAccessTokenInput(); + input.setProvider(ExternalServiceProviderDto.GITHUB); + input.setAuthorizationCode("mockCode"); + + tester = addCurrentUserHeader(tester, user); + + tester.document(mutation) + .variable("input", input) + .execute() + .path("generateAccessToken").entity(Boolean.class).isEqualTo(true); + } + + @Test + void testInternalGetExternalUserIds(WebGraphQlTester tester) { + UUID userId1 = UUID.randomUUID(); + UUID userId2 = UUID.randomUUID(); + + var expectedResult = List.of( + new ExternalUserIdWithUser(userId1, "user1"), + new ExternalUserIdWithUser(userId2, "user2") + ); + + when(accessTokenService.getExternalUserIds(ExternalServiceProviderDto.GITHUB, List.of(userId1, userId2))) + .thenReturn(expectedResult); + + String query = """ + query($provider: ExternalServiceProviderDto!, $userIds: [UUID!]!) { + _internal_noauth_getExternalUserIds(provider: $provider, userIds: $userIds) { + userId + externalUserId + } + } + """; + + List result = tester.document(query) + .variable("provider", ExternalServiceProviderDto.GITHUB) + .variable("userIds", List.of(userId1, userId2)) + .execute() + .path("_internal_noauth_getExternalUserIds") + .entityList(ExternalUserIdWithUser.class) + .get(); + + assertEquals(2, result.size()); + assertTrue(result.containsAll(expectedResult)); + } + + @Test + void testInternalGetExternalUserIds_emptyList(WebGraphQlTester tester) { + List userIds = List.of(UUID.randomUUID(), UUID.randomUUID()); + + when(accessTokenService.getExternalUserIds(ExternalServiceProviderDto.GITHUB, userIds)) + .thenReturn(List.of()); + + String query = """ + query($provider: ExternalServiceProviderDto!, $userIds: [UUID!]!) { + _internal_noauth_getExternalUserIds(provider: $provider, userIds: $userIds) { + userId + externalUserId + } + } + """; + + tester.document(query) + .variable("provider", ExternalServiceProviderDto.GITHUB) + .variable("userIds", userIds) + .execute() + .path("_internal_noauth_getExternalUserIds") + .entityList(ExternalUserIdWithUser.class) + .hasSize(0); + } + +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/QueryUserNicknameByUserId.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/QueryUserNicknameByUserId.java new file mode 100644 index 0000000..70a947f --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/api/QueryUserNicknameByUserId.java @@ -0,0 +1,67 @@ +package de.unistuttgart.iste.meitrex.user_service.api; + +import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.UserInfo; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.UserEntity; +import de.unistuttgart.iste.meitrex.user_service.persistence.repository.UserRepository; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockAccessTokenServiceConfiguration; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockKeycloakConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.graphql.test.tester.WebGraphQlTester; +import org.springframework.test.context.ContextConfiguration; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; + +import static de.unistuttgart.iste.meitrex.common.testutil.HeaderUtils.addCurrentUserHeader; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +@GraphQlApiTest +@ContextConfiguration(classes = { + MockKeycloakConfiguration.class, + MockAccessTokenServiceConfiguration.class +}) +public class QueryUserNicknameByUserId { + + UUID courseId = UUID.randomUUID(); + + private final LoggedInUser user = LoggedInUser.builder() + .id(MockKeycloakConfiguration.firstUserId) + .userName("firstuser") + .firstName("First") + .lastName("User") + .courseMemberships(Collections.emptyList()) + .realmRoles(Set.of()) + .build(); + @Autowired + private UserRepository userRepository; + + @Test + void testQueryUserNicknameByUserId(WebGraphQlTester tester) { + UserEntity userEntity = new UserEntity(); + userEntity.setId(MockKeycloakConfiguration.firstUserId); + userEntity.setNickname("TestNickname"); + userRepository.save(userEntity); + final String query = """ + query { + _internal_noauth_userNicknameByUserId(userId: "%s"){ + id + nickname + firstName + } + } + """.formatted(MockKeycloakConfiguration.firstUserId); + + tester = addCurrentUserHeader(tester, user); + + UserInfo userInfo = tester.document(query) + .execute() + .path("_internal_noauth_userNicknameByUserId") + .entity(UserInfo.class).get(); + assertThat(userInfo.getNickname(), is("TestNickname")); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/client/UserServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/client/UserServiceClientTest.java new file mode 100644 index 0000000..a6448bc --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/client/UserServiceClientTest.java @@ -0,0 +1,182 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.unistuttgart.iste.meitrex.common.testutil.GraphQlTesterParameterResolver; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.AccessToken; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalServiceProviderDto; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalUserIdWithUser; +import de.unistuttgart.iste.meitrex.generated.dto.UserInfo; +import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockAccessTokenServiceConfiguration; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockKeycloakConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.graphql.client.GraphQlClient; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.client.MockMvcWebTestClient; +import org.springframework.web.context.WebApplicationContext; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + + +@ExtendWith(GraphQlTesterParameterResolver.class) +@SpringBootTest({"spring.main.allow-bean-definition-overriding=true"}) +@ContextConfiguration(classes = {MockKeycloakConfiguration.class, MockAccessTokenServiceConfiguration.class}) +class UserServiceClientTest { + + private GraphQlClient graphQlClient; + + @Autowired + private WebApplicationContext applicationContext; + + @BeforeEach + void setUp() { + final WebTestClient webTestClient = MockMvcWebTestClient.bindToApplicationContext(applicationContext) + .configureClient().baseUrl("/graphql").build(); + + graphQlClient = GraphQlClient.builder(new WebTestClientTransport(webTestClient)).build(); + } + + @Test + void testQueryUserInfos() throws UserServiceConnectionException { + final UserServiceClient userServiceClient = new UserServiceClient(graphQlClient); + + List userIds = List.of(MockKeycloakConfiguration.firstUserId, MockKeycloakConfiguration.secondUserId, MockKeycloakConfiguration.thirdUserId); + + List queriedUserInfos = userServiceClient.queryUserInfos(userIds); + + assertThat(queriedUserInfos, hasSize(3)); + + UserInfo firstUserInfo = queriedUserInfos.get(0); + UserInfo secondUserInfo = queriedUserInfos.get(1); + UserInfo thirdUserInfo = queriedUserInfos.get(2); + + System.out.println(firstUserInfo); + System.out.println(secondUserInfo); + System.out.println(thirdUserInfo); + + assertThat(firstUserInfo.getUserName(), is("firstuser")); + assertThat(firstUserInfo.getFirstName(), is("First")); + assertThat(firstUserInfo.getLastName(), is("User")); + + assertThat(secondUserInfo.getUserName(), is("seconduser")); + assertThat(secondUserInfo.getFirstName(), is("Second")); + assertThat(secondUserInfo.getLastName(), is("User")); + + assertThat(thirdUserInfo.getUserName(), is("thirduser")); + assertThat(thirdUserInfo.getFirstName(), is("Third")); + assertThat(thirdUserInfo.getLastName(), is("User")); + + } + + @Test + void testQueryUserInfoNoUsers() { + final UserServiceClient userServiceClient = new UserServiceClient(graphQlClient); + + List userIds = List.of(); + + try { + userServiceClient.queryUserInfos(userIds); + assertThat(true, is(false)); + + } catch (UserServiceConnectionException e) { + assertThat(e.getMessage(), is("Error fetching userInfo from UserService: UserInfo List is empty.")); + } + } + + @Test + void testQueryUserInfoBadId() throws UserServiceConnectionException { + final UserServiceClient userServiceClient = new UserServiceClient(graphQlClient); + + List userIds = List.of(MockKeycloakConfiguration.nonExistingUserId); + + List queriedUserInfos = userServiceClient.queryUserInfos(userIds); + + assertThat(queriedUserInfos, hasSize(1)); + assertThat(queriedUserInfos.getFirst(), is(nullValue())); + + } + + @Test + void testQueryUserInfoGoodAndBadId() throws UserServiceConnectionException { + final UserServiceClient userServiceClient = new UserServiceClient(graphQlClient); + + List userIds = List.of(MockKeycloakConfiguration.firstUserId, MockKeycloakConfiguration.nonExistingUserId, MockKeycloakConfiguration.thirdUserId); + + List queriedUserInfos = userServiceClient.queryUserInfos(userIds); + + assertThat(queriedUserInfos, hasSize(3)); + assertThat(queriedUserInfos.get(0).getUserName(), is("firstuser")); + assertThat(queriedUserInfos.get(1), is(nullValue())); + assertThat(queriedUserInfos.get(2).getUserName(), is("thirduser")); + + + } + + @Test + void testQueryAccessToken() throws Exception { + final ObjectMapper objectMapper = new ObjectMapper(); + + final LoggedInUser loggedInUser = LoggedInUser.builder() + .id(MockKeycloakConfiguration.firstUserId) + .userName("firstuser") + .firstName("First") + .lastName("User") + .courseMemberships(List.of()) + .realmRoles(Set.of()) + .build(); + + final String loggedInUserJson = objectMapper.writeValueAsString(loggedInUser); + + final WebTestClient webTestClientWithHeader = MockMvcWebTestClient + .bindToApplicationContext(applicationContext) + .configureClient() + .baseUrl("/graphql") + .defaultHeader("CurrentUser", loggedInUserJson) + .build(); + + final GraphQlClient customClient = GraphQlClient.builder(new WebTestClientTransport(webTestClientWithHeader)).build(); + final UserServiceClient userServiceClient = new UserServiceClient(customClient); + + final AccessToken accessToken = userServiceClient.queryAccessToken(loggedInUser, ExternalServiceProviderDto.GITHUB); + + assertThat(accessToken, is(notNullValue())); + assertThat(accessToken.getAccessToken(), is("mocked-access-token")); + assertThat(accessToken.getExternalUserId(), is(nullValue())); // Adapt if needed + } + + + + @Test + void testQueryExternalUserIds() throws UserServiceConnectionException { + final UserServiceClient userServiceClient = new UserServiceClient(graphQlClient); + + List userIds = List.of( + MockKeycloakConfiguration.firstUserId, + MockKeycloakConfiguration.thirdUserId + ); + + List externalIds = userServiceClient.queryExternalUserIds( + ExternalServiceProviderDto.GITHUB, + userIds + ); + + assertThat(externalIds, hasSize(2)); + + assertThat(externalIds.get(0).getUserId(), is(MockKeycloakConfiguration.firstUserId)); + assertThat(externalIds.get(0).getExternalUserId(), is("github_firstuser")); + + assertThat(externalIds.get(1).getUserId(), is(MockKeycloakConfiguration.thirdUserId)); + assertThat(externalIds.get(1).getExternalUserId(), is("github_thirduser")); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/client/WebTestClientTransport.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/client/WebTestClientTransport.java new file mode 100644 index 0000000..67e3783 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/client/WebTestClientTransport.java @@ -0,0 +1,61 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + + +// COPY PASTED from org.springframework.graphql.test.tester.WebTestClientTransport +// because it is not public +// author: Rossen Stoyanchev + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.GraphQlRequest; +import org.springframework.graphql.GraphQlResponse; +import org.springframework.graphql.client.GraphQlTransport; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.Assert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collections; +import java.util.Map; + +public class WebTestClientTransport implements GraphQlTransport { + + private static final ParameterizedTypeReference> MAP_TYPE = + new ParameterizedTypeReference>() { + }; + + + private final WebTestClient webTestClient; + + + WebTestClientTransport(WebTestClient webTestClient) { + Assert.notNull(webTestClient, "WebTestClient is required"); + this.webTestClient = webTestClient; + } + + + @Override + public Mono execute(GraphQlRequest request) { + + Map responseMap = this.webTestClient.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(request.toMap()) + .exchange() + .expectStatus().isOk() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody(MAP_TYPE) + .returnResult() + .getResponseBody(); + + responseMap = (responseMap != null ? responseMap : Collections.emptyMap()); + GraphQlResponse response = GraphQlTransport.createResponse(responseMap); + return Mono.just(response); + } + + @Override + public Flux executeSubscription(GraphQlRequest request) { + throw new UnsupportedOperationException("Subscriptions not supported over HTTP"); + } + +} \ No newline at end of file diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/persistence/AccessTokenRepositoryTest.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/persistence/AccessTokenRepositoryTest.java new file mode 100644 index 0000000..107fe89 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/persistence/AccessTokenRepositoryTest.java @@ -0,0 +1,44 @@ +package de.unistuttgart.iste.meitrex.user_service.persistence; + +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.AccessTokenEntity; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; +import de.unistuttgart.iste.meitrex.user_service.persistence.repository.AccessTokenRepository; +import de.unistuttgart.iste.meitrex.user_service.test_config.MockKeycloakConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +import java.time.OffsetDateTime; +import java.util.Optional; +import java.util.UUID; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@SpringBootTest({"spring.main.allow-bean-definition-overriding=true"}) +@ContextConfiguration(classes = MockKeycloakConfiguration.class) +class AccessTokenRepositoryTest { + + @Autowired + private AccessTokenRepository accessTokenRepository; + + @Test + + void testFindTokenByUserIdAndProvider() { + UUID userId = UUID.randomUUID(); + AccessTokenEntity accessToken = AccessTokenEntity.builder() + .userId(userId) + .provider(ExternalServiceProvider.GITHUB) + .accessToken("token") + .accessTokenExpiresAt(OffsetDateTime.now()) + .build(); + + accessTokenRepository.save(accessToken); + + Optional retrievedToken = accessTokenRepository.findByUserIdAndProvider(userId, ExternalServiceProvider.GITHUB); + + assertThat(retrievedToken.isPresent(), is(true)); + assertThat(retrievedToken.get().getUserId(), is(userId)); + } +} \ No newline at end of file diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/AccessTokenServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/AccessTokenServiceTest.java new file mode 100644 index 0000000..b178bee --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/AccessTokenServiceTest.java @@ -0,0 +1,381 @@ +package de.unistuttgart.iste.meitrex.user_service.service; + +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.AccessTokenResponse; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.ExternalServiceProviderInfo; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.AccessTokenEntity; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; +import de.unistuttgart.iste.meitrex.user_service.persistence.repository.AccessTokenRepository; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.ExternalServiceProviderConfiguration; +import de.unistuttgart.iste.meitrex.user_service.service.oauth.ExternalOAuthClient; +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.modelmapper.ModelMapper; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.hamcrest.MatcherAssert.*; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +class AccessTokenServiceTest { + + @Mock + private AccessTokenRepository accessTokenRepository; + + @Mock + private UserService userService; + + @Mock + private ExternalServiceProviderConfiguration providersConfig; + + @Mock + private ModelMapper modelMapper; + + @Mock + private ExternalOAuthClient externalOAuthClient; + + @InjectMocks + private AccessTokenService accessTokenService; + + private final LoggedInUser loggedInUser = mock(LoggedInUser.class); + private final ExternalServiceProviderDto providerDto = ExternalServiceProviderDto.GITHUB; + private final AccessTokenEntity validAccessToken = new AccessTokenEntity(); + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + + UUID userId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + when(loggedInUser.getId()).thenReturn(userId); + + UserInfo userInfo = mock(UserInfo.class); + when(userInfo.getId()).thenReturn(userId); + + when(userService.findUserInfo(userId)).thenReturn(Optional.of(userInfo)); + when(userService.findUserInfoInHeader(loggedInUser)).thenReturn(userInfo); + } + + + + @Test + void testIsAccessTokenAvailable_NoTokenFound() { + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.empty()); + + Boolean result = accessTokenService.isAccessTokenAvailable(loggedInUser, providerDto); + + assertThat(result, is(false)); + } + + @Test + void testIsAccessTokenAvailable_ValidNonExpiringToken() { + validAccessToken.setAccessToken("valid_access_token"); + validAccessToken.setAccessTokenExpiresAt(null); + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.of(validAccessToken)); + + Boolean result = accessTokenService.isAccessTokenAvailable(loggedInUser, providerDto); + + assertThat(result, is(true)); + } + + @Test + void testIsAccessTokenAvailable_ValidExpiringToken() { + validAccessToken.setAccessToken("valid_access_token"); + validAccessToken.setAccessTokenExpiresAt(OffsetDateTime.now().plusMinutes(5)); + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.of(validAccessToken)); + + Boolean result = accessTokenService.isAccessTokenAvailable(loggedInUser, providerDto); + + assertThat(result, is(true)); + } + + @Test + void testIsAccessTokenAvailable_ExpiredToken_ValidRefreshToken() throws Exception{ + validAccessToken.setAccessToken("valid_access_token"); + validAccessToken.setAccessTokenExpiresAt(OffsetDateTime.now().minusMinutes(5)); + validAccessToken.setRefreshToken("refresh_token"); + validAccessToken.setRefreshTokenExpiresAt(OffsetDateTime.now().plusMinutes(5)); + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.of(validAccessToken)); + + AccessTokenResponse refreshed = new AccessTokenResponse( + "new_valid_access_token", /* expiresIn */ 3600, "new_refresh", /* refreshExpiresIn */ 7200); + when(externalOAuthClient.refreshAccessToken(eq("refresh_token"), any())) + .thenReturn(refreshed); + + Boolean result = accessTokenService.isAccessTokenAvailable(loggedInUser, providerDto); + + assertThat(result, is(true)); + } + + @Test + void testIsAccessTokenAvailable_ExpiredToken_ExpiredRefreshToken() { + validAccessToken.setAccessToken("valid_access_token"); + validAccessToken.setAccessTokenExpiresAt(OffsetDateTime.now().minusMinutes(5)); + validAccessToken.setRefreshToken("refresh_token"); + validAccessToken.setRefreshTokenExpiresAt(OffsetDateTime.now().minusMinutes(5)); + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.of(validAccessToken)); + + Boolean result = accessTokenService.isAccessTokenAvailable(loggedInUser, providerDto); + + assertThat(result, is(false)); + } + + @Test + void testGetAccessToken_NoTokenFound() { + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.empty()); + + UUID userId = loggedInUser.getId(); + + assertThrows(EntityNotFoundException.class, () -> { + accessTokenService.getAccessToken(userId, providerDto); + }); + } + + @Test + void testGetAccessToken_ValidNonExpiringToken() { + validAccessToken.setAccessToken("valid_access_token"); + validAccessToken.setAccessTokenExpiresAt(null); + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.of(validAccessToken)); + + AccessToken result = accessTokenService.getAccessToken(loggedInUser.getId(), providerDto); + + assertThat(result.getAccessToken(), equalTo("valid_access_token")); + } + + @Test + void testGetAccessToken_ValidExpiringToken() { + validAccessToken.setAccessToken("valid_access_token"); + validAccessToken.setAccessTokenExpiresAt(OffsetDateTime.now().plusMinutes(5)); + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.of(validAccessToken)); + + AccessToken result = accessTokenService.getAccessToken(loggedInUser.getId(), providerDto); + + assertThat(result.getAccessToken(), equalTo("valid_access_token")); + } + + @Test + void testGetAccessToken_ExpiredToken_ExpiredRefreshToken() { + validAccessToken.setAccessToken("valid_access_token"); + validAccessToken.setAccessTokenExpiresAt(OffsetDateTime.now().minusMinutes(5)); + validAccessToken.setRefreshToken("refresh_token"); + validAccessToken.setRefreshTokenExpiresAt(OffsetDateTime.now().minusMinutes(5)); + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.of(validAccessToken)); + + UUID userId = loggedInUser.getId(); + + assertThrows(EntityNotFoundException.class, () -> { + accessTokenService.getAccessToken(userId, providerDto); + }); + } + + @SuppressWarnings("unchecked") + @Test + void testGetAccessToken_ExpiredToken_ValidRefreshToken() throws Exception { + validAccessToken.setAccessToken("expired_access_token"); + validAccessToken.setAccessTokenExpiresAt(OffsetDateTime.now().minusMinutes(5)); + validAccessToken.setRefreshToken("refresh_token"); + validAccessToken.setRefreshTokenExpiresAt(OffsetDateTime.now().plusMinutes(5)); + + when(accessTokenRepository.findByUserIdAndProvider(any(), any())).thenReturn(Optional.of(validAccessToken)); + + when(modelMapper.map(ExternalServiceProviderDto.GITHUB, ExternalServiceProvider.class)) + .thenReturn(ExternalServiceProvider.GITHUB); + + AccessTokenResponse tokenResponse = new AccessTokenResponse("new_valid_access_token", 28800, "refresh_token", 15897600); + when(externalOAuthClient.refreshAccessToken(any(), any())) + .thenReturn(tokenResponse); + + accessTokenService = new AccessTokenService( + userService, + accessTokenRepository, + modelMapper, + externalOAuthClient + ); + + AccessToken result = accessTokenService.getAccessToken(loggedInUser.getId(), providerDto); + + assertThat(result.getAccessToken(), equalTo("new_valid_access_token")); + verify(accessTokenRepository, times(1)).save(any(AccessTokenEntity.class)); + } + + @SuppressWarnings("unchecked") + @Test + void testGenerateAccessToken_Success() throws Exception { + GenerateAccessTokenInput input = new GenerateAccessTokenInput(); + input.setAuthorizationCode("mockCode"); + input.setProvider(ExternalServiceProviderDto.GITHUB); + + when(modelMapper.map(ExternalServiceProviderDto.GITHUB, ExternalServiceProvider.class)) + .thenReturn(ExternalServiceProvider.GITHUB); + + ExternalServiceProviderInfo providerInfo = mock(ExternalServiceProviderInfo.class); + when(providerInfo.getClientId()).thenReturn("clientId"); + when(providerInfo.getClientSecret()).thenReturn("clientSecret"); + when(providerInfo.getTokenRequestUrl()).thenReturn("https://mock.token.url"); + when(providerInfo.getExternalUserIdUrl()).thenReturn("https://api.github.com/user"); + + Map providersMap = + Map.of(ExternalServiceProvider.GITHUB, providerInfo); + when(providersConfig.getProviders()).thenReturn(providersMap); + + AccessTokenResponse tokenResponse = new AccessTokenResponse("generated_access_token", 28800, "refresh_token", 15897600); + when(externalOAuthClient.exchangeCodeForAccessToken(any(), any())) + .thenReturn(tokenResponse); + + when(externalOAuthClient.fetchExternalUserId(any(), any())) + .thenReturn("external_user_id"); + + accessTokenService = new AccessTokenService( + userService, + accessTokenRepository, + modelMapper, + externalOAuthClient + ); + + boolean result = accessTokenService.generateAccessToken(loggedInUser, input); + assertTrue(result); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AccessTokenEntity.class); + verify(accessTokenRepository, times(1)).save(captor.capture()); + + AccessTokenEntity savedEntity = captor.getValue(); + assertThat(savedEntity.getUserId(), is(loggedInUser.getId())); + assertThat(savedEntity.getProvider(), is(ExternalServiceProvider.GITHUB)); + assertThat(savedEntity.getAccessToken(), is("generated_access_token")); + assertThat(savedEntity.getRefreshToken(), is("refresh_token")); + assertThat(savedEntity.getExternalUserId(), is("external_user_id")); + } + + @Test + void testGetExternalUserIds_someUsersHaveTokens() { + // Given + UUID userId1 = UUID.randomUUID(); + UUID userId2 = UUID.randomUUID(); + UUID noTokenUserId = UUID.randomUUID(); + + AccessTokenEntity token1 = new AccessTokenEntity(); + token1.setUserId(userId1); + token1.setExternalUserId("user1_ext"); + + AccessTokenEntity token2 = new AccessTokenEntity(); + token2.setUserId(userId2); + token2.setExternalUserId("user2_ext"); + + when(modelMapper.map(ExternalServiceProviderDto.GITHUB, ExternalServiceProvider.class)) + .thenReturn(ExternalServiceProvider.GITHUB); + + when(accessTokenRepository.findByUserIdAndProvider(userId1, ExternalServiceProvider.GITHUB)) + .thenReturn(Optional.of(token1)); + when(accessTokenRepository.findByUserIdAndProvider(userId2, ExternalServiceProvider.GITHUB)) + .thenReturn(Optional.of(token2)); + when(accessTokenRepository.findByUserIdAndProvider(noTokenUserId, ExternalServiceProvider.GITHUB)) + .thenReturn(Optional.empty()); + + List result = accessTokenService.getExternalUserIds( + ExternalServiceProviderDto.GITHUB, + List.of(userId1, userId2, noTokenUserId) + ); + + assertThat(result, hasSize(2)); + assertThat(result, containsInAnyOrder( + new ExternalUserIdWithUser(userId1, "user1_ext"), + new ExternalUserIdWithUser(userId2, "user2_ext") + )); + } + + @Test + void testGetExternalUserIds_noUsersHaveTokens() { + UUID userId1 = UUID.randomUUID(); + UUID userId2 = UUID.randomUUID(); + + when(modelMapper.map(ExternalServiceProviderDto.GITHUB, ExternalServiceProvider.class)) + .thenReturn(ExternalServiceProvider.GITHUB); + + when(accessTokenRepository.findByUserIdAndProvider(any(), eq(ExternalServiceProvider.GITHUB))) + .thenReturn(Optional.empty()); + + List result = accessTokenService.getExternalUserIds( + ExternalServiceProviderDto.GITHUB, + List.of(userId1, userId2) + ); + + assertThat(result, is(empty())); + } + + @Test + void testGetExternalUserIds_emptyInputList() { + List result = accessTokenService.getExternalUserIds( + ExternalServiceProviderDto.GITHUB, + List.of() + ); + + assertThat(result, is(empty())); + verifyNoInteractions(accessTokenRepository); + } + + @Test + void testGenerateAccessToken_throwsIOException() throws Exception { + GenerateAccessTokenInput input = new GenerateAccessTokenInput(); + input.setAuthorizationCode("mockCode"); + input.setProvider(ExternalServiceProviderDto.GITHUB); + + when(modelMapper.map(input.getProvider(), ExternalServiceProvider.class)) + .thenReturn(ExternalServiceProvider.GITHUB); + + when(externalOAuthClient.exchangeCodeForAccessToken(any(), any())) + .thenThrow(new IOException("Simulated failure")); + + boolean result = accessTokenService.generateAccessToken(loggedInUser, input); + + assertFalse(result); + } + + @Test + void testGenerateAccessToken_throwsInterruptedException() throws Exception { + GenerateAccessTokenInput input = new GenerateAccessTokenInput(); + input.setAuthorizationCode("mockCode"); + input.setProvider(ExternalServiceProviderDto.GITHUB); + + when(modelMapper.map(input.getProvider(), ExternalServiceProvider.class)) + .thenReturn(ExternalServiceProvider.GITHUB); + + when(externalOAuthClient.exchangeCodeForAccessToken(any(), any())) + .thenThrow(new InterruptedException("Simulated interrupt")); + + boolean result = accessTokenService.generateAccessToken(loggedInUser, input); + + assertFalse(result); + } + + @Test + void testGetAccessToken_refreshToken_throwsIOException() throws Exception { + validAccessToken.setAccessToken("expired_access_token"); + validAccessToken.setAccessTokenExpiresAt(OffsetDateTime.now().minusMinutes(5)); + validAccessToken.setRefreshToken("refresh_token"); + validAccessToken.setRefreshTokenExpiresAt(OffsetDateTime.now().plusMinutes(5)); + + when(modelMapper.map(providerDto, ExternalServiceProvider.class)) + .thenReturn(ExternalServiceProvider.GITHUB); + + when(accessTokenRepository.findByUserIdAndProvider(any(), any())) + .thenReturn(Optional.of(validAccessToken)); + + when(externalOAuthClient.refreshAccessToken(any(), any())) + .thenThrow(new IOException("Simulated error")); + + AccessToken token = accessTokenService.getAccessToken(loggedInUser.getId(), providerDto); + + assertNull(token); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/ExternalOAuthClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/ExternalOAuthClientTest.java new file mode 100644 index 0000000..51d0d36 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/ExternalOAuthClientTest.java @@ -0,0 +1,71 @@ +package de.unistuttgart.iste.meitrex.user_service.service; + +import de.unistuttgart.iste.meitrex.user_service.config.access_token.AccessTokenResponse; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; +import de.unistuttgart.iste.meitrex.user_service.service.oauth.ExternalOAuthClient; +import de.unistuttgart.iste.meitrex.user_service.service.oauth.ExternalOAuthStrategy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ExternalOAuthClientTest { + + private ExternalOAuthStrategy githubStrategy; + private ExternalOAuthClient oAuthClient; + + @BeforeEach + void setUp() { + githubStrategy = mock(ExternalOAuthStrategy.class); + when(githubStrategy.supports(ExternalServiceProvider.GITHUB)).thenReturn(true); + + oAuthClient = new ExternalOAuthClient(List.of(githubStrategy)); + } + + @Test + void testExchangeCodeForAccessToken_delegatesToCorrectStrategy() throws IOException, InterruptedException { + AccessTokenResponse expectedResponse = new AccessTokenResponse("accessToken", 3600, "refreshToken", 7200); + when(githubStrategy.exchangeCodeForAccessToken("authCode")).thenReturn(expectedResponse); + + AccessTokenResponse result = oAuthClient.exchangeCodeForAccessToken("authCode", ExternalServiceProvider.GITHUB); + + assertEquals(expectedResponse, result); + verify(githubStrategy).exchangeCodeForAccessToken("authCode"); + } + + @Test + void testRefreshAccessToken_delegatesToCorrectStrategy() throws IOException, InterruptedException { + AccessTokenResponse expectedResponse = new AccessTokenResponse("newAccessToken", 3600, "newRefreshToken", 7200); + when(githubStrategy.refreshAccessToken("oldRefreshToken")).thenReturn(expectedResponse); + + AccessTokenResponse result = oAuthClient.refreshAccessToken("oldRefreshToken", ExternalServiceProvider.GITHUB); + + assertEquals(expectedResponse, result); + verify(githubStrategy).refreshAccessToken("oldRefreshToken"); + } + + @Test + void testFetchExternalUserId_delegatesToCorrectStrategy() throws IOException, InterruptedException { + when(githubStrategy.fetchExternalUserId("accessToken")).thenReturn("github_username"); + + String result = oAuthClient.fetchExternalUserId("accessToken", ExternalServiceProvider.GITHUB); + + assertEquals("github_username", result); + verify(githubStrategy).fetchExternalUserId("accessToken"); + } + + @Test + void testUnsupportedProvider_throwsException() { + ExternalOAuthClient clientWithNoSupport = new ExternalOAuthClient(List.of()); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> + clientWithNoSupport.exchangeCodeForAccessToken("code", ExternalServiceProvider.GITHUB) + ); + + assertTrue(exception.getMessage().contains("No OAuth strategy found")); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/GitHubOAuthStrategyTest.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/GitHubOAuthStrategyTest.java new file mode 100644 index 0000000..9d14bf6 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/service/GitHubOAuthStrategyTest.java @@ -0,0 +1,151 @@ +package de.unistuttgart.iste.meitrex.user_service.service; + +import de.unistuttgart.iste.meitrex.user_service.config.access_token.AccessTokenResponse; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.ExternalServiceProviderConfiguration; +import de.unistuttgart.iste.meitrex.user_service.config.access_token.ExternalServiceProviderInfo; +import de.unistuttgart.iste.meitrex.user_service.persistence.entity.ExternalServiceProvider; +import de.unistuttgart.iste.meitrex.user_service.service.oauth.GitHubOAuthStrategy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GitHubOAuthStrategyTest { + + private GitHubOAuthStrategy strategy; + private HttpClient mockClient; + private HttpResponse mockResponse; + + @BeforeEach + void setUp() { + mockClient = mock(HttpClient.class); + ExternalServiceProviderConfiguration mockConfig = mock(ExternalServiceProviderConfiguration.class); + mockResponse = mock(HttpResponse.class); + + ExternalServiceProviderInfo githubInfo = ExternalServiceProviderInfo.builder() + .clientId("clientId") + .clientSecret("clientSecret") + .tokenRequestUrl("https://github.com/login/oauth/access_token") + .externalUserIdUrl("https://api.github.com/user") + .build(); + + when(mockConfig.getProviders()).thenReturn(Map.of(ExternalServiceProvider.GITHUB, githubInfo)); + + strategy = new GitHubOAuthStrategy(mockClient, mockConfig); + } + + @Test + void supports_shouldReturnTrueForGitHub() { + assertTrue(strategy.supports(ExternalServiceProvider.GITHUB)); + } + + @Test + void exchangeCodeForAccessToken_shouldReturnAccessTokenResponseOnSuccess() throws IOException, InterruptedException { + String json = """ + { + "access_token": "abc123", + "expires_in": 3600, + "refresh_token": "ref456", + "refresh_token_expires_in": 7200 + } + """; + + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn(json); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + AccessTokenResponse result = strategy.exchangeCodeForAccessToken("dummyCode"); + + assertNotNull(result); + assertEquals("abc123", result.getAccessToken()); + assertEquals(3600, result.getExpiresIn()); + assertEquals("ref456", result.getRefreshToken()); + assertEquals(7200, result.getRefreshTokenExpiresIn()); + } + + @Test + void fetchExternalUserId_shouldReturnUsernameOnSuccess() throws IOException, InterruptedException { + String json = """ + { + "login": "bohdan" + } + """; + + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn(json); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + String userId = strategy.fetchExternalUserId("valid_token"); + assertEquals("bohdan", userId); + } + + @Test + void refreshAccessToken_shouldReturnAccessTokenResponseOnSuccess() throws IOException, InterruptedException { + String json = """ + { + "access_token": "new_access_token", + "expires_in": 3600, + "refresh_token": "new_refresh_token", + "refresh_token_expires_in": 86400 + } + """; + + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn(json); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + AccessTokenResponse result = strategy.refreshAccessToken("old_refresh_token"); + + assertNotNull(result); + assertEquals("new_access_token", result.getAccessToken()); + assertEquals(3600, result.getExpiresIn()); + assertEquals("new_refresh_token", result.getRefreshToken()); + assertEquals(86400, result.getRefreshTokenExpiresIn()); + } + + @Test + void exchangeCodeForAccessToken_shouldReturnNullOnFailure() throws IOException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(401); + when(mockResponse.body()).thenReturn("Unauthorized"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + AccessTokenResponse result = strategy.exchangeCodeForAccessToken("invalid_code"); + + assertNull(result); + } + + @Test + void refreshAccessToken_shouldReturnNullOnFailure() throws IOException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(400); + when(mockResponse.body()).thenReturn("Bad Request"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + AccessTokenResponse result = strategy.refreshAccessToken("invalid_refresh_token"); + + assertNull(result); + } + + @Test + void fetchExternalUserId_shouldReturnNullOnFailure() throws IOException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(403); + when(mockResponse.body()).thenReturn("Forbidden"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + String userId = strategy.fetchExternalUserId("invalid_token"); + + assertNull(userId); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/test_config/MockAccessTokenServiceConfiguration.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/test_config/MockAccessTokenServiceConfiguration.java new file mode 100644 index 0000000..6a1dd90 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/test_config/MockAccessTokenServiceConfiguration.java @@ -0,0 +1,51 @@ +package de.unistuttgart.iste.meitrex.user_service.test_config; + +import de.unistuttgart.iste.meitrex.generated.dto.AccessToken; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalServiceProviderDto; +import de.unistuttgart.iste.meitrex.generated.dto.ExternalUserIdWithUser; +import de.unistuttgart.iste.meitrex.user_service.service.AccessTokenService; +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +@TestConfiguration +public class MockAccessTokenServiceConfiguration { + + @Primary + @Bean + public AccessTokenService accessTokenService() { + AccessTokenService mock = Mockito.mock(AccessTokenService.class); + + Mockito.when(mock.getAccessToken(any(), eq(ExternalServiceProviderDto.GITHUB))) + .thenReturn(new AccessToken("mocked-access-token", null)); + + Mockito.when(mock.getExternalUserIds(eq(ExternalServiceProviderDto.GITHUB), any())) + .thenAnswer(invocation -> { + List userIds = invocation.getArgument(1); + return userIds.stream() + .map(id -> { + String externalId; + if (MockKeycloakConfiguration.firstUserId.equals(id)) { + externalId = "github_firstuser"; + } else if (MockKeycloakConfiguration.secondUserId.equals(id)) { + externalId = "github_seconduser"; + } else if (MockKeycloakConfiguration.thirdUserId.equals(id)) { + externalId = "github_thirduser"; + } else { + externalId = null; // simulate missing mapping + } + return new ExternalUserIdWithUser(id, externalId); + }).toList(); + }); + + return mock; + } +} + diff --git a/src/test/java/de/unistuttgart/iste/meitrex/user_service/test_config/MockKeycloakConfiguration.java b/src/test/java/de/unistuttgart/iste/meitrex/user_service/test_config/MockKeycloakConfiguration.java index 8f6ebd6..4e21f0a 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/user_service/test_config/MockKeycloakConfiguration.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/user_service/test_config/MockKeycloakConfiguration.java @@ -1,6 +1,6 @@ package de.unistuttgart.iste.meitrex.user_service.test_config; -import de.unistuttgart.iste.meitrex.user_service.config.KeycloakWrapper; +import de.unistuttgart.iste.meitrex.user_service.config.user.KeycloakWrapper; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.representations.idm.UserRepresentation; import org.mockito.Mockito; diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 54191c1..e74e066 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,5 +1,6 @@ -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -spring.datasource.url=jdbc:tc:postgresql:///course_service +spring.datasource.url=jdbc:tc:postgresql:///user-service spring.datasource.username=root spring.datasource.password=root -spring.jpa.hibernate.ddl-auto=create \ No newline at end of file +spring.jpa.hibernate.ddl-auto=create + +spring.graphql.schema.locations=classpath*:graphql/common/**/,classpath:graphql/** \ No newline at end of file From 2960f73ed6d1a9f417ee019b61a02a525f472796 Mon Sep 17 00:00:00 2001 From: lVentus Date: Tue, 9 Sep 2025 12:12:31 +0200 Subject: [PATCH 09/10] Add Client --- api.md | 810 ------------------ .../user_service/client/QueryDefinitions.java | 31 + .../client/SettingsServiceClient.java | 111 +++ .../SettingServiceConnectionException.java | 52 ++ .../settings/SettingsServiceClientTest.java | 336 ++++++++ 5 files changed, 530 insertions(+), 810 deletions(-) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/settings/SettingsServiceClientTest.java diff --git a/api.md b/api.md index de30479..e69de29 100644 --- a/api.md +++ b/api.md @@ -1,810 +0,0 @@ -# User Service API - -
- Table of Contents - - * [Query](#query) - * [Mutation](#mutation) - * [Objects](#objects) - * [AccessToken](#accesstoken) - * [ExternalUserIdWithUser](#externaluseridwithuser) - * [Notification](#notification) - * [PaginationInfo](#paginationinfo) - * [PublicUserInfo](#publicuserinfo) - * [Settings](#settings) - * [UserInfo](#userinfo) - * [Inputs](#inputs) - * [DateTimeFilter](#datetimefilter) - * [GenerateAccessTokenInput](#generateaccesstokeninput) - * [IntFilter](#intfilter) - * [NotificationInput](#notificationinput) - * [Pagination](#pagination) - * [SettingsInput](#settingsinput) - * [StringFilter](#stringfilter) - * [Enums](#enums) - * [ExternalServiceProviderDto](#externalserviceproviderdto) - * [Gamification](#gamification) - * [GlobalUserRole](#globaluserrole) - * [SortDirection](#sortdirection) - * [Scalars](#scalars) - * [Boolean](#boolean) - * [Date](#date) - * [DateTime](#datetime) - * [Int](#int) - * [LocalTime](#localtime) - * [String](#string) - * [Time](#time) - * [UUID](#uuid) - * [Url](#url) - -
- -## Query -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
findPublicUserInfos[PublicUserInfo]! - -Gets the publicly available information for a list of users with the specified IDs. -If a user does not exist, null is returned for that user. - -
ids[UUID!]!
currentUserInfoUserInfo! - -Gets the user information of the currently authorized user. - -
findUserInfos[UserInfo]! - -Gets all of the users' information for a list of users with the specified IDs. -Only available to privileged users. -If a user does not exist, null is returned for that user. - -
ids[UUID!]!
isAccessTokenAvailableBoolean! - -Checks whether an access token for a given third-party provider exists and is still valid for the currently authenticated user. -Returns `true` if: -- The access token exists and is not expired, OR -- The refresh token exists and is not expired. - -
providerExternalServiceProviderDto!
_internal_noauth_getAccessTokenAccessToken! - -Retrieves the access token of the specified user for a specified third-party provider. - -- If the access token is expired but a valid refresh token is available, the system will attempt to generate a new access token. -- Throws an exception if neither a valid access token nor a valid refresh token is available. - -⚠️ This query is **only accessible internally**. ⚠️ - -
userIdUUID!
providerExternalServiceProviderDto!
_internal_noauth_getExternalUserIds[ExternalUserIdWithUser!]! - -Retrieves the external user IDs for a list of users with the specified IDs. -If a user does not exist, null is returned for that user. -If the user does not have an external user ID for the specified provider, null is returned for that user. -⚠️ This query is **only accessible internally** and the caller must be at least a tutor of a course. The permission must be validated by the caller method. ⚠️ - -
userIds[UUID!]!
providerExternalServiceProviderDto
findUserSettingsSettings!
userIdUUID
findUsersSettings[Settings]!
usersIds[UUID]!
- -## Mutation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
generateAccessTokenBoolean! - -Generates an access token for the given provider using an authorization code obtained from the OAuth flow. -This should be called **only after** the user completes authorization and the frontend retrieves the auth code. -After the access token is generated, the user is redirected to the redirect URI. - -
inputGenerateAccessTokenInput!
updateSettingsSettings!
userIdUUID!
inputSettingsInput!
defaultSettingsSettings!
userIdUUID!
- -## Objects - -### AccessToken - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
accessTokenString!
externalUserIdString
- -### ExternalUserIdWithUser - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
userIdUUID!
externalUserIdString!
- -### Notification - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
gamificationBoolean
lectureBoolean
- -### PaginationInfo - -Return type for information about paginated results. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
pageInt! - -The current page number. - -
sizeInt! - -The number of elements per page. - -
totalElementsInt! - -The total number of elements across all pages. - -
totalPagesInt! - -The total number of pages. - -
hasNextBoolean! - -Whether there is a next page. - -
- -### PublicUserInfo - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
idUUID!
userNameString!
- -### Settings - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
gamificationGamification
notificationNotification
- -### UserInfo - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
idUUID!
userNameString!
firstNameString!
lastNameString!
realmRoles[GlobalUserRole!]!
- -## Inputs - -### DateTimeFilter - -Filter for date values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
afterDateTime - -If specified, filters for dates after the specified value. - -
beforeDateTime - -If specified, filters for dates before the specified value. - -
- -### GenerateAccessTokenInput - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
providerExternalServiceProviderDto!
authorizationCodeString!
- -### IntFilter - -Filter for integer values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
equalsInt - -An integer value to match exactly. - -
greaterThanInt - -If specified, filters for values greater than to the specified value. - -
lessThanInt - -If specified, filters for values less than to the specified value. - -
- -### NotificationInput - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
gamificationBoolean
lectureBoolean
- -### Pagination - -Specifies the page size and page number for paginated results. - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
pageInt! - -The page number, starting at 0. -If not specified, the default value is 0. -For values greater than 0, the page size must be specified. -If this value is larger than the number of pages, an empty page is returned. - -
sizeInt! - -The number of elements per page. - -
- -### SettingsInput - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
gamificationGamification
notificationNotificationInput
- -### StringFilter - -Filter for string values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
equalsString - -A string value to match exactly. - -
containsString - -A string value that must be contained in the field that is being filtered. - -
ignoreCaseBoolean! - -If true, the filter is case-insensitive. - -
- -## Enums - -### ExternalServiceProviderDto - - - - - - - - - - - - - - -
ValueDescription
GITHUB
- -### Gamification - - - - - - - - - - - - - - - - - - - - - - -
ValueDescription
GAMIFICATION_ENABLED
ADAPTIVE_GAMIFICATION_ENABLED
ALL_GAMIFICATION_DISABLED
- -### GlobalUserRole - - - - - - - - - - - - - - - - - - -
ValueDescription
SUPER_USER
COURSE_CREATOR
- -### SortDirection - -Specifies the sort direction, either ascending or descending. - - - - - - - - - - - - - - - - - - -
ValueDescription
ASC
DESC
- -## Scalars - -### Boolean - -The `Boolean` scalar type represents `true` or `false`. - -### Date - -### DateTime - -### Int - -The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. - -### LocalTime - -### String - -The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. - -### Time - -### UUID - -### Url - diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java new file mode 100644 index 0000000..e8542eb --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java @@ -0,0 +1,31 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + +import lombok.NoArgsConstructor; + +/** + * GraphQL documents used by SettingsServiceClient. + */ +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public final class QueryDefinitions { + + public static final String FIND_USER_SETTINGS_QUERY_NAME = "findUserSettings"; + public static final String FIND_USERS_SETTINGS_QUERY_NAME = "findUsersSettings"; + + public static final String FIND_USER_SETTINGS_QUERY = """ + query($userId: UUID!) { + findUserSettings(userId: $userId) { + gamification + notification { gamification lecture } + } + } + """; + + public static final String FIND_USERS_SETTINGS_QUERY = """ + query($usersIds: [UUID!]!) { + findUsersSettings(usersIds: $usersIds) { + gamification + notification { gamification lecture } + } + } + """; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java new file mode 100644 index 0000000..8b8f06f --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java @@ -0,0 +1,111 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + +import de.unistuttgart.iste.meitrex.generated.dto.Settings; +import de.unistuttgart.iste.meitrex.user_service.exception.SettingServiceConnectionException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.graphql.client.ClientGraphQlResponse; +import org.springframework.graphql.client.GraphQlClient; +import reactor.core.publisher.SynchronousSink; + +import java.util.*; +import java.util.UUID; + +/** + * Client for the user-service to query user settings over GraphQL. + */ +@Slf4j +public class SettingsServiceClient { + + private static final long RETRY_COUNT = 3; + private final GraphQlClient graphQlClient; + + public SettingsServiceClient(final GraphQlClient graphQlClient) { + this.graphQlClient = graphQlClient; + } + + /** + * Query settings for a single user. + * + * @param userId the user id + * @return settings of the user (never null on success) + * @throws SettingServiceConnectionException if the response has GraphQL errors, the field is missing/null, or mapping fails + */ + public Settings queryUserSettings(final UUID userId) throws SettingServiceConnectionException { + try { + return graphQlClient.document(QueryDefinitions.FIND_USER_SETTINGS_QUERY) + .variable("userId", userId) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink sink) -> { + if (!result.isValid()) { + sink.error(new SettingServiceConnectionException( + "Invalid response from user-service (findUserSettings).", + result.getErrors())); + return; + } + try { + final Settings s = result + .field(QueryDefinitions.FIND_USER_SETTINGS_QUERY_NAME) + .toEntity(Settings.class); + if (s == null) { + sink.error(new SettingServiceConnectionException( + "Missing field 'findUserSettings' in user-service response.")); + return; + } + sink.next(s); + sink.complete(); + } catch (Exception mappingEx) { + sink.error(new SettingServiceConnectionException( + "Failed to map 'findUserSettings' from user-service response: " + + mappingEx.getMessage())); + } + }) + .retry(RETRY_COUNT) + .block(); + } catch (final RuntimeException e) { + SettingServiceConnectionException.unwrapAndThrow(e); + return null; + } + } + + /** + * Query settings for multiple users. + * + * @param userIds list of user ids + * @return list of settings (never null; may be empty) + * @throws SettingServiceConnectionException if the response has GraphQL errors or mapping fails + */ + public List queryUsersSettings(final List userIds) throws SettingServiceConnectionException { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + try { + return graphQlClient.document(QueryDefinitions.FIND_USERS_SETTINGS_QUERY) + .variable("usersIds", userIds) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink> sink) -> { + if (!result.isValid()) { + sink.error(new SettingServiceConnectionException( + "Invalid response from user-service (findUsersSettings).", + result.getErrors())); + return; + } + try { + final List list = result + .field(QueryDefinitions.FIND_USERS_SETTINGS_QUERY_NAME) + .toEntityList(Settings.class); + sink.next(list != null ? list : List.of()); + sink.complete(); + } catch (Exception mappingEx) { + sink.error(new SettingServiceConnectionException( + "Failed to map 'findUsersSettings' from user-service response: " + + mappingEx.getMessage())); + } + }) + .retry(RETRY_COUNT) + .block(); + } catch (final RuntimeException e) { + SettingServiceConnectionException.unwrapAndThrow(e); + return List.of(); // unreachable + } + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java new file mode 100644 index 0000000..d05aeae --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java @@ -0,0 +1,52 @@ +package de.unistuttgart.iste.meitrex.user_service.exception; + +import org.springframework.graphql.ResponseError; + +import java.util.List; + +/** + * Exception thrown when the connection to the user-service fails or returns an invalid response. + */ +public class SettingServiceConnectionException extends Exception { + + private final String message; + + public SettingServiceConnectionException(final String message) { + super(message); + this.message = message; + } + + public SettingServiceConnectionException(final String message, final List errors) { + super(withErrors(message, errors)); + this.message = withErrors(message, errors); + } + + private static String withErrors(final String base, final List errors) { + if (errors == null || errors.isEmpty()) return base; + final StringBuilder sb = new StringBuilder(base).append('\n') + .append("GraphQL Response Errors:\n"); + for (final ResponseError err : errors) { + sb.append(err.getMessage()).append(" at path ").append(err.getPath()).append('\n'); + } + return sb.toString(); + } + + @Override + public String getMessage() { + return this.message; + } + + /** + * Unwraps a RuntimeException thrown by reactive pipelines and rethrows as UserServiceConnectionException when possible. + */ + public static void unwrapAndThrow(final RuntimeException e) throws SettingServiceConnectionException { + Throwable t = e; + while (t != null) { + if (t instanceof SettingServiceConnectionException uce) { + throw uce; + } + t = t.getCause(); + } + throw e; + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/settings/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/settings/SettingsServiceClientTest.java new file mode 100644 index 0000000..ec84b02 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/settings/SettingsServiceClientTest.java @@ -0,0 +1,336 @@ +package de.unistuttgart.iste.meitrex.client; + +import de.unistuttgart.iste.meitrex.generated.dto.Gamification; +import de.unistuttgart.iste.meitrex.generated.dto.Settings; +import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient; +import de.unistuttgart.iste.meitrex.user_service.exception.SettingServiceConnectionException; +import org.junit.jupiter.api.Test; +import org.springframework.graphql.ResponseError; + +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import org.springframework.graphql.client.GraphQlClient; +import org.springframework.graphql.client.HttpGraphQlClient; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tests for SettingsServiceClient + */ +class SettingsServiceClientTest { + + @Test + void queryUserSettings_success() throws Exception { + String json = """ + { + "data": { + "findUserSettings": { + "gamification": "GAMIFICATION_ENABLED", + "notification": { "gamification": true, "lecture": false } + } + } + } + """; + + SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json)); + + UUID userId = UUID.randomUUID(); + Settings s = client.queryUserSettings(userId); + + assertNotNull(s); + assertEquals(Gamification.GAMIFICATION_ENABLED, s.getGamification()); + assertNotNull(s.getNotification()); + assertEquals(Boolean.TRUE, s.getNotification().getGamification()); + assertEquals(Boolean.FALSE, s.getNotification().getLecture()); + } + + @Test + void queryUsersSettings_success() throws Exception { + String json = """ + { + "data": { + "findUsersSettings": [ + { + "gamification": "GAMIFICATION_ENABLED", + "notification": { "gamification": true, "lecture": false } + }, + { + "gamification": "ALL_GAMIFICATION_DISABLED", + "notification": { "gamification": false, "lecture": true } + } + ] + } + } + """; + + SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json)); + + var u1 = UUID.randomUUID(); + var u2 = UUID.randomUUID(); + List list = client.queryUsersSettings(List.of(u1, u2)); + + assertNotNull(list); + assertEquals(2, list.size()); + + assertEquals(Gamification.GAMIFICATION_ENABLED, list.get(0).getGamification()); + assertEquals(Boolean.TRUE, list.get(0).getNotification().getGamification()); + assertEquals(Boolean.FALSE, list.get(0).getNotification().getLecture()); + + assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, list.get(1).getGamification()); + assertEquals(Boolean.FALSE, list.get(1).getNotification().getGamification()); + assertEquals(Boolean.TRUE, list.get(1).getNotification().getLecture()); + } + + @Test + void queryUserSettings_graphQlErrors_throws() { + String json = """ + { + "data": { "findUserSettings": null }, + "errors": [ { "message": "boom", "path": ["findUserSettings"] } ] + } + """; + + ExchangeFunction fx = req -> { + ClientResponse resp = ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(json) + .build(); + return Mono.just(resp); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + GraphQlClient gql = HttpGraphQlClient.builder(webClient).build(); + + SettingsServiceClient client = new SettingsServiceClient(gql); + + assertThrows(SettingServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + @Test + void queryUserSettings_fieldMissing_throws() { + String json = "{ \"data\": { } }"; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + assertThrows(SettingServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + + @Test + void queryUserSettings_retriesThenSuccess() throws Exception { + String err = "{ \"data\": { \"findUserSettings\": null }, \"errors\": [ { \"message\": \"e\" } ] }"; + String ok = """ + { "data": { "findUserSettings": { + "gamification": "ALL_GAMIFICATION_DISABLED", + "notification": { "gamification": false, "lecture": true } + } } } + """; + SettingsServiceClient client = new SettingsServiceClient(gqlWithSequence(err, err, ok)); + + Settings s = client.queryUserSettings(UUID.randomUUID()); + + assertNotNull(s); + assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, s.getGamification()); + assertEquals(Boolean.FALSE, s.getNotification().getGamification()); + assertEquals(Boolean.TRUE, s.getNotification().getLecture()); + } + + @Test + void queryUsersSettings_emptyInput_noHttpCall() { + AtomicInteger calls = new AtomicInteger(0); + SettingsServiceClient client = new SettingsServiceClient(gqlCounting(calls)); + + try { + List list = client.queryUsersSettings(List.of()); + assertNotNull(list); + assertTrue(list.isEmpty()); + assertEquals(0, calls.get(), "No HTTP call should be made for empty input"); + } catch (SettingServiceConnectionException e) { + fail("Unexpected exception: " + e.getMessage()); + } + } + + + + @Test + void queryUsersSettings_mappingError_throws() { + String json = "{ \"data\": { \"findUsersSettings\": { \"not\": \"a list\" } } }"; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + assertThrows(SettingServiceConnectionException.class, + () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); + } + + + /** + * Creates a GraphQlClient backed by a WebClient that always returns the given JSON. + * No server, no extra deps. + */ + private static GraphQlClient mockGraphQl(String jsonResponse) { + ExchangeFunction fx = request -> { + ClientResponse resp = ClientResponse + .create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(jsonResponse) + .build(); + return Mono.just(resp); + }; + WebClient webClient = WebClient.builder() + .exchangeFunction(fx) + .build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlWithJson(String json) { + ExchangeFunction fx = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(json) + .build() + ); + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlWithSequence(String... jsonResponses) { + AtomicInteger idx = new AtomicInteger(0); + ExchangeFunction fx = req -> { + String body = jsonResponses[Math.min(idx.getAndIncrement(), jsonResponses.length - 1)]; + return Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(body) + .build()); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlCounting(AtomicInteger counter) { + ExchangeFunction fx = req -> { + counter.incrementAndGet(); + return Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body("{ \"data\": { \"findUsersSettings\": [] } }") + .build()); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlErrorWrapped(String message) { + ExchangeFunction fx = req -> { + return Mono.error(new RuntimeException( + new SettingServiceConnectionException(message) + )); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + @Test + void queryUserSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { + SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); + assertThrows(SettingServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + @Test + void queryUsersSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { + SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); + assertThrows(SettingServiceConnectionException.class, + () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); + } + + @Test + void queryUsersSettings_nullList_normalizedToEmpty() throws SettingServiceConnectionException { + String json = """ + { "data": { "findUsersSettings": null } } + """; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + List list = client.queryUsersSettings(List.of(UUID.randomUUID())); + org.junit.jupiter.api.Assertions.assertNotNull(list); + org.junit.jupiter.api.Assertions.assertTrue(list.isEmpty()); + } + + private static ResponseError responseError(String message) { + return (ResponseError) Proxy.newProxyInstance( + ResponseError.class.getClassLoader(), + new Class[]{ResponseError.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("getMessage".equals(name)) { + return message; + } + if ("toSpecification".equals(name)) { + return Map.of("message", message); + } + return null; + } + ); + } + + + @Test + void constructor_withMessage_keepsMessage() { + var ex = new SettingServiceConnectionException("plain"); + assertEquals("plain", ex.getMessage()); + } + + @Test + void constructor_withErrors_formatsMessage() { + var base = "Invalid response"; + var ex = new SettingServiceConnectionException(base, java.util.List.of( + responseError("boom1"), + responseError("boom2") + )); + + String msg = ex.getMessage(); + assertTrue(msg.contains(base)); + assertTrue(msg.contains("boom1")); + assertTrue(msg.contains("boom2")); + } + + @Test + void constructor_withNullErrors_fallsBackToBaseMessage() { + var ex = new SettingServiceConnectionException("only-base", null); + assertEquals("only-base", ex.getMessage()); + } + + @Test + void unwrapAndThrow_directWrapped_throwsInnerUserEx() { + var inner = new SettingServiceConnectionException("inner"); + var outer = new RuntimeException(inner); + + var thrown = assertThrows(SettingServiceConnectionException.class, + () -> SettingServiceConnectionException.unwrapAndThrow(outer)); + assertEquals("inner", thrown.getMessage()); + } + + @Test + void unwrapAndThrow_deeplyNested_throwsInnerUserEx() { + var inner = new SettingServiceConnectionException("deep"); + var outer = new RuntimeException(new IllegalStateException(inner)); + + var thrown = assertThrows(SettingServiceConnectionException.class, + () -> SettingServiceConnectionException.unwrapAndThrow(outer)); + assertEquals("deep", thrown.getMessage()); + } + + @Test + void unwrapAndThrow_noUserEx_rethrowsOriginalRuntime() { + var outer = new RuntimeException(new IllegalArgumentException("no-user-ex")); + var rethrown = assertThrows(RuntimeException.class, + () -> SettingServiceConnectionException.unwrapAndThrow(outer)); + assertSame(outer, rethrown); + } +} From 3d1f399c2dbe739c501f93532f05dcf40f3ea4c8 Mon Sep 17 00:00:00 2001 From: lVentus Date: Tue, 9 Sep 2025 19:11:32 +0200 Subject: [PATCH 10/10] Update build.gradle --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index ef929da..21e2aff 100644 --- a/build.gradle +++ b/build.gradle @@ -110,7 +110,7 @@ repositories { dependencies { implementation("com.google.code.gson:gson:2.10.1") - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.6' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.9' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -127,7 +127,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.6' + testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.9' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.graphql:spring-graphql-test'