Skip to content

by-name parameters incorrectly evaluated when mocking #446

Open
@bpatters

Description

@bpatters

I've provided a very simple sbt project that reproduces this problem.
by-name-repro.zip

In the reproduction project, similar to my actual scenario but simplified, the scenario is as follows:
I have a class we can call DataManager that I want to test. It's methods all follow a similar pattern of:

class DataManager {

  /**
    * Perform admin operations if the user is allowed to perform them
    * @param data  admin data
    * @param securityPolicy the security policy to check users access
    * @param userRole the current users role
    * @return
    */
  def doAdminStuff(
    data: String
  )(
    implicit securityPolicy: SecurityPolicy,
    userRole:                UserRole
  ): Future[Int] =
    securityPolicy.withRole(UserRole.ADMIN) {
      println("Performing admin operation")
      Future(1)
    }
}

The implicit Security policy is what I'm mocking in my tests and then testing a normal instance of the Data Manager
The security policy uses curried operations of the following form:

  /**
    * Run the specified operation only if the expectedRole is equal to the users role
    * @param expectedRole the required role to execute the operation
    * @param operation the operation to perform
    * @param userRole the users actual role
    * @return the result of the operation or throws an exception if unauthorized
    */
  def withRole(expectedRole: UserRole)(operation: => Future[Int])(implicit userRole: UserRole) =
    if (expectedRole == userRole)
      operation
    else
      throw new Exception(s"must have $expectedRole to perform this operation")

The operation should only ever be evaluated if the security check passes.

The following tests will fail because the operation is evaluated even though the security policy's mocked answer is executed correctly:

  test("Admin operation should not be called with user role") {
    implicit val securityPolicy: SecurityPolicy = mock[SecurityPolicy]
    val dataManager = new DataManager()

    // by-name parameter evaluation are implemented by wrapping in a Function0
    { (role: UserRole, operation: Function0[Future[Int]], userRole: UserRole) =>
      throw new Exception("Access denied")
    } willBe answered by securityPolicy.withRole(eqTo(UserRole.ADMIN))(any[Future[Int]])(
      any[UserRole]
    )
    intercept[Exception] {
      implicit val userRole: UserRole = UserRole.USER
      Await.result(dataManager.doAdminStuff("test"), Duration.Inf)
    }
    assert(dataManager.adminOperationCount == 0)
  }

However if I wrap the DataManager instance in a spy then the same test will pass as the operation isn't executed:

  test("using spy, Admin operation should not be called with user") {
    implicit val securityPolicy: SecurityPolicy = mock[SecurityPolicy]
    val dataManager = spy(new DataManager())

   // by-name parameter evaluation are implemented by wrapping in a Function0
    { (role: UserRole, operation: Function0[Future[Int]], userRole: UserRole) =>
      throw new Exception("Access denied")
    } willBe answered by securityPolicy.withRole(eqTo(UserRole.ADMIN))(any[Future[Int]])(
      any[UserRole]
    )
    doCallRealMethod().when(dataManager).doAdminStuff(anyString)(any[SecurityPolicy], any[UserRole])
    doCallRealMethod().when(dataManager).adminOperationCount

    intercept[Exception] {
      implicit val userRole: UserRole = UserRole.USER
      Await.result(dataManager.doAdminStuff("test"), Duration.Inf)
    }
    assert(dataManager.adminOperationCount == 0)
  }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions