From 05c40bbd490a4f667ba34251573eb11504a8930c Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Thu, 22 Oct 2026 21:07:17 -0400 Subject: [PATCH] Serve escalated.admin.chat.end from the agent UI session The shared TicketShow posts End Chat to route('escalated.admin.chat.end', chat_session_id). The only end-chat endpoint was on the stateless /escalated/api chain, which ignores the browser session, so the name did not resolve and the page could not end a chat. Add POST /escalated/agent/chat/{session}/end behind the agent gate and CSRF, 422 when already ended as escalated-laravel. --- CHANGELOG.md | 9 ++ src/main/java/dev/escalated/ui/UiRoutes.java | 2 + .../ui/controllers/AgentUiController.java | 20 +++ .../dev/escalated/ui/AgentChatEndUiTest.java | 152 ++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 src/test/java/dev/escalated/ui/AgentChatEndUiTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ceea06a..2c2bac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- End Chat on the agent ticket page works from the Inertia UI: `POST /escalated/agent/chat/{session}/end` + (`escalated.admin.chat.end`, the name the shared TicketShow asks for, taking the chat session id) + ends the session from the agent's signed-in browser session with the page's CSRF token. It reads + no body and answers `200 {"message":"Chat ended."}`, `422 {"message":"Chat is already ended."}` + when the chat has already ended (as escalated-laravel), and 404 for an unknown session. The + existing `POST /escalated/api/agent/chat/{sessionId}/end` is unchanged; it is on the stateless + API chain, so the page's browser session could not reach it. + ## [0.2.5] - 2026-10-22 ### Upgrading diff --git a/src/main/java/dev/escalated/ui/UiRoutes.java b/src/main/java/dev/escalated/ui/UiRoutes.java index fd297d5..4611cd8 100644 --- a/src/main/java/dev/escalated/ui/UiRoutes.java +++ b/src/main/java/dev/escalated/ui/UiRoutes.java @@ -42,6 +42,8 @@ public UiRoutes() { register("escalated.agent.tickets.macro", "/agent/tickets/{ticket}/macro"); register("escalated.agent.tickets.follow", "/agent/tickets/{ticket}/follow"); register("escalated.agent.tickets.custom-action", "/agent/tickets/{ticket}/actions/{action}"); + // End Chat on the agent ticket page; Laravel serves it at /admin/chat/{session}/end under this name. + register("escalated.admin.chat.end", "/agent/chat/{session}/end"); // Customer register("escalated.customer.tickets.index", ""); register("escalated.customer.tickets.create", "/create"); diff --git a/src/main/java/dev/escalated/ui/controllers/AgentUiController.java b/src/main/java/dev/escalated/ui/controllers/AgentUiController.java index 719ad19..2b82f35 100644 --- a/src/main/java/dev/escalated/ui/controllers/AgentUiController.java +++ b/src/main/java/dev/escalated/ui/controllers/AgentUiController.java @@ -5,6 +5,7 @@ import dev.escalated.models.Ticket; import dev.escalated.repositories.AgentProfileRepository; import dev.escalated.repositories.TagRepository; +import dev.escalated.services.ChatSessionService; import dev.escalated.services.MacroService; import dev.escalated.services.TicketActionRegistry; import dev.escalated.services.TicketService; @@ -16,6 +17,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.LinkedHashMap; import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.ResponseEntity; @@ -42,6 +44,9 @@ public class AgentUiController extends TicketWritesController { private final TicketActionRegistry ticketActions; private final ApplicationEventPublisher events; + @Autowired + private ChatSessionService chats; + public AgentUiController( EscalatedUiRenderer renderer, Inertia inertia, @@ -75,6 +80,21 @@ public Object show(@PathVariable("ticket") String reference, Authentication auth return renderer.render("Escalated/Agent/TicketShow", pages.show(ticket.getId(), agentId(authentication))); } + /** + * End Chat on TicketShow ({@code escalated.admin.chat.end}): the chat session named by id, from + * the agent's browser session. The {@code /escalated/api/agent/chat/{id}/end} endpoint is on the + * stateless API chain and does not see that session. Takes no body fields. 200 with a message, + * 422 when the chat has already ended (as escalated-laravel), 404 for an unknown session. + */ + @PostMapping("/chat/{session}/end") + public ResponseEntity> endChat(@PathVariable("session") Long sessionId) { + if ("ended".equals(chats.findById(sessionId).getStatus())) { + return ResponseEntity.unprocessableEntity().body(Map.of("message", "Chat is already ended.")); + } + chats.end(sessionId); + return ResponseEntity.ok(Map.of("message", "Chat ended.")); + } + @PostMapping("/tickets/{ticket}/actions/{action}") public ResponseEntity customAction(@PathVariable("ticket") String reference, @PathVariable String action, HttpServletRequest request, Authentication authentication) { diff --git a/src/test/java/dev/escalated/ui/AgentChatEndUiTest.java b/src/test/java/dev/escalated/ui/AgentChatEndUiTest.java new file mode 100644 index 0000000..d3d2984 --- /dev/null +++ b/src/test/java/dev/escalated/ui/AgentChatEndUiTest.java @@ -0,0 +1,152 @@ +package dev.escalated.ui; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.escalated.models.AgentProfile; +import dev.escalated.models.ChatSession; +import dev.escalated.repositories.AgentProfileRepository; +import dev.escalated.services.ChatSessionService; +import hostapp.HostApplication; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +/** + * End Chat on the agent ticket page: {@code route('escalated.admin.chat.end', chat_session_id)} + * POSTed with {@code {}}, the csrf-token meta tag and the agent's browser session, as the shared + * frontend's ChatActionBar does. + */ +@SpringBootTest(classes = HostApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.mail.host=localhost", + "escalated.ui.enabled=true", + "escalated.ui.version=build-1", +}) +class AgentChatEndUiTest { + + @Autowired private MockMvc mvc; + @Autowired private AgentProfileRepository agents; + @Autowired private ChatSessionService chats; + @Autowired private UiRoutes routes; + + private AgentProfile agent; + private Long sessionId; + + @BeforeEach + void seed() { + String unique = UUID.randomUUID().toString().substring(0, 8); + agent = new AgentProfile(); + agent.setName("Agent " + unique); + agent.setEmail("chat-agent-" + unique + "@example.com"); + agent.setAgent(true); + agent = agents.save(agent); + sessionId = chats.startForWidget("Visitor", "visitor-" + unique + "@example.com", "Hello?", null, null) + .session().getId(); + } + + @Test + void theRouteNameResolvesToTheUiEndpoint() { + assertThat(routes.url("escalated.admin.chat.end", sessionId)) + .isEqualTo("/escalated/agent/chat/" + sessionId + "/end"); + assertThat(routes.script()).contains("escalated.admin.chat.end"); + } + + @Test + void anAgentsBrowserSessionEndsTheChatThroughTheNamedRoute() throws Exception { + endChat(routes.url("escalated.admin.chat.end", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Chat ended.")); + + ChatSession ended = chats.findById(sessionId); + assertThat(ended.getStatus()).isEqualTo("ended"); + assertThat(ended.getEndedAt()).isNotNull(); + } + + @Test + void anAlreadyEndedChatIsA422() throws Exception { + chats.end(sessionId); + + endChat(routes.url("escalated.admin.chat.end", sessionId)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.message").value("Chat is already ended.")); + } + + @Test + void anUnknownSessionIsA404() throws Exception { + endChat(routes.url("escalated.admin.chat.end", Long.MAX_VALUE)).andExpect(status().isNotFound()); + } + + @Test + void withoutTheCsrfTokenItIsRefused() throws Exception { + mvc.perform(post(routes.url("escalated.admin.chat.end", sessionId)).session(agentSession()) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isForbidden()); + assertThat(chats.findById(sessionId).getStatus()).isNotEqualTo("ended"); + } + + @Test + void someoneWhoIsNotAnAgentCannotEndAChat() throws Exception { + mvc.perform(withCsrf(post(routes.url("escalated.admin.chat.end", sessionId))) + .with(user("customer-" + UUID.randomUUID() + "@example.com")) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isForbidden()); + assertThat(chats.findById(sessionId).getStatus()).isNotEqualTo("ended"); + } + + @Test + void theStatelessApiEndpointDoesNotAcceptTheBrowserSessionButStillWorksForApiCallers() throws Exception { + // Why the page needs the UI route: /escalated/api/** ignores the session cookie. + mvc.perform(post("/escalated/api/agent/chat/{id}/end", sessionId).session(agentSession()) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(result -> assertThat(result.getResponse().getStatus()).isIn(401, 403)); + assertThat(chats.findById(sessionId).getStatus()).isNotEqualTo("ended"); + + // The existing API shape is unchanged for an authenticated caller. + mvc.perform(post("/escalated/api/agent/chat/{id}/end", sessionId).with(user(agent.getEmail()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ended")); + } + + private org.springframework.test.web.servlet.ResultActions endChat(String url) throws Exception { + return mvc.perform(withCsrf(post(url)).session(agentSession()) + .accept(MediaType.APPLICATION_JSON) + .header("X-Requested-With", "XMLHttpRequest") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + } + + /** The csrf-token meta tag's value sent back as X-CSRF-TOKEN, with its cookie. */ + private static MockHttpServletRequestBuilder withCsrf(MockHttpServletRequestBuilder request) { + String token = UUID.randomUUID().toString(); + return request.cookie(new jakarta.servlet.http.Cookie("XSRF-TOKEN", token)).header("X-CSRF-TOKEN", token); + } + + /** A signed-in browser session: the security context stored in the HTTP session. */ + private MockHttpSession agentSession() { + MockHttpSession session = new MockHttpSession(); + UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( + agent.getEmail(), null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + new SecurityContextImpl(authentication)); + return session; + } +}