feat(server): die Builds-API trägt das Repository im Pfad — /api/repos/<name>/builds/…
Sitzung D, erste Hälfte (docs/plan/22-multi-repo.md): Jede Route der Builds-API gibt es jetzt zweimal — repo-benannt und unscoped. Die unscoped Form bleibt dauerhaft und meint das bediente Repository (`RepoRegistry.current`): Lesezeichen, die alte Oberfläche und die bereits nach Gitea geposteten Links kennen kein Repo-Segment, und eine CI, die ihre eigenen alten Links bricht, ist eine CI, der niemand traut. Der Controller hält kein `BuildResultRepository`, keinen `ArtifactStore` und keinen `RepoContext` mehr als Bohne, sondern löst je Anfrage über die Registry auf. Ein unbekannter Name beantwortet sich in der Form dieser API (404 mit `error`) über einen eigenen ExceptionHandler statt über Springs Problem-Detail. Dabei gefunden und mitbehoben: `cancel` griff über Repository-Grenzen. Der Executor bricht per Schlüssel instanzweit ab — eine Route, die ein Repository benennt, darf damit kein fremdes erreichen. Sie prüft jetzt zuerst, ob der Schlüssel in den Ergebnissen DIESES Repositories steht; ein wartender oder laufender Build hat dort immer sein PENDING/RUNNING. Drei neue Tests: die repo-benannte Route antwortet, ein unbekannter Name gibt 404 mit Meldung, und `cancel` erreicht keinen fremden Build. 496 Tests grün, ktlint sauber. Noch nicht scoped: die Oberfläche und die Artefakt-Routen — die kommen mit der zweiten Hälfte von Sitzung D. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6ca67a6a37
commit
4304dd7c4b
@@ -9,6 +9,7 @@ import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.build.RunningBuild
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
@@ -55,6 +56,9 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
@MockkBean
|
||||
lateinit var registry: RepoRegistry
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
private val successResult =
|
||||
@@ -80,8 +84,15 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo)
|
||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo, registry)
|
||||
every { repo.name } returns "test"
|
||||
every { repo.workingDir } returns tempDir
|
||||
every { repo.results } returns repository
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
// the unscoped routes mean the served repository; `/api/repos/test/…` names it
|
||||
every { registry.current() } returns repo
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
||||
every { repository.latestGreenFor(any()) } returns null
|
||||
}
|
||||
@@ -330,6 +341,8 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
||||
every { repository.history() } returns
|
||||
listOf(successResult.copy(artifactKey = "known-key"), successResult.copy(artifactKey = "unknown-key"))
|
||||
every { buildExecutor.cancel("known-key") } returns true
|
||||
every { buildExecutor.cancel("unknown-key") } returns false
|
||||
|
||||
@@ -342,6 +355,32 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("cancel does not reach a build of another repository") {
|
||||
// the key exists in the executor, but not in this repository's results
|
||||
every { repository.history() } returns listOf(successResult)
|
||||
every { buildExecutor.cancel(any()) } returns true
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/repos/test/builds/other-repo-key/cancel")
|
||||
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
||||
).andExpect(status().isNotFound)
|
||||
verify(exactly = 0) { buildExecutor.cancel(any()) }
|
||||
}
|
||||
|
||||
test("the repository-scoped routes answer for the named repository and 404 for an unknown name") {
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/repos/test/builds/latest"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].artifactKey").value("main-abc123-key"))
|
||||
mockMvc
|
||||
.perform(get("/api/repos/no-such-repo/builds/latest"))
|
||||
.andExpect(status().isNotFound)
|
||||
.andExpect(jsonPath("$.error").value("no repository named 'no-such-repo'"))
|
||||
}
|
||||
|
||||
test("a token in the query string is not accepted — the header is the only way") {
|
||||
mockMvc
|
||||
.perform(post("/api/builds/restart").param("branch", "main").param("token", "secret"))
|
||||
|
||||
Reference in New Issue
Block a user