implemented 07-server-mode.md: added server profile with REST API for builds, artifacts, live logs, and control tokens; lifecycle management for watcher; controller and service tests
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
@WebMvcTest(ArtifactFileController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class ArtifactFileControllerTest : FunSpec() {
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var artifactStore: ArtifactStore
|
||||
|
||||
private val artifactDir: Path = Files.createTempDirectory("gittally-artifact-serve-test")
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(artifactStore)
|
||||
every { artifactStore.artifactDir(any()) } returns null
|
||||
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
||||
}
|
||||
|
||||
test("serves an html artifact with no-cache headers") {
|
||||
Files.writeString(artifactDir.resolve("index.html"), "<html>report</html>")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/index.html"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(header().string("Content-Type", "text/html"))
|
||||
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
|
||||
.andExpect(content().string("<html>report</html>"))
|
||||
}
|
||||
|
||||
test("serves a log file as UTF-8 text with no-cache headers") {
|
||||
Files.writeString(artifactDir.resolve("build.log"), "line one")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/build.log"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(header().string("Content-Type", "text/plain;charset=UTF-8"))
|
||||
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
|
||||
}
|
||||
|
||||
test("serves nested report files without no-cache headers") {
|
||||
val nested = Files.createDirectories(artifactDir.resolve("reports/tests"))
|
||||
Files.writeString(nested.resolve("summary.css"), "body {}")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/reports/tests/summary.css"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(header().doesNotExist("Cache-Control"))
|
||||
}
|
||||
|
||||
test("unknown artifact key answers 404") {
|
||||
mockMvc
|
||||
.perform(get("/artifacts/unknown-key/index.html"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("missing file answers 404") {
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/no-such-file.html"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("path traversal out of the artifact directory is rejected") {
|
||||
val outside = Files.writeString(artifactDir.parent.resolve("outside.txt"), "secret")
|
||||
try {
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/../outside.txt"))
|
||||
.andExpect(status().is4xxClientError)
|
||||
} finally {
|
||||
Files.deleteIfExists(outside)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import de.hoennig.gittally.build.BuildExecutor
|
||||
import de.hoennig.gittally.build.BuildResult
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import de.hoennig.gittally.build.RunningBuild
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.verify
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class BuildsApiControllerTest : FunSpec() {
|
||||
private val tempDir: Path = Files.createTempDirectory("gittally-server-test")
|
||||
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var repository: BuildResultRepository
|
||||
|
||||
@MockkBean
|
||||
lateinit var buildExecutor: BuildExecutor
|
||||
|
||||
@MockkBean
|
||||
lateinit var artifactStore: ArtifactStore
|
||||
|
||||
@MockkBean
|
||||
lateinit var controlTokens: ControlTokenService
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
private val successResult =
|
||||
BuildResult(
|
||||
branch = "main",
|
||||
commit = "0123456789abcdef0123456789abcdef01234567",
|
||||
status = BuildStatus.SUCCESS,
|
||||
startedAt = startedAt,
|
||||
duration = Duration.ofSeconds(83),
|
||||
artifactKey = "main-abc123-key",
|
||||
)
|
||||
|
||||
private fun runningBuild(liveLogFile: Path) =
|
||||
RunningBuild(
|
||||
branch = "main",
|
||||
commit = successResult.commit,
|
||||
artifactKey = "main-abc123-running",
|
||||
startedAt = startedAt,
|
||||
stagingDir = liveLogFile.parent,
|
||||
liveLogFile = liveLogFile,
|
||||
)
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens)
|
||||
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
||||
}
|
||||
|
||||
test("latest answers one entry per branch with lowercase status and duration in seconds") {
|
||||
every { repository.latestPerBranch() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/latest"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].branch").value("main"))
|
||||
.andExpect(jsonPath("$[0].status").value("success"))
|
||||
.andExpect(jsonPath("$[0].durationSeconds").value(83))
|
||||
.andExpect(jsonPath("$[0].artifactKey").value("main-abc123-key"))
|
||||
}
|
||||
|
||||
test("history answers all builds") {
|
||||
every { repository.history() } returns listOf(successResult, successResult.copy(status = BuildStatus.FAILED))
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/history"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.length()").value(2))
|
||||
.andExpect(jsonPath("$[1].status").value("failed"))
|
||||
}
|
||||
|
||||
test("current answers the running builds with live status and log size") {
|
||||
val liveLogFile = Files.writeString(tempDir.resolve("build.log"), "12345")
|
||||
val build = runningBuild(liveLogFile)
|
||||
every { buildExecutor.currentBuilds() } returns listOf(build)
|
||||
every { repository.history() } returns
|
||||
listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = build.artifactKey))
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/current"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].artifactKey").value(build.artifactKey))
|
||||
.andExpect(jsonPath("$[0].status").value("running"))
|
||||
.andExpect(jsonPath("$[0].logSize").value(5))
|
||||
}
|
||||
|
||||
test("current log answers the tail from the requested offset") {
|
||||
val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world")
|
||||
val build = runningBuild(liveLogFile)
|
||||
every { buildExecutor.currentBuilds() } returns listOf(build)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/current/${build.artifactKey}/log").param("offset", "6"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.content").value("world"))
|
||||
.andExpect(jsonPath("$.nextOffset").value(11))
|
||||
}
|
||||
|
||||
test("current log of an unknown artifact key answers 404") {
|
||||
every { buildExecutor.currentBuilds() } returns emptyList()
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/current/no-such-key/log"))
|
||||
.andExpect(status().isNotFound)
|
||||
.andExpect(jsonPath("$.error").exists())
|
||||
}
|
||||
|
||||
test("restart enqueues the branch's last recorded commit") {
|
||||
val liveLogFile = tempDir.resolve("restart.log")
|
||||
every { repository.latestFor("main") } returns successResult
|
||||
every { buildExecutor.startBuild("main", successResult.commit) } returns runningBuild(liveLogFile)
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/main/restart").header(BuildsApiController.TOKEN_HEADER, "secret"))
|
||||
.andExpect(status().isAccepted)
|
||||
.andExpect(jsonPath("$.status").value("pending"))
|
||||
.andExpect(jsonPath("$.artifactKey").value("main-abc123-running"))
|
||||
|
||||
verify { buildExecutor.startBuild("main", successResult.commit) }
|
||||
}
|
||||
|
||||
test("restart of a branch without recorded builds answers 404") {
|
||||
every { repository.latestFor("gone") } returns null
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/gone/restart").param("token", "secret"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("restart with a wrong token answers 403 and does not build") {
|
||||
mockMvc
|
||||
.perform(post("/api/builds/main/restart").header(BuildsApiController.TOKEN_HEADER, "wrong"))
|
||||
.andExpect(status().isForbidden)
|
||||
|
||||
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) }
|
||||
}
|
||||
|
||||
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
||||
every { buildExecutor.cancel("known-key") } returns true
|
||||
every { buildExecutor.cancel("unknown-key") } returns false
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/known-key/cancel").param("token", "secret"))
|
||||
.andExpect(status().isAccepted)
|
||||
.andExpect(jsonPath("$.cancelled").value("known-key"))
|
||||
mockMvc
|
||||
.perform(post("/api/builds/unknown-key/cancel").param("token", "secret"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("cancel without token answers 403") {
|
||||
mockMvc
|
||||
.perform(post("/api/builds/some-key/cancel"))
|
||||
.andExpect(status().isForbidden)
|
||||
|
||||
verify(exactly = 0) { buildExecutor.cancel(any()) }
|
||||
}
|
||||
|
||||
test("delete removes the result and prunes its artifacts") {
|
||||
every { repository.delete("old-key") } returns true
|
||||
every { repository.history() } returns listOf(successResult)
|
||||
every { artifactStore.prune(any()) } returns listOf("old-key")
|
||||
|
||||
mockMvc
|
||||
.perform(delete("/api/builds/old-key").header(BuildsApiController.TOKEN_HEADER, "secret"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.deleted").value("old-key"))
|
||||
|
||||
verify { artifactStore.prune(listOf(successResult)) }
|
||||
}
|
||||
|
||||
test("delete of an unknown artifact key answers 404 without pruning") {
|
||||
every { repository.delete("unknown-key") } returns false
|
||||
|
||||
mockMvc
|
||||
.perform(delete("/api/builds/unknown-key").param("token", "secret"))
|
||||
.andExpect(status().isNotFound)
|
||||
|
||||
verify(exactly = 0) { artifactStore.prune(any()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldMatch
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
class ControlTokenServiceTest : FunSpec() {
|
||||
private fun newTokenFile(): Path = Files.createTempDirectory("gittally-token-test").resolve("control-token")
|
||||
|
||||
init {
|
||||
test("generates a hex token once and persists it") {
|
||||
val tokenFile = newTokenFile()
|
||||
val service = ControlTokenService(tokenFile)
|
||||
|
||||
val token = service.token()
|
||||
|
||||
token shouldMatch Regex("[0-9a-f]{48}")
|
||||
Files.readString(tokenFile).trim() shouldBe token
|
||||
service.token() shouldBe token
|
||||
}
|
||||
|
||||
test("reuses an operator-provided token file") {
|
||||
val tokenFile = newTokenFile()
|
||||
Files.createDirectories(tokenFile.parent)
|
||||
Files.writeString(tokenFile, "my-own-token\n")
|
||||
|
||||
ControlTokenService(tokenFile).token() shouldBe "my-own-token"
|
||||
}
|
||||
|
||||
test("matches only the exact token") {
|
||||
val service = ControlTokenService(newTokenFile())
|
||||
val token = service.token()
|
||||
|
||||
service.matches(token) shouldBe true
|
||||
service.matches(token + "x") shouldBe false
|
||||
service.matches("") shouldBe false
|
||||
service.matches(null) shouldBe false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.build.BuildResult
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import de.hoennig.gittally.gitea.GiteaClient
|
||||
import de.hoennig.gittally.gitea.GiteaStatusResult
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.Instant
|
||||
|
||||
@WebMvcTest(StatusApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class StatusApiControllerTest : FunSpec() {
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var repository: BuildResultRepository
|
||||
|
||||
@MockkBean
|
||||
lateinit var giteaClient: GiteaClient
|
||||
|
||||
private val commit = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
private val localResult =
|
||||
BuildResult(
|
||||
branch = "main",
|
||||
commit = commit,
|
||||
status = BuildStatus.FAILED,
|
||||
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
|
||||
duration = null,
|
||||
artifactKey = "main-abc123-key",
|
||||
)
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(repository, giteaClient)
|
||||
every { repository.history() } returns emptyList()
|
||||
}
|
||||
|
||||
test("prefers the Gitea status over the local status") {
|
||||
every { repository.history() } returns listOf(localResult)
|
||||
every { giteaClient.readStatus(commit, any()) } returns GiteaStatusResult.Found(BuildStatus.SUCCESS)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/status/$commit"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.giteaStatus").value("success"))
|
||||
.andExpect(jsonPath("$.localStatus").value("failed"))
|
||||
.andExpect(jsonPath("$.giteaError").doesNotExist())
|
||||
}
|
||||
|
||||
test("falls back to the local status when Gitea is disabled") {
|
||||
every { repository.history() } returns listOf(localResult)
|
||||
every { giteaClient.readStatus(commit, any()) } returns GiteaStatusResult.Disabled
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/status/$commit"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("failed"))
|
||||
.andExpect(jsonPath("$.giteaStatus").doesNotExist())
|
||||
}
|
||||
|
||||
test("resolves an abbreviated commit hash against the local history") {
|
||||
every { repository.history() } returns listOf(localResult)
|
||||
every { giteaClient.readStatus(any(), any()) } returns GiteaStatusResult.None
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/status/${commit.take(8)}"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("failed"))
|
||||
}
|
||||
|
||||
test("Gitea failure without a local build answers 200 with an explicit unknown status") {
|
||||
every { giteaClient.readStatus(commit, any()) } returns GiteaStatusResult.Error("Gitea status request failed")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/status/$commit"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("unknown"))
|
||||
.andExpect(jsonPath("$.giteaError").value("Gitea status request failed"))
|
||||
}
|
||||
|
||||
test("rejects malformed commit hashes") {
|
||||
mockMvc
|
||||
.perform(get("/api/status/not-a-commit"))
|
||||
.andExpect(status().isBadRequest)
|
||||
mockMvc
|
||||
.perform(get("/api/status/abc123"))
|
||||
.andExpect(status().isBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.watcher.Watcher
|
||||
import de.hoennig.gittally.watcher.WatcherState
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.Instant
|
||||
|
||||
@WebMvcTest(WatcherApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class WatcherApiControllerTest : FunSpec() {
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var watcher: Watcher
|
||||
|
||||
init {
|
||||
beforeEach { clearMocks(watcher) }
|
||||
|
||||
test("watcher health answers last poll and errors") {
|
||||
every { watcher.state() } returns
|
||||
WatcherState(
|
||||
running = true,
|
||||
lastPollAt = Instant.parse("2026-07-07T10:00:00Z"),
|
||||
lastFetchError = "origin unreachable",
|
||||
lastPollError = null,
|
||||
queuedBranches = listOf("main"),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/watcher"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.running").value(true))
|
||||
.andExpect(jsonPath("$.lastPollAt").value("2026-07-07T10:00:00Z"))
|
||||
.andExpect(jsonPath("$.lastFetchError").value("origin unreachable"))
|
||||
.andExpect(jsonPath("$.lastPollError").doesNotExist())
|
||||
.andExpect(jsonPath("$.queuedBranches[0]").value("main"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user