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:
mhoennig
2026-09-03 13:04:52 +02:00
co-authored by Claude Opus 5
parent 6ca67a6a37
commit 4304dd7c4b
3 changed files with 116 additions and 26 deletions
@@ -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"))