feat(backend): Taiga-Proxy — vier schmale Endpunkte, Ziel-URL aus der Server-Konfiguration (D91, #trk.create.proxy)

API First: /taiga/auth, /taiga/projects, /taiga/userstories, /taiga/tasks
in der OpenAPI-Spec; TaigaClient/TaigaProperties in
de.werkbaum.integration.taiga. Die API-URL kommt aus
WERKBAUM_TAIGA_API_URL (nie Request-Parameter — SSRF), das Token je
Aufruf im Header X-Taiga-Token (Authorization muessen OpenAPI-Werkzeuge
als Header-Parameter ignorieren) und geht als Bearer hinaus; der Server
speichert nichts und loggt keine Request-Bodies. Taiga-4xx werden samt
_error_message durchgereicht, 5xx/Netz sind 502, unkonfiguriert 503 —
und GET /info meldet das Feature (taiga). Tests gegen aufgezeichnete
Antwortformen auf einem JDK-HttpServer-Stub (statt WireMock: keine neue
Test-Abhaengigkeit, dieselbe Zusicherung); Gegenprobe: ohne den
type-Durchreich faellt genau der benannte Test. check gruen, 93 %
Coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 16:33:35 +02:00
co-authored by Claude Fable 5
parent 5a18505571
commit fd0e730656
15 changed files with 1015 additions and 5 deletions
@@ -0,0 +1,173 @@
package de.werkbaum.api
import com.sun.net.httpserver.HttpServer
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.context.TestPropertySource
import org.springframework.test.web.servlet.client.RestTestClient
import java.net.InetSocketAddress
/**
* Der Taiga-Proxy Ende-zu-Ende: eigene API -> Client -> Stub-Instanz.
* Deckt die Verdrahtung ab, die der Unit-Test des Clients nicht sieht —
* generierte Signaturen, Header-Namen, Fehler-Mapping, das Feature-Flag in
* `GET /info`.
*/
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@TestPropertySource(
properties = [
// Eigene Datenbank: zweiter Spring-Kontext (siehe MasterPasswordDefaultTest).
"spring.datasource.url=jdbc:h2:mem:editor-taiga;" +
"DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1",
]
)
class TaigaApiTest {
@Autowired
private lateinit var client: RestTestClient
@Test
fun `info meldet das konfigurierte Taiga-Feature`() {
val result = client.get().uri("/api/v1/info").exchange().returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"taiga\":true"
}
@Test
fun `die Anmeldung liefert die schmale Sitzung in camelCase`() {
val result = client.post()
.uri("/api/v1/taiga/auth")
.header("Content-Type", "application/json")
.body("""{"username":"mi","password":"geheim"}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"authToken\":\"tok-abc123\""
result.responseBody!! shouldContain "\"userId\":42"
}
@Test
fun `abgelehnte Zugangsdaten kommen als 400 mit Taigas Fehlertext an`() {
stubStatus = 400
stubBody = TaigaClientTestData.AUTH_FAIL
try {
val result = client.post()
.uri("/api/v1/taiga/auth")
.header("Content-Type", "application/json")
.body("""{"username":"mi","password":"falsch"}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 400
result.responseBody!! shouldContain "does not matches"
} finally {
stubStatus = 200
stubBody = null
}
}
@Test
fun `die Projektliste nimmt das Token aus X-Taiga-Token`() {
val result = client.get()
.uri("/api/v1/taiga/projects?member=42")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"slug\":\"mi-intern\""
}
@Test
fun `eine angelegte Story antwortet mit 201 und ihrer Ref`() {
val result = client.post()
.uri("/api/v1/taiga/userstories")
.header("X-Taiga-Token", "tok-abc123")
.header("Content-Type", "application/json")
.body("""{"project":7,"subject":"Backend bauen"}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 201
result.responseBody!! shouldContain "\"ref\":123"
}
@Test
fun `eine angelegte Task antwortet mit 201 und ihrer Ref`() {
val result = client.post()
.uri("/api/v1/taiga/tasks")
.header("X-Taiga-Token", "tok-abc123")
.header("Content-Type", "application/json")
.body("""{"project":7,"subject":"API-Teil","userStory":1234}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 201
result.responseBody!! shouldContain "\"ref\":124"
}
companion object {
private lateinit var stub: HttpServer
@Volatile private var stubStatus = 200
@Volatile private var stubBody: String? = null
private fun startStub() {
if (::stub.isInitialized) return
stub = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
stub.createContext("/") { ex ->
val canned = stubBody ?: when (ex.requestURI.path) {
"/api/v1/auth" -> TaigaClientTestData.AUTH_OK
"/api/v1/projects" -> TaigaClientTestData.PROJECTS_OK
"/api/v1/userstories" -> TaigaClientTestData.STORY_OK
"/api/v1/tasks" -> TaigaClientTestData.TASK_OK
else -> "{}"
}
val status = if (stubBody != null) stubStatus
else if (ex.requestMethod == "POST" && ex.requestURI.path != "/api/v1/auth") 201
else 200
ex.requestBody.readBytes()
val bytes = canned.encodeToByteArray()
ex.responseHeaders.set("Content-Type", "application/json")
ex.sendResponseHeaders(status, bytes.size.toLong())
ex.responseBody.use { it.write(bytes) }
}
stub.start()
}
@JvmStatic
@AfterAll
fun stopStub() {
if (::stub.isInitialized) stub.stop(0)
}
/* Der Stub muss VOR dem Spring-Kontext laufen — die Property braucht
seinen Port. DynamicPropertySource läuft beim Kontextaufbau, also
genau rechtzeitig. */
@JvmStatic
@DynamicPropertySource
fun taigaUrl(registry: DynamicPropertyRegistry) {
startStub()
registry.add("werkbaum.taiga.api-url") { "http://127.0.0.1:${stub.address.port}/api/v1" }
}
}
}
/** Aufgezeichnete Antwortformen (dieselben Formen wie im Client-Unit-Test). */
object TaigaClientTestData {
const val AUTH_OK =
"""{"id": 42, "username": "mi", "full_name": "Michael", "auth_token": "tok-abc123"}"""
const val AUTH_FAIL =
"""{"_error_message": "Username or password does not matches user.", "_error_type": "taiga.base.exceptions.WrongArguments"}"""
const val PROJECTS_OK =
"""[{"id": 7, "name": "Intern", "slug": "mi-intern"}, {"id": 9, "name": "Kunde", "slug": "mi-kunde"}]"""
const val STORY_OK =
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7}"""
const val TASK_OK =
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
}
@@ -0,0 +1,51 @@
package de.werkbaum.api
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestPropertySource
import org.springframework.test.web.servlet.client.RestTestClient
/**
* Ohne konfigurierte Taiga-Instanz ist der Proxy **aus**, nicht kaputt:
* `GET /info` meldet `taiga: false` (der Editor zeigt die Aktionen dann gar
* nicht erst), und ein Aufruf trotzdem antwortet mit 503 statt eines
* nichtssagenden Fehlers (D91).
*/
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@TestPropertySource(
properties = [
// Eigene Datenbank: zweiter Spring-Kontext (siehe MasterPasswordDefaultTest).
"spring.datasource.url=jdbc:h2:mem:editor-taiga-off;" +
"DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1",
]
)
class TaigaDisabledTest {
@Autowired
private lateinit var client: RestTestClient
@Test
fun `info meldet das fehlende Taiga-Feature`() {
val result = client.get().uri("/api/v1/info").exchange().returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"taiga\":false"
}
@Test
fun `ein Aufruf ohne Konfiguration antwortet mit 503`() {
val result = client.post()
.uri("/api/v1/taiga/auth")
.header("Content-Type", "application/json")
.body("""{"username":"mi","password":"geheim"}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 503
}
}
@@ -0,0 +1,177 @@
package de.werkbaum.integration.taiga
import com.sun.net.httpserver.HttpServer
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.net.InetSocketAddress
/**
* Der Taiga-Client gegen **aufgezeichnete Antworten** (Stub-Server im Test),
* nie gegen die Live-Instanz (backend/CLAUDE.md). Die Antwortformen stammen
* aus der Vermessung der Zielinstanz (D91-Nachtrag 1) bzw. der Taiga-API.
*/
class TaigaClientTest {
private fun client() = TaigaClient(TaigaProperties(apiUrl = "http://127.0.0.1:$port/api/v1"))
@BeforeEach
fun reset() {
recorded = null
responseStatus = 200
responseBody = "{}"
}
@Test
fun `login reicht Typ, Benutzername und Passwort durch und liefert die schmale Sitzung`() {
responseBody = AUTH_OK
val session = client().login("mi", "geheim")
session.authToken shouldBe "tok-abc123"
session.userId shouldBe 42L
session.username shouldBe "mi"
session.fullName shouldBe "Michael"
val req = recorded!!
req.path shouldBe "/api/v1/auth"
req.body shouldContain "\"type\":\"ldap\""
req.body shouldContain "\"username\":\"mi\""
req.body shouldContain "\"password\":\"geheim\""
}
@Test
fun `abgelehnte Zugangsdaten (Taiga 400) werden mit Status und Fehlertext durchgereicht`() {
responseStatus = 400
responseBody = AUTH_FAIL
val ex = shouldThrow<TaigaUpstreamException> { client().login("mi", "falsch") }
ex.status shouldBe 400
ex.message shouldContain "does not matches"
}
@Test
fun `projects sendet Bearer-Token, member-Filter und schaltet die Paginierung ab`() {
responseBody = PROJECTS_OK
val projects = client().projects("tok-abc123", 42)
projects.map { it.slug } shouldBe listOf("mi-intern", "mi-kunde")
projects[0].id shouldBe 7L
projects[0].name shouldBe "Intern"
val req = recorded!!
req.path shouldBe "/api/v1/projects"
req.query shouldBe "member=42&order_by=user_order"
req.auth shouldBe "Bearer tok-abc123"
req.noPagination shouldBe "1"
}
@Test
fun `createStory postet project und subject und liefert die Ref`() {
responseStatus = 201
responseBody = STORY_OK
val ticket = client().createStory("tok-abc123", 7, "Backend bauen")
ticket.ref shouldBe 123L
ticket.id shouldBe 1234L
ticket.subject shouldBe "Backend bauen"
val req = recorded!!
req.path shouldBe "/api/v1/userstories"
req.auth shouldBe "Bearer tok-abc123"
req.body shouldContain "\"project\":7"
req.body shouldContain "\"subject\":\"Backend bauen\""
}
@Test
fun `createTask haengt die Task per user_story an ihre Story`() {
responseStatus = 201
responseBody = TASK_OK
val ticket = client().createTask("tok-abc123", 7, "API-Teil", 1234)
ticket.ref shouldBe 124L
recorded!!.path shouldBe "/api/v1/tasks"
recorded!!.body shouldContain "\"user_story\":1234"
}
@Test
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
shouldThrow<TaigaNotConfiguredException> { bare.login("mi", "geheim") }
}
@Test
fun `eine nicht erreichbare Instanz ist ein eigener, benannter Fehler`() {
val dead = TaigaClient(TaigaProperties(apiUrl = "http://127.0.0.1:$deadPort/api/v1"))
shouldThrow<TaigaUnavailableException> { dead.login("mi", "geheim") }
}
@Test
fun `eine Antwort ohne die erwarteten Felder scheitert laut statt still`() {
responseBody = """{"unexpected": true}"""
val ex = shouldThrow<TaigaUnavailableException> { client().login("mi", "geheim") }
ex.message shouldContain "auth_token"
}
data class Recorded(
val path: String,
val query: String?,
val auth: String?,
val noPagination: String?,
val body: String,
)
companion object {
private lateinit var server: HttpServer
private var port = 0
private var deadPort = 0
@Volatile var recorded: Recorded? = null
@Volatile var responseStatus = 200
@Volatile var responseBody = "{}"
/* Aufgezeichnete Antwortformen (gekuerzt auf die gebrauchten Felder
plus typisches Beiwerk, damit der Client Unbekanntes ignoriert). */
const val AUTH_OK =
"""{"id": 42, "username": "mi", "full_name": "Michael", "email": "mi@example.org", "auth_token": "tok-abc123", "roles": ["Product Owner"]}"""
const val AUTH_FAIL =
"""{"_error_message": "Username or password does not matches user.", "_error_type": "taiga.base.exceptions.WrongArguments"}"""
const val PROJECTS_OK =
"""[{"id": 7, "name": "Intern", "slug": "mi-intern", "description": "x"}, {"id": 9, "name": "Kunde", "slug": "mi-kunde", "description": "y"}]"""
const val STORY_OK =
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7, "status": 1}"""
const val TASK_OK =
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
@JvmStatic
@BeforeAll
fun startStub() {
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { ex ->
recorded = Recorded(
path = ex.requestURI.path,
query = ex.requestURI.query,
auth = ex.requestHeaders.getFirst("Authorization"),
noPagination = ex.requestHeaders.getFirst("x-disable-pagination"),
body = ex.requestBody.readBytes().decodeToString(),
)
val bytes = responseBody.encodeToByteArray()
ex.responseHeaders.set("Content-Type", "application/json")
ex.sendResponseHeaders(responseStatus, bytes.size.toLong())
ex.responseBody.use { it.write(bytes) }
}
server.start()
port = server.address.port
/* Ein Port, hinter dem sicher nichts lauscht: kurz binden, wieder
freigeben. */
val probe = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
deadPort = probe.address.port
probe.stop(0)
}
@JvmStatic
@AfterAll
fun stopStub() = server.stop(0)
}
}