Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -47,7 +45,7 @@ class IncidentController(
@GetMapping
@PreAuthorize("isAuthenticated()")
fun list(@PathVariable key: String): List<IncidentResponse> {
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) }
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
)
}

Expand All @@ -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")
}

Expand All @@ -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)
Expand All @@ -84,8 +84,8 @@ class ServiceDeskController(

@GetMapping("/sla-policies")
fun listSlaPolicies(@PathVariable key: String): List<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)
return serviceDeskService.listSlaPolicies(sd.id).map { SlaPolicyResponse.from(it) }
}

Expand Down
6 changes: 6 additions & 0 deletions backend/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
6 changes: 6 additions & 0 deletions backend/src/main/resources/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
@@ -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<String>()
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")
}
}
}
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Original file line number Diff line number Diff line change
@@ -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'")))
}
}
Loading