feat: add chat sharing foundation (#25041)

This commit is contained in:
Danielle Maywood
2026-05-18 22:32:05 +01:00
committed by GitHub
parent 2732378da2
commit 170a6e1fe9
49 changed files with 1872 additions and 103 deletions
+227
View File
@@ -14291,6 +14291,233 @@ func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Erro
return sdkErr
}
func TestChatReadOnlySharedWriteHandlers(t *testing.T) {
t.Parallel()
const sharedChatText = "read only shared chat"
setup := func(t *testing.T) (
ctx context.Context,
ownerClient *codersdk.ExperimentalClient,
sharedClient *codersdk.ExperimentalClient,
chat codersdk.Chat,
db database.Store,
) {
t.Helper()
ctx = testutil.Context(t, testutil.WaitLong)
ownerClient, db = newChatClientWithDatabase(t)
owner := coderdtest.CreateFirstUser(t, ownerClient.Client)
_ = createChatModelConfig(t, ownerClient)
sharedRaw, sharedUser := coderdtest.CreateAnotherUser(
t,
ownerClient.Client,
owner.OrganizationID,
rbac.ScopedRoleAgentsAccess(owner.OrganizationID),
)
sharedClient = codersdk.NewExperimentalClient(sharedRaw)
var err error
chat, err = ownerClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: owner.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: sharedChatText,
}},
})
require.NoError(t, err)
err = db.UpdateChatACLByID(dbauthz.As(ctx, rbac.Subject{
ID: owner.UserID.String(),
Roles: rbac.RoleIdentifiers{rbac.RoleOwner()},
Scope: rbac.ScopeAll,
}), database.UpdateChatACLByIDParams{
ID: chat.ID,
UserACL: database.ChatACL{
sharedUser.ID.String(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
},
GroupACL: database.ChatACL{},
})
require.NoError(t, err)
return ctx, ownerClient, sharedClient, chat, db
}
t.Run("GetChatAndMessages", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
gotChat, err := sharedClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, gotChat.ID)
messagesResult, err := sharedClient.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.NotEmpty(t, messagesResult.Messages)
foundUserMessage := false
for _, message := range messagesResult.Messages {
if message.Role != codersdk.ChatMessageRoleUser {
continue
}
for _, part := range message.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == sharedChatText {
foundUserMessage = true
break
}
}
}
require.True(t, foundUserMessage)
})
t.Run("PatchChat", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
err := sharedClient.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Archived: ptr.Ref(true),
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("PatchChatMessage", func(t *testing.T) {
t.Parallel()
ctx, ownerClient, sharedClient, chat, _ := setup(t)
messagesResult, err := ownerClient.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var userMessageID int64
for _, msg := range messagesResult.Messages {
if msg.Role == codersdk.ChatMessageRoleUser {
userMessageID = msg.ID
break
}
}
require.NotZero(t, userMessageID)
_, err = sharedClient.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "read only user cannot edit",
}},
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("PostChatMessages", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "read only user cannot send messages",
}},
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("PromoteChatQueuedMessage", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, db := setup(t)
queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("queued"),
})
require.NoError(t, err)
queuedMessage, err := db.InsertChatQueuedMessage(
dbauthz.AsSystemRestricted(ctx),
database.InsertChatQueuedMessageParams{
ChatID: chat.ID,
Content: queuedContent,
},
)
require.NoError(t, err)
res, err := sharedClient.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("PostChatToolResults", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
err := sharedClient.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
Results: []codersdk.ToolResult{{
ToolCallID: "call_read_only",
Output: json.RawMessage(`"forbidden"`),
}},
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("DeleteChatQueuedMessage", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, db := setup(t)
queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("queued"),
})
require.NoError(t, err)
queuedMessage, err := db.InsertChatQueuedMessage(
dbauthz.AsSystemRestricted(ctx),
database.InsertChatQueuedMessageParams{
ChatID: chat.ID,
Content: queuedContent,
},
)
require.NoError(t, err)
res, err := sharedClient.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("InterruptChat", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.InterruptChat(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("RegenerateChatTitle", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.RegenerateChatTitle(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("ProposeChatTitle", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.ProposeChatTitle(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
}
// TestChatOwnerOnlyWriteHandlers verifies that only the chat owner can
// call handlers that trigger chat processing. Org admins pass the RBAC
// ActionUpdate check (org-level permission) but must still be blocked