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:
co-authored by
Claude Fable 5
parent
5a18505571
commit
fd0e730656
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user