diff --git a/thehive/app/org/thp/thehive/controllers/v0/Router.scala b/thehive/app/org/thp/thehive/controllers/v0/Router.scala index 0328e5401f..c058a6db1a 100644 --- a/thehive/app/org/thp/thehive/controllers/v0/Router.scala +++ b/thehive/app/org/thp/thehive/controllers/v0/Router.scala @@ -215,7 +215,6 @@ class Router @Inject() ( case GET(p"/tag") => tagCtrl.search case POST(p"/tag/_search") => tagCtrl.search case POST(p"/tag/_stats") => tagCtrl.stats - case POST(p"/tag/_import") => tagCtrl.importTaxonomy case GET(p"/tag/$id") => tagCtrl.get(id) case GET(p"/user") => userCtrl.search diff --git a/thehive/app/org/thp/thehive/controllers/v0/TagCtrl.scala b/thehive/app/org/thp/thehive/controllers/v0/TagCtrl.scala index 616e85fab9..17a44965ef 100644 --- a/thehive/app/org/thp/thehive/controllers/v0/TagCtrl.scala +++ b/thehive/app/org/thp/thehive/controllers/v0/TagCtrl.scala @@ -1,21 +1,19 @@ package org.thp.thehive.controllers.v0 import org.apache.tinkerpop.gremlin.structure.Vertex -import org.thp.scalligraph.controllers.{Entrypoint, FFile, FieldsParser, Renderer} +import org.thp.scalligraph.EntityIdOrName +import org.thp.scalligraph.controllers.{Entrypoint, Renderer} import org.thp.scalligraph.models.{Database, Entity, UMapping} import org.thp.scalligraph.query._ import org.thp.scalligraph.traversal.TraversalOps._ import org.thp.scalligraph.traversal.{Converter, IteratorOutput, Traversal} import org.thp.scalligraph.utils.FunctionalCondition.When -import org.thp.scalligraph.{EntityIdOrName, RichSeq} import org.thp.thehive.controllers.v0.Conversion._ -import org.thp.thehive.models.{Permissions, Tag} +import org.thp.thehive.models.Tag import org.thp.thehive.services.TagOps._ import org.thp.thehive.services.{OrganisationSrv, TagSrv} -import play.api.libs.json.{JsNumber, JsObject, JsValue, Json} import play.api.mvc.{Action, AnyContent, Results} -import java.nio.file.Files import javax.inject.{Inject, Named, Singleton} class TagCtrl @Inject() ( @@ -25,69 +23,6 @@ class TagCtrl @Inject() ( @Named("v0") override val queryExecutor: QueryExecutor, override val publicData: PublicTag ) extends QueryCtrl { - def importTaxonomy: Action[AnyContent] = - entrypoint("import taxonomy") - .extract("file", FieldsParser.file.optional.on("file")) - .extract("content", FieldsParser.jsObject.optional.on("content")) - .authPermittedTransaction(db, Permissions.manageTag) { implicit request => implicit graph => - val file: Option[FFile] = request.body("file") - val content: Option[JsObject] = request.body("content") - val tags = file - .fold(Seq.empty[Tag])(ffile => parseTaxonomy(Json.parse(Files.newInputStream(ffile.filepath)))) ++ - content.fold(Seq.empty[Tag])(parseTaxonomy) - - tags - .filterNot(tagSrv.startTraversal.getTag(_).exists) - .toTry(tagSrv.create) - .map(ts => Results.Ok(JsNumber(ts.size))) - } - - def parseTaxonomy(taxonomy: JsValue): Seq[Tag] = - (taxonomy \ "namespace").asOpt[String].fold(Seq.empty[Tag]) { namespace => - (taxonomy \ "values").asOpt[Seq[JsObject]].filter(_.nonEmpty) match { - case Some(values) => parseValues(namespace, values) - case _ => (taxonomy \ "predicates").asOpt[Seq[JsObject]].fold(Seq.empty[Tag])(parsePredicates(namespace, _)) - } - } - - def parseValues(namespace: String, values: Seq[JsObject]): Seq[Tag] = - for { - value <- - values - .foldLeft((Seq.empty[JsObject], Seq.empty[String]))((acc, v) => distinct((v \ "predicate").asOpt[String], acc, v)) - ._1 - predicate <- (value \ "predicate").asOpt[String].toList - entry <- - (value \ "entry") - .asOpt[Seq[JsObject]] - .getOrElse(Nil) - .foldLeft((Seq.empty[JsObject], Seq.empty[String]))((acc, v) => distinct((v \ "value").asOpt[String], acc, v)) - ._1 - v <- (entry \ "value").asOpt[String] - colour = - (entry \ "colour") - .asOpt[String] - .getOrElse("#000000") - e = (entry \ "description").asOpt[String] orElse (entry \ "expanded").asOpt[String] - } yield Tag(namespace, predicate, Some(v), e, colour) - - private def distinct(valueOpt: Option[String], acc: (Seq[JsObject], Seq[String]), v: JsObject): (Seq[JsObject], Seq[String]) = - if (valueOpt.isDefined && acc._2.contains(valueOpt.get)) acc - else (acc._1 :+ v, valueOpt.fold(acc._2)(acc._2 :+ _)) - - def parsePredicates(namespace: String, predicates: Seq[JsObject]): Seq[Tag] = - for { - predicate <- - predicates - .foldLeft((Seq.empty[JsObject], Seq.empty[String]))((acc, v) => distinct((v \ "value").asOpt[String], acc, v)) - ._1 - v <- (predicate \ "value").asOpt[String] - e = (predicate \ "expanded").asOpt[String] - colour = - (predicate \ "colour") - .asOpt[String] - .getOrElse("#000000") - } yield Tag(namespace, v, None, e, colour) def get(tagId: String): Action[AnyContent] = entrypoint("get tag") @@ -122,7 +57,7 @@ class PublicTag @Inject() (tagSrv: TagSrv, organisationSrv: OrganisationSrv) ext Query[Traversal.V[Tag], Traversal.V[Tag]]("fromObservable", (tagSteps, _) => tagSteps.fromObservable), Query[Traversal.V[Tag], Traversal.V[Tag]]("fromAlert", (tagSteps, _) => tagSteps.fromAlert), Query.initWithParam[TagHint, Traversal[String, Vertex, Converter[String, Vertex]]]( - "autoComplete", + "TagAutoComplete", (tagHint, graph, authContext) => tagHint .freeTag diff --git a/thehive/app/org/thp/thehive/controllers/v1/Conversion.scala b/thehive/app/org/thp/thehive/controllers/v1/Conversion.scala index b6e14d8bf8..9b5358c624 100644 --- a/thehive/app/org/thp/thehive/controllers/v1/Conversion.scala +++ b/thehive/app/org/thp/thehive/controllers/v1/Conversion.scala @@ -299,9 +299,11 @@ object Conversion { .transform } - implicit val tagOutput: Renderer.Aux[Tag, OutputTag] = - Renderer.toJson[Tag, OutputTag]( - _.into[OutputTag] + implicit val tagOutput: Renderer.Aux[Tag with Entity, OutputTag] = + Renderer.toJson[Tag with Entity, OutputTag](tag => + tag + .asInstanceOf[Tag] + .into[OutputTag] .withFieldComputed(_.namespace, t => if (t.isFreeTag) "_freetags_" else t.namespace) .transform ) diff --git a/thehive/app/org/thp/thehive/controllers/v1/Properties.scala b/thehive/app/org/thp/thehive/controllers/v1/Properties.scala index 44f6c6c8b5..4afa5b0ffa 100644 --- a/thehive/app/org/thp/thehive/controllers/v1/Properties.scala +++ b/thehive/app/org/thp/thehive/controllers/v1/Properties.scala @@ -18,6 +18,7 @@ import org.thp.thehive.services.OrganisationOps._ import org.thp.thehive.services.PatternOps._ import org.thp.thehive.services.ProcedureOps._ import org.thp.thehive.services.ShareOps._ +import org.thp.thehive.services.TagOps._ import org.thp.thehive.services.TaskOps._ import org.thp.thehive.services.TaxonomyOps._ import org.thp.thehive.services.UserOps._ @@ -36,6 +37,7 @@ class Properties @Inject() ( caseTemplateSrv: CaseTemplateSrv, observableSrv: ObservableSrv, customFieldSrv: CustomFieldSrv, + organisationSrv: OrganisationSrv, db: Database ) { @@ -407,4 +409,23 @@ class Properties @Inject() ( .property("version", UMapping.int)(_.field.readonly) .property("enabled", UMapping.boolean)(_.select(_.enabled).readonly) .build + + lazy val tag: PublicProperties = + PublicPropertyListBuilder[Tag] + .property("namespace", UMapping.string)(_.field.readonly) + .property("predicate", UMapping.string)(_.field.updatable) + .property("value", UMapping.string.optional)(_.field.readonly) + .property("description", UMapping.string.optional)(_.field.updatable) + .property("colour", UMapping.string)(_.field.updatable) + .property("text", UMapping.string)( + _.select(_.displayName) + .filter[String] { + case (_, tags, authContext, Right(predicate)) => tags.freetags(organisationSrv)(authContext).has(_.predicate, predicate) + case (_, tags, _, Left(true)) => tags + case (_, tags, _, Left(false)) => tags.empty + } + .readonly + ) + .build + } diff --git a/thehive/app/org/thp/thehive/controllers/v1/Router.scala b/thehive/app/org/thp/thehive/controllers/v1/Router.scala index 6675dbeb18..aac67ed2bc 100644 --- a/thehive/app/org/thp/thehive/controllers/v1/Router.scala +++ b/thehive/app/org/thp/thehive/controllers/v1/Router.scala @@ -28,6 +28,7 @@ class Router @Inject() ( patternCtrl: PatternCtrl, procedureCtrl: ProcedureCtrl, profileCtrl: ProfileCtrl, + tagCtrl: TagCtrl, taskCtrl: TaskCtrl, shareCtrl: ShareCtrl, taxonomyCtrl: TaxonomyCtrl, @@ -35,7 +36,6 @@ class Router @Inject() ( userCtrl: UserCtrl, statusCtrl: StatusCtrl // streamCtrl: StreamCtrl, - // tagCtrl: TagCtrl, ) extends SimpleRouter { override def routes: Routes = { @@ -165,6 +165,9 @@ class Router @Inject() ( case PATCH(p"/profile/$profileId") => profileCtrl.update(profileId) case DELETE(p"/profile/$profileId") => profileCtrl.delete(profileId) + case GET(p"/tag/$id") => tagCtrl.get(id) + case PATCH(p"/tag/$id") => tagCtrl.update(id) + case GET(p"/describe/_all") => describeCtrl.describeAll case GET(p"/describe/$modelName") => describeCtrl.describe(modelName) diff --git a/thehive/app/org/thp/thehive/controllers/v1/TagCtrl.scala b/thehive/app/org/thp/thehive/controllers/v1/TagCtrl.scala new file mode 100644 index 0000000000..a9e8736fdb --- /dev/null +++ b/thehive/app/org/thp/thehive/controllers/v1/TagCtrl.scala @@ -0,0 +1,75 @@ +package org.thp.thehive.controllers.v1 + +import org.apache.tinkerpop.gremlin.structure.Vertex +import org.thp.scalligraph.EntityIdOrName +import org.thp.scalligraph.controllers.{Entrypoint, FieldsParser} +import org.thp.scalligraph.models.{Database, Entity} +import org.thp.scalligraph.query._ +import org.thp.scalligraph.traversal.TraversalOps._ +import org.thp.scalligraph.traversal.{Converter, IteratorOutput, Traversal} +import org.thp.scalligraph.utils.FunctionalCondition.When +import org.thp.thehive.controllers.v1.Conversion._ +import org.thp.thehive.models.{Permissions, Tag} +import org.thp.thehive.services.TagOps._ +import org.thp.thehive.services.{OrganisationSrv, TagSrv} +import play.api.mvc.{Action, AnyContent, Results} + +import javax.inject.Inject + +case class TagHint(freeTag: Option[String], namespace: Option[String], predicate: Option[String], value: Option[String], limit: Option[Long]) + +class TagCtrl @Inject() ( + entrypoint: Entrypoint, + db: Database, + tagSrv: TagSrv, + organisationSrv: OrganisationSrv, + properties: Properties +) extends QueryableCtrl { + override val entityName: String = "Tag" + override val publicProperties: PublicProperties = properties.tag + override val initialQuery: Query = + Query.init[Traversal.V[Tag]]("listTag", (graph, authContext) => tagSrv.startTraversal(graph).visible(authContext)) + override val pageQuery: ParamQuery[OutputParam] = Query.withParam[OutputParam, Traversal.V[Tag], IteratorOutput]( + "page", + (range, tagSteps, _) => tagSteps.page(range.from, range.to, withTotal = true) + ) + override val outputQuery: Query = Query.output[Tag with Entity] + override val getQuery: ParamQuery[EntityIdOrName] = Query.initWithParam[EntityIdOrName, Traversal.V[Tag]]( + "getTag", + (idOrName, graph, _) => tagSrv.get(idOrName)(graph) + ) + override val extraQueries: Seq[ParamQuery[_]] = Seq( + Query[Traversal.V[Tag], Traversal.V[Tag]]("freetags", (tagSteps, authContext) => tagSteps.freetags(organisationSrv)(authContext)), + Query.initWithParam[TagHint, Traversal[String, Vertex, Converter[String, Vertex]]]( + "TagAutoComplete", + (tagHint, graph, authContext) => + tagHint + .freeTag + .fold(tagSrv.startTraversal(graph).autoComplete(tagHint.namespace, tagHint.predicate, tagHint.value)(authContext).visible(authContext))( + tagSrv.startTraversal(graph).autoComplete(organisationSrv, _)(authContext) + ) + .merge(tagHint.limit)(_.limit(_)) + .displayName + ), + Query[Traversal.V[Tag], Traversal[String, Vertex, Converter[String, Vertex]]]("text", (tagSteps, _) => tagSteps.displayName), + Query.output[String, Traversal[String, Vertex, Converter[String, Vertex]]] + ) + + def get(tagId: String): Action[AnyContent] = + entrypoint("get tag") + .authRoTransaction(db) { _ => implicit graph => + tagSrv + .getOrFail(EntityIdOrName(tagId)) + .map(tag => Results.Ok(tag.toJson)) + } + + def update(tagId: String): Action[AnyContent] = + entrypoint("update tag") + .extract("tag", FieldsParser.update("tag", publicProperties)) + .authPermittedTransaction(db, Permissions.manageTag) { implicit request => implicit graph => + val propertyUpdaters: Seq[PropertyUpdater] = request.body("tag") + tagSrv + .update(_.getFreetag(organisationSrv, EntityIdOrName(tagId)), propertyUpdaters) + .map(_ => Results.NoContent) + } +} diff --git a/thehive/app/org/thp/thehive/models/Taxonomy.scala b/thehive/app/org/thp/thehive/models/Taxonomy.scala index b677c54230..fc074467cc 100644 --- a/thehive/app/org/thp/thehive/models/Taxonomy.scala +++ b/thehive/app/org/thp/thehive/models/Taxonomy.scala @@ -17,7 +17,7 @@ case class TaxonomyTag() case class RichTaxonomy( taxonomy: Taxonomy with Entity, - tags: Seq[Tag] + tags: Seq[Tag with Entity] ) { def _id: EntityId = taxonomy._id def _createdBy: String = taxonomy._createdBy diff --git a/thehive/app/org/thp/thehive/services/TagSrv.scala b/thehive/app/org/thp/thehive/services/TagSrv.scala index efdf43ce44..60b3390088 100644 --- a/thehive/app/org/thp/thehive/services/TagSrv.scala +++ b/thehive/app/org/thp/thehive/services/TagSrv.scala @@ -3,6 +3,7 @@ package org.thp.thehive.services import akka.actor.ActorRef import org.apache.tinkerpop.gremlin.process.traversal.TextP import org.apache.tinkerpop.gremlin.structure.Vertex +import org.thp.scalligraph.EntityIdOrName import org.thp.scalligraph.auth.AuthContext import org.thp.scalligraph.models.{Database, Entity} import org.thp.scalligraph.services.config.{ApplicationConfig, ConfigItem} @@ -119,6 +120,9 @@ object TagOps { .has(_.namespace, freeTagNamespace) } + def getFreetag(organisationSrv: OrganisationSrv, idOrName: EntityIdOrName)(implicit authContext: AuthContext): Traversal.V[Tag] = + idOrName.fold(traversal.getByIds(_), traversal.has(_.predicate, _)).freetags(organisationSrv) + def autoComplete(organisationSrv: OrganisationSrv, freeTag: String)(implicit authContext: AuthContext): Traversal.V[Tag] = freetags(organisationSrv) .has(_.predicate, TextP.containing(freeTag)) diff --git a/thehive/test/org/thp/thehive/controllers/v0/TagCtrlTest.scala b/thehive/test/org/thp/thehive/controllers/v0/TagCtrlTest.scala index 24870d5b2e..fc988db8ad 100644 --- a/thehive/test/org/thp/thehive/controllers/v0/TagCtrlTest.scala +++ b/thehive/test/org/thp/thehive/controllers/v0/TagCtrlTest.scala @@ -1,124 +1,16 @@ package org.thp.thehive.controllers.v0 -import java.io.File -import java.nio.file.{Path, Files => JFiles} - import akka.stream.Materializer import org.thp.scalligraph.models.Database import org.thp.scalligraph.traversal.TraversalOps._ import org.thp.thehive.TestAppBuilder import org.thp.thehive.dto.v0.OutputTag import org.thp.thehive.services.TagSrv -import play.api.libs.Files -import play.api.libs.Files.TemporaryFileCreator import play.api.libs.json.Json -import play.api.mvc.MultipartFormData.FilePart -import play.api.mvc.{AnyContentAsMultipartFormData, Headers, MultipartFormData} -import play.api.test.{FakeRequest, NoTemporaryFileCreator, PlaySpecification} +import play.api.test.{FakeRequest, PlaySpecification} class TagCtrlTest extends PlaySpecification with TestAppBuilder { "tag controller" should { - "import a taxonomy json" in testApp { app => - val request = FakeRequest("POST", "/api/tag/_import") - .withHeaders("user" -> "admin@thehive.local") - .withJsonBody(Json.parse(s"""{ - "content": { - "namespace": "access-method", - "description": "The access method used to remotely access a system.", - "version": 1, - "expanded": "Access method", - "predicates": [ - { - "value": "brute-force", - "expanded": "Brute force", - "description": "Access was gained through systematic trial of credentials in bulk." - }, - { - "value": "password-guessing", - "expanded": "Password guessing", - "description": "Access was gained through guessing passwords through trial and error." - } - ] - } - }""".stripMargin)) - val result = app[TagCtrl].importTaxonomy(request) - - status(result) must equalTo(200).updateMessage(s => s"$s\n${contentAsString(result)}") - contentAsString(result) shouldEqual "2" - } - - "import a taxonomy json with value" in testApp { app => - val request = FakeRequest("POST", "/api/tag/_import") - .withHeaders("user" -> "admin@thehive.local") - .withJsonBody(Json.parse(s"""{ - "content":{ - "namespace":"dark-web", - "expanded":"Dark Web", - "description":"Criminal motivation on the dark web: A categorisation model for law enforcement.", - "version":3, - "predicates":[ - { - "value":"topic", - "description":"Topic associated with the materials tagged", - "expanded":"Topic" - }, - { - "value":"topic", - "description":"Topic associated with the materials tagged", - "expanded":"Topic" - }, - { - "value":"structure", - "description":"Structure of the materials tagged", - "expanded":"Structure" - } - ], - "values":[ - { - "predicate":"topic", - "entry":[ - { - "value":"drugs-narcotics", - "expanded":"Drugs/Narcotics", - "description":"Illegal drugs/chemical compounds for consumption/ingestion..." - }, - { - "value":"drugs-narcotics", - "expanded":"Drugs/Narcotics", - "description":"Illegal drugs/chemical compounds for consumption/ingestion..." - } - ] - } - ] - } - }""".stripMargin)) - val result = app[TagCtrl].importTaxonomy(request) - - status(result) must equalTo(200).updateMessage(s => s"$s\n${contentAsString(result)}") - contentAsString(result) shouldEqual "1" - } - - "import a taxonomy file if not existing" in testApp { app => - WithFakeTaxonomyFile { tempFile => - val files = Seq(FilePart("file", "machinetag.json", Some("application/json"), tempFile)) - val request = FakeRequest( - "POST", - "/api/tag/_import", - Headers("user" -> "admin@thehive.local"), - body = AnyContentAsMultipartFormData(MultipartFormData(Map.empty, files, Nil)) - ) - val result = app[TagCtrl].importTaxonomy(request) - - status(result) must equalTo(200).updateMessage(s => s"$s\n${contentAsString(result)}") - contentAsString(result) shouldEqual "3" - - val resultBis = app[TagCtrl].importTaxonomy(request) - - status(resultBis) must equalTo(200).updateMessage(s => s"$s\n${contentAsString(resultBis)}") - contentAsString(resultBis) shouldEqual "0" - } - } - "get a tag" in testApp { app => // Get a tag id first val tags = app[Database].roTransaction(implicit graph => app[TagSrv].startTraversal.toSeq) @@ -168,46 +60,3 @@ class TagCtrlTest extends PlaySpecification with TestAppBuilder { } } } - -object WithFakeTaxonomyFile { - - def apply[A](body: Files.TemporaryFile => A): A = { - val tempFile = JFiles.createTempFile("thehive-", "-test.json") - JFiles.write( - tempFile, - s"""{ - "namespace": "access-method", - "description": "The access method used to remotely access a system.", - "version": 1, - "expanded": "Access method", - "predicates": [ - { - "value": "password-guessing2", - "expanded": "Password guessing", - "description": "Access was gained through guessing passwords through trial and error." - }, - { - "value": "password-guessing", - "expanded": "Password guessing", - "description": "Access was gained through guessing passwords through trial and error." - }, - { - "value": "brute-force2", - "expanded": "Brute forcing", - "description": "Yeah..." - } - ] - }""".getBytes - ) - val fakeTempFile = new Files.TemporaryFile { - override def path: Path = tempFile - override def file: File = tempFile.toFile - override def temporaryFileCreator: TemporaryFileCreator = NoTemporaryFileCreator - } - try body(fakeTempFile) - finally { - JFiles.deleteIfExists(tempFile) - () - } - } -} diff --git a/thehive/test/org/thp/thehive/controllers/v1/TaxonomyCtrlTest.scala b/thehive/test/org/thp/thehive/controllers/v1/TaxonomyCtrlTest.scala index 6086e0df71..eed6e776fe 100644 --- a/thehive/test/org/thp/thehive/controllers/v1/TaxonomyCtrlTest.scala +++ b/thehive/test/org/thp/thehive/controllers/v1/TaxonomyCtrlTest.scala @@ -13,7 +13,7 @@ case class TestTaxonomy( namespace: String, description: String, version: Int, - tags: Set[OutputTag] + tags: Set[TestTag] ) object TestTaxonomy { @@ -22,10 +22,16 @@ object TestTaxonomy { outputTaxonomy.namespace, outputTaxonomy.description, outputTaxonomy.version, - outputTaxonomy.tags.toSet + outputTaxonomy.tags.toSet.map(TestTag.apply) ) } +case class TestTag(namespace: String, predicate: String, value: Option[String], description: Option[String], colour: String) + +object TestTag { + def apply(outputTag: OutputTag): TestTag = + TestTag(outputTag.namespace, outputTag.predicate, outputTag.value, outputTag.description, outputTag.colour) +} class TaxonomyCtrlTest extends PlaySpecification with TestAppBuilder { "taxonomy controller" should { @@ -74,9 +80,9 @@ class TaxonomyCtrlTest extends PlaySpecification with TestAppBuilder { "A test taxonomy", 1, Set( - OutputTag("test-taxo", "pred1", Some("entry1"), None, "#ffa800"), - OutputTag("test-taxo", "pred2", Some("entry2"), None, "#00ad1c"), - OutputTag("test-taxo", "pred2", Some("entry21"), None, "#00ad1c") + TestTag("test-taxo", "pred1", Some("entry1"), None, "#ffa800"), + TestTag("test-taxo", "pred2", Some("entry2"), None, "#00ad1c"), + TestTag("test-taxo", "pred2", Some("entry21"), None, "#00ad1c") ) ) } @@ -116,7 +122,7 @@ class TaxonomyCtrlTest extends PlaySpecification with TestAppBuilder { "taxonomy1", "The taxonomy 1", 1, - Set(OutputTag("taxonomy1", "pred1", Some("value1"), None, "#00f300")) + Set(TestTag("taxonomy1", "pred1", Some("value1"), None, "#00f300")) ) } @@ -191,8 +197,8 @@ class TaxonomyCtrlTest extends PlaySpecification with TestAppBuilder { "Updated The taxonomy 1", 2, Set( - OutputTag("taxonomy1", "pred1", Some("value2"), None, "#fba800"), - OutputTag("taxonomy1", "pred1", Some("value1"), None, "#00f300") + TestTag("taxonomy1", "pred1", Some("value2"), None, "#fba800"), + TestTag("taxonomy1", "pred1", Some("value1"), None, "#00f300") ) ) }