Merge remote-tracking branch 'upstream/develop' into develop

This commit is contained in:
Marko Milić 2025-12-23 13:37:28 +01:00
commit 2720f10165
12 changed files with 894 additions and 160 deletions

View File

@ -1,19 +1,32 @@
# Contributing
## Hello!
Thank you for your interest in contributing to the Open Bank Project!
## Coding Standards
### Character Encoding
- **Use UTF-8 encoding** for all source files
- **DO NOT use emojis** in source code (scripts, Scala, Java, config files, etc.)
- **Emojis are only allowed in Markdown (.md) files** - use them if you must.
- **Avoid non-ASCII characters** in code unless absolutely necessary (e.g., comments in non-English languages)
- Use plain ASCII alternatives in source code:
- Instead of checkmark use [OK] or PASS
- Instead of X mark use [FAIL] or ERROR
- Instead of multiply use x
- Instead of arrow use -> or <-
Thank you for your interest in contributing to the Open Bank Project!
## Pull requests
If submitting a pull request please read and sign our [CLA](http://github.com/OpenBankProject/OBP-API/blob/develop/Harmony_Individual_Contributor_Assignment_Agreement.txt) first.
If submitting a pull request please read and sign our [CLA](http://github.com/OpenBankProject/OBP-API/blob/develop/Harmony_Individual_Contributor_Assignment_Agreement.txt) first.
In the first instance it is sufficient if you create a text file of the CLA with your name and include that in a git commit description.
If you end up making large changes to the source code, we might ask for a paper signed copy of your CLA sent by email to contact@tesobe.com
If you end up making large changes to the source code, we might ask for a paper signed copy of your CLA sent by email to contact@tesobe.com
## Git commit messages
Please structure git commit messages in a way as shown below:
1. bugfix/Something
2. feature/Something
3. docfix/Something
@ -89,6 +102,7 @@ When naming variables use strict camel case e.g. use myUrl not myURL. This is so
}
}
```
### Recommended order of checks at an endpoint
```scala
@ -98,30 +112,34 @@ When naming variables use strict camel case e.g. use myUrl not myURL. This is so
for {
// 1. makes sure the user which attempts to use the endpoint is authorized
(Full(u), callContext) <- authorizedAccess(cc)
// 2. makes sure the user which attempts to use the endpoint is allowed to consume it
// 2. makes sure the user which attempts to use the endpoint is allowed to consume it
_ <- NewStyle.function.hasAtLeastOneEntitlement(failMsg = createProductEntitlementsRequiredText)(bankId.value, u.userId, createProductEntitlements, callContext)
// 3. checks the endpoint constraints
(_, callContext) <- NewStyle.function.getBank(bankId, callContext)
failMsg = s"$InvalidJsonFormat The Json body should be the $PostPutProductJsonV310 "
...
```
Please note that that checks at an endpoint should be applied only in case an user is authorized and has privilege to consume the endpoint. Otherwise we can reveal sensitive data to the user. For instace if we reorder the checks in next way:
```scala
// 1. makes sure the user which attempts to use the endpoint is authorized
(Full(u), callContext) <- authorizedAccess(cc)
// 3. checks the endpoint constraints
(_, callContext) <- NewStyle.function.getBank(bankId, callContext)
failMsg = s"$InvalidJsonFormat The Json body should be the $PostPutProductJsonV310 "
failMsg = s"$InvalidJsonFormat The Json body should be the $PostPutProductJsonV310 "
(Full(u), callContext) <- authorizedAccess(cc)
// 2. makes sure the user which attempts to use the endpoint is allowed to consume it
_ <- NewStyle.function.hasAtLeastOneEntitlement(failMsg = createProductEntitlementsRequiredText)(bankId.value, u.userId, createProductEntitlements, callContext)
// 2. makes sure the user which attempts to use the endpoint is allowed to consume it
_ <- NewStyle.function.hasAtLeastOneEntitlement(failMsg = createProductEntitlementsRequiredText)(bankId.value, u.userId, createProductEntitlements, callContext)
```
the user which cannot consume the endpoint still can check does some bank exist or not at that instance. It's not the issue if banks are public data at the instance but it wouldn't be the only business case all the time.
## Writing tests
When you write a test for an endpoint please tag it with a version and the endpoint.
An example of how to tag tests:
```scala
class FundsAvailableTest extends V310ServerSetup {
@ -152,10 +170,11 @@ class FundsAvailableTest extends V310ServerSetup {
}
}
```
```
## Code Generation
We support to generate the OBP-API code from the following three types of json. You can choose one of them as your own requirements.
We support to generate the OBP-API code from the following three types of json. You can choose one of them as your own requirements.
1 Choose one of the following types: type1 or type2 or type3
2 Modify the json file your selected, for now, we only support these three types: String, Double, Int. other types may throw the exceptions
@ -163,19 +182,24 @@ We support to generate the OBP-API code from the following three types of json.
4 Run/Restart OBP-API project.
5 Run API_Exploer project to test your new APIs. (click the Tag `APIBuilder B1)
Here are the three types:
Here are the three types:
Type1: If you use `modelSource.json`, please run `APIBuilderModel.scala` main method
```
/OBP-API/obp-api/src/main/resources/apiBuilder/APIModelSource.json
/OBP-API/obp-api/src/main/scala/code/api/APIBuilder/APIBuilderModel.scala
```
Type2: If you use `apisResource.json`, please run `APIBuilder.scala` main method
```
/OBP-API/obp-api/src/main/resources/apiBuilder/apisResource.json
OBP-API/src/main/scala/code/api/APIBuilder/APIBuilder.scala
```
Type3: If you use `swaggerResource.json`, please run `APIBuilderSwagger.scala` main method
```
/OBP-API/obp-api/src/main/resources/apiBuilder/swaggerResource.json
OBP-API/src/main/scala/code/api/APIBuilder/APIBuilderSwagger.scala

View File

@ -586,7 +586,7 @@
<forkMode>once</forkMode>
<junitxml>.</junitxml>
<filereports>WDF TestSuite.txt</filereports>
<argLine>-Drun.mode=test -XX:MaxMetaspaceSize=512m -Xms512m -Xmx512m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED</argLine>
<argLine>-Drun.mode=test -XX:MaxMetaspaceSize=512m -Xms512m -Xmx512m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.lang.invoke=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.jar=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED</argLine>
<tagsToExclude>code.external</tagsToExclude>
</configuration>
<executions>

View File

@ -27,6 +27,11 @@ starConnector_supported_types = mapped,internal
# Connector cache time-to-live in seconds, caching disabled if not set
#connector.cache.ttl.seconds=3
# Disable metrics writing during tests to prevent database bloat
# Metrics accumulate with every API call - with 2000+ tests this can create 100,000+ records
# causing MetricsTest to hang on bulkDelete operations
# Note: Specific tests (like code.api.v5_1_0.MetricTest) explicitly enable this when needed
write_metrics = false
#this is needed for oauth to work. it's important to access the api over this url, e.g.
# if this is 127.0.0.1 don't use localhost to access it.
@ -56,8 +61,9 @@ End of minimum settings
# if connector is mapped, set a database backend. If not set, this will be set to an in-memory h2 database by default
# you can use a no config needed h2 database by setting db.driver=org.h2.Driver and not including db.url
# Please note that since update o version 2.1.214 we use NON_KEYWORDS=VALUE to bypass reserved word issue in SQL statements
# IMPORTANT: For tests, use test_only_lift_proto.db so the cleanup script can safely delete it
#db.driver=org.h2.Driver
#db.url=jdbc:h2:./lift_proto.db;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE
#db.url=jdbc:h2:./test_only_lift_proto.db;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE
#set this to false if you don't want the api payments call to work
payments_enabled=false
@ -117,4 +123,4 @@ allow_public_views =true
#external.port=8080
# Enable /Disable Create password reset url endpoint
#ResetPasswordUrlEnabled=true
#ResetPasswordUrlEnabled=true

View File

@ -2,7 +2,7 @@ package code.abacrule
/**
* ABAC Rule Examples
*
*
* This file contains example ABAC rules that can be used as templates.
* Copy the rule code (the string in quotes) when creating new ABAC rules via the API.
*/
@ -15,21 +15,21 @@ object AbacRuleExamples {
* Only users with "admin" in their email address can access
*/
val adminOnlyRule: String =
"""user.emailAddress.contains("admin")"""
"""user.emailAddress.contains(\"admin\")"""
/**
* Example 2: Specific User Provider
* Only allow users from a specific authentication provider
*/
val providerCheckRule: String =
"""user.provider == "obp""""
"""user.provider == \"obp\""""
/**
* Example 3: User Email Domain
* Only allow users from specific email domain
*/
val emailDomainRule: String =
"""user.emailAddress.endsWith("@example.com")"""
"""user.emailAddress.endsWith(\"@example.com\")"""
/**
* Example 4: User Has Username
@ -45,14 +45,14 @@ object AbacRuleExamples {
* Only allow access to a specific bank
*/
val specificBankRule: String =
"""bankOpt.exists(_.bankId.value == "gh.29.uk")"""
"""bankOpt.exists(_.bankId.value == \"gh.29.uk\")"""
/**
* Example 6: Bank Short Name Check
* Only allow access to banks with specific short name
*/
val bankShortNameRule: String =
"""bankOpt.exists(_.shortName.contains("Example"))"""
"""bankOpt.exists(_.shortName.contains(\"Example\"))"""
/**
* Example 7: Bank Must Be Present
@ -86,21 +86,21 @@ object AbacRuleExamples {
* Only allow access to accounts with specific currency
*/
val currencyRule: String =
"""accountOpt.exists(_.currency == "EUR")"""
"""accountOpt.exists(_.currency == \"EUR\")"""
/**
* Example 11: Account Type Check
* Only allow access to savings accounts
*/
val accountTypeRule: String =
"""accountOpt.exists(_.accountType == "SAVINGS")"""
"""accountOpt.exists(_.accountType == \"SAVINGS\")"""
/**
* Example 12: Account Label Contains
* Only allow access to accounts with specific label
*/
val accountLabelRule: String =
"""accountOpt.exists(_.label.contains("VIP"))"""
"""accountOpt.exists(_.label.contains(\"VIP\"))"""
// ==================== TRANSACTION-BASED RULES ====================
@ -127,14 +127,14 @@ object AbacRuleExamples {
* Only allow access to specific transaction types
*/
val transactionTypeRule: String =
"""transactionOpt.exists(_.transactionType == "PAYMENT")"""
"""transactionOpt.exists(_.transactionType == \"PAYMENT\")"""
/**
* Example 16: Transaction Currency Check
* Only allow access to transactions in specific currency
*/
val transactionCurrencyRule: String =
"""transactionOpt.exists(_.currency == "USD")"""
"""transactionOpt.exists(_.currency == \"USD\")"""
// ==================== CUSTOMER-BASED RULES ====================
@ -143,21 +143,21 @@ object AbacRuleExamples {
* Only allow access if customer email is from specific domain
*/
val customerEmailDomainRule: String =
"""customerOpt.exists(_.email.endsWith("@corporate.com"))"""
"""customerOpt.exists(_.email.endsWith(\"@corporate.com\"))"""
/**
* Example 18: Customer Legal Name Check
* Only allow access to customers with specific name pattern
*/
val customerNameRule: String =
"""customerOpt.exists(_.legalName.contains("Corporation"))"""
"""customerOpt.exists(_.legalName.contains(\"Corporation\"))"""
/**
* Example 19: Customer Mobile Number Pattern
* Only allow access to customers with specific mobile pattern
*/
val customerMobileRule: String =
"""customerOpt.exists(_.mobilePhoneNumber.startsWith("+44"))"""
"""customerOpt.exists(_.mobilePhoneNumber.startsWith(\"+44\"))"""
// ==================== COMBINED RULES ====================
@ -166,15 +166,15 @@ object AbacRuleExamples {
* Managers can only access specific bank
*/
val managerBankRule: String =
"""user.emailAddress.contains("manager") &&
|bankOpt.exists(_.bankId.value == "gh.29.uk")""".stripMargin
"""user.emailAddress.contains(\"manager\") &&
|bankOpt.exists(_.bankId.value == \"gh.29.uk\")""".stripMargin
/**
* Example 21: High Value Account Access
* Only managers can access high-value accounts
*/
val managerHighValueRule: String =
"""user.emailAddress.contains("manager") &&
"""user.emailAddress.contains(\"manager\") &&
|accountOpt.exists(account => {
| account.balance.toString.toDoubleOption.exists(_ > 50000.0)
|})""".stripMargin
@ -184,27 +184,27 @@ object AbacRuleExamples {
* Auditors can only view completed transactions
*/
val auditorTransactionRule: String =
"""user.emailAddress.contains("auditor") &&
|transactionOpt.exists(_.status == "COMPLETED")""".stripMargin
"""user.emailAddress.contains(\"auditor\") &&
|transactionOpt.exists(_.status == \"COMPLETED\")""".stripMargin
/**
* Example 23: VIP Customer Manager Access
* Only specific managers can access VIP customer accounts
*/
val vipManagerRule: String =
"""(user.emailAddress.contains("vip-manager") || user.emailAddress.contains("director")) &&
|accountOpt.exists(_.label.contains("VIP"))""".stripMargin
"""(user.emailAddress.contains(\"vip-manager\") || user.emailAddress.contains(\"director\")) &&
|accountOpt.exists(_.label.contains(\"VIP\"))""".stripMargin
/**
* Example 24: Multi-Condition Access
* Complex rule with multiple conditions
*/
val complexRule: String =
"""user.emailAddress.contains("manager") &&
|user.provider == "obp" &&
|bankOpt.exists(_.bankId.value == "gh.29.uk") &&
"""user.emailAddress.contains(\"manager\") &&
|user.provider == \"obp\" &&
|bankOpt.exists(_.bankId.value == \"gh.29.uk\") &&
|accountOpt.exists(account => {
| account.currency == "GBP" &&
| account.currency == \"GBP\" &&
| account.balance.toString.toDoubleOption.exists(_ > 5000.0) &&
| account.balance.toString.toDoubleOption.exists(_ < 100000.0)
|})""".stripMargin
@ -216,7 +216,7 @@ object AbacRuleExamples {
* Deny access to specific user
*/
val blockUserRule: String =
"""!user.emailAddress.contains("blocked@example.com")"""
"""!user.emailAddress.contains(\"blocked@example.com\")"""
/**
* Example 26: Block Inactive Accounts
@ -241,7 +241,7 @@ object AbacRuleExamples {
* Use regex-like pattern matching
*/
val emailPatternRule: String =
"""user.emailAddress.matches(".*@(internal|corporate)\\.com")"""
"""user.emailAddress.matches(\".*@(internal|corporate)\\\\.com\")"""
/**
* Example 29: Multiple Bank Access
@ -249,7 +249,7 @@ object AbacRuleExamples {
*/
val multipleBanksRule: String =
"""bankOpt.exists(bank => {
| val allowedBanks = Set("gh.29.uk", "de.10.de", "us.01.us")
| val allowedBanks = Set(\"gh.29.uk\", \"de.10.de\", \"us.01.us\")
| allowedBanks.contains(bank.bankId.value)
|})""".stripMargin
@ -269,9 +269,9 @@ object AbacRuleExamples {
* Allow access if any condition is true
*/
val orLogicRule: String =
"""user.emailAddress.contains("admin") ||
|user.emailAddress.contains("manager") ||
|user.emailAddress.contains("director")""".stripMargin
"""user.emailAddress.contains(\"admin\") ||
|user.emailAddress.contains(\"manager\") ||
|user.emailAddress.contains(\"director\")""".stripMargin
/**
* Example 32: Nested Option Handling
@ -311,7 +311,7 @@ object AbacRuleExamples {
| )
|} else {
| // Default case
| user.emailAddress.contains("admin")
| user.emailAddress.contains(\"admin\")
|}""".stripMargin
// ==================== HELPER FUNCTIONS ====================
@ -366,4 +366,4 @@ object AbacRuleExamples {
* List all available example names
*/
def listExampleNames: List[String] = getAllExamples.keys.toList.sorted
}
}

View File

@ -663,6 +663,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
List(apiTagDocumentation, apiTagApi)
)
// Note: Swagger format requires special character escaping because it builds JSON via string concatenation (unlike OBP/OpenAPI formats which use case class serialization)
def getResourceDocsSwagger : OBPEndpoint = {
case "resource-docs" :: requestedApiVersionString :: "swagger" :: Nil JsonGet _ => {

View File

@ -40,6 +40,35 @@ import scala.reflect.runtime.universe
object SwaggerJSONFactory extends MdcLoggable {
type Coll[T] = GenTraversableLike[T, _]
/**
* Escapes a string value to be safely included in JSON.
* Handles quotes, backslashes, newlines, and other special characters.
*/
private def escapeJsonString(value: String): String = {
if (value == null) return ""
value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\b", "\\b")
.replace("\f", "\\f")
}
/**
* Safely converts any value to a JSON example string.
* Handles JValue, String, and other types with proper escaping.
*/
private def safeExampleValue(value: Any): String = {
value match {
case null | None => ""
case v: JValue => try { escapeJsonString(JsonUtils.toString(v)) } catch { case e: Exception => logger.warn(s"Failed to convert JValue to string for example: ${e.getMessage}"); "" }
case v: String => escapeJsonString(v)
case v => escapeJsonString(v.toString)
}
}
//Info Object
//link ->https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#infoObject
case class InfoJson(
@ -107,14 +136,26 @@ object SwaggerJSONFactory extends MdcLoggable {
| }
|}
|""".stripMargin
json.parse(definition)
try {
json.parse(definition)
} catch {
case e: Exception =>
logger.error(s"Failed to parse ListResult schema JSON: ${e.getMessage}\nJSON was: $definition")
throw new RuntimeException(s"Invalid JSON in ListResult schema generation: ${e.getMessage}", e)
}
}
}
case class JObjectSchemaJson(jObject: JObject) extends ResponseObjectSchemaJson with JsonAble {
override def toJValue(implicit format: Formats): json.JValue = {
val schema = buildSwaggerSchema(typeOf[JObject], jObject)
json.parse(schema)
try {
json.parse(schema)
} catch {
case e: Exception =>
logger.error(s"Failed to parse JObject schema JSON: ${e.getMessage}\nSchema was: $schema")
throw new RuntimeException(s"Invalid JSON in JObject schema generation: ${e.getMessage}", e)
}
}
}
@ -122,7 +163,13 @@ object SwaggerJSONFactory extends MdcLoggable {
override def toJValue(implicit format: Formats): json.JValue = {
val schema = buildSwaggerSchema(typeOf[JArray], jArray)
json.parse(schema)
try {
json.parse(schema)
} catch {
case e: Exception =>
logger.error(s"Failed to parse JArray schema JSON: ${e.getMessage}\nSchema was: $schema")
throw new RuntimeException(s"Invalid JSON in JArray schema generation: ${e.getMessage}", e)
}
}
}
@ -646,8 +693,7 @@ object SwaggerJSONFactory extends MdcLoggable {
}
def example = exampleValue match {
case null | None => ""
case v: JValue => s""", "example": "${JsonUtils.toString(v)}" """
case v => s""", "example": "$v" """
case v => s""", "example": "${safeExampleValue(v)}" """
}
paramType match {
@ -968,11 +1014,12 @@ object SwaggerJSONFactory extends MdcLoggable {
.toList
.map(it => {
val (errorName, errorMessage) = it
val escapedMessage = escapeJsonString(errorMessage.toString)
s""""Error$errorName": {
| "properties": {
| "message": {
| "type": "string",
| "example": "$errorMessage"
| "example": "$escapedMessage"
| }
| }
}""".stripMargin
@ -989,7 +1036,14 @@ object SwaggerJSONFactory extends MdcLoggable {
//Make a final string
val definitions = "{\"definitions\":{" + particularDefinitionsPart + "}}"
//Make a jsonAST from a string
parse(definitions)
try {
parse(definitions)
} catch {
case e: Exception =>
logger.error(s"Failed to parse Swagger definitions JSON: ${e.getMessage}")
logger.error(s"JSON was: ${definitions.take(500)}...")
throw new RuntimeException(s"Invalid JSON in Swagger definitions generation. This may be due to unescaped special characters in examples or field names. Error: ${e.getMessage}", e)
}
}

View File

@ -83,9 +83,7 @@ trait APIMethodsDynamicEntity {
val singleName = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "")
val isGetAll = StringUtils.isBlank(id)
// e.g: "someMultiple-part_Name" -> ["Some", "Multiple", "Part", "Name"]
val capitalizedNameParts = entityName.split("(?<=[a-z0-9])(?=[A-Z])|-|_").map(_.capitalize).filterNot(_.trim.isEmpty)
val splitName = s"""${capitalizedNameParts.mkString(" ")}"""
val splitName = entityName
val splitNameWithBankId = if (bankId.isDefined)
s"""$splitName(${bankId.getOrElse("")})"""
else
@ -169,9 +167,7 @@ trait APIMethodsDynamicEntity {
case EntityName(bankId, entityName, _, isPersonalEntity) JsonPost json -> _ => { cc =>
val singleName = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "")
val operation: DynamicEntityOperation = CREATE
// e.g: "someMultiple-part_Name" -> ["Some", "Multiple", "Part", "Name"]
val capitalizedNameParts = entityName.split("(?<=[a-z0-9])(?=[A-Z])|-|_").map(_.capitalize).filterNot(_.trim.isEmpty)
val splitName = s"""${capitalizedNameParts.mkString(" ")}"""
val splitName = entityName
val splitNameWithBankId = if (bankId.isDefined)
s"""$splitName(${bankId.getOrElse("")})"""
else
@ -230,9 +226,7 @@ trait APIMethodsDynamicEntity {
case EntityName(bankId, entityName, id, isPersonalEntity) JsonPut json -> _ => { cc =>
val singleName = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "")
val operation: DynamicEntityOperation = UPDATE
// e.g: "someMultiple-part_Name" -> ["Some", "Multiple", "Part", "Name"]
val capitalizedNameParts = entityName.split("(?<=[a-z0-9])(?=[A-Z])|-|_").map(_.capitalize).filterNot(_.trim.isEmpty)
val splitName = s"""${capitalizedNameParts.mkString(" ")}"""
val splitName = entityName
val splitNameWithBankId = if (bankId.isDefined)
s"""$splitName(${bankId.getOrElse("")})"""
else
@ -303,9 +297,7 @@ trait APIMethodsDynamicEntity {
}
case EntityName(bankId, entityName, id, isPersonalEntity) JsonDelete _ => { cc =>
val operation: DynamicEntityOperation = DELETE
// e.g: "someMultiple-part_Name" -> ["Some", "Multiple", "Part", "Name"]
val capitalizedNameParts = entityName.split("(?<=[a-z0-9])(?=[A-Z])|-|_").map(_.capitalize).filterNot(_.trim.isEmpty)
val splitName = s"""${capitalizedNameParts.mkString(" ")}"""
val splitName = entityName
val splitNameWithBankId = if (bankId.isDefined)
s"""$splitName(${bankId.getOrElse("")})"""
else

View File

@ -29,7 +29,7 @@ object EntityName {
case "my" :: entityName :: id :: Nil =>
DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty && definitionMap._2.hasPersonalEntity)
.map(_ => (None, entityName, id, true))
//eg: /FooBar21
case entityName :: Nil =>
DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty)
@ -39,7 +39,7 @@ object EntityName {
DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty)
.map(_ => (None, entityName, id, false))
//eg: /Banks/BANK_ID/my/FooBar21
case "banks" :: bankId :: "my" :: entityName :: Nil =>
DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId) && definitionMap._2.hasPersonalEntity)
@ -58,14 +58,14 @@ object EntityName {
case "banks" :: bankId :: entityName :: id :: Nil =>
DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId))
.map(_ => (Some(bankId),entityName, id, false))//no bank:
case _ => None
}
}
object DynamicEntityHelper {
private val implementedInApiVersion = ApiVersion.v4_0_0
// (Some(BankId), EntityName, DynamicEntityInfo)
def definitionsMap: Map[(Option[String], String), DynamicEntityInfo] = NewStyle.function.getDynamicEntities(None, true).map(it => ((it.bankId, it.entityName), DynamicEntityInfo(it.metadataJson, it.entityName, it.bankId, it.hasPersonalEntity))).toMap
@ -82,7 +82,7 @@ object DynamicEntityHelper {
// eg: entityName = PetEntity => entityIdName = pet_entity_id
s"${entityName}_Id".replaceAll(regexPattern, "_").toLowerCase
}
def operationToResourceDoc: Map[(DynamicEntityOperation, String), ResourceDoc] = {
val addPrefix = APIUtil.getPropsAsBoolValue("dynamic_entities_have_prefix", true)
@ -98,7 +98,7 @@ object DynamicEntityHelper {
// Csem_case -> Csem Case
// _Csem_case -> _Csem Case
// csem-case -> Csem Case
def prettyTagName(s: String) = s.capitalize.split("(?<=[^-_])[-_]+").reduceLeft(_ + " " + _.capitalize)
def prettyTagName(s: String) = s
def apiTag(entityName: String, singularName: String): ResourceDocTag = {
@ -139,15 +139,13 @@ object DynamicEntityHelper {
(dynamicEntityInfo: DynamicEntityInfo): mutable.Map[(DynamicEntityOperation, String), ResourceDoc] = {
val entityName = dynamicEntityInfo.entityName
val hasPersonalEntity = dynamicEntityInfo.hasPersonalEntity
val splitName = entityName
// e.g: "someMultiple-part_Name" -> ["Some", "Multiple", "Part", "Name"]
val capitalizedNameParts = entityName.split("(?<=[a-z0-9])(?=[A-Z])|-|_").map(_.capitalize).filterNot(_.trim.isEmpty)
val splitName = s"""${capitalizedNameParts.mkString(" ")}"""
val splitNameWithBankId = if (dynamicEntityInfo.bankId.isDefined)
s"""$splitName(${dynamicEntityInfo.bankId.getOrElse("")})"""
else
s"""$splitName(${dynamicEntityInfo.bankId.getOrElse("")})"""
else
s"""$splitName"""
val mySplitNameWithBankId = s"My$splitNameWithBankId"
val idNameInUrl = StringHelpers.snakify(dynamicEntityInfo.idName).toUpperCase()
@ -193,7 +191,7 @@ object DynamicEntityHelper {
Some(List(dynamicEntityInfo.canGetRole)),
createdByBankId= dynamicEntityInfo.bankId
)
resourceDocs += (DynamicEntityOperation.GET_ONE, splitNameWithBankId) -> ResourceDoc(
endPoint,
implementedInApiVersion,
@ -339,7 +337,7 @@ object DynamicEntityHelper {
List(apiTag, apiTagDynamicEntity, apiTagDynamic),
createdByBankId= dynamicEntityInfo.bankId
)
resourceDocs += (DynamicEntityOperation.GET_ONE, mySplitNameWithBankId) -> ResourceDoc(
endPoint,
implementedInApiVersion,
@ -365,7 +363,7 @@ object DynamicEntityHelper {
List(apiTag, apiTagDynamicEntity, apiTagDynamic),
createdByBankId= dynamicEntityInfo.bankId
)
resourceDocs += (DynamicEntityOperation.CREATE, mySplitNameWithBankId) -> ResourceDoc(
endPoint,
implementedInApiVersion,
@ -393,7 +391,7 @@ object DynamicEntityHelper {
List(apiTag, apiTagDynamicEntity, apiTagDynamic),
createdByBankId= dynamicEntityInfo.bankId
)
resourceDocs += (DynamicEntityOperation.UPDATE, mySplitNameWithBankId) -> ResourceDoc(
endPoint,
implementedInApiVersion,
@ -422,7 +420,7 @@ object DynamicEntityHelper {
Some(List(dynamicEntityInfo.canUpdateRole)),
createdByBankId= dynamicEntityInfo.bankId
)
resourceDocs += (DynamicEntityOperation.DELETE, mySplitNameWithBankId) -> ResourceDoc(
endPoint,
implementedInApiVersion,
@ -505,7 +503,7 @@ case class DynamicEntityInfo(definition: String, entityName: String, bankId: Opt
val idName = StringUtils.uncapitalize(entityName) + "Id"
val listName = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "_list")
val singleName = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "")
val jsonTypeMap: Map[String, Class[_]] = DynamicEntityFieldType.nameToValue.mapValues(_.jValueType)
@ -575,7 +573,7 @@ case class DynamicEntityInfo(definition: String, entityName: String, bankId: Opt
JObject(exampleFields)
}
val bankIdJObject: JObject = ("bank-id" -> ExampleValue.bankIdExample.value)
def getSingleExample: JObject = if (bankId.isDefined){
val SingleObject: JObject = (singleName -> (JObject(JField(idName, JString(ExampleValue.idExample.value)) :: getSingleExampleWithoutId.obj)))
bankIdJObject merge SingleObject
@ -585,7 +583,7 @@ case class DynamicEntityInfo(definition: String, entityName: String, bankId: Opt
def getExampleList: JObject = if (bankId.isDefined){
val objectList: JObject = (listName -> JArray(List(getSingleExample)))
bankIdJObject merge objectList
bankIdJObject merge objectList
} else{
(listName -> JArray(List(getSingleExample)))
}
@ -597,33 +595,33 @@ case class DynamicEntityInfo(definition: String, entityName: String, bankId: Opt
}
object DynamicEntityInfo {
def canCreateRole(entityName: String, bankId:Option[String]): ApiRole =
if(bankId.isDefined)
getOrCreateDynamicApiRole("CanCreateDynamicEntity_" + entityName, true)
else
getOrCreateDynamicApiRole("CanCreateDynamicEntity_System" + entityName, false)
def canUpdateRole(entityName: String, bankId:Option[String]): ApiRole =
if(bankId.isDefined)
getOrCreateDynamicApiRole("CanUpdateDynamicEntity_" + entityName, true)
else
getOrCreateDynamicApiRole("CanUpdateDynamicEntity_System" + entityName, false)
def canGetRole(entityName: String, bankId:Option[String]): ApiRole =
def canCreateRole(entityName: String, bankId:Option[String]): ApiRole =
if(bankId.isDefined)
getOrCreateDynamicApiRole("CanGetDynamicEntity_" + entityName, true)
else
getOrCreateDynamicApiRole("CanGetDynamicEntity_System" + entityName, false)
def canDeleteRole(entityName: String, bankId:Option[String]): ApiRole =
if(bankId.isDefined)
getOrCreateDynamicApiRole("CanDeleteDynamicEntity_" + entityName, true)
else
getOrCreateDynamicApiRole("CanDeleteDynamicEntity_System" + entityName, false)
getOrCreateDynamicApiRole("CanCreateDynamicEntity_" + entityName, true)
else
getOrCreateDynamicApiRole("CanCreateDynamicEntity_System" + entityName, false)
def canUpdateRole(entityName: String, bankId:Option[String]): ApiRole =
if(bankId.isDefined)
getOrCreateDynamicApiRole("CanUpdateDynamicEntity_" + entityName, true)
else
getOrCreateDynamicApiRole("CanUpdateDynamicEntity_System" + entityName, false)
def canGetRole(entityName: String, bankId:Option[String]): ApiRole =
if(bankId.isDefined)
getOrCreateDynamicApiRole("CanGetDynamicEntity_" + entityName, true)
else
getOrCreateDynamicApiRole("CanGetDynamicEntity_System" + entityName, false)
def canDeleteRole(entityName: String, bankId:Option[String]): ApiRole =
if(bankId.isDefined)
getOrCreateDynamicApiRole("CanDeleteDynamicEntity_" + entityName, true)
else
getOrCreateDynamicApiRole("CanDeleteDynamicEntity_System" + entityName, false)
def roleNames(entityName: String, bankId:Option[String]): List[String] = List(
canCreateRole(entityName, bankId),
canCreateRole(entityName, bankId),
canUpdateRole(entityName, bankId),
canGetRole(entityName, bankId),
canGetRole(entityName, bankId),
canDeleteRole(entityName, bankId)
).map(_.toString())
}
}

View File

@ -14,59 +14,87 @@ import code.util.Helper.MdcLoggable
import scala.collection.mutable.ArrayBuffer
// Test case classes for JSON escaping tests
case class TestWithQuotes(name: String, description: String)
case class TestWithNewlines(text: String)
case class AbacRule(rule: String)
class SwaggerFactoryUnitTest extends V140ServerSetup with MdcLoggable {
feature("Unit tests for the translateEntity method") {
scenario("Test the $colon faild case") {
val translateCaseClassToSwaggerFormatString: String = SwaggerJSONFactory.translateEntity(SwaggerDefinitionsJSON.license)
val translateCaseClassToSwaggerFormatString: String =
SwaggerJSONFactory.translateEntity(SwaggerDefinitionsJSON.license)
logger.debug("{" + translateCaseClassToSwaggerFormatString + "}")
translateCaseClassToSwaggerFormatString should not include ("$colon")
}
scenario("Test the the List[Case Class] in translateEntity function") {
val translateCaseClassToSwaggerFormatString: String = SwaggerJSONFactory.translateEntity(SwaggerDefinitionsJSON.postCounterpartyJSON)
val translateCaseClassToSwaggerFormatString: String =
SwaggerJSONFactory.translateEntity(
SwaggerDefinitionsJSON.postCounterpartyJSON
)
logger.debug("{" + translateCaseClassToSwaggerFormatString + "}")
translateCaseClassToSwaggerFormatString should not include ("$colon")
}
scenario("Test `null` in translateEntity function") {
val translateCaseClassToSwaggerFormatString: String = SwaggerJSONFactory.translateEntity(SwaggerDefinitionsJSON.counterpartyMetadataJson)
val translateCaseClassToSwaggerFormatString: String =
SwaggerJSONFactory.translateEntity(
SwaggerDefinitionsJSON.counterpartyMetadataJson
)
logger.debug("{" + translateCaseClassToSwaggerFormatString + "}")
translateCaseClassToSwaggerFormatString should not include ("$colon")
}
scenario("Test `SecondaryIdentification: Option[String] = None,` in translateEntity function") {
val translateCaseClassToSwaggerFormatString: String = SwaggerJSONFactory.translateEntity(SwaggerDefinitionsJSON.accountInnerJsonUKOpenBanking_v200.copy(SecondaryIdentification = Some("1111")))
scenario(
"Test `SecondaryIdentification: Option[String] = None,` in translateEntity function"
) {
val translateCaseClassToSwaggerFormatString: String =
SwaggerJSONFactory.translateEntity(
SwaggerDefinitionsJSON.accountInnerJsonUKOpenBanking_v200
.copy(SecondaryIdentification = Some("1111"))
)
logger.debug("{" + translateCaseClassToSwaggerFormatString + "}")
//This optional type should be "1111", should not contain Some(1111)
// This optional type should be "1111", should not contain Some(1111)
translateCaseClassToSwaggerFormatString should not include ("""Some(1111)""")
}
scenario("Test `product_attributes = Some(List(productAttributeResponseJson))` in translateEntity function") {
val translateCaseClassToSwaggerFormatString: String = SwaggerJSONFactory.translateEntity(SwaggerDefinitionsJSON.productJsonV310)
scenario(
"Test `product_attributes = Some(List(productAttributeResponseJson))` in translateEntity function"
) {
val translateCaseClassToSwaggerFormatString: String =
SwaggerJSONFactory.translateEntity(
SwaggerDefinitionsJSON.productJsonV310
)
logger.debug("{" + translateCaseClassToSwaggerFormatString + "}")
translateCaseClassToSwaggerFormatString should not include ("""/definitions/scala.Some""")
translateCaseClassToSwaggerFormatString should not include ("""$colon""")
}
scenario("Test `enumeration` for translateEntity function") {
val translateCaseClassToSwaggerFormatString: String = SwaggerJSONFactory.translateEntity(SwaggerDefinitionsJSON.cardAttributeCommons)
val translateCaseClassToSwaggerFormatString: String =
SwaggerJSONFactory.translateEntity(
SwaggerDefinitionsJSON.cardAttributeCommons
)
logger.debug("{" + translateCaseClassToSwaggerFormatString + "}")
translateCaseClassToSwaggerFormatString should not include ("""/definitions/Val""")
}
}
feature("Test all V300, V220 and V210, exampleRequestBodies and successResponseBodies and all the case classes in SwaggerDefinitionsJSON") {
feature(
"Test all V300, V220 and V210, exampleRequestBodies and successResponseBodies and all the case classes in SwaggerDefinitionsJSON"
) {
scenario("Test all the case classes") {
val resourceDocList: ArrayBuffer[ResourceDoc] = ArrayBuffer.empty
OBPAPI6_0_0.allResourceDocs ++
OBPAPI5_1_0.allResourceDocs ++
OBPAPI5_0_0.allResourceDocs ++
OBPAPI4_0_0.allResourceDocs ++
OBPAPI3_1_0.allResourceDocs ++
OBPAPI3_0_0.allResourceDocs ++
OBPAPI2_2_0.allResourceDocs ++
OBPAPI6_0_0.allResourceDocs ++
OBPAPI5_1_0.allResourceDocs ++
OBPAPI5_0_0.allResourceDocs ++
OBPAPI4_0_0.allResourceDocs ++
OBPAPI3_1_0.allResourceDocs ++
OBPAPI3_0_0.allResourceDocs ++
OBPAPI2_2_0.allResourceDocs ++
OBPAPI2_1_0.allResourceDocs
//Translate every entity(JSON Case Class) in a list to appropriate swagger format
// Translate every entity(JSON Case Class) in a list to appropriate swagger format
val listOfExampleRequestBodyDefinition =
for (e <- resourceDocList if e.exampleRequestBody != null)
yield {
@ -79,13 +107,15 @@ class SwaggerFactoryUnitTest extends V140ServerSetup with MdcLoggable {
SwaggerJSONFactory.translateEntity(e.successResponseBody)
}
val listNestedMissingDefinition: List[String] = SwaggerDefinitionsJSON.allFields
.map(SwaggerJSONFactory.translateEntity)
.toList
val listNestedMissingDefinition: List[String] =
SwaggerDefinitionsJSON.allFields
.map(SwaggerJSONFactory.translateEntity)
.toList
val allStrings = listOfExampleRequestBodyDefinition ++ listOfSuccessRequestBodyDefinition ++ listNestedMissingDefinition
//All of the following are invalid value in Swagger, if any of them exist,
//need check how you create the case class object in SwaggerDefinitionsJSON.json.
val allStrings =
listOfExampleRequestBodyDefinition ++ listOfSuccessRequestBodyDefinition ++ listNestedMissingDefinition
// All of the following are invalid value in Swagger, if any of them exist,
// need check how you create the case class object in SwaggerDefinitionsJSON.json.
allStrings.toString() should not include ("Nil$")
allStrings.toString() should not include ("JArray")
allStrings.toString() should not include ("JBool")
@ -98,5 +128,66 @@ class SwaggerFactoryUnitTest extends V140ServerSetup with MdcLoggable {
logger.debug(allStrings)
}
}
feature("Test JSON escaping robustness in Swagger generation") {
scenario("Test quotes in example values are properly escaped") {
val testObj = TestWithQuotes(
name = "Test with \"quotes\"",
description = "Has 'single' and \"double\" quotes"
)
val result = SwaggerJSONFactory.translateEntity(testObj)
noException should be thrownBy {
net.liftweb.json.parse("{" + result + "}")
}
result should include("\\\"")
}
scenario("Test newlines and special chars are properly escaped") {
val testObj = TestWithNewlines(text = "Line 1\nLine 2\tTab")
val result = SwaggerJSONFactory.translateEntity(testObj)
noException should be thrownBy {
net.liftweb.json.parse("{" + result + "}")
}
result should include("\\n")
}
scenario("Test ABAC rule-like strings with escaped quotes") {
val testObj = AbacRule(rule = """user.emailAddress.contains(\"admin\")""")
val result = SwaggerJSONFactory.translateEntity(testObj)
noException should be thrownBy {
net.liftweb.json.parse("{" + result + "}")
}
}
scenario("Test error messages with special characters") {
import code.api.v1_4_0.JSONFactory1_4_0
val mockResourceDoc = JSONFactory1_4_0.ResourceDocJson(
operation_id = "testOp",
implemented_by = JSONFactory1_4_0.ImplementedByJson("1.0.0", "test"),
request_verb = "GET",
request_url = "/test",
summary = "Test",
description = "Test desc",
description_markdown = "Test desc",
example_request_body = null,
success_response_body = SwaggerDefinitionsJSON.bankJSON,
error_response_bodies = List("OBP-10000"),
tags = List("Test"),
typed_request_body = net.liftweb.json.JNothing,
typed_success_response_body = net.liftweb.json.JNothing,
roles = Some(List()),
is_featured = false,
special_instructions = "",
specified_url = "/obp/v4.0.0/test",
connector_methods = List(),
created_by_bank_id = None
)
noException should be thrownBy {
SwaggerJSONFactory.loadDefinitions(
List(mockResourceDoc),
SwaggerDefinitionsJSON.allFields.take(10)
)
}
}
}
}

View File

@ -1,5 +1,23 @@
package code.api.v5_0_0
/*
* CardTest is completely commented out due to initialization issues.
*
* The problem: When this test class is loaded during test discovery, it triggers initialization of
* V500ServerSetupAsync which tries to start a test server. This causes port binding issues and
* initialization errors that abort the entire test suite.
*
* Additional issues:
* - createPhysicalCardJsonV500 causes circular dependency chain
* - ExampleValue$ Glossary$ Helper$.ObpS cglib proxy creation fails
* - NoClassDefFoundError when running on Java 17 with Java 11 project configuration
* - Port 8018 binding conflicts
*
* TODO: Fix the initialization order, move createPhysicalCardJsonV500 call inside test methods,
* and resolve server setup issues before re-enabling this test.
*/
/*
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.{createPhysicalCardJsonV500}
import code.api.util.ApiRole
@ -19,19 +37,6 @@ import org.scalatest.{Ignore, Tag}
import java.util.Date
/**
* CardTest is temporarily disabled due to initialization issues with createPhysicalCardJsonV500.
*
* The problem: When this test class is loaded, it triggers initialization of createPhysicalCardJsonV500
* at line 37, which causes a circular dependency chain:
* - createPhysicalCardJsonV500 ExampleValue$ Glossary$ Helper$.ObpS cglib proxy creation
*
* This fails with NoClassDefFoundError when running on Java 17 with Java 11 project configuration.
* The error occurs because cglib cannot create proxies due to module access restrictions.
*
* TODO: Fix the initialization order or move createPhysicalCardJsonV500 call inside test methods
* instead of at class initialization time (line 37).
*/
@Ignore
class CardTest extends V500ServerSetupAsync with DefaultUsers {
@ -41,8 +46,8 @@ class CardTest extends V500ServerSetupAsync with DefaultUsers {
feature("test Card APIs") {
scenario("We will create Card with many error cases",
ApiEndpointAddCardForBank,
scenario("We will create Card with many error cases",
ApiEndpointAddCardForBank,
VersionOfApi
) {
Given("The test bank and test account")
@ -61,7 +66,7 @@ class CardTest extends V500ServerSetupAsync with DefaultUsers {
val properCardJson = dummyCard.copy(account_id = testAccount.value, issue_number = "123", customer_id = customerId)
val requestAnonymous = (v5_0_0_Request / "management"/"banks" / testBank.value / "cards" ).POST
val requestAnonymous = (v5_0_0_Request / "management"/"banks" / testBank.value / "cards" ).POST
val requestWithAuthUser = (v5_0_0_Request / "management" /"banks" / testBank.value / "cards" ).POST <@ (user1)
Then(s"We test with anonymous user.")
@ -99,7 +104,7 @@ class CardTest extends V500ServerSetupAsync with DefaultUsers {
responseWithWrongVlaueForAllows.body.toString contains(AllowedValuesAre++ CardAction.availableValues.mkString(", "))
Then(s"We call the authentication user, but wrong card.replacement value")
val wrongCardReplacementReasonJson = dummyCard.copy(replacement = Some(ReplacementJSON(new Date(),"Wrong"))) // The replacement must be Enum of `CardReplacementReason`
val wrongCardReplacementReasonJson = dummyCard.copy(replacement = Some(ReplacementJSON(new Date(),"Wrong"))) // The replacement must be Enum of `CardReplacementReason`
val responseWrongCardReplacementReasonJson = makePostRequest(requestWithAuthUser, write(wrongCardReplacementReasonJson))
And(s"We should get 400 and get the error message")
responseWrongCardReplacementReasonJson.code should equal(400)
@ -169,4 +174,5 @@ class CardTest extends V500ServerSetupAsync with DefaultUsers {
}
}
}
}
*/

View File

@ -46,7 +46,7 @@ class MetricsTest extends V500ServerSetup {
override def afterAll(): Unit = {
super.afterAll()
}
/**
* Test tags
* Example: To run tests with tag "getPermissions":
@ -57,14 +57,17 @@ class MetricsTest extends V500ServerSetup {
object VersionOfApi extends Tag(ApiVersion.v5_0_0.toString)
object ApiEndpoint1 extends Tag(nameOf(Implementations5_0_0.getMetricsAtBank))
lazy val apiEndpointName = nameOf(Implementations5_0_0.getMetricsAtBank)
lazy val versionName = ApiVersion.v5_0_0.toString
lazy val bankId = testBankId1.value
def getMetrics(consumerAndToken: Option[(Consumer, Token)], bankId: String): APIResponse = {
val request = v5_0_0_Request / "management" / "metrics" / "banks" / bankId <@(consumerAndToken)
makeGetRequest(request)
}
feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") {
feature(s"test $apiEndpointName version $versionName - Unauthorized access") {
scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) {
When(s"We make a request $ApiEndpoint1")
val response400 = getMetrics(None, bankId)
@ -73,7 +76,7 @@ class MetricsTest extends V500ServerSetup {
response400.body.extract[ErrorMessage].message should equal(UserNotLoggedIn)
}
}
feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access") {
feature(s"test $apiEndpointName version $versionName - Authorized access") {
scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) {
When(s"We make a request $ApiEndpoint1")
val response400 = getMetrics(user1, bankId)
@ -82,7 +85,7 @@ class MetricsTest extends V500ServerSetup {
response400.body.extract[ErrorMessage].message contains (UserHasMissingRoles + CanGetMetricsAtOneBank) should be (true)
}
}
feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access with proper Role") {
feature(s"test $apiEndpointName version $versionName - Authorized access with proper Role") {
scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) {
When(s"We make a request $ApiEndpoint1")
Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetMetricsAtOneBank.toString)
@ -92,5 +95,5 @@ class MetricsTest extends V500ServerSetup {
response400.body.extract[MetricsJson]
}
}
}

559
run_all_tests.sh Executable file
View File

@ -0,0 +1,559 @@
#!/bin/bash
################################################################################
# OBP-API Test Runner Script
#
# What it does:
# 1. Changes terminal to blue background with "Tests Running" in title
# 2. Runs: mvn clean test
# 3. Shows all test output in real-time
# 4. Updates title bar with: phase, time elapsed, pass/fail counts
# 5. Saves detailed log and summary to test-results/
# 6. Restores terminal to normal when done
#
# Usage:
# ./run_all_tests.sh - Run full test suite
# ./run_all_tests.sh --summary-only - Regenerate summary from existing log
################################################################################
set -e
################################################################################
# PARSE COMMAND LINE ARGUMENTS
################################################################################
SUMMARY_ONLY=false
if [ "$1" = "--summary-only" ]; then
SUMMARY_ONLY=true
fi
################################################################################
# TERMINAL STYLING FUNCTIONS
################################################################################
# Set terminal to "test mode" - blue background, special title
set_terminal_style() {
local phase="${1:-Running}"
echo -ne "\033]0;OBP-API Tests ${phase}...\007" # Title
echo -ne "\033]11;#001f3f\007" # Dark blue background
echo -ne "\033]10;#ffffff\007" # White text
# Print header bar
printf "\033[44m\033[1;37m%-$(tput cols)s\r OBP-API TEST RUNNER ACTIVE - ${phase} \n%-$(tput cols)s\033[0m\n" " " " "
}
# Update title bar with progress: "Testing: DynamicEntityTest - Scenario name [5m 23s]"
update_terminal_title() {
local phase="$1" # Starting, Building, Testing, Complete
local elapsed="${2:-}" # Time elapsed (e.g. "5m 23s")
local counts="${3:-}" # Module counts (e.g. "obp-commons:+38 obp-api:+245")
local suite="${4:-}" # Current test suite name
local scenario="${5:-}" # Current scenario name
local title="OBP-API ${phase}"
[ -n "$suite" ] && title="${title}: ${suite}"
[ -n "$scenario" ] && title="${title} - ${scenario}"
title="${title}..."
[ -n "$elapsed" ] && title="${title} [${elapsed}]"
[ -n "$counts" ] && title="${title} ${counts}"
echo -ne "\033]0;${title}\007"
}
# Restore terminal to normal (black background, default title)
restore_terminal_style() {
echo -ne "\033]0;Terminal\007\033]11;#000000\007\033]10;#ffffff\007\033[0m"
}
# Cleanup function: stop monitor, restore terminal, remove flag files
cleanup_on_exit() {
# Stop background monitor if running
if [ -n "${MONITOR_PID:-}" ]; then
kill $MONITOR_PID 2>/dev/null || true
wait $MONITOR_PID 2>/dev/null || true
fi
# Remove monitor flag file
rm -f "${LOG_DIR}/monitor.flag" 2>/dev/null || true
# Restore terminal
restore_terminal_style
}
# Always cleanup on exit (Ctrl+C, errors, or normal completion)
trap cleanup_on_exit EXIT INT TERM
################################################################################
# CONFIGURATION
################################################################################
LOG_DIR="test-results"
DETAIL_LOG="${LOG_DIR}/last_run.log" # Full Maven output
SUMMARY_LOG="${LOG_DIR}/last_run_summary.log" # Summary only
mkdir -p "${LOG_DIR}"
# If summary-only mode, skip to summary generation
if [ "$SUMMARY_ONLY" = true ]; then
if [ ! -f "${DETAIL_LOG}" ]; then
echo "ERROR: No log file found at ${DETAIL_LOG}"
echo "Please run tests first without --summary-only flag"
exit 1
fi
echo "Regenerating summary from existing log: ${DETAIL_LOG}"
# Skip cleanup and jump to summary generation
START_TIME=0
END_TIME=0
DURATION=0
DURATION_MIN=0
DURATION_SEC=0
else
# Delete old log files and stale flag files from previous run
echo "Cleaning up old files..."
if [ -f "${DETAIL_LOG}" ]; then
rm -f "${DETAIL_LOG}"
echo " - Removed old detail log"
fi
if [ -f "${SUMMARY_LOG}" ]; then
rm -f "${SUMMARY_LOG}"
echo " - Removed old summary log"
fi
if [ -f "${LOG_DIR}/monitor.flag" ]; then
rm -f "${LOG_DIR}/monitor.flag"
echo " - Removed stale monitor flag"
fi
if [ -f "${LOG_DIR}/warning_analysis.tmp" ]; then
rm -f "${LOG_DIR}/warning_analysis.tmp"
echo " - Removed stale warning analysis"
fi
if [ -f "${LOG_DIR}/recent_lines.tmp" ]; then
rm -f "${LOG_DIR}/recent_lines.tmp"
echo " - Removed stale temp file"
fi
fi # End of if [ "$SUMMARY_ONLY" = true ]
################################################################################
# HELPER FUNCTIONS
################################################################################
# Log message to terminal and summary file
log_message() {
echo "$1"
echo "[$(date +"%Y-%m-%d %H:%M:%S")] $1" >> "${SUMMARY_LOG}"
}
# Print section header
print_header() {
echo ""
echo "================================================================================"
echo "$1"
echo "================================================================================"
echo ""
}
# Analyze warnings and return top contributors
analyze_warnings() {
local log_file="$1"
local temp_file="${LOG_DIR}/warning_analysis.tmp"
# Extract and categorize warnings from last 5000 lines (for performance)
# This gives good coverage without scanning entire multi-MB log file
tail -n 5000 "${log_file}" 2>/dev/null | grep -i "warning" | \
# Normalize patterns to group similar warnings
sed -E 's/line [0-9]+/line XXX/g' | \
sed -E 's/[0-9]+ warnings?/N warnings/g' | \
sed -E 's/\[WARNING\] .*(src|test)\/[^ ]+/[WARNING] <source-file>/g' | \
sed -E 's/version [0-9]+\.[0-9]+(\.[0-9]+)?/version X.X/g' | \
# Extract the core warning message
sed -E 's/^.*\[WARNING\] *//' | \
sort | uniq -c | sort -rn > "${temp_file}"
# Return the temp file path for further processing
echo "${temp_file}"
}
# Format and display top warning factors
display_warning_factors() {
local analysis_file="$1"
local max_display="${2:-10}"
if [ ! -f "${analysis_file}" ] || [ ! -s "${analysis_file}" ]; then
log_message " No detailed warning analysis available"
return
fi
local total_warning_types=$(wc -l < "${analysis_file}")
local displayed=0
log_message "Top Warning Factors:"
log_message "-------------------"
while IFS= read -r line && [ $displayed -lt $max_display ]; do
# Extract count and message
local count=$(echo "$line" | awk '{print $1}')
local message=$(echo "$line" | sed -E 's/^[[:space:]]*[0-9]+[[:space:]]*//')
# Truncate long messages
if [ ${#message} -gt 80 ]; then
message="${message:0:77}..."
fi
# Format with count prominence
printf " %4d x %s\n" "$count" "$message" | tee -a "${SUMMARY_LOG}" > /dev/tty
displayed=$((displayed + 1))
done < "${analysis_file}"
if [ $total_warning_types -gt $max_display ]; then
local remaining=$((total_warning_types - max_display))
log_message " ... and ${remaining} more warning type(s)"
fi
# Clean up temp file
rm -f "${analysis_file}"
}
################################################################################
# GENERATE SUMMARY FUNCTION (DRY)
################################################################################
generate_summary() {
local detail_log="$1"
local summary_log="$2"
local start_time="${3:-0}"
local end_time="${4:-0}"
# Calculate duration
local duration=$((end_time - start_time))
local duration_min=$((duration / 60))
local duration_sec=$((duration % 60))
# If no timing info (summary-only mode), extract from log
if [ $duration -eq 0 ] && grep -q "Total time:" "$detail_log"; then
local time_str=$(grep "Total time:" "$detail_log" | tail -1)
duration_min=$(echo "$time_str" | grep -oP '\d+(?= min)' || echo "0")
duration_sec=$(echo "$time_str" | grep -oP '\d+(?=\.\d+ s)' || echo "0")
fi
print_header "Test Results Summary"
# Extract test statistics from ScalaTest output (with UNKNOWN fallback if extraction fails)
# ScalaTest outputs across multiple lines:
# Run completed in X seconds.
# Total number of tests run: N
# Suites: completed M, aborted 0
# Tests: succeeded N, failed 0, canceled 0, ignored 0, pending 0
# All tests passed.
# We need to extract the stats from the last test run (in case there are multiple modules)
SCALATEST_SECTION=$(grep -A 4 "Run completed" "${detail_log}" | tail -5)
if [ -n "$SCALATEST_SECTION" ]; then
TOTAL_TESTS=$(echo "$SCALATEST_SECTION" | grep -oP "Total number of tests run: \K\d+" || echo "UNKNOWN")
SUCCEEDED=$(echo "$SCALATEST_SECTION" | grep -oP "succeeded \K\d+" || echo "UNKNOWN")
FAILED=$(echo "$SCALATEST_SECTION" | grep -oP "failed \K\d+" || echo "UNKNOWN")
ERRORS=$(echo "$SCALATEST_SECTION" | grep -oP "errors \K\d+" || echo "0")
SKIPPED=$(echo "$SCALATEST_SECTION" | grep -oP "ignored \K\d+" || echo "UNKNOWN")
else
TOTAL_TESTS="UNKNOWN"
SUCCEEDED="UNKNOWN"
FAILED="UNKNOWN"
ERRORS="0"
SKIPPED="UNKNOWN"
fi
WARNINGS=$(grep -c "WARNING" "${detail_log}" || echo "UNKNOWN")
# Determine build status
if grep -q "BUILD SUCCESS" "${detail_log}"; then
BUILD_STATUS="SUCCESS"
BUILD_COLOR=""
elif grep -q "BUILD FAILURE" "${detail_log}"; then
BUILD_STATUS="FAILURE"
BUILD_COLOR=""
else
BUILD_STATUS="UNKNOWN"
BUILD_COLOR=""
fi
# Print summary
log_message "Test Run Summary"
log_message "================"
log_message "Timestamp: $(date)"
log_message "Duration: ${duration_min}m ${duration_sec}s"
log_message "Build Status: ${BUILD_STATUS}"
log_message ""
log_message "Test Statistics:"
log_message " Total: ${TOTAL_TESTS}"
log_message " Succeeded: ${SUCCEEDED}"
log_message " Failed: ${FAILED}"
log_message " Errors: ${ERRORS}"
log_message " Skipped: ${SKIPPED}"
log_message " Warnings: ${WARNINGS}"
log_message ""
# Analyze and display warning factors if warnings exist
if [ "${WARNINGS}" != "0" ] && [ "${WARNINGS}" != "UNKNOWN" ]; then
warning_analysis=$(analyze_warnings "${detail_log}")
display_warning_factors "${warning_analysis}" 10
log_message ""
fi
# Show failed tests if any (only actual test failures, not application ERROR logs)
if [ "${FAILED}" != "0" ] && [ "${FAILED}" != "UNKNOWN" ]; then
log_message "Failed Tests:"
# Look for ScalaTest failure markers, not application ERROR logs
grep -E "\*\*\* FAILED \*\*\*|\*\*\* RUN ABORTED \*\*\*" "${detail_log}" | head -50 >> "${summary_log}"
log_message ""
elif [ "${ERRORS}" != "0" ] && [ "${ERRORS}" != "UNKNOWN" ]; then
log_message "Test Errors:"
grep -E "\*\*\* FAILED \*\*\*|\*\*\* RUN ABORTED \*\*\*" "${detail_log}" | head -50 >> "${summary_log}"
log_message ""
fi
# Final result
print_header "Test Run Complete"
if [ "${BUILD_STATUS}" = "SUCCESS" ] && [ "${FAILED}" = "0" ] && [ "${ERRORS}" = "0" ]; then
log_message "[PASS] All tests passed!"
return 0
else
log_message "[FAIL] Tests failed"
return 1
fi
}
################################################################################
# SUMMARY-ONLY MODE
################################################################################
if [ "$SUMMARY_ONLY" = true ]; then
# Just regenerate the summary and exit
rm -f "${SUMMARY_LOG}"
if generate_summary "${DETAIL_LOG}" "${SUMMARY_LOG}" 0 0; then
log_message ""
log_message "Summary regenerated:"
log_message " ${SUMMARY_LOG}"
exit 0
else
exit 1
fi
fi
################################################################################
# START TEST RUN
################################################################################
set_terminal_style "Starting"
# Start the test run
print_header "OBP-API Test Suite"
log_message "Starting test run at $(date)"
log_message "Detail log: ${DETAIL_LOG}"
log_message "Summary log: ${SUMMARY_LOG}"
echo ""
# Set Maven options for tests
# The --add-opens flags tell Java 17 to allow Kryo serialization library to access
# the internal java.lang.invoke and java.lang modules, which fixes the InaccessibleObjectException
export MAVEN_OPTS="-Xss128m -Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G --add-opens java.base/java.lang.invoke=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED"
log_message "Maven Options: ${MAVEN_OPTS}"
echo ""
# Ensure test properties file exists
PROPS_FILE="obp-api/src/main/resources/props/test.default.props"
PROPS_TEMPLATE="${PROPS_FILE}.template"
if [ -f "${PROPS_FILE}" ]; then
log_message "[OK] Found test.default.props"
else
log_message "[WARNING] test.default.props not found - creating from template"
if [ -f "${PROPS_TEMPLATE}" ]; then
cp "${PROPS_TEMPLATE}" "${PROPS_FILE}"
log_message "[OK] Created test.default.props"
else
log_message "ERROR: ${PROPS_TEMPLATE} not found!"
exit 1
fi
fi
################################################################################
# CHECK AND CLEANUP TEST SERVER PORTS
# Port 8018 is used by the embedded Jetty test server (configured in test.default.props)
################################################################################
print_header "Checking Test Server Ports"
log_message "Checking if test server port 8018 is available..."
# Check if port 8018 is in use
if lsof -i :8018 >/dev/null 2>&1; then
log_message "[WARNING] Port 8018 is in use - attempting to kill process"
# Try to kill the process using the port
PORT_PID=$(lsof -t -i :8018 2>/dev/null)
if [ -n "$PORT_PID" ]; then
kill -9 $PORT_PID 2>/dev/null || true
sleep 2
log_message "[OK] Killed process $PORT_PID using port 8018"
fi
else
log_message "[OK] Port 8018 is available"
fi
# Also check for any stale Java test processes
STALE_TEST_PROCS=$(ps aux | grep -E "TestServer|ScalaTest.*obp-api" | grep -v grep | awk '{print $2}' || true)
if [ -n "$STALE_TEST_PROCS" ]; then
log_message "[WARNING] Found stale test processes - cleaning up"
echo "$STALE_TEST_PROCS" | xargs kill -9 2>/dev/null || true
sleep 2
log_message "[OK] Cleaned up stale test processes"
else
log_message "[OK] No stale test processes found"
fi
log_message ""
################################################################################
# CLEAN METRICS DATABASE
################################################################################
print_header "Cleaning Metrics Database"
log_message "Checking for test database files..."
# Only delete specific test database files to prevent accidental data loss
# The test configuration uses test_only_lift_proto.db as the database filename
TEST_DB_PATTERNS=(
"./test_only_lift_proto.db"
"./test_only_lift_proto.db.mv.db"
"./test_only_lift_proto.db.trace.db"
"./obp-api/test_only_lift_proto.db"
"./obp-api/test_only_lift_proto.db.mv.db"
"./obp-api/test_only_lift_proto.db.trace.db"
)
FOUND_FILES=false
for dbfile in "${TEST_DB_PATTERNS[@]}"; do
if [ -f "$dbfile" ]; then
FOUND_FILES=true
rm -f "$dbfile"
log_message " [OK] Deleted: $dbfile"
fi
done
if [ "$FOUND_FILES" = false ]; then
log_message "No old test database files found"
fi
log_message ""
################################################################################
# RUN TESTS
################################################################################
print_header "Running Tests"
update_terminal_title "Building"
log_message "Executing: mvn clean test"
echo ""
START_TIME=$(date +%s)
export START_TIME
# Create flag file to signal background process to stop
MONITOR_FLAG="${LOG_DIR}/monitor.flag"
touch "${MONITOR_FLAG}"
# Background process: Monitor log file and update title bar with progress
(
# Wait for log file to be created and have Maven output
while [ ! -f "${DETAIL_LOG}" ] || [ ! -s "${DETAIL_LOG}" ]; do
sleep 1
done
phase="Building"
in_testing=false
# Keep monitoring until flag file is removed
while [ -f "${MONITOR_FLAG}" ]; do
# Use tail to look at recent lines only (last 500 lines for performance)
# This ensures O(1) performance regardless of log file size
recent_lines=$(tail -n 500 "${DETAIL_LOG}" 2>/dev/null)
# Switch to "Testing" phase when tests start
if ! $in_testing && echo "$recent_lines" | grep -q "Run starting" 2>/dev/null; then
phase="Testing"
in_testing=true
fi
# Extract current running test suite and scenario from recent lines
suite=""
scenario=""
if $in_testing; then
# Find the most recent test suite name (pattern like "SomeTest:")
# Pipe directly to avoid temp file I/O
suite=$(echo "$recent_lines" | grep -E "Test:" | tail -1 | sed 's/\x1b\[[0-9;]*m//g' | sed 's/:$//' | tr -d '\n\r')
# Find the most recent scenario name (pattern like " Scenario: ..." or "- Scenario: ...")
scenario=$(echo "$recent_lines" | grep -i "scenario:" | tail -1 | sed 's/\x1b\[[0-9;]*m//g' | sed 's/^[[:space:]]*-*[[:space:]]*//' | sed -E 's/^[Ss]cenario:[[:space:]]*//' | tr -d '\n\r')
# Truncate scenario if too long (max 50 chars)
if [ -n "$scenario" ] && [ ${#scenario} -gt 50 ]; then
scenario="${scenario:0:47}..."
fi
fi
# Calculate elapsed time
duration=$(($(date +%s) - START_TIME))
minutes=$((duration / 60))
seconds=$((duration % 60))
elapsed=$(printf "%dm %ds" $minutes $seconds)
# Update title: "Testing: DynamicEntityTest - Scenario name [5m 23s]"
update_terminal_title "$phase" "$elapsed" "" "$suite" "$scenario"
sleep 5
done
) &
MONITOR_PID=$!
# Run Maven (all output goes to terminal AND log file)
if mvn clean test 2>&1 | tee "${DETAIL_LOG}"; then
TEST_RESULT="SUCCESS"
RESULT_COLOR=""
else
TEST_RESULT="FAILURE"
RESULT_COLOR=""
fi
# Stop background monitor by removing flag file
rm -f "${MONITOR_FLAG}"
sleep 1
kill $MONITOR_PID 2>/dev/null || true
wait $MONITOR_PID 2>/dev/null || true
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
DURATION_MIN=$((DURATION / 60))
DURATION_SEC=$((DURATION % 60))
# Update title with final results (no suite/scenario name for Complete phase)
FINAL_ELAPSED=$(printf "%dm %ds" $DURATION_MIN $DURATION_SEC)
# Build final counts with module context
FINAL_COMMONS=$(sed -n '/Building Open Bank Project Commons/,/Building Open Bank Project API/{/Tests: succeeded/p;}' "${DETAIL_LOG}" 2>/dev/null | grep -oP "succeeded \K\d+" | head -1)
FINAL_API=$(sed -n '/Building Open Bank Project API/,/OBP Http4s Runner/{/Tests: succeeded/p;}' "${DETAIL_LOG}" 2>/dev/null | grep -oP "succeeded \K\d+" | tail -1)
FINAL_COUNTS=""
[ -n "$FINAL_COMMONS" ] && FINAL_COUNTS="commons:+${FINAL_COMMONS}"
[ -n "$FINAL_API" ] && FINAL_COUNTS="${FINAL_COUNTS:+${FINAL_COUNTS} }api:+${FINAL_API}"
update_terminal_title "Complete" "$FINAL_ELAPSED" "$FINAL_COUNTS" "" ""
################################################################################
# GENERATE SUMMARY (using DRY function)
################################################################################
if generate_summary "${DETAIL_LOG}" "${SUMMARY_LOG}" "$START_TIME" "$END_TIME"; then
EXIT_CODE=0
else
EXIT_CODE=1
fi
log_message ""
log_message "Logs saved to:"
log_message " ${DETAIL_LOG}"
log_message " ${SUMMARY_LOG}"
echo ""
exit ${EXIT_CODE}