mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
- Experiment, behind `chat-projects` (off by default). First of three stacked PRs: #29301 (memory), #29583 (UI). This PR is API only; the screenshots below are from the UI in #29583. - CRUD under `/api/experimental/organizations/{organization}/chats/projects/{project}`. `GET /api/experimental/chats/projects` lists the caller's projects across all organizations. - `project_id` on chat create and as a chat list filter. A chat joins a project only at creation; there is no move. - Projects are identified by ID. Names are display labels (64 characters, description 1024) with no uniqueness constraint. An optional `icon` URL (usually an emoji image under `/emojis`) is capped at 256 characters and audited. - A user can own at most 100 projects across all organizations; creating another answers 409. Creates count and insert under a per-owner advisory lock so concurrent requests cannot overshoot. - Projects are private to their `owner_id`. Members may only create their own, and a chat can only be bound to a project its owner owns. An unreadable or foreign project on chat create answers 404 so IDs cannot be probed; a project from another organization answers 400. - Permissions ride on the `agents-access` role next to chats (after #30004), so whoever can use Coder Agents can use projects. Owners and org admins reach every project. ### Examples Create a project in an organization: ```http POST /api/experimental/organizations/{organization}/chats/projects {"name": "Platform", "description": "Infra and deploy work", "icon": "/emojis/1f680.png"} ``` ```json { "id": "7c0e4a3e-5b1f-4f7d-9a51-2f3c8f6d2b10", "organization_id": "bb640d07-ca8a-4869-b6bc-ae61ebb2fda1", "owner_id": "0ed9befc-4911-4ccf-a8e2-559bf72daa94", "name": "Platform", "description": "Infra and deploy work", "icon": "/emojis/1f680.png", "created_at": "2026-09-30T15:04:05Z", "updated_at": "2026-09-30T15:04:05Z" } ``` List the caller's projects across organizations with `GET /api/experimental/chats/projects` (an array of the object above). Update with `PATCH .../chats/projects/{project}` and any of `name`, `description`, `icon`; delete with `DELETE`, which detaches its chats. Create a chat in the project, and list only its chats: ```http POST /api/v2/chats {"organization_id": "bb640d07-…", "project_id": "7c0e4a3e-…", "content": [{"type": "text", "text": "Plan the deploy"}]} GET /api/v2/chats?project_id=7c0e4a3e-… ``` At the limit: ```json 409 {"message": "You can have at most 100 chat projects. Delete a project to create another."} ``` <details> <summary>Screenshots (UI from #29583)</summary>      </details> --- Generated with Coder Agents on behalf of @f0ssel. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
372 lines
15 KiB
Go
372 lines
15 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
func TestChatProjectsCRUDListAndDeleteDetaches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatProjectClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
|
|
project := createChatProject(t, client, firstUser.OrganizationID, "Project One")
|
|
otherOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false})
|
|
otherOrganizationProject := dbgen.ChatProject(t, db, database.ChatProject{
|
|
OrganizationID: otherOrganization.ID,
|
|
OwnerID: firstUser.UserID,
|
|
Name: "Other Organization Project",
|
|
})
|
|
|
|
// The list spans organizations so clients can load it in one request.
|
|
projects, err := client.ListChatProjects(ctx)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t,
|
|
[]uuid.UUID{project.ID, otherOrganizationProject.ID},
|
|
[]uuid.UUID{projects[0].ID, projects[1].ID},
|
|
)
|
|
|
|
chat := createChatInProject(t, client, firstUser.OrganizationID, &project.ID)
|
|
|
|
fetched, err := client.GetChatProject(ctx, firstUser.OrganizationID, project.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, project.ID, fetched.ID)
|
|
|
|
// The project exists and is readable, but not under this organization.
|
|
_, err = client.GetChatProject(ctx, otherOrganization.ID, project.ID)
|
|
require.Equal(t, 404, coderdtest.SDKError(t, err).StatusCode())
|
|
|
|
updatedName := "Renamed Project"
|
|
updatedDescription := "Updated description"
|
|
updatedIcon := " /emojis/1f680.png "
|
|
updated, err := client.UpdateChatProject(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectRequest{
|
|
Name: &updatedName,
|
|
Description: &updatedDescription,
|
|
Icon: &updatedIcon,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, updatedName, updated.Name)
|
|
require.Equal(t, updatedDescription, updated.Description)
|
|
require.Equal(t, "/emojis/1f680.png", updated.Icon)
|
|
|
|
// Omitted fields keep their stored values.
|
|
keptName := "Renamed Again"
|
|
updated, err = client.UpdateChatProject(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectRequest{Name: &keptName})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "/emojis/1f680.png", updated.Icon)
|
|
|
|
longIcon := strings.Repeat("x", 257)
|
|
_, err = client.UpdateChatProject(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectRequest{Icon: &longIcon})
|
|
requireChatProjectFieldError(t, err, "icon", "Icon must be at most 256 characters.")
|
|
|
|
blankName := " "
|
|
_, err = client.UpdateChatProject(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectRequest{Name: &blankName})
|
|
requireChatProjectFieldError(t, err, "name", "Name must not be blank.")
|
|
|
|
// Names are labels, not identifiers: the same owner may reuse one
|
|
// through create or rename, and each project stays addressable by ID.
|
|
duplicate := createChatProject(t, client, firstUser.OrganizationID, keptName)
|
|
require.Equal(t, keptName, duplicate.Name)
|
|
require.NotEqual(t, project.ID, duplicate.ID)
|
|
duplicateAgain := createChatProject(t, client, firstUser.OrganizationID, keptName)
|
|
require.NotEqual(t, duplicate.ID, duplicateAgain.ID)
|
|
projects, err = client.ListChatProjects(ctx)
|
|
require.NoError(t, err)
|
|
var sameName []uuid.UUID
|
|
for _, listed := range projects {
|
|
if listed.Name == keptName {
|
|
sameName = append(sameName, listed.ID)
|
|
}
|
|
}
|
|
require.ElementsMatch(t, []uuid.UUID{project.ID, duplicate.ID, duplicateAgain.ID}, sameName)
|
|
fetched, err = client.GetChatProject(ctx, firstUser.OrganizationID, duplicate.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, duplicate.ID, fetched.ID)
|
|
|
|
require.NoError(t, client.DeleteChatProject(ctx, firstUser.OrganizationID, project.ID))
|
|
storedChat, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedChat.ProjectID)
|
|
}
|
|
|
|
func TestChatProjectsAuthorizationAndCrossOrganizationBinding(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatProjectClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
project := createChatProject(t, client, firstUser.OrganizationID, "Protected Project")
|
|
|
|
// Projects are private to their creator until shared: another member
|
|
// neither sees nor can bind chats to it.
|
|
memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
|
member := codersdk.NewExperimentalClient(memberRaw)
|
|
_, err := member.GetChatProject(ctx, firstUser.OrganizationID, project.ID)
|
|
require.Equal(t, 404, coderdtest.SDKError(t, err).StatusCode())
|
|
projects, err := member.ListChatProjects(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, projects)
|
|
_, err = member.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
ProjectID: &project.ID,
|
|
Content: []codersdk.ChatInputPart{{
|
|
Type: codersdk.ChatInputPartTypeText,
|
|
Text: "reject private project",
|
|
}},
|
|
})
|
|
requireChatProjectNotFound(t, err)
|
|
|
|
_, err = member.UpdateChatProject(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectRequest{})
|
|
require.Equal(t, 404, coderdtest.SDKError(t, err).StatusCode())
|
|
err = member.DeleteChatProject(ctx, firstUser.OrganizationID, project.ID)
|
|
require.Equal(t, 404, coderdtest.SDKError(t, err).StatusCode())
|
|
|
|
// Members still create their own projects, which only they see.
|
|
memberProject := createChatProject(t, member, firstUser.OrganizationID, "Member Project")
|
|
projects, err = member.ListChatProjects(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, projects, 1)
|
|
require.Equal(t, memberProject.ID, projects[0].ID)
|
|
// Like chats, the list holds only the caller's own projects, even for a
|
|
// site owner who can read others' projects by ID.
|
|
projects, err = client.ListChatProjects(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, projects, 1)
|
|
require.Equal(t, project.ID, projects[0].ID)
|
|
_, err = client.GetChatProject(ctx, firstUser.OrganizationID, memberProject.ID)
|
|
require.NoError(t, err)
|
|
|
|
otherOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false})
|
|
otherProject := dbgen.ChatProject(t, db, database.ChatProject{
|
|
OrganizationID: otherOrganization.ID,
|
|
OwnerID: firstUser.UserID,
|
|
Name: "Other Project",
|
|
})
|
|
otherMemberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrganization.ID)
|
|
otherMember := codersdk.NewExperimentalClient(otherMemberRaw)
|
|
_, err = otherMember.GetChatProject(ctx, otherOrganization.ID, project.ID)
|
|
require.Equal(t, 404, coderdtest.SDKError(t, err).StatusCode())
|
|
|
|
_, err = client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
ProjectID: &otherProject.ID,
|
|
Content: []codersdk.ChatInputPart{{
|
|
Type: codersdk.ChatInputPartTypeText,
|
|
Text: "reject cross-organization project",
|
|
}},
|
|
})
|
|
sdkErr := coderdtest.SDKError(t, err)
|
|
require.Equal(t, 400, sdkErr.StatusCode())
|
|
require.Equal(t, "Chat project does not belong to this chat's organization.", sdkErr.Message)
|
|
|
|
adminRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID,
|
|
rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID))
|
|
admin := codersdk.NewExperimentalClient(adminRaw)
|
|
adminName := "Updated by admin"
|
|
_, err = admin.UpdateChatProject(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectRequest{Name: &adminName})
|
|
require.NoError(t, err)
|
|
|
|
// The owner may create chats for other users, but binding one to the
|
|
// owner's project would expose its memory to someone who cannot read
|
|
// the project, so the chat owner must own the project. The response
|
|
// matches an unknown project so the check does not reveal existence.
|
|
_, memberUser := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
|
_, err = client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
OwnerID: &memberUser.ID,
|
|
ProjectID: &project.ID,
|
|
Content: []codersdk.ChatInputPart{{
|
|
Type: codersdk.ChatInputPartTypeText,
|
|
Text: "reject binding another user's chat",
|
|
}},
|
|
})
|
|
requireChatProjectNotFound(t, err)
|
|
|
|
missingProjectID := uuid.New()
|
|
_, err = client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
ProjectID: &missingProjectID,
|
|
Content: []codersdk.ChatInputPart{{
|
|
Type: codersdk.ChatInputPartTypeText,
|
|
Text: "reject missing project",
|
|
}},
|
|
})
|
|
requireChatProjectNotFound(t, err)
|
|
require.NoError(t, admin.DeleteChatProject(ctx, firstUser.OrganizationID, project.ID))
|
|
}
|
|
|
|
func TestChatProjectFieldLimits(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, _ := newChatProjectClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
|
|
_, err := client.CreateChatProject(ctx, firstUser.OrganizationID, codersdk.CreateChatProjectRequest{
|
|
Name: strings.Repeat("n", 65),
|
|
})
|
|
requireChatProjectFieldError(t, err, "name", "Name must be at most 64 characters.")
|
|
_, err = client.CreateChatProject(ctx, firstUser.OrganizationID, codersdk.CreateChatProjectRequest{
|
|
Name: " ",
|
|
})
|
|
requireChatProjectFieldError(t, err, "name", "Name is required.")
|
|
// Surrounding whitespace is trimmed before the length check.
|
|
project := createChatProject(t, client, firstUser.OrganizationID, " "+strings.Repeat("n", 64)+" ")
|
|
require.Equal(t, strings.Repeat("n", 64), project.Name)
|
|
longDescription := strings.Repeat("d", 1025)
|
|
_, err = client.UpdateChatProject(ctx, firstUser.OrganizationID, project.ID, codersdk.UpdateChatProjectRequest{Description: &longDescription})
|
|
requireChatProjectFieldError(t, err, "description", "Description must be at most 1024 characters.")
|
|
}
|
|
|
|
func TestChatProjectLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatProjectClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
// The cap counts the owner's projects in every organization.
|
|
otherOrganization := dbgen.Organization(t, db, database.Organization{})
|
|
var last database.ChatProject
|
|
for i := range 100 {
|
|
organizationID := firstUser.OrganizationID
|
|
if i%2 == 0 {
|
|
organizationID = otherOrganization.ID
|
|
}
|
|
last = dbgen.ChatProject(t, db, database.ChatProject{OrganizationID: organizationID, OwnerID: firstUser.UserID})
|
|
}
|
|
|
|
_, err := client.CreateChatProject(ctx, firstUser.OrganizationID, codersdk.CreateChatProjectRequest{Name: "One too many"})
|
|
require.Equal(t, http.StatusConflict, coderdtest.SDKError(t, err).StatusCode())
|
|
|
|
// Another user's projects do not count toward the cap.
|
|
memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
|
_ = createChatProject(t, codersdk.NewExperimentalClient(memberRaw), firstUser.OrganizationID, "Member project")
|
|
|
|
require.NoError(t, client.DeleteChatProject(ctx, last.OrganizationID, last.ID))
|
|
_ = createChatProject(t, client, firstUser.OrganizationID, "Fits after a delete")
|
|
}
|
|
|
|
func TestChatProjectListFilter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, _ := newChatProjectClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
projectA := createChatProject(t, client, firstUser.OrganizationID, "Project A")
|
|
projectB := createChatProject(t, client, firstUser.OrganizationID, "Project B")
|
|
|
|
chat := createChatInProject(t, client, firstUser.OrganizationID, &projectA.ID)
|
|
otherChat := createChatInProject(t, client, firstUser.OrganizationID, &projectB.ID)
|
|
require.Equal(t, &projectA.ID, chat.ProjectID)
|
|
|
|
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ProjectID: &projectA.ID})
|
|
require.NoError(t, err)
|
|
require.Len(t, chats, 1)
|
|
require.Equal(t, chat.ID, chats[0].ID)
|
|
require.NotEqual(t, otherChat.ID, chats[0].ID)
|
|
|
|
// Membership is fixed at creation; the update endpoint ignores project_id.
|
|
require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Title: new("renamed")}))
|
|
updated, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, &projectA.ID, updated.ProjectID)
|
|
}
|
|
|
|
func TestChatProjectsExperimentDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db := newChatClientWithDatabase(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModel(t, client)
|
|
// The project routes bypass the experiment in development builds, so
|
|
// seed the row directly and exercise chat creation, which does not.
|
|
project := dbgen.ChatProject(t, db, database.ChatProject{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
OwnerID: firstUser.UserID,
|
|
Name: "Off",
|
|
})
|
|
|
|
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
ProjectID: &project.ID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "experiment off"}},
|
|
})
|
|
require.Equal(t, 400, coderdtest.SDKError(t, err).StatusCode())
|
|
|
|
_, err = client.ListChats(ctx, &codersdk.ListChatsOptions{ProjectID: &project.ID})
|
|
require.Equal(t, 400, coderdtest.SDKError(t, err).StatusCode())
|
|
}
|
|
|
|
func newChatProjectClient(t testing.TB) (*codersdk.ExperimentalClient, database.Store) {
|
|
t.Helper()
|
|
client, db := newChatClientWithDatabase(t,
|
|
func(options *coderdtest.Options) {
|
|
options.DeploymentValues.Experiments = serpent.StringArray{
|
|
string(codersdk.ExperimentChatProjects),
|
|
}
|
|
},
|
|
withChatWorkerDisabled,
|
|
)
|
|
return client, db
|
|
}
|
|
|
|
func createChatProject(t testing.TB, client *codersdk.ExperimentalClient, organizationID uuid.UUID, name string) codersdk.ChatProject {
|
|
t.Helper()
|
|
|
|
project, err := client.CreateChatProject(testutil.Context(t, testutil.WaitLong), organizationID, codersdk.CreateChatProjectRequest{
|
|
Name: name,
|
|
})
|
|
require.NoError(t, err)
|
|
return project
|
|
}
|
|
|
|
func requireChatProjectFieldError(t testing.TB, err error, field, detail string) {
|
|
t.Helper()
|
|
|
|
sdkErr := coderdtest.SDKError(t, err)
|
|
require.Equal(t, 400, sdkErr.StatusCode())
|
|
require.Equal(t, detail, sdkErr.Message)
|
|
require.Equal(t, []codersdk.ValidationError{{Field: field, Detail: detail}}, sdkErr.Validations)
|
|
}
|
|
|
|
func requireChatProjectNotFound(t testing.TB, err error) {
|
|
t.Helper()
|
|
|
|
sdkErr := coderdtest.SDKError(t, err)
|
|
require.Equal(t, 404, sdkErr.StatusCode())
|
|
require.Equal(t, "Chat project not found.", sdkErr.Message)
|
|
}
|
|
|
|
func createChatInProject(t testing.TB, client *codersdk.ExperimentalClient, organizationID uuid.UUID, projectID *uuid.UUID) codersdk.Chat {
|
|
t.Helper()
|
|
|
|
chat, err := client.CreateChat(testutil.Context(t, testutil.WaitLong), codersdk.CreateChatRequest{
|
|
OrganizationID: organizationID,
|
|
ProjectID: projectID,
|
|
Content: []codersdk.ChatInputPart{{
|
|
Type: codersdk.ChatInputPartTypeText,
|
|
Text: "chat project coverage",
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
return chat
|
|
}
|