diff --git a/.github/workflows/test_worktree_build.yml b/.github/workflows/test_worktree_build.yml new file mode 100644 index 0000000000..86bf256e14 --- /dev/null +++ b/.github/workflows/test_worktree_build.yml @@ -0,0 +1,72 @@ +name: Test Git Worktree Build + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to test' + required: false + default: 'develop' + +jobs: + test-worktree: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.branch }} + fetch-depth: 0 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: "11" + distribution: "adopt" + cache: maven + + - name: Create git worktree + run: git worktree add --detach ../obp-api-worktree HEAD + + - name: Build from worktree + working-directory: ../obp-api-worktree + run: | + set -o pipefail + MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" mvn clean package -DskipTests 2>&1 | tee "$GITHUB_WORKSPACE/worktree-build.log" + + - name: Verify git.properties was generated + run: | + PROPS_FILE="../obp-api-worktree/obp-api/target/classes/git.properties" + if [ ! -f "$PROPS_FILE" ]; then + echo "FAIL: git.properties not found" + exit 1 + fi + echo "Contents of git.properties:" + cat "$PROPS_FILE" + + check_field() { + local field=$1 + local value + value=$(grep "^${field}=" "$PROPS_FILE" | cut -d'=' -f2-) + if [ -z "$value" ]; then + echo "FAIL: $field is empty or missing" + exit 1 + fi + echo "OK: $field=$value" + } + + check_field "git.commit.id" + check_field "git.branch" + check_field "git.build.time" + + - name: Upload build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: worktree-build-log + if-no-files-found: ignore + path: worktree-build.log + + - name: Clean up worktree + if: always() + run: git worktree remove ../obp-api-worktree --force || true diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index b906ce4bc1..381eac5574 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -4,6 +4,7 @@ import code.api.util.{APIUtil, ErrorMessages} import code.api.cache.Redis import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.ApiStandards +import net.liftweb.common.Box import net.liftweb.util.Props @@ -137,7 +138,7 @@ object Constant extends MdcLoggable { final val mailUsersUserinfoSenderAddress = APIUtil.getPropsValue("mail.users.userinfo.sender.address", "sender-not-set") - final val oauth2JwkSetUrl = APIUtil.getPropsValue(nameOfProperty = "oauth2.jwk_set.url") + def oauth2JwkSetUrl: Box[String] = APIUtil.getPropsValue(nameOfProperty = "oauth2.jwk_set.url") final val consumerDefaultLogoUrl = APIUtil.getPropsValue("consumer_default_logo_url") 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 9c17c4f2df..bb8544adfa 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3032,14 +3032,22 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ } private def getFilteredOrFullErrorMessage[T](e: Box[Throwable]): JsonResponse = { + def findObpMessage(t: Throwable): Option[String] = { + if (t == null) None + else Option(t.getMessage).filter(_.startsWith("OBP-")) + .orElse(findObpMessage(t.getCause)) + } getPropsAsBoolValue("display_internal_errors", false) match { case true => // Show all error in a chain errorJsonResponse( - AnUnspecifiedOrInternalErrorOccurred + - e.map(error => " -> " + error.getCause() + " -> " + error.getStackTrace().mkString(";")).getOrElse("") + e.map { error => + val leadMessage = findObpMessage(error).getOrElse(AnUnspecifiedOrInternalErrorOccurred) + leadMessage + " -> " + error.getStackTrace().mkString(";") + }.getOrElse(AnUnspecifiedOrInternalErrorOccurred) ) case false => // Do not display internal errors - errorJsonResponse(AnUnspecifiedOrInternalErrorOccurred) + val obpMessage = e.flatMap(error => findObpMessage(error)) + errorJsonResponse(obpMessage.getOrElse(AnUnspecifiedOrInternalErrorOccurred)) } } diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 2da89d8df3..0e03ec442d 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -2084,7 +2084,7 @@ object Glossary extends MdcLoggable { | """) - val oauth2EnabledMessage : String = if (APIUtil.getPropsAsBoolValue("allow_oauth2_login", false)) + val oauth2EnabledMessage : String = if (APIUtil.getPropsAsBoolValue("allow_oauth2_login", true)) {"OAuth2 is allowed on this instance."} else {"Note: *OAuth2 is NOT allowed on this instance!*"} // OAuth2 documentation is sourced from OpenAPI31JSONFactory (the source of truth for auth docs) diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnectionPool.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnectionPool.scala index 54cfb6d898..5474a0be72 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnectionPool.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnectionPool.scala @@ -66,7 +66,17 @@ object RabbitMQConnectionPool { private val pool = new GenericObjectPool[Connection](new RabbitMQConnectionFactory(), poolConfig) // Method to borrow a connection from the pool - def borrowConnection(): Connection = pool.borrowObject() + def borrowConnection(): Connection = try { + pool.borrowObject() + } catch { + case e: java.net.ConnectException => + throw new RuntimeException( + s"OBP-60013: RabbitMQ is unavailable. Could not connect to ${RabbitMQUtils.host}:${RabbitMQUtils.port}. " + + s"Please ensure RabbitMQ is running and reachable. Details: ${e.getMessage}", e) + case e: Exception => + throw new RuntimeException( + s"OBP-60013: Failed to borrow a RabbitMQ connection from the pool. Details: ${e.getMessage}", e) + } // Method to return a connection to the pool def returnConnection(conn: Connection): Unit = pool.returnObject(conn) diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQUtils.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQUtils.scala index 51de1848bd..53c47c529a 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQUtils.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQUtils.scala @@ -90,43 +90,46 @@ object RabbitMQUtils extends MdcLoggable{ def sendRequestUndGetResponseFromRabbitMQ[T: Manifest](messageId: String, outBound: TopicTrait): Future[Box[T]] = { val rabbitRequestJsonString: String = write(outBound) // convert OutBound to json string - - val connection = RabbitMQConnectionPool.borrowConnection() - // Check if queue already exists using a temporary channel (passive declare closes channel on failure) - val queueExists = try { - val tempChannel = connection.createChannel() - try { - tempChannel.queueDeclarePassive(RPC_QUEUE_NAME) - true - } finally { - if (tempChannel.isOpen) tempChannel.close() - } - } catch { - case _: java.io.IOException => false - } - - val channel = connection.createChannel() // channel is not thread safe, so we always create new channel for each message. - // Only declare queue if it doesn't already exist (avoids argument conflicts with external adapters) - if (!queueExists) { - channel.queueDeclare( - RPC_QUEUE_NAME, // Queue name - true, // durable: non-persis, here set durable = true - false, // exclusive: non-excl4, here set exclusive = false - false, // autoDelete: delete, here set autoDelete = false - rpcQueueArgs // extra arguments, - ) - } - val replyQueueName:String = channel.queueDeclare( - s"${RPC_REPLY_TO_QUEUE_NAME_PREFIX}_${messageId.replace("obp_","")}_${UUID.randomUUID.toString}", // Queue name, it will be a unique name for each queue - false, // durable: non-persis, here set durable = false - true, // exclusive: non-excl4, here set exclusive = true - true, // autoDelete: delete, here set autoDelete = true - rpcReplyToQueueArgs // extra arguments, - ).getQueue + var connectionOpt: Option[com.rabbitmq.client.Connection] = None val rabbitResponseJsonFuture = { try { + val connection = RabbitMQConnectionPool.borrowConnection() + connectionOpt = Some(connection) + // Check if queue already exists using a temporary channel (passive declare closes channel on failure) + val queueExists = try { + val tempChannel = connection.createChannel() + try { + tempChannel.queueDeclarePassive(RPC_QUEUE_NAME) + true + } finally { + if (tempChannel.isOpen) tempChannel.close() + } + } catch { + case _: java.io.IOException => false + } + + val channel = connection.createChannel() // channel is not thread safe, so we always create new channel for each message. + // Only declare queue if it doesn't already exist (avoids argument conflicts with external adapters) + if (!queueExists) { + channel.queueDeclare( + RPC_QUEUE_NAME, // Queue name + true, // durable: non-persis, here set durable = true + false, // exclusive: non-excl4, here set exclusive = false + false, // autoDelete: delete, here set autoDelete = false + rpcQueueArgs // extra arguments, + ) + } + + val replyQueueName:String = channel.queueDeclare( + s"${RPC_REPLY_TO_QUEUE_NAME_PREFIX}_${messageId.replace("obp_","")}_${UUID.randomUUID.toString}", // Queue name, it will be a unique name for each queue + false, // durable: non-persis, here set durable = false + true, // exclusive: non-excl4, here set exclusive = true + true, // autoDelete: delete, here set autoDelete = true + rpcReplyToQueueArgs // extra arguments, + ).getQueue + logger.debug(s"${RabbitMQConnector_vOct2024.toString} outBoundJson: $messageId = $rabbitRequestJsonString") logger.info(s"[RabbitMQ] Sending message to queue: $RPC_QUEUE_NAME, messageId: $messageId, replyTo: $replyQueueName") @@ -144,13 +147,15 @@ object RabbitMQUtils extends MdcLoggable{ channel.basicConsume(replyQueueName, true, responseCallback, cancelCallback) responseCallback.take() } catch { - case e: Throwable =>{ + case e: RuntimeException if e.getMessage != null && e.getMessage.startsWith("OBP-") => + logger.debug(s"${RabbitMQConnector_vOct2024.toString} inBoundJson exception: $messageId = ${e}") + throw e + case e: Throwable => logger.debug(s"${RabbitMQConnector_vOct2024.toString} inBoundJson exception: $messageId = ${e}") throw new RuntimeException(s"$AdapterUnknownError Please Check Adapter Side! Details: ${e.getMessage}")//TODO error handling to API level - } - } + } finally { - RabbitMQConnectionPool.returnConnection(connection) + connectionOpt.foreach(RabbitMQConnectionPool.returnConnection) } } rabbitResponseJsonFuture.map(rabbitResponseJsonString =>logger.debug(s"${RabbitMQConnector_vOct2024.toString} inBoundJson: $messageId = $rabbitResponseJsonString" )) diff --git a/obp-api/src/test/scala/code/SandboxServer.scala b/obp-api/src/test/scala/code/SandboxServer.scala new file mode 100644 index 0000000000..3be231b10b --- /dev/null +++ b/obp-api/src/test/scala/code/SandboxServer.scala @@ -0,0 +1,319 @@ +package code + +import cats.effect._ +import cats.effect.unsafe.{IORuntime, IORuntimeBuilder} +import code.api.Constant.HostName +import code.api.util.APIUtil +import code.api.util.ApiRole._ +import code.api.util.http4s.Http4sApp +import code.connector.MockedCbsConnector.setPropsValues +import code.consumer.Consumers +import code.entitlement.Entitlement +import code.model.TokenType.Access +import code.model.dataAccess.AuthUser +import code.model.UserX +import code.token.Tokens +import code.users.Users +import com.comcast.ip4s._ +import net.liftweb.common.{Empty, Failure, Full, Logger} +import net.liftweb.mapper.By +import net.liftweb.util.Helpers._ +import net.liftweb.util.Props +import org.http4s.ember.server.EmberServerBuilder + +import java.util.{Date, UUID} +import scala.concurrent.duration._ +import scala.io.StdIn + +/** + * SandboxServer — A standalone HTTP server backed by H2 in-memory database + * for testing import flows with external applications. + * + * Every run starts fresh (H2 in-memory, wiped on JVM exit). + * The server keeps running until you press ENTER. + * + * Usage: + * mvn -pl obp-api -am test-compile exec:java \ + * -Dexec.mainClass="code.SandboxServer" \ + * -Dexec.classpathScope="test" + * + * On startup it prints the DirectLogin token and base URL so the external + * app can authenticate immediately without any setup. + */ +object SandboxServer { + + private val logger = Logger("code.SandboxServer") + + val sandboxPort = 8080 + val sandboxUsername = "sandbox_user" + val sandboxPassword = "sandbox_pass123" + val sandboxEmail = "sandbox@example.com" + + private val sandboxProps: Map[String, String] = Map( + "db.driver" -> "org.h2.Driver", + "db.url" -> "jdbc:h2:mem:sandbox;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1", + "remotedata.db.driver" -> "org.h2.Driver", + "remotedata.db.url" -> "jdbc:h2:mem:sandbox_remote;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1", + "connector" -> "star", + "starConnector_supported_types" -> "mapped,internal", + "migration_scripts.execute_all" -> "true", + "migration_scripts.execute" -> "true", + "transactionRequests_supported_types" -> "ACCOUNT,COUNTERPARTY,SEPA,SANDBOX_TAN,FREE_FORM", + "create_system_views_at_boot" -> "true", + "create_just_in_time_entitlements" -> "true", + "api_instance_id" -> "sandbox_1", + "berlin_group_mandatory_headers" -> "", + "berlin_group_mandatory_header_consent" -> "", + "allow_dauth" -> "false", + "jwt_token_secret" -> "sandbox-jwt-secret-at-least-256-bits-long-ok", + // Pekko — avoid port conflicts with running test suite + "pekko.remote.artery.canonical.port" -> "0", + "pekko.remote.artery.bind.port" -> "0", + "display_internal_errors" -> "true", + // Override test.default.props hostname (which points to port 8016) + "hostname" -> s"http://localhost:$sandboxPort", + "allow_oauth2_login" -> "true", + "oauth2.oidc_provider" -> "obp-oidc", + "oauth2.obp_oidc.host" -> "http://localhost:9000", + "oauth2.obp_oidc.issuer" -> "http://localhost:9000/obp-oidc", + "oauth2.obp_oidc.well_known" -> "http://localhost:9000/obp-oidc/.well-known/openid-configuration", + "oauth2.jwk_set.url" -> "https://www.googleapis.com/oauth2/v3/certs,https://login.microsoftonline.com/97d6317d-60a0-413c-b50c-d799f60bfdd2/discovery/v2.0/keys,http://localhost:7787/realms/master/protocol/openid-connect/certs,http://localhost:9000/obp-oidc/jwks", + ) + + def main(args: Array[String]): Unit = { + // Force Lift to run in Test mode so it loads test.default.props (H2 default, no PostgreSQL). + // Must be set before Props is first accessed. + System.setProperty("run.mode", "test") + // Touch Props to trigger its Scala object initialization (sets lockedProviders = Nil). + // Only after that can we safely prepend our overrides — otherwise Boot.boot() would + // reinitialize Props and wipe whatever we injected before first access. + val _ = Props.mode + setPropsValues(sandboxProps.toSeq: _*) + + logger.info("[SandboxServer] Booting Lift...") + new bootstrap.liftweb.Boot().boot + logger.info("[SandboxServer] Lift boot complete") + + implicit val runtime: IORuntime = IORuntimeBuilder().build() + + try { + val serverResource = EmberServerBuilder + .default[IO] + .withHost(ipv4"127.0.0.1") + .withPort(Port.fromInt(sandboxPort).getOrElse(port"8001")) + .withHttpApp(Http4sApp.httpApp) + .withShutdownTimeout(scala.concurrent.duration.DurationInt(1).second) + .build + + val fiber = serverResource.use(_ => IO.never).start.unsafeRunSync() + waitForServer() + logger.info(s"[SandboxServer] Server started on port $sandboxPort") + + printEffectiveProps() + val tokenKey = setupSandboxUser() + printStartupBanner(tokenKey) + printDatabaseContents() + + println("Press ENTER to shut down the sandbox server...") + StdIn.readLine() + + logger.info("[SandboxServer] Shutting down...") + (fiber.cancel >> fiber.join).unsafeRunSync() + } finally { + runtime.shutdown() + } + } + + private def orThrow[T](box: net.liftweb.common.Box[T], msg: String): T = box match { + case Full(v) => v + case Failure(failMsg, ex, _) => throw new RuntimeException(s"$msg: $failMsg", ex.openOr(null)) + case Empty => throw new RuntimeException(s"$msg: got Empty") + } + + private def waitForServer(timeoutMs: Long = 30000, intervalMs: Long = 500): Unit = { + val url = s"http://localhost:$sandboxPort/obp/v6.0.0/root" + val deadline = System.currentTimeMillis() + timeoutMs + var ready = false + while (!ready && System.currentTimeMillis() < deadline) { + try { + val conn = new java.net.URL(url).openConnection().asInstanceOf[java.net.HttpURLConnection] + conn.setConnectTimeout(intervalMs.toInt) + conn.setReadTimeout(intervalMs.toInt) + conn.connect() + if (conn.getResponseCode == 200) ready = true + conn.disconnect() + } catch { + case _: java.io.IOException => Thread.sleep(intervalMs) + } + } + if (!ready) throw new RuntimeException(s"[SandboxServer] Server did not become ready within ${timeoutMs}ms") + } + + private def setupSandboxUser(): String = { + // 1. Create AuthUser (needed for DirectLogin password auth) + if (AuthUser.find(By(AuthUser.username, sandboxUsername)).isEmpty) { + val authUser = AuthUser.create + .email(sandboxEmail) + .firstName("Sandbox") + .lastName("User") + .username(sandboxUsername) + .password(sandboxPassword) + .validated(true) + .passwordShouldBeChanged(false) + authUser.save() + } + + // 2. Get or create the ResourceUser created by AuthUser.save() + val resourceUser = orThrow( + Users.users.vend + .getUserByProviderAndUsername(HostName, sandboxUsername) + .map(_.asInstanceOf[code.model.dataAccess.ResourceUser]), + "Failed to resolve sandbox resource user after AuthUser.save()" + ) + + // 3. Create a Consumer + val consumer = orThrow( + Consumers.consumers.vend.createConsumer( + key = Some("obp-sandbox-populator-key"), + secret = Some("obp-sandbox-populator-secret"), + isActive = Some(true), + name = Some("OBP Sandbox Populator"), + appType = None, + description = Some("OBP Sandbox Populator"), + developerEmail = Some(sandboxEmail), + redirectURL = None, + createdByUserId = Some(resourceUser.userId), + None, None, None + ), + "Failed to create sandbox consumer" + ) + + // 4. Create a long-lived DirectLogin token + val expiration = weeks(520) // ~10 years + val token = orThrow( + Tokens.tokens.vend.createToken( + Access, + Some(consumer.id.get), + Some(resourceUser.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(expiration), + Some(TimeSpan(expiration + System.currentTimeMillis())), + Some(new Date()), + None + ), + "Failed to create sandbox token" + ) + + // 5. Grant global entitlements. + // Bank-specific roles (CanCreateAccount, CanCreateCustomer, etc.) are granted + // automatically at request time via create_just_in_time_entitlements=true + // as long as the user has CanCreateEntitlementAtAnyBank. + val globalRoles = List( + canCreateBank, + canCreateEntitlementAtAnyBank, + canGetAnyUser, + canCreateCustomerAtAnyBank, + canGetCustomersAtAllBanks, + canGetCustomersMinimalAtAllBanks, + canCreateUserCustomerLinkAtAnyBank, + canGetUserCustomerLinkAtAnyBank, + canCreateFxRateAtAnyBank, + canCreateHistoricalTransaction, + canCreateCounterpartyAtAnyBank, + ) + + globalRoles.foreach { role => + Entitlement.entitlement.vend.addEntitlement("", resourceUser.userId, role.toString) + } + + logger.info(s"[SandboxServer] Sandbox user ready: userId=${resourceUser.userId}") + token.key.get + } + + // Tables we explicitly populate in setupSandboxUser, in display order. + private val sandboxTables: List[(String, String)] = List( + "Auth Users" -> "authuser", + "Resource Users" -> "resourceuser", + "Consumers" -> "consumer", + "Tokens" -> "token", + "Entitlements" -> "mappedentitlement", + ) + + private def printDatabaseContents(): Unit = { + import net.liftweb.db.DB + import net.liftweb.util.DefaultConnectionIdentifier + + println("\n╔══ Sandbox seed data ══════════════════════════════════════════════════════════") + // Borrow a connection from Lift's own pool — the same pool the ORM writes through. + // A fresh DriverManager.getConnection would create a new empty in-memory DB due to + // classloader isolation in H2. + DB.use(DefaultConnectionIdentifier) { conn => + for ((label, tbl) <- sandboxTables) { + val st = conn.createStatement() + try { + val rs = st.executeQuery(s"""SELECT * FROM "${tbl.toUpperCase}"""") + val meta = rs.getMetaData + val ncols = meta.getColumnCount + val cols = (1 to ncols).map(i => meta.getColumnName(i)) + // Collect all rows first so we can compute per-column widths from actual data. + val rows = Iterator.continually(rs).takeWhile(_.next()).map { r => + (1 to ncols).map(i => Option(r.getString(i)).getOrElse("NULL")) + }.toVector + rs.close() + val colWidths = (0 until ncols).map { c => + (cols(c).length +: rows.map(_(c).length)).max + } + def formatRow(cells: IndexedSeq[String]) = + cells.zipWithIndex.map { case (v, i) => v.padTo(colWidths(i), ' ') }.mkString(" | ") + val divider = colWidths.map("-" * _).mkString("-+-") + println(s"║\n║ $label") + println(s"║ ${formatRow(cols)}") + println(s"║ $divider") + if (rows.isEmpty) println(s"║ (empty)") + else rows.foreach(row => println(s"║ ${formatRow(row)}")) + } finally st.close() + } + } + println("\n╚══════════════════════════════════════════════════════════════════════════════") + } + + private def printEffectiveProps(): Unit = { + val lines = sandboxProps.keys.toSeq.sorted.map { k => + val v = Props.get(k).getOrElse("") + f" $k%-50s = $v" + }.mkString("\n") + println( + s""" + |╔══════════════════════════════════════════════════════════════════════════════╗ + |║ Effective Props ║ + |╚══════════════════════════════════════════════════════════════════════════════╝ + |$lines + |""".stripMargin) + } + + private def printStartupBanner(tokenKey: String): Unit = { + val baseUrl = s"http://localhost:$sandboxPort/obp/v6.0.0" + val banner = + s""" + |╔══════════════════════════════════════════════════════════════════════════════╗ + |║ OBP-API Sandbox Server ║ + |╠══════════════════════════════════════════════════════════════════════════════╣ + |║ Base URL : $baseUrl + |║ Database : H2 in-memory (fresh on every start) + |╠══════════════════════════════════════════════════════════════════════════════╣ + |║ Credentials (for DirectLogin token refresh): + |║ Username : $sandboxUsername + |║ Password : $sandboxPassword + |╠══════════════════════════════════════════════════════════════════════════════╣ + |║ Auth header for all requests: + |║ DirectLogin token=$tokenKey + |╠══════════════════════════════════════════════════════════════════════════════╣ + |║ To get a fresh token: + |║ GET $baseUrl/../my/logins/direct + |║ Header: DirectLogin username="$sandboxUsername",password="$sandboxPassword",consumer_key= + |╚══════════════════════════════════════════════════════════════════════════════╝ + |""".stripMargin + println(banner) + } +}