diff --git a/README.md b/README.md index 5335526..3726731 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JDK 21 for development or running without docker ## Run ``` -cp src/main/resources/application.conf.example to src/main/resources/application.conf +cp src/main/resources/application.conf.example src/main/resources/application.conf ./gradlew run ``` diff --git a/build.gradle.kts b/build.gradle.kts index d25f262..12b6083 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ repositories { } jacoco { - toolVersion = "0.8.12" + toolVersion = "0.8.15" } plugins { @@ -34,7 +34,7 @@ sonar { dependencies { implementation(kotlin("stdlib")) - val ktorVersion = "3.4.3" + val ktorVersion = "3.5.1" implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") implementation("io.ktor:ktor-client-cio:$ktorVersion") @@ -58,7 +58,7 @@ dependencies { implementation("io.ktor:ktor-server-netty:$ktorVersion") implementation("io.ktor:ktor-server-resources:$ktorVersion") implementation("io.ktor:ktor-server-status-pages:$ktorVersion") - val kotestVersion = "6.1.11" + val kotestVersion = "6.2.2" testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") testImplementation("io.kotest:kotest-assertions-json-jvm:$kotestVersion") @@ -67,17 +67,17 @@ dependencies { testImplementation("io.ktor:ktor-server-test-host:$ktorVersion") - implementation("ch.qos.logback:logback-classic:1.5.18") + implementation("ch.qos.logback:logback-classic:1.5.38") val junitVersion = "5.13.1" testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") // sign JWTs locally in tests with the same shared secret as the // layer1-service container (see src/test/resources/test.env) - testImplementation("com.auth0:java-jwt:4.5.0") + testImplementation("com.auth0:java-jwt:4.6.0") - val jenaVersion = "6.0.0" + val jenaVersion = "6.1.0" implementation("org.apache.jena:jena-arq:${jenaVersion}") implementation("org.apache.jena:jena-querybuilder:${jenaVersion}") // CommonSpec.beforeEach uses RDFConnection to reload cluster.trig via GSP diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt index 92d885f..482f3c5 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt @@ -60,7 +60,10 @@ fun Application.module() { //register(ContentType.Application.Json, GsonConverter()) } install(CORS) { - allowCredentials = true + // credentials (the Authorization header) may only be shared with + // configured origins — anyHost()+allowCredentials would let any + // website replay a logged-in user's session + allowCredentials = GlobalFlexoConfig.corsAllowedOrigins.isNotEmpty() allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.ContentType) @@ -86,7 +89,16 @@ fun Application.module() { exposeHeader(HttpHeaders.Link) exposeHeader("Flexo-Mms-Layer-1") - anyHost() // @TODO: make configuration + if (GlobalFlexoConfig.corsAllowedOrigins.isEmpty()) { + anyHost() + } else { + GlobalFlexoConfig.corsAllowedOrigins.forEach { origin -> + val scheme = origin.substringBefore("://", "") + val host = origin.substringAfter("://") + if (scheme.isEmpty()) allowHost(host, listOf("http", "https")) + else allowHost(host, listOf(scheme)) + } + } } install(AutoHeadResponse) // see https://ktor.io/docs/autoheadresponse.html install(Compression, ApplicationCompressionConfiguration()) // see https://ktor.io/docs/compression.html @@ -139,7 +151,10 @@ data class FlexoConfig( val org: String, val defaultTimeout: Long, val auth: String, - val basePath: String + val basePath: String, + // origins allowed for CORS ("scheme://host[:port]", space-separated); + // empty means any host, which browsers only honor without credentials + val corsAllowedOrigins: List = emptyList() ) /** @@ -154,5 +169,7 @@ val Application.flexoConfig: FlexoConfig val defaultTimeout = property("flexo.defaultTimeout")?.getString()?.toLong() ?: 60L val auth = property("flexo.auth")?.getString() ?: "" val basePath = property("flexo.basePath")?.getString() ?: "" - return FlexoConfig(protocol, host, port, org, defaultTimeout, auth, basePath) + val corsAllowedOrigins = property("flexo.corsAllowedOrigins")?.getString() + ?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() + return FlexoConfig(protocol, host, port, org, defaultTimeout, auth, basePath, corsAllowedOrigins) } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt index 263eeec..340017d 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt @@ -35,6 +35,7 @@ fun escapeRdfDoubleQuotedLiteralContents(contents: String): String { .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") + .replace("\r", "\\r") } private val RDF_IRI_ESCAPE_REGEX = "([\\x00-\\x20<>\"{}|^`\\\\]|%(?![0-9A-F][0-9A-F]))".toRegex() @@ -335,10 +336,14 @@ class FlexoModelHandlerWithFocalNode( suspend fun RoutingContext.forward(flexoResponse: FlexoResponse) { val response = flexoResponse.response - call.respondText(response.bodyAsText(), response.contentType(), response.status) { - // forward the response headers - headersOf(*response.headers.names().map { name -> - name to headers.getAll(name).orEmpty() - }.toTypedArray()) + // forward the upstream response headers (e.g. ETag, Location, + // WWW-Authenticate); content headers are managed by respondText and + // Date/Server belong to this server, not the upstream + response.headers.forEach { name, values -> + if (HttpHeaders.isUnsafe(name) + || name.equals(HttpHeaders.Date, ignoreCase = true) + || name.equals(HttpHeaders.Server, ignoreCase = true)) return@forEach + values.forEach { call.response.headers.append(name, it) } } + call.respondText(response.bodyAsText(), response.contentType(), response.status) } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt index 216da0f..9e49148 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt @@ -39,16 +39,15 @@ fun FlexoModelHandler.branchFromResponse( ): Branch? { val commitSuffix = outgoing[MMS.commit]?.resource()?.uri?.uriSuffix ?: return null val commit = Identified(commitSuffix) + // never fabricate created/name for malformed upstream data — invented + // values mask triplestore bugs (see issue #20) return Branch( atId = branchId, atType = Branch.AtType.Branch, - created = OffsetDateTime.parse( - outgoing[MMS.created]?.literal() - ?: OffsetDateTime.now().toString() - ), + created = OffsetDateTime.parse(outgoing[MMS.created]?.literal() ?: return null), owningProject = Identified(projectId), referencedCommit = commit, - name = outgoing [DCTerms.title]?.literal() ?: "", + name = outgoing[DCTerms.title]?.literal() ?: return null, head = commit ) } @@ -111,7 +110,10 @@ fun Route.BranchApi() { } // parse the response model, convert it to JSON, and reply to client val branch = flexoResponse.parseModel { - model.listSubjectsWithProperty(RDF.type, MMS.Branch).mapWith { + // soft-deleted branches are invisible + model.listSubjectsWithProperty(RDF.type, MMS.Branch).filterDrop { + it.hasProperty(SYSMLV2.DELETED) + }.mapWith { branchFromResponse(it.outgoing(), path.projectId, it.uri.uriSuffix) }.toList().filterNotNull().firstOrNull() } ?: return@get call.respond(HttpStatusCode.NotFound) diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt index 7d1cf56..cec5999 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt @@ -142,9 +142,18 @@ fun Route.CommitApi() { val projectResponse = flexoRequestGet { orgPath("/repos/$projectId") } + // nonexistent/unauthorized project: forward instead of crashing + // on an empty model below + if (projectResponse.isFailure()) { + return@post forward(projectResponse) + } projectResponse.parseModel { - val outgoing = model.listSubjectsWithProperty(RDF.type, MMS.Repo).next()!!.outgoing() - branchId = outgoing[SYSMLV2.DEFAULT_BRANCH_ID]?.literal()?: "master" + val repos = model.listSubjectsWithProperty(RDF.type, MMS.Repo) + if (repos.hasNext()) { + branchId = repos.next().outgoing()[SYSMLV2.DEFAULT_BRANCH_ID]?.literal()?: "master" + } else { + branchId = "master" + } } } // each change (DataVersionRequest) @@ -162,15 +171,20 @@ fun Route.CommitApi() { val identityId = identity?.atId var payloadId = payload?.getOrDefault("@id", null)?.jsonPrimitive?.content if (identityId != null && payloadId != null && identityId != payloadId) { - //bad, log error? - continue + // silently skipping the change would report a successful + // commit while discarding data + throw InvalidSysmlSerializationError( + "identity @id '$identityId' does not match payload @id '$payloadId' at .change[$index]") } if (identityId == null && payloadId == null && payload != null) { payloadId = generateId() // generate an id } - // subject node, target element - val elementNode: Node = SYSMLV2.element(identityId ?: payloadId!!).asNode() + // subject node, target element; the id is interpolated into + // SPARQL, so it must be validated + val elementId = identityId ?: payloadId!! + requireValidId(elementId, ".change[$index] element @id") + val elementNode: Node = SYSMLV2.element(elementId).asNode() // transform payload into property pairs mutableListOf>>().apply { @@ -219,7 +233,10 @@ fun Route.CommitApi() { if(value[0] is JsonObject) { // create additional triples to link the elements add(SYSMLV2.prop(key) to value.jsonArray.map { - SYSMLV2.element(it.jsonObject["@id"]!!.jsonPrimitive.content).asNode() + val refId = it.jsonObject["@id"]?.jsonPrimitive?.content + ?: throw InvalidSysmlSerializationError("Missing @id at .change[$index].$key") + requireValidId(refId, ".change[$index].$key.@id") + SYSMLV2.element(refId).asNode() }.toSet()) } // and first element is a primitive @@ -242,9 +259,12 @@ fun Route.CommitApi() { // @id reference if(valueObj.containsKey("@id")) { if(valueObj.keys.size > 1) { - throw Error("Unexpected extra keys at .${key}") + // client-input problem: must map to a 400, not a 500 + throw InvalidSysmlSerializationError("Unexpected extra keys at .change[$index].$key") } - add(SYSMLV2.prop(key) to setOf(SYSMLV2.element(valueObj["@id"]!!.jsonPrimitive.content).asNode())) + val refId = valueObj["@id"]!!.jsonPrimitive.content + requireValidId(refId, ".change[$index].$key.@id") + add(SYSMLV2.prop(key) to setOf(SYSMLV2.element(refId).asNode())) } else { throw InvalidSysmlSerializationError("Unexpected JSON object at .change[$index].$key == ${Json.encodeToString(value)}") @@ -264,6 +284,27 @@ fun Route.CommitApi() { } } if (replace == null || replace != "true") { //regular update + // deleted elements must not leave dangling references: remove + // incoming link triples, and the json: array annotations of the + // referencing property (they cannot be rewritten in SPARQL; the + // remaining link triples render as a best-effort array instead) + val deleteIncomingUpdate = if (deleteIncoming.isEmpty()) "" else """ + delete { + ?ref_s ?ref_p ?deleted_n . + ?ref_s ?ref_ann ?ann_val . + } where { + values ?deleted_n { + ${deleteIncoming.joinToString("\n").reindent(5)} + } + ?ref_s ?ref_p ?deleted_n . + optional { + ?ref_s ?ref_ann ?ann_val . + filter(strstarts(str(?ref_ann), "${SYSMLV2.ANNOTATION_JSON}")) + filter(strafter(str(?ref_ann), "${SYSMLV2.ANNOTATION_JSON}") = strafter(str(?ref_p), "${SYSMLV2.VOCABULARY}")) + } + }; + """ + // build SPARQL UPDATE string var sparqlUpdateString = """ ${DEFAULT_PREFIX_MAPPING.nsPrefixMap.filter { (id, iri) -> @@ -277,14 +318,15 @@ fun Route.CommitApi() { } where { values ?element_n { ${values.joinToString("\n").reindent(5)} - } + } optional { ?element_n ?element_p ?element_o . } }; + $deleteIncomingUpdate insert data { ${inserts.joinToString("\n\n").reindent(4)} - } + } """ // trim indent for better inspectability diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt index b9e1c31..70373cd 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt @@ -11,6 +11,7 @@ */ package org.openmbee.flexo.sysmlv2.apis +import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.resources.* import io.ktor.server.response.* @@ -68,6 +69,30 @@ class InvalidTripleError( value: RDFNode ): Error("$message at <$subjectIri> <${predicate.uri}> ${value.stringify()}") +private fun FlexoModelHandler.jsonFromRdfNode(elementIri: String, predicate: Property, obj: RDFNode): JsonElement { + return if (obj == RDF.nil) { + JsonNull + } else if (obj.isResource) { + buildJsonObject { + put("@id", obj.asResource().uri.autoSuffix) + } + } else if (obj.isLiteral) { + val lit = obj.asLiteral() + + // depending on its datatype + when (lit.datatype.uri) { + XSD.xboolean.uri -> JsonPrimitive(lit.boolean) + XSD.integer.uri -> JsonPrimitive(lit.int) + XSD.decimal.uri, XSD.xdouble.uri -> JsonPrimitive(lit.float) + else -> JsonPrimitive(lit.string) + } + } + // invalid + else { + throw InvalidTripleError("Don't know what this is", elementIri, predicate, obj) + } +} + fun FlexoModelHandler.extractModelElementToJson(elementIri: String): JsonObject { // direct outgoing properties of element val out = indexOut(elementIri) @@ -76,13 +101,17 @@ fun FlexoModelHandler.extractModelElementToJson(elementIri: String): JsonObject val type = out[RDF.type].resource()?.uri?.autoSuffix val id = elementIri.urnSuffix + // properties whose authoritative (ordered) form is carried by a JSON + // array annotation; the plain sysml: triples for these are skipped + val annotatedKeys = out.keys + .filter { it.uri.startsWith(SYSMLV2.ANNOTATION_JSON) } + .map { it.uri.urnSuffix } + .toSet() + return buildJsonObject { put("@type", type) put("@id", id) - // keeps track of json array annotations, if we already deserialized an array, ignore any triple with the same property - val seenArrays = mutableListOf() - // outgoing properties out.forEach { (predicate, values) -> // extract the suffix name part @@ -92,29 +121,20 @@ fun FlexoModelHandler.extractModelElementToJson(elementIri: String): JsonObject val obj = values.elementAt(0) if(predicate.uri.startsWith(SYSMLV2.VOCABULARY)) { - // multiple values means it's an array, skip and prefer JSON annotation - // if we've already seen a JSON annotation with the same property key then ignore - if (values.size > 1 || seenArrays.contains(propertyKey)) return@forEach - if (obj == RDF.nil) { - put(propertyKey, JsonNull) - } else if (obj.isResource) { - put(propertyKey, buildJsonObject { - put("@id", obj.asResource().uri.autoSuffix) + // the JSON annotation carries this property's array form + if (annotatedKeys.contains(propertyKey)) return@forEach + if (values.size > 1) { + // multi-valued property without an annotation (e.g. data + // loaded as raw RDF rather than through this API): emit a + // best-effort array rather than dropping the property. + // RDF triples are unordered, so sort for determinism. + put(propertyKey, buildJsonArray { + values.map { jsonFromRdfNode(elementIri, predicate, it) } + .sortedBy { it.toString() } + .forEach { add(it) } }) - } else if (obj.isLiteral) { - val lit = obj.asLiteral() - - // depending on its datatype - when (lit.datatype.uri) { - XSD.xboolean.uri -> put(propertyKey, lit.boolean) - XSD.integer.uri -> put(propertyKey, lit.int) - XSD.decimal.uri, XSD.xdouble.uri -> put(propertyKey, lit.float) - else -> put(propertyKey, lit.string) - } - } - // invalid - else { - throw InvalidTripleError("Don't know what this is", elementIri, predicate, obj) + } else { + put(propertyKey, jsonFromRdfNode(elementIri, predicate, obj)) } } // annotations @@ -144,9 +164,6 @@ fun FlexoModelHandler.extractModelElementToJson(elementIri: String): JsonObject // add parsed element to JSON object put(propertyKey, jsonElement) - - // do not overwrite this property - seenArrays.add(propertyKey) } // something else else { @@ -178,10 +195,12 @@ fun Route.ElementApi() { } // parse the response model, extract the target element to JSON, and reply to client - call.respond(flexoResponse.parseModel { - // extract the target model element to JSON - extractModelElementToJson(elementIri) - }) + val element = flexoResponse.parseModel { + // an empty result means no such element at this commit + if (!model.listStatements(model.getResource(elementIri), null, null as RDFNode?).hasNext()) null + else extractModelElementToJson(elementIri) + } ?: return@get call.respond(HttpStatusCode.NotFound) + call.respond(element) } // get multiple elements @@ -201,6 +220,7 @@ fun Route.ElementApi() { val result = buildJsonArray { flexoResponse.parseModel { for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } @@ -238,6 +258,7 @@ fun Route.ElementApi() { val result = buildJsonArray { flexoResponse.parseModel { for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } @@ -281,6 +302,7 @@ fun Route.ElementApi() { val result = buildJsonArray { flexoResponse.parseModel { for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt index 0deeb75..88305c1 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt @@ -33,16 +33,16 @@ fun projectFromResponse( outgoing: Map>, projectId: String = outgoing[MMS.id]?.literal() ?: error("project missing mms:id"), branchId: String = outgoing[SYSMLV2.DEFAULT_BRANCH_ID]?.literal() ?: error("project missing default branch id") -): Project { +): Project? { + // never fabricate created/name for malformed upstream data — invented + // values mask triplestore bugs (see issue #20) return Project( atId = projectId, atType = Project.AtType.Project, - created = OffsetDateTime.parse( - outgoing[MMS.created]?.literal() - ?: OffsetDateTime.now().toString()), + created = OffsetDateTime.parse(outgoing[MMS.created]?.literal() ?: return null), defaultBranch = Identified(branchId), description = outgoing[DCTerms.description]?.literal()?: "", - name = outgoing[DCTerms.title]?.literal()?: "" + name = outgoing[DCTerms.title]?.literal() ?: return null ) } @@ -133,7 +133,8 @@ fun Route.ProjectApi() { return@get forward(flexoResponse) } val project = flexoResponse.findFirstByType(MMS.Repo) { - projectFromResponse(it) + // soft-deleted projects are invisible + if (it[SYSMLV2.DELETED] != null) null else projectFromResponse(it) } ?: return@get call.respond(HttpStatusCode.NotFound) call.respond(project) } @@ -154,7 +155,7 @@ fun Route.ProjectApi() { it.hasProperty(SYSMLV2.DELETED) }.mapWith { projectFromResponse(it.outgoing()) - }.toList() + }.toList().filterNotNull() }) } @@ -165,6 +166,15 @@ fun Route.ProjectApi() { projectRequest.defaultBranch?.atId?.let { requireValidId(it, "defaultBranch.@id") } // use provided project ID or generate one if not provided val projectId = projectRequest.atId ?: generateId() + // POST must not silently overwrite an existing project + if (projectRequest.atId != null) { + val existing = flexoRequestGet { + orgPath("/repos/${projectId}") + } + if (!existing.isFailure()) { + return@post call.respond(HttpStatusCode.Conflict) + } + } val flexoResponse = createOrUpdateProject(projectId, projectRequest, true) if (flexoResponse.isFailure()) { return@post forward(flexoResponse) diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt index 1d61f25..36cbb2a 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt @@ -39,59 +39,72 @@ import org.openmbee.flexo.sysmlv2.models.QueryRequest fun PrimitiveConstraint.toSparql(cb: ConstructBuilder): Expr { val p: Node - var pvar: String = property + // property names are client input; only variable-safe characters may + // appear in the SPARQL variable derived from them + var pvar: String = property.replace(Regex("[^A-Za-z0-9_]"), "_") when(property) { "@id" -> { p = SYSMLV2.prop("elementId").asNode(); pvar = "id" } "@type" -> { p = RDF.type.asNode(); pvar = "Metatype" } else -> { p = SYSMLV2.prop(property).asNode() } } - if (value.isEmpty()) { + // schema: value is null, or an array of at least one scalar/Identified/null; + // a null value (either form) matches elements lacking the property + val values = value ?: listOf(JsonNull) + if (values.isEmpty()) { throw BadRequestException("PrimitiveConstraint value must not be empty") } - val firstVal = value[0] - val v: Node = when(firstVal) { - is JsonObject -> { - if (firstVal.containsKey("@id")) { - SYSMLV2.element(firstVal.jsonObject["@id"]!!.jsonPrimitive.content).asNode() - } else { - throw BadRequestException("PrimitiveConstraint value object must contain @id") + // null stands for "property absent"; everything else becomes a comparable node + val nodes: List = values.map { el -> + when(el) { + is JsonObject -> { + if (el.containsKey("@id")) { + SYSMLV2.element(el["@id"]!!.jsonPrimitive.content).asNode() + } else { + throw BadRequestException("PrimitiveConstraint value object must contain @id") + } } - } - is JsonPrimitive -> { - if (firstVal == JsonNull) { - RDF.nil.asNode() - } else { - if (property == "@type") SYSMLV2.type(firstVal.content).asNode() else firstVal.toRdfLiteralNode() + is JsonPrimitive -> { + if (el == JsonNull) { + null + } else { + if (property == "@type") SYSMLV2.type(el.content).asNode() else el.toRdfLiteralNode() + } } + else -> throw BadRequestException("Unexpected JSON element type in PrimitiveConstraint value") } - is JsonArray -> throw BadRequestException("PrimitiveConstraint value cannot be array") - else -> throw BadRequestException("Unexpected JSON element type in PrimitiveConstraint value") - } - if (firstVal == JsonNull) { - cb.addOptional("?e", p, "?$pvar") - } else { - cb.addWhere("?e", p, "?$pvar") } + // the pattern must be optional so that (a) null values can match elements + // lacking the property and (b) an or-composite over different properties + // does not require all of them to be present; bound() guards below keep + // required-presence semantics for the individual comparisons + cb.addOptional("?e", p, "?$pvar") val ef = cb.exprFactory + val matchesAbsent = nodes.any { it == null } + val comparable = nodes.filterNotNull() val expr = when(operator) { - PrimitiveConstraint.Operator.Equal -> { - if (value == JsonNull) { - ef.or(ef.not(ef.bound("?$pvar")), ef.eq("?$pvar", v)) - } else { - ef.eq("?$pvar", v) - } - } - PrimitiveConstraint.Operator.Less_Than -> { - ef.lt("?$pvar", v) + // per the OMG schema "=" takes an array; both it and "in" are set + // membership over the array's values + PrimitiveConstraint.Operator.Equal, PrimitiveConstraint.Operator.In -> { + val membership = comparable + .map { ef.eq("?$pvar", it) as Expr } + .reduceOrNull { acc, e -> ef.or(acc, e) } + ?.let { ef.and(ef.bound("?$pvar"), it) as Expr } + val absent = if (matchesAbsent) ef.not(ef.bound("?$pvar")) as Expr else null + listOfNotNull(absent, membership).reduce { acc, e -> ef.or(acc, e) } } - PrimitiveConstraint.Operator.Less_Than_Equal -> { - ef.le("?$pvar", v) - } - PrimitiveConstraint.Operator.Greater_Than -> { - ef.gt("?$pvar", v) - } - PrimitiveConstraint.Operator.Greater_Than_Equal -> { - ef.ge("?$pvar", v) + else -> { + if (matchesAbsent || comparable.size != 1) { + throw BadRequestException("PrimitiveConstraint operator '${operator.value}' requires exactly one non-null value") + } + val v = comparable[0] + val cmp = when(operator) { + PrimitiveConstraint.Operator.Less_Than -> ef.lt("?$pvar", v) + PrimitiveConstraint.Operator.Less_Than_Equal -> ef.le("?$pvar", v) + PrimitiveConstraint.Operator.Greater_Than -> ef.gt("?$pvar", v) + PrimitiveConstraint.Operator.Greater_Than_Equal -> ef.ge("?$pvar", v) + else -> throw BadRequestException("Unsupported PrimitiveConstraint operator '${operator.value}'") + } + ef.and(ef.bound("?$pvar"), cmp) } } return if (inverse) ef.not(expr) else expr @@ -121,8 +134,8 @@ suspend fun RoutingContext.createOrUpdateQuery( val query = Query(atId = queryId, atType = Query.AtType.Query, owningProject = Identified(projectId), - select = queryRequest.select!!, - where = queryRequest.where!!) + select = queryRequest.select ?: throw BadRequestException("Query must have a select"), + where = queryRequest.where ?: throw BadRequestException("Query must have a where")) val queryString = Json.encodeToString(query) val queryUri = SYSMLV2.query(queryId) val insertBuilder = UpdateBuilder().addInsert(queryUri, DCTerms.description, queryString) @@ -147,9 +160,15 @@ suspend fun RoutingContext.runQuery( val projectResponse = flexoRequestGet { orgPath("/repos/$projectId") } + // nonexistent/unauthorized project: let the caller forward the failure + if (projectResponse.isFailure()) { + return projectResponse + } projectResponse.parseModel { - val outgoing = model.listSubjectsWithProperty(RDF.type, MMS.Repo).next()!!.outgoing() - branchId = outgoing[SYSMLV2.DEFAULT_BRANCH_ID]?.literal() ?: "master" + val repos = model.listSubjectsWithProperty(RDF.type, MMS.Repo) + if (repos.hasNext()) { + branchId = repos.next().outgoing()[SYSMLV2.DEFAULT_BRANCH_ID]?.literal() ?: "master" + } } } val cb = ConstructBuilder().addConstruct("?e", "?p", "?o") @@ -290,7 +309,8 @@ fun Route.QueryApi() { // parse the response model, extract the elements to JSON, and reply to client call.respond(buildJsonArray { flexoResponse.parseModel { - for (subject in model.listSubjects()) { + for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } @@ -310,7 +330,8 @@ fun Route.QueryApi() { // parse the response model, extract the elements to JSON, and reply to client call.respond(buildJsonArray { flexoResponse.parseModel { - for (subject in model.listSubjects()) { + for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/RelationshipApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/RelationshipApi.kt index a3773a8..e5d8b29 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/RelationshipApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/RelationshipApi.kt @@ -72,6 +72,7 @@ fun Route.RelationshipApi() { val result = buildJsonArray { flexoResponse.parseModel { for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt index 7c6635e..dfa6072 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt @@ -39,17 +39,16 @@ fun FlexoModelHandler.tagFromResponse( ): Tag? { val commitSuffix = outgoing[MMS.commit]?.resource()?.uri?.uriSuffix ?: return null val commit = Identified(commitSuffix) + // never fabricate created/name for malformed upstream data — invented + // values mask triplestore bugs (see issue #20) return Tag( atId = tagId, atType = Tag.AtType.Tag, - created = OffsetDateTime.parse( - outgoing[MMS.created]?.literal() - ?: OffsetDateTime.now().toString() - ), + created = OffsetDateTime.parse(outgoing[MMS.created]?.literal() ?: return null), owningProject = Identified(projectId), referencedCommit = commit, taggedCommit = commit, - name = outgoing[DCTerms.title]?.literal() ?: "", + name = outgoing[DCTerms.title]?.literal() ?: return null, ) } fun Route.TagApi() { @@ -89,7 +88,10 @@ fun Route.TagApi() { return@get forward(flexoResponse) } val tags = flexoResponse.parseModel { - model.listSubjectsWithProperty(RDF.type, MMS.Lock).mapWith { + // soft-deleted tags are invisible + model.listSubjectsWithProperty(RDF.type, MMS.Lock).filterDrop { + it.hasProperty(SYSMLV2.DELETED) + }.mapWith { tagFromResponse(it.outgoing(), path.projectId, it.uri.uriSuffix) }.toList().filterNotNull() } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/PrimitiveConstraint.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/PrimitiveConstraint.kt index c5d2b4b..02f165d 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/PrimitiveConstraint.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/PrimitiveConstraint.kt @@ -29,12 +29,13 @@ data class PrimitiveConstraint @OptIn(ExperimentalSerializationApi::class) const val inverse: kotlin.Boolean = false, val operator: PrimitiveConstraint.Operator, val property: kotlin.String, - val value: kotlin.collections.List, //can be Identified, boolean, string, number + // schema: array of Identified/boolean/string/number/null, or null + val value: kotlin.collections.List?, ) : Constraint() { /** * - * Values: Equal,Greater_Than,Less_Than + * Values: Equal,Greater_Than,Less_Than,In */ enum class Operator(val value: kotlin.String){ @SerialName("=") @@ -46,7 +47,9 @@ data class PrimitiveConstraint @OptIn(ExperimentalSerializationApi::class) const @SerialName("<=") Less_Than_Equal("<="), @SerialName(">=") - Greater_Than_Equal(">="); + Greater_Than_Equal(">="), + @SerialName("in") + In("in"); } } diff --git a/src/main/resources/application.conf.example b/src/main/resources/application.conf.example index 740990d..3b059db 100644 --- a/src/main/resources/application.conf.example +++ b/src/main/resources/application.conf.example @@ -27,4 +27,8 @@ flexo { auth = ${?FLEXO_AUTH} basePath = "" basePath = ${?BASEPATH} + # space-separated origins allowed for CORS, e.g. "https://app.example.org http://localhost:3000"; + # empty allows any origin but disables credentialed (Authorization) cross-origin requests + corsAllowedOrigins = "" + corsAllowedOrigins = ${?FLEXO_CORS_ALLOWED_ORIGINS} } diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/BranchListPairingTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/BranchListPairingTest.kt new file mode 100644 index 0000000..2bdf772 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/BranchListPairingTest.kt @@ -0,0 +1,55 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.maps.shouldContainAll +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Regression surface for issue #20: the branch list endpoint must keep + * each branch's name associated with its own @id (reported shuffled on + * GraphDB deployments — the wildcard-CONSTRUCT bug lives upstream in + * layer1, but this pairing must hold against any conformant store). + */ +class BranchListPairingTest : ProjectAny() { + val elementChange = """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "44444444-4444-4444-8444-444444444444"}, + "payload": {"@type": "PartDefinition", "name": "anything"} + } + ] + } + """.trimIndent() + + init { + "GET branches pairs each name with its own @id" { + testApplication { + val commitId = commitChanges(demoProjectId, elementChange).atId() + val idAlpha = createBranch(demoProjectId, commitId, "alpha").atId() + val idBravo = createBranch(demoProjectId, commitId, "bravo").atId() + val idCharlie = createBranch(demoProjectId, commitId, "charlie").atId() + + val response = getBranches(demoProjectId) + response shouldHaveStatus HttpStatusCode.OK + val byId = Json.parseToJsonElement(response.bodyAsText()).jsonArray + .associate { + it.jsonObject["@id"]!!.jsonPrimitive.content to + it.jsonObject["name"]!!.jsonPrimitive.content + } + byId shouldContainAll mapOf( + idAlpha to "alpha", + idBravo to "bravo", + idCharlie to "charlie", + ) + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/DeleteElementTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/DeleteElementTest.kt new file mode 100644 index 0000000..578995c --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/DeleteElementTest.kt @@ -0,0 +1,96 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.string.shouldContain as stringShouldContain +import io.kotest.matchers.string.shouldNotContain as stringShouldNotContain +import io.ktor.client.statement.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Deleting an element (null payload) must not leave dangling references + * in other elements — neither single-valued refs nor array annotations. + */ +class DeleteElementTest : ProjectAny() { + val targetId = "7b1f3c4d-0000-4000-8000-000000000001" + val keepId = "7b1f3c4d-0000-4000-8000-000000000002" + val singleRefId = "7b1f3c4d-0000-4000-8000-000000000003" + val arrayRefId = "7b1f3c4d-0000-4000-8000-000000000004" + + init { + "deleting an element removes it and all references to it" { + testApplication { + commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$targetId"}, + "payload": {"@type": "Feature", "name": "target"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$keepId"}, + "payload": {"@type": "Feature", "name": "keep"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$singleRefId"}, + "payload": {"@type": "PartUsage", "name": "singleRef", "owner": {"@id": "$targetId"}} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$arrayRefId"}, + "payload": { + "@type": "FlowUsage", + "name": "arrayRef", + "featureChain": [{"@id": "$targetId"}, {"@id": "$keepId"}] + } + } + ] + } + """.trimIndent() + ).atId() + + val deleteCommitId = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$targetId"}, + "payload": null + } + ] + } + """.trimIndent() + ).atId() + + val body = getElements(demoProjectId, deleteCommitId).bodyAsText() + val elements = Json.parseToJsonElement(body).jsonArray + val ids = elements.map { it.jsonObject["@id"]!!.jsonPrimitive.content } + + // the deleted element is gone, everything else survives + ids shouldNotContain targetId + ids shouldContain singleRefId + ids shouldContain arrayRefId + + // no element still references the deleted id anywhere + body stringShouldNotContain targetId + + // the array reference to a surviving element is intact + val arrayRef = elements.first { + it.jsonObject["@id"]!!.jsonPrimitive.content == arrayRefId + }.toString() + arrayRef stringShouldContain keepId + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementArrayTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementArrayTest.kt new file mode 100644 index 0000000..c203b77 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementArrayTest.kt @@ -0,0 +1,133 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Round-trip coverage for array-valued element properties (e.g. + * featureChain) — the regression surface of issue #19, where multi-valued + * properties disappeared from /elements responses. + */ +class ElementArrayTest : ProjectAny() { + val chainAId = "6a0e2b3c-0000-4000-8000-00000000000a" + val chainBId = "6a0e2b3c-0000-4000-8000-00000000000b" + val flowId = "6a0e2b3c-0000-4000-8000-00000000000f" + + init { + "array-of-ref property committed via the API round-trips in order" { + testApplication { + val commitId = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$chainAId"}, + "payload": {"@type": "Feature", "name": "chainA"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$chainBId"}, + "payload": {"@type": "Feature", "name": "chainB"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$flowId"}, + "payload": { + "@type": "FlowUsage", + "name": "flow1", + "featureChain": [{"@id": "$chainBId"}, {"@id": "$chainAId"}] + } + } + ] + } + """.trimIndent() + ).atId() + + val element = getElement(demoProjectId, commitId, flowId).bodyAsJsonObject() + val chain = element["featureChain"]!!.jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + // the JSON annotation preserves the original array order + chain shouldBe listOf(chainBId, chainAId) + } + } + + "multi-valued property loaded as raw RDF still appears in /elements" { + testApplication { + // write element triples directly to layer1's graph endpoint — + // no json: annotation triples, as if the model had been + // ingested by another tool rather than through this API + val config = testEnv() + val layer1 = "${config.property("flexo.protocol").getString()}://" + + "${config.property("flexo.host").getString()}:" + + config.property("flexo.port").getString() + val org = config.property("flexo.org").getString() + val defaultBranchId = getProject(demoProjectId).bodyAsJsonObject() + .nestedAtId("defaultBranch") + HttpClient().use { client -> + val response = client.put( + "$layer1/orgs/$org/repos/$demoProjectId/branches/$defaultBranchId/graph") { + header(HttpHeaders.Authorization, authorization()) + header(HttpHeaders.ContentType, "text/turtle") + setBody( + """ + @prefix sysml: . + a sysml:FlowUsage ; + sysml:name "externalFlow" ; + sysml:featureChain , . + a sysml:Feature ; sysml:name "a" . + a sysml:Feature ; sysml:name "b" . + """.trimIndent() + ) + } + require(response.status.isSuccess()) { + "failed to load raw RDF into layer1: ${response.status} ${response.bodyAsText()}" + } + } + + // the load created a commit; find it through the API + val commits = Json.parseToJsonElement( + getCommits(demoProjectId).bodyAsText()).jsonArray + val commitId = commits.first().jsonObject["@id"]!!.jsonPrimitive.content + + val element = getElement(demoProjectId, commitId, flowId).bodyAsJsonObject() + // annotation-less multi-valued properties must not be dropped; + // order is best-effort since RDF triples are unordered + val chain = element["featureChain"]!!.jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + chain shouldContainExactlyInAnyOrder listOf(chainAId, chainBId) + } + } + + "commit change whose identity and payload @id disagree is a 400" { + testApplication { + val response = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$chainAId"}, + "payload": {"@type": "Feature", "@id": "$chainBId", "name": "mismatch"} + } + ] + } + """.trimIndent() + ) + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/EscapingTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/EscapingTest.kt new file mode 100644 index 0000000..86c08f2 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/EscapingTest.kt @@ -0,0 +1,81 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.shouldBe +import io.ktor.http.* +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Client-supplied strings are interpolated into SPARQL updates: literal + * contents must be escaped (including carriage returns, which previously + * produced an illegal SPARQL string and failed the whole commit), and + * element ids must be rejected before reaching an IRI position. + */ +class EscapingTest : ProjectAny() { + val elementId = "33333333-3333-4333-8333-333333333333" + + init { + "element name with control and quote characters round-trips" { + testApplication { + val commitId = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$elementId"}, + "payload": {"@type": "PartDefinition", "name": "line1\r\nline2 \"quoted\" back\\slash"} + } + ] + } + """.trimIndent() + ).atId() + + val element = getElement(demoProjectId, commitId, elementId).bodyAsJsonObject() + element["name"]!!.jsonPrimitive.content shouldBe "line1\r\nline2 \"quoted\" back\\slash" + } + } + + "commit with an invalid element @id is a 400" { + testApplication { + val response = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "../not/a/valid#id"}, + "payload": {"@type": "PartDefinition", "name": "evil"} + } + ] + } + """.trimIndent() + ) + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + + "commit with an invalid referenced @id is a 400" { + testApplication { + val response = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$elementId"}, + "payload": {"@type": "PartUsage", "owner": {"@id": "urn:evil> . { + val response = httpPost("/projects/$demoProjectId/query-results") { + setJsonBody("""{ "@type": "Query", "where": $whereJson }""") + } + response shouldHaveStatus HttpStatusCode.OK + return Json.parseToJsonElement(response.bodyAsText()).jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + } + + "POST query-results - '=' with a single-element array matches by name" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Alpha"]}""" + ) + ids shouldContainExactlyInAnyOrder listOf(alphaId) + } + } + + "POST query-results - '=' with a multi-element array is set membership" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Alpha", "Beta"]}""" + ) + ids shouldContainExactlyInAnyOrder listOf(alphaId, betaId) + } + } + + "POST query-results - 'in' behaves like multi-value '='" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "in", "property": "name", "value": ["Beta", "Gamma"]}""" + ) + ids shouldContainExactlyInAnyOrder listOf(betaId, gammaId) + } + } + + "POST query-results - '=' on @type filters by metaclass" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "=", "property": "@type", "value": ["PartUsage"]}""" + ) + ids shouldContainExactlyInAnyOrder listOf(gammaId) + } + } + + "POST query-results - '=' null matches elements lacking the property" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": null}""" + ) + ids shouldContainExactlyInAnyOrder listOf(docId) + } + } + + "POST query-results - 'or' composite over different properties matches both sides" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """ + { + "@type": "CompositeConstraint", + "operator": "or", + "constraint": [ + {"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Alpha"]}, + {"@type": "PrimitiveConstraint", "operator": "=", "property": "body", "value": ["docs"]} + ] + } + """.trimIndent() + ) + ids shouldContainExactlyInAnyOrder listOf(alphaId, docId) + } + } + + "POST query-results - 'and' composite intersects constraints" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """ + { + "@type": "CompositeConstraint", + "operator": "and", + "constraint": [ + {"@type": "PrimitiveConstraint", "operator": "=", "property": "@type", "value": ["PartDefinition"]}, + {"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Beta"]} + ] + } + """.trimIndent() + ) + ids shouldContainExactlyInAnyOrder listOf(betaId) + } + } + + "GET queries/{queryId}/results - runs a saved query" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val queryId = createQuery(demoProjectId, "name", "Gamma").atId() + + val response = httpGet("/projects/$demoProjectId/queries/$queryId/results") + response shouldHaveStatus HttpStatusCode.OK + val ids = Json.parseToJsonElement(response.bodyAsText()).jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + ids shouldContainExactlyInAnyOrder listOf(gammaId) + } + } + + "POST queries - missing select is a 400, not a server error" { + testApplication { + val response = httpPost("/projects/$demoProjectId/queries") { + setJsonBody( + """ + { + "@type": "Query", + "where": {"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["x"]} + } + """.trimIndent() + ) + } + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + } +} diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/SoftDeleteTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/SoftDeleteTest.kt new file mode 100644 index 0000000..1f1b474 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/SoftDeleteTest.kt @@ -0,0 +1,70 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.ktor.http.* +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Soft-deleted resources must be invisible through single-resource GETs, + * not just filtered from list responses; and POST must not silently + * overwrite an existing resource. + */ +class SoftDeleteTest : ProjectAny() { + val elementChange = """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "22222222-2222-4222-8222-222222222222"}, + "payload": {"@type": "PartDefinition", "name": "anything"} + } + ] + } + """.trimIndent() + + init { + "GET a soft-deleted project by id is a 404" { + testApplication { + deleteProject(demoProjectId) shouldHaveStatus HttpStatusCode.OK + getProject(demoProjectId) shouldHaveStatus HttpStatusCode.NotFound + } + } + + "GET a soft-deleted branch by id is a 404" { + testApplication { + val commitId = commitChanges(demoProjectId, elementChange).atId() + val branchId = createBranch(demoProjectId, commitId, "doomed").atId() + + deleteBranch(demoProjectId, branchId) shouldHaveStatus HttpStatusCode.OK + getBranch(demoProjectId, branchId) shouldHaveStatus HttpStatusCode.NotFound + } + } + + "GET a soft-deleted tag by id is a 404" { + testApplication { + val commitId = commitChanges(demoProjectId, elementChange).atId() + val tagId = createTag(demoProjectId, commitId, "v-doomed").atId() + + deleteTag(demoProjectId, tagId) shouldHaveStatus HttpStatusCode.OK + getTag(demoProjectId, tagId) shouldHaveStatus HttpStatusCode.NotFound + } + } + + "POST /projects with an existing @id is a 409, not a silent overwrite" { + testApplication { + val response = httpPost("/projects") { + setJsonBody( + """ + { + "@type": "Project", + "@id": "$demoProjectId", + "name": "usurper" + } + """.trimIndent() + ) + } + response shouldHaveStatus HttpStatusCode.Conflict + } + } + } +}