Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WM-2500][WM-2502] Fetch Github token from ECM for importing and running private workflows #7392

Merged
merged 19 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -578,10 +578,8 @@ services {
config {
enabled = false
auth.azure = false
# Notes:
# - don't include the 'Bearer' before the token
# - this config value should be removed when support for fetching tokens from ECM has been added to Cromwell
access-token = "dummy-token"
# Set this to the service that Cromwell should retrieve Github access token associated with user's token.
# ecm.base-url = "https://externalcreds.dsde-dev.broadinstitute.org/"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My previous comment disappeared because of another change to this file (thanks Github) so just calling out that we talked about removing this URL in a now-outdated thread.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I had the change locally but forgot to push it. ecm.base-url should now have value https://example.org

}
}
}
Expand Down
2 changes: 1 addition & 1 deletion project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ object Dependencies {
val servicesDependencies: List[ModuleID] = List(
"com.google.api" % "gax-grpc" % googleGaxGrpcV,
"org.apache.commons" % "commons-csv" % commonsCsvV,
) ++ testDatabaseDependencies
) ++ testDatabaseDependencies ++ akkaHttpDependencies

val serverDependencies: List[ModuleID] = slf4jBindingDependencies

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ object GithubAuthVending {
override def serviceName: String = "GithubAuthVending"
}

case class GithubAuthRequest(terraToken: String) extends GithubAuthVendingMessage
// types of tokens
case class TerraToken(value: String)
case class GithubToken(value: String)

case class GithubAuthRequest(terraToken: TerraToken) extends GithubAuthVendingMessage

sealed trait GithubAuthVendingResponse extends GithubAuthVendingMessage
case class GithubAuthTokenResponse(accessToken: String) extends GithubAuthVendingResponse
case class GithubAuthTokenResponse(githubAccessToken: GithubToken) extends GithubAuthVendingResponse
case object NoGithubAuthResponse extends GithubAuthVendingResponse
case class GithubAuthVendingFailure(error: Exception) extends GithubAuthVendingResponse
case class GithubAuthVendingFailure(errorMsg: String) extends GithubAuthVendingResponse

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
GithubAuthTokenResponse,
GithubAuthVendingFailure,
GithubAuthVendingResponse,
NoGithubAuthResponse
NoGithubAuthResponse,
TerraToken
}

import net.ceedubs.ficus.Ficus._

import scala.concurrent.{ExecutionContext, Future}

trait GithubAuthVendingSupport extends AskSupport with StrictLogging {
Expand All @@ -30,7 +31,7 @@
def importAuthProvider(token: String)(implicit timeout: Timeout): ImportAuthProvider = new GithubImportAuthProvider {
override def authHeader(): Future[Map[String, String]] =
serviceRegistryActor
.ask(GithubAuthRequest(token))
.ask(GithubAuthRequest(TerraToken(token)))

Check warning on line 34 in services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala#L34

Added line #L34 was not covered by tests
.mapTo[GithubAuthVendingResponse]
.recoverWith {
case e: AskTimeoutException =>
Expand All @@ -41,10 +42,11 @@
Future.failed(new Exception("Failed to resolve github auth token", e))
}
.flatMap {
case GithubAuthTokenResponse(token) => Future.successful(Map("Authorization" -> s"Bearer ${token}"))
case GithubAuthTokenResponse(githubToken) =>
Future.successful(Map("Authorization" -> s"Bearer ${githubToken.value}"))

Check warning on line 46 in services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala#L46

Added line #L46 was not covered by tests
case NoGithubAuthResponse => Future.successful(Map.empty)
case GithubAuthVendingFailure(error) =>
Future.failed(new Exception("Failed to resolve github auth token", error))
Future.failed(new Exception(s"Failed to resolve GitHub auth token. Error: $error"))

Check warning on line 49 in services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala#L49

Added line #L49 was not covered by tests
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package cromwell.services.auth.ecm

import com.typesafe.config.Config
import net.ceedubs.ficus.Ficus._

final case class EcmConfig(baseUrl: Option[String])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOL is there a reason the enabled and auth.azure fields are not here?

Copy link
Contributor Author

@salonishah11 salonishah11 Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed via Slack but posting here for reference: Since these values are more related to enabling GithubAuthVending service they are separate from the ecm sub-section in the config.


object EcmConfig {
def apply(config: Config): EcmConfig = EcmConfig(config.as[Option[String]]("ecm.base-url"))

Check warning on line 9 in services/src/main/scala/cromwell/services/auth/ecm/EcmConfig.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmConfig.scala#L9

Added line #L9 was not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if enabled is false?

Copy link
Contributor Author

@salonishah11 salonishah11 Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed via Slack but posting here for reference: Since it is separate from ecm config it is handled separately here

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cromwell.services.auth.ecm

import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.model._
import akka.http.scaladsl.model.headers.RawHeader
import akka.util.ByteString
import cromwell.services.auth.GithubAuthVending.{GithubToken, TerraToken}
import spray.json._

import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Success, Try}

class EcmService(baseEcmUrl: String) {
private val getGithubAccessTokenApiPath = "api/oauth/v1/github/access-token"

/*
ECM does generally return standard JSON error response, but for 401 status code it seems some other layer in
between (like the apache proxies, etc) returns HTML pages. This helper method returns custom error message for 401
status code as it contains HTML tags. For all other status code, the response format is generally of ErrorReport
schema and this method tries to extract the actual message from the JSON object and return it. In case it fails
to parse JSON, it returns the original error response body.
ErrorReport schema: {"message":"<actual_error_msg>", "statusCode":<code>}
*/
def extractErrorMessage(errorCode: StatusCode, responseBodyAsStr: String): String =
errorCode match {
case StatusCodes.Unauthorized => "Invalid or missing authentication credentials."

Check warning on line 27 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L27

Added line #L27 was not covered by tests
case _ =>
Try(responseBodyAsStr.parseJson) match {

Check warning on line 29 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L29

Added line #L29 was not covered by tests
case Success(JsObject(fields)) =>
fields.get("message").map(_.toString().replaceAll("\"", "")).getOrElse(responseBodyAsStr)
case _ => responseBodyAsStr

Check warning on line 32 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L31-L32

Added lines #L31 - L32 were not covered by tests
}
}

def getGithubAccessToken(
userToken: TerraToken
)(implicit actorSystem: ActorSystem, ec: ExecutionContext): Future[GithubToken] = {

def responseEntityToFutureStr(responseEntity: ResponseEntity): Future[String] =
responseEntity.dataBytes.runFold(ByteString(""))(_ ++ _).map(_.utf8String)

Check warning on line 41 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L41

Added line #L41 was not covered by tests
Comment on lines +40 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know akka is picky and can throw exceptions if we don't read the bytes in time (within 1 second?). How comfortable are we that this future will evaluate in time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied the pattern we have in Cromwell (see WorkflowCallbackActor.scala and TesAsyncBackendJobExecutionActor.scala) and didn't realize that 1 second was the default timeout. Is there an implicit timeout being imported in these 2 references that I also need to update here? 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible that we're hitting this error in cases where we aren't choosing to read the bytes at all, rather than cases where we take too long to read them. We're planning to do WX-1525 next week, which should confirm that one way or the other.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, should the whole response be loaded into memory as Strict entity using .toStrict(<timeout>) method?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just make sure that responseEntity.dataBytes.runFold is actually getting executed in all code paths. It looks like you're calling it on both success and failure codes, so I think you're probably okay!


val headers: HttpHeader = RawHeader("Authorization", s"Bearer ${userToken.value}")

Check warning on line 43 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L43

Added line #L43 was not covered by tests
val httpRequest =
HttpRequest(method = HttpMethods.GET, uri = s"$baseEcmUrl/$getGithubAccessTokenApiPath").withHeaders(headers)

Check warning on line 45 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L45

Added line #L45 was not covered by tests

Http()
.singleRequest(httpRequest)
.flatMap((response: HttpResponse) =>

Check warning on line 49 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L49

Added line #L49 was not covered by tests
if (response.status.isFailure()) {
responseEntityToFutureStr(response.entity) flatMap { errorBody =>
val errorMessage = extractErrorMessage(response.status, errorBody)
Future.failed(new RuntimeException(s"HTTP ${response.status.value}. $errorMessage"))
}
} else responseEntityToFutureStr(response.entity).map(GithubToken)
)
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,57 @@
package cromwell.services.auth.impl

import akka.actor.{Actor, ActorRef, Props}
import akka.actor.{Actor, ActorRef, ActorSystem, Props}
import com.typesafe.config.Config
import com.typesafe.scalalogging.LazyLogging
import common.util.StringUtil.EnhancedToStringable
import cromwell.core.Dispatcher.ServiceDispatcher
import cromwell.services.auth.GithubAuthVending.{GithubAuthRequest, GithubAuthTokenResponse, NoGithubAuthResponse}
import cromwell.services.auth.GithubAuthVending.{
GithubAuthRequest,
GithubAuthTokenResponse,
GithubAuthVendingFailure,
NoGithubAuthResponse
}
import cromwell.services.auth.ecm.{EcmConfig, EcmService}
import cromwell.util.GracefulShutdownHelper.ShutdownCommand

import scala.concurrent.ExecutionContext
import scala.util.{Failure, Success}

class GithubAuthVendingActor(serviceConfig: Config, globalConfig: Config, serviceRegistryActor: ActorRef)
extends Actor
with LazyLogging {

lazy val enabled = serviceConfig.getBoolean("enabled")
implicit val system: ActorSystem = context.system
implicit val ec: ExecutionContext = context.dispatcher

lazy val enabled: Boolean = serviceConfig.getBoolean("enabled")

lazy val ecmConfigOpt: EcmConfig = EcmConfig(serviceConfig)
lazy val ecmServiceOpt: Option[EcmService] = ecmConfigOpt.baseUrl.map(url => new EcmService(url))

override def receive: Receive = {
case GithubAuthRequest(_) if enabled =>
sender() ! GithubAuthTokenResponse(serviceConfig.getString("access-token"))
case GithubAuthRequest(terraToken) if enabled =>
val respondTo = sender()

Check warning on line 34 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L33-L34

Added lines #L33 - L34 were not covered by tests
ecmServiceOpt match {
case Some(ecmService) =>
ecmService.getGithubAccessToken(terraToken) onComplete {
case Success(githubToken) => respondTo ! GithubAuthTokenResponse(githubToken)
case Failure(e) => respondTo ! GithubAuthVendingFailure(e.getMessage)

Check warning on line 39 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L37-L39

Added lines #L37 - L39 were not covered by tests
}
case None =>
respondTo ! GithubAuthVendingFailure(

Check warning on line 42 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L42

Added line #L42 was not covered by tests
"Invalid configuration for service 'GithubAuthVending': missing 'ecm.base-url' value."
)
}
case GithubAuthRequest(_) => sender() ! NoGithubAuthResponse

Check warning on line 46 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L46

Added line #L46 was not covered by tests
// This service currently doesn't do any work on shutdown but the service registry pattern requires it
// (see https://github.com/broadinstitute/cromwell/issues/2575)
case ShutdownCommand => context stop self
case _ =>
sender() ! NoGithubAuthResponse
case other =>

Check warning on line 50 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L50

Added line #L50 was not covered by tests
logger.error(
s"Programmer Error: Unexpected message ${other.toPrettyElidedString(1000)} received by ${this.self.path.name}."
)
sender() ! GithubAuthVendingFailure(s"Received unexpected message ${other.toPrettyElidedString(1000)}.")

Check warning on line 54 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L54

Added line #L54 was not covered by tests
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import com.typesafe.config.ConfigFactory
import cromwell.core.TestKitSuite
import cromwell.languages.util.ImportResolver.GithubImportAuthProvider
import cromwell.services.ServiceRegistryActor.ServiceRegistryFailure
import cromwell.services.auth.GithubAuthVending.GithubAuthRequest
import cromwell.services.auth.GithubAuthVending.{GithubAuthRequest, GithubToken, TerraToken}
import cromwell.services.auth.GithubAuthVendingSupportSpec.TestGithubAuthVendingSupport
import org.scalatest.concurrent.Eventually
import org.scalatest.flatspec.AnyFlatSpecLike
Expand Down Expand Up @@ -42,8 +42,8 @@ class GithubAuthVendingSupportSpec extends TestKitSuite with AnyFlatSpecLike wit
val provider = testSupport.importAuthProvider("user-token")
val authHeader: Future[Map[String, String]] = provider.authHeader()

serviceRegistryActor.expectMsg(GithubAuthRequest("user-token"))
serviceRegistryActor.reply(GithubAuthVending.GithubAuthTokenResponse("github-token"))
serviceRegistryActor.expectMsg(GithubAuthRequest(TerraToken("user-token")))
serviceRegistryActor.reply(GithubAuthVending.GithubAuthTokenResponse(GithubToken("github-token")))

Await.result(authHeader, 10.seconds) should be(Map("Authorization" -> "Bearer github-token"))
}
Expand All @@ -54,7 +54,7 @@ class GithubAuthVendingSupportSpec extends TestKitSuite with AnyFlatSpecLike wit
val provider = testSupport.importAuthProvider("user-token")
val authHeader: Future[Map[String, String]] = provider.authHeader()

serviceRegistryActor.expectMsg(GithubAuthRequest("user-token"))
serviceRegistryActor.expectMsg(GithubAuthRequest(TerraToken("user-token")))
serviceRegistryActor.reply(GithubAuthVending.NoGithubAuthResponse)

Await.result(authHeader, 10.seconds) should be(Map.empty)
Expand All @@ -66,13 +66,12 @@ class GithubAuthVendingSupportSpec extends TestKitSuite with AnyFlatSpecLike wit
val provider = testSupport.importAuthProvider("user-token")
val authHeader: Future[Map[String, String]] = provider.authHeader()

serviceRegistryActor.expectMsg(GithubAuthRequest("user-token"))
serviceRegistryActor.reply(GithubAuthVending.GithubAuthVendingFailure(new Exception("BOOM")))
serviceRegistryActor.expectMsg(GithubAuthRequest(TerraToken("user-token")))
serviceRegistryActor.reply(GithubAuthVending.GithubAuthVendingFailure("BOOM"))

eventually {
authHeader.isCompleted should be(true)
authHeader.value.get.failed.get.getMessage should be("Failed to resolve github auth token")
authHeader.value.get.failed.get.getCause.getMessage should be("BOOM")
authHeader.value.get.failed.get.getMessage should be("Failed to resolve GitHub auth token. Error: BOOM")
}
}

Expand All @@ -95,7 +94,7 @@ class GithubAuthVendingSupportSpec extends TestKitSuite with AnyFlatSpecLike wit
val provider = testSupport.importAuthProvider("user-token")
val authHeader: Future[Map[String, String]] = provider.authHeader()

serviceRegistryActor.expectMsg(GithubAuthRequest("user-token"))
serviceRegistryActor.expectMsg(GithubAuthRequest(TerraToken("user-token")))
serviceRegistryActor.reply(ServiceRegistryFailure("GithubAuthVending"))

eventually {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cromwell.services.auth.ecm

import com.typesafe.config.ConfigFactory
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class EcmConfigSpec extends AnyFlatSpec with Matchers {

it should "parse ECM base url when present" in {
val config = ConfigFactory.parseString(s"""
|enabled = true
|auth.azure = true
|ecm.base-url = "https://mock-ecm-url.org"
""".stripMargin)

val actualEcmConfig = EcmConfig(config)

actualEcmConfig.baseUrl shouldBe defined
actualEcmConfig.baseUrl.get shouldBe "https://mock-ecm-url.org"
}

it should "return None when ECM base url is absent" in {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also have a case for the other config values (to continue the theme... like enabled!)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes they should be covered here

val config = ConfigFactory.parseString(s"""
|enabled = true
|auth.azure = true
""".stripMargin)

EcmConfig(config).baseUrl shouldBe None
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cromwell.services.auth.ecm

import akka.http.scaladsl.model.StatusCodes
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.prop.TableDrivenPropertyChecks

class EcmServiceSpec extends AnyFlatSpec with Matchers with TableDrivenPropertyChecks {

private val ecmService = new EcmService("https://mock-ecm-url.org")

private val ecm400ErrorMsg = "No enum constant bio.terra.externalcreds.generated.model.Provider.MyOwnProvider"
private val ecm404ErrorMsg =
"No linked account found for user ID: 123 and provider: github. Please go to the Terra Profile page External Identities tab to link your account for this provider"

private val testCases = Table(
("test name", "response status code", "response body string", "expected error message"),
("return custom 401 error when status code is 401",
StatusCodes.Unauthorized,
"<h2>could be anything</h2>",
"Invalid or missing authentication credentials."
),
("extract message from valid ErrorReport JSON if status code is 400",
StatusCodes.BadRequest,
s"""{ "message" : "$ecm400ErrorMsg", "statusCode" : 400}""",
ecm400ErrorMsg
),
("extract message from valid ErrorReport JSON if status code is 404",
StatusCodes.NotFound,
s"""{ "message" : "$ecm404ErrorMsg", "statusCode" : 404}""",
ecm404ErrorMsg
),
("extract message from valid ErrorReport JSON if status code is 500",
StatusCodes.InternalServerError,
"""{ "message" : "Internal error", "statusCode" : 500}""",
"Internal error"
),
("return response error body if it fails to parse JSON",
StatusCodes.InternalServerError,
"Response error - not a JSON",
"Response error - not a JSON"
),
("return response error body if JSON doesn't contain 'message' key",
StatusCodes.BadRequest,
"""{"non-message-key" : "error message"}""",
"""{"non-message-key" : "error message"}"""
)
)

behavior of "extractErrorMessage in EcmService"

forAll(testCases) { (testName, statusCode, responseBodyAsStr, expectedErrorMsg) =>
it should testName in {
assert(ecmService.extractErrorMessage(statusCode, responseBodyAsStr) == expectedErrorMsg)
}
Comment on lines +52 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woohoo for table based tests!

}
}
Loading
Loading