From 078256969fe0b19d230c0b5c444faeee380f3960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 5 Mar 2026 09:12:12 +0100 Subject: [PATCH 1/6] feature/Add endpoint getUserByProviderAndUsername v6.0.0 --- .../SwaggerDefinitionsJSON.scala | 16 ++ .../scala/code/api/v6_0_0/APIMethods600.scala | 62 ++++++++ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 45 ++++++ .../GetUserByProviderAndUsernameTest.scala | 149 ++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 0591bd6a1e..598b65f913 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -2649,6 +2649,22 @@ object SwaggerDefinitionsJSON { users = List(userInfoJsonV600) ) + lazy val userWithNamesJsonV600 = UserWithNamesJsonV600( + user_id = ExampleValue.userIdExample.value, + email = ExampleValue.emailExample.value, + provider_id = providerIdValueExample.value, + provider = providerValueExample.value, + username = usernameExample.value, + firstname = ExampleValue.firstNameExample.value, + lastname = ExampleValue.lastNameExample.value, + entitlements = entitlementJSONs, + views = Some(viewsJSON300), + agreements = Some(List(userAgreementJson)), + is_deleted = false, + last_marketing_agreement_signed_date = Some(DateWithDayExampleObject), + is_locked = false + ) + lazy val migrationScriptLogJsonV600 = MigrationScriptLogJsonV600( migration_script_log_id = "550e8400-e29b-41d4-a716-446655440000", name = "addUniqueIndexOnResourceUserUserId", diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 2daffc7682..a0050992b6 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -71,6 +71,8 @@ import code.api.util.ExampleValue import code.api.util.ExampleValue.dynamicEntityResponseBodyExample import net.liftweb.common.Box +import java.net.URLDecoder +import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import java.util.UUID.randomUUID import scala.collection.immutable.{List, Nil} @@ -8713,6 +8715,66 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getUserByProviderAndUsername, + implementedInApiVersion, + nameOf(getUserByProviderAndUsername), + "GET", + "/users/provider/PROVIDER/username/USERNAME", + "Get User by USERNAME", + s"""Get user by PROVIDER and USERNAME + | + |Get a User by their authentication provider and username. + | + |**URL Parameters:** + | + |* PROVIDER - The authentication provider (e.g., http://127.0.0.1:8080, google.com, OBP) + |* USERNAME - The username at that provider (e.g., obpstripe, john.doe) + | + |**Important:** The PROVIDER parameter can contain special characters like slashes and colons. + |For example, if the provider is "http://127.0.0.1:8080", the full URL would be: + | + |`GET /obp/v6.0.0/users/provider/http://127.0.0.1:8080/username/obpstripe` + | + |The API will correctly parse the provider value even with these special characters. + | + |${userAuthenticationMessage(true)} + | + |CanGetAnyUser entitlement is required. + | + """.stripMargin, + EmptyBody, + userWithNamesJsonV600, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByProviderAndUsername, UnknownError), + List(apiTagUser), + Some(List(canGetAnyUser)) + ) + + lazy val : OBPEndpoint = { + case "users" :: "provider" :: provider :: "username" :: username :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + user <- Users.users.vend.getUserByProviderAndUsernameFuture(URLDecoder.decode(provider, StandardCharsets.UTF_8), username) map { + x => unboxFullOrFail(x, cc.callContext, UserNotFoundByProviderAndUsername, 404) + } + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, cc.callContext) + isLocked = LoginAttempt.userIsLocked(user.provider, user.name) + authUser = code.model.dataAccess.AuthUser.find( + By(code.model.dataAccess.AuthUser.user, user.userPrimaryKey.value) + ) + } yield { + (JSONFactory600.createUserWithNamesJSON( + user, + authUser.map(_.firstName.get).getOrElse(""), + authUser.map(_.lastName.get).getOrElse(""), + entitlements, + None, + isLocked + ), HttpCode.`200`(cc.callContext)) + } + } + } + staticResourceDocs += ResourceDoc( verifyUserCredentials, implementedInApiVersion, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 5ff232ea5e..d307ba43e4 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -261,6 +261,22 @@ case class UserInfoJsonV600( case class UsersInfoJsonV600(users: List[UserInfoJsonV600]) +case class UserWithNamesJsonV600( + user_id: String, + email: String, + provider_id: String, + provider: String, + username: String, + firstname: String, + lastname: String, + entitlements: EntitlementJSONs, + views: Option[ViewsJSON300], + agreements: Option[List[UserAgreementJson]], + is_deleted: Boolean, + last_marketing_agreement_signed_date: Option[Date], + is_locked: Boolean +) + case class CreateUserJsonV600( email: String, username: String, @@ -1105,6 +1121,35 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } + def createUserWithNamesJSON( + user: User, + firstName: String, + lastName: String, + entitlements: List[Entitlement], + agreements: Option[List[UserAgreement]], + isLocked: Boolean + ): UserWithNamesJsonV600 = { + UserWithNamesJsonV600( + user_id = user.userId, + email = user.emailAddress, + provider_id = user.idGivenByProvider, + provider = stringOrNull(user.provider), + username = stringOrNull(user.name), + firstname = firstName, + lastname = lastName, + entitlements = JSONFactory200.createEntitlementJSONs(entitlements), + views = None, + agreements = agreements.map( + _.map(i => + UserAgreementJson(`type` = i.agreementType, text = i.agreementText) + ) + ), + is_deleted = user.isDeleted.getOrElse(false), + last_marketing_agreement_signed_date = user.lastMarketingAgreementSignedDate, + is_locked = isLocked + ) + } + def createUsersInfoJsonV600( users: List[ (ResourceUser, Box[List[Entitlement]], Option[List[UserAgreement]]) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala new file mode 100644 index 0000000000..c47bb0ffd8 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala @@ -0,0 +1,149 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetAnyUser +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages.UserHasMissingRoles +import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement +import code.model.dataAccess.AuthUser +import code.model.UserX +import code.setup.DefaultUsers +import code.users.Users +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import org.scalatest.Tag + +import java.util.UUID + +class GetUserByProviderAndUsernameTest extends V600ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint extends Tag(nameOf(Implementations6_0_0.getUserByProviderAndUsername)) + + feature(s"Get User by USERNAME - GET /obp/v6.0.0/users/provider/PROVIDER/username/USERNAME - $VersionOfApi") { + + scenario("Anonymous access should fail with 401", ApiEndpoint, VersionOfApi) { + When("We make the request without authentication") + val request = (v6_0_0_Request / "users" / "provider" / "x" / "username" / "USERNAME").GET + val response = makeGetRequest(request) + + Then("We should get a 401") + response.code should equal(401) + And("The error message should indicate authentication is required") + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + } + + scenario("Authenticated user without CanGetAnyUser role should fail with 403", ApiEndpoint, VersionOfApi) { + When("We make the request without the required role") + val request = (v6_0_0_Request / "users" / "provider" / defaultProvider / "username" / "USERNAME").GET <@ (user1) + val response = makeGetRequest(request) + + Then("We should get a 403") + response.code should equal(403) + And("The error message should indicate missing role") + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetAnyUser) + } + + scenario("Authenticated user with CanGetAnyUser role should succeed", ApiEndpoint, VersionOfApi) { + val user = UserX.createResourceUser(defaultProvider, Some("user.v600.1"), None, Some("user.v600.1"), None, Some(UUID.randomUUID.toString), None) + .openOrThrowException("Failed to create test user") + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + + When("We make the request with the required role") + val request = (v6_0_0_Request / "users" / "provider" / user.provider / "username" / user.name).GET <@ (user1) + val response = try { + makeGetRequest(request) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + Users.users.vend.deleteResourceUser(user.id.get) + } + + Then("We should get a 200") + response.code should equal(200) + And("The response should contain the correct user_id and username") + (response.body \ "user_id").extract[String] should equal(user.userId) + (response.body \ "username").extract[String] should equal(user.name) + And("The response should include firstname and lastname fields") + (response.body \ "firstname").extract[String] should equal("") + (response.body \ "lastname").extract[String] should equal("") + } + + scenario("Response should include firstname and lastname from AuthUser when present", ApiEndpoint, VersionOfApi) { + val username = "user.v600.names." + randomString(6).toLowerCase + val email = username + "@example.com" + + val authUser = AuthUser.create + .email(email) + .username(username) + .password(randomString(12)) + .validated(true) + .firstName("Alice") + .lastName("Smith") + .provider(defaultProvider) + .saveMe() + + val resourceUser = AuthUser.find(By(AuthUser.username, username)) + .flatMap(au => Users.users.vend.getUserByProviderId(defaultProvider, username)) + .openOrThrowException("Resource user must exist after AuthUser creation") + + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + + When("We request a user that has an AuthUser with first/last name") + val request = (v6_0_0_Request / "users" / "provider" / defaultProvider / "username" / username).GET <@ (user1) + val response = try { + makeGetRequest(request) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + authUser.delete_! + } + + Then("We should get a 200") + response.code should equal(200) + And("The firstname and lastname should be populated from AuthUser") + (response.body \ "firstname").extract[String] should equal("Alice") + (response.body \ "lastname").extract[String] should equal("Smith") + } + + scenario("Request for non-existent user should fail with 404", ApiEndpoint, VersionOfApi) { + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + + When("We request a user that does not exist") + val request = (v6_0_0_Request / "users" / "provider" / defaultProvider / "username" / ("nonexistent_" + randomString(8))).GET <@ (user1) + val response = try { + makeGetRequest(request) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + + Then("We should get a 404") + response.code should equal(404) + } + + scenario("Provider with URL-encoded special characters should be decoded correctly", ApiEndpoint, VersionOfApi) { + val provider = "http://127.0.0.1:8080" + val user = UserX.createResourceUser(provider, Some("user.v600.url"), None, Some("user.v600.url"), None, Some(UUID.randomUUID.toString), None) + .openOrThrowException("Failed to create test user") + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + + // dispatch encodes '/' as '%2F', so "http://127.0.0.1:8080" becomes "http:%2F%2F127.0.0.1:8080" on the wire. + // The endpoint applies URLDecoder.decode to recover the original value before the user lookup. + When("We make the request with a provider containing slashes and colons") + val request = (v6_0_0_Request / "users" / "provider" / provider / "username" / user.name).GET <@ (user1) + val response = try { + makeGetRequest(request) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + Users.users.vend.deleteResourceUser(user.id.get) + } + + Then("We should get a 200 - provider was correctly URL-decoded") + response.code should equal(200) + (response.body \ "user_id").extract[String] should equal(user.userId) + } + + } +} From 1a66a9f7857d1984f0e812ea0420a106b756fdc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 5 Mar 2026 09:30:29 +0100 Subject: [PATCH 2/6] feature/Add endpoint getUserByProviderAndUsername v6.0.0 --- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index badad2904b..3b8ad88b23 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -8750,7 +8750,7 @@ trait APIMethods600 { Some(List(canGetAnyUser)) ) - lazy val : OBPEndpoint = { + lazy val getUserByProviderAndUsername: OBPEndpoint = { case "users" :: "provider" :: provider :: "username" :: username :: Nil JsonGet _ => { cc => implicit val ec = EndpointContext(Some(cc)) for { From c0bd671e38f1691097dbe993dfee2aec67919a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 5 Mar 2026 10:11:39 +0100 Subject: [PATCH 3/6] feature/Tweak endpoint getUserByUserId v6.0.0 --- .../SwaggerDefinitionsJSON.scala | 2 ++ .../scala/code/api/v6_0_0/APIMethods600.scala | 14 +++++++++++++- .../scala/code/api/v6_0_0/JSONFactory6.0.0.scala | 16 +++++++++++++--- .../code/api/v6_0_0/GetUserByUserIdTest.scala | 3 +++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 598b65f913..8dc6b4c68d 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -2635,6 +2635,8 @@ object SwaggerDefinitionsJSON { provider_id = providerIdValueExample.value, provider = providerValueExample.value, username = usernameExample.value, + firstname = ExampleValue.firstNameExample.value, + lastname = ExampleValue.lastNameExample.value, entitlements = entitlementJSONs, views = Some(viewsJSON300), agreements = Some(List(userAgreementJson)), diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 3b8ad88b23..3283746e41 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1852,6 +1852,9 @@ trait APIMethods600 { if (agreementList.isEmpty) None else Some(agreementList) } isLocked = LoginAttempt.userIsLocked(user.provider, user.name) + authUser = code.model.dataAccess.AuthUser.find( + By(code.model.dataAccess.AuthUser.user, user.userPrimaryKey.value) + ) // Fetch metrics data for the user userMetrics <- Future { code.metrics.MappedMetric.findAll( @@ -1863,7 +1866,16 @@ trait APIMethods600 { lastActivityDate = userMetrics.headOption.map(_.getDate()) recentOperationIds = userMetrics.map(_.getImplementedByPartialFunction()).distinct.take(5) } yield { - (JSONFactory600.createUserInfoJsonV600(user, entitlements, agreements, isLocked, lastActivityDate, recentOperationIds), HttpCode.`200`(callContext)) + (JSONFactory600.createUserInfoJsonV600( + user, + authUser.map(_.firstName.get).getOrElse(""), + authUser.map(_.lastName.get).getOrElse(""), + entitlements, + agreements, + isLocked, + lastActivityDate, + recentOperationIds + ), HttpCode.`200`(callContext)) } } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index d307ba43e4..d4ae0fca23 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -37,8 +37,9 @@ import code.apiproductattribute.ApiProductAttributeTrait import code.featuredapicollection.FeaturedApiCollectionTrait import code.loginattempts.LoginAttempt import code.model.ModeratedBankAccountCore -import code.model.dataAccess.ResourceUser +import code.model.dataAccess.{AuthUser, ResourceUser} import code.users.UserAgreement +import net.liftweb.mapper.By import code.util.Helper.MdcLoggable import com.openbankproject.commons.model.{ AmountOfMoneyJsonV121, @@ -249,6 +250,8 @@ case class UserInfoJsonV600( provider_id: String, provider: String, username: String, + firstname: String, + lastname: String, entitlements: EntitlementJSONs, views: Option[ViewsJSON300], agreements: Option[List[UserAgreementJson]], @@ -1093,6 +1096,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createUserInfoJsonV600( user: User, + firstName: String, + lastName: String, entitlements: List[Entitlement], agreements: Option[List[UserAgreement]], isLocked: Boolean, @@ -1105,6 +1110,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { username = stringOrNull(user.name), provider_id = user.idGivenByProvider, provider = stringOrNull(user.provider), + firstname = firstName, + lastname = lastName, entitlements = JSONFactory200.createEntitlementJSONs(entitlements), views = None, agreements = agreements.map( @@ -1156,16 +1163,19 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ] ): UsersInfoJsonV600 = { UsersInfoJsonV600( - users.map(t => + users.map { t => + val authUser = AuthUser.find(By(AuthUser.user, t._1.id.get)) createUserInfoJsonV600( t._1, + authUser.map(_.firstName.get).getOrElse(""), + authUser.map(_.lastName.get).getOrElse(""), t._2.getOrElse(Nil), t._3, LoginAttempt.userIsLocked(t._1.provider, t._1.name), None, List.empty ) - ) + } ) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala index a984877124..968627de23 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala @@ -57,6 +57,9 @@ class GetUserByUserIdTest extends V600ServerSetup with DefaultUsers { And("The response should contain user details") (response.body \ "user_id").extract[String] should equal(resourceUser1.userId) + And("The response should include firstname and lastname fields") + response.body \ "firstname" should not equal net.liftweb.json.JNothing + response.body \ "lastname" should not equal net.liftweb.json.JNothing } } From a487f45990052613d6acc44cff1cc20dd6b2fa92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 5 Mar 2026 12:41:17 +0100 Subject: [PATCH 4/6] feature/Tweak firstname to first_name and lastname to last_name --- .../SwaggerDefinitionsJSON.scala | 8 ++++---- .../scala/code/api/v6_0_0/JSONFactory6.0.0.scala | 16 ++++++++-------- .../GetUserByProviderAndUsernameTest.scala | 12 ++++++------ .../code/api/v6_0_0/GetUserByUserIdTest.scala | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 8dc6b4c68d..7894dfe8bb 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -2635,8 +2635,8 @@ object SwaggerDefinitionsJSON { provider_id = providerIdValueExample.value, provider = providerValueExample.value, username = usernameExample.value, - firstname = ExampleValue.firstNameExample.value, - lastname = ExampleValue.lastNameExample.value, + first_name = ExampleValue.firstNameExample.value, + last_name = ExampleValue.lastNameExample.value, entitlements = entitlementJSONs, views = Some(viewsJSON300), agreements = Some(List(userAgreementJson)), @@ -2657,8 +2657,8 @@ object SwaggerDefinitionsJSON { provider_id = providerIdValueExample.value, provider = providerValueExample.value, username = usernameExample.value, - firstname = ExampleValue.firstNameExample.value, - lastname = ExampleValue.lastNameExample.value, + first_name = ExampleValue.firstNameExample.value, + last_name = ExampleValue.lastNameExample.value, entitlements = entitlementJSONs, views = Some(viewsJSON300), agreements = Some(List(userAgreementJson)), diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index d4ae0fca23..f886693b70 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -250,8 +250,8 @@ case class UserInfoJsonV600( provider_id: String, provider: String, username: String, - firstname: String, - lastname: String, + first_name: String, + last_name: String, entitlements: EntitlementJSONs, views: Option[ViewsJSON300], agreements: Option[List[UserAgreementJson]], @@ -270,8 +270,8 @@ case class UserWithNamesJsonV600( provider_id: String, provider: String, username: String, - firstname: String, - lastname: String, + first_name: String, + last_name: String, entitlements: EntitlementJSONs, views: Option[ViewsJSON300], agreements: Option[List[UserAgreementJson]], @@ -1110,8 +1110,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { username = stringOrNull(user.name), provider_id = user.idGivenByProvider, provider = stringOrNull(user.provider), - firstname = firstName, - lastname = lastName, + first_name = firstName, + last_name = lastName, entitlements = JSONFactory200.createEntitlementJSONs(entitlements), views = None, agreements = agreements.map( @@ -1142,8 +1142,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { provider_id = user.idGivenByProvider, provider = stringOrNull(user.provider), username = stringOrNull(user.name), - firstname = firstName, - lastname = lastName, + first_name = firstName, + last_name = lastName, entitlements = JSONFactory200.createEntitlementJSONs(entitlements), views = None, agreements = agreements.map( diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala index c47bb0ffd8..559639e43a 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala @@ -67,9 +67,9 @@ class GetUserByProviderAndUsernameTest extends V600ServerSetup with DefaultUsers And("The response should contain the correct user_id and username") (response.body \ "user_id").extract[String] should equal(user.userId) (response.body \ "username").extract[String] should equal(user.name) - And("The response should include firstname and lastname fields") - (response.body \ "firstname").extract[String] should equal("") - (response.body \ "lastname").extract[String] should equal("") + And("The response should include first_name and last_name fields") + (response.body \ "first_name").extract[String] should equal("") + (response.body \ "last_name").extract[String] should equal("") } scenario("Response should include firstname and lastname from AuthUser when present", ApiEndpoint, VersionOfApi) { @@ -103,9 +103,9 @@ class GetUserByProviderAndUsernameTest extends V600ServerSetup with DefaultUsers Then("We should get a 200") response.code should equal(200) - And("The firstname and lastname should be populated from AuthUser") - (response.body \ "firstname").extract[String] should equal("Alice") - (response.body \ "lastname").extract[String] should equal("Smith") + And("The first_name and last_name should be populated from AuthUser") + (response.body \ "first_name").extract[String] should equal("Alice") + (response.body \ "last_name").extract[String] should equal("Smith") } scenario("Request for non-existent user should fail with 404", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala index 968627de23..c393da1fd3 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala @@ -57,9 +57,9 @@ class GetUserByUserIdTest extends V600ServerSetup with DefaultUsers { And("The response should contain user details") (response.body \ "user_id").extract[String] should equal(resourceUser1.userId) - And("The response should include firstname and lastname fields") - response.body \ "firstname" should not equal net.liftweb.json.JNothing - response.body \ "lastname" should not equal net.liftweb.json.JNothing + And("The response should include first_name and last_name fields") + response.body \ "first_name" should not equal net.liftweb.json.JNothing + response.body \ "last_name" should not equal net.liftweb.json.JNothing } } From f76656ec0901d079fe219ff3fd0131f9f3e6e5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 5 Mar 2026 14:44:29 +0100 Subject: [PATCH 5/6] bugfix/hikari.keepaliveTime=30000 --- obp-api/src/main/resources/props/sample.props.template | 5 ++++- .../src/main/scala/bootstrap/liftweb/CustomDBVendor.scala | 6 ++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 8c51d388fd..9ef3a3bfcf 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -37,7 +37,10 @@ connector=star #hikari.connectionTimeout= #hikari.maximumPoolSize= #hikari.idleTimeout= -#hikari.keepaliveTime= +# keepaliveTime must be set below the database/firewall idle TCP timeout to prevent stale-connection +# failures on the first call after a period of inactivity. HikariCP will ping each idle connection +# at this interval (in ms), keeping it alive. Recommended: 30000 (30s) when DB/firewall timeout is ~60s. +#hikari.keepaliveTime=30000 #hikari.maxLifetime= ## if connector = star, then need to set which connectors will be used. For now, obp support rest, akka. diff --git a/obp-api/src/main/scala/bootstrap/liftweb/CustomDBVendor.scala b/obp-api/src/main/scala/bootstrap/liftweb/CustomDBVendor.scala index 0f7c3fbe50..763c7ca4ae 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/CustomDBVendor.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/CustomDBVendor.scala @@ -30,7 +30,7 @@ class CustomDBVendor(driverName: String, val connectionTimeout = APIUtil.getPropsAsLongValue("hikari.connectionTimeout") val maximumPoolSize = APIUtil.getPropsAsIntValue("hikari.maximumPoolSize") val idleTimeout = APIUtil.getPropsAsLongValue("hikari.idleTimeout") - val keepaliveTime = APIUtil.getPropsAsLongValue("hikari.keepaliveTime") + val keepaliveTime = APIUtil.getPropsAsLongValue("hikari.keepaliveTime", 30000L) val maxLifetime = APIUtil.getPropsAsLongValue("hikari.maxLifetime") if(connectionTimeout.isDefined){ @@ -42,9 +42,7 @@ class CustomDBVendor(driverName: String, if(idleTimeout.isDefined){ config.setIdleTimeout(idleTimeout.head) } - if(keepaliveTime.isDefined){ - config.setKeepaliveTime(keepaliveTime.head) - } + config.setKeepaliveTime(keepaliveTime) if(maxLifetime.isDefined){ config.setMaxLifetime(maxLifetime.head) } From e2424ea3507beb8db4997d48ebbaf5b2cabe5fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 5 Mar 2026 16:54:03 +0100 Subject: [PATCH 6/6] feature/Tweak endpoint getUserByProviderAndUsername v5.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved to v5.1.0 (JSONFactory5.1.0.scala): - Added UserWithNamesJsonV510 case class (same 13 fields: user_id, email, provider_id, provider, username, first_name, last_name, entitlements, views, agreements, is_deleted, last_marketing_agreement_signed_date, is_locked) - Added createUserWithNamesJSON factory method with AuthUser lookup Updated v5.1.0 endpoint (APIMethods510.scala): - Adds authUser = AuthUser.find(...) lookup - Returns JSONFactory510.createUserWithNamesJSON(...) with first_name/last_name - ResourceDoc response example changed from userJsonV400 → userWithNamesJsonV510 Updated v5.1.0 tests (UserTest.scala): - 200-success scenario now extracts UserWithNamesJsonV510 and asserts first_name == "" / last_name == "" - New scenario creates an AuthUser with firstName("Alice").lastName("Smith") and asserts both fields Cleaned up v6.0.0: - Removed getUserByProviderAndUsername ResourceDoc + endpoint from APIMethods600.scala - Removed UserWithNamesJsonV600 case class from JSONFactory6.0.0.scala - Removed createUserWithNamesJSON factory method from JSONFactory6.0.0.scala - Deleted GetUserByProviderAndUsernameTest.scala - Renamed userWithNamesJsonV600 → userWithNamesJsonV510 in SwaggerDefinitionsJSON.scala Since v6.0.0 inherits all v5.1.0 endpoints, /obp/v6.0.0/users/provider/.../username/... automatically uses the improved v5.1.0 implementation — no separate v6.0.0 endpoint needed. --- .../SwaggerDefinitionsJSON.scala | 2 +- .../scala/code/api/v5_1_0/APIMethods510.scala | 10 +- .../code/api/v5_1_0/JSONFactory5.1.0.scala | 51 +++++- .../scala/code/api/v6_0_0/APIMethods600.scala | 60 ------- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 45 ------ .../test/scala/code/api/v5_1_0/UserTest.scala | 34 +++- .../GetUserByProviderAndUsernameTest.scala | 149 ------------------ 7 files changed, 88 insertions(+), 263 deletions(-) delete mode 100644 obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 7894dfe8bb..294dd08cb4 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -2651,7 +2651,7 @@ object SwaggerDefinitionsJSON { users = List(userInfoJsonV600) ) - lazy val userWithNamesJsonV600 = UserWithNamesJsonV600( + lazy val userWithNamesJsonV510 = UserWithNamesJsonV510( user_id = ExampleValue.userIdExample.value, email = ExampleValue.emailExample.value, provider_id = providerIdValueExample.value, diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 1e8b90773a..e6728b32f5 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -2670,7 +2670,7 @@ trait APIMethods510 { | """.stripMargin, EmptyBody, - userJsonV400, + userWithNamesJsonV510, List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByProviderAndUsername, UnknownError), List(apiTagUser), Some(List(canGetAnyUser)) @@ -2685,8 +2685,14 @@ trait APIMethods510 { } entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, cc.callContext) isLocked = LoginAttempt.userIsLocked(user.provider, user.name) + authUser = AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) } yield { - (JSONFactory400.createUserInfoJSON(user, entitlements, None, isLocked), HttpCode.`200`(cc.callContext)) + (JSONFactory510.createUserWithNamesJSON( + user, + authUser.map(_.firstName.get).getOrElse(""), + authUser.map(_.lastName.get).getOrElse(""), + entitlements, None, isLocked + ), HttpCode.`200`(cc.callContext)) } } } diff --git a/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala b/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala index f1f36add98..54543a4c45 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala @@ -40,8 +40,14 @@ import code.api.v2_1_0.ResourceUserJSON import code.api.v3_0_0.JSONFactory300.{createLocationJson, createMetaJson, transformToAddressFromV300} import code.api.v3_0_0.{AddressJsonV300, OpeningTimesV300} import code.api.v3_1_0.{CallLimitJson, RateLimit, RedisCallLimitJson} -import code.api.v4_0_0.{EnergySource400, HostedAt400, HostedBy400} +import code.api.v2_0_0.{EntitlementJSONs, JSONFactory200} +import code.api.v3_0_0.ViewsJSON300 +import code.api.v4_0_0.{EnergySource400, HostedAt400, HostedBy400, UserAgreementJson} import code.api.v5_0_0.PostConsentRequestJsonV500 +import code.entitlement.Entitlement +import code.model.dataAccess.AuthUser +import code.users.UserAgreement +import net.liftweb.mapper.By import code.atmattribute.AtmAttribute import code.atms.Atms.Atm import code.consent.MappedConsent @@ -662,6 +668,22 @@ case class SyncExternalUserJson(user_id: String) case class UserValidatedJson(is_validated: Boolean) +case class UserWithNamesJsonV510( + user_id: String, + email: String, + provider_id: String, + provider: String, + username: String, + first_name: String, + last_name: String, + entitlements: EntitlementJSONs, + views: Option[ViewsJSON300], + agreements: Option[List[UserAgreementJson]], + is_deleted: Boolean, + last_marketing_agreement_signed_date: Option[Date], + is_locked: Boolean +) + case class BankAccountBalanceRequestJsonV510( balance_type: String, @@ -1346,4 +1368,31 @@ object JSONFactory510 extends CustomJsonFormats with MdcLoggable { } + def createUserWithNamesJSON( + user: User, + firstName: String, + lastName: String, + entitlements: List[Entitlement], + agreements: Option[List[UserAgreement]], + isLocked: Boolean + ): UserWithNamesJsonV510 = { + UserWithNamesJsonV510( + user_id = user.userId, + email = user.emailAddress, + provider_id = user.idGivenByProvider, + provider = stringOrNull(user.provider), + username = stringOrNull(user.name), + first_name = firstName, + last_name = lastName, + entitlements = JSONFactory200.createEntitlementJSONs(entitlements), + views = None, + agreements = agreements.map( + _.map(i => UserAgreementJson(`type` = i.agreementType, text = i.agreementText)) + ), + is_deleted = user.isDeleted.getOrElse(false), + last_marketing_agreement_signed_date = user.lastMarketingAgreementSignedDate, + is_locked = isLocked + ) + } + } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index fea200d432..24e75d26ec 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -8727,66 +8727,6 @@ trait APIMethods600 { } } - staticResourceDocs += ResourceDoc( - getUserByProviderAndUsername, - implementedInApiVersion, - nameOf(getUserByProviderAndUsername), - "GET", - "/users/provider/PROVIDER/username/USERNAME", - "Get User by USERNAME", - s"""Get user by PROVIDER and USERNAME - | - |Get a User by their authentication provider and username. - | - |**URL Parameters:** - | - |* PROVIDER - The authentication provider (e.g., http://127.0.0.1:8080, google.com, OBP) - |* USERNAME - The username at that provider (e.g., obpstripe, john.doe) - | - |**Important:** The PROVIDER parameter can contain special characters like slashes and colons. - |For example, if the provider is "http://127.0.0.1:8080", the full URL would be: - | - |`GET /obp/v6.0.0/users/provider/http://127.0.0.1:8080/username/obpstripe` - | - |The API will correctly parse the provider value even with these special characters. - | - |${userAuthenticationMessage(true)} - | - |CanGetAnyUser entitlement is required. - | - """.stripMargin, - EmptyBody, - userWithNamesJsonV600, - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByProviderAndUsername, UnknownError), - List(apiTagUser), - Some(List(canGetAnyUser)) - ) - - lazy val getUserByProviderAndUsername: OBPEndpoint = { - case "users" :: "provider" :: provider :: "username" :: username :: Nil JsonGet _ => { - cc => implicit val ec = EndpointContext(Some(cc)) - for { - user <- Users.users.vend.getUserByProviderAndUsernameFuture(URLDecoder.decode(provider, StandardCharsets.UTF_8), username) map { - x => unboxFullOrFail(x, cc.callContext, UserNotFoundByProviderAndUsername, 404) - } - entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, cc.callContext) - isLocked = LoginAttempt.userIsLocked(user.provider, user.name) - authUser = code.model.dataAccess.AuthUser.find( - By(code.model.dataAccess.AuthUser.user, user.userPrimaryKey.value) - ) - } yield { - (JSONFactory600.createUserWithNamesJSON( - user, - authUser.map(_.firstName.get).getOrElse(""), - authUser.map(_.lastName.get).getOrElse(""), - entitlements, - None, - isLocked - ), HttpCode.`200`(cc.callContext)) - } - } - } - staticResourceDocs += ResourceDoc( verifyUserCredentials, implementedInApiVersion, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index f886693b70..2eab8090be 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -264,22 +264,6 @@ case class UserInfoJsonV600( case class UsersInfoJsonV600(users: List[UserInfoJsonV600]) -case class UserWithNamesJsonV600( - user_id: String, - email: String, - provider_id: String, - provider: String, - username: String, - first_name: String, - last_name: String, - entitlements: EntitlementJSONs, - views: Option[ViewsJSON300], - agreements: Option[List[UserAgreementJson]], - is_deleted: Boolean, - last_marketing_agreement_signed_date: Option[Date], - is_locked: Boolean -) - case class CreateUserJsonV600( email: String, username: String, @@ -1128,35 +1112,6 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } - def createUserWithNamesJSON( - user: User, - firstName: String, - lastName: String, - entitlements: List[Entitlement], - agreements: Option[List[UserAgreement]], - isLocked: Boolean - ): UserWithNamesJsonV600 = { - UserWithNamesJsonV600( - user_id = user.userId, - email = user.emailAddress, - provider_id = user.idGivenByProvider, - provider = stringOrNull(user.provider), - username = stringOrNull(user.name), - first_name = firstName, - last_name = lastName, - entitlements = JSONFactory200.createEntitlementJSONs(entitlements), - views = None, - agreements = agreements.map( - _.map(i => - UserAgreementJson(`type` = i.agreementType, text = i.agreementText) - ) - ), - is_deleted = user.isDeleted.getOrElse(false), - last_marketing_agreement_signed_date = user.lastMarketingAgreementSignedDate, - is_locked = isLocked - ) - } - def createUsersInfoJsonV600( users: List[ (ResourceUser, Box[List[Entitlement]], Option[List[UserAgreement]]) diff --git a/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala index 64878373f3..2ae08eeb39 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala @@ -4,15 +4,16 @@ import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole.{CanGetAnyUser, CanGetEntitlementsForAnyUserAtAnyBank, CanValidateUser} import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired, attemptedToOpenAnEmptyBox} import code.api.v3_0_0.UserJsonV300 -import code.api.v4_0_0.UserJsonV400 import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0 import code.entitlement.Entitlement import code.model.UserX +import code.model.dataAccess.AuthUser import code.users.Users import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ErrorMessage import com.openbankproject.commons.util.ApiVersion import net.liftweb.json.Serialization.write +import net.liftweb.util.Helpers.randomString import org.scalatest.Tag import java.util.UUID @@ -59,14 +60,37 @@ class UserTest extends V510ServerSetup { When("We make a request v5.1.0") val request400 = (v5_1_0_Request / "users" / "provider"/user.provider / "username" / user.name ).GET <@(user1) val response400 = makeGetRequest(request400) - Then("We get successful response") + Then("We get successful response with first_name and last_name fields") response400.code should equal(200) - response400.body.extract[UserJsonV400] + val json = response400.body.extract[UserWithNamesJsonV510] + json.first_name should equal("") + json.last_name should equal("") Users.users.vend.deleteResourceUser(user.id.get) } } - + feature(s"test $ApiEndpoint1 version $VersionOfApi - first_name and last_name populated from AuthUser") { + scenario("We will call the endpoint with an AuthUser that has first and last name set", ApiEndpoint1, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + val username = "user.withnames." + UUID.randomUUID.toString.take(8) + val email = s"$username@example.com" + val user = UserX.createResourceUser(defaultProvider, Some(username), None, Some(username), None, Some(UUID.randomUUID.toString), None).openOrThrowException(attemptedToOpenAnEmptyBox) + val authUser = AuthUser.create + .email(email).username(username).password(randomString(12)) + .validated(true).firstName("Alice").lastName("Smith") + .provider(defaultProvider).user(user.userPrimaryKey.value).saveMe() + When("We make a request v5.1.0") + val request = (v5_1_0_Request / "users" / "provider" / user.provider / "username" / user.name).GET <@(user1) + val response = makeGetRequest(request) + Then("We get first_name and last_name from AuthUser") + response.code should equal(200) + val json = response.body.extract[UserWithNamesJsonV510] + json.first_name should equal("Alice") + json.last_name should equal("Smith") + authUser.delete_! + Users.users.vend.deleteResourceUser(user.id.get) + } + } feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access with URL-encoded provider") { scenario("We will call the endpoint with a provider containing special URL characters (colon, slash)", ApiEndpoint1, VersionOfApi) { @@ -81,7 +105,7 @@ class UserTest extends V510ServerSetup { val response = makeGetRequest(request) Then("We get successful response - endpoint correctly URL-decodes the provider") response.code should equal(200) - response.body.extract[UserJsonV400] + response.body.extract[UserWithNamesJsonV510] Users.users.vend.deleteResourceUser(user.id.get) } } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala deleted file mode 100644 index 559639e43a..0000000000 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByProviderAndUsernameTest.scala +++ /dev/null @@ -1,149 +0,0 @@ -package code.api.v6_0_0 - -import code.api.util.APIUtil.OAuth._ -import code.api.util.ApiRole.CanGetAnyUser -import code.api.util.ErrorMessages -import code.api.util.ErrorMessages.UserHasMissingRoles -import code.api.v6_0_0.APIMethods600.Implementations6_0_0 -import code.entitlement.Entitlement -import code.model.dataAccess.AuthUser -import code.model.UserX -import code.setup.DefaultUsers -import code.users.Users -import com.github.dwickern.macros.NameOf.nameOf -import com.openbankproject.commons.model.ErrorMessage -import com.openbankproject.commons.util.ApiVersion -import net.liftweb.mapper.By -import net.liftweb.util.Helpers.randomString -import org.scalatest.Tag - -import java.util.UUID - -class GetUserByProviderAndUsernameTest extends V600ServerSetup with DefaultUsers { - - object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) - object ApiEndpoint extends Tag(nameOf(Implementations6_0_0.getUserByProviderAndUsername)) - - feature(s"Get User by USERNAME - GET /obp/v6.0.0/users/provider/PROVIDER/username/USERNAME - $VersionOfApi") { - - scenario("Anonymous access should fail with 401", ApiEndpoint, VersionOfApi) { - When("We make the request without authentication") - val request = (v6_0_0_Request / "users" / "provider" / "x" / "username" / "USERNAME").GET - val response = makeGetRequest(request) - - Then("We should get a 401") - response.code should equal(401) - And("The error message should indicate authentication is required") - response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) - } - - scenario("Authenticated user without CanGetAnyUser role should fail with 403", ApiEndpoint, VersionOfApi) { - When("We make the request without the required role") - val request = (v6_0_0_Request / "users" / "provider" / defaultProvider / "username" / "USERNAME").GET <@ (user1) - val response = makeGetRequest(request) - - Then("We should get a 403") - response.code should equal(403) - And("The error message should indicate missing role") - response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetAnyUser) - } - - scenario("Authenticated user with CanGetAnyUser role should succeed", ApiEndpoint, VersionOfApi) { - val user = UserX.createResourceUser(defaultProvider, Some("user.v600.1"), None, Some("user.v600.1"), None, Some(UUID.randomUUID.toString), None) - .openOrThrowException("Failed to create test user") - val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) - - When("We make the request with the required role") - val request = (v6_0_0_Request / "users" / "provider" / user.provider / "username" / user.name).GET <@ (user1) - val response = try { - makeGetRequest(request) - } finally { - Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) - Users.users.vend.deleteResourceUser(user.id.get) - } - - Then("We should get a 200") - response.code should equal(200) - And("The response should contain the correct user_id and username") - (response.body \ "user_id").extract[String] should equal(user.userId) - (response.body \ "username").extract[String] should equal(user.name) - And("The response should include first_name and last_name fields") - (response.body \ "first_name").extract[String] should equal("") - (response.body \ "last_name").extract[String] should equal("") - } - - scenario("Response should include firstname and lastname from AuthUser when present", ApiEndpoint, VersionOfApi) { - val username = "user.v600.names." + randomString(6).toLowerCase - val email = username + "@example.com" - - val authUser = AuthUser.create - .email(email) - .username(username) - .password(randomString(12)) - .validated(true) - .firstName("Alice") - .lastName("Smith") - .provider(defaultProvider) - .saveMe() - - val resourceUser = AuthUser.find(By(AuthUser.username, username)) - .flatMap(au => Users.users.vend.getUserByProviderId(defaultProvider, username)) - .openOrThrowException("Resource user must exist after AuthUser creation") - - val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) - - When("We request a user that has an AuthUser with first/last name") - val request = (v6_0_0_Request / "users" / "provider" / defaultProvider / "username" / username).GET <@ (user1) - val response = try { - makeGetRequest(request) - } finally { - Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) - authUser.delete_! - } - - Then("We should get a 200") - response.code should equal(200) - And("The first_name and last_name should be populated from AuthUser") - (response.body \ "first_name").extract[String] should equal("Alice") - (response.body \ "last_name").extract[String] should equal("Smith") - } - - scenario("Request for non-existent user should fail with 404", ApiEndpoint, VersionOfApi) { - val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) - - When("We request a user that does not exist") - val request = (v6_0_0_Request / "users" / "provider" / defaultProvider / "username" / ("nonexistent_" + randomString(8))).GET <@ (user1) - val response = try { - makeGetRequest(request) - } finally { - Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) - } - - Then("We should get a 404") - response.code should equal(404) - } - - scenario("Provider with URL-encoded special characters should be decoded correctly", ApiEndpoint, VersionOfApi) { - val provider = "http://127.0.0.1:8080" - val user = UserX.createResourceUser(provider, Some("user.v600.url"), None, Some("user.v600.url"), None, Some(UUID.randomUUID.toString), None) - .openOrThrowException("Failed to create test user") - val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) - - // dispatch encodes '/' as '%2F', so "http://127.0.0.1:8080" becomes "http:%2F%2F127.0.0.1:8080" on the wire. - // The endpoint applies URLDecoder.decode to recover the original value before the user lookup. - When("We make the request with a provider containing slashes and colons") - val request = (v6_0_0_Request / "users" / "provider" / provider / "username" / user.name).GET <@ (user1) - val response = try { - makeGetRequest(request) - } finally { - Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) - Users.users.vend.deleteResourceUser(user.id.get) - } - - Then("We should get a 200 - provider was correctly URL-decoded") - response.code should equal(200) - (response.body \ "user_id").extract[String] should equal(user.userId) - } - - } -}