From 570573ac5b2b384bab2ed8543fa429d9f64ba517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 3 Sep 2025 15:44:27 +0200 Subject: [PATCH 1/6] feature/Tweak ZED tasks, MAVEN_OPTS --- zed/tasks.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/zed/tasks.json b/zed/tasks.json index 9407f34885..4655b6ea82 100644 --- a/zed/tasks.json +++ b/zed/tasks.json @@ -4,7 +4,7 @@ "command": "mvn", "args": ["jetty:run", "-pl", "obp-api"], "env": { - "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util.jar=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED" + "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED" }, "use_new_terminal": true, "allow_concurrent_runs": false, @@ -31,7 +31,7 @@ "command": "mvn", "args": ["compile", "-pl", "obp-api"], "env": { - "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util.jar=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED" + "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED" }, "use_new_terminal": false, "allow_concurrent_runs": false, @@ -50,7 +50,7 @@ "-Ddependency-check.skip=true" ], "env": { - "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util.jar=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED" + "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED" }, "use_new_terminal": false, "allow_concurrent_runs": false, @@ -83,7 +83,7 @@ "command": "mvn", "args": ["test", "-pl", "obp-api"], "env": { - "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util.jar=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED" + "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED" }, "use_new_terminal": false, "allow_concurrent_runs": false, From f2a1eacaec5d500185d8ffd7007972ee0ea4ee4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 5 Sep 2025 08:29:51 +0200 Subject: [PATCH 2/6] bugfix/Fix consumer rate limiting state function --- .../scala/code/api/util/RateLimitingUtil.scala | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala index 079a30e80a..a6ecb1df48 100644 --- a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala +++ b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala @@ -153,13 +153,14 @@ object RateLimitingUtil extends MdcLoggable { def getInfo(consumerKey: String, period: LimitCallPeriod): ((Option[Long], Option[Long]), LimitCallPeriod) = { val key = createUniqueKey(consumerKey, period) - val ttl = Redis.use(JedisMethod.TTL, key).get.toLong - ttl match { - case -2 => - ((None, None), period) - case _ => - ((Redis.use(JedisMethod.TTL, key).map(_.toLong), Some(ttl)), period) - } + + // get TTL + val ttlOpt: Option[Long] = Redis.use(JedisMethod.TTL, key).map(_.toLong) + + // get value (assuming string storage) + val valueOpt: Option[Long] = Redis.use(JedisMethod.GET, key).map(_.toLong) + + ((valueOpt, ttlOpt), period) } getInfo(consumerKey, RateLimitingPeriod.PER_SECOND) :: @@ -167,7 +168,7 @@ object RateLimitingUtil extends MdcLoggable { getInfo(consumerKey, RateLimitingPeriod.PER_HOUR) :: getInfo(consumerKey, RateLimitingPeriod.PER_DAY) :: getInfo(consumerKey, RateLimitingPeriod.PER_WEEK) :: - getInfo(consumerKey, RateLimitingPeriod.PER_MONTH) :: + getInfo(consumerKey, RateLimitingPeriod.PER_MONTH) :: Nil } From d1e3e819a56bf51a457ac78b551501155e337c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 5 Sep 2025 08:31:47 +0200 Subject: [PATCH 3/6] feature/Add function getCallsLimit v5.1.0 --- .../scala/code/api/v5_1_0/APIMethods510.scala | 47 ++++++- .../code/api/v5_1_0/JSONFactory5.1.0.scala | 75 +++++++++- .../ratelimiting/MappedRateLimiting.scala | 94 ++++++------- .../code/ratelimiting/RateLimiting.scala | 1 + .../code/api/v5_1_0/RateLimitingTest.scala | 132 ++++++++++++++++++ .../code/api/v5_1_0/V510ServerSetup.scala | 12 +- zed/tasks.json | 4 +- 7 files changed, 306 insertions(+), 59 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v5_1_0/RateLimitingTest.scala 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 3d066c754f..948c370d99 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 @@ -29,7 +29,7 @@ import code.api.v3_1_0._ import code.api.v4_0_0.JSONFactory400.{createAccountBalancesJson, createBalancesJson, createNewCoreBankAccountJson} import code.api.v4_0_0._ import code.api.v5_0_0.JSONFactory500 -import code.api.v5_1_0.JSONFactory510.{createConsentsInfoJsonV510, createConsentsJsonV510, createRegulatedEntitiesJson, createRegulatedEntityJson} +import code.api.v5_1_0.JSONFactory510.{createCallLimitJson, createConsentsInfoJsonV510, createConsentsJsonV510, createRegulatedEntitiesJson, createRegulatedEntityJson} import code.atmattribute.AtmAttribute import code.bankconnectors.Connector import code.consent.{ConsentRequests, ConsentStatus, Consents, MappedConsent} @@ -39,6 +39,7 @@ import code.loginattempts.LoginAttempt import code.metrics.APIMetrics import code.model.dataAccess.{AuthUser, MappedBankAccount} import code.model.{AppType, Consumer} +import code.ratelimiting.{RateLimiting, RateLimitingDI} import code.regulatedentities.MappedRegulatedEntityProvider import code.userlocks.UserLocksProvider import code.users.Users @@ -3290,6 +3291,50 @@ trait APIMethods510 { } + staticResourceDocs += ResourceDoc( + getCallsLimit, + implementedInApiVersion, + nameOf(getCallsLimit), + "GET", + "/management/consumers/CONSUMER_ID/consumer/call-limits", + "Get Call Limits for a Consumer", + s""" + |Get Calls limits per Consumer. + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + callLimitJson, + List( + $UserNotLoggedIn, + InvalidJsonFormat, + InvalidConsumerId, + ConsumerNotFoundByConsumerId, + UserHasMissingRoles, + UpdateConsumerError, + UnknownError + ), + List(apiTagConsumer), + Some(List(canReadCallLimits))) + + + lazy val getCallsLimit: OBPEndpoint = { + case "management" :: "consumers" :: consumerId :: "consumer" :: "call-limits" :: Nil JsonGet _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + // (Full(u), callContext) <- authenticatedAccess(cc) + // _ <- NewStyle.function.hasEntitlement("", cc.userId, canReadCallLimits, callContext) + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, cc.callContext) + rateLimiting: Option[RateLimiting] <- RateLimitingDI.rateLimiting.vend.findMostRecentRateLimit(consumerId, None, None, None) + rateLimit <- Future(RateLimitingUtil.consumerRateLimitState(consumer.consumerId.get).toList) + } yield { + (createCallLimitJson(consumer, rateLimiting, rateLimit), HttpCode.`200`(cc.callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( updateConsumerRedirectURL, implementedInApiVersion, 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 909d5d83fe..1c64a09e51 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 @@ -31,6 +31,7 @@ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.ConsentAccessJson import code.api.util.APIUtil.{DateWithDay, DateWithSeconds, gitCommit, stringOrNull} import code.api.util.ErrorMessages.MandatoryPropertyIsNotSet +import code.api.util.RateLimitingPeriod.LimitCallPeriod import code.api.util._ import code.api.v1_2_1.BankRoutingJsonV121 import code.api.v1_4_0.JSONFactory1_4_0.{ChallengeJsonV140, LocationJsonV140, MetaJsonV140, TransactionRequestAccountJsonV140, transformToLocationFromV140, transformToMetaFromV140} @@ -38,6 +39,7 @@ import code.api.v2_0_0.TransactionRequestChargeJsonV200 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.v5_0_0.PostConsentRequestJsonV500 import code.atmattribute.AtmAttribute @@ -45,6 +47,7 @@ import code.atms.Atms.Atm import code.consent.MappedConsent import code.metrics.APIMetric import code.model.Consumer +import code.ratelimiting.RateLimiting import code.users.{UserAttribute, Users} import code.util.Helper.MdcLoggable import code.views.system.{AccountAccess, ViewDefinition, ViewPermission} @@ -147,8 +150,8 @@ case class CheckSystemIntegrityJsonV510( debug_info: Option[String] = None ) -case class ConsentJsonV510(consent_id: String, - jwt: String, +case class ConsentJsonV510(consent_id: String, + jwt: String, status: String, consent_request_id: Option[String], scopes: Option[List[Role]], @@ -466,7 +469,7 @@ case class ConsumerJsonV510(consumer_id: String, certificate_info: Option[CertificateInfoJsonV510], created_by_user: ResourceUserJSON, enabled: Boolean, - created: Date, + created: Date, logo_url: Option[String] ) case class MyConsumerJsonV510(consumer_id: String, @@ -482,7 +485,7 @@ case class MyConsumerJsonV510(consumer_id: String, certificate_info: Option[CertificateInfoJsonV510], created_by_user: ResourceUserJSON, enabled: Boolean, - created: Date, + created: Date, logo_url: Option[String] ) case class ConsumerJsonOnlyForPostResponseV510(consumer_id: String, @@ -682,6 +685,20 @@ case class ViewPermissionJson( extra_data: Option[List[String]] ) +case class CallLimitJson510( + from_date: Date, + to_date: Date, + per_second_call_limit : String, + per_minute_call_limit : String, + per_hour_call_limit : String, + per_day_call_limit : String, + per_week_call_limit : String, + per_month_call_limit : String, + created_at : Date, + updated_at : Date, + current_state: Option[RedisCallLimitJson] + ) + object JSONFactory510 extends CustomJsonFormats with MdcLoggable { def createTransactionRequestJson(tr : TransactionRequest, transactionRequestAttributes: List[TransactionRequestAttributeTrait] ) : TransactionRequestJsonV510 = { @@ -718,7 +735,7 @@ object JSONFactory510 extends CustomJsonFormats with MdcLoggable { def createTransactionRequestJSONs(transactionRequests : List[TransactionRequest], transactionRequestAttributes: List[TransactionRequestAttributeTrait]) : TransactionRequestsJsonV510 = { TransactionRequestsJsonV510( transactionRequests.map( - transactionRequest => + transactionRequest => createTransactionRequestJson(transactionRequest, transactionRequestAttributes) )) } @@ -1259,13 +1276,13 @@ object JSONFactory510 extends CustomJsonFormats with MdcLoggable { if(value == null || value.isEmpty) None else Some(value.split(",").toList) ) } - + def createMinimalAgentsJson(agents: List[Agent]): MinimalAgentsJsonV510 = { MinimalAgentsJsonV510( agents .filter(_.isConfirmedAgent == true) .map(agent => MinimalAgentJsonV510( - agent_id = agent.agentId, + agent_id = agent.agentId, legal_name = agent.legalName, agent_number = agent.number ))) @@ -1306,4 +1323,48 @@ object JSONFactory510 extends CustomJsonFormats with MdcLoggable { ) } + def createCallLimitJson(consumer: Consumer, rateLimiting: Option[RateLimiting], rateLimits: List[((Option[Long], Option[Long]), LimitCallPeriod)]): CallLimitJson510 = { + val redisRateLimit = rateLimits match { + case Nil => None + case _ => + def getInfo(period: RateLimitingPeriod.Value): Option[RateLimit] = { + rateLimits.filter(_._2 == period) match { + case x :: Nil => + x._1 match { + case (Some(x), Some(y)) => Some(RateLimit(Some(x), Some(y))) + case _ => None + + } + case _ => None + } + } + + Some( + RedisCallLimitJson( + getInfo(RateLimitingPeriod.PER_SECOND), + getInfo(RateLimitingPeriod.PER_MINUTE), + getInfo(RateLimitingPeriod.PER_HOUR), + getInfo(RateLimitingPeriod.PER_DAY), + getInfo(RateLimitingPeriod.PER_WEEK), + getInfo(RateLimitingPeriod.PER_MONTH) + ) + ) + } + + CallLimitJson510( + from_date = rateLimiting.map(_.fromDate).orNull, + to_date = rateLimiting.map(_.toDate).orNull, + per_second_call_limit = rateLimiting.map(_.perSecondCallLimit.toString).getOrElse("-1"), + per_minute_call_limit = rateLimiting.map(_.perMinuteCallLimit.toString).getOrElse("-1"), + per_hour_call_limit = rateLimiting.map(_.perHourCallLimit.toString).getOrElse("-1"), + per_day_call_limit = rateLimiting.map(_.perDayCallLimit.toString).getOrElse("-1"), + per_week_call_limit = rateLimiting.map(_.perWeekCallLimit.toString).getOrElse("-1"), + per_month_call_limit = rateLimiting.map(_.perMonthCallLimit.toString).getOrElse("-1"), + created_at = rateLimiting.map(_.createdAt.get).orNull, + updated_at = rateLimiting.map(_.updatedAt.get).orNull, + redisRateLimit + ) + + } + } diff --git a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala index 6ce5227d3b..bf01e1eaaa 100644 --- a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala @@ -101,6 +101,28 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait { } result } + + def findMostRecentRateLimit(consumerId: String, + bankId: Option[String], + apiVersion: Option[String], + apiName: Option[String]): Future[Option[RateLimiting]] = Future { + findMostRecentRateLimitCommon(consumerId, bankId, apiVersion, apiName) + } + def findMostRecentRateLimitCommon(consumerId: String, + bankId: Option[String], + apiVersion: Option[String], + apiName: Option[String]): Option[RateLimiting] = { + val byConsumerParam = By(RateLimiting.ConsumerId, consumerId) + val byBankParam = bankId.map(v => By(RateLimiting.BankId, v)).getOrElse(NullRef(RateLimiting.BankId)) + val byApiVersionParam = apiVersion.map(v => By(RateLimiting.ApiVersion, v)).getOrElse(NullRef(RateLimiting.ApiVersion)) + val byApiNameParam = apiName.map(v => By(RateLimiting.ApiName, v)).getOrElse(NullRef(RateLimiting.ApiName)) + + RateLimiting.findAll( + byConsumerParam, byBankParam, byApiVersionParam, byApiNameParam, + OrderBy(RateLimiting.updatedAt, Descending) + ).headOption + } + def createOrUpdateConsumerCallLimits(consumerId: String, fromDate: Date, toDate: Date, @@ -113,64 +135,40 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait { perDay: Option[String], perWeek: Option[String], perMonth: Option[String]): Future[Box[RateLimiting]] = Future { - - def createRateLimit(c: RateLimiting): Box[RateLimiting] = { + + def createOrUpdateRateLimit(c: RateLimiting): Box[RateLimiting] = { tryo { c.FromDate(fromDate) c.ToDate(toDate) - perSecond match { - case Some(v) => c.PerSecondCallLimit(v.toLong) - case None => - } - perMinute match { - case Some(v) => c.PerMinuteCallLimit(v.toLong) - case None => - } - perHour match { - case Some(v) => c.PerHourCallLimit(v.toLong) - case None => - } - perDay match { - case Some(v) => c.PerDayCallLimit(v.toLong) - case None => - } - perWeek match { - case Some(v) => c.PerWeekCallLimit(v.toLong) - case None => - } - perMonth match { - case Some(v) => c.PerMonthCallLimit(v.toLong) - case None => - } - bankId match { - case Some(v) => c.BankId(v) - case None => c.BankId(null) - } - apiName match { - case Some(v) => c.ApiName(v) - case None => c.ApiName(null) - } - apiVersion match { - case Some(v) => c.ApiVersion(v) - case None => c.ApiVersion(null) - } + + perSecond.foreach(v => c.PerSecondCallLimit(v.toLong)) + perMinute.foreach(v => c.PerMinuteCallLimit(v.toLong)) + perHour.foreach(v => c.PerHourCallLimit(v.toLong)) + perDay.foreach(v => c.PerDayCallLimit(v.toLong)) + perWeek.foreach(v => c.PerWeekCallLimit(v.toLong)) + perMonth.foreach(v => c.PerMonthCallLimit(v.toLong)) + + c.BankId(bankId.orNull) + c.ApiName(apiName.orNull) + c.ApiVersion(apiVersion.orNull) c.ConsumerId(consumerId) + + // 👇 bump timestamp for last-write-wins + c.updatedAt(new Date()) + c.saveMe() } } - - val byConsumerParam = By(RateLimiting.ConsumerId, consumerId) - val byBankParam = if(bankId.isDefined) By(RateLimiting.BankId, bankId.get) else NullRef(RateLimiting.BankId) - val byApiVersionParam = if(apiVersion.isDefined) By(RateLimiting.ApiVersion, apiVersion.get) else NullRef(RateLimiting.ApiVersion) - val byApiNameParam = if(apiName.isDefined) By(RateLimiting.ApiName, apiName.get) else NullRef(RateLimiting.ApiName) - - val rateLimit = RateLimiting.find(byConsumerParam, byBankParam, byApiVersionParam, byApiNameParam) - val result = rateLimit match { - case Full(limit) => createRateLimit(limit) - case _ => createRateLimit(RateLimiting.create) + + val result = findMostRecentRateLimitCommon(consumerId, bankId, apiVersion, apiName) match { + case Some(limit) => createOrUpdateRateLimit(limit) + case None => createOrUpdateRateLimit(RateLimiting.create) } + result } + + } class RateLimiting extends RateLimitingTrait with LongKeyedMapper[RateLimiting] with IdPK with CreatedUpdated { diff --git a/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala index 728da15c00..27fc319c54 100644 --- a/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala @@ -17,6 +17,7 @@ trait RateLimitingProviderTrait { def getAll(): Future[List[RateLimiting]] def getAllByConsumerId(consumerId: String, date: Option[Date] = None): Future[List[RateLimiting]] def getByConsumerId(consumerId: String, apiVersion: String, apiName: String, date: Option[Date] = None): Future[Box[RateLimiting]] + def findMostRecentRateLimit(consumerId: String, bankId: Option[String], apiVersion: Option[String], apiName: Option[String]): Future[Option[RateLimiting]] def createOrUpdateConsumerCallLimits(consumerId: String, fromDate: Date, toDate: Date, diff --git a/obp-api/src/test/scala/code/api/v5_1_0/RateLimitingTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/RateLimitingTest.scala new file mode 100644 index 0000000000..4b9b4f5272 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_1_0/RateLimitingTest.scala @@ -0,0 +1,132 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2019, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v5_1_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole +import code.api.util.ApiRole.CanReadCallLimits +import code.api.util.ErrorMessages.{UserHasMissingRoles, UserNotLoggedIn} +import code.api.v4_0_0.CallLimitPostJsonV400 +import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0 +import code.consumer.Consumers +import code.entitlement.Entitlement +import code.setup.PropsReset +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +import java.time.format.DateTimeFormatter +import java.time.{ZoneId, ZonedDateTime} +import java.util.Date + +class RateLimitingTest extends V510ServerSetup with PropsReset { + + /** + * Test tags + * Example: To run tests with tag "getPermissions": + * mvn test -D tagsToInclude + * + * This is made possible by the scalatest maven plugin + */ + object ApiVersion400 extends Tag(ApiVersion.v4_0_0.toString) + object ApiVersion510 extends Tag(ApiVersion.v5_1_0.toString) + object ApiCallsLimit extends Tag(nameOf(Implementations5_1_0.getCallsLimit)) + + override def beforeEach() = { + super.beforeEach() + setPropsValues("use_consumer_limits"->"true") + setPropsValues("user_consumer_limit_anonymous_access"->"6000") + } + + val yesterday = ZonedDateTime.now(ZoneId.of("UTC")).minusDays(1) + val tomorrow = ZonedDateTime.now(ZoneId.of("UTC")).plusDays(10) + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'") + + val fromDate = Date.from(yesterday.toInstant()) + val toDate = Date.from(tomorrow.toInstant()) + + val callLimitJsonInitial = CallLimitPostJsonV400( + from_date = fromDate, + to_date = toDate, + api_version = None, + api_name = None, + bank_id = None, + per_second_call_limit = "-1", + per_minute_call_limit = "-1", + per_hour_call_limit = "-1", + per_day_call_limit ="-1", + per_week_call_limit = "-1", + per_month_call_limit = "-1" + ) + val callLimitJsonMonth: CallLimitPostJsonV400 = callLimitJsonInitial.copy(per_month_call_limit = "100") + + + feature("Rate Limit - " + ApiCallsLimit + " - " + ApiVersion400) { + + scenario("We will try to get calls limit per minute for a Consumer - unauthorized access", ApiCallsLimit, ApiVersion510) { + When(s"We make a request $ApiVersion510") + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + val request510 = (v5_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").GET + val response510 = makeGetRequest(request510) + Then("We should get a 401") + response510.code should equal(401) + And("error should be " + UserNotLoggedIn) + response510.body.extract[ErrorMessage].message should equal(UserNotLoggedIn) + } + scenario("We will try to get calls limit per minute without a proper Role " + ApiRole.canReadCallLimits, ApiCallsLimit, ApiVersion510) { + When("We make a request v3.1.0 without a Role " + ApiRole.canReadCallLimits) + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + val request510 = (v5_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").GET <@ (user1) + val response510 = makeGetRequest(request510) + Then("We should get a 403") + response510.code should equal(403) + And("error should be " + UserHasMissingRoles + CanReadCallLimits) + response510.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanReadCallLimits) + } + scenario("We will try to get calls limit per minute with a proper Role " + ApiRole.canReadCallLimits, ApiCallsLimit, ApiVersion510) { + + When("We make a request v5.1.0 with a Role " + ApiRole.canSetCallLimits) + val response01 = setRateLimiting(user1, callLimitJsonMonth) + Then("We should get a 200") + response01.code should equal(200) + + When(s"We make a request v$ApiVersion510 with a Role " + ApiRole.canReadCallLimits) + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.CanReadCallLimits.toString) + val request510 = (v5_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").GET <@ (user1) + val response510 = makeGetRequest(request510) + Then("We should get a 200") + response510.code should equal(200) + response510.body.extract[CallLimitJson510] + + } + + } +} \ No newline at end of file diff --git a/obp-api/src/test/scala/code/api/v5_1_0/V510ServerSetup.scala b/obp-api/src/test/scala/code/api/v5_1_0/V510ServerSetup.scala index e25fd43b24..32fa520658 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/V510ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/V510ServerSetup.scala @@ -11,8 +11,9 @@ import code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140 import code.api.v2_0_0.{BasicAccountsJSON, TransactionRequestBodyJsonV200} import code.api.v3_0_0.ViewJsonV300 import code.api.v3_1_0.{CreateAccountRequestJsonV310, CreateAccountResponseJsonV310, CustomerJsonV310} -import code.api.v4_0_0.{AtmJsonV400, BanksJson400, PostAccountAccessJsonV400, PostViewJsonV400, TransactionRequestWithChargeJSON400} +import code.api.v4_0_0.{AtmJsonV400, BanksJson400, CallLimitPostJsonV400, PostAccountAccessJsonV400, PostViewJsonV400, TransactionRequestWithChargeJSON400} import code.api.v5_0_0.PostCustomerJsonV500 +import code.consumer.Consumers import code.entitlement.Entitlement import code.setup.{APIResponse, DefaultUsers, ServerSetupWithTestData} import com.openbankproject.commons.model.{AccountRoutingJsonV121, AmountOfMoneyJsonV121, CreateViewJson} @@ -32,6 +33,15 @@ trait V510ServerSetup extends ServerSetupWithTestData with DefaultUsers { def dynamicEndpoint_Request: Req = baseRequest / "obp" / ApiShortVersions.`dynamic-endpoint`.toString def dynamicEntity_Request: Req = baseRequest / "obp" / ApiShortVersions.`dynamic-entity`.toString + + def setRateLimiting(consumerAndToken: Option[(Consumer, Token)], putJson: CallLimitPostJsonV400): APIResponse = { + val Some((c, _)) = consumerAndToken + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.CanSetCallLimits.toString) + val request400 = (v4_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@ (consumerAndToken) + makePutRequest(request400, write(putJson)) + } + def randomBankId : String = { def getBanksInfo : APIResponse = { val request = v5_1_0_Request / "banks" diff --git a/zed/tasks.json b/zed/tasks.json index 4655b6ea82..a369daf572 100644 --- a/zed/tasks.json +++ b/zed/tasks.json @@ -4,8 +4,8 @@ "command": "mvn", "args": ["jetty:run", "-pl", "obp-api"], "env": { - "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED" - }, + "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/java.util.stream=ALL-UNNAMED --add-opens=java.base/java.util.regex=ALL-UNNAMED" + } "use_new_terminal": true, "allow_concurrent_runs": false, "reveal": "always", From d56fcf300464d0464b2e31420d9ad6cbfa36f72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 8 Sep 2025 13:37:00 +0200 Subject: [PATCH 4/6] feature/Tweak function getRegulatedEntityByCertificate --- .../main/scala/code/api/util/APIUtil.scala | 25 ++++++---- .../code/api/util/BerlinGroupSigning.scala | 49 ++++++++++++------- .../scala/code/api/util/ErrorMessages.scala | 1 + .../MappedRegulatedEntitiyProvider.scala | 24 ++++----- 4 files changed, 55 insertions(+), 44 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index a948c2e2eb..132948c3ad 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3999,17 +3999,22 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val consumerName = cc.flatMap(_.consumer.map(_.name.get)).getOrElse("") val certificate = getCertificateFromTppSignatureCertificate(requestHeaders) for { - tpp <- BerlinGroupSigning.getTppByCertificate(certificate, cc) + tpps <- BerlinGroupSigning.getRegulatedEntityByCertificate(certificate, cc) } yield { - if (tpp.nonEmpty) { - val hasRole = tpp.exists(_.services.contains(serviceProvider)) - if (hasRole) { - Full(true) - } else { - Failure(X509ActionIsNotAllowed) - } - } else { - Failure("No valid Tpp") + tpps match { + case Nil => + Failure(RegulatedEntityNotFoundByCertificate) + case single :: Nil => + // Only one match, proceed to role check + if (single.services.contains(serviceProvider)) { + Full(true) + } else { + Failure(X509ActionIsNotAllowed) + } + case multiple => + // Ambiguity detected: more than one TPP matches the certificate + val names = multiple.map(e => s"'${e.entityName}' (Code: ${e.entityCode})").mkString(", ") + Failure(s"$RegulatedEntityAmbiguityByCertificate: multiple TPPs found: $names") } } case value if value.toUpperCase == "CERTIFICATE" => Future { diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala index b5b23376a7..cb186a4396 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala @@ -8,7 +8,7 @@ import code.consumer.Consumers import code.model.Consumer import code.util.Helper.MdcLoggable import com.openbankproject.commons.ExecutionContext.Implicits.global -import com.openbankproject.commons.model.{RegulatedEntityTrait, User} +import com.openbankproject.commons.model.{RegulatedEntityAttributeSimple, RegulatedEntityTrait, User} import net.liftweb.common.{Box, Empty, Failure, Full} import net.liftweb.http.provider.HTTPParam import net.liftweb.util.Helpers @@ -111,29 +111,40 @@ object BerlinGroupSigning extends MdcLoggable { certificate } - def getTppByCertificate(certificate: X509Certificate, callContext: Option[CallContext]): Future[List[RegulatedEntityTrait]] = { - // Use the regular expression to find the value of CN - val extractedCN = cnPattern.findFirstMatchIn(certificate.getIssuerDN.getName) match { - case Some(m) => m.group(1) // Extract the value of CN - case None => "CN not found" - } - val issuerCommonName = extractedCN // Certificate.caCert + def getRegulatedEntityByCertificate(certificate: X509Certificate, callContext: Option[CallContext]): Future[List[RegulatedEntityTrait]] = { + val issuerCN = cnPattern.findFirstMatchIn(certificate.getIssuerDN.getName) + .map(_.group(1).trim) + .getOrElse("CN not found") + val serialNumber = certificate.getSerialNumber.toString - val regulatedEntities: Future[List[RegulatedEntityTrait]] = for { + + for { (entities, _) <- getRegulatedEntitiesNewStyle(callContext) } yield { - logger.debug("Regulated Entities: " + entities) entities.filter { entity => - val hasSerialNumber = entity.attributes.exists(_.exists(a => - a.name == "CERTIFICATE_SERIAL_NUMBER" && a.value == serialNumber - )) - val hasCaName = entity.attributes.exists(_.exists(a => - a.name == "CERTIFICATE_CA_NAME" && a.value == issuerCommonName - )) - hasSerialNumber && hasCaName + val attrs = entity.attributes.getOrElse(Nil) + + // Extract serial number and CA name from attributes + val serialOpt = attrs.collectFirst { case a if a.name.equalsIgnoreCase("CERTIFICATE_SERIAL_NUMBER") => a.value.trim } + val caNameOpt = attrs.collectFirst { case a if a.name.equalsIgnoreCase("CERTIFICATE_CA_NAME") => a.value.trim } + + val serialMatches = serialOpt.contains(serialNumber) + val caNameMatches = caNameOpt.exists(_.equalsIgnoreCase(issuerCN)) + + val isMatch = serialMatches && caNameMatches + + // Log everything for debugging + val serialLog = serialOpt.getOrElse("N/A") + val caNameLog = caNameOpt.getOrElse("N/A") + val allAttrsLog = attrs.map(a => s"${a.name}='${a.value}'").mkString(", ") + + if (isMatch) + logger.debug(s"[MATCH] Entity '${entity.entityName}' (Code: ${entity.entityCode}) matches CN='$issuerCN', Serial='$serialNumber' " + + s"(Attributes found: Serial='$serialLog', CA Name='$caNameLog', All Attributes: [$allAttrsLog])") + + isMatch } } - regulatedEntities } @@ -280,7 +291,7 @@ object BerlinGroupSigning extends MdcLoggable { } for { - entities <- getTppByCertificate(certificate, forwardResult._2) // Find TPP via certificate + entities <- getRegulatedEntityByCertificate(certificate, forwardResult._2) // Find Regulated Entity via certificate } yield { // Certificate can be changed but this value is permanent per Regulated entity val idno = entities.map(_.entityCode).headOption.getOrElse("") diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 3e9f6fe084..756c5e5d4c 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -580,6 +580,7 @@ object ErrorMessages { val RegulatedEntityNotFound = "OBP-34100: Regulated Entity not found. Please specify a valid value for REGULATED_ENTITY_ID." val RegulatedEntityNotDeleted = "OBP-34101: Regulated Entity cannot be deleted. Please specify a valid value for REGULATED_ENTITY_ID." val RegulatedEntityNotFoundByCertificate = "OBP-34102: Regulated Entity cannot be found by provided certificate." + val RegulatedEntityAmbiguityByCertificate = "OBP-34103: More than 1 Regulated Entity found by provided certificate." val PostJsonIsNotSigned = "OBP-34110: JWT at the post json cannot be verified." // Consents diff --git a/obp-api/src/main/scala/code/regulatedentities/MappedRegulatedEntitiyProvider.scala b/obp-api/src/main/scala/code/regulatedentities/MappedRegulatedEntitiyProvider.scala index 67fa429900..8023da53d4 100644 --- a/obp-api/src/main/scala/code/regulatedentities/MappedRegulatedEntitiyProvider.scala +++ b/obp-api/src/main/scala/code/regulatedentities/MappedRegulatedEntitiyProvider.scala @@ -1,7 +1,8 @@ package code.regulatedentities +import code.regulatedentities.attribute.RegulatedEntityAttribute import code.util.MappedUUID -import com.openbankproject.commons.model.{RegulatedEntityTrait,RegulatedEntityAttributeSimple} +import com.openbankproject.commons.model.{RegulatedEntityAttributeSimple, RegulatedEntityTrait} import net.liftweb.common.Box import net.liftweb.common.Box.tryo import net.liftweb.mapper._ @@ -120,20 +121,13 @@ class MappedRegulatedEntity extends RegulatedEntityTrait with LongKeyedMapper[Ma override def entityCountry: String = EntityCountry.get override def entityWebSite: String = EntityWebSite.get override def services: String = Services.get - override def attributes: Option[List[RegulatedEntityAttributeSimple]] = Some( - List( - RegulatedEntityAttributeSimple( - attributeType="STRING", - name="CERTIFICATE_SERIAL_NUMBER", - value="1082" - ), - RegulatedEntityAttributeSimple( - attributeType="STRING", - name="CERTIFICATE_CA_NAME", - value="BNM CA (test)" - ), - )) -// override def attributes: Option[List[RegulatedEntityAttributeSimple]] = None //not for mapped mode yet, will add it later. + override def attributes: Option[List[RegulatedEntityAttributeSimple]] = { + Some( + RegulatedEntityAttribute.findAll( + By(RegulatedEntityAttribute.RegulatedEntityId_, EntityId.get) + ).map(i => RegulatedEntityAttributeSimple(i.attributeType.toString, i.name, i.value)) + ) + } } From 6538dd4d70b85eb55b7196cac769995bd203f8fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 9 Sep 2025 13:59:14 +0200 Subject: [PATCH 5/6] feature/Add check is it consumer disabled --- obp-api/src/main/scala/code/api/util/APIUtil.scala | 4 +++- .../main/scala/code/api/util/AfterApiAuth.scala | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 132948c3ad..d22d76c3f2 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3196,8 +3196,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // COMMON POST AUTHENTICATION CODE GOES BELOW + // Check is it Consumer disabled + val consumerIsDisabled: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkConsumerIsDisabled(res) // Check is it a user deleted or locked - val userIsLockedOrDeleted: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkUserIsDeletedOrLocked(res) + val userIsLockedOrDeleted: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkUserIsDeletedOrLocked(consumerIsDisabled) // Check Rate Limiting val resultWithRateLimiting: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkRateLimiting(userIsLockedOrDeleted) // User init actions diff --git a/obp-api/src/main/scala/code/api/util/AfterApiAuth.scala b/obp-api/src/main/scala/code/api/util/AfterApiAuth.scala index d063303a47..0fc70591c1 100644 --- a/obp-api/src/main/scala/code/api/util/AfterApiAuth.scala +++ b/obp-api/src/main/scala/code/api/util/AfterApiAuth.scala @@ -6,7 +6,7 @@ import code.accountholders.AccountHolders import code.api.Constant import code.api.util.APIUtil.getPropsAsBoolValue import code.api.util.ApiRole.{CanCreateAccount, CanCreateHistoricalTransactionAtBank} -import code.api.util.ErrorMessages.{UserIsDeleted, UsernameHasBeenLocked} +import code.api.util.ErrorMessages.{ConsumerIsDisabled, UserIsDeleted, UsernameHasBeenLocked} import code.api.util.RateLimitingJson.CallLimit import code.bankconnectors.{Connector, LocalMappedConnectorInternal} import code.entitlement.Entitlement @@ -78,6 +78,18 @@ object AfterApiAuth extends MdcLoggable{ } } } + def checkConsumerIsDisabled(res: Future[(Box[User], Option[CallContext])]): Future[(Box[User], Option[CallContext])] = { + for { + (user: Box[User], cc) <- res + } yield { + cc.map(_.consumer) match { + case Some(Full(consumer)) if !consumer.isActive.get => // There is a consumer. Check it. + (Failure(ConsumerIsDisabled), cc) // The Consumer is DISABLED. + case _ => // There is no Consumer. Just forward the result. + (user, cc) + } + } + } /** * This block of code needs to update Call Context with Rate Limiting From c39c1338284dbdbf7261f76e1f78f7a67fd4e15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 11 Sep 2025 09:37:26 +0200 Subject: [PATCH 6/6] feature/Cannot create consent for max amount - 180 Days --- .../berlin/group/v1_3/BgSpecValidation.scala | 23 +--- .../group/v1_3/BgSpecValidationTest.scala | 106 ++++++++++++++++++ 2 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/berlin/group/v1_3/BgSpecValidationTest.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala index 13c589a00b..646873c1f5 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BgSpecValidation.scala @@ -36,10 +36,10 @@ object BgSpecValidation { if (date.isBefore(today)) { Left(s"$InvalidDateFormat The `validUntil` date ($dateStr) cannot be in the past!") - } else if (date.isEqual(MaxValidDays) || date.isAfter(MaxValidDays)) { + } else if (date.isAfter(MaxValidDays)) { Left(s"$InvalidDateFormat The `validUntil` date ($dateStr) exceeds the maximum allowed period of 180 days (until $MaxValidDays).") } else { - Right(date) // Valid date + Right(date) // Valid date (inclusive of 180 days) } } catch { case _: DateTimeParseException => @@ -55,23 +55,4 @@ object BgSpecValidation { } } - // Example usage - def main(args: Array[String]): Unit = { - val testDates = Seq( - "2025-05-10", // More than 180 days ahead - "9999-12-31", // Exceeds max allowed - "2015-01-01", // In the past - "invalid-date", // Invalid format - LocalDate.now().plusDays(90).toString, // Valid (within 180 days) - LocalDate.now().plusDays(180).toString, // Valid (exactly 180 days) - LocalDate.now().plusDays(181).toString // More than 180 days - ) - - testDates.foreach { date => - validateValidUntil(date) match { - case Right(validDate) => println(s"Valid date: $validDate") - case Left(error) => println(s"Error: $error") - } - } - } } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BgSpecValidationTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BgSpecValidationTest.scala new file mode 100644 index 0000000000..9971995043 --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BgSpecValidationTest.scala @@ -0,0 +1,106 @@ +package code.api.berlin.group.v1_3 + +import code.api.berlin.group.v1_3.BgSpecValidation._ +import code.api.v4_0_0.V400ServerSetup +import org.scalatest.Tag + +import java.time.LocalDate +import java.util.Date + +class BgSpecValidationTest extends V400ServerSetup { + + // Test tags + object File extends Tag("BgSpecValidation.scala") + object Function1 extends Tag("validateValidUntil") + object Function2 extends Tag("getErrorMessage") + object Function3 extends Tag("getDate") + object Function4 extends Tag("formatToISODate") + + feature(s"Test function: $Function1 at file $File") { + + scenario("Reject past date", Function1) { + When("The client provides a date in the past") + val yesterday = LocalDate.now().minusDays(1).toString + + Then("It should be rejected") + val error = getErrorMessage(yesterday) + error should include("cannot be in the past") + } + + scenario("Accept today's date", Function1) { + When("The client provides today's date") + val today = LocalDate.now().toString + + Then("It should be accepted") + val error = getErrorMessage(today) + error shouldBe "" + } + + scenario("Accept exactly 180 days in the future", Function1) { + When("The client provides the maximum allowed date (180 days)") + val maxDay = MaxValidDays.toString + + Then("It should be accepted") + val error = getErrorMessage(maxDay) + error shouldBe "" + } + + scenario("Reject date beyond 180 days", Function1) { + When("The client provides a date 181 days in the future") + val tooFar = MaxValidDays.plusDays(1).toString + + Then("It should be rejected") + val error = getErrorMessage(tooFar) + error should include("exceeds the maximum allowed period") + } + + scenario("Reject invalid date format", Function1) { + When("The client provides a date in wrong format") + val invalid = "2025/12/31" + + Then("It should be rejected") + val error = getErrorMessage(invalid) + error should include("invalid") + } + } + + feature(s"Test function: $Function2 and $Function3 at file $File") { + + scenario("getDate returns valid Date for correct input", Function3) { + When("We provide a valid ISO date") + val today = LocalDate.now().toString + val result = getDate(today) + + Then("It should return a non-null Date") + result shouldBe a[Date] + } + + scenario("getDate returns null for invalid input", Function3) { + When("We provide an invalid date format") + val result = getDate("2025/12/31") + + Then("It should return null") + result shouldBe null + } + } + + feature(s"Test function: $Function4 at file $File") { + + scenario("formatToISODate formats a valid Date", Function4) { + When("We pass a valid Date object") + val today = new Date() + val formatted = formatToISODate(today) + + Then("It should return an ISO date string") + formatted should fullyMatch regex """\d{4}-\d{2}-\d{2}""" + } + + scenario("formatToISODate handles null gracefully", Function4) { + When("We pass null") + val formatted = formatToISODate(null) + + Then("It should return empty string") + formatted shouldBe "" + } + } +}