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 7ba6c8176..14279692f 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3078,10 +3078,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ else getCorrelationId() - val reqHeaders = if (cc.requestHeaders.nonEmpty) - cc.requestHeaders - else - S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers + val reqHeaders = if (cc.requestHeaders.nonEmpty) + cc.requestHeaders + else + S.request.map(_.request.headers).openOr(Nil) val remoteIpAddress = if (cc.ipAddress.nonEmpty) cc.ipAddress @@ -5189,4 +5189,4 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ .distinct // List pairs (bank_id, account_id) } -} \ No newline at end of file +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index cee52b861..78e946fb0 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -2,13 +2,15 @@ package code.api.util.http4s import cats.data.{EitherT, Kleisli, OptionT} import cats.effect._ +import code.api.v7_0_0.Http4s700 import code.api.APIFailureNewStyle import code.api.util.APIUtil.ResourceDoc import code.api.util.ErrorMessages._ import code.api.util.newstyle.ViewNewStyle -import code.api.util.{APIUtil, CallContext, NewStyle} +import code.api.util.{APIUtil, ApiRole, CallContext, NewStyle} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ +import com.github.dwickern.macros.NameOf.nameOf import net.liftweb.common.{Box, Empty, Full} import org.http4s._ import org.http4s.headers.`Content-Type` @@ -70,7 +72,7 @@ object ResourceDocMiddleware extends MdcLoggable { * - Special case: resource-docs endpoint checks resource_docs_requires_role property */ private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = { - if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") { + if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700)) { APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false) } else { resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty) @@ -172,7 +174,14 @@ object ResourceDocMiddleware extends MdcLoggable { private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { import DSL._ - resourceDoc.roles match { + val rolesToCheck: Option[List[ApiRole]] = + if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700) && APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)) { + Some(List(ApiRole.canReadResourceDoc)) + } else { + resourceDoc.roles + } + + rolesToCheck match { case Some(roles) if roles.nonEmpty => ctx.user match { case Full(user) => diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 55da729fc..229c61027 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -6,12 +6,12 @@ import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} -import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc} +import code.api.util.ApiRole.canGetCardsForBank import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.{Http4sRequestAttributes, ResourceDocMiddleware} import code.api.util.http4s.Http4sRequestAttributes.{RequestOps, EndpointHelpers} -import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle} +import code.api.util.{ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle} import code.api.v1_3_0.JSONFactory1_3_0 import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v4_0_0.JSONFactory400 @@ -201,44 +201,26 @@ object Http4s700 { val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" => - implicit val cc: CallContext = req.callContext - for { - result <- IO.fromFuture(IO { - // Check resource_docs_requires_role property - val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false) - - for { - // Authentication based on property - (boxUser, cc1) <- if (resourceDocsRequireRole) - authenticatedAccess(cc) - else - anonymousAccess(cc) - - // Role check based on property - _ <- if (resourceDocsRequireRole) { - NewStyle.function.hasAtLeastOneEntitlement( - failMsg = UserHasMissingRoles + canReadResourceDoc.toString - )("", boxUser.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc1) - } else { - Future.successful(()) - } - - httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) - tagsParam = httpParams.filter(_.name == "tags").map(_.values).headOption - functionsParam = httpParams.filter(_.name == "functions").map(_.values).headOption - localeParam = httpParams.filter(param => param.name == "locale" || param.name == "language").map(_.values).flatten.headOption - contentParam = httpParams.filter(_.name == "content").map(_.values).flatten.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam).headOption - apiCollectionIdParam = httpParams.filter(_.name == "api-collection-id").map(_.values).flatten.headOption - tags = tagsParam.map(_.map(ResourceDocTag(_))) - functions = functionsParam.map(_.toList) - requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) - resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) - filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) - resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) - } yield convertAnyToJsonString(resourceDocsJson) - }) - response <- Ok(result) - } yield response + EndpointHelpers.executeAndRespond(req) { _ => + val queryParams = req.uri.query.multiParams + val tags = queryParams + .get("tags") + .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)).toList) + val functions = queryParams + .get("functions") + .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).toList) + val localeParam = queryParams + .get("locale") + .flatMap(_.headOption) + .orElse(queryParams.get("language").flatMap(_.headOption)) + .map(_.trim) + .filter(_.nonEmpty) + for { + requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) + resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) + filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) + } yield JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) + } } diff --git a/obp-api/src/test/scala/code/api/util/http4s/Http4sCallContextBuilderTest.scala b/obp-api/src/test/scala/code/api/util/http4s/Http4sCallContextBuilderTest.scala new file mode 100644 index 000000000..d6d22baee --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/Http4sCallContextBuilderTest.scala @@ -0,0 +1,454 @@ +package code.api.util.http4s + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import net.liftweb.common.{Empty, Full} +import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.headers.`Content-Type` +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag} + +/** + * Unit tests for Http4sCallContextBuilder + * + * Tests CallContext building from http4s Request[IO]: + * - URL extraction (including query parameters) + * - Header extraction and conversion to HTTPParam + * - Body extraction for POST requests + * - Correlation ID generation/extraction + * - IP address extraction (X-Forwarded-For and direct) + * - Auth header extraction for all auth types + * + */ +class Http4sCallContextBuilderTest extends FeatureSpec with Matchers with GivenWhenThen { + + object Http4sCallContextBuilderTag extends Tag("Http4sCallContextBuilder") + + feature("Http4sCallContextBuilder - URL extraction") { + + scenario("Extract URL with path only", Http4sCallContextBuilderTag) { + Given("A request with path /obp/v7.0.0/banks") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("URL should match the request URI") + callContext.url should equal("/obp/v7.0.0/banks") + } + + scenario("Extract URL with query parameters", Http4sCallContextBuilderTag) { + Given("A request with query parameters") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks?limit=10&offset=0") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("URL should include query parameters") + callContext.url should equal("/obp/v7.0.0/banks?limit=10&offset=0") + } + + scenario("Extract URL with path parameters", Http4sCallContextBuilderTag) { + Given("A request with path parameters") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("URL should include path parameters") + callContext.url should equal("/obp/v7.0.0/banks/gh.29.de/accounts/test1") + } + } + + feature("Http4sCallContextBuilder - Header extraction") { + + scenario("Extract headers and convert to HTTPParam", Http4sCallContextBuilderTag) { + Given("A request with multiple headers") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Content-Type"), "application/json"), + Header.Raw(org.typelevel.ci.CIString("Accept"), "application/json"), + Header.Raw(org.typelevel.ci.CIString("X-Custom-Header"), "test-value") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Headers should be converted to HTTPParam list") + callContext.requestHeaders should not be empty + callContext.requestHeaders.exists(_.name == "Content-Type") should be(true) + callContext.requestHeaders.exists(_.name == "Accept") should be(true) + callContext.requestHeaders.exists(_.name == "X-Custom-Header") should be(true) + } + + scenario("Extract empty headers list", Http4sCallContextBuilderTag) { + Given("A request with no custom headers") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Headers list should be empty or contain only default headers") + // http4s may add default headers, so we just check it's a list + callContext.requestHeaders should be(a[List[_]]) + } + } + + feature("Http4sCallContextBuilder - Body extraction") { + + scenario("Extract body from POST request", Http4sCallContextBuilderTag) { + Given("A POST request with JSON body") + val jsonBody = """{"name": "Test Bank", "id": "test-bank-1"}""" + val request = Request[IO]( + method = Method.POST, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withEntity(jsonBody) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Body should be extracted as Some(string)") + callContext.httpBody should be(Some(jsonBody)) + } + + scenario("Extract empty body from GET request", Http4sCallContextBuilderTag) { + Given("A GET request with no body") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Body should be None") + callContext.httpBody should be(None) + } + + scenario("Extract body from PUT request", Http4sCallContextBuilderTag) { + Given("A PUT request with JSON body") + val jsonBody = """{"name": "Updated Bank"}""" + val request = Request[IO]( + method = Method.PUT, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks/test-bank-1") + ).withEntity(jsonBody) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Body should be extracted") + callContext.httpBody should be(Some(jsonBody)) + } + } + + feature("Http4sCallContextBuilder - Correlation ID") { + + scenario("Extract correlation ID from X-Request-ID header", Http4sCallContextBuilderTag) { + Given("A request with X-Request-ID header") + val requestId = "test-correlation-id-12345" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("X-Request-ID"), requestId) + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Correlation ID should match the header value") + callContext.correlationId should equal(requestId) + } + + scenario("Generate correlation ID when header missing", Http4sCallContextBuilderTag) { + Given("A request without X-Request-ID header") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Correlation ID should be generated (UUID format)") + callContext.correlationId should not be empty + // UUID format: 8-4-4-4-12 hex digits + callContext.correlationId should fullyMatch regex "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + } + } + + feature("Http4sCallContextBuilder - IP address extraction") { + + scenario("Extract IP from X-Forwarded-For header", Http4sCallContextBuilderTag) { + Given("A request with X-Forwarded-For header") + val clientIp = "192.168.1.100" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), clientIp) + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("IP address should match the header value") + callContext.ipAddress should equal(clientIp) + } + + scenario("Extract first IP from X-Forwarded-For with multiple IPs", Http4sCallContextBuilderTag) { + Given("A request with X-Forwarded-For containing multiple IPs") + val forwardedFor = "192.168.1.100, 10.0.0.1, 172.16.0.1" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), forwardedFor) + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("IP address should be the first IP in the list") + callContext.ipAddress should equal("192.168.1.100") + } + + scenario("Handle missing IP address", Http4sCallContextBuilderTag) { + Given("A request without X-Forwarded-For or remote address") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("IP address should be empty string") + callContext.ipAddress should equal("") + } + } + + feature("Http4sCallContextBuilder - Authentication header extraction") { + + scenario("Extract DirectLogin token from DirectLogin header (new format)", Http4sCallContextBuilderTag) { + Given("A request with DirectLogin header") + val token = "eyJhbGciOiJIUzI1NiJ9.eyIiOiIifQ.test" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("DirectLogin params should contain token") + callContext.directLoginParams should contain key "token" + callContext.directLoginParams("token") should equal(token) + } + + scenario("Extract DirectLogin token from Authorization header (old format)", Http4sCallContextBuilderTag) { + Given("A request with Authorization: DirectLogin header") + val token = "eyJhbGciOiJIUzI1NiJ9.eyIiOiIifQ.test" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Authorization"), s"DirectLogin token=$token") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("DirectLogin params should contain token") + callContext.directLoginParams should contain key "token" + callContext.directLoginParams("token") should equal(token) + + And("Authorization header should be stored") + callContext.authReqHeaderField should equal(Full(s"DirectLogin token=$token")) + } + + scenario("Extract DirectLogin with username and password", Http4sCallContextBuilderTag) { + Given("A request with DirectLogin username and password") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("DirectLogin"), """username="testuser", password="testpass", consumer_key="key123"""") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("DirectLogin params should contain all parameters") + callContext.directLoginParams should contain key "username" + callContext.directLoginParams should contain key "password" + callContext.directLoginParams should contain key "consumer_key" + callContext.directLoginParams("username") should equal("testuser") + callContext.directLoginParams("password") should equal("testpass") + callContext.directLoginParams("consumer_key") should equal("key123") + } + + scenario("Extract OAuth parameters from Authorization header", Http4sCallContextBuilderTag) { + Given("A request with OAuth Authorization header") + val oauthHeader = """OAuth oauth_consumer_key="consumer123", oauth_token="token456", oauth_signature="sig789"""" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Authorization"), oauthHeader) + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("OAuth params should be extracted") + callContext.oAuthParams should contain key "oauth_consumer_key" + callContext.oAuthParams should contain key "oauth_token" + callContext.oAuthParams should contain key "oauth_signature" + callContext.oAuthParams("oauth_consumer_key") should equal("consumer123") + callContext.oAuthParams("oauth_token") should equal("token456") + callContext.oAuthParams("oauth_signature") should equal("sig789") + + And("Authorization header should be stored") + callContext.authReqHeaderField should equal(Full(oauthHeader)) + } + + scenario("Extract Bearer token from Authorization header", Http4sCallContextBuilderTag) { + Given("A request with Bearer token") + val bearerToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Authorization"), s"Bearer $bearerToken") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Authorization header should be stored") + callContext.authReqHeaderField should equal(Full(s"Bearer $bearerToken")) + } + + scenario("Handle missing Authorization header", Http4sCallContextBuilderTag) { + Given("A request without Authorization header") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Auth header field should be Empty") + callContext.authReqHeaderField should equal(Empty) + + And("DirectLogin params should be empty") + callContext.directLoginParams should be(empty) + + And("OAuth params should be empty") + callContext.oAuthParams should be(empty) + } + } + + feature("Http4sCallContextBuilder - Request metadata") { + + scenario("Extract HTTP verb", Http4sCallContextBuilderTag) { + Given("A POST request") + val request = Request[IO]( + method = Method.POST, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Verb should be POST") + callContext.verb should equal("POST") + } + + scenario("Set implementedInVersion from parameter", Http4sCallContextBuilderTag) { + Given("A request with API version v7.0.0") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext with version parameter") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("implementedInVersion should match the parameter") + callContext.implementedInVersion should equal("v7.0.0") + } + + scenario("Set startTime to current date", Http4sCallContextBuilderTag) { + Given("A request") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val beforeTime = new java.util.Date() + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + val afterTime = new java.util.Date() + + Then("startTime should be set and within reasonable range") + callContext.startTime should be(defined) + callContext.startTime.get.getTime should be >= beforeTime.getTime + callContext.startTime.get.getTime should be <= afterTime.getTime + } + } + + feature("Http4sCallContextBuilder - Complete integration") { + + scenario("Build complete CallContext with all fields", Http4sCallContextBuilderTag) { + Given("A complete POST request with all headers and body") + val jsonBody = """{"name": "Test Bank"}""" + val token = "test-token-123" + val correlationId = "correlation-123" + val clientIp = "192.168.1.100" + + val request = Request[IO]( + method = Method.POST, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks?limit=10") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Content-Type"), "application/json"), + Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token"), + Header.Raw(org.typelevel.ci.CIString("X-Request-ID"), correlationId), + Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), clientIp) + ).withEntity(jsonBody) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("All fields should be populated correctly") + callContext.url should equal("/obp/v7.0.0/banks?limit=10") + callContext.verb should equal("POST") + callContext.implementedInVersion should equal("v7.0.0") + callContext.correlationId should equal(correlationId) + callContext.ipAddress should equal(clientIp) + callContext.httpBody should be(Some(jsonBody)) + callContext.directLoginParams should contain key "token" + callContext.directLoginParams("token") should equal(token) + callContext.requestHeaders should not be empty + callContext.startTime should be(defined) + } + } +} diff --git a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMatcherTest.scala b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMatcherTest.scala new file mode 100644 index 000000000..a686295aa --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMatcherTest.scala @@ -0,0 +1,545 @@ +package code.api.util.http4s + +import code.api.util.APIUtil.ResourceDoc +import code.api.util.ApiTag.ResourceDocTag +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.JsonAST.JObject +import org.http4s._ +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag} + +import scala.collection.mutable.ArrayBuffer + +/** + * Unit tests for ResourceDocMatcher + * + * Tests ResourceDoc matching and path parameter extraction: + * - Matching by verb and exact path + * - Matching with BANK_ID variable + * - Matching with BANK_ID + ACCOUNT_ID variables + * - Matching with BANK_ID + ACCOUNT_ID + VIEW_ID variables + * - Matching with COUNTERPARTY_ID variable + * - Non-matching requests return None + * - Path parameter extraction for all variable types + * + */ +class ResourceDocMatcherTest extends FeatureSpec with Matchers with GivenWhenThen { + + object ResourceDocMatcherTag extends Tag("ResourceDocMatcher") + + // Helper to create minimal ResourceDoc for testing + private def createResourceDoc( + verb: String, + url: String, + operationId: String = "testOperation" + ): ResourceDoc = { + ResourceDoc( + partialFunction = null, // Not needed for matching tests + implementedInApiVersion = ApiVersion.v7_0_0, + partialFunctionName = operationId, + requestVerb = verb, + requestUrl = url, + summary = "Test endpoint", + description = "Test description", + exampleRequestBody = JObject(Nil), + successResponseBody = JObject(Nil), + errorResponseBodies = List.empty, + tags = List(ResourceDocTag("test")), + roles = None + ) + } + + feature("ResourceDocMatcher - Exact path matching") { + + scenario("Match GET request with exact path", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching a GET request to /obp/v7.0.0/banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBanks") + } + + scenario("Match POST request with exact path", ResourceDocMatcherTag) { + Given("A ResourceDoc for POST /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("POST", "/banks", "createBank") + ) + + When("Matching a POST request to /obp/v7.0.0/banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("POST", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("createBank") + } + + scenario("Match request with multi-segment path", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /management/metrics") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/management/metrics", "getMetrics") + ) + + When("Matching a GET request to /obp/v7.0.0/management/metrics") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/management/metrics") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getMetrics") + } + + scenario("Verb mismatch returns None", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching a POST request to /obp/v7.0.0/banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("POST", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + + scenario("Path mismatch returns None", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching a GET request to /obp/v7.0.0/accounts") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + } + + feature("ResourceDocMatcher - BANK_ID variable matching") { + + scenario("Match request with BANK_ID variable", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID", "getBank") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBank") + } + + scenario("Match request with BANK_ID and additional segments", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/test-bank-1/accounts") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank-1/accounts") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBankAccounts") + } + + scenario("Extract BANK_ID parameter value", ResourceDocMatcherTag) { + Given("A matched ResourceDoc with BANK_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank") + + When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract BANK_ID value") + params should contain key "BANK_ID" + params("BANK_ID") should equal("gh.29.de") + } + } + + feature("ResourceDocMatcher - BANK_ID + ACCOUNT_ID variables") { + + scenario("Match request with BANK_ID and ACCOUNT_ID variables", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID", "getBankAccount") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de/accounts/test1") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBankAccount") + } + + scenario("Extract BANK_ID and ACCOUNT_ID parameter values", ResourceDocMatcherTag) { + Given("A matched ResourceDoc with BANK_ID and ACCOUNT_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID", "getBankAccount") + + When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de/accounts/test1") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract both BANK_ID and ACCOUNT_ID values") + params should contain key "BANK_ID" + params should contain key "ACCOUNT_ID" + params("BANK_ID") should equal("gh.29.de") + params("ACCOUNT_ID") should equal("test1") + } + + scenario("Match request with BANK_ID, ACCOUNT_ID and additional segments", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/transactions") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/transactions", "getTransactions") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/test-bank/accounts/acc-123/transactions") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank/accounts/acc-123/transactions") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getTransactions") + } + } + + feature("ResourceDocMatcher - BANK_ID + ACCOUNT_ID + VIEW_ID variables") { + + scenario("Match request with BANK_ID, ACCOUNT_ID and VIEW_ID variables", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", "getTransactionsForView") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getTransactionsForView") + } + + scenario("Extract BANK_ID, ACCOUNT_ID and VIEW_ID parameter values", ResourceDocMatcherTag) { + Given("A matched ResourceDoc with BANK_ID, ACCOUNT_ID and VIEW_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", "getTransactionsForView") + + When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract all three parameter values") + params should contain key "BANK_ID" + params should contain key "ACCOUNT_ID" + params should contain key "VIEW_ID" + params("BANK_ID") should equal("gh.29.de") + params("ACCOUNT_ID") should equal("test1") + params("VIEW_ID") should equal("owner") + } + + scenario("Match request with VIEW_ID in different position", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", "getAccountForView") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/test-bank/accounts/acc-1/public/account") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank/accounts/acc-1/public/account") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getAccountForView") + } + } + + feature("ResourceDocMatcher - COUNTERPARTY_ID variable") { + + scenario("Match request with COUNTERPARTY_ID variable", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "getCounterparty") + ) + + When("Matching a GET request with counterparty ID") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/counterparties/ff010868-ac7d-4f96-9fc5-70dd5757e891") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getCounterparty") + } + + scenario("Extract COUNTERPARTY_ID parameter value", ResourceDocMatcherTag) { + Given("A matched ResourceDoc with COUNTERPARTY_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "getCounterparty") + + When("Extracting path parameters") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/counterparties/ff010868-ac7d-4f96-9fc5-70dd5757e891") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract all parameter values including COUNTERPARTY_ID") + params should contain key "BANK_ID" + params should contain key "ACCOUNT_ID" + params should contain key "VIEW_ID" + params should contain key "COUNTERPARTY_ID" + params("BANK_ID") should equal("gh.29.de") + params("ACCOUNT_ID") should equal("test1") + params("VIEW_ID") should equal("owner") + params("COUNTERPARTY_ID") should equal("ff010868-ac7d-4f96-9fc5-70dd5757e891") + } + + scenario("Match request with COUNTERPARTY_ID in different URL structure", ResourceDocMatcherTag) { + Given("A ResourceDoc for DELETE /management/counterparties/COUNTERPARTY_ID") + val resourceDocs = ArrayBuffer( + createResourceDoc("DELETE", "/management/counterparties/COUNTERPARTY_ID", "deleteCounterparty") + ) + + When("Matching a DELETE request") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/management/counterparties/counterparty-123") + val result = ResourceDocMatcher.findResourceDoc("DELETE", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("deleteCounterparty") + } + } + + feature("ResourceDocMatcher - Non-matching requests") { + + scenario("Return None when no ResourceDoc matches", ResourceDocMatcherTag) { + Given("ResourceDocs for specific endpoints") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks"), + createResourceDoc("GET", "/banks/BANK_ID", "getBank"), + createResourceDoc("POST", "/banks", "createBank") + ) + + When("Matching a request that doesn't match any ResourceDoc") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + + scenario("Return None when verb doesn't match", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching a DELETE request to /obp/v7.0.0/banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("DELETE", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + + scenario("Return None when path segment count doesn't match", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts") + ) + + When("Matching a request with different segment count") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + + scenario("Return None when literal segments don't match", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts") + ) + + When("Matching a request with different literal segment") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/transactions") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + } + + feature("ResourceDocMatcher - Path parameter extraction edge cases") { + + scenario("Extract parameters from path with no variables", ResourceDocMatcherTag) { + Given("A ResourceDoc with no path variables") + val resourceDoc = createResourceDoc("GET", "/banks", "getBanks") + + When("Extracting path parameters") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should return empty map") + params should be(empty) + } + + scenario("Extract parameters with special characters in values", ResourceDocMatcherTag) { + Given("A ResourceDoc with BANK_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank") + + When("Extracting path parameters with special characters") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de-test_bank") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract the full value including special characters") + params should contain key "BANK_ID" + params("BANK_ID") should equal("gh.29.de-test_bank") + } + + scenario("Return empty map when path doesn't match template", ResourceDocMatcherTag) { + Given("A ResourceDoc for /banks/BANK_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank") + + When("Extracting parameters from path with different segment count") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should return empty map due to segment count mismatch") + params should be(empty) + } + } + + feature("ResourceDocMatcher - attachToCallContext") { + + scenario("Attach ResourceDoc to CallContext", ResourceDocMatcherTag) { + Given("A CallContext and a matched ResourceDoc") + val resourceDoc = createResourceDoc("GET", "/banks", "getBanks") + val callContext = code.api.util.CallContext( + correlationId = "test-correlation-id" + ) + + When("Attaching ResourceDoc to CallContext") + val updatedContext = ResourceDocMatcher.attachToCallContext(callContext, resourceDoc) + + Then("CallContext should have resourceDocument set") + updatedContext.resourceDocument should be(defined) + updatedContext.resourceDocument.get should equal(resourceDoc) + } + + scenario("Attach ResourceDoc sets operationId", ResourceDocMatcherTag) { + Given("A CallContext and a matched ResourceDoc") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank") + val callContext = code.api.util.CallContext( + correlationId = "test-correlation-id" + ) + + When("Attaching ResourceDoc to CallContext") + val updatedContext = ResourceDocMatcher.attachToCallContext(callContext, resourceDoc) + + Then("CallContext should have operationId set") + updatedContext.operationId should be(defined) + updatedContext.operationId.get should equal(resourceDoc.operationId) + } + + scenario("Preserve other CallContext fields when attaching ResourceDoc", ResourceDocMatcherTag) { + Given("A CallContext with existing fields") + val resourceDoc = createResourceDoc("GET", "/banks", "getBanks") + val originalContext = code.api.util.CallContext( + correlationId = "test-correlation-id", + url = "/obp/v7.0.0/banks", + verb = "GET", + implementedInVersion = "v7.0.0" + ) + + When("Attaching ResourceDoc to CallContext") + val updatedContext = ResourceDocMatcher.attachToCallContext(originalContext, resourceDoc) + + Then("Other fields should be preserved") + updatedContext.correlationId should equal(originalContext.correlationId) + updatedContext.url should equal(originalContext.url) + updatedContext.verb should equal(originalContext.verb) + updatedContext.implementedInVersion should equal(originalContext.implementedInVersion) + } + } + + feature("ResourceDocMatcher - Multiple ResourceDocs selection") { + + scenario("Select correct ResourceDoc from multiple candidates", ResourceDocMatcherTag) { + Given("Multiple ResourceDocs with different paths") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks"), + createResourceDoc("GET", "/banks/BANK_ID", "getBank"), + createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts"), + createResourceDoc("POST", "/banks", "createBank") + ) + + When("Matching a specific request") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should select the most specific matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBankAccounts") + } + + scenario("Match first ResourceDoc when multiple exact matches exist", ResourceDocMatcherTag) { + Given("Multiple ResourceDocs with same path and verb") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks1"), + createResourceDoc("GET", "/banks", "getBanks2") + ) + + When("Matching a request") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return the first matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBanks1") + } + } + + feature("ResourceDocMatcher - Case sensitivity") { + + scenario("HTTP verb matching is case-insensitive", ResourceDocMatcherTag) { + Given("A ResourceDoc with uppercase GET") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching with lowercase get") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("get", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBanks") + } + + scenario("Path matching is case-sensitive for literal segments", ResourceDocMatcherTag) { + Given("A ResourceDoc for /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching with different case /Banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/Banks") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should not match (case-sensitive)") + result should be(None) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala new file mode 100644 index 000000000..c43070d73 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -0,0 +1,420 @@ +package code.api.v7_0_0 + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles} +import code.setup.ServerSetupWithTestData +import net.liftweb.json.JValue +import net.liftweb.json.JsonAST.{JArray, JField, JObject, JString} +import net.liftweb.json.JsonParser.parse +import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.implicits._ +import org.scalatest.Tag + +class Http4s700RoutesTest extends ServerSetupWithTestData { + + object Http4s700RoutesTag extends Tag("Http4s700Routes") + + private def runAndParseJson(request: Request[IO]): (Status, JValue) = { + val response = Http4s700.wrappedRoutesV700Services.orNotFound.run(request).unsafeRunSync() + val body = response.as[String].unsafeRunSync() + val json = if (body.trim.isEmpty) JObject(Nil) else parse(body) + (response.status, json) + } + + private def withDirectLoginToken(request: Request[IO], token: String): Request[IO] = { + request.withHeaders( + Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token") + ) + } + + private def toFieldMap(fields: List[JField]): Map[String, JValue] = { + fields.map(field => field.name -> field.value).toMap + } + + feature("Http4s700 root endpoint") { + + scenario("Return API info JSON", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/root request") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/root") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with API info fields") + status shouldBe Status.Ok + json match { + case JObject(fields) => + val keys = fields.map(_.name) + keys should contain("version") + keys should contain("version_status") + keys should contain("git_commit") + keys should contain("connector") + case _ => + fail("Expected JSON object for root endpoint") + } + } + } + + feature("Http4s700 banks endpoint") { + + scenario("Return banks list JSON", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/banks request") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with banks array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + val valueOpt = toFieldMap(fields).get("banks") + valueOpt should not be empty + valueOpt.get match { + case JArray(_) => + succeed + case _ => + fail("Expected banks field to be an array") + } + case _ => + fail("Expected JSON object for banks endpoint") + } + } + } + + feature("Http4s700 cards endpoint") { + + scenario("Reject unauthenticated access to cards", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/cards request without auth headers") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/cards") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 401 Unauthorized with appropriate error message") + status.code shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(AuthenticatedUserIsRequired) + case _ => + fail("Expected message field as JSON string for cards unauthorized response") + } + case _ => + fail("Expected JSON object for cards unauthorized response") + } + } + + scenario("Return cards list JSON when authenticated", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/cards request with DirectLogin header") + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/cards") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with cards array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("cards") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected cards field to be an array") + } + case _ => fail("Expected JSON object for cards endpoint") + } + } + } + + feature("Http4s700 bank cards endpoint") { + + scenario("Return bank cards list JSON when authenticated and entitled", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/banks/BANK_ID/cards request with DirectLogin header and role") + val bankId = testBankId1.value + addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString) + + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards?limit=10&offset=0") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with cards array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("cards") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected cards field to be an array") + } + case _ => fail("Expected JSON object for bank cards endpoint") + } + } + + scenario("Reject bank cards access when missing required role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/banks/BANK_ID/cards request with DirectLogin header but no role") + val bankId = testBankId1.value + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 403 Forbidden") + status.code shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(UserHasMissingRoles) + message should include(canGetCardsForBank.toString) + case _ => + fail("Expected message field as JSON string for missing-role response") + } + case _ => + fail("Expected JSON object for missing-role response") + } + } + + scenario("Return BankNotFound when bank does not exist and user is entitled", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/banks/BANK_ID/cards request for non-existing bank") + val bankId = "non-existing-bank-id" + addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString) + + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 404 Not Found with BankNotFound message") + status.code shouldBe 404 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(BankNotFound) + case _ => + fail("Expected message field as JSON string for BankNotFound response") + } + case _ => + fail("Expected JSON object for BankNotFound response") + } + } + } + + feature("Http4s700 resource-docs endpoint") { + + scenario("Allow public access when resource docs role is not required", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request without auth headers") + setPropsValues("resource_docs_requires_role" -> "false") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with resource_docs array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(_)) => + succeed + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object for resource-docs endpoint") + } + } + + scenario("Reject unauthenticated access when resource docs role is required", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request without auth headers and role required") + setPropsValues("resource_docs_requires_role" -> "true") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 401 Unauthorized") + status.code shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(AuthenticatedUserIsRequired) + case _ => + fail("Expected message field as JSON string for resource-docs unauthorized response") + } + case _ => + fail("Expected JSON object for resource-docs unauthorized response") + } + } + + scenario("Reject access when authenticated but missing canReadResourceDoc role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request with auth but no canReadResourceDoc role") + setPropsValues("resource_docs_requires_role" -> "true") + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 403 Forbidden") + status.code shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(UserHasMissingRoles) + message should include(canReadResourceDoc.toString) + case _ => + fail("Expected message field as JSON string for missing-role response") + } + case _ => + fail("Expected JSON object for missing-role response") + } + } + + scenario("Return docs when authenticated and entitled with canReadResourceDoc", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request with auth and canReadResourceDoc role") + setPropsValues("resource_docs_requires_role" -> "true") + addEntitlement("", resourceUser1.userId, canReadResourceDoc.toString) + + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with resource_docs array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(_)) => + succeed + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object for resource-docs endpoint") + } + } + + scenario("Filter docs by tags parameter", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp?tags=Card request") + setPropsValues("resource_docs_requires_role" -> "false") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp?tags=Card") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK and all returned docs contain Card tag") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(resourceDocs)) => + resourceDocs.foreach { + case JObject(rdFields) => + toFieldMap(rdFields).get("tags") match { + case Some(JArray(tags)) => + tags.exists { + case JString(tag) => tag == "Card" + case _ => false + } shouldBe true + case _ => + fail("Expected tags field to be an array") + } + case _ => + fail("Expected resource doc to be a JSON object") + } + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object for resource-docs endpoint") + } + } + + scenario("Filter docs by functions parameter", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp?functions=getBanks request") + setPropsValues("resource_docs_requires_role" -> "false") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp?functions=getBanks") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK and includes GET /banks") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(resourceDocs)) => + resourceDocs.foreach { + case JObject(rdFields) => + val fieldMap = toFieldMap(rdFields) + (fieldMap.get("request_verb"), fieldMap.get("request_url")) match { + case (Some(JString(verb)), Some(JString(url))) => + verb shouldBe "GET" + url should endWith("/banks") + case _ => + fail("Expected request_verb and request_url fields as JSON strings") + } + case _ => + fail("Expected resource doc to be a JSON object") + } + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object for resource-docs endpoint") + } + } + } + +}