From 69d2ba994cb223c97e7ff3ed2102081aeb8640a3 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 26 Sep 2026 15:38:55 +0200 Subject: [PATCH 1/3] Make rebuilding an existing onboarding path a PM action A member could replace their own path (and lose their progress) at any time through /me/path/personalize. Members now only build their first path there; replacing an existing one is refused with 403 unless the caller manages the project. Watching a running generation stays open. New POST /projects/{projectId}/onboarding/users/{userId}/path/personalize lets the project's manager (or an admin) rebuild a member's path. It runs through the same generation registry under the member's auth id, so the member's own page attaches to it like to one they started. Co-Authored-By: Claude Opus 5.5 --- .../controller/OnboardingPathController.kt | 71 +++++++++++- .../service/OnboardingPathService.kt | 14 +++ .../user/external/UserApi.kt | 10 ++ .../user/service/UserApiService.kt | 12 ++ .../OnboardingPathControllerTest.kt | 107 ++++++++++++++++++ .../service/OnboardingPathServiceTest.kt | 19 ++++ 6 files changed, 230 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt index e1ac3874..6217dd52 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException import java.util.UUID /** @@ -193,6 +194,7 @@ class OnboardingPathController( class ProjectOnboardingPathController( private val onboardingPersonalizationService: OnboardingPersonalizationService, private val onboardingGenerationRegistry: OnboardingGenerationRegistry, + private val onboardingPathService: OnboardingPathService, private val userApi: UserApi, ) { /** @@ -245,14 +247,18 @@ class ProjectOnboardingPathController( * template. The service rejects a project the user is not assigned to. Any existing path is * replaced. A project must have exactly one active blueprint. * + * A member builds their *first* path here; rebuilding one they already have is the project + * manager's call ([personalizePathForUser]), because it throws away the member's progress. The + * project's manager and admins may still replace their own path from here. + * * The generation runs detached from this request (see [OnboardingGenerationRegistry]): closing * the stream does not cancel it, and a request while one is running watches that one instead of * starting another. * * @param projectId The project whose active blueprint seeds the path. * @return A stream of progress events ending in the new path plus a `done` event. - * @throws ResponseStatusException `403` when the user is not assigned to the project, - * `404` when the user does not exist. + * @throws ResponseStatusException `403` when the user is not assigned to the project, or already + * has a path and does not manage the project; `404` when the user does not exist. */ @Operation( summary = "Create onboarding path from blueprint", @@ -269,7 +275,8 @@ class ProjectOnboardingPathController( ApiResponse( responseCode = "403", description = "Insufficient role to create an onboarding path, " + - "or the authenticated user is not assigned to the given project", + "the authenticated user is not assigned to the given project, " + + "or the user already has a path and does not manage the project", ), ApiResponse(responseCode = "404", description = "No user found for the authenticated user"), ], @@ -283,6 +290,64 @@ class ProjectOnboardingPathController( @Parameter(hidden = true) @AuthenticationPrincipal jwt: Jwt, ): Flow { + // Watching a running generation stays open to everyone -- including one a PM started for this + // member. Only starting a new one over an existing path is the manager's call. + val startsNewRun = onboardingGenerationRegistry.status(jwt.subject) == null + if (startsNewRun && + onboardingPathService.hasPathForMe(jwt.subject) && + !userApi.canManageProject(jwt.subject, projectId) + ) { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Only the project manager can rebuild an existing onboarding path", + ) + } return onboardingGenerationRegistry.startOrAttach(jwt.subject, projectId) } + + /** + * Rebuilds a member's onboarding path from [projectId]'s active blueprint, on the project + * manager's behalf. + * + * The same generation as [personalizePath], run for the member: it replaces their path (and + * with it their progress), and the member's own onboarding page attaches to it like to one they + * started. Closing this stream does not cancel it. + * + * @param projectId The project whose active blueprint seeds the path. + * @param userId The member whose path is rebuilt. + * @return A stream of progress events ending in the new path plus a `done` event. + * @throws ResponseStatusException `403` when the member is not assigned to the project, `404` + * when the member does not exist. + */ + @Operation( + summary = "Rebuild a member's onboarding path", + description = "Rebuilds the member's onboarding path from the project's active blueprint, " + + "replacing the path they have. For the project's manager and admins. Failures after the " + + "stream has opened are reported as an `error` event inside the `200` stream.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "SSE stream of personalization events"), + ApiResponse(responseCode = "401", description = "Authentication required"), + ApiResponse( + responseCode = "403", + description = "Caller does not manage the project, or the member is not assigned to it", + ), + ApiResponse(responseCode = "404", description = "No user found with the given ID"), + ], + ) + @ResponseStatus(HttpStatus.OK) + @PostMapping("/users/{userId}/path/personalize", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) + @PreAuthorize("@projectAuth.canManageProject(authentication, #projectId)") + fun personalizePathForUser( + @Parameter(description = "UUID of the project whose active blueprint seeds the path") + @PathVariable projectId: UUID, + @Parameter(description = "UUID of the member whose path is rebuilt") + @PathVariable userId: UUID, + ): Flow { + val authId = userApi.getAuthIdByUserId(userId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "No user found with id: $userId") + } + return onboardingGenerationRegistry.startOrAttach(authId, projectId) + } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt index 5db888d6..cbb98b0f 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt @@ -66,6 +66,20 @@ class OnboardingPathService( ) } + /** + * Whether the authenticated user already has an onboarding path. An unknown user has none. + * + * @param authId External authentication identifier. + * @return `true` when a path exists for the user. + */ + @Transactional(readOnly = true) + @Tracked("Checking whether the user has an onboarding path") + fun hasPathForMe(authId: String): Boolean = + userApi + .getUserIdByAuthId(authId) + .map { userId -> onboardingPathRepository.existsByUserId(userId) } + .orElse(false) + /** * Deletes the onboarding path owned by the authenticated user. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt index 7a39acc6..0529dd0b 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt @@ -35,6 +35,16 @@ interface UserApi { */ fun getUserIdByAuthId(authId: String): Optional + /** + * Resolves the Keycloak authentication subject for an internal SprintStart user ID -- the + * reverse of [getUserIdByAuthId], for acting on somebody else's behalf (a PM rebuilding a + * member's onboarding path). + * + * @param userId Internal SprintStart user identifier. + * @return The matching auth ID when present. + */ + fun getAuthIdByUserId(userId: UUID): Optional + fun getUserByAuthId(authId: String): UserDto fun searchUsers( diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt index 1b9f7797..1572e11a 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt @@ -69,6 +69,18 @@ class UserApiService( return userRepository.findIdByAuthId(authId) } + /** + * Resolves the external authentication identifier for an internal user ID. + * + * @param userId Internal user identifier. + * @return The matching auth ID when present. + */ + @Transactional(readOnly = true) + @Tracked("Resolving auth ID by user ID") + override fun getAuthIdByUserId(userId: UUID): Optional { + return userRepository.findAuthIdById(userId) + } + /** * Retrieves a user by their external authentication identifier. * diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt index ca975a30..362e21e1 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt @@ -8,6 +8,7 @@ import com.sprintstart.sprintstartbackend.onboarding.service.OnboardingPathServi import com.sprintstart.sprintstartbackend.onboarding.service.OnboardingPersonalizationService import com.sprintstart.sprintstartbackend.user.external.UserApi import com.sprintstart.sprintstartbackend.user.external.UserOnboardingProfile +import com.sprintstart.sprintstartbackend.user.external.security.ProjectAuthorization import io.mockk.Runs import io.mockk.every import io.mockk.just @@ -56,6 +57,9 @@ class OnboardingPathControllerTest( @MockkBean private lateinit var jwtDecoder: JwtDecoder + @MockkBean(name = "projectAuth") + private lateinit var projectAuthorization: ProjectAuthorization + private val pathId = UUID.randomUUID() private val userId = UUID.randomUUID() private val projectId = UUID.randomUUID() @@ -191,6 +195,8 @@ class OnboardingPathControllerTest( @Test fun `personalizePath passes the selected project path variable to the generation registry`() { + every { onboardingGenerationRegistry.status(authId) } returns null + every { onboardingPathService.hasPathForMe(authId) } returns false every { onboardingGenerationRegistry.startOrAttach(authId, projectId) } throws ResponseStatusException(HttpStatus.BAD_REQUEST, "rejected") @@ -205,6 +211,107 @@ class OnboardingPathControllerTest( } } + @Test + fun `personalizePath refuses a member rebuilding a path they already have`() { + every { onboardingGenerationRegistry.status(authId) } returns null + every { onboardingPathService.hasPathForMe(authId) } returns true + every { userApi.canManageProject(authId, projectId) } returns false + + mockMvc + .perform( + post("/api/v1/projects/$projectId/onboarding/me/path/personalize") + .with(userJwt), + ).andExpect(status().isForbidden) + + verify(exactly = 0) { onboardingGenerationRegistry.startOrAttach(any(), any()) } + } + + @Test + fun `personalizePath lets the project's manager rebuild their own path`() { + every { onboardingGenerationRegistry.status(authId) } returns null + every { onboardingPathService.hasPathForMe(authId) } returns true + every { userApi.canManageProject(authId, projectId) } returns true + every { onboardingGenerationRegistry.startOrAttach(authId, projectId) } throws + ResponseStatusException(HttpStatus.BAD_REQUEST, "reached") + + mockMvc + .perform( + post("/api/v1/projects/$projectId/onboarding/me/path/personalize") + .with(userJwt), + ).andExpect(status().isBadRequest) + + verify(exactly = 1) { onboardingGenerationRegistry.startOrAttach(authId, projectId) } + } + + @Test + fun `personalizePath still attaches a member to a running rebuild over their path`() { + every { onboardingGenerationRegistry.status(authId) } returns + OnboardingGenerationRegistry.GenerationRun(projectId = projectId, startedAt = Instant.now()) + every { onboardingGenerationRegistry.startOrAttach(authId, projectId) } throws + ResponseStatusException(HttpStatus.BAD_REQUEST, "reached") + + mockMvc + .perform( + post("/api/v1/projects/$projectId/onboarding/me/path/personalize") + .with(userJwt), + ).andExpect(status().isBadRequest) + + verify(exactly = 0) { onboardingPathService.hasPathForMe(any()) } + verify(exactly = 1) { onboardingGenerationRegistry.startOrAttach(authId, projectId) } + } + + // ========================== PM rebuild of a member's path ========================== + + @Test + fun `personalizePathForUser starts the generation under the member's auth id`() { + val memberAuthId = "member-auth-id" + every { projectAuthorization.canManageProject(any(), projectId) } returns true + every { userApi.getAuthIdByUserId(userId) } returns Optional.of(memberAuthId) + every { onboardingGenerationRegistry.startOrAttach(memberAuthId, projectId) } throws + ResponseStatusException(HttpStatus.BAD_REQUEST, "reached") + + mockMvc + .perform( + post("/api/v1/projects/$projectId/onboarding/users/$userId/path/personalize") + .with(adminJwt), + ).andExpect(status().isBadRequest) + + verify(exactly = 1) { onboardingGenerationRegistry.startOrAttach(memberAuthId, projectId) } + } + + @Test + fun `personalizePathForUser is refused to anyone who does not manage the project`() { + every { projectAuthorization.canManageProject(any(), projectId) } returns false + + mockMvc + .perform( + post("/api/v1/projects/$projectId/onboarding/users/$userId/path/personalize") + .with(userJwt), + ).andExpect(status().isForbidden) + + verify(exactly = 0) { onboardingGenerationRegistry.startOrAttach(any(), any()) } + } + + @Test + fun `personalizePathForUser returns 404 for an unknown member`() { + every { projectAuthorization.canManageProject(any(), projectId) } returns true + every { userApi.getAuthIdByUserId(userId) } returns Optional.empty() + + mockMvc + .perform( + post("/api/v1/projects/$projectId/onboarding/users/$userId/path/personalize") + .with(adminJwt), + ).andExpect(status().isNotFound) + } + + @Test + fun `personalizePathForUser should return 401 when not authenticated`() { + mockMvc + .perform( + post("/api/v1/projects/$projectId/onboarding/users/$userId/path/personalize"), + ).andExpect(status().isUnauthorized) + } + @Test fun `getGenerationStatus reports a running generation and the project's blueprint`() { val startedAt = Instant.parse("2026-09-15T10:00:00Z") diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt index 9e5d04b1..83935bb2 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt @@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException import java.util.Optional import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class OnboardingPathServiceTest { @@ -253,6 +254,24 @@ class OnboardingPathServiceTest { } } + @Nested + inner class HasPathForMe { + @Test + fun `is true when the user has a path`() { + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { onboardingPathRepository.existsByUserId(userId) } returns true + + assertTrue(service.hasPathForMe(authId)) + } + + @Test + fun `is false for an unknown user`() { + every { userApi.getUserIdByAuthId(authId) } returns Optional.empty() + + assertFalse(service.hasPathForMe(authId)) + } + } + @Nested inner class GetTeamOverview { private val pageable = PageRequest.of(0, 10) From 19f894e297b6d4cbfcf59de5d0d433e0fda9e423 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 26 Sep 2026 15:41:05 +0200 Subject: [PATCH 2/3] Let a member retry a path whose every phase failed An empty path holds no progress, so retrying it is not a rebuild. The guard on /me/path/personalize now only protects a path with phases. Co-Authored-By: Claude Opus 5.5 --- .../controller/OnboardingPathController.kt | 9 +++--- .../service/OnboardingPathService.kt | 13 +++++---- .../OnboardingPathControllerTest.kt | 8 +++--- .../service/OnboardingPathServiceTest.kt | 28 +++++++++++++++---- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt index 6217dd52..e1a0edaf 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt @@ -247,9 +247,10 @@ class ProjectOnboardingPathController( * template. The service rejects a project the user is not assigned to. Any existing path is * replaced. A project must have exactly one active blueprint. * - * A member builds their *first* path here; rebuilding one they already have is the project - * manager's call ([personalizePathForUser]), because it throws away the member's progress. The - * project's manager and admins may still replace their own path from here. + * A member builds their *first* path here (or retries one whose every phase failed); rebuilding + * a path with phases in it is the project manager's call ([personalizePathForUser]), because it + * throws away the member's progress. The project's manager and admins may still replace their + * own path from here. * * The generation runs detached from this request (see [OnboardingGenerationRegistry]): closing * the stream does not cancel it, and a request while one is running watches that one instead of @@ -294,7 +295,7 @@ class ProjectOnboardingPathController( // member. Only starting a new one over an existing path is the manager's call. val startsNewRun = onboardingGenerationRegistry.status(jwt.subject) == null if (startsNewRun && - onboardingPathService.hasPathForMe(jwt.subject) && + onboardingPathService.hasBuiltPathForMe(jwt.subject) && !userApi.canManageProject(jwt.subject, projectId) ) { throw ResponseStatusException( diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt index cbb98b0f..90d2d21e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathService.kt @@ -67,17 +67,20 @@ class OnboardingPathService( } /** - * Whether the authenticated user already has an onboarding path. An unknown user has none. + * Whether the authenticated user has an onboarding path with anything in it. An unknown user + * has none; neither does one whose every phase failed to generate -- an empty path holds no + * progress, so building it again takes nothing away. * * @param authId External authentication identifier. - * @return `true` when a path exists for the user. + * @return `true` when the user's path has at least one phase. */ @Transactional(readOnly = true) - @Tracked("Checking whether the user has an onboarding path") - fun hasPathForMe(authId: String): Boolean = + @Tracked("Checking whether the user has a built onboarding path") + fun hasBuiltPathForMe(authId: String): Boolean = userApi .getUserIdByAuthId(authId) - .map { userId -> onboardingPathRepository.existsByUserId(userId) } + .flatMap { userId -> onboardingPathRepository.findByUserId(userId) } + .map { path -> path.phases.isNotEmpty() } .orElse(false) /** diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt index 362e21e1..1ef4772f 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt @@ -196,7 +196,7 @@ class OnboardingPathControllerTest( @Test fun `personalizePath passes the selected project path variable to the generation registry`() { every { onboardingGenerationRegistry.status(authId) } returns null - every { onboardingPathService.hasPathForMe(authId) } returns false + every { onboardingPathService.hasBuiltPathForMe(authId) } returns false every { onboardingGenerationRegistry.startOrAttach(authId, projectId) } throws ResponseStatusException(HttpStatus.BAD_REQUEST, "rejected") @@ -214,7 +214,7 @@ class OnboardingPathControllerTest( @Test fun `personalizePath refuses a member rebuilding a path they already have`() { every { onboardingGenerationRegistry.status(authId) } returns null - every { onboardingPathService.hasPathForMe(authId) } returns true + every { onboardingPathService.hasBuiltPathForMe(authId) } returns true every { userApi.canManageProject(authId, projectId) } returns false mockMvc @@ -229,7 +229,7 @@ class OnboardingPathControllerTest( @Test fun `personalizePath lets the project's manager rebuild their own path`() { every { onboardingGenerationRegistry.status(authId) } returns null - every { onboardingPathService.hasPathForMe(authId) } returns true + every { onboardingPathService.hasBuiltPathForMe(authId) } returns true every { userApi.canManageProject(authId, projectId) } returns true every { onboardingGenerationRegistry.startOrAttach(authId, projectId) } throws ResponseStatusException(HttpStatus.BAD_REQUEST, "reached") @@ -256,7 +256,7 @@ class OnboardingPathControllerTest( .with(userJwt), ).andExpect(status().isBadRequest) - verify(exactly = 0) { onboardingPathService.hasPathForMe(any()) } + verify(exactly = 0) { onboardingPathService.hasBuiltPathForMe(any()) } verify(exactly = 1) { onboardingGenerationRegistry.startOrAttach(authId, projectId) } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt index 83935bb2..c6973cbd 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/OnboardingPathServiceTest.kt @@ -255,20 +255,38 @@ class OnboardingPathServiceTest { } @Nested - inner class HasPathForMe { + inner class HasBuiltPathForMe { @Test - fun `is true when the user has a path`() { + fun `is true when the user's path has phases`() { every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) - every { onboardingPathRepository.existsByUserId(userId) } returns true + every { onboardingPathRepository.findByUserId(userId) } returns + Optional.of(mockk { every { phases } returns mutableListOf(mockk()) }) - assertTrue(service.hasPathForMe(authId)) + assertTrue(service.hasBuiltPathForMe(authId)) + } + + @Test + fun `is false when every phase failed to generate`() { + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { onboardingPathRepository.findByUserId(userId) } returns + Optional.of(mockk { every { phases } returns mutableListOf() }) + + assertFalse(service.hasBuiltPathForMe(authId)) + } + + @Test + fun `is false for a user without a path`() { + every { userApi.getUserIdByAuthId(authId) } returns Optional.of(userId) + every { onboardingPathRepository.findByUserId(userId) } returns Optional.empty() + + assertFalse(service.hasBuiltPathForMe(authId)) } @Test fun `is false for an unknown user`() { every { userApi.getUserIdByAuthId(authId) } returns Optional.empty() - assertFalse(service.hasPathForMe(authId)) + assertFalse(service.hasBuiltPathForMe(authId)) } } From 5eb61ea34eafdaa0b81163b96f5f1941e38cb360 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 26 Sep 2026 16:06:12 +0200 Subject: [PATCH 3/3] Restrict deleting one's own onboarding path to PM and admin A member deleting their path and building a new one was the same rebuild that is now the project manager's call. DELETE /onboarding/me/path now requires PM or ADMIN; the frontend never called it for members. Co-Authored-By: Claude Opus 5.5 --- .../controller/OnboardingPathController.kt | 8 ++++++-- .../controller/OnboardingPathControllerTest.kt | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt index e1a0edaf..6181d778 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathController.kt @@ -88,11 +88,15 @@ class OnboardingPathController( * This removes the hierarchy root at depth 0. Any nested descendants below that * root are deleted according to the persistence rules of the underlying model. * + * PM and admin only: a member deleting their path and building a new one would be the rebuild + * that is the project manager's call (see [ProjectOnboardingPathController.personalizePathForUser]). + * * @param jwt Authenticated JWT used to resolve the current user. */ @Operation( summary = "Delete current user's onboarding path", - description = "Deletes the onboarding path at hierarchy depth 0 for the authenticated user.", + description = "Deletes the onboarding path at hierarchy depth 0 for the authenticated user. " + + "PM and admin only: members cannot discard their own path.", ) @ApiResponses( value = [ @@ -104,7 +108,7 @@ class OnboardingPathController( ) @ResponseStatus(HttpStatus.NO_CONTENT) @DeleteMapping("/me/path") - @PreAuthorize("hasRole('USER')") + @PreAuthorize("hasAnyRole('PM', 'ADMIN')") fun deletePathForMe( @Parameter(hidden = true) @AuthenticationPrincipal jwt: Jwt, diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt index 1ef4772f..109ff25d 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/OnboardingPathControllerTest.kt @@ -86,6 +86,7 @@ class OnboardingPathControllerTest( private val userJwt = jwtWithSubject(authId, "USER") private val adminJwt = jwtWithSubject(adminAuthId, "USER", "ADMIN") private val noUserRoleJwt = jwtWithSubject(authId, "NONE") + private val pmJwt = jwtWithSubject(authId, "USER", "PM") // ========================== /me endpoints ========================== @@ -151,7 +152,7 @@ class OnboardingPathControllerTest( mockMvc .perform( delete("/api/v1/onboarding/me/path") - .with(userJwt), + .with(pmJwt), ).andExpect(status().isNoContent) verify(exactly = 1) { @@ -175,6 +176,17 @@ class OnboardingPathControllerTest( ).andExpect(status().isForbidden) } + @Test + fun `deleteOnboardingPathForMe is refused to a plain member`() { + mockMvc + .perform( + delete("/api/v1/onboarding/me/path") + .with(userJwt), + ).andExpect(status().isForbidden) + + verify(exactly = 0) { onboardingPathService.deleteOnboardingPathForMe(any()) } + } + @Test fun `deleteOnboardingPathForMe should return 404 when not found`() { every { onboardingPathService.deleteOnboardingPathForMe(authId) } throws @@ -183,7 +195,7 @@ class OnboardingPathControllerTest( mockMvc .perform( delete("/api/v1/onboarding/me/path") - .with(userJwt), + .with(pmJwt), ).andExpect(status().isNotFound) verify(exactly = 1) {