diff --git a/backend/src/main/kotlin/com/taskowolf/servicedesk/api/IncidentController.kt b/backend/src/main/kotlin/com/taskowolf/servicedesk/api/IncidentController.kt index 29e92cf5..95a4892b 100644 --- a/backend/src/main/kotlin/com/taskowolf/servicedesk/api/IncidentController.kt +++ b/backend/src/main/kotlin/com/taskowolf/servicedesk/api/IncidentController.kt @@ -1,5 +1,7 @@ package com.taskowolf.servicedesk.api +import com.taskowolf.core.infrastructure.BadRequestException +import com.taskowolf.core.infrastructure.NotFoundException import com.taskowolf.projects.infrastructure.ProjectRepository import com.taskowolf.servicedesk.api.dto.CreateIncidentRequest import com.taskowolf.servicedesk.api.dto.IncidentResponse @@ -9,7 +11,6 @@ import com.taskowolf.servicedesk.domain.IncidentSeverity import org.springframework.http.HttpStatus import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* -import org.springframework.web.server.ResponseStatusException import java.util.UUID @RestController @@ -29,10 +30,7 @@ class IncidentController( val severity = try { IncidentSeverity.valueOf(req.severity) } catch (e: IllegalArgumentException) { - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Invalid severity '${req.severity}'. Must be one of: ${IncidentSeverity.entries.joinToString()}" - ) + throw BadRequestException.keyed("incident.invalidSeverity", req.severity, IncidentSeverity.entries.joinToString()) } return IncidentResponse.from( incidentService.create( @@ -47,7 +45,7 @@ class IncidentController( @GetMapping @PreAuthorize("isAuthenticated()") fun list(@PathVariable key: String): List { - val project = projectRepository.findByKey(key) ?: error("Project not found: $key") + val project = projectRepository.findByKey(key) ?: throw NotFoundException.keyed("project.notFound", key) return incidentService.listByProject(project.id).map { IncidentResponse.from(it) } } diff --git a/backend/src/main/kotlin/com/taskowolf/servicedesk/api/ServiceDeskController.kt b/backend/src/main/kotlin/com/taskowolf/servicedesk/api/ServiceDeskController.kt index ee1788fd..d299e089 100644 --- a/backend/src/main/kotlin/com/taskowolf/servicedesk/api/ServiceDeskController.kt +++ b/backend/src/main/kotlin/com/taskowolf/servicedesk/api/ServiceDeskController.kt @@ -1,6 +1,8 @@ package com.taskowolf.servicedesk.api import com.taskowolf.auth.domain.User +import com.taskowolf.core.infrastructure.BadRequestException +import com.taskowolf.core.infrastructure.NotFoundException import com.taskowolf.issues.api.dto.IssueResponse import com.taskowolf.issues.application.IssueService import com.taskowolf.issues.domain.IssuePriority @@ -12,7 +14,6 @@ import org.springframework.http.HttpStatus import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* -import org.springframework.web.server.ResponseStatusException import java.util.UUID @RestController @@ -29,15 +30,15 @@ class ServiceDeskController( @PathVariable key: String, @RequestBody req: CreateServiceDeskRequest ): ServiceDeskResponse { - val project = projectRepository.findByKey(key) ?: error("Project not found: $key") + val project = projectRepository.findByKey(key) ?: throw NotFoundException.keyed("project.notFound", key) return ServiceDeskResponse.from(serviceDeskService.enable(project.id, req.emailAddress)) } @GetMapping fun get(@PathVariable key: String): ServiceDeskResponse { - val project = projectRepository.findByKey(key) ?: error("Project not found: $key") + val project = projectRepository.findByKey(key) ?: throw NotFoundException.keyed("project.notFound", key) return ServiceDeskResponse.from( - serviceDeskService.findByProject(project.id) ?: error("Service desk not enabled for project: $key") + serviceDeskService.findByProject(project.id) ?: throw NotFoundException.keyed("serviceDesk.notEnabled", key) ) } @@ -48,7 +49,7 @@ class ServiceDeskController( @PathVariable key: String, @Valid @RequestBody req: SubmitTicketRequest ) { - val project = projectRepository.findByKey(key) ?: error("Project not found: $key") + val project = projectRepository.findByKey(key) ?: throw NotFoundException.keyed("project.notFound", key) issueService.createTicketFromEmail(project.id, req.title, req.description, req.senderEmail ?: "anonymous") } @@ -69,13 +70,12 @@ class ServiceDeskController( @PathVariable key: String, @Valid @RequestBody req: CreateSlaPolicyRequest ): SlaPolicyResponse { - val project = projectRepository.findByKey(key) ?: error("Project not found: $key") - val sd = serviceDeskService.findByProject(project.id) ?: error("Service desk not enabled for project: $key") + val project = projectRepository.findByKey(key) ?: throw NotFoundException.keyed("project.notFound", key) + val sd = serviceDeskService.findByProject(project.id) ?: throw NotFoundException.keyed("serviceDesk.notEnabled", key) val priority = try { IssuePriority.valueOf(req.priority) } catch (e: IllegalArgumentException) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, - "Invalid priority: ${req.priority}. Valid values: ${IssuePriority.entries.joinToString()}") + throw BadRequestException.keyed("serviceDesk.invalidPriority", req.priority, IssuePriority.entries.joinToString()) } return SlaPolicyResponse.from( serviceDeskService.addSlaPolicy(sd.id, req.name, priority, req.responseMinutes, req.resolutionMinutes) @@ -84,8 +84,8 @@ class ServiceDeskController( @GetMapping("/sla-policies") fun listSlaPolicies(@PathVariable key: String): List { - val project = projectRepository.findByKey(key) ?: error("Project not found: $key") - val sd = serviceDeskService.findByProject(project.id) ?: error("Service desk not enabled for project: $key") + val project = projectRepository.findByKey(key) ?: throw NotFoundException.keyed("project.notFound", key) + val sd = serviceDeskService.findByProject(project.id) ?: throw NotFoundException.keyed("serviceDesk.notEnabled", key) return serviceDeskService.listSlaPolicies(sd.id).map { SlaPolicyResponse.from(it) } } diff --git a/backend/src/main/resources/messages.properties b/backend/src/main/resources/messages.properties index 1ba6eab3..e518a395 100644 --- a/backend/src/main/resources/messages.properties +++ b/backend/src/main/resources/messages.properties @@ -134,3 +134,9 @@ attachment.notFound=Attachment not found: {0} # --- notification --- notification.notFound=Notification not found notification.unknownType=Unknown notification type: {0} + +# --- serviceDesk --- +serviceDesk.notEnabled=Service desk not enabled for project: {0} +serviceDesk.invalidPriority=Invalid priority: {0}. Valid values: {1} +# --- incident --- +incident.invalidSeverity=Invalid severity ''{0}''. Must be one of: {1} diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index aa8d2443..9b6b1c76 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -134,3 +134,9 @@ attachment.notFound=Anhang nicht gefunden: {0} # --- notification --- notification.notFound=Benachrichtigung nicht gefunden notification.unknownType=Unbekannter Benachrichtigungstyp: {0} + +# --- serviceDesk --- +serviceDesk.notEnabled=Servicedesk ist f\u00fcr Projekt {0} nicht aktiviert +serviceDesk.invalidPriority=Ung\u00fcltige Priorit\u00e4t: {0}. G\u00fcltige Werte: {1} +# --- incident --- +incident.invalidSeverity=Ung\u00fcltiger Schweregrad ''{0}''. Muss einer von: {1} diff --git a/backend/src/test/kotlin/com/taskowolf/core/NoUnkeyedUserFacingThrowTest.kt b/backend/src/test/kotlin/com/taskowolf/core/NoUnkeyedUserFacingThrowTest.kt new file mode 100644 index 00000000..1da370fd --- /dev/null +++ b/backend/src/test/kotlin/com/taskowolf/core/NoUnkeyedUserFacingThrowTest.kt @@ -0,0 +1,70 @@ +package com.taskowolf.core + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Static guard: no user-facing free-text throw may reach production code — every such + * throw must go through the `.keyed(...)` factory so it localizes. Catches BARE and + * FULLY-QUALIFIED throws alike (`throw NotFoundException("…")` AND + * `throw com.taskowolf...NotFoundException("…")`) — the FQ form is the blind spot that + * let an un-keyed AttachmentController throw ship through the sweep. `.keyed(` never + * matches (the exception name is followed by `.keyed(`, not `("`). + * + * Allowlisted: the two intentional internal invariants (Decision 2), which are NOT + * user-facing and stay as `?: error(...)`. + */ +class NoUnkeyedUserFacingThrowTest { + + private fun sourceRoot(): File { + val direct = File("src/main/kotlin") + return if (direct.isDirectory) direct else File("backend/src/main/kotlin") + } + + // (fileName, substring of the offending line) — Decision-2 internal invariants. + private val allowlist = listOf( + "SsoController.kt" to "clientSecret required", + "OidcUserProvisioningService.kt" to "OIDC user has no email", + ) + + private val patterns = listOf( + // free-text throw of a keyed domain exception (bare or fully-qualified) + Regex("""throw\s+(?:[\w.]+\.)?(?:NotFoundException|ForbiddenException|ConflictException|BadRequestException)\s*\(\s*""""), + // free-text orElseThrow lambda (bare or fully-qualified) + Regex("""orElseThrow\s*\{\s*(?:[\w.]+\.)?(?:NotFoundException|ForbiddenException|ConflictException|BadRequestException)\s*\(\s*""""), + // nullable-fallback error() → user-facing IllegalStateException + Regex("""\?:\s*error\s*\("""), + // web-layer / security constructs that should be keyed instead + Regex("""throw\s+ResponseStatusException\s*\("""), + Regex("""throw\s+AccessDeniedException\s*\("""), + ) + + private fun allowlisted(fileName: String, line: String) = + allowlist.any { (f, s) -> fileName == f && line.contains(s) } + + @Test + fun `no un-keyed user-facing throw in production code`() { + val root = sourceRoot() + assertTrue(root.isDirectory) { "sourceRoot() did not resolve to a directory: ${root.absolutePath}" } + + val violations = mutableListOf() + var filesScanned = 0 + root.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .forEach { file -> + filesScanned++ + file.readLines().forEachIndexed { i, line -> + if (patterns.any { it.containsMatchIn(line) } && !allowlisted(file.name, line)) { + violations.add("${file.name}:${i + 1} ${line.trim()}") + } + } + } + + assertTrue(filesScanned > 0) { "guard scanned no files under ${root.absolutePath}" } + assertTrue(violations.isEmpty()) { + "Un-keyed user-facing throw(s) found — use `.keyed(\"key\", …)` " + + "(or allowlist a genuine internal invariant):\n" + violations.joinToString("\n") + } + } +} diff --git a/backend/src/test/kotlin/com/taskowolf/i18n/ServiceDeskMessagesTest.kt b/backend/src/test/kotlin/com/taskowolf/i18n/ServiceDeskMessagesTest.kt new file mode 100644 index 00000000..6b18d588 --- /dev/null +++ b/backend/src/test/kotlin/com/taskowolf/i18n/ServiceDeskMessagesTest.kt @@ -0,0 +1,27 @@ +package com.taskowolf.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.context.support.ResourceBundleMessageSource +import java.util.Locale + +class ServiceDeskMessagesTest { + private val src = ResourceBundleMessageSource().apply { + setBasename("messages"); setDefaultEncoding("UTF-8"); setFallbackToSystemLocale(false) + } + private fun en(key: String, vararg a: Any?) = src.getMessage(key, a, Locale.ENGLISH) + private fun de(key: String, vararg a: Any?) = src.getMessage(key, a, Locale.GERMAN) + + @Test fun `servicedesk keys render en and de`() { + assertEquals("Service desk not enabled for project: DEMO", en("serviceDesk.notEnabled", "DEMO")) + assertEquals("Servicedesk ist für Projekt DEMO nicht aktiviert", de("serviceDesk.notEnabled", "DEMO")) + assertEquals("Invalid priority: X. Valid values: CRITICAL, HIGH, MEDIUM, LOW", + en("serviceDesk.invalidPriority", "X", "CRITICAL, HIGH, MEDIUM, LOW")) + assertEquals("Ungültige Priorität: X. Gültige Werte: CRITICAL, HIGH, MEDIUM, LOW", + de("serviceDesk.invalidPriority", "X", "CRITICAL, HIGH, MEDIUM, LOW")) + assertEquals("Invalid severity 'SEV5'. Must be one of: P1, P2, P3, P4", + en("incident.invalidSeverity", "SEV5", "P1, P2, P3, P4")) + assertEquals("Ungültiger Schweregrad 'SEV5'. Muss einer von: P1, P2, P3, P4", + de("incident.invalidSeverity", "SEV5", "P1, P2, P3, P4")) + } +} diff --git a/backend/src/test/kotlin/com/taskowolf/servicedesk/ServiceDeskErrorLocalizationTest.kt b/backend/src/test/kotlin/com/taskowolf/servicedesk/ServiceDeskErrorLocalizationTest.kt new file mode 100644 index 00000000..6771c376 --- /dev/null +++ b/backend/src/test/kotlin/com/taskowolf/servicedesk/ServiceDeskErrorLocalizationTest.kt @@ -0,0 +1,90 @@ +package com.taskowolf.servicedesk + +import com.fasterxml.jackson.databind.ObjectMapper +import com.taskowolf.IntegrationTestBase +import org.hamcrest.Matchers.startsWith +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.UUID + +class ServiceDeskErrorLocalizationTest : IntegrationTestBase() { + + @Autowired private lateinit var mockMvc: MockMvc + @Autowired private lateinit var objectMapper: ObjectMapper + + private fun register(email: String): String { + val res = mockMvc.perform( + post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON) + .content("""{"email":"$email","displayName":"User","password":"password123"}""") + ).andReturn() + return objectMapper.readTree(res.response.contentAsString).get("accessToken").asText() + } + private fun createProject(token: String, key: String) = + mockMvc.perform( + post("/api/v1/projects").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"key":"$key","name":"Demo"}""") + ).andExpect(status().isCreated) + private fun enableServiceDesk(token: String, key: String) = + mockMvc.perform( + post("/api/v1/projects/$key/service-desk/enable").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"emailAddress":"desk@test.com"}""") + ).andExpect(status().isOk) + + @Test + fun `unknown project returns 404 not 500 (localized de)`() { + val token = register("sd-nf-de@test.com") + mockMvc.perform( + get("/api/v1/projects/NOPE/service-desk").header("Authorization", "Bearer $token") + .header("Accept-Language", "de") + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("NOT_FOUND")) + .andExpect(jsonPath("$.message").value("Projekt nicht gefunden: NOPE")) + } + + @Test + fun `service desk not enabled returns 404 not 500 (localized de)`() { + val token = register("sd-off-de@test.com") + createProject(token, "SDOFF") + mockMvc.perform( + get("/api/v1/projects/SDOFF/service-desk").header("Authorization", "Bearer $token") + .header("Accept-Language", "de") + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("NOT_FOUND")) + .andExpect(jsonPath("$.message").value("Servicedesk ist für Projekt SDOFF nicht aktiviert")) + } + + @Test + fun `invalid priority returns 400 with localized body (de)`() { + val token = register("sd-prio-de@test.com") + createProject(token, "SDPRIO") + enableServiceDesk(token, "SDPRIO") + mockMvc.perform( + post("/api/v1/projects/SDPRIO/service-desk/sla-policies").header("Authorization", "Bearer $token") + .header("Accept-Language", "de").contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"P1","priority":"BOGUS","responseMinutes":10,"resolutionMinutes":60}""") + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message", startsWith("Ungültige Priorität: BOGUS"))) + } + + @Test + fun `invalid severity returns 400 with localized body (de)`() { + val token = register("sd-sev-de@test.com") + createProject(token, "SDSEV") + mockMvc.perform( + post("/api/v1/projects/SDSEV/incidents").header("Authorization", "Bearer $token") + .header("Accept-Language", "de").contentType(MediaType.APPLICATION_JSON) + .content("""{"issueId":"${UUID.randomUUID()}","severity":"BOGUS","onCallAssigneeId":null,"notifyUserIds":[]}""") + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message", startsWith("Ungültiger Schweregrad 'BOGUS'"))) + } +}