diff --git a/.gitignore b/.gitignore index 5e614ef9e1..917c1ff683 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ http-client.private.env.json # VS Code .vscode/ + +# Agent files. +.github/chatmodes/ diff --git a/components/search/api-model/build.gradle.kts b/components/search/api-model/build.gradle.kts new file mode 100644 index 0000000000..a8941566bd --- /dev/null +++ b/components/search/api-model/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +plugins { + id("ort-server-kotlin-multiplatform-conventions") + id("ort-server-publication-conventions") + alias(libs.plugins.kotlinSerialization) +} + +group = "org.eclipse.apoapsis.ortserver.components.search" + +kotlin { + jvm() + linuxX64() + macosArm64() + macosX64() + mingwX64() + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlinxDatetime) + implementation(libs.kotlinxSerializationJson) + } + } + } +} diff --git a/components/search/api-model/src/commonMain/kotlin/RunWithPackage.kt b/components/search/api-model/src/commonMain/kotlin/RunWithPackage.kt new file mode 100644 index 0000000000..177a3eae77 --- /dev/null +++ b/components/search/api-model/src/commonMain/kotlin/RunWithPackage.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.search.apimodel + +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class RunWithPackage( + val organizationId: Long, + val productId: Long, + val repositoryId: Long, + val ortRunId: Long, + val revision: String?, + val createdAt: Instant, + val packageId: String, +) diff --git a/components/search/backend/build.gradle.kts b/components/search/backend/build.gradle.kts new file mode 100644 index 0000000000..ba2b45524e --- /dev/null +++ b/components/search/backend/build.gradle.kts @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +plugins { + id("ort-server-kotlin-component-backend-conventions") + id("ort-server-publication-conventions") +} + +group = "org.eclipse.apoapsis.ortserver.components.search" + +repositories { + exclusiveContent { + forRepository { + maven("https://repo.gradle.org/gradle/libs-releases/") + } + filter { + includeGroup("org.gradle") + } + } +} + +dependencies { + api(libs.exposedCore) + + implementation(projects.components.search.apiModel) + implementation(projects.dao) + implementation(projects.model) + + routesImplementation(projects.components.authorizationKeycloak.backend) + routesImplementation(projects.shared.apiModel) + routesImplementation(projects.shared.ktorUtils) + + routesImplementation(ktorLibs.server.auth) + routesImplementation(ktorLibs.server.core) + routesImplementation(libs.ktorOpenApi) + + testImplementation(testFixtures(projects.clients.keycloak)) + testImplementation(testFixtures(projects.dao)) + testImplementation(testFixtures(projects.shared.ktorUtils)) + + testImplementation(ktorLibs.serialization.kotlinx.json) + testImplementation(ktorLibs.server.auth) + testImplementation(ktorLibs.server.contentNegotiation) + testImplementation(ktorLibs.server.statusPages) + testImplementation(ktorLibs.server.testHost) + testImplementation(libs.kotestAssertionsKtor) + testImplementation(libs.kotestRunnerJunit5) + testImplementation(libs.kotlinxSerializationJson) + testImplementation(libs.mockk) +} diff --git a/components/search/backend/src/main/kotlin/SearchService.kt b/components/search/backend/src/main/kotlin/SearchService.kt new file mode 100644 index 0000000000..6bb25efefc --- /dev/null +++ b/components/search/backend/src/main/kotlin/SearchService.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.search.backend + +import org.eclipse.apoapsis.ortserver.components.search.apimodel.RunWithPackage +import org.eclipse.apoapsis.ortserver.dao.blockingQuery +import org.eclipse.apoapsis.ortserver.dao.repositories.analyzerjob.AnalyzerJobsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.analyzerrun.AnalyzerRunsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.analyzerrun.PackagesAnalyzerRunsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.analyzerrun.PackagesTable +import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunDao +import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.product.ProductsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable +import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifiersTable +import org.eclipse.apoapsis.ortserver.dao.utils.applyRegex + +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SqlExpressionBuilder.concat +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.innerJoin +import org.jetbrains.exposed.sql.stringLiteral + +class SearchService(private val db: Database) { + /** + * Search for Analyzer runs containing the given package identifier, with optional scoping. + * Throws IllegalArgumentException for invalid scoping hierarchy. + */ + @Suppress("LongMethod") + fun findOrtRunsByPackage( + identifier: String, + organizationId: Long? = null, + productId: Long? = null, + repositoryId: Long? = null + ): List = db.blockingQuery { + validateScope(organizationId, productId, repositoryId) + + // Build base query + var query = OrtRunsTable + .innerJoin(AnalyzerJobsTable, { OrtRunsTable.id }, { ortRunId }) + .innerJoin(AnalyzerRunsTable, { AnalyzerJobsTable.id }, { analyzerJobId }) + .innerJoin(PackagesAnalyzerRunsTable, { AnalyzerRunsTable.id }, { PackagesAnalyzerRunsTable.analyzerRunId }) + .innerJoin(PackagesTable, { PackagesAnalyzerRunsTable.packageId }, { PackagesTable.id }) + .innerJoin(IdentifiersTable, { PackagesTable.identifierId }, { IdentifiersTable.id }) + + // Convert Identifier to a concatenated string format for ILike comparison + val concatenatedIdentifier = concat( + IdentifiersTable.type, + stringLiteral(":"), + IdentifiersTable.namespace, + stringLiteral(":"), + IdentifiersTable.name, + stringLiteral(":"), + IdentifiersTable.version + ) + + val conditions = mutableListOf(concatenatedIdentifier.applyRegex(identifier)) + + val scopeRequested = organizationId != null || productId != null || repositoryId != null + if (scopeRequested) { + query = query + .innerJoin(RepositoriesTable, { OrtRunsTable.repositoryId }, { RepositoriesTable.id }) + .innerJoin(ProductsTable, { RepositoriesTable.productId }, { ProductsTable.id }) + } + + organizationId?.let { conditions += ProductsTable.organizationId eq it } + productId?.let { conditions += RepositoriesTable.productId eq it } + repositoryId?.let { conditions += OrtRunsTable.repositoryId eq it } + + val whereClause = conditions.reduce { acc, expression -> acc and expression } + + val resultRows = query.select(OrtRunsTable.columns + IdentifiersTable.columns).where { whereClause } + resultRows.map { row -> + val ortRun = OrtRunDao.wrapRow(row).mapToModel() + val packageId = listOf( + row[IdentifiersTable.type], + row[IdentifiersTable.namespace], + row[IdentifiersTable.name], + row[IdentifiersTable.version] + ).joinToString(":") + RunWithPackage( + organizationId = ortRun.organizationId, + productId = ortRun.productId, + repositoryId = ortRun.repositoryId, + ortRunId = ortRun.id, + revision = ortRun.revision, + createdAt = ortRun.createdAt, + packageId = packageId + ) + } + } + + private fun validateScope(organizationId: Long?, productId: Long?, repositoryId: Long?) { + require(!(repositoryId != null && (productId == null || organizationId == null))) { + "If repositoryId is provided, productId and organizationId must also be provided." + } + require(organizationId != null || productId == null) { + "If productId is provided, organizationId must also be provided." + } + } +} diff --git a/components/search/backend/src/routes/kotlin/Routing.kt b/components/search/backend/src/routes/kotlin/Routing.kt new file mode 100644 index 0000000000..6960407395 --- /dev/null +++ b/components/search/backend/src/routes/kotlin/Routing.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.search + +import io.ktor.server.routing.Route + +import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService +import org.eclipse.apoapsis.ortserver.components.search.routes.getRunsWithPackage + +/** Add all package-search routes. */ +fun Route.searchRoutes(searchService: SearchService) { + getRunsWithPackage(searchService) +} diff --git a/components/search/backend/src/routes/kotlin/routes/GetRunsWithPackage.kt b/components/search/backend/src/routes/kotlin/routes/GetRunsWithPackage.kt new file mode 100644 index 0000000000..79690ed817 --- /dev/null +++ b/components/search/backend/src/routes/kotlin/routes/GetRunsWithPackage.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.search.routes + +import io.github.smiley4.ktoropenapi.get + +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Clock + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.OrganizationPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.ProductPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requireSuperuser +import org.eclipse.apoapsis.ortserver.components.search.apimodel.RunWithPackage +import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter + +@Suppress("LongMethod") +internal fun Route.getRunsWithPackage(searchService: SearchService) = + get("/search/package", { + operationId = "getRunsWithPackage" + summary = "Return ORT runs containing a package, possibly scoped by organization, product, and repository" + tags = listOf("Search") + + request { + queryParameter("identifier") { + description = "The package identifier to search for. Also RegEx supported." + required = true + } + queryParameter("organizationId") { + description = "Optional organization ID to filter the search." + } + queryParameter("productId") { + description = "Optional product ID to filter the search." + } + queryParameter("repositoryId") { + description = "Optional repository ID to filter the search." + } + } + + response { + HttpStatusCode.OK to { + description = "Success" + jsonBody> { + example("Package Search Result") { + value = listOf( + RunWithPackage( + organizationId = 1L, + productId = 2L, + repositoryId = 3L, + ortRunId = 42L, + revision = "a1b2c3d4", + createdAt = Clock.System.now(), + packageId = "Maven:foo:bar:1.0.0" + ), + RunWithPackage( + organizationId = 1L, + productId = 3L, + repositoryId = 7L, + ortRunId = 120L, + revision = "a1b2c3d4", + createdAt = Clock.System.now(), + packageId = "Maven:foo:bar:1.0.0" + ) + ) + } + } + } + } + }) { + val identifierParam = call.requireParameter("identifier") + val organizationIdParam = call.request.queryParameters["organizationId"]?.toLongOrNull() + val productIdParam = call.request.queryParameters["productId"]?.toLongOrNull() + val repositoryIdParam = call.request.queryParameters["repositoryId"]?.toLongOrNull() + + if (repositoryIdParam != null && (productIdParam == null || organizationIdParam == null)) { + return@get call.respond( + HttpStatusCode.BadRequest, + "A repository ID requires a product and an organization ID." + ) + } + if (productIdParam != null && organizationIdParam == null) { + return@get call.respond( + HttpStatusCode.BadRequest, + "A product ID requires an organization ID." + ) + } + + if (repositoryIdParam != null) { + requirePermission(RepositoryPermission.READ.roleName(repositoryIdParam)) + } else if (productIdParam != null) { + requirePermission(ProductPermission.READ.roleName(productIdParam)) + } else if (organizationIdParam != null) { + requirePermission(OrganizationPermission.READ.roleName(organizationIdParam)) + } else { + requireSuperuser() + } + + val ortRuns = searchService.findOrtRunsByPackage( + identifier = identifierParam, + organizationId = organizationIdParam, + productId = productIdParam, + repositoryId = repositoryIdParam + ) + call.respond(HttpStatusCode.OK, ortRuns) + } diff --git a/components/search/backend/src/test/kotlin/SearchIntegrationTest.kt b/components/search/backend/src/test/kotlin/SearchIntegrationTest.kt new file mode 100644 index 0000000000..0d8b65571e --- /dev/null +++ b/components/search/backend/src/test/kotlin/SearchIntegrationTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package ort.eclipse.apoapsis.ortserver.components.search + +import io.ktor.client.HttpClient +import io.ktor.server.testing.ApplicationTestBuilder + +import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService +import org.eclipse.apoapsis.ortserver.components.search.searchRoutes +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest + +import org.jetbrains.exposed.sql.Database + +/** An [AbstractIntegrationTest] pre-configured for testing the search routes. */ +@Suppress("UnnecessaryAbstractClass") +abstract class SearchIntegrationTest( + body: SearchIntegrationTest.() -> Unit +) : AbstractIntegrationTest({}) { + lateinit var searchService: SearchService + + private lateinit var db: Database + private lateinit var fixtures: Fixtures + + init { + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + searchService = SearchService(db) + } + + body() + } + + fun searchTestApplication( + block: suspend ApplicationTestBuilder.(client: HttpClient) -> Unit + ) = integrationTestApplication( + routes = { searchRoutes(searchService) }, + block = block + ) +} diff --git a/components/search/backend/src/test/kotlin/SearchServiceTest.kt b/components/search/backend/src/test/kotlin/SearchServiceTest.kt new file mode 100644 index 0000000000..642b20b385 --- /dev/null +++ b/components/search/backend/src/test/kotlin/SearchServiceTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package ort.eclipse.apoapsis.ortserver.components.search + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.collections.beEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.should + +import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService +import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.runs.Identifier + +import org.jetbrains.exposed.sql.Database + +class SearchServiceTest : WordSpec({ + val dbExtension = extension(DatabaseTestExtension()) + + lateinit var db: Database + lateinit var fixtures: Fixtures + lateinit var searchService: SearchService + var repositoryId = -1L + + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + searchService = SearchService(db) + repositoryId = fixtures.repository.id + } + + "findOrtRunsByPackage" should { + "throw if productId without organizationId" { + shouldThrow { + searchService.findOrtRunsByPackage( + Identifier( + type = "maven", + namespace = "foo", + name = "bar", + version = "1.0.0" + ).toCoordinates(), + productId = 2L + ) + } + } + + "throw if repositoryId without productId and organizationId" { + shouldThrow { + searchService.findOrtRunsByPackage( + Identifier( + type = "maven", + namespace = "foo", + name = "bar", + version = "1.0.0" + ).toCoordinates(), + repositoryId = 3L + ) + } + shouldThrow { + searchService.findOrtRunsByPackage( + Identifier( + type = "maven", + namespace = "foo", + name = "bar", + version = "1.0.0" + ).toCoordinates(), + organizationId = 1L, + repositoryId = 3L + ) + } + } + + "support global search" { + val run = createRunWithPackage(fixtures = fixtures, repoId = repositoryId) + val expectedId = run.packageId + + val result = searchService.findOrtRunsByPackage(expectedId) + + result shouldContainExactly listOf(run) + } + + "support search inside an organization" { + val run = createRunWithPackage(fixtures = fixtures, repoId = repositoryId) + val expectedId = run.packageId + val otherOrg = dbExtension.fixtures.createOrganization(name = "other-org") + val otherProd = dbExtension.fixtures.createProduct(name = "other-prod", organizationId = otherOrg.id) + val otherRepo = dbExtension.fixtures.createRepository( + productId = otherProd.id, + url = "https://example.com/other-repo.git" + ) + createRunWithPackage(fixtures = fixtures, repoId = otherRepo.id) + + val result = searchService.findOrtRunsByPackage( + identifier = expectedId, + organizationId = run.organizationId + ) + + result shouldContainExactly listOf(run) + } + + "support search inside a product" { + val run = createRunWithPackage(fixtures = fixtures, repoId = repositoryId) + val expectedId = run.packageId + val otherProd = dbExtension.fixtures.createProduct(name = "other-prod", organizationId = run.organizationId) + val otherRepo = dbExtension.fixtures.createRepository( + productId = otherProd.id, + url = "https://example.com/other-repo.git" + ) + createRunWithPackage(fixtures = fixtures, repoId = otherRepo.id) + + val result = searchService.findOrtRunsByPackage( + expectedId, + organizationId = run.organizationId, + productId = run.productId + ) + result shouldContainExactly listOf(run) + } + + "support search inside a repository" { + val run = createRunWithPackage(fixtures = fixtures, repoId = repositoryId) + val expectedId = run.packageId + val otherRepo = dbExtension.fixtures.createRepository( + productId = run.productId, + url = "https://example.com/other-repo.git" + ) + createRunWithPackage(fixtures = fixtures, repoId = otherRepo.id) + + val result = searchService.findOrtRunsByPackage( + expectedId, + organizationId = run.organizationId, + productId = run.productId, + repositoryId = run.repositoryId + ) + result shouldContainExactly listOf(run) + } + + "find all runs for a package from multiple repositories/products/organizations" { + val run1 = createRunWithPackage(fixtures = fixtures, repoId = repositoryId) + val run2 = createRunWithPackage(fixtures = fixtures, repoId = repositoryId) + val expectedId = run1.packageId + + val otherOrg = dbExtension.fixtures.createOrganization(name = "other-org") + val otherProd = dbExtension.fixtures.createProduct(name = "other-prod", organizationId = otherOrg.id) + val otherRepo = dbExtension.fixtures.createRepository( + productId = otherProd.id, + url = "https://example.com/other-repo.git" + ) + val run3 = createRunWithPackage( + fixtures = fixtures, repoId = otherRepo.id, + pkgId = Identifier( + type = "test", + namespace = "ns", + name = "name", + version = "ver" + ) + ) + + val result = searchService.findOrtRunsByPackage(expectedId) + + result shouldContainExactlyInAnyOrder(listOf(run1, run2, run3)) + } + + "return empty when package is not present in the given scope" { + val run = createRunWithPackage(fixtures = fixtures, repoId = repositoryId) + val expectedId = "maven:nonexistent:package:1.0.0" + + val result = searchService.findOrtRunsByPackage( + expectedId, + organizationId = run.organizationId, + productId = run.productId, + repositoryId = run.repositoryId + ) + + result should beEmpty() + } + } +}) diff --git a/components/search/backend/src/test/kotlin/Utils.kt b/components/search/backend/src/test/kotlin/Utils.kt new file mode 100644 index 0000000000..b42b14e554 --- /dev/null +++ b/components/search/backend/src/test/kotlin/Utils.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package ort.eclipse.apoapsis.ortserver.components.search + +import org.eclipse.apoapsis.ortserver.components.search.apimodel.RunWithPackage +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.runs.Identifier + +fun createRunWithPackage( + fixtures: Fixtures, + repoId: Long = -1L, + pkgId: Identifier = Identifier("test", "ns", "name", "ver") +): RunWithPackage { + val pkg = fixtures.generatePackage(pkgId) + val ortRun = fixtures.createAnalyzerRunWithPackages(packages = setOf(pkg), repositoryId = repoId) + + return RunWithPackage( + organizationId = ortRun.organizationId, + productId = ortRun.productId, + repositoryId = ortRun.repositoryId, + ortRunId = ortRun.id, + revision = ortRun.revision, + createdAt = ortRun.createdAt, + packageId = pkgId.toCoordinates() + ) +} + +fun Identifier.toCoordinates(): String = "$type:$namespace:$name:$version" diff --git a/components/search/backend/src/test/kotlin/routes/GetRunsWithPackageAuthorizationTest.kt b/components/search/backend/src/test/kotlin/routes/GetRunsWithPackageAuthorizationTest.kt new file mode 100644 index 0000000000..920e96c1e6 --- /dev/null +++ b/components/search/backend/src/test/kotlin/routes/GetRunsWithPackageAuthorizationTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package ort.eclipse.apoapsis.ortserver.components.search.routes + +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.OrganizationPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.ProductPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.search.apimodel.RunWithPackage +import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService +import org.eclipse.apoapsis.ortserver.components.search.searchRoutes +import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractAuthorizationTest + +import ort.eclipse.apoapsis.ortserver.components.search.createRunWithPackage + +class GetRunsWithPackageAuthorizationTest : AbstractAuthorizationTest({ + lateinit var searchService: SearchService + lateinit var runWithPackage: RunWithPackage + + beforeEach { + searchService = SearchService(dbExtension.db) + runWithPackage = createRunWithPackage( + fixtures = dbExtension.fixtures, + repoId = dbExtension.fixtures.repository.id + ) + authorizationService.ensureSuperuserAndSynchronizeRolesAndPermissions() + } + + "GetRunsWithPackage" should { + "require superuser role for unscoped searches" { + requestShouldRequireSuperuser( + routes = { searchRoutes(searchService) }, + successStatus = HttpStatusCode.OK + ) { + get("/search/package") { + parameter("identifier", runWithPackage.packageId) + } + } + } + + "require organization permission when scoped to organization" { + val organizationRole = OrganizationPermission.READ.roleName(runWithPackage.organizationId) + + requestShouldRequireRole( + routes = { searchRoutes(searchService) }, + role = organizationRole, + successStatus = HttpStatusCode.OK + ) { + get("/search/package") { + parameter("identifier", runWithPackage.packageId) + parameter("organizationId", runWithPackage.organizationId.toString()) + } + } + } + + "require product permission when scoped to product" { + val productRole = ProductPermission.READ.roleName(runWithPackage.productId) + + requestShouldRequireRole( + routes = { searchRoutes(searchService) }, + role = productRole, + successStatus = HttpStatusCode.OK + ) { + get("/search/package") { + parameter("identifier", runWithPackage.packageId) + parameter("organizationId", runWithPackage.organizationId.toString()) + parameter("productId", runWithPackage.productId.toString()) + } + } + } + + "require repository permission when scoped to repository" { + val repositoryRole = RepositoryPermission.READ.roleName(runWithPackage.repositoryId) + + requestShouldRequireRole( + routes = { searchRoutes(searchService) }, + role = repositoryRole, + successStatus = HttpStatusCode.OK + ) { + get("/search/package") { + parameter("identifier", runWithPackage.packageId) + parameter("organizationId", runWithPackage.organizationId.toString()) + parameter("productId", runWithPackage.productId.toString()) + parameter("repositoryId", runWithPackage.repositoryId.toString()) + } + } + } + } +}) diff --git a/components/search/backend/src/test/kotlin/routes/GetRunsWithPackageIntegrationTest.kt b/components/search/backend/src/test/kotlin/routes/GetRunsWithPackageIntegrationTest.kt new file mode 100644 index 0000000000..1d0f4f53e0 --- /dev/null +++ b/components/search/backend/src/test/kotlin/routes/GetRunsWithPackageIntegrationTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package ort.eclipse.apoapsis.ortserver.components.search.routes + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder + +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.search.apimodel.RunWithPackage +import org.eclipse.apoapsis.ortserver.model.runs.Identifier + +import ort.eclipse.apoapsis.ortserver.components.search.SearchIntegrationTest +import ort.eclipse.apoapsis.ortserver.components.search.createRunWithPackage +import ort.eclipse.apoapsis.ortserver.components.search.toCoordinates + +private const val SEARCH_ROUTE = "/search/package" + +class GetRunsWithPackageIntegrationTest : SearchIntegrationTest({ + "GetRunsWithPackage" should { + "return BadRequest if identifier is missing" { + searchTestApplication { client -> + val response = client.get(SEARCH_ROUTE) + + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + + "return BadRequest if productId without organizationId is provided" { + val fixtures = dbExtension.fixtures + val run = createRunWithPackage(fixtures = fixtures, repoId = fixtures.repository.id) + + searchTestApplication { client -> + val response = client.get(SEARCH_ROUTE) { + parameter("identifier", run.packageId) + parameter("productId", run.productId.toString()) + } + + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + + "return BadRequest if repositoryId without productId and organizationId is provided" { + val fixtures = dbExtension.fixtures + val run = createRunWithPackage(fixtures = fixtures, repoId = fixtures.repository.id) + + searchTestApplication { client -> + val withoutScopes = client.get(SEARCH_ROUTE) { + parameter("identifier", run.packageId) + parameter("repositoryId", run.repositoryId.toString()) + } + + withoutScopes shouldHaveStatus HttpStatusCode.BadRequest + + val missingProduct = client.get(SEARCH_ROUTE) { + parameter("identifier", run.packageId) + parameter("organizationId", run.organizationId.toString()) + parameter("repositoryId", run.repositoryId.toString()) + } + + missingProduct shouldHaveStatus HttpStatusCode.BadRequest + } + } + + "return runs globally when package exists" { + val fixtures = dbExtension.fixtures + val packageIdentifier = identifierFor("global") + val run = createRunWithPackage( + fixtures = fixtures, + repoId = fixtures.repository.id, + pkgId = packageIdentifier + ) + + searchTestApplication { client -> + val response = client.get(SEARCH_ROUTE) { + parameter("identifier", run.packageId) + } + + response shouldHaveStatus HttpStatusCode.OK + + val body = response.body>() + body shouldContainExactly listOf(run) + } + } + + "return runs scoped to organization" { + val fixtures = dbExtension.fixtures + val packageIdentifier = identifierFor("organization") + val run = createRunWithPackage( + fixtures = fixtures, + repoId = fixtures.repository.id, + pkgId = packageIdentifier + ) + val otherOrg = fixtures.createOrganization(name = "other-org") + val otherProd = fixtures.createProduct(name = "other-prod", organizationId = otherOrg.id) + val otherRepo = fixtures.createRepository( + productId = otherProd.id, + url = "https://example.com/org-scope.git" + ) + createRunWithPackage( + fixtures = fixtures, + repoId = otherRepo.id, + pkgId = packageIdentifier + ) + + searchTestApplication { client -> + val response = client.get(SEARCH_ROUTE) { + parameter("identifier", run.packageId) + parameter("organizationId", run.organizationId.toString()) + } + + response shouldHaveStatus HttpStatusCode.OK + + val body = response.body>() + body shouldContainExactly listOf(run) + } + } + + "return runs scoped to product" { + val fixtures = dbExtension.fixtures + val packageIdentifier = identifierFor("product") + val run = createRunWithPackage( + fixtures = fixtures, + repoId = fixtures.repository.id, + pkgId = packageIdentifier + ) + val otherProd = fixtures.createProduct(name = "other-prod", organizationId = run.organizationId) + val otherRepo = fixtures.createRepository( + productId = otherProd.id, + url = "https://example.com/product-scope.git" + ) + createRunWithPackage( + fixtures = fixtures, + repoId = otherRepo.id, + pkgId = packageIdentifier + ) + + searchTestApplication { client -> + val response = client.get(SEARCH_ROUTE) { + parameter("identifier", run.packageId) + parameter("organizationId", run.organizationId.toString()) + parameter("productId", run.productId.toString()) + } + + response shouldHaveStatus HttpStatusCode.OK + + val body = response.body>() + body shouldContainExactly listOf(run) + } + } + + "return runs scoped to repository" { + val fixtures = dbExtension.fixtures + val packageIdentifier = identifierFor("repository") + val run = createRunWithPackage( + fixtures = fixtures, + repoId = fixtures.repository.id, + pkgId = packageIdentifier + ) + val otherRepo = fixtures.createRepository( + productId = run.productId, + url = "https://example.com/repository-scope.git" + ) + createRunWithPackage( + fixtures = fixtures, + repoId = otherRepo.id, + pkgId = packageIdentifier + ) + + searchTestApplication { client -> + val response = client.get(SEARCH_ROUTE) { + parameter("identifier", run.packageId) + parameter("organizationId", run.organizationId.toString()) + parameter("productId", run.productId.toString()) + parameter("repositoryId", run.repositoryId.toString()) + } + + response shouldHaveStatus HttpStatusCode.OK + + val body = response.body>() + body shouldContainExactly listOf(run) + } + } + + "return all runs for a package across scopes" { + val fixtures = dbExtension.fixtures + val packageIdentifier = identifierFor("aggregate") + val baseRepoId = fixtures.repository.id + val run1 = createRunWithPackage( + fixtures = fixtures, + repoId = baseRepoId, + pkgId = packageIdentifier + ) + val run2 = createRunWithPackage( + fixtures = fixtures, + repoId = baseRepoId, + pkgId = packageIdentifier + ) + + val otherOrg = fixtures.createOrganization(name = "agg-org") + val otherProd = fixtures.createProduct(name = "agg-prod", organizationId = otherOrg.id) + val otherRepo = fixtures.createRepository( + productId = otherProd.id, + url = "https://example.com/aggregate-scope.git" + ) + val run3 = createRunWithPackage( + fixtures = fixtures, + repoId = otherRepo.id, + pkgId = packageIdentifier + ) + + searchTestApplication { client -> + val response = client.get(SEARCH_ROUTE) { + parameter("identifier", run1.packageId) + } + + response shouldHaveStatus HttpStatusCode.OK + + val body = response.body>() + body shouldContainExactlyInAnyOrder(listOf(run1, run2, run3)) + } + } + + "return empty list when package not present in scope" { + val fixtures = dbExtension.fixtures + val packageIdentifier = identifierFor("empty-run") + val run = createRunWithPackage( + fixtures = fixtures, + repoId = fixtures.repository.id, + pkgId = packageIdentifier + ) + val missingIdentifier = identifierFor("missing").toCoordinates() + + searchTestApplication { client -> + val response = client.get(SEARCH_ROUTE) { + parameter("identifier", missingIdentifier) + parameter("organizationId", run.organizationId.toString()) + parameter("productId", run.productId.toString()) + parameter("repositoryId", run.repositoryId.toString()) + } + + response shouldHaveStatus HttpStatusCode.OK + + val body = response.body>() + body.shouldBeEmpty() + } + } + } +}) + +private fun identifierFor(suffix: String) = Identifier( + type = "maven", + namespace = "ns-$suffix", + name = "name-$suffix", + version = "1.0.0" +) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index e46b896c7c..7a9935f26c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -84,6 +84,12 @@ dependencies { requireCapability("$group:routes:$version") } } + implementation(projects.components.search.backend) + implementation(projects.components.search.backend) { + capabilities { + requireCapability("$group:routes:$version") + } + } implementation(projects.components.secrets.backend) implementation(projects.components.secrets.backend) { capabilities { diff --git a/core/src/main/kotlin/di/Module.kt b/core/src/main/kotlin/di/Module.kt index 6cf8df9d98..dad47625f9 100644 --- a/core/src/main/kotlin/di/Module.kt +++ b/core/src/main/kotlin/di/Module.kt @@ -36,6 +36,7 @@ import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginEventStore import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginService import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginTemplateEventStore import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginTemplateService +import org.eclipse.apoapsis.ortserver.components.search.backend.SearchService import org.eclipse.apoapsis.ortserver.components.secrets.SecretService import org.eclipse.apoapsis.ortserver.config.ConfigManager import org.eclipse.apoapsis.ortserver.core.plugins.customSerializersModule @@ -188,6 +189,7 @@ fun ortServerModule(config: ApplicationConfig, db: Database?, authorizationServi singleOf(::ProjectService) singleOf(::RepositoryService) singleOf(::RuleViolationService) + singleOf(::SearchService) singleOf(::SecretService) singleOf(::VulnerabilityService) diff --git a/core/src/main/kotlin/plugins/OpenApi.kt b/core/src/main/kotlin/plugins/OpenApi.kt index 54ba340629..6bc57bfc3b 100644 --- a/core/src/main/kotlin/plugins/OpenApi.kt +++ b/core/src/main/kotlin/plugins/OpenApi.kt @@ -110,6 +110,7 @@ fun Application.configureOpenApi() { tag("Runs") { } tag("Admin") { } tag("Versions") { } + tag("Search") { } } schemas { diff --git a/core/src/main/kotlin/plugins/Routing.kt b/core/src/main/kotlin/plugins/Routing.kt index c4c14b8220..0acec87895 100644 --- a/core/src/main/kotlin/plugins/Routing.kt +++ b/core/src/main/kotlin/plugins/Routing.kt @@ -28,6 +28,7 @@ import org.eclipse.apoapsis.ortserver.components.adminconfig.adminConfigRoutes import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.SecurityConfigurations import org.eclipse.apoapsis.ortserver.components.infrastructureservices.infrastructureServicesRoutes import org.eclipse.apoapsis.ortserver.components.pluginmanager.pluginManagerRoutes +import org.eclipse.apoapsis.ortserver.components.search.searchRoutes import org.eclipse.apoapsis.ortserver.components.secrets.secretsRoutes import org.eclipse.apoapsis.ortserver.compositions.secretsroutes.secretsCompositionRoutes import org.eclipse.apoapsis.ortserver.core.api.admin @@ -57,6 +58,7 @@ fun Application.configureRouting() { products() repositories() runs() + searchRoutes(get()) secretsCompositionRoutes(get(), get()) secretsRoutes(get(), get()) versions() diff --git a/dao/src/testFixtures/kotlin/Fixtures.kt b/dao/src/testFixtures/kotlin/Fixtures.kt index 7b0e674b6b..87c6eb1188 100644 --- a/dao/src/testFixtures/kotlin/Fixtures.kt +++ b/dao/src/testFixtures/kotlin/Fixtures.kt @@ -48,6 +48,7 @@ import org.eclipse.apoapsis.ortserver.model.EvaluatorJobConfiguration import org.eclipse.apoapsis.ortserver.model.JobConfigurations import org.eclipse.apoapsis.ortserver.model.Jobs import org.eclipse.apoapsis.ortserver.model.NotifierJobConfiguration +import org.eclipse.apoapsis.ortserver.model.OrtRun import org.eclipse.apoapsis.ortserver.model.PluginConfig import org.eclipse.apoapsis.ortserver.model.ReporterJobConfiguration import org.eclipse.apoapsis.ortserver.model.RepositoryType @@ -196,6 +197,33 @@ class Fixtures(private val db: Database) { return Jobs(analyzerJob, advisorJob, scannerJob, evaluatorJob, reporterJob, notifierJob) } + fun createAnalyzerRunWithPackages( + packages: Set, + repositoryId: Long = createRepository().id, + projects: Set = emptySet(), + shortestPaths: Map> = emptyMap() + ): OrtRun { + val ortRun = createOrtRun( + repositoryId = repositoryId, + revision = "revision", + jobConfigurations = JobConfigurations() + ) + + val analyzerJob = createAnalyzerJob( + ortRunId = ortRun.id, + configuration = AnalyzerJobConfiguration(), + ) + + createAnalyzerRun( + analyzerJobId = analyzerJob.id, + projects = projects, + packages = packages, + shortestDependencyPaths = shortestPaths + ) + + return ortRun + } + fun createIdentifier( identifier: Identifier = Identifier( "identifier_type", diff --git a/settings.gradle.kts b/settings.gradle.kts index 02fc16fe7c..b9f5f96d52 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,8 @@ include(":components:infrastructure-services:api-model") include(":components:infrastructure-services:backend") include(":components:plugin-manager:api-model") include(":components:plugin-manager:backend") +include(":components:search:api-model") +include(":components:search:backend") include(":components:secrets:api-model") include(":components:secrets:backend") include(":compositions:secrets-routes")