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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/dev/escalated/ui/UiRoutes.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/dev/escalated/ui/controllers/AgentUiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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<Map<String, String>> 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<Void> customAction(@PathVariable("ticket") String reference, @PathVariable String action,
HttpServletRequest request, Authentication authentication) {
Expand Down
152 changes: 152 additions & 0 deletions src/test/java/dev/escalated/ui/AgentChatEndUiTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading