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
+16
-2
@@ -17,6 +17,18 @@ geplante Owner-Passwort binden (`#col.live.owner` im Plan) — Endpunkte so
|
|||||||
schneiden, dass die Berechtigungsprüfung dazukommen kann, ohne die Signatur
|
schneiden, dass die Berechtigungsprüfung dazukommen kann, ohne die Signatur
|
||||||
zu brechen.
|
zu brechen.
|
||||||
|
|
||||||
|
**Taiga-Proxy (D91):** schmale, benannte Endpunkte unter `/api/v1/taiga/*`
|
||||||
|
(auth, projects, userstories, tasks) in `de.werkbaum.integration.taiga`
|
||||||
|
(`TaigaClient` + `TaigaProperties`), Controller in `api`. Die Basis-URL der
|
||||||
|
Taiga-**API** ist Server-Konfiguration (`werkbaum.taiga.api-url` bzw.
|
||||||
|
`WERKBAUM_TAIGA_API_URL`), **nie** Request-Parameter — die SSRF-Falle
|
||||||
|
naiver Proxies. Das Token kommt je Aufruf im Header `X-Taiga-Token`
|
||||||
|
(eigener Name: `Authorization` müssen OpenAPI-Werkzeuge als Header-Parameter
|
||||||
|
ignorieren, und er kollidierte mit dem Master-Passwort) und geht als
|
||||||
|
`Authorization: Bearer …` hinaus; der Server speichert nichts und **loggt
|
||||||
|
keine Request-Bodies** — der Auth-Endpunkt sieht das Passwort nur im
|
||||||
|
Durchflug. Unkonfiguriert: 503, und `GET /info` meldet `taiga: false`.
|
||||||
|
|
||||||
## Konventionen
|
## Konventionen
|
||||||
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
|
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
|
||||||
- Paketwurzel `de.werkbaum`; Schichten: `api` (Controller), `domain`,
|
- Paketwurzel `de.werkbaum`; Schichten: `api` (Controller), `domain`,
|
||||||
@@ -28,8 +40,10 @@ zu brechen.
|
|||||||
- Tests mit JUnit 5 als Runner + **Kotest-Assertions** (`shouldBe`,
|
- Tests mit JUnit 5 als Runner + **Kotest-Assertions** (`shouldBe`,
|
||||||
`shouldContain`, `shouldThrow`) und MockK; Verhalten per Cucumber gegen die
|
`shouldContain`, `shouldThrow`) und MockK; Verhalten per Cucumber gegen die
|
||||||
laufende Anwendung (`RestTestClient`, nicht TestRestTemplate — das ist in
|
laufende Anwendung (`RestTestClient`, nicht TestRestTemplate — das ist in
|
||||||
Boot 4 Auslaufmodell). Taiga-Client gegen aufgezeichnete Antworten
|
Boot 4 Auslaufmodell). Taiga-Client gegen aufgezeichnete Antworten,
|
||||||
(WireMock), nie gegen Live-Instanzen.
|
nie gegen Live-Instanzen — umgesetzt mit dem JDK-eigenen `HttpServer`
|
||||||
|
als Stub (`TaigaClientTest`/`TaigaApiTest`) statt WireMock: keine neue
|
||||||
|
Test-Abhängigkeit, dieselbe Zusicherung.
|
||||||
- Konfiguration über `application.yaml` + Umgebungsvariablen;
|
- Konfiguration über `application.yaml` + Umgebungsvariablen;
|
||||||
keine Zugangsdaten im Repository.
|
keine Zugangsdaten im Repository.
|
||||||
- Keine neuen **Laufzeit**-Abhängigkeiten ohne Rückfrage (Wurzel-CLAUDE.md);
|
- Keine neuen **Laufzeit**-Abhängigkeiten ohne Rückfrage (Wurzel-CLAUDE.md);
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ class CorsConfiguration(private val properties: CorsProperties) {
|
|||||||
val config = CorsConfiguration().apply {
|
val config = CorsConfiguration().apply {
|
||||||
allowedOriginPatterns = properties.allowedOrigins
|
allowedOriginPatterns = properties.allowedOrigins
|
||||||
allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||||
// Authorization fuer das Master-Passwort, Content-Type fuer JSON.
|
// Authorization fuer das Master-Passwort, Content-Type fuer JSON,
|
||||||
allowedHeaders = listOf("Authorization", "Content-Type")
|
// X-Taiga-Token fuer den Taiga-Proxy (D91).
|
||||||
|
allowedHeaders = listOf("Authorization", "Content-Type", "X-Taiga-Token")
|
||||||
// Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet.
|
// Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet.
|
||||||
allowCredentials = false
|
allowCredentials = false
|
||||||
maxAge = 3600
|
maxAge = 3600
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import de.werkbaum.domain.ChangeFeed
|
|||||||
import de.werkbaum.domain.ContentPatch
|
import de.werkbaum.domain.ContentPatch
|
||||||
import de.werkbaum.domain.Document
|
import de.werkbaum.domain.Document
|
||||||
import de.werkbaum.domain.DocumentHistoryEntry
|
import de.werkbaum.domain.DocumentHistoryEntry
|
||||||
|
import de.werkbaum.integration.taiga.TaigaProperties
|
||||||
import de.werkbaum.service.DocumentService
|
import de.werkbaum.service.DocumentService
|
||||||
import de.werkbaum.service.LiveEditingService
|
import de.werkbaum.service.LiveEditingService
|
||||||
import org.springframework.boot.info.BuildProperties
|
import org.springframework.boot.info.BuildProperties
|
||||||
@@ -45,6 +46,7 @@ class DocumentsController(
|
|||||||
* nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert.
|
* nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert.
|
||||||
*/
|
*/
|
||||||
private val buildProperties: BuildProperties? = null,
|
private val buildProperties: BuildProperties? = null,
|
||||||
|
private val taigaProperties: TaigaProperties,
|
||||||
) : DocumentsApi {
|
) : DocumentsApi {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +62,9 @@ class DocumentsController(
|
|||||||
name = buildProperties?.name ?: "werkbaum-backend",
|
name = buildProperties?.name ?: "werkbaum-backend",
|
||||||
version = buildProperties?.version ?: "unbekannt",
|
version = buildProperties?.version ?: "unbekannt",
|
||||||
builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC),
|
builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC),
|
||||||
|
// Feature-Meldung des Taiga-Proxys (D91): Der Editor zeigt die
|
||||||
|
// Ticket-Aktionen nur, wo ein konfiguriertes Backend antwortet.
|
||||||
|
taiga = taigaProperties.configured,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import de.werkbaum.service.DocumentDeletedException
|
|||||||
import de.werkbaum.service.DocumentNotFoundException
|
import de.werkbaum.service.DocumentNotFoundException
|
||||||
import de.werkbaum.service.InvalidPatchException
|
import de.werkbaum.service.InvalidPatchException
|
||||||
import de.werkbaum.service.StalePatchSequenceException
|
import de.werkbaum.service.StalePatchSequenceException
|
||||||
|
import de.werkbaum.integration.taiga.TaigaNotConfiguredException
|
||||||
|
import de.werkbaum.integration.taiga.TaigaUnavailableException
|
||||||
|
import de.werkbaum.integration.taiga.TaigaUpstreamException
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ProblemDetail
|
import org.springframework.http.ProblemDetail
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@@ -75,4 +78,33 @@ class GlobalExceptionHandler {
|
|||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
ex.message ?: "Ungültige Anfrage",
|
ex.message ?: "Ungültige Anfrage",
|
||||||
).apply { title = "Ungültige Anfrage" }
|
).apply { title = "Ungültige Anfrage" }
|
||||||
|
|
||||||
|
/* ---- Taiga-Proxy (D91) ---- */
|
||||||
|
|
||||||
|
/** Kein Ziel konfiguriert: 503 — der Editor fragt vorher `GET /info`. */
|
||||||
|
@ExceptionHandler(TaigaNotConfiguredException::class)
|
||||||
|
fun handleTaigaNotConfigured(ex: TaigaNotConfiguredException): ProblemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
ex.message ?: "Taiga nicht konfiguriert",
|
||||||
|
).apply { title = "Taiga nicht konfiguriert" }
|
||||||
|
|
||||||
|
@ExceptionHandler(TaigaUnavailableException::class)
|
||||||
|
fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
ex.message ?: "Taiga nicht erreichbar",
|
||||||
|
).apply { title = "Taiga nicht erreichbar" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taiga hat mit einem Fehler geantwortet: 4xx wird durchgereicht — Taiga
|
||||||
|
* meldet z. B. falsche Zugangsdaten als 400, und der Text hilft dem
|
||||||
|
* Benutzer —, ein fremder 5xx wird zu 502.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(TaigaUpstreamException::class)
|
||||||
|
fun handleTaigaUpstream(ex: TaigaUpstreamException): ProblemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(
|
||||||
|
if (ex.status in 400..499) HttpStatus.valueOf(ex.status) else HttpStatus.BAD_GATEWAY,
|
||||||
|
ex.message ?: "Taiga-Fehler",
|
||||||
|
).apply { title = "Taiga-Fehler" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package de.werkbaum.api
|
||||||
|
|
||||||
|
import de.werkbaum.generated.api.TaigaApi
|
||||||
|
import de.werkbaum.generated.model.TaigaAuthRequest
|
||||||
|
import de.werkbaum.generated.model.TaigaProject
|
||||||
|
import de.werkbaum.generated.model.TaigaSession
|
||||||
|
import de.werkbaum.generated.model.TaigaStoryCreateRequest
|
||||||
|
import de.werkbaum.generated.model.TaigaTaskCreateRequest
|
||||||
|
import de.werkbaum.generated.model.TaigaTicket
|
||||||
|
import de.werkbaum.integration.taiga.TaigaClient
|
||||||
|
import de.werkbaum.integration.taiga.TaigaTicketData
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Taiga-Proxy (D91) — implementiert das generierte Interface, wie der
|
||||||
|
* DocumentsController seines: Ändert sich die Spezifikation, schlägt hier
|
||||||
|
* der Compile fehl (API First).
|
||||||
|
*
|
||||||
|
* Hier gibt es nur die Abbildung API ↔ Client; alles Inhaltliche —
|
||||||
|
* Ziel-URL aus der Server-Konfiguration, Fehlerklassen, das schmale
|
||||||
|
* Antwortformat — liegt im [TaigaClient]. Kein Logging von Request-Bodies:
|
||||||
|
* Der Auth-Endpunkt sieht das Passwort nur im Durchflug.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1")
|
||||||
|
class TaigaController(private val client: TaigaClient) : TaigaApi {
|
||||||
|
|
||||||
|
override fun taigaLogin(taigaAuthRequest: TaigaAuthRequest): ResponseEntity<TaigaSession> {
|
||||||
|
val session = client.login(taigaAuthRequest.username, taigaAuthRequest.password)
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
TaigaSession(
|
||||||
|
authToken = session.authToken,
|
||||||
|
userId = session.userId,
|
||||||
|
username = session.username,
|
||||||
|
fullName = session.fullName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun taigaProjects(xTaigaToken: String, member: Long): ResponseEntity<List<TaigaProject>> =
|
||||||
|
ResponseEntity.ok(
|
||||||
|
client.projects(xTaigaToken, member).map {
|
||||||
|
TaigaProject(id = it.id, name = it.name, slug = it.slug)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun taigaCreateStory(
|
||||||
|
xTaigaToken: String,
|
||||||
|
taigaStoryCreateRequest: TaigaStoryCreateRequest,
|
||||||
|
): ResponseEntity<TaigaTicket> {
|
||||||
|
val ticket = client.createStory(
|
||||||
|
token = xTaigaToken,
|
||||||
|
project = taigaStoryCreateRequest.project,
|
||||||
|
subject = taigaStoryCreateRequest.subject,
|
||||||
|
)
|
||||||
|
return created(ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun taigaCreateTask(
|
||||||
|
xTaigaToken: String,
|
||||||
|
taigaTaskCreateRequest: TaigaTaskCreateRequest,
|
||||||
|
): ResponseEntity<TaigaTicket> {
|
||||||
|
val ticket = client.createTask(
|
||||||
|
token = xTaigaToken,
|
||||||
|
project = taigaTaskCreateRequest.project,
|
||||||
|
subject = taigaTaskCreateRequest.subject,
|
||||||
|
userStory = taigaTaskCreateRequest.userStory,
|
||||||
|
)
|
||||||
|
return created(ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
|
||||||
|
ResponseEntity.status(HttpStatus.CREATED).body(
|
||||||
|
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package de.werkbaum.integration.taiga
|
||||||
|
|
||||||
|
import org.springframework.core.ParameterizedTypeReference
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.client.JdkClientHttpRequestFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.web.client.ResourceAccessException
|
||||||
|
import org.springframework.web.client.RestClient
|
||||||
|
import org.springframework.web.client.RestClientResponseException
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
|
||||||
|
class TaigaNotConfiguredException :
|
||||||
|
RuntimeException("Keine Taiga-Instanz konfiguriert (werkbaum.taiga.api-url)")
|
||||||
|
|
||||||
|
/** Taiga nicht erreichbar oder mit unbrauchbarer Antwort (502). */
|
||||||
|
class TaigaUnavailableException(message: String, cause: Throwable? = null) :
|
||||||
|
RuntimeException(message, cause)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taiga hat mit einem Fehlerstatus geantwortet. 4xx wird durchgereicht
|
||||||
|
* (Taiga meldet z. B. falsche Zugangsdaten als 400), 5xx wird zu 502 —
|
||||||
|
* ein fremder Serverfehler ist aus Client-Sicht „Upstream kaputt“.
|
||||||
|
*/
|
||||||
|
class TaigaUpstreamException(val status: Int, message: String) : RuntimeException(message)
|
||||||
|
|
||||||
|
data class TaigaSessionData(
|
||||||
|
val authToken: String,
|
||||||
|
val userId: Long,
|
||||||
|
val username: String,
|
||||||
|
val fullName: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TaigaProjectData(val id: Long, val name: String, val slug: String)
|
||||||
|
|
||||||
|
data class TaigaTicketData(val id: Long, val ref: Long, val subject: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
|
||||||
|
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
|
||||||
|
*
|
||||||
|
* Das Token kommt je Aufruf vom Browser herein und geht als
|
||||||
|
* `Authorization: Bearer …` hinaus; der Server **speichert nichts** und
|
||||||
|
* **loggt keine Request-Bodies** (der Auth-Endpunkt sieht das Passwort nur
|
||||||
|
* im Durchflug). Die Antworten werden als Maps gelesen und auf die schmalen
|
||||||
|
* Datenklassen abgebildet — so hängt nichts an Taigas übrigen Feldern.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class TaigaClient(private val properties: TaigaProperties) {
|
||||||
|
|
||||||
|
private val rest: RestClient = RestClient.builder()
|
||||||
|
.requestFactory(
|
||||||
|
JdkClientHttpRequestFactory(
|
||||||
|
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
|
||||||
|
).apply { setReadTimeout(Duration.ofSeconds(20)) }
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun login(username: String, password: String): TaigaSessionData {
|
||||||
|
val map = exchange {
|
||||||
|
rest.post().uri(url("/auth"))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(mapOf("type" to properties.authType, "username" to username, "password" to password))
|
||||||
|
.retrieve().body(MAP)
|
||||||
|
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga (/auth)")
|
||||||
|
return TaigaSessionData(
|
||||||
|
authToken = str(map, "auth_token"),
|
||||||
|
userId = num(map, "id"),
|
||||||
|
username = str(map, "username"),
|
||||||
|
fullName = map["full_name"] as? String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun projects(token: String, member: Long): List<TaigaProjectData> {
|
||||||
|
val list = exchange {
|
||||||
|
rest.get().uri(url("/projects?member=$member&order_by=user_order"))
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
// Taiga paginiert sonst bei 30 — die Projektliste eines
|
||||||
|
// Nutzers soll vollständig sein.
|
||||||
|
.header("x-disable-pagination", "1")
|
||||||
|
.retrieve().body(LIST)
|
||||||
|
} ?: emptyList()
|
||||||
|
return list.map { TaigaProjectData(num(it, "id"), str(it, "name"), str(it, "slug")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createStory(token: String, project: Long, subject: String): TaigaTicketData =
|
||||||
|
create(token, "/userstories", mapOf("project" to project, "subject" to subject))
|
||||||
|
|
||||||
|
fun createTask(token: String, project: Long, subject: String, userStory: Long): TaigaTicketData =
|
||||||
|
// Taigas Feldname; unsere API sagt `userStory` (camelCase wie überall).
|
||||||
|
create(token, "/tasks", mapOf("project" to project, "subject" to subject, "user_story" to userStory))
|
||||||
|
|
||||||
|
private fun create(token: String, path: String, body: Map<String, Any>): TaigaTicketData {
|
||||||
|
val map = exchange {
|
||||||
|
rest.post().uri(url(path))
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(body)
|
||||||
|
.retrieve().body(MAP)
|
||||||
|
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($path)")
|
||||||
|
return TaigaTicketData(id = num(map, "id"), ref = num(map, "ref"), subject = str(map, "subject"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun url(path: String): String {
|
||||||
|
if (!properties.configured) throw TaigaNotConfiguredException()
|
||||||
|
return properties.apiUrl.trimEnd('/') + path
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> exchange(call: () -> T): T =
|
||||||
|
try {
|
||||||
|
call()
|
||||||
|
} catch (e: RestClientResponseException) {
|
||||||
|
// Der Fehlertext kommt aus Taigas ANTWORT (`_error_message`) —
|
||||||
|
// nie aus der Anfrage; Zugangsdaten stehen darin nicht.
|
||||||
|
throw TaigaUpstreamException(e.statusCode.value(), errorMessage(e))
|
||||||
|
} catch (e: ResourceAccessException) {
|
||||||
|
throw TaigaUnavailableException("Taiga-Instanz nicht erreichbar: ${e.message}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorMessage(e: RestClientResponseException): String {
|
||||||
|
val fromBody = Regex("\"_error_message\"\\s*:\\s*\"([^\"]*)\"")
|
||||||
|
.find(e.responseBodyAsString)?.groupValues?.get(1)
|
||||||
|
return fromBody ?: "Taiga antwortete mit ${e.statusCode.value()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun str(m: Map<String, Any?>, key: String): String =
|
||||||
|
m[key] as? String
|
||||||
|
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
|
||||||
|
|
||||||
|
private fun num(m: Map<String, Any?>, key: String): Long =
|
||||||
|
(m[key] as? Number)?.toLong()
|
||||||
|
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val MAP = object : ParameterizedTypeReference<Map<String, Any?>>() {}
|
||||||
|
private val LIST = object : ParameterizedTypeReference<List<Map<String, Any?>>>() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.werkbaum.integration.taiga
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Taiga-Proxy (D91).
|
||||||
|
*
|
||||||
|
* Die Basis-URL der Taiga-API ist **Server-Konfiguration**, nie
|
||||||
|
* Request-Parameter — ein Proxy, der sein Ziel vom Aufrufer nimmt, ist ein
|
||||||
|
* offenes Relay (die SSRF-Falle naiver Proxies). Leer heißt: Feature aus;
|
||||||
|
* alle Taiga-Endpunkte antworten dann mit 503, und `GET /info` meldet
|
||||||
|
* `taiga: false`, sodass der Editor die Aktionen gar nicht erst zeigt.
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "werkbaum.taiga")
|
||||||
|
data class TaigaProperties(
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basis-URL der Taiga-**API**, nicht des Frontends — bei der Zielinstanz
|
||||||
|
* liegt sie auf einem eigenen Host (`https://plan-api.hostsharing.net/api/v1`,
|
||||||
|
* aus deren `conf.json` gelesen; D91-Nachtrag 1).
|
||||||
|
*/
|
||||||
|
val apiUrl: String = "",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login-Typ für `POST /auth`: `ldap` (LDAP-Plugin, so die Zielinstanz)
|
||||||
|
* oder `normal`. Nur der Auth-Endpunkt braucht ihn; bei der angekündigten
|
||||||
|
* OIDC-Umstellung wird er durch den Redirect-Flow ersetzt.
|
||||||
|
*/
|
||||||
|
val authType: String = "ldap",
|
||||||
|
) {
|
||||||
|
val configured: Boolean get() = apiUrl.isNotBlank()
|
||||||
|
}
|
||||||
@@ -50,6 +50,16 @@ werkbaum:
|
|||||||
cors:
|
cors:
|
||||||
allowed-origins: "*"
|
allowed-origins: "*"
|
||||||
|
|
||||||
|
# Taiga-Proxy (D91): Die Basis-URL der Taiga-API ist SERVER-Konfiguration,
|
||||||
|
# nie Request-Parameter (SSRF-Falle naiver Proxies). Leer = Feature aus;
|
||||||
|
# GET /info meldet es (taiga). Achtung: die API-URL, nicht das Frontend -
|
||||||
|
# bei der Zielinstanz z. B. https://plan-api.hostsharing.net/api/v1
|
||||||
|
taiga:
|
||||||
|
api-url: ${WERKBAUM_TAIGA_API_URL:}
|
||||||
|
# Login-Typ der Instanz fuer POST /taiga/auth: "ldap" (LDAP-Plugin,
|
||||||
|
# plan.hostsharing.net) oder "normal".
|
||||||
|
auth-type: ${WERKBAUM_TAIGA_AUTH_TYPE:ldap}
|
||||||
|
|
||||||
# Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus
|
# Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus
|
||||||
# der Umgebung. Ohne ihn bleibt GET /documents gesperrt.
|
# der Umgebung. Ohne ihn bleibt GET /documents gesperrt.
|
||||||
master-password:
|
master-password:
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ servers:
|
|||||||
tags:
|
tags:
|
||||||
- name: Documents
|
- name: Documents
|
||||||
description: Verwaltung von Dokumenten
|
description: Verwaltung von Dokumenten
|
||||||
|
- name: Taiga
|
||||||
|
description: >
|
||||||
|
Schmaler, benannter Proxy zur konfigurierten Taiga-Instanz (D91).
|
||||||
|
Kein Durchreich-Proxy: Die Taiga-Basis-URL ist Server-Konfiguration
|
||||||
|
(`werkbaum.taiga.api-url`), nie Request-Parameter - die SSRF-Falle
|
||||||
|
naiver Proxies. Das Token bleibt im Browser; der Server speichert
|
||||||
|
nichts. Ohne konfigurierte Instanz antworten alle Taiga-Endpunkte
|
||||||
|
mit 503; ob sie konfiguriert ist, meldet `GET /info` (`taiga`).
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/documents:
|
/documents:
|
||||||
@@ -363,6 +371,159 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/taiga/auth:
|
||||||
|
post:
|
||||||
|
tags: [Taiga]
|
||||||
|
operationId: taigaLogin
|
||||||
|
summary: Bei Taiga anmelden (Proxy)
|
||||||
|
description: >
|
||||||
|
Reicht Benutzername und Passwort einmalig an die konfigurierte
|
||||||
|
Taiga-Instanz durch (`POST <api-url>/auth`, mit dem serverseitig
|
||||||
|
konfigurierten Login-Typ, Voreinstellung `ldap` - D91-Nachtrag 1).
|
||||||
|
Der Endpunkt sieht das Passwort nur im Durchflug: Der Server
|
||||||
|
speichert nichts und loggt den Request-Body nie; das Token gehoert
|
||||||
|
dem Browser. Bei der angekuendigten OIDC-Umstellung der Instanz wird
|
||||||
|
dieser Endpunkt durch den Redirect-Flow ersetzt - die uebrigen
|
||||||
|
Taiga-Endpunkte bleiben unveraendert (sie nehmen nur das Token).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TaigaAuthRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Anmeldung gelungen
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TaigaSession"
|
||||||
|
"400":
|
||||||
|
description: >
|
||||||
|
Zugangsdaten abgelehnt - Taiga meldet falsche Anmeldedaten als
|
||||||
|
400, der Status wird durchgereicht.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/TaigaUnavailable"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/TaigaNotConfigured"
|
||||||
|
|
||||||
|
/taiga/projects:
|
||||||
|
get:
|
||||||
|
tags: [Taiga]
|
||||||
|
operationId: taigaProjects
|
||||||
|
summary: Projekte des angemeldeten Nutzers auflisten (Proxy)
|
||||||
|
description: >
|
||||||
|
`GET <api-url>/projects?member=<userId>` - die Auswahlliste des
|
||||||
|
Projekt-Dialogs der Ticket-Anlage. Der `slug` ist zugleich der Wert
|
||||||
|
des Schlagworts `&taiga.<slug>` (SPEC par. 1).
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TaigaToken"
|
||||||
|
- name: member
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: Taiga-Benutzer-Id aus der Sitzung; filtert auf die eigenen Projekte.
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Projekte des Nutzers
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/TaigaProject"
|
||||||
|
"401":
|
||||||
|
description: Token fehlt oder ist abgelaufen
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/TaigaUnavailable"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/TaigaNotConfigured"
|
||||||
|
|
||||||
|
/taiga/userstories:
|
||||||
|
post:
|
||||||
|
tags: [Taiga]
|
||||||
|
operationId: taigaCreateStory
|
||||||
|
summary: User Story anlegen (Proxy)
|
||||||
|
description: >
|
||||||
|
`POST <api-url>/userstories`. Die Antwort traegt die projektweite
|
||||||
|
`ref` - Werkbaum schreibt daraus `#US-<ref>` als Token an die
|
||||||
|
Knotenzeile (SPEC par. 11, D91-Nachtrag 2).
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TaigaToken"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TaigaStoryCreateRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Story wurde angelegt
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TaigaTicket"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
description: Token fehlt oder ist abgelaufen
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/TaigaUnavailable"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/TaigaNotConfigured"
|
||||||
|
|
||||||
|
/taiga/tasks:
|
||||||
|
post:
|
||||||
|
tags: [Taiga]
|
||||||
|
operationId: taigaCreateTask
|
||||||
|
summary: Task unter einer User Story anlegen (Proxy)
|
||||||
|
description: >
|
||||||
|
`POST <api-url>/tasks`. Tasks haengen immer an einer Story
|
||||||
|
(`userStory` ist die Id, nicht die Ref) - storyless Tasks sind im
|
||||||
|
Kanban unsichtbar und werden bewusst nicht angeboten (D91). Die
|
||||||
|
Antwort traegt die `ref`; Werkbaum schreibt daraus `#T-<ref>`.
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TaigaToken"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TaigaTaskCreateRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Task wurde angelegt
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TaigaTicket"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
description: Token fehlt oder ist abgelaufen
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/TaigaUnavailable"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/TaigaNotConfigured"
|
||||||
|
|
||||||
/info:
|
/info:
|
||||||
get:
|
get:
|
||||||
tags: [Documents]
|
tags: [Documents]
|
||||||
@@ -383,7 +544,37 @@ paths:
|
|||||||
$ref: "#/components/schemas/ServiceInfo"
|
$ref: "#/components/schemas/ServiceInfo"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
parameters:
|
||||||
|
TaigaToken:
|
||||||
|
name: X-Taiga-Token
|
||||||
|
in: header
|
||||||
|
required: true
|
||||||
|
description: >
|
||||||
|
Das Taiga-Token aus `POST /taiga/auth`, nackt (ohne `Bearer `-Praefix).
|
||||||
|
Der Proxy setzt daraus den `Authorization: Bearer <token>`-Header der
|
||||||
|
Weiterleitung. Bewusst ein eigener Header-Name: Einen Header-Parameter
|
||||||
|
namens `Authorization` muessen OpenAPI-Werkzeuge laut Spezifikation
|
||||||
|
ignorieren, und der Name kollidierte mit dem Master-Passwort (Basic).
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
maxLength: 512
|
||||||
|
|
||||||
responses:
|
responses:
|
||||||
|
TaigaNotConfigured:
|
||||||
|
description: >
|
||||||
|
Keine Taiga-Instanz konfiguriert (`werkbaum.taiga.api-url`) - der
|
||||||
|
Editor fragt vorher `GET /info` (`taiga`) und zeigt die Aktionen
|
||||||
|
dann gar nicht erst.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
TaigaUnavailable:
|
||||||
|
description: Taiga-Instanz nicht erreichbar oder antwortet fehlerhaft
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
NotFound:
|
NotFound:
|
||||||
description: Ressource nicht gefunden
|
description: Ressource nicht gefunden
|
||||||
content:
|
content:
|
||||||
@@ -675,6 +866,106 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE).
|
description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE).
|
||||||
|
taiga:
|
||||||
|
type: boolean
|
||||||
|
description: >
|
||||||
|
true, wenn eine Taiga-Instanz konfiguriert ist
|
||||||
|
(`werkbaum.taiga.api-url`) - der Editor zeigt die Ticket-Aktionen
|
||||||
|
im Knoten-Fenster nur dann (D91).
|
||||||
|
|
||||||
|
TaigaAuthRequest:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 255
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 255
|
||||||
|
description: Wird nur durchgereicht - nie gespeichert, nie geloggt.
|
||||||
|
|
||||||
|
TaigaSession:
|
||||||
|
type: object
|
||||||
|
required: [authToken, userId, username]
|
||||||
|
properties:
|
||||||
|
authToken:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Bearer-Token der Taiga-Sitzung. Es gehoert dem Browser; der
|
||||||
|
Server merkt sich nichts davon.
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Taiga-Benutzer-Id - der `member`-Filter der Projektliste.
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
fullName:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
TaigaProject:
|
||||||
|
type: object
|
||||||
|
required: [id, name, slug]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Zugleich der Wert des Schlagworts `&taiga.<slug>` im
|
||||||
|
Notationstext (SPEC par. 1, D91-Nachtrag 3).
|
||||||
|
|
||||||
|
TaigaStoryCreateRequest:
|
||||||
|
type: object
|
||||||
|
required: [project, subject]
|
||||||
|
properties:
|
||||||
|
project:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Taiga-Projekt-Id (aus der Projektliste).
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 500
|
||||||
|
|
||||||
|
TaigaTaskCreateRequest:
|
||||||
|
type: object
|
||||||
|
required: [project, subject, userStory]
|
||||||
|
properties:
|
||||||
|
project:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 500
|
||||||
|
userStory:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Id (nicht Ref) der User Story, unter der die Task haengt.
|
||||||
|
|
||||||
|
TaigaTicket:
|
||||||
|
type: object
|
||||||
|
required: [id, ref, subject]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
ref:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: >
|
||||||
|
Projektweite Nummer, fortlaufend ueber alle Typen. Werkbaum
|
||||||
|
schreibt daraus `#US-<ref>` bzw. `#T-<ref>` an die Knotenzeile -
|
||||||
|
die Praefixe traegt Werkbaum selbst, Taiga zeigt nur `#<ref>`
|
||||||
|
(D91-Nachtrag 2).
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
|
||||||
ProblemDetail:
|
ProblemDetail:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ reverse.
|
|||||||
|
|
||||||
## 2026-08-27
|
## 2026-08-27
|
||||||
|
|
||||||
|
- The backend proxies Taiga through four named endpoints — login, projects, create story, create task; the Taiga API address is server configuration, the token stays in the browser, and `/api/v1/info` says whether the feature is on
|
||||||
- Free `&tag` keywords are part of the notation now: free-standing only, quoted mentions like `(&taiga.slug)` stay labels — and `&taiga.slug` names the Taiga project of a subtree, inherited downwards
|
- Free `&tag` keywords are part of the notation now: free-standing only, quoted mentions like `(&taiga.slug)` stay labels — and `&taiga.slug` names the Taiga project of a subtree, inherited downwards
|
||||||
- Plans spanning several Taiga projects: a `&taiga.slug` tag names the project per subtree, inherited downwards — the first consumer of the reserved free tags, recorded as planned
|
- Plans spanning several Taiga projects: a `&taiga.slug` tag names the project per subtree, inherited downwards — the first consumer of the reserved free tags, recorded as planned
|
||||||
- Creating Taiga tickets from nodes recorded as planned: a story per node from the node window, sub-packages as tasks picked in a dialog, through a backend proxy — the ref lands in the line as `#US-123`/`#T-1234`, beside the node id
|
- Creating Taiga tickets from nodes recorded as planned: a story per node from the node window, sub-packages as tasks picked in a dialog, through a backend proxy — the ref lands in the line as `#US-123`/`#T-1234`, beside the node id
|
||||||
|
|||||||
@@ -206,7 +206,7 @@
|
|||||||
- [ ] #trk.resolve.map: Map the workflow onto the states (S)
|
- [ ] #trk.resolve.map: Map the workflow onto the states (S)
|
||||||
- [?] #trk.write: Write the status back (M) :#trk.resolve
|
- [?] #trk.write: Write the status back (M) :#trk.resolve
|
||||||
- [ ] #trk.create: Create tickets from nodes (XL)
|
- [ ] #trk.create: Create tickets from nodes (XL)
|
||||||
- [ ] #trk.create.proxy: Backend proxy with named endpoints (M)
|
- [x] #trk.create.proxy: Backend proxy with named endpoints (M)
|
||||||
- [ ] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy
|
- [ ] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy
|
||||||
- [ ] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project
|
- [ ] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project
|
||||||
- [ ] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login
|
- [ ] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ if [ ! -f "$DIR/env" ]; then
|
|||||||
# Solange der Hash leer ist, bleibt GET /api/v1/documents gesperrt
|
# Solange der Hash leer ist, bleibt GET /api/v1/documents gesperrt
|
||||||
# (D76-Nachtrag 6).
|
# (D76-Nachtrag 6).
|
||||||
WERKBAUM_MASTER_PASSWORD_HASH=
|
WERKBAUM_MASTER_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Taiga-Proxy (D91): Basis-URL der Taiga-API — nicht des Frontends; bei der
|
||||||
|
# Zielinstanz liegt sie auf einem eigenen Host. Leer = Feature aus;
|
||||||
|
# GET /api/v1/info meldet es (taiga).
|
||||||
|
#WERKBAUM_TAIGA_API_URL=https://plan-api.hostsharing.net/api/v1
|
||||||
ENV
|
ENV
|
||||||
chmod 600 "$DIR/env"
|
chmod 600 "$DIR/env"
|
||||||
echo " ! $DIR/env angelegt — Master-Passwort-Hash dort eintragen,"
|
echo " ! $DIR/env angelegt — Master-Passwort-Hash dort eintragen,"
|
||||||
|
|||||||
Reference in New Issue
Block a user