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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ templates/**/.claude/commands/speckit*.md
# Scoped to worktrees/ rather than all of .claude/ so tracked agent config
# elsewhere in the estate is not silently ignored.
.claude/worktrees/

# Kotlin compiler daemon session lock files.
.kotlin/
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import com.jorisjonkers.personalstack.agents.application.RepositoryInstallationS
import com.jorisjonkers.personalstack.agents.application.RepositoryVerificationService
import com.jorisjonkers.personalstack.agents.application.chat.ChatAnswerStreamService
import com.jorisjonkers.personalstack.agents.application.maintenance.RunnerMaintenanceService
import com.jorisjonkers.personalstack.agents.application.query.ChatSessionQueryService
import com.jorisjonkers.personalstack.agents.application.query.ConversationQueryService
import com.jorisjonkers.personalstack.agents.application.query.GetTurnHistoryQueryService
import com.jorisjonkers.personalstack.agents.application.query.GetWorkspaceQueryService
import com.jorisjonkers.personalstack.agents.application.query.ListWorkspacesQueryService
Expand Down Expand Up @@ -34,6 +34,7 @@ import com.jorisjonkers.personalstack.agents.infrastructure.web.AgentSessionCont
import com.jorisjonkers.personalstack.agents.infrastructure.web.AgentSetupController
import com.jorisjonkers.personalstack.agents.infrastructure.web.AgentSetupExceptionHandler
import com.jorisjonkers.personalstack.agents.infrastructure.web.ChatSessionController
import com.jorisjonkers.personalstack.agents.infrastructure.web.ConversationController
import com.jorisjonkers.personalstack.agents.infrastructure.web.CredentialController
import com.jorisjonkers.personalstack.agents.infrastructure.web.GitController
import com.jorisjonkers.personalstack.agents.infrastructure.web.HealthController
Expand Down Expand Up @@ -77,6 +78,7 @@ import java.nio.file.Paths
AgentSetupController::class,
AgentSessionController::class,
ChatSessionController::class,
ConversationController::class,
CredentialController::class,
GitController::class,
HealthController::class,
Expand Down Expand Up @@ -112,6 +114,7 @@ import java.nio.file.Paths
AgentSetupController::class,
AgentSessionController::class,
ChatSessionController::class,
ConversationController::class,
CredentialController::class,
GitController::class,
HealthController::class,
Expand Down Expand Up @@ -246,7 +249,7 @@ class OpenApiSpecExportTest
@TestConfiguration(proxyBeanMethods = false)
class QueryCollaborators {
@Bean
fun chatSessionQueryService(): ChatSessionQueryService = mockk(relaxed = true)
fun conversationQueryService(): ConversationQueryService = mockk(relaxed = true)

@Bean
fun getTurnHistoryQueryService(): GetTurnHistoryQueryService = mockk(relaxed = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ class AgentsApiContractIntegrationTest
mockMvc
.perform(
post("/api/v1/chat-sessions/$sessionId/messages")
.header("X-User-Id", userId)
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(
Expand All @@ -273,9 +274,85 @@ class AgentsApiContractIntegrationTest
.andExpect(jsonPath("$.sessionId").value(sessionId))

mockMvc
.perform(get("/api/v1/chat-sessions/$sessionId"))
.perform(get("/api/v1/chat-sessions/$sessionId").header("X-User-Id", userId))
.andExpect(status().isOk)
.andExpect(jsonPath("$.session.id").value(sessionId))
.andExpect(jsonPath("$.messages[0].body").value("hello world"))
}

@Test
fun conversationCreationResponseMatchesExpectedSchema() {
val userId = UUID.randomUUID().toString()
mockMvc
.perform(
post("/api/v1/conversations")
.header("X-User-Id", userId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(mapOf("title" to "Demo chat"))),
).andExpect(status().isCreated)
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.userId").value(userId))
.andExpect(jsonPath("$.title").value("Demo chat"))
.andExpect(jsonPath("$.status").value("ACTIVE"))
.andExpect(jsonPath("$.kind").value("PLAIN"))
}

@Test
fun conversationListResponseIsValidJSONArray() {
val userId = UUID.randomUUID().toString()
mockMvc.perform(
post("/api/v1/conversations")
.header("X-User-Id", userId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(mapOf("title" to "List me"))),
)
mockMvc
.perform(get("/api/v1/conversations").header("X-User-Id", userId))
.andExpect(status().isOk)
.andExpect(jsonPath("$").isArray)
.andExpect(jsonPath("$[0].id").exists())
.andExpect(jsonPath("$[0].status").exists())
}

@Test
fun appendingAConversationMessageReturnsTheTypedEnvelopeAndDetail() {
val userId = UUID.randomUUID().toString()
val createResult =
mockMvc
.perform(
post("/api/v1/conversations")
.header("X-User-Id", userId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(mapOf("title" to "msg-test"))),
).andExpect(status().isCreated)
.andReturn()
val conversationId = objectMapper.readTree(createResult.response.contentAsString)["id"].asText()

mockMvc
.perform(
post("/api/v1/conversations/$conversationId/messages")
.header("X-User-Id", userId)
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(
mapOf("body" to "hello world", "role" to "USER"),
),
),
).andExpect(status().isCreated)
.andExpect(jsonPath("$.body").value("hello world"))
.andExpect(jsonPath("$.role").value("USER"))
.andExpect(jsonPath("$.conversationId").value(conversationId))

mockMvc
.perform(get("/api/v1/conversations/$conversationId").header("X-User-Id", userId))
.andExpect(status().isOk)
.andExpect(jsonPath("$.id").value(conversationId))
.andExpect(jsonPath("$.messages[0].body").value("hello world"))

mockMvc
.perform(get("/api/v1/conversations/$conversationId/messages").header("X-User-Id", userId))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].body").value("hello world"))
.andExpect(jsonPath("$[0].content").value("hello world"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.jorisjonkers.personalstack.agents.flow

import com.fasterxml.jackson.databind.ObjectMapper
import com.jorisjonkers.personalstack.agents.IntegrationTestBase
import org.junit.jupiter.api.BeforeEach
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 org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import java.util.UUID

// Split out from AgentsApiContractIntegrationTest (which was already at
// detekt's 15-function ceiling): a second user must never read or append
// to the first user's Conversation, on the canonical path or the
// deprecated alias (#80).
class ConversationOwnershipIntegrationTest
@Autowired
constructor(
private val webApplicationContext: WebApplicationContext,
) : IntegrationTestBase {
private lateinit var mockMvc: MockMvc
private val objectMapper = ObjectMapper()

@BeforeEach
fun setUp() {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build()
}

@Test
fun aSecondUserCannotReadOrAppendToTheFirstUsersConversation() {
val ownerId = UUID.randomUUID().toString()
val otherUserId = UUID.randomUUID().toString()
val conversationId = createConversation("/api/v1/conversations", ownerId)

mockMvc
.perform(get("/api/v1/conversations/$conversationId").header("X-User-Id", otherUserId))
.andExpect(status().isNotFound)

mockMvc
.perform(get("/api/v1/conversations/$conversationId/messages").header("X-User-Id", otherUserId))
.andExpect(status().isNotFound)

mockMvc
.perform(
post("/api/v1/conversations/$conversationId/messages")
.header("X-User-Id", otherUserId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(mapOf("body" to "intruder", "role" to "USER"))),
).andExpect(status().isNotFound)

mockMvc
.perform(
post("/api/v1/conversations/$conversationId/messages/stream")
.header("X-User-Id", otherUserId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(mapOf("body" to "intruder", "role" to "USER"))),
).andExpect(status().isNotFound)

mockMvc
.perform(get("/api/v1/conversations/$conversationId").header("X-User-Id", ownerId))
.andExpect(status().isOk)
.andExpect(jsonPath("$.messages").isEmpty)

mockMvc
.perform(get("/api/v1/conversations/$conversationId/messages").header("X-User-Id", ownerId))
.andExpect(status().isOk)
.andExpect(jsonPath("$").isEmpty)
}

@Test
fun aSecondUserCannotReadOrAppendToTheFirstUsersChatSession() {
val ownerId = UUID.randomUUID().toString()
val otherUserId = UUID.randomUUID().toString()
val sessionId = createConversation("/api/v1/chat-sessions", ownerId)

mockMvc
.perform(get("/api/v1/chat-sessions/$sessionId").header("X-User-Id", otherUserId))
.andExpect(status().isNotFound)

mockMvc
.perform(
post("/api/v1/chat-sessions/$sessionId/messages")
.header("X-User-Id", otherUserId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(mapOf("body" to "intruder", "role" to "USER"))),
).andExpect(status().isNotFound)

mockMvc
.perform(get("/api/v1/chat-sessions/$sessionId").header("X-User-Id", ownerId))
.andExpect(status().isOk)
.andExpect(jsonPath("$.messages").isEmpty)
}

private fun createConversation(
basePath: String,
ownerId: String,
): String {
val createResult =
mockMvc
.perform(
post(basePath)
.header("X-User-Id", ownerId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(mapOf("title" to "private"))),
).andExpect(status().isCreated)
.andReturn()
return objectMapper.readTree(createResult.response.contentAsString)["id"].asText()
}
}

This file was deleted.

Loading
Loading