From ea900c43b7a4bbd0ed8bbf1c127e9b59288198c7 Mon Sep 17 00:00:00 2001 From: Martin Weise <martin.weise@tuwien.ac.at> Date: Thu, 13 Feb 2025 14:16:30 +0100 Subject: [PATCH] Fixed the sync Signed-off-by: Martin Weise <martin.weise@tuwien.ac.at> --- dbrepo-auth-service/dbrepo-realm.json | 61 +- .../src/main/java/at/tuwien/Client.java | 3 +- .../tuwien/CreateEventListenerProvider.java | 1 - .../target/create-event-listener.jar | Bin 10130 -> 10238 bytes .../at/tuwien/api/auth/CreateUserDto.java | 3 - .../java/at/tuwien/entities/user/User.java | 2 +- .../at/tuwien/endpoints/UserEndpoint.java | 44 +- .../tuwien/validation/EndpointValidator.java | 2 +- .../at/tuwien/ApplicationIntegrationTest.java | 23 + .../endpoints/DatabaseEndpointUnitTest.java | 79 +- .../endpoints/TableEndpointUnitTest.java | 68 +- .../endpoints/UserEndpointUnitTest.java | 73 +- .../KeycloakGatewayIntegrationTest.java | 19 - .../handlers/ApiExceptionHandlerTest.java | 907 +++++++++++++++++- .../service/UserServiceIntegrationTest.java | 85 ++ .../service/UserServicePersistenceTest.java | 14 +- .../tuwien/service/UserServiceUnitTest.java | 4 +- .../validator/EndpointValidatorUnitTest.java | 94 +- .../at/tuwien/gateway/KeycloakGateway.java | 9 +- .../gateway/impl/KeycloakGatewayImpl.java | 33 +- .../tuwien/service/AuthenticationService.java | 4 +- .../java/at/tuwien/service/UserService.java | 3 +- .../impl/AuthenticationServiceImpl.java | 7 +- .../tuwien/service/impl/UserServiceImpl.java | 4 +- .../main/java/at/tuwien/test/BaseTest.java | 57 +- .../composables/authentication-service.ts | 24 - dbrepo-ui/composables/axios-instance.ts | 2 +- dbrepo-ui/composables/user-service.ts | 32 - dbrepo-ui/layouts/default.vue | 29 +- dbrepo-ui/locales/en-US.json | 12 +- dbrepo-ui/pages/user/authentication.vue | 13 +- dbrepo-ui/pages/user/info.vue | 14 +- docker-compose.yml | 2 +- helm/dbrepo/files/create-event-listener.jar | Bin 10130 -> 10238 bytes 34 files changed, 1476 insertions(+), 251 deletions(-) create mode 100644 dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/ApplicationIntegrationTest.java create mode 100644 dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServiceIntegrationTest.java delete mode 100644 dbrepo-ui/composables/authentication-service.ts diff --git a/dbrepo-auth-service/dbrepo-realm.json b/dbrepo-auth-service/dbrepo-realm.json index 1c703b8375..bac2ddc978 100644 --- a/dbrepo-auth-service/dbrepo-realm.json +++ b/dbrepo-auth-service/dbrepo-realm.json @@ -1475,6 +1475,39 @@ "claim.name" : "language", "jsonType.label" : "String" } + }, { + "id" : "9bdc3e60-09b8-4241-915e-29f083434026", + "name" : "provider", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usersessionmodel-note-mapper", + "consentRequired" : false, + "config" : { + "user.session.note" : "identity_provider", + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "id.token.claim" : "true", + "lightweight.claim" : "false", + "access.token.claim" : "true", + "claim.name" : "identity_provider", + "jsonType.label" : "String", + "access.tokenResponse.claim" : "false" + } + }, { + "id" : "e567cb5c-8856-4124-8b86-f19cd53d7c71", + "name" : "setup_finished", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "SETUP_FINISHED", + "id.token.claim" : "true", + "lightweight.claim" : "false", + "access.token.claim" : "true", + "claim.name" : "setup_finished", + "jsonType.label" : "boolean" + } }, { "id" : "b817424d-7f91-43d8-b7d0-6a32582377fb", "name" : "family name", @@ -2376,7 +2409,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "saml-user-property-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper" ] } }, { "id" : "1849e52a-b8c9-44a8-af3d-ee19376a1ed1", @@ -2402,7 +2435,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-address-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-full-name-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-address-mapper" ] } } ], "org.keycloak.userprofile.UserProfileProvider" : [ { @@ -2426,8 +2459,8 @@ "config" : { "ldap.attribute" : [ "createTimestamp" ], "is.mandatory.in.ldap" : [ "false" ], - "always.read.value.from.ldap" : [ "true" ], "read.only" : [ "true" ], + "always.read.value.from.ldap" : [ "true" ], "user.model.attribute" : [ "createTimestamp" ] } }, { @@ -2438,8 +2471,8 @@ "config" : { "ldap.attribute" : [ "sn" ], "is.mandatory.in.ldap" : [ "true" ], - "read.only" : [ "false" ], "always.read.value.from.ldap" : [ "true" ], + "read.only" : [ "false" ], "user.model.attribute" : [ "lastName" ] } }, { @@ -2450,8 +2483,8 @@ "config" : { "ldap.attribute" : [ "cn" ], "is.mandatory.in.ldap" : [ "true" ], - "always.read.value.from.ldap" : [ "true" ], "read.only" : [ "false" ], + "always.read.value.from.ldap" : [ "true" ], "user.model.attribute" : [ "firstName" ] } }, { @@ -2462,8 +2495,8 @@ "config" : { "ldap.attribute" : [ "mail" ], "is.mandatory.in.ldap" : [ "false" ], - "always.read.value.from.ldap" : [ "false" ], "read.only" : [ "false" ], + "always.read.value.from.ldap" : [ "false" ], "user.model.attribute" : [ "email" ] } }, { @@ -2476,15 +2509,15 @@ "membership.attribute.type" : [ "DN" ], "user.roles.retrieve.strategy" : [ "LOAD_GROUPS_BY_MEMBER_ATTRIBUTE" ], "group.name.ldap.attribute" : [ "cn" ], - "membership.user.ldap.attribute" : [ "uid" ], - "ignore.missing.groups" : [ "false" ], "preserve.group.inheritance" : [ "false" ], "membership.ldap.attribute" : [ "member" ], - "memberof.ldap.attribute" : [ "memberOf" ], - "group.object.classes" : [ "groupOfNames" ], + "ignore.missing.groups" : [ "false" ], + "membership.user.ldap.attribute" : [ "uid" ], "groups.dn" : [ "ou=users,dc=dbrepo,dc=at" ], - "groups.path" : [ "/" ], - "drop.non.existing.groups.during.sync" : [ "false" ] + "group.object.classes" : [ "groupOfNames" ], + "memberof.ldap.attribute" : [ "memberOf" ], + "drop.non.existing.groups.during.sync" : [ "false" ], + "groups.path" : [ "/" ] } }, { "id" : "b6ff3285-35af-4e86-8bb4-d94b8e0d70bb", @@ -2518,8 +2551,8 @@ "fullSyncPeriod" : [ "-1" ], "pagination" : [ "false" ], "startTls" : [ "false" ], - "connectionPooling" : [ "true" ], "usersDn" : [ "ou=users,dc=dbrepo,dc=at" ], + "connectionPooling" : [ "true" ], "cachePolicy" : [ "DEFAULT" ], "useKerberosForPasswordAuthentication" : [ "false" ], "importEnabled" : [ "true" ], @@ -2531,8 +2564,8 @@ "lastSync" : [ "1719252666" ], "vendor" : [ "other" ], "uuidLDAPAttribute" : [ "entryUUID" ], - "allowKerberosAuthentication" : [ "false" ], "connectionUrl" : [ "ldap://identity-service:1389" ], + "allowKerberosAuthentication" : [ "false" ], "syncRegistrations" : [ "true" ], "authType" : [ "simple" ], "useTruststoreSpi" : [ "always" ], diff --git a/dbrepo-auth-service/listeners/src/main/java/at/tuwien/Client.java b/dbrepo-auth-service/listeners/src/main/java/at/tuwien/Client.java index 769ec49097..c63e88618b 100644 --- a/dbrepo-auth-service/listeners/src/main/java/at/tuwien/Client.java +++ b/dbrepo-auth-service/listeners/src/main/java/at/tuwien/Client.java @@ -31,8 +31,7 @@ public class Client { if (systemPassword == null || systemPassword.isEmpty()) { throw new IllegalArgumentException("Environment variable SYSTEM_PASSWORD is not set or is empty."); } - - URL url = URI.create(urlString).toURL(); + final URL url = URI.create(urlString + "/api/user").toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setDoOutput(true); conn.setRequestMethod("POST"); diff --git a/dbrepo-auth-service/listeners/src/main/java/at/tuwien/CreateEventListenerProvider.java b/dbrepo-auth-service/listeners/src/main/java/at/tuwien/CreateEventListenerProvider.java index 93f2b2919b..ea4aa7794b 100644 --- a/dbrepo-auth-service/listeners/src/main/java/at/tuwien/CreateEventListenerProvider.java +++ b/dbrepo-auth-service/listeners/src/main/java/at/tuwien/CreateEventListenerProvider.java @@ -57,7 +57,6 @@ public class CreateEventListenerProvider implements EventListenerProvider { final String userData = "{" + quoteAttr("id", user.getId()) + ", " + quoteAttr("username", user.getUsername()) + ", " + - quoteAttr("email", user.getEmail()) + ", " + quoteAttr("ldap_id", user.getFirstAttribute("LDAP_ID")) + ", " + quoteAttr("given_name", user.getFirstName()) + ", " + quoteAttr("family_name", user.getLastName()) + diff --git a/dbrepo-auth-service/listeners/target/create-event-listener.jar b/dbrepo-auth-service/listeners/target/create-event-listener.jar index a23243d39509ec3821219e5799a25740c93e2ca1..a970096eeaa5015d9e95064f6b341fffdf8aae5f 100644 GIT binary patch delta 6843 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 T<fSgnST{LMU5!mh8Dtm$g_hbd delta 6774 zcmez8KgnM@z?+#xgn@yBgW-jkPt^OFECMBr3=C(O7#Kt*+bWCK_XZv8yJaA-*Zq|J z5B8kRiYgPL7q*KzYCCQ(uQt*Y>ui4GsZk#<oDmSXbvOI%<ny2RJU73mm96#VNd2PE z%eJys3r@C(vJ7cajgRBn7HS*y?c~9OUtZh@SrUF^lX2<E>rW@X_vd&RCFg$qL<pNm ztJk0Qg;LyMS?$ZGt?uKyv$*~>?*rqaCw^_0&U;30TbJ-U^|kF~@ok4E)dq^V$IlM# z<&CPE|DN*~|H2u6)&D!5yMA|B0{f!L`b%u)pFb257f^1N(aN;YLn_+x>B&-uxz76e zpHgI47jNFMCUm{qzVj=3H$8rMzmFw*e|6nkhl3%(X)ibYUpDK*o!*Ui-zVBXGOib_ zIx%_9{f*TbKUQrOYi!%ozU)V8hD1=?&kLE1xyt<&jk`jR8wx0DCjQ`-`Mto>X3q6v z!cisL4_UuC8~ZG0*NM-bMr(sksDIW}xyfV_V&y9wky5qptIy)7JMZ@H`t|Tmw3Lda z%S{&ki?&;MoSGKwo|cdz^Oq46D_jf=91JX=Xqmj8QHA-rn9t;EjJjZ&jY$qnt23!G zzYz18?9ZeHrfZl?!1QJ&FOd8s=CdGb@&)E@5WP8$WeyYXb1|Q&fQh$$9N=JJ=$p*W zu2?U8T(b6Yc53Cpz5_Rknisd6W_Oz);FR0<W5U6;HAjS&wQVcWUHg_ba`l^aJ_|fA zo8{ixvc=@3Yt=>LFMf(4WpCA%i2HiH|7|yE-pks*$Jc*3d#atsNYmcf*!uIc+4=X% zd-eX5zcOUF{ZU?@D`3R}4_8Ak7A?N4$593gRo$ER);qI*ouM4C>8yv;Iljh2yM8h9 z+c5om7qDgPV$)ky3PP{sUu5yia(pfM^0n-%%ia%*d>U6JaZb~lAZD3l{YzqL?~A^9 zGU5@@;^!hub(AeH1=T+d(bOvFWt!iW8sXPDOSt4xJFl%|j{k<Gb*-GmE(_FhqLdFd zJ1@&+w=_Jo_h7w2_1&zG39ScCxt8Yft=YJDMO(aMZ`bpOQH`&TrUd@qVK+riQY>P+ z`~MG5-)z0YUlG{-T7)Gere9ht=uw_f0FSlFw4%NpJnw@yL_KJ$3<+0S*q_lEmaSrY z?04I{j+dIp4JOO25q-g<dYt#P	>gcBDz(3UK1rsk+*b$UbRVz3_%o^@ONT-qYFh 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 X3>z637=%&G5Sn~RU5#y-5=amLiEW)w diff --git a/dbrepo-metadata-service/api/src/main/java/at/tuwien/api/auth/CreateUserDto.java b/dbrepo-metadata-service/api/src/main/java/at/tuwien/api/auth/CreateUserDto.java index 16f45aec4d..9742986ae0 100644 --- a/dbrepo-metadata-service/api/src/main/java/at/tuwien/api/auth/CreateUserDto.java +++ b/dbrepo-metadata-service/api/src/main/java/at/tuwien/api/auth/CreateUserDto.java @@ -40,7 +40,4 @@ public class CreateUserDto { @Schema(example = "bar") private String familyName; - @Schema(example = "foo.bar@example.com") - private String email; - } diff --git a/dbrepo-metadata-service/entities/src/main/java/at/tuwien/entities/user/User.java b/dbrepo-metadata-service/entities/src/main/java/at/tuwien/entities/user/User.java index ba86e3d29c..156fc3b4c8 100644 --- a/dbrepo-metadata-service/entities/src/main/java/at/tuwien/entities/user/User.java +++ b/dbrepo-metadata-service/entities/src/main/java/at/tuwien/entities/user/User.java @@ -65,7 +65,7 @@ public class User { @Column(name = "mariadb_password", nullable = false) private String mariadbPassword; - @Column(name = "is_internal", nullable = false, updatable = false) + @Column(name = "is_internal", nullable = false, updatable = false, columnDefinition = "bool default false") private Boolean isInternal; } diff --git a/dbrepo-metadata-service/rest-service/src/main/java/at/tuwien/endpoints/UserEndpoint.java b/dbrepo-metadata-service/rest-service/src/main/java/at/tuwien/endpoints/UserEndpoint.java index 51f323c30f..5ca14a5a34 100644 --- a/dbrepo-metadata-service/rest-service/src/main/java/at/tuwien/endpoints/UserEndpoint.java +++ b/dbrepo-metadata-service/rest-service/src/main/java/at/tuwien/endpoints/UserEndpoint.java @@ -106,47 +106,15 @@ public class UserEndpoint extends AbstractEndpoint { @ApiResponse(responseCode = "400", description = "Parameters are not well-formed (likely email)", content = {@Content(mediaType = "application/json")}), - @ApiResponse(responseCode = "403", - description = "Internal authentication to the auth service is invalid", - content = {@Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorDto.class))}), - @ApiResponse(responseCode = "404", - description = "Default role not found", - content = {@Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorDto.class))}), - @ApiResponse(responseCode = "409", - description = "User with username already exists", - content = {@Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorDto.class))}), - @ApiResponse(responseCode = "417", - description = "User with e-mail already exists", - content = {@Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorDto.class))}), - @ApiResponse(responseCode = "502", - description = "Failed to create in auth service", - content = {@Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorDto.class))}), - @ApiResponse(responseCode = "503", - description = "Failed to create in auth service", - content = {@Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiErrorDto.class))}), }) - public ResponseEntity<UserBriefDto> create(@NotNull @Valid @RequestBody CreateUserDto data) - throws UserExistsException, EmailExistsException, AuthServiceException, AuthServiceConnectionException, - UserNotFoundException, CredentialsInvalidException { + public ResponseEntity<UserBriefDto> create(@NotNull @Valid @RequestBody CreateUserDto data) { log.debug("endpoint create user, data.id={}, data.username={}", data.getId(), data.getUsername()); return ResponseEntity.status(HttpStatus.CREATED) .body(userMapper.userToUserBriefDto( userService.create(data))); } - @GetMapping("/{userId}") + @RequestMapping(value = "/{userId}", method = {RequestMethod.GET, RequestMethod.HEAD}) @Transactional(readOnly = true) @PreAuthorize("isAuthenticated()") @Observed(name = "dbrepo_user_find") @@ -181,12 +149,14 @@ public class UserEndpoint extends AbstractEndpoint { throw new NotAllowedException("Failed to find user: foreign user"); } if (user.getIsInternal()) { - throw new UserNotFoundException("Failed to find user with username: " + user.getUsername()); + log.error("Failed to find user: internal user"); + throw new NotAllowedException("Failed to find user: internal user"); } final HttpHeaders headers = new HttpHeaders(); if (isSystem(principal)) { headers.set("X-Username", user.getUsername()); headers.set("X-Password", user.getMariadbPassword()); + headers.set("Access-Control-Expose-Headers", "X-Username X-Password"); } return ResponseEntity.status(HttpStatus.OK) .headers(headers) @@ -282,18 +252,18 @@ public class UserEndpoint extends AbstractEndpoint { @NotNull @Valid @RequestBody UserPasswordDto data, @NotNull Principal principal) throws NotAllowedException, UserNotFoundException, DatabaseNotFoundException, DataServiceException, - DataServiceConnectionException { + DataServiceConnectionException, AuthServiceException { log.debug("endpoint modify a user password, userId={}, principal.name={}", userId, principal.getName()); final User user = userService.findById(userId); if (!user.getUsername().equals(principal.getName())) { log.error("Failed to modify user password: not current user"); throw new NotAllowedException("Failed to modify user password: not current user"); } - authenticationService.updatePassword(user, data); for (Database database : databaseService.findAllAtLestReadAccess(userId)) { databaseService.updatePassword(database, user); } userService.updatePassword(user, data); + authenticationService.setupFinished(user); return ResponseEntity.accepted() .build(); } diff --git a/dbrepo-metadata-service/rest-service/src/main/java/at/tuwien/validation/EndpointValidator.java b/dbrepo-metadata-service/rest-service/src/main/java/at/tuwien/validation/EndpointValidator.java index a54f616b01..6fe29c118b 100644 --- a/dbrepo-metadata-service/rest-service/src/main/java/at/tuwien/validation/EndpointValidator.java +++ b/dbrepo-metadata-service/rest-service/src/main/java/at/tuwien/validation/EndpointValidator.java @@ -131,7 +131,7 @@ public class EndpointValidator extends AbstractEndpoint { final Optional<CreateTableColumnDto> optional3 = data.getColumns() .stream() .filter(c -> c.getType().equals(ColumnTypeDto.SET)) - .filter(c -> c.getEnums() == null || c.getSets().isEmpty()) + .filter(c -> c.getSets() == null || c.getSets().isEmpty()) .findFirst(); if (optional3.isPresent()) { log.error("Validation failed: column {} needs at least 1 allowed set value", optional3.get().getName()); diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/ApplicationIntegrationTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/ApplicationIntegrationTest.java new file mode 100644 index 0000000000..33c7bc76c5 --- /dev/null +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/ApplicationIntegrationTest.java @@ -0,0 +1,23 @@ +package at.tuwien; + +import lombok.extern.log4j.Log4j2; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@Log4j2 +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +@SpringBootTest +@ExtendWith(SpringExtension.class) +public class ApplicationIntegrationTest { + + @Test + public void main_succeeds() { + + /* test */ + DbrepoMetadataServiceApplication.main(new String[]{}); + } + +} diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/DatabaseEndpointUnitTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/DatabaseEndpointUnitTest.java index fd91fb5655..7ab4d2c16a 100644 --- a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/DatabaseEndpointUnitTest.java +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/DatabaseEndpointUnitTest.java @@ -269,7 +269,7 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { @Test @WithAnonymousUser - public void list_anonymous_succeeds() throws DatabaseNotFoundException, UserNotFoundException { + public void list_anonymous_succeeds() { /* mock */ when(databaseService.findAllPublicOrSchemaPublic()) @@ -281,7 +281,7 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { @Test @WithMockUser(username = USER_1_USERNAME, authorities = {"list-databases"}) - public void list_hasRole_succeeds() throws DatabaseNotFoundException, UserNotFoundException { + public void list_hasRole_succeeds() { /* pre-condition */ assertTrue(DATABASE_3_PUBLIC); @@ -296,7 +296,7 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { @Test @WithMockUser(username = USER_1_USERNAME, authorities = {"list-databases"}) - public void list_hasRoleForeign_succeeds() throws DatabaseNotFoundException, UserNotFoundException { + public void list_hasRoleForeign_succeeds() { /* pre-condition */ assertTrue(DATABASE_3_PUBLIC); @@ -311,7 +311,7 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { @Test @WithMockUser(username = USER_1_USERNAME, authorities = {"list-databases"}) - public void list_hasRoleFilter_succeeds() throws DatabaseNotFoundException, UserNotFoundException { + public void list_hasRoleFilter_succeeds() { /* mock */ when(databaseService.findAllPublicOrSchemaPublicOrReadAccessByInternalName(USER_1_ID, DATABASE_3_INTERNALNAME)) @@ -323,7 +323,7 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { @Test @WithMockUser(username = USER_1_USERNAME, authorities = {"list-databases"}) - public void list_hasRoleFilterNoResult_succeeds() throws DatabaseNotFoundException, UserNotFoundException { + public void list_hasRoleFilterNoResult_succeeds() { /* mock */ when(databaseService.findAllPublicOrSchemaPublicOrReadAccessByInternalName(USER_1_ID, "i_do_not_exist")) @@ -333,6 +333,18 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { list_generic("i_do_not_exist", USER_1_PRINCIPAL, 0); } + @Test + @WithAnonymousUser + public void list_filterNoResult_succeeds() { + + /* mock */ + when(databaseService.findAllPublicOrSchemaPublicByInternalName("i_do_not_exist")) + .thenReturn(List.of()); + + /* test */ + list_generic("i_do_not_exist", null, 0); + } + @Test @WithAnonymousUser public void visibility_anonymous_fails() { @@ -569,7 +581,7 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { /* test */ final DatabaseDto database = findById_generic(DATABASE_1_ID, DATABASE_1, USER_LOCAL_ADMIN_PRINCIPAL); - assertEquals(4, database.getTables().size()); + assertEquals(2, database.getTables().size()); assertEquals(2, database.getViews().size()); assertNotEquals(0, database.getAccesses().size()); } @@ -610,6 +622,58 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { assertEquals(3, database.getAccesses().size()); } + @Test + @WithMockUser(username = USER_2_USERNAME) + public void findById_hiddenAccessRights_succeeds() throws DataServiceException, DataServiceConnectionException, + DatabaseNotFoundException, ExchangeNotFoundException, UserNotFoundException, NotAllowedException { + + /* mock */ + when(accessService.list(DATABASE_1)) + .thenReturn(List.of(DATABASE_1_USER_1_WRITE_ALL_ACCESS, DATABASE_1_USER_2_READ_ACCESS)); + + /* test */ + final DatabaseDto database = findById_generic(DATABASE_1_ID, DATABASE_1, USER_2_PRINCIPAL); + assertEquals(4, database.getTables().size()); + assertEquals(3, database.getViews().size()); + assertEquals(0, database.getAccesses().size()); + } + + @Test + @WithMockUser(username = USER_1_USERNAME) + public void findById_hiddenAccessRightsSeesOwn_succeeds() throws DataServiceException, DataServiceConnectionException, + DatabaseNotFoundException, ExchangeNotFoundException, UserNotFoundException, NotAllowedException { + + /* mock */ + when(accessService.list(DATABASE_1)) + .thenReturn(List.of(DATABASE_1_USER_1_WRITE_ALL_ACCESS, DATABASE_1_USER_2_READ_ACCESS)); + + /* test */ + final DatabaseDto database = findById_generic(DATABASE_1_ID, DATABASE_1, USER_1_PRINCIPAL); + assertEquals(4, database.getTables().size()); + assertEquals(3, database.getViews().size()); + assertEquals(3, database.getAccesses().size()); + } + + @Test + @WithMockUser(username = USER_1_USERNAME) + public void findById_privateDataPrivateSchemaNoAccess_fails() { + + /* test */ + assertThrows(NotAllowedException.class, () -> { + findById_generic(DATABASE_1_ID, DATABASE_1, USER_4_PRINCIPAL); + }); + } + + @Test + @WithMockUser(username = USER_1_USERNAME) + public void findById_anonymousPrivateDataPrivateSchema_fails() { + + /* test */ + assertThrows(NotAllowedException.class, () -> { + findById_generic(DATABASE_1_ID, DATABASE_1, null); + }); + } + @Test @WithAnonymousUser public void findPreviewImage_anonymous_succeeds() throws DatabaseNotFoundException { @@ -660,8 +724,7 @@ public class DatabaseEndpointUnitTest extends AbstractUnitTest { /* ## GENERIC TEST CASES ## */ /* ################################################################################################### */ - public void list_generic(String internalName, Principal principal, Integer expectedSize) - throws DatabaseNotFoundException, UserNotFoundException { + public void list_generic(String internalName, Principal principal, Integer expectedSize) { /* test */ final ResponseEntity<List<DatabaseBriefDto>> response = databaseEndpoint.list(internalName, principal); diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/TableEndpointUnitTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/TableEndpointUnitTest.java index a17d31649e..094673416b 100644 --- a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/TableEndpointUnitTest.java +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/TableEndpointUnitTest.java @@ -1,12 +1,12 @@ package at.tuwien.endpoints; -import at.tuwien.api.database.table.TableBriefDto; import at.tuwien.api.database.table.CreateTableDto; +import at.tuwien.api.database.table.TableBriefDto; import at.tuwien.api.database.table.TableDto; import at.tuwien.api.database.table.TableUpdateDto; -import at.tuwien.api.database.table.columns.CreateTableColumnDto; import at.tuwien.api.database.table.columns.ColumnDto; import at.tuwien.api.database.table.columns.ColumnTypeDto; +import at.tuwien.api.database.table.columns.CreateTableColumnDto; import at.tuwien.api.database.table.columns.concepts.ColumnSemanticsUpdateDto; import at.tuwien.api.database.table.constraints.CreateTableConstraintsDto; import at.tuwien.api.semantics.EntityDto; @@ -40,6 +40,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; import java.security.Principal; import java.util.List; +import java.util.UUID; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; @@ -524,7 +525,8 @@ public class TableEndpointUnitTest extends AbstractUnitTest { @Test @WithAnonymousUser public void findById_publicDatabasePrivateDataPrivateSchemaAnonymous_succeeds() throws UserNotFoundException, - TableNotFoundException, NotAllowedException, DataServiceException, DatabaseNotFoundException, AccessNotFoundException, QueueNotFoundException, DataServiceConnectionException { + TableNotFoundException, NotAllowedException, DataServiceException, DatabaseNotFoundException, + AccessNotFoundException, QueueNotFoundException, DataServiceConnectionException { /* test */ final ResponseEntity<TableDto> response = generic_findById(DATABASE_3_ID, DATABASE_3, TABLE_8_ID, TABLE_8, null, null, null); @@ -596,6 +598,16 @@ public class TableEndpointUnitTest extends AbstractUnitTest { }); } + @Test + @WithMockUser(username = USER_4_USERNAME, authorities = {"table-semantic-analyse"}) + public void analyseTable_notOwner_fails() { + + /* test */ + assertThrows(NotAllowedException.class, () -> { + analyseTable_generic(DATABASE_1_ID, DATABASE_1, TABLE_1_ID, TABLE_1, USER_4_PRINCIPAL); + }); + } + @Test @WithMockUser(username = USER_4_USERNAME) public void findAll_noRole_fails() { @@ -930,6 +942,29 @@ public class TableEndpointUnitTest extends AbstractUnitTest { generic_findById(DATABASE_1_ID, DATABASE_1, TABLE_2_ID, TABLE_2, null, null, null); } + @Test + @WithMockUser(username = USER_4_USERNAME) + public void findById_privateSchemaNotOwnerNoAccess_fails() { + + /* test */ + assertThrows(NotAllowedException.class, () -> { + generic_findById(DATABASE_1_ID, DATABASE_1, TABLE_4_ID, TABLE_4, USER_4_PRINCIPAL, USER_4, null); + }); + } + + @Test + @WithMockUser(username = USER_4_USERNAME) + public void findById_publicSchemaNotOwnerNoAccess_succeeds() throws UserNotFoundException, TableNotFoundException, + NotAllowedException, DataServiceException, DatabaseNotFoundException, AccessNotFoundException, + QueueNotFoundException, DataServiceConnectionException { + + /* test */ + final ResponseEntity<TableDto> response = generic_findById(DATABASE_1_ID, DATABASE_1, TABLE_2_ID, TABLE_2, USER_4_PRINCIPAL, USER_4, null); + assertEquals(HttpStatus.OK, response.getStatusCode()); + final TableDto body = response.getBody(); + assertNotNull(body); + } + @Test @WithMockUser(username = USER_1_USERNAME, authorities = "find-table") public void findById_privateHasRoleTableNotFound_fails() { @@ -970,7 +1005,7 @@ public class TableEndpointUnitTest extends AbstractUnitTest { AccessNotFoundException, QueueNotFoundException, DataServiceConnectionException { /* test */ - generic_findById(DATABASE_1_ID, DATABASE_1, TABLE_1_ID, TABLE_1, USER_4_PRINCIPAL, USER_4, null); + generic_findById(DATABASE_1_ID, DATABASE_1, TABLE_1_ID, TABLE_1, USER_4_PRINCIPAL, USER_4, DATABASE_1_USER_4_READ_ACCESS); } @Test @@ -1081,6 +1116,17 @@ public class TableEndpointUnitTest extends AbstractUnitTest { assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); } + @Test + @WithMockUser(username = USER_LOCAL_ADMIN_USERNAME, authorities = {"system", "update-table-statistic"}) + public void updateStatistic_internalUser_succeeds() throws TableNotFoundException, SearchServiceException, + MalformedException, NotAllowedException, DataServiceException, DatabaseNotFoundException, + SearchServiceConnectionException, DataServiceConnectionException { + + /* test */ + final ResponseEntity<Void> response = generic_updateStatistic(USER_LOCAL_ADMIN_PRINCIPAL); + assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); + } + /* ################################################################################################### */ /* ## GENERIC TEST CASES ## */ /* ################################################################################################### */ @@ -1148,7 +1194,7 @@ public class TableEndpointUnitTest extends AbstractUnitTest { } protected ResponseEntity<TableBriefDto> generic_create(Long databaseId, Database database, CreateTableDto data, - Principal principal, User user, DatabaseAccess access) + Principal principal, User user, DatabaseAccess access) throws MalformedException, NotAllowedException, DataServiceException, DataServiceConnectionException, UserNotFoundException, DatabaseNotFoundException, AccessNotFoundException, TableNotFoundException, TableExistsException, SearchServiceException, SearchServiceConnectionException, OntologyNotFoundException, @@ -1203,11 +1249,21 @@ public class TableEndpointUnitTest extends AbstractUnitTest { .when(tableService) .findById(any(Database.class), eq(tableId)); } - if (principal != null) { + if (user != null) { when(userService.findById(user.getId())) .thenReturn(user); + } else { + doThrow(UserNotFoundException.class) + .when(userService) + .findById(any(UUID.class)); + } + if (access != null) { when(accessService.find(any(Database.class), eq(user))) .thenReturn(access); + } else { + doThrow(AccessNotFoundException.class) + .when(accessService) + .find(any(Database.class), eq(user)); } /* test */ diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/UserEndpointUnitTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/UserEndpointUnitTest.java index 6ef4bd8779..de5c8993a1 100644 --- a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/UserEndpointUnitTest.java +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/endpoints/UserEndpointUnitTest.java @@ -1,5 +1,6 @@ package at.tuwien.endpoints; +import at.tuwien.api.auth.CreateUserDto; import at.tuwien.api.user.UserBriefDto; import at.tuwien.api.user.UserDto; import at.tuwien.api.user.UserPasswordDto; @@ -19,6 +20,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.test.context.support.WithAnonymousUser; @@ -63,6 +65,15 @@ public class UserEndpointUnitTest extends AbstractUnitTest { assertEquals(2, response.size()); } + @Test + @WithAnonymousUser + public void findAll_filterInternalUserEmptyList_succeeds() throws UserNotFoundException { + + /* test */ + final List<UserBriefDto> response = findAll_generic(USER_LOCAL_ADMIN_USERNAME, null); + assertEquals(0, response.size()); + } + @Test @WithMockUser(username = USER_1_USERNAME) public void findAll_noRole_succeeds() throws UserNotFoundException { @@ -142,6 +153,18 @@ public class UserEndpointUnitTest extends AbstractUnitTest { assertEquals(USER_3_DATABASE_PASSWORD, response.getHeaders().get("X-Password").get(0)); } + @Test + @WithMockUser(username = USER_LOCAL_ADMIN_USERNAME, authorities = {"system"}) + public void find_internalUser_fails() { + final Principal principal = new UsernamePasswordAuthenticationToken(USER_LOCAL_ADMIN_DETAILS, USER_LOCAL_ADMIN_PASSWORD, List.of( + new SimpleGrantedAuthority("system"))); + + /* test */ + assertThrows(NotAllowedException.class, () -> { + find_generic(USER_LOCAL_ADMIN_ID, USER_LOCAL, principal); + }); + } + @Test @WithAnonymousUser public void modify_anonymous_fails() { @@ -233,7 +256,7 @@ public class UserEndpointUnitTest extends AbstractUnitTest { @Test @WithMockUser(username = USER_1_USERNAME) public void password_succeeds() throws NotAllowedException, DataServiceException, DataServiceConnectionException, - UserNotFoundException, DatabaseNotFoundException { + UserNotFoundException, DatabaseNotFoundException, AuthServiceException { final UserPasswordDto request = UserPasswordDto.builder() .password(USER_1_PASSWORD) .build(); @@ -242,6 +265,38 @@ public class UserEndpointUnitTest extends AbstractUnitTest { password_generic(USER_1_PRINCIPAL, request); } + @Test + @WithAnonymousUser + public void create_anonymous_fails() { + + /* test */ + assertThrows(AccessDeniedException.class, () -> { + generic_create(USER_1_CREATE_USER_DTO); + }); + } + + @Test + @WithMockUser(username = USER_2_USERNAME) + public void create_notInternalUser_fails() { + + /* test */ + assertThrows(AccessDeniedException.class, () -> { + generic_create(USER_1_CREATE_USER_DTO); + }); + } + + @Test + @WithMockUser(username = USER_LOCAL_ADMIN_USERNAME, authorities = {"system"}) + public void create_succeeds() { + + /* mock */ + when(userService.create(USER_1_CREATE_USER_DTO)) + .thenReturn(USER_1); + + /* test */ + generic_create(USER_1_CREATE_USER_DTO); + } + /* ################################################################################################### */ /* ## GENERIC TEST CASES ## */ /* ################################################################################################### */ @@ -260,7 +315,7 @@ public class UserEndpointUnitTest extends AbstractUnitTest { } } else { when(userService.findAll()) - .thenReturn(List.of(USER_1, USER_2)); + .thenReturn(List.of(USER_1, USER_2, USER_LOCAL)); } /* test */ @@ -310,14 +365,15 @@ public class UserEndpointUnitTest extends AbstractUnitTest { } protected void password_generic(Principal principal, UserPasswordDto data) throws NotAllowedException, - DataServiceException, DataServiceConnectionException, UserNotFoundException, DatabaseNotFoundException { + DataServiceException, DataServiceConnectionException, UserNotFoundException, DatabaseNotFoundException, + AuthServiceException { /* mock */ when(userService.findById(USER_1_ID)) .thenReturn(USER_1); doNothing() .when(authenticationService) - .updatePassword(USER_1, data); + .setupFinished(USER_1); doNothing() .when(userService) .updatePassword(USER_1, data); @@ -331,4 +387,13 @@ public class UserEndpointUnitTest extends AbstractUnitTest { final ResponseEntity<?> response = userEndpoint.password(USER_1_ID, data, principal); assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); } + + protected void generic_create(CreateUserDto data) { + + /* test */ + final ResponseEntity<UserBriefDto> response = userEndpoint.create(data); + assertEquals(HttpStatus.CREATED, response.getStatusCode()); + final UserBriefDto body = response.getBody(); + assertNotNull(body); + } } diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/gateway/KeycloakGatewayIntegrationTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/gateway/KeycloakGatewayIntegrationTest.java index e72cd7fa75..58701adfc3 100644 --- a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/gateway/KeycloakGatewayIntegrationTest.java +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/gateway/KeycloakGatewayIntegrationTest.java @@ -92,23 +92,4 @@ public class KeycloakGatewayIntegrationTest extends AbstractUnitTest { }); } - @Test - public void updateUserCredentials_succeeds() throws UserNotFoundException { - - /* mock */ - keycloakUtils.createUser(USER_1_ID, USER_1_KEYCLOAK_SIGNUP_REQUEST); - - /* test */ - keycloakGateway.updateUserCredentials(keycloakUtils.getUserId(USER_1_USERNAME), USER_1_PASSWORD_DTO); - } - - @Test - public void updateUserCredentials_notFound_fails() { - - /* test */ - assertThrows(UserNotFoundException.class, () -> { - keycloakGateway.updateUserCredentials(keycloakUtils.getUserId(USER_1_USERNAME), USER_1_PASSWORD_DTO); - }); - } - } diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/handlers/ApiExceptionHandlerTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/handlers/ApiExceptionHandlerTest.java index 9075ec2a02..a58a4b49da 100644 --- a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/handlers/ApiExceptionHandlerTest.java +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/handlers/ApiExceptionHandlerTest.java @@ -1,12 +1,16 @@ package at.tuwien.handlers; +import at.tuwien.api.error.ApiErrorDto; +import at.tuwien.exception.*; import at.tuwien.test.AbstractUnitTest; import lombok.extern.log4j.Log4j2; import org.junit.jupiter.api.Assertions; 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.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.web.bind.annotation.ResponseStatus; @@ -18,12 +22,17 @@ import java.util.Optional; import static at.tuwien.test.utils.EndpointUtils.getErrorCodes; import static at.tuwien.test.utils.EndpointUtils.getExceptions; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; @Log4j2 @ExtendWith(SpringExtension.class) @SpringBootTest public class ApiExceptionHandlerTest extends AbstractUnitTest { + @Autowired + private ApiExceptionHandler apiExceptionHandler; + @Test public void handle_succeeds() throws ClassNotFoundException, IOException { final List<Method> handlers = Arrays.asList(ApiExceptionHandler.class.getMethods()); @@ -42,7 +51,903 @@ public class ApiExceptionHandlerTest extends AbstractUnitTest { Assertions.assertNotNull(exception.getDeclaredAnnotation(ResponseStatus.class).reason(), "Exception " + exception.getName() + " does not provide a reason code"); Assertions.assertTrue(errorCodes.contains(exception.getDeclaredAnnotation(ResponseStatus.class).reason()), "Exception code " + exception.getDeclaredAnnotation(ResponseStatus.class).reason() + " does have a reason code mapped in localized ui error messages"); /* handler method */ - Assertions.assertEquals(method.getDeclaredAnnotation(ResponseStatus.class).code(), exception.getDeclaredAnnotation(ResponseStatus.class).code()); + assertEquals(method.getDeclaredAnnotation(ResponseStatus.class).code(), exception.getDeclaredAnnotation(ResponseStatus.class).code()); } } + + @Test + public void handle_accessNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new AccessNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.access.missing", body.getCode()); + } + + + @Test + public void handle_accountNotSetupException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new AccountNotSetupException("msg")); + assertEquals(HttpStatus.PRECONDITION_REQUIRED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.PRECONDITION_REQUIRED, body.getStatus()); + assertEquals("error.user.setup", body.getCode()); + } + + + @Test + public void handle_analyseServiceException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new AnalyseServiceException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.analyse.invalid", body.getCode()); + } + + + @Test + public void handle_authServiceConnectionException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new AuthServiceConnectionException("msg")); + assertEquals(HttpStatus.BAD_GATEWAY, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_GATEWAY, body.getStatus()); + assertEquals("error.auth.connection", body.getCode()); + } + + + @Test + public void handle_authServiceException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new AuthServiceException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.auth.invalid", body.getCode()); + } + + + @Test + public void handle_brokerServiceConnectionException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new BrokerServiceConnectionException("msg")); + assertEquals(HttpStatus.BAD_GATEWAY, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_GATEWAY, body.getStatus()); + assertEquals("error.broker.connection", body.getCode()); + } + + + @Test + public void handle_brokerServiceException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new BrokerServiceException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.broker.invalid", body.getCode()); + } + + + @Test + public void handle_conceptNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ConceptNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.concept.missing", body.getCode()); + } + + + @Test + public void handle_containerAlreadyExistsException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ContainerAlreadyExistsException("msg")); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.CONFLICT, body.getStatus()); + assertEquals("error.container.exists", body.getCode()); + } + + + @Test + public void handle_containerNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ContainerNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.container.missing", body.getCode()); + } + + + @Test + public void handle_containerQuotaException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ContainerQuotaException("msg")); + assertEquals(HttpStatus.LOCKED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.LOCKED, body.getStatus()); + assertEquals("error.container.quota", body.getCode()); + } + + + @Test + public void handle_credentialsInvalidException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new CredentialsInvalidException("msg")); + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.FORBIDDEN, body.getStatus()); + assertEquals("error.user.credentials", body.getCode()); + } + + + @Test + public void handle_dataServiceConnectionException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new DataServiceConnectionException("msg")); + assertEquals(HttpStatus.BAD_GATEWAY, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_GATEWAY, body.getStatus()); + assertEquals("error.data.connection", body.getCode()); + } + + + @Test + public void handle_dataServiceException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new DataServiceException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.data.invalid", body.getCode()); + } + + + @Test + public void handle_databaseMalformedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new DatabaseMalformedException("msg")); + assertEquals(HttpStatus.EXPECTATION_FAILED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.EXPECTATION_FAILED, body.getStatus()); + assertEquals("error.database.invalid", body.getCode()); + } + + + @Test + public void handle_databaseNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new DatabaseNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.database.missing", body.getCode()); + } + + + @Test + public void handle_databaseUnavailableException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new DatabaseUnavailableException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.database.connection", body.getCode()); + } + + + @Test + public void handle_doiNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new DoiNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.doi.missing", body.getCode()); + } + + + @Test + public void handle_emailExistsException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new EmailExistsException("msg")); + assertEquals(HttpStatus.EXPECTATION_FAILED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.EXPECTATION_FAILED, body.getStatus()); + assertEquals("error.user.email-exists", body.getCode()); + } + + + @Test + public void handle_exchangeNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ExchangeNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.exchange.missing", body.getCode()); + } + + + @Test + public void handle_externalServiceException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ExternalServiceException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.external.invalid", body.getCode()); + } + + + @Test + public void handle_filterBadRequestException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new FilterBadRequestException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.semantic.filter", body.getCode()); + } + + + @Test + public void handle_formatNotAvailableException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new FormatNotAvailableException("msg")); + assertEquals(HttpStatus.NOT_ACCEPTABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_ACCEPTABLE, body.getStatus()); + assertEquals("error.identifier.format", body.getCode()); + } + + + @Test + public void handle_identifierNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new IdentifierNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.identifier.missing", body.getCode()); + } + + + @Test + public void handle_identifierNotSupportedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new IdentifierNotSupportedException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.identifier.unsupported", body.getCode()); + } + + + @Test + public void handle_imageAlreadyExistsException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ImageAlreadyExistsException("msg")); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.CONFLICT, body.getStatus()); + assertEquals("error.image.exists", body.getCode()); + } + + + @Test + public void handle_imageInvalidException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ImageInvalidException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.image.invalid", body.getCode()); + } + + + @Test + public void handle_imageNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ImageNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.image.missing", body.getCode()); + } + + + @Test + public void handle_licenseNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new LicenseNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.license.missing", body.getCode()); + } + + + @Test + public void handle_malformedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new MalformedException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.request.invalid", body.getCode()); + } + + + @Test + public void handle_messageNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new MessageNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.message.missing", body.getCode()); + } + + + @Test + public void handle_metadataServiceConnectionException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new MetadataServiceConnectionException("msg")); + assertEquals(HttpStatus.BAD_GATEWAY, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_GATEWAY, body.getStatus()); + assertEquals("error.metadata.connection", body.getCode()); + } + + + @Test + public void handle_metadataServiceException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new MetadataServiceException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.metadata.invalid", body.getCode()); + } + + + @Test + public void handle_notAllowedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new NotAllowedException("msg")); + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.FORBIDDEN, body.getStatus()); + assertEquals("error.request.forbidden", body.getCode()); + } + + + @Test + public void handle_ontologyNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new OntologyNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.ontology.missing", body.getCode()); + } + + + @Test + public void handle_orcidNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new OrcidNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.orcid.missing", body.getCode()); + } + + + @Test + public void handle_paginationException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new PaginationException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.request.pagination", body.getCode()); + } + + + @Test + public void handle_queryMalformedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new QueryMalformedException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.query.invalid", body.getCode()); + } + + + @Test + public void handle_queryNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new QueryNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.query.missing", body.getCode()); + } + + + @Test + public void handle_queryNotSupportedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new QueryNotSupportedException("msg")); + assertEquals(HttpStatus.NOT_IMPLEMENTED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_IMPLEMENTED, body.getStatus()); + assertEquals("error.query.invalid", body.getCode()); + } + + + @Test + public void handle_queryStoreCreateException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new QueryStoreCreateException("msg")); + assertEquals(HttpStatus.EXPECTATION_FAILED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.EXPECTATION_FAILED, body.getStatus()); + assertEquals("error.store.invalid", body.getCode()); + } + + + @Test + public void handle_queryStoreGCException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new QueryStoreGCException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.store.clean", body.getCode()); + } + + + @Test + public void handle_queryStoreInsertException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new QueryStoreInsertException("msg")); + assertEquals(HttpStatus.EXPECTATION_FAILED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.EXPECTATION_FAILED, body.getStatus()); + assertEquals("error.store.insert", body.getCode()); + } + + + @Test + public void handle_queryStorePersistException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new QueryStorePersistException("msg")); + assertEquals(HttpStatus.EXPECTATION_FAILED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.EXPECTATION_FAILED, body.getStatus()); + assertEquals("error.store.persist", body.getCode()); + } + + + @Test + public void handle_queueNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new QueueNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.queue.missing", body.getCode()); + } + + + @Test + public void handle_remoteUnavailableException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new RemoteUnavailableException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.metadata.privileged", body.getCode()); + } + + + @Test + public void handle_rorNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new RorNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.ror.missing", body.getCode()); + } + + + @Test + public void handle_searchServiceConnectionException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new SearchServiceConnectionException("msg")); + assertEquals(HttpStatus.BAD_GATEWAY, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_GATEWAY, body.getStatus()); + assertEquals("error.search.connection", body.getCode()); + } + + + @Test + public void handle_searchServiceException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new SearchServiceException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.search.invalid", body.getCode()); + } + + + @Test + public void handle_semanticEntityNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new SemanticEntityNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.semantic.missing", body.getCode()); + } + + + @Test + public void handle_sortException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new SortException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.request.sort", body.getCode()); + } + + + @Test + public void handle_storageNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new StorageNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.storage.missing", body.getCode()); + } + + + @Test + public void handle_storageUnavailableException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new StorageUnavailableException("msg")); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, body.getStatus()); + assertEquals("error.storage.invalid", body.getCode()); + } + + + @Test + public void handle_tableExistsException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new TableExistsException("msg")); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.CONFLICT, body.getStatus()); + assertEquals("error.table.exists", body.getCode()); + } + + + @Test + public void handle_tableMalformedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new TableMalformedException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.table.invalid", body.getCode()); + } + + + @Test + public void handle_tableNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new TableNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.table.missing", body.getCode()); + } + + + @Test + public void handle_tableSchemaException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new TableSchemaException("msg")); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.CONFLICT, body.getStatus()); + assertEquals("error.schema.table", body.getCode()); + } + + + @Test + public void handle_unitNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new UnitNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.unit.missing", body.getCode()); + } + + + @Test + public void handle_uriMalformedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new UriMalformedException("msg")); + assertEquals(HttpStatus.EXPECTATION_FAILED, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.EXPECTATION_FAILED, body.getStatus()); + assertEquals("error.semantics.uri", body.getCode()); + } + + + @Test + public void handle_userExistsException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new UserExistsException("msg")); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.CONFLICT, body.getStatus()); + assertEquals("error.user.exists", body.getCode()); + } + + + @Test + public void handle_userNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new UserNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.user.missing", body.getCode()); + } + + + @Test + public void handle_viewMalformedException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ViewMalformedException("msg")); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.BAD_REQUEST, body.getStatus()); + assertEquals("error.view.invalid", body.getCode()); + } + + + @Test + public void handle_viewNotFoundException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ViewNotFoundException("msg")); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.NOT_FOUND, body.getStatus()); + assertEquals("error.view.missing", body.getCode()); + } + + + @Test + public void handle_viewSchemaException_succeeds() { + + /* test */ + final ResponseEntity<ApiErrorDto> response = apiExceptionHandler.handle(new ViewSchemaException("msg")); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + final ApiErrorDto body = response.getBody(); + assertNotNull(body); + assertEquals("msg", body.getMessage()); + assertEquals(HttpStatus.CONFLICT, body.getStatus()); + assertEquals("error.schema.view", body.getCode()); + } + } diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServiceIntegrationTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServiceIntegrationTest.java new file mode 100644 index 0000000000..39aed0d28e --- /dev/null +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServiceIntegrationTest.java @@ -0,0 +1,85 @@ +package at.tuwien.service; + +import at.tuwien.entities.user.User; +import at.tuwien.exception.AuthServiceException; +import at.tuwien.exception.UserNotFoundException; +import at.tuwien.gateway.KeycloakGateway; +import at.tuwien.repository.UserRepository; +import at.tuwien.test.AbstractUnitTest; +import at.tuwien.utils.KeycloakUtils; +import dasniko.testcontainers.keycloak.KeycloakContainer; +import lombok.extern.log4j.Log4j2; +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.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.testcontainers.images.PullPolicy; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@Log4j2 +@Testcontainers +@ExtendWith(SpringExtension.class) +@SpringBootTest +public class UserServiceIntegrationTest extends AbstractUnitTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + private KeycloakUtils keycloakUtils; + + @BeforeEach + public void beforeEach() { + genesis(); + /* keycloak */ + userRepository.deleteAll(); + keycloakUtils.deleteUser(USER_1_USERNAME); + } + + @Container + private static KeycloakContainer keycloakContainer = new KeycloakContainer(KEYCLOAK_IMAGE) + .withImagePullPolicy(PullPolicy.alwaysPull()) + .withAdminUsername("admin") + .withAdminPassword("admin") + .withRealmImportFile("./init/dbrepo-realm.json") + .withEnv("KC_HOSTNAME_STRICT_HTTPS", "false"); + + @DynamicPropertySource + static void keycloakProperties(DynamicPropertyRegistry registry) { + final String authServiceEndpoint = "http://localhost:" + keycloakContainer.getMappedPort(8080); + log.trace("set auth endpoint: {}", authServiceEndpoint); + registry.add("dbrepo.endpoints.authService", () -> authServiceEndpoint); + } + + @Test + public void create_succeeds() throws UserNotFoundException, AuthServiceException { + + /* test */ + final User response = userService.create(USER_1_CREATE_USER_DTO); + assertEquals(USER_1_ID, response.getId()); + assertEquals(USER_1_KEYCLOAK_ID, response.getKeycloakId()); + assertEquals(USER_1_USERNAME, response.getUsername()); + assertEquals(USER_1_THEME, response.getTheme()); + assertNotNull(response.getMariadbPassword()); + assertEquals(USER_1_LANGUAGE, response.getLanguage()); + assertEquals(USER_1_FIRSTNAME, response.getFirstname()); + assertEquals(USER_1_LASTNAME, response.getLastname()); + assertEquals(USER_1_IS_INTERNAL, response.getIsInternal()); + } + +} diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServicePersistenceTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServicePersistenceTest.java index 514d23b227..eb228ab3c3 100644 --- a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServicePersistenceTest.java +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServicePersistenceTest.java @@ -42,7 +42,7 @@ public class UserServicePersistenceTest extends AbstractUnitTest { public void beforeEach() { genesis(); /* metadata database */ - userRepository.save(USER_1); + userRepository.saveAll(List.of(USER_1, USER_LOCAL)); } @Test @@ -54,6 +54,16 @@ public class UserServicePersistenceTest extends AbstractUnitTest { assertEquals(USER_1_USERNAME, response.getUsername()); } + @Test + public void findAllInternalUsers_succeeds() { + + /* test */ + final List<User> response = userService.findAllInternalUsers(); + assertEquals(1, response.size()); + final User user0 = response.get(0); + assertEquals(USER_LOCAL_ADMIN_ID, user0.getId()); + } + @Test public void findByUsername_fails() { @@ -68,7 +78,7 @@ public class UserServicePersistenceTest extends AbstractUnitTest { /* test */ final List<User> response = userService.findAll(); - assertEquals(1, response.size()); + assertEquals(2, response.size()); } @Test diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServiceUnitTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServiceUnitTest.java index 58d7cdc5e4..df13d00b08 100644 --- a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServiceUnitTest.java +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/service/UserServiceUnitTest.java @@ -93,12 +93,12 @@ public class UserServiceUnitTest extends AbstractUnitTest { } @Test - public void updatePassword_succeeds() throws UserNotFoundException { + public void updatePassword_succeeds() throws UserNotFoundException, AuthServiceException { /* mock */ doNothing() .when(keycloakGateway) - .updateUserCredentials(USER_1_ID, USER_1_PASSWORD_DTO); + .setupFinished(USER_1_ID); when(userRepository.findById(USER_1_ID)) .thenReturn(Optional.of(USER_1)); when(userRepository.save(any(User.class))) diff --git a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/validator/EndpointValidatorUnitTest.java b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/validator/EndpointValidatorUnitTest.java index 486db28e59..8105a7fb89 100644 --- a/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/validator/EndpointValidatorUnitTest.java +++ b/dbrepo-metadata-service/rest-service/src/test/java/at/tuwien/validator/EndpointValidatorUnitTest.java @@ -52,9 +52,27 @@ public class EndpointValidatorUnitTest extends AbstractUnitTest { public static Stream<Arguments> needSize_parameters() { return Stream.of( - Arguments.arguments(ColumnTypeDto.VARCHAR), - Arguments.arguments(ColumnTypeDto.BINARY), - Arguments.arguments(ColumnTypeDto.VARBINARY) + Arguments.arguments("varchar", ColumnTypeDto.VARCHAR), + Arguments.arguments("binary", ColumnTypeDto.BINARY), + Arguments.arguments("varbinary", ColumnTypeDto.VARBINARY) + ); + } + + public static Stream<Arguments> needSizeAndD_parameters() { + return Stream.of( + Arguments.arguments("double_size", ColumnTypeDto.DOUBLE, 40L, null), + Arguments.arguments("double_d", ColumnTypeDto.DOUBLE, null, 10L), + Arguments.arguments("decimal_size", ColumnTypeDto.DECIMAL, 40L, null), + Arguments.arguments("decimal_d", ColumnTypeDto.DECIMAL, null, 10L) + ); + } + + public static Stream<Arguments> enums_parameters() { + return Stream.of( + Arguments.arguments("enums_null", ColumnTypeDto.ENUM, null), + Arguments.arguments("enums_empty", ColumnTypeDto.ENUM, List.of()), + Arguments.arguments("sets_null", ColumnTypeDto.SET, null), + Arguments.arguments("sets_empty", ColumnTypeDto.SET, List.of()) ); } @@ -244,6 +262,20 @@ public class EndpointValidatorUnitTest extends AbstractUnitTest { endpointValidator.validateOnlyWriteOwnOrWriteAllAccess(TABLE_1, USER_1); } + @Test + public void validateOnlyWriteOwnOrWriteAllAccess_writeOwnAccess_succeeds() throws DatabaseNotFoundException, + TableNotFoundException, AccessNotFoundException, NotAllowedException { + + /* mock */ + when(tableService.findById(DATABASE_1, TABLE_1_ID)) + .thenReturn(TABLE_1); + when(accessService.find(eq(DATABASE_1), any(User.class))) + .thenReturn(DATABASE_1_USER_1_WRITE_OWN_ACCESS); + + /* test */ + endpointValidator.validateOnlyWriteOwnOrWriteAllAccess(TABLE_1, USER_1); + } + @Test public void validateOnlyWriteOwnOrWriteAllAccess_privateHasReadAccess_fails() throws DatabaseNotFoundException, TableNotFoundException, AccessNotFoundException { @@ -285,7 +317,7 @@ public class EndpointValidatorUnitTest extends AbstractUnitTest { @ParameterizedTest @MethodSource("needSize_parameters") - public void validateColumnCreateConstraints_needSize_fails(ColumnTypeDto type) { + public void validateColumnCreateConstraints_needSize_fails(String name, ColumnTypeDto type) { final CreateTableDto request = CreateTableDto.builder() .columns(List.of(CreateTableColumnDto.builder() .type(type) @@ -299,12 +331,13 @@ public class EndpointValidatorUnitTest extends AbstractUnitTest { }); } - @Test - public void validateColumnCreateConstraints_needEnum_fails() { + @ParameterizedTest + @MethodSource("enums_parameters") + public void validateColumnCreateConstraints_needEnum_fails(String name, ColumnTypeDto type, List<String> enums) { final CreateTableDto request = CreateTableDto.builder() .columns(List.of(CreateTableColumnDto.builder() - .type(ColumnTypeDto.ENUM) - .enums(null) // <<<<<<< + .type(type) + .enums(enums) .build())) .build(); @@ -314,12 +347,14 @@ public class EndpointValidatorUnitTest extends AbstractUnitTest { }); } - @Test - public void validateColumnCreateConstraints_needSet_fails() { + @ParameterizedTest + @MethodSource("needSizeAndD_parameters") + public void validateColumnCreateConstraints_needSizeAndD_fails(String name, ColumnTypeDto type, Long size, Long d) { final CreateTableDto request = CreateTableDto.builder() .columns(List.of(CreateTableColumnDto.builder() - .type(ColumnTypeDto.SET) - .sets(null) // <<<<<<< + .type(type) + .size(size) + .d(d) .build())) .build(); @@ -345,6 +380,34 @@ public class EndpointValidatorUnitTest extends AbstractUnitTest { }); } + @Test + public void validateOnlyOwnerOrWriteAll_writeOwnAccess_succeeds() throws DatabaseNotFoundException, + TableNotFoundException, AccessNotFoundException, NotAllowedException { + + /* mock */ + when(tableService.findById(DATABASE_1, TABLE_1_ID)) + .thenReturn(TABLE_1); + when(accessService.find(DATABASE_1, USER_1)) + .thenReturn(DATABASE_1_USER_1_WRITE_OWN_ACCESS); + + /* test */ + endpointValidator.validateOnlyOwnerOrWriteAll(TABLE_1, USER_1); + } + + @Test + public void validateOnlyOwnerOrWriteAll_writeAllAccess_succeeds() throws DatabaseNotFoundException, + TableNotFoundException, AccessNotFoundException, NotAllowedException { + + /* mock */ + when(tableService.findById(DATABASE_1, TABLE_1_ID)) + .thenReturn(TABLE_1); + when(accessService.find(DATABASE_1, USER_2)) + .thenReturn(DATABASE_1_USER_2_WRITE_ALL_ACCESS); + + /* test */ + endpointValidator.validateOnlyOwnerOrWriteAll(TABLE_1, USER_2); + } + @Test public void validateOnlyPrivateDataHasRole_publicDatabase_succeeds() throws NotAllowedException { @@ -555,6 +618,13 @@ public class EndpointValidatorUnitTest extends AbstractUnitTest { assertTrue(endpointValidator.validateOnlyMineOrWriteAccessOrHasRole(USER_1, USER_1_PRINCIPAL, DATABASE_1_USER_1_WRITE_OWN_ACCESS, "nobody-role")); } + @Test + public void validateOnlyMineOrWriteAccessOrHasRole_ownerOnlyWriteAll_succeeds() { + + /* test */ + assertTrue(endpointValidator.validateOnlyMineOrWriteAccessOrHasRole(USER_1, USER_1_PRINCIPAL, DATABASE_1_USER_1_WRITE_ALL_ACCESS, "nobody-role")); + } + @Test public void validateOnlyMineOrWriteAccessOrHasRole_notOwnerOnlyWriteOwn_fails() { diff --git a/dbrepo-metadata-service/services/src/main/java/at/tuwien/gateway/KeycloakGateway.java b/dbrepo-metadata-service/services/src/main/java/at/tuwien/gateway/KeycloakGateway.java index cd5fd08a7e..296457b3d8 100644 --- a/dbrepo-metadata-service/services/src/main/java/at/tuwien/gateway/KeycloakGateway.java +++ b/dbrepo-metadata-service/services/src/main/java/at/tuwien/gateway/KeycloakGateway.java @@ -1,7 +1,6 @@ package at.tuwien.gateway; import at.tuwien.api.keycloak.TokenDto; -import at.tuwien.api.user.UserPasswordDto; import at.tuwien.api.user.UserUpdateDto; import at.tuwien.exception.AuthServiceException; import at.tuwien.exception.UserNotFoundException; @@ -22,13 +21,7 @@ public interface KeycloakGateway { */ void deleteUser(UUID id) throws UserNotFoundException; - /** - * Update the credentials for a given user. - * - * @param id The user id. - * @param password The user credential. - */ - void updateUserCredentials(UUID id, UserPasswordDto password) throws UserNotFoundException; + void setupFinished(UUID id) throws AuthServiceException, UserNotFoundException; void updateUser(UUID id, UserUpdateDto data) throws AuthServiceException, UserNotFoundException; } diff --git a/dbrepo-metadata-service/services/src/main/java/at/tuwien/gateway/impl/KeycloakGatewayImpl.java b/dbrepo-metadata-service/services/src/main/java/at/tuwien/gateway/impl/KeycloakGatewayImpl.java index af54651d6c..270653ee8f 100644 --- a/dbrepo-metadata-service/services/src/main/java/at/tuwien/gateway/impl/KeycloakGatewayImpl.java +++ b/dbrepo-metadata-service/services/src/main/java/at/tuwien/gateway/impl/KeycloakGatewayImpl.java @@ -1,14 +1,12 @@ package at.tuwien.gateway.impl; import at.tuwien.api.keycloak.TokenDto; -import at.tuwien.api.user.UserPasswordDto; import at.tuwien.api.user.UserUpdateDto; import at.tuwien.config.KeycloakConfig; import at.tuwien.exception.AuthServiceException; import at.tuwien.exception.UserNotFoundException; import at.tuwien.gateway.KeycloakGateway; import at.tuwien.mapper.MetadataMapper; -import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; @@ -17,7 +15,6 @@ import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; import org.keycloak.admin.client.resource.UserResource; -import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.springframework.stereotype.Service; @@ -80,21 +77,25 @@ public class KeycloakGatewayImpl implements KeycloakGateway { } @Override - public void updateUserCredentials(UUID id, UserPasswordDto data) throws UserNotFoundException { - final CredentialRepresentation credential = new CredentialRepresentation(); - credential.setTemporary(false); - credential.setValue(data.getPassword()); - credential.setType(CredentialRepresentation.PASSWORD); + public void setupFinished(UUID id) throws AuthServiceException, UserNotFoundException { + final UserResource resource = keycloak.realm(keycloakConfig.getRealm()) + .users() + .get(String.valueOf(id)); + final UserRepresentation user; try { - keycloak.realm(keycloakConfig.getRealm()) - .users() - .get(String.valueOf(id)) - .resetPassword(credential); + user = resource.toRepresentation(); } catch (NotFoundException e) { - log.error("Failed to update user password: not found"); - throw new UserNotFoundException("Failed to update user password: not found", e); + log.error("Failed to update user setup: not found: {}", e.getMessage()); + throw new UserNotFoundException("Failed to update user setup: not found", e); + } + user.singleAttribute("SETUP_FINISHED", "true"); + try { + resource.update(user); + } catch (ForbiddenException e) { + log.error("Failed to update user setup: forbidden: {}", e.getMessage()); + throw new AuthServiceException("Failed to update user setup: forbidden", e); } - log.info("Updated user {} password at auth service", id); + log.info("Updated user {} setup at auth service", id); } @Override @@ -102,7 +103,7 @@ public class KeycloakGatewayImpl implements KeycloakGateway { final UserResource resource = keycloak.realm(keycloakConfig.getRealm()) .users() .get(String.valueOf(id)); - UserRepresentation user; + final UserRepresentation user; try { user = resource.toRepresentation(); } catch (NotFoundException e) { diff --git a/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/AuthenticationService.java b/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/AuthenticationService.java index 75b647bf95..3abe07a10a 100644 --- a/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/AuthenticationService.java +++ b/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/AuthenticationService.java @@ -1,6 +1,5 @@ package at.tuwien.service; -import at.tuwien.api.user.UserPasswordDto; import at.tuwien.entities.user.User; import at.tuwien.exception.AuthServiceConnectionException; import at.tuwien.exception.AuthServiceException; @@ -23,8 +22,7 @@ public interface AuthenticationService { * Updates the password of a user with given id. * * @param user The user. - * @param data The new password. * @throws UserNotFoundException The user was not found after creation in the auth database. */ - void updatePassword(User user, UserPasswordDto data) throws UserNotFoundException; + void setupFinished(User user) throws UserNotFoundException, AuthServiceException; } diff --git a/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/UserService.java b/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/UserService.java index 581641a93a..c6ca0ff21e 100644 --- a/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/UserService.java +++ b/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/UserService.java @@ -4,7 +4,6 @@ import at.tuwien.api.auth.CreateUserDto; import at.tuwien.api.user.UserPasswordDto; import at.tuwien.api.user.UserUpdateDto; import at.tuwien.entities.user.User; -import at.tuwien.exception.AuthServiceConnectionException; import at.tuwien.exception.AuthServiceException; import at.tuwien.exception.UserExistsException; import at.tuwien.exception.UserNotFoundException; @@ -47,7 +46,7 @@ public interface UserService { * @param data The user data. * @return The user, if successful. */ - User create(CreateUserDto data) throws UserNotFoundException, AuthServiceException; + User create(CreateUserDto data); /** * Updates the user information for a user with given id in the metadata database. diff --git a/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/impl/AuthenticationServiceImpl.java b/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/impl/AuthenticationServiceImpl.java index 1159913039..dec3577886 100644 --- a/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/impl/AuthenticationServiceImpl.java +++ b/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/impl/AuthenticationServiceImpl.java @@ -1,10 +1,7 @@ package at.tuwien.service.impl; -import at.tuwien.api.user.UserPasswordDto; import at.tuwien.entities.user.User; -import at.tuwien.exception.AuthServiceConnectionException; import at.tuwien.exception.AuthServiceException; -import at.tuwien.exception.CredentialsInvalidException; import at.tuwien.exception.UserNotFoundException; import at.tuwien.gateway.KeycloakGateway; import at.tuwien.service.AuthenticationService; @@ -29,8 +26,8 @@ public class AuthenticationServiceImpl implements AuthenticationService { } @Override - public void updatePassword(User user, UserPasswordDto data) throws UserNotFoundException { - keycloakGateway.updateUserCredentials(user.getKeycloakId(), data); + public void setupFinished(User user) throws AuthServiceException, UserNotFoundException { + keycloakGateway.setupFinished(user.getKeycloakId()); } } diff --git a/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/impl/UserServiceImpl.java b/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/impl/UserServiceImpl.java index 1d582bb975..e79dd9bd84 100644 --- a/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/impl/UserServiceImpl.java +++ b/dbrepo-metadata-service/services/src/main/java/at/tuwien/service/impl/UserServiceImpl.java @@ -65,14 +65,14 @@ public class UserServiceImpl implements UserService { } @Override - public User create(CreateUserDto data) throws UserNotFoundException, AuthServiceException { + public User create(CreateUserDto data) { /* create at authentication service */ final User entity = User.builder() .id(data.getLdapId()) .keycloakId(data.getId()) .username(data.getUsername()) .theme("light") - .mariadbPassword(getMariaDbPassword(RandomStringUtils.randomAlphabetic(10))) /* user needs to set it later to access */ + .mariadbPassword(getMariaDbPassword(RandomStringUtils.randomAlphabetic(10))) .language("en") .firstname(data.getGivenName()) .lastname(data.getFamilyName()) diff --git a/dbrepo-metadata-service/test/src/main/java/at/tuwien/test/BaseTest.java b/dbrepo-metadata-service/test/src/main/java/at/tuwien/test/BaseTest.java index bccf903b8b..860eeb8253 100644 --- a/dbrepo-metadata-service/test/src/main/java/at/tuwien/test/BaseTest.java +++ b/dbrepo-metadata-service/test/src/main/java/at/tuwien/test/BaseTest.java @@ -5,6 +5,7 @@ import at.tuwien.api.amqp.CreateVirtualHostDto; import at.tuwien.api.amqp.ExchangeDto; import at.tuwien.api.amqp.GrantVirtualHostPermissionsDto; import at.tuwien.api.amqp.QueueDto; +import at.tuwien.api.auth.CreateUserDto; import at.tuwien.api.auth.LoginRequestDto; import at.tuwien.api.auth.RefreshTokenRequestDto; import at.tuwien.api.container.ContainerBriefDto; @@ -478,8 +479,6 @@ public abstract class BaseTest { @SuppressWarnings("java:S2068") public final static String USER_1_PASSWORD = "junit1"; @SuppressWarnings("java:S2068") - public final static String USER_1_PASSWORD_ENCODED = "$2a$10$0dtdedA/RLTrFbUsvpbUw.I73AXOKeQP3t5UXj96OvnDEaDb3d3M6"; - @SuppressWarnings("java:S2068") public final static String USER_1_DATABASE_PASSWORD = "*440BA4FD1A87A0999647DB67C0EE258198B247BA" /* junit1 */; public final static String USER_1_FIRSTNAME = "John"; public final static String USER_1_LASTNAME = "Doe"; @@ -487,18 +486,11 @@ public abstract class BaseTest { public final static String USER_1_NAME = "John Doe"; public final static String USER_1_AFFILIATION = "TU Graz"; public final static String USER_1_ORCID_URL = "https://orcid.org/0000-0003-4216-302X"; - public final static String USER_1_TITLES_BEFORE = "Dr."; - public final static String USER_1_TITLES_AFTER = "MSc BSc"; - public final static Boolean USER_1_VERIFIED = false; - public final static Boolean USER_1_TOTP = false; - public final static Long USER_1_NOT_BEFORE = 0L; public final static Boolean USER_1_ENABLED = true; public final static Boolean USER_1_IS_INTERNAL = false; public final static String USER_1_THEME = "light"; public final static String USER_1_LANGUAGE = "en"; public final static Instant USER_1_CREATED = Instant.ofEpochSecond(1677399441L) /* 2023-02-26 08:17:21 (UTC) */; - public final static Instant USER_1_LAST_MODIFIED = USER_1_CREATED; - public final static UUID USER_1_REALM_ID = REALM_DBREPO_ID; public final static UpdateUserPasswordDto USER_1_UPDATE_PASSWORD_DTO = UpdateUserPasswordDto.builder() .username(USER_1_USERNAME) @@ -568,6 +560,14 @@ public abstract class BaseTest { .qualifiedName(USER_1_QUALIFIED_NAME) .build(); + public final static CreateUserDto USER_1_CREATE_USER_DTO = CreateUserDto.builder() + .id(USER_1_KEYCLOAK_ID) + .ldapId(USER_1_ID) + .givenName(USER_1_FIRSTNAME) + .familyName(USER_1_LASTNAME) + .username(USER_1_USERNAME) + .build(); + public final static UserUpdateDto USER_1_UPDATE_DTO = UserUpdateDto.builder() .firstname(USER_1_FIRSTNAME) .lastname(USER_1_LASTNAME) @@ -608,7 +608,6 @@ public abstract class BaseTest { public final static UUID USER_2_ID = UUID.fromString("eeb9a51b-4cd8-4039-90bf-e24f17372f7c"); public final static UUID USER_2_KEYCLOAK_ID = UUID.fromString("eeb9a51b-4cd8-4039-90bf-e24f17372f7c"); - public final static String USER_2_EMAIL = "jane.doe@example.com"; public final static String USER_2_USERNAME = "junit2"; public final static String USER_2_FIRSTNAME = "Jane"; public final static String USER_2_LASTNAME = "Doe"; @@ -620,16 +619,9 @@ public abstract class BaseTest { @SuppressWarnings("java:S2068") public final static String USER_2_DATABASE_PASSWORD = "*9AA70A8B0EEFAFCB5BED5BDEF6EE264D5DA915AE" /* junit2 */; public final static String USER_2_QUALIFIED_NAME = USER_2_FIRSTNAME + " " + USER_2_LASTNAME + " — @" + USER_2_USERNAME; - public final static Boolean USER_2_VERIFIED = true; - public final static Boolean USER_2_TOTP = false; - public final static Long USER_2_NOT_BEFORE = 0L; - public final static Boolean USER_2_ENABLED = true; public final static Boolean USER_2_IS_INTERNAL = false; public final static String USER_2_THEME = "light"; public final static String USER_2_LANGUAGE = "de"; - public final static Instant USER_2_CREATED = Instant.ofEpochSecond(1677399528L) /* 2023-02-26 08:18:48 (UTC) */; - public final static Instant USER_2_LAST_MODIFIED = USER_1_CREATED; - public final static UUID USER_2_REALM_ID = REALM_DBREPO_ID; public final static UserAttributesDto USER_2_ATTRIBUTES_DTO = UserAttributesDto.builder() .theme(USER_2_THEME) @@ -697,20 +689,13 @@ public abstract class BaseTest { public final static String USER_3_AFFILIATION = "TU Wien"; public final static String USER_3_ORCID_URL = null; public final static String USER_3_ORCID_UNCOMPRESSED = null; - public final static String USER_3_EMAIL = "system@example.com"; @SuppressWarnings("java:S2068") public final static String USER_3_PASSWORD = "password"; @SuppressWarnings("java:S2068") public final static String USER_3_DATABASE_PASSWORD = "*D65FCA043964B63E849DD6334699ECB065905DA4" /* junit3 */; public final static String USER_3_QUALIFIED_NAME = USER_3_FIRSTNAME + " " + USER_3_LASTNAME + " — @" + USER_3_USERNAME; - public final static Boolean USER_3_VERIFIED = true; - public final static Boolean USER_3_TOTP = false; - public final static Long USER_3_NOT_BEFORE = 0L; - public final static Boolean USER_3_ENABLED = true; public final static Boolean USER_3_IS_INTERNAL = false; public final static String USER_3_THEME = "light"; - public final static Instant USER_3_CREATED = Instant.ofEpochSecond(1677399559L) /* 2023-02-26 08:19:19 (UTC) */; - public final static UUID USER_3_REALM_ID = REALM_DBREPO_ID; public final static UserAttributesDto USER_3_ATTRIBUTES_DTO = UserAttributesDto.builder() .theme(USER_3_THEME) @@ -779,12 +764,8 @@ public abstract class BaseTest { @SuppressWarnings("java:S2068") public final static String USER_4_DATABASE_PASSWORD = "*C20EF5C6875857DEFA9BE6E9B62DD76AAAE51882" /* junit4 */; public final static String USER_4_QUALIFIED_NAME = USER_4_FIRSTNAME + " " + USER_4_LASTNAME + " — @" + USER_4_USERNAME; - public final static Boolean USER_4_VERIFIED = true; - public final static Boolean USER_4_ENABLED = true; public final static Boolean USER_4_IS_INTERNAL = false; public final static String USER_4_THEME = "light"; - public final static Instant USER_4_CREATED = Instant.ofEpochSecond(1677399592L) /* 2023-02-26 08:19:52 (UTC) */; - public final static UUID USER_4_REALM_ID = REALM_DBREPO_ID; public final static UserAttributesDto USER_4_ATTRIBUTES_DTO = UserAttributesDto.builder() .theme(USER_4_THEME) @@ -842,18 +823,13 @@ public abstract class BaseTest { public final static String USER_5_LASTNAME = "Body"; public final static String USER_5_NAME = "No Body"; public final static String USER_5_AFFILIATION = "TU Wien"; - public final static String USER_5_ORCID = null; @SuppressWarnings("java:S2068") public final static String USER_5_PASSWORD = "junit5"; @SuppressWarnings("java:S2068") public final static String USER_5_DATABASE_PASSWORD = "*C20EF5C6875857DEFA9BE6E9B62DD76AAAE51882" /* junit5 */; public final static String USER_5_QUALIFIED_NAME = USER_5_FIRSTNAME + " " + USER_5_LASTNAME + " — @" + USER_5_USERNAME; - public final static Boolean USER_5_VERIFIED = true; - public final static Boolean USER_5_ENABLED = true; public final static Boolean USER_5_IS_INTERNAL = false; public final static String USER_5_THEME = "dark"; - public final static Instant USER_5_CREATED = Instant.ofEpochSecond(1677399592L) /* 2023-02-26 08:19:52 (UTC) */; - public final static UUID USER_5_REALM_ID = REALM_DBREPO_ID; public final static UserAttributesDto USER_5_ATTRIBUTES_DTO = UserAttributesDto.builder() .theme(USER_5_THEME) @@ -7680,6 +7656,21 @@ public abstract class BaseTest { .user(USER_3_BRIEF_DTO) .build(); + public final static DatabaseAccess DATABASE_1_USER_4_READ_ACCESS = DatabaseAccess.builder() + .type(AccessType.READ) + .hdbid(DATABASE_1_ID) + .database(DATABASE_1) + .huserid(USER_4_ID) + .user(USER_4) + .build(); + + public final static DatabaseAccessDto DATABASE_1_USER_4_READ_ACCESS_DTO = DatabaseAccessDto.builder() + .type(AccessTypeDto.READ) + .hdbid(DATABASE_1_ID) + .huserid(USER_4_ID) + .user(USER_4_BRIEF_DTO) + .build(); + public final static Database DATABASE_2 = Database.builder() .id(DATABASE_2_ID) .created(DATABASE_2_CREATED) diff --git a/dbrepo-ui/composables/authentication-service.ts b/dbrepo-ui/composables/authentication-service.ts deleted file mode 100644 index 39f6cc5a3f..0000000000 --- a/dbrepo-ui/composables/authentication-service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {jwtDecode} from 'jwt-decode' - -export const useAuthenticationService = (): any => { - - function isExpiredToken(token: string): boolean { - if (!token) { - return false - } - return tokenToExpiryDate(token) < Date.now() - } - - function tokenToExpiryDate(token: string): number { - if (!token) { - return -1 - } - const exp: number = jwtDecode<Token>(token).exp - if (exp) { - return exp * 1000 - } - return -1 - } - - return {isExpiredToken, tokenToExpiryDate} -} diff --git a/dbrepo-ui/composables/axios-instance.ts b/dbrepo-ui/composables/axios-instance.ts index ca7a7b111c..cd3737f2bf 100644 --- a/dbrepo-ui/composables/axios-instance.ts +++ b/dbrepo-ui/composables/axios-instance.ts @@ -16,7 +16,7 @@ export const useAxiosInstance = () => { baseURL: config.public.api.client }); instance.interceptors.request.use((config) => { - const { loggedIn, user, login, logout } = useOidcAuth() + const { loggedIn, user } = useOidcAuth() if (!loggedIn) { return config } diff --git a/dbrepo-ui/composables/user-service.ts b/dbrepo-ui/composables/user-service.ts index 3425dbaa5c..b90ee34033 100644 --- a/dbrepo-ui/composables/user-service.ts +++ b/dbrepo-ui/composables/user-service.ts @@ -1,5 +1,3 @@ -import {jwtDecode} from 'jwt-decode' -import axios from 'axios' import {axiosErrorToApiError} from '@/utils' export const useUserService = (): any => { @@ -80,36 +78,6 @@ export const useUserService = (): any => { }) } - async function refreshToken(refreshToken: string): Promise<KeycloakOpenIdTokenDto> { - console.debug('refresh user token') - return new Promise<KeycloakOpenIdTokenDto>((resolve, reject) => { - const config = useRuntimeConfig() - const instance = axios.create({ - timeout: 90_000, - params: {}, - baseURL: config.public.api.client - }) - instance.put<KeycloakOpenIdTokenDto>('/api/user/token', {refresh_token: refreshToken}) - .then((response) => { - console.info('Refreshed user token') - const userStore = useUserStore() - // eslint-disable-next-line camelcase - const {access_token, refresh_token} = response.data - userStore.setToken(access_token) - userStore.setRefreshToken(refresh_token) - resolve(response.data) - }).catch((error) => { - console.error('Failed to refresh user token', error) - reject(axiosErrorToApiError(error)) - }) - }) - } - - function tokenToRoles(token: string): string[] { - const data: Token = jwtDecode<Token>(token) - return data.realm_access.roles || [] - } - function nameIdentifierToNameIdentifierScheme(nameIdentifier: string) { if (nameIdentifier.includes('orcid.org')) { return 'ORCID' diff --git a/dbrepo-ui/layouts/default.vue b/dbrepo-ui/layouts/default.vue index a26c6d2539..85d530e74d 100644 --- a/dbrepo-ui/layouts/default.vue +++ b/dbrepo-ui/layouts/default.vue @@ -141,12 +141,30 @@ </v-form> <v-main> <v-container> - <slot /> + <div + v-cloak> + <v-alert + v-if="isNotFinishedAccountSetup" + border="start" + color="info" + class="mb-4"> + {{ $t('pages.settings.subpages.authentication.setup.text') }} + <v-btn + variant="flat" + size="small" + to="/user/authentication"> + {{ $t('pages.settings.subpages.authentication.setup.action') }} + </v-btn> + . + </v-alert> + </div> <JumboBox v-if="error" :title="$t(errorCodeKey(error).title, { resource })" :subtitle="$t(errorCodeKey(error).subtitle)" :text="$t(errorCodeKey(error).text, { resource })" /> + <slot + v-else /> </v-container> </v-main> </v-app> @@ -251,6 +269,15 @@ export default { commitShort () { return this.$config.public.commit.substr(0, 8) }, + isNotFinishedAccountSetup () { + if (!this.cacheUser) { + return false + } + if (!('setup_finished' in this.cacheUser)) { + return true + } + return this.cacheUser.setup_finished === false + }, error () { if (this.identifier) { return null diff --git a/dbrepo-ui/locales/en-US.json b/dbrepo-ui/locales/en-US.json index 07ac0163ef..d17da2341d 100644 --- a/dbrepo-ui/locales/en-US.json +++ b/dbrepo-ui/locales/en-US.json @@ -794,11 +794,11 @@ }, "firstname": { "label": "Given Name", - "hint": "" + "hint": "Managed by your identity provider: {provider}" }, "lastname": { "label": "Family Name", - "hint": "" + "hint": "Managed by your identity provider: {provider}" }, "affiliation": { "label": "Affiliation Identifier", @@ -834,8 +834,12 @@ "settings": { "subpages": { "authentication": { - "title": "User Password", - "subtitle": "Update the user password used for basic authentication with all interfaces", + "title": "API Password", + "subtitle": "Update the user password used for authentication with all interfaces (e.g. HTTP API, AMQP API, MQTT API)", + "setup": { + "text": "Finish your account setup by setting the", + "action": "API Password" + }, "password": { "label": "Password", "hint": "Required" diff --git a/dbrepo-ui/pages/user/authentication.vue b/dbrepo-ui/pages/user/authentication.vue index 50008d3c5d..912c1878c4 100644 --- a/dbrepo-ui/pages/user/authentication.vue +++ b/dbrepo-ui/pages/user/authentication.vue @@ -61,7 +61,7 @@ </template> <script setup> -const { loggedIn } = useOidcAuth() +const { loggedIn, user } = useOidcAuth() </script> <script> import UserToolbar from '@/components/user/UserToolbar.vue' @@ -113,11 +113,20 @@ export default { const userService = useUserService() userService.updatePassword(this.cacheUser.uid, {'password': this.password}) .then(() => { + const user = Object.assign({}, this.cacheUser) + user.setup_finished = true + this.cacheStore.setUser(user) + // fixme [mweise]: currently nuxt-oidc-auth cannot refresh the session correctly const toast = useToastInstance() toast.success(this.$t('success.user.password')) this.loadingUpdate = false }) - .catch(() => { + .catch(({code, message}) => { + const toast = useToastInstance() + if (typeof code !== 'string') { + return + } + toast.error(message) this.loadingUpdate = false }) .finally(() => { diff --git a/dbrepo-ui/pages/user/info.vue b/dbrepo-ui/pages/user/info.vue index 8674c57e2d..9c8dbf873d 100644 --- a/dbrepo-ui/pages/user/info.vue +++ b/dbrepo-ui/pages/user/info.vue @@ -74,24 +74,24 @@ <v-col md="6"> <v-text-field v-model="model.firstname" - :disabled="!canModifyInformation" + :disabled="!canModifyInformation || identityProvider" clearable persistent-hint :variant="inputVariant" :label="$t('pages.user.subpages.info.firstname.label')" - :hint="$t('pages.user.subpages.info.firstname.hint')" /> + :hint="identityProvider ? $t('pages.user.subpages.info.firstname.hint', { provider: identityProvider }) : ''" /> </v-col> </v-row> <v-row dense> <v-col md="6"> <v-text-field v-model="model.lastname" - :disabled="!canModifyInformation" + :disabled="!canModifyInformation || identityProvider" clearable persistent-hint :variant="inputVariant" :label="$t('pages.user.subpages.info.lastname.label')" - :hint="$t('pages.user.subpages.info.lastname.hint')" /> + :hint="identityProvider ? $t('pages.user.subpages.info.lastname.hint', { provider: identityProvider }) : ''" /> </v-col> </v-row> <v-row dense> @@ -191,6 +191,12 @@ export default { cacheUser () { return this.cacheStore.getUser }, + identityProvider () { + if (!this.cacheUser || !('identity_provider' in this.cacheUser)) { + return false + } + return this.cacheUser.identity_provider + }, canModifyInformation () { if (!this.roles) { return false diff --git a/docker-compose.yml b/docker-compose.yml index 9176f6404a..94b5fc30a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -96,7 +96,7 @@ services: KEYCLOAK_DATABASE_NAME: "${AUTH_DB_NAME:-keycloak}" KEYCLOAK_DATABASE_USER: "${AUTH_DB_USERNAME:-keycloak}" KEYCLOAK_DATABASE_PASSWORD: "${AUTH_DB_PASSWORD:-dbrepo}" - METADATA_SERVICE_ENDPOINT: "${METADATA_SERVICE_ENDPOINT:-http://metadata-service:8080}/api/user" + METADATA_SERVICE_ENDPOINT: "${METADATA_SERVICE_ENDPOINT:-http://metadata-service:8080}" SYSTEM_USERNAME: "${SYSTEM_USERNAME:-admin}" SYSTEM_PASSWORD: "${SYSTEM_PASSWORD:-admin}" healthcheck: diff --git a/helm/dbrepo/files/create-event-listener.jar b/helm/dbrepo/files/create-event-listener.jar index a23243d39509ec3821219e5799a25740c93e2ca1..a970096eeaa5015d9e95064f6b341fffdf8aae5f 100644 GIT binary patch delta 6843 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 T<fSgnST{LMU5!mh8Dtm$g_hbd delta 6774 zcmez8KgnM@z?+#xgn@yBgW-jkPt^OFECMBr3=C(O7#Kt*+bWCK_XZv8yJaA-*Zq|J z5B8kRiYgPL7q*KzYCCQ(uQt*Y>ui4GsZk#<oDmSXbvOI%<ny2RJU73mm96#VNd2PE z%eJys3r@C(vJ7cajgRBn7HS*y?c~9OUtZh@SrUF^lX2<E>rW@X_vd&RCFg$qL<pNm ztJk0Qg;LyMS?$ZGt?uKyv$*~>?*rqaCw^_0&U;30TbJ-U^|kF~@ok4E)dq^V$IlM# z<&CPE|DN*~|H2u6)&D!5yMA|B0{f!L`b%u)pFb257f^1N(aN;YLn_+x>B&-uxz76e zpHgI47jNFMCUm{qzVj=3H$8rMzmFw*e|6nkhl3%(X)ibYUpDK*o!*Ui-zVBXGOib_ zIx%_9{f*TbKUQrOYi!%ozU)V8hD1=?&kLE1xyt<&jk`jR8wx0DCjQ`-`Mto>X3q6v z!cisL4_UuC8~ZG0*NM-bMr(sksDIW}xyfV_V&y9wky5qptIy)7JMZ@H`t|Tmw3Lda z%S{&ki?&;MoSGKwo|cdz^Oq46D_jf=91JX=Xqmj8QHA-rn9t;EjJjZ&jY$qnt23!G zzYz18?9ZeHrfZl?!1QJ&FOd8s=CdGb@&)E@5WP8$WeyYXb1|Q&fQh$$9N=JJ=$p*W zu2?U8T(b6Yc53Cpz5_Rknisd6W_Oz);FR0<W5U6;HAjS&wQVcWUHg_ba`l^aJ_|fA zo8{ixvc=@3Yt=>LFMf(4WpCA%i2HiH|7|yE-pks*$Jc*3d#atsNYmcf*!uIc+4=X% zd-eX5zcOUF{ZU?@D`3R}4_8Ak7A?N4$593gRo$ER);qI*ouM4C>8yv;Iljh2yM8h9 z+c5om7qDgPV$)ky3PP{sUu5yia(pfM^0n-%%ia%*d>U6JaZb~lAZD3l{YzqL?~A^9 zGU5@@;^!hub(AeH1=T+d(bOvFWt!iW8sXPDOSt4xJFl%|j{k<Gb*-GmE(_FhqLdFd zJ1@&+w=_Jo_h7w2_1&zG39ScCxt8Yft=YJDMO(aMZ`bpOQH`&TrUd@qVK+riQY>P+ z`~MG5-)z0YUlG{-T7)Gere9ht=uw_f0FSlFw4%NpJnw@yL_KJ$3<+0S*q_lEmaSrY z?04I{j+dIp4JOO25q-g<dYt#P	>gcBDz(3UK1rsk+*b$UbRVz3_%o^@ONT-qYFh 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 X3>z637=%&G5Sn~RU5#y-5=amLiEW)w -- GitLab