diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 813f70c..a2a0e72 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 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 - Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21. - Paketwurzel `de.werkbaum`; Schichten: `api` (Controller), `domain`, @@ -28,8 +40,10 @@ zu brechen. - Tests mit JUnit 5 als Runner + **Kotest-Assertions** (`shouldBe`, `shouldContain`, `shouldThrow`) und MockK; Verhalten per Cucumber gegen die laufende Anwendung (`RestTestClient`, nicht TestRestTemplate — das ist in - Boot 4 Auslaufmodell). Taiga-Client gegen aufgezeichnete Antworten - (WireMock), nie gegen Live-Instanzen. + Boot 4 Auslaufmodell). Taiga-Client gegen aufgezeichnete Antworten, + 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; keine Zugangsdaten im Repository. - Keine neuen **Laufzeit**-Abhängigkeiten ohne Rückfrage (Wurzel-CLAUDE.md); diff --git a/backend/src/main/kotlin/de/werkbaum/api/CorsConfiguration.kt b/backend/src/main/kotlin/de/werkbaum/api/CorsConfiguration.kt index bc723cf..399828a 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/CorsConfiguration.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/CorsConfiguration.kt @@ -33,8 +33,9 @@ class CorsConfiguration(private val properties: CorsProperties) { val config = CorsConfiguration().apply { allowedOriginPatterns = properties.allowedOrigins allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") - // Authorization fuer das Master-Passwort, Content-Type fuer JSON. - allowedHeaders = listOf("Authorization", "Content-Type") + // Authorization fuer das Master-Passwort, Content-Type fuer JSON, + // X-Taiga-Token fuer den Taiga-Proxy (D91). + allowedHeaders = listOf("Authorization", "Content-Type", "X-Taiga-Token") // Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet. allowCredentials = false maxAge = 3600 diff --git a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt index e9a99f4..4952c69 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt @@ -18,6 +18,7 @@ import de.werkbaum.domain.ChangeFeed import de.werkbaum.domain.ContentPatch import de.werkbaum.domain.Document import de.werkbaum.domain.DocumentHistoryEntry +import de.werkbaum.integration.taiga.TaigaProperties import de.werkbaum.service.DocumentService import de.werkbaum.service.LiveEditingService import org.springframework.boot.info.BuildProperties @@ -45,6 +46,7 @@ class DocumentsController( * nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert. */ private val buildProperties: BuildProperties? = null, + private val taigaProperties: TaigaProperties, ) : DocumentsApi { /** @@ -60,6 +62,9 @@ class DocumentsController( name = buildProperties?.name ?: "werkbaum-backend", version = buildProperties?.version ?: "unbekannt", 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, ) ) diff --git a/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt b/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt index e2f1a24..ac970e6 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt @@ -8,6 +8,9 @@ import de.werkbaum.service.DocumentDeletedException import de.werkbaum.service.DocumentNotFoundException import de.werkbaum.service.InvalidPatchException 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.ProblemDetail import org.springframework.http.ResponseEntity @@ -75,4 +78,33 @@ class GlobalExceptionHandler { HttpStatus.BAD_REQUEST, ex.message ?: "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" } } diff --git a/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt b/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt new file mode 100644 index 0000000..d5dd1d9 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt @@ -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 { + 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> = + 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 { + val ticket = client.createStory( + token = xTaigaToken, + project = taigaStoryCreateRequest.project, + subject = taigaStoryCreateRequest.subject, + ) + return created(ticket) + } + + override fun taigaCreateTask( + xTaigaToken: String, + taigaTaskCreateRequest: TaigaTaskCreateRequest, + ): ResponseEntity { + val ticket = client.createTask( + token = xTaigaToken, + project = taigaTaskCreateRequest.project, + subject = taigaTaskCreateRequest.subject, + userStory = taigaTaskCreateRequest.userStory, + ) + return created(ticket) + } + + private fun created(ticket: TaigaTicketData): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED).body( + TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject) + ) +} diff --git a/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt new file mode 100644 index 0000000..47e5268 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt @@ -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 { + 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): 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 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, key: String): String = + m[key] as? String + ?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt") + + private fun num(m: Map, key: String): Long = + (m[key] as? Number)?.toLong() + ?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt") + + companion object { + private val MAP = object : ParameterizedTypeReference>() {} + private val LIST = object : ParameterizedTypeReference>>() {} + } +} diff --git a/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaProperties.kt b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaProperties.kt new file mode 100644 index 0000000..2e9fcd2 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaProperties.kt @@ -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() +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 569b35d..c09bf3d 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -50,6 +50,16 @@ werkbaum: cors: 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 # der Umgebung. Ohne ihn bleibt GET /documents gesperrt. master-password: diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 0e197e1..e0321cc 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -20,6 +20,14 @@ servers: tags: - name: Documents 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: /documents: @@ -363,6 +371,159 @@ paths: "404": $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 /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 /projects?member=` - die Auswahlliste des + Projekt-Dialogs der Ticket-Anlage. Der `slug` ist zugleich der Wert + des Schlagworts `&taiga.` (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 /userstories`. Die Antwort traegt die projektweite + `ref` - Werkbaum schreibt daraus `#US-` 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 /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-`. + 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: get: tags: [Documents] @@ -383,7 +544,37 @@ paths: $ref: "#/components/schemas/ServiceInfo" 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 `-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: + 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: description: Ressource nicht gefunden content: @@ -675,6 +866,106 @@ components: type: string format: date-time 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.` 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-` bzw. `#T-` an die Knotenzeile - + die Praefixe traegt Werkbaum selbst, Taiga zeigt nur `#` + (D91-Nachtrag 2). + subject: + type: string ProblemDetail: type: object diff --git a/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt b/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt new file mode 100644 index 0000000..37ea330 --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt @@ -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}""" +} diff --git a/backend/src/test/kotlin/de/werkbaum/api/TaigaDisabledTest.kt b/backend/src/test/kotlin/de/werkbaum/api/TaigaDisabledTest.kt new file mode 100644 index 0000000..3b2d9a0 --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/api/TaigaDisabledTest.kt @@ -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 + } +} diff --git a/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt b/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt new file mode 100644 index 0000000..d53ec17 --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt @@ -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 { 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 { 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 { dead.login("mi", "geheim") } + } + + @Test + fun `eine Antwort ohne die erwarteten Felder scheitert laut statt still`() { + responseBody = """{"unexpected": true}""" + val ex = shouldThrow { 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) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8830467..3f5abbe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 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 - 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 diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index beb64ff..5d16d3c 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -206,7 +206,7 @@ - [ ] #trk.resolve.map: Map the workflow onto the states (S) - [?] #trk.write: Write the status back (M) :#trk.resolve - [ ] #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.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 diff --git a/scripts/deploy-backend.sh b/scripts/deploy-backend.sh index ebe1a11..f62ff22 100755 --- a/scripts/deploy-backend.sh +++ b/scripts/deploy-backend.sh @@ -176,6 +176,11 @@ if [ ! -f "$DIR/env" ]; then # Solange der Hash leer ist, bleibt GET /api/v1/documents gesperrt # (D76-Nachtrag 6). 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 chmod 600 "$DIR/env" echo " ! $DIR/env angelegt — Master-Passwort-Hash dort eintragen,"