Ctrl K
ComunidadGuías…

coderd/chat_projects_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package coderd_test

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"net/http"
	"slices"
	"strings"
	"testing"

	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/coderd"
	"github.com/coder/coder/v2/coderd/audit"
	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"github.com/coder/coder/v2/coderd/database/dbgen"
	"github.com/coder/coder/v2/coderd/database/dbtestutil"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
	"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)
	// The list holds projects the caller owns or holds a grant on, not every
	// project a site owner can read 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)

	_, 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, api := newChatProjectClientWithAPI(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)

	driveChatToWaiting(ctx, t, api, chat.ID)
	require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &projectB.ID}))
	chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{ProjectID: &projectB.ID})
	require.NoError(t, err)
	require.ElementsMatch(t, []uuid.UUID{chat.ID, otherChat.ID}, chatIDs(chats))
}

func TestPatchChatProject(t *testing.T) {
	t.Parallel()

	t.Run("MovesIntoBetweenAndOut", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		client, api := newChatProjectClientWithAPI(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 := createIdleChat(ctx, t, client, api, firstUser.OrganizationID)
		child := createSubagentChat(t, api, chat, database.ChatStatusWaiting)

		requireProjectChats := func(project codersdk.ChatProject, want ...uuid.UUID) {
			t.Helper()
			chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ProjectID: &project.ID})
			require.NoError(t, err)
			require.ElementsMatch(t, want, chatIDs(chats), "chats of %s", project.Name)
		}

		require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &projectA.ID}))
		requireChatProjectID(ctx, t, client, chat.ID, &projectA.ID)
		requireProjectChats(projectA, chat.ID)

		require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &projectB.ID}))
		requireChatProjectID(ctx, t, client, chat.ID, &projectB.ID)
		requireProjectChats(projectA)
		requireProjectChats(projectB, chat.ID)

		require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: new(uuid.Nil)}))
		requireChatProjectID(ctx, t, client, chat.ID, nil)
		requireProjectChats(projectB)

		// Subagent chats have no project and use the root's.
		requireChatProjectID(ctx, t, client, child.ID, nil)
	})

	t.Run("MoveBackWaitsForAPendingMove", func(t *testing.T) {
		t.Parallel()

		// A move back to the current project compares with the locked row,
		// so it cannot succeed while an earlier move commits after it.
		ctx := testutil.Context(t, testutil.WaitLong)
		db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
		client, api := newChatProjectClientWithAPI(t, func(options *coderdtest.Options) {
			options.Database = db
		})
		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 := createIdleChat(ctx, t, client, api, firstUser.OrganizationID)
		require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &projectA.ID}))

		pendingMove, err := sqlDB.BeginTx(ctx, nil)
		require.NoError(t, err)
		t.Cleanup(func() {
			if err := pendingMove.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
				t.Errorf("roll back the pending move: %v", err)
			}
		})
		_, err = pendingMove.ExecContext(ctx, `UPDATE chats SET project_id = $1 WHERE id = $2`, projectB.ID, chat.ID)
		require.NoError(t, err)

		moveBack := make(chan error, 1)
		go func() {
			moveBack <- client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &projectA.ID})
		}()
		testutil.Eventually(ctx, t, func(ctx context.Context) bool {
			if len(moveBack) > 0 {
				return true
			}
			var waiting int
			err := sqlDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM pg_stat_activity
WHERE datname = current_database()
	AND pid <> pg_backend_pid()
	AND wait_event_type = 'Lock'
	AND query LIKE '%-- name: GetChatByIDForUpdate%'
`).Scan(&waiting)
			return err == nil && waiting == 1
		}, testutil.IntervalFast, "the move back must answer or wait for the chat row")
		require.NoError(t, pendingMove.Commit())

		require.NoError(t, testutil.RequireReceive(ctx, t, moveBack))
		requireChatProjectID(ctx, t, client, chat.ID, &projectA.ID)
	})

	t.Run("AnotherOwnersProject", func(t *testing.T) {
		t.Parallel()

		// The site owner can read every project and update every chat, so
		// only the access check keeps a member's chat out of the site
		// owner's project and its memory until the project is shared with
		// the member.
		ctx := testutil.Context(t, testutil.WaitLong)
		client, api := newChatProjectClientWithAPI(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)
		project := createChatProject(t, client, firstUser.OrganizationID, "Owner Project")
		member, memberUser := newChatProjectMember(t, client, firstUser.OrganizationID)
		chat := createIdleChat(ctx, t, member, api, firstUser.OrganizationID)

		err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &project.ID})
		requireChatProjectNotFound(t, err)
		requireChatProjectID(ctx, t, member, chat.ID, nil)

		err = client.UpdateChatProjectACL(ctx, project.OrganizationID, project.ID, codersdk.UpdateChatProjectACL{
			UserRoles: map[string]codersdk.ChatProjectRole{memberUser.ID.String(): codersdk.ChatProjectRoleUse},
		})
		require.NoError(t, err)
		require.NoError(t, member.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &project.ID}))
		requireChatProjectID(ctx, t, member, chat.ID, &project.ID)
	})

	t.Run("AnotherOrganization", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		client, api := newChatProjectClientWithAPI(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)
		otherOrganization := dbgen.Organization(t, api.Database, database.Organization{})
		otherProject := dbgen.ChatProject(t, api.Database, database.ChatProject{
			OrganizationID: otherOrganization.ID,
			OwnerID:        firstUser.UserID,
		})
		chat := createIdleChat(ctx, t, client, api, firstUser.OrganizationID)

		err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &otherProject.ID})
		sdkErr := requireSDKError(t, err, http.StatusBadRequest)
		require.Equal(t, "Chat project does not belong to this chat's organization.", sdkErr.Message)
		requireChatProjectID(ctx, t, client, chat.ID, nil)
	})

	t.Run("ChildChat", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		client, api := newChatProjectClientWithAPI(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)
		project := createChatProject(t, client, firstUser.OrganizationID, "Project")
		chat := createIdleChat(ctx, t, client, api, firstUser.OrganizationID)
		child := createSubagentChat(t, api, chat, database.ChatStatusWaiting)

		err := client.UpdateChat(ctx, child.ID, codersdk.UpdateChatRequest{ProjectID: &project.ID})
		sdkErr := requireSDKError(t, err, http.StatusBadRequest)
		require.Equal(t, "Cannot move a child chat.", sdkErr.Message)
		requireChatProjectID(ctx, t, client, child.ID, nil)
	})

	t.Run("ArchivedChat", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		client, api := newChatProjectClientWithAPI(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)
		project := createChatProject(t, client, firstUser.OrganizationID, "Project")
		chat := createIdleChat(ctx, t, client, api, firstUser.OrganizationID)

		// Archiving in the same request is refused before any write.
		err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: new(true), ProjectID: &project.ID})
		sdkErr := requireSDKError(t, err, http.StatusBadRequest)
		require.Equal(t, "Cannot move an archived chat.", sdkErr.Message)
		got, err := client.GetChat(ctx, chat.ID)
		require.NoError(t, err)
		require.False(t, got.Archived)

		require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: new(true)}))
		err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &project.ID})
		sdkErr = requireSDKError(t, err, http.StatusBadRequest)
		require.Equal(t, "Cannot move an archived chat.", sdkErr.Message)
		requireChatProjectID(ctx, t, client, chat.ID, nil)

		// The unarchive runs before the move, so both apply.
		require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: new(false), ProjectID: &project.ID}))
		requireChatProjectID(ctx, t, client, chat.ID, &project.ID)
	})

	t.Run("ActiveFamilyReturnsConflict", func(t *testing.T) {
		t.Parallel()

		for _, tc := range []struct {
			name string
			// active is the status of the family member that blocks the move.
			active database.ChatStatus
			// activate returns the root chat after it puts the family in a
			// state that is not idle.
			activate func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, api *coderd.API, organizationID uuid.UUID) codersdk.Chat
		}{
			{
				name:   "RunningRoot",
				active: database.ChatStatusRunning,
				activate: func(_ context.Context, t *testing.T, client *codersdk.ExperimentalClient, _ *coderd.API, organizationID uuid.UUID) codersdk.Chat {
					return createChatInProject(t, client, organizationID, nil)
				},
			},
			{
				name:   "InterruptingRoot",
				active: database.ChatStatusInterrupting,
				activate: func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, api *coderd.API, organizationID uuid.UUID) codersdk.Chat {
					chat := createChatInProject(t, client, organizationID, nil)
					chatdCtx := dbauthz.AsChatd(ctx) //nolint:gocritic // Test fixture mirrors a worker that owns the chat.
					machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chat.ID)
					require.NoError(t, machine.Update(chatdCtx, func(tx *chatstate.Tx, _ database.Store) error {
						_, err := tx.Acquire(chatstate.AcquireInput{WorkerID: uuid.New(), RunnerID: uuid.New()})
						return err
					}))
					_, err := client.InterruptChat(ctx, chat.ID)
					require.NoError(t, err)
					return chat
				},
			},
			{
				name:   "RequiresActionRoot",
				active: database.ChatStatusRequiresAction,
				activate: func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, api *coderd.API, organizationID uuid.UUID) codersdk.Chat {
					chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
						OrganizationID: organizationID,
						Content:        []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
						UnsafeDynamicTools: []codersdk.DynamicTool{{
							Name:        "echo",
							Description: "test echo tool",
							InputSchema: json.RawMessage(`{"type":"object"}`),
						}},
					})
					require.NoError(t, err)
					_ = driveChatToRequiresAction(ctx, t, api, chat, "echo")
					return chat
				},
			},
			{
				name:   "RunningSubagent",
				active: database.ChatStatusRunning,
				activate: func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, api *coderd.API, organizationID uuid.UUID) codersdk.Chat {
					chat := createIdleChat(ctx, t, client, api, organizationID)
					_ = createSubagentChat(t, api, chat, database.ChatStatusRunning)
					return chat
				},
			},
		} {
			t.Run(tc.name, func(t *testing.T) {
				t.Parallel()

				ctx := testutil.Context(t, testutil.WaitLong)
				client, api := newChatProjectClientWithAPI(t)
				firstUser := coderdtest.CreateFirstUser(t, client.Client)
				_ = createChatModel(t, client)
				project := createChatProject(t, client, firstUser.OrganizationID, "Project")
				chat := tc.activate(ctx, t, client, api, firstUser.OrganizationID)
				before := chatRowsOfFamily(ctx, t, api, chat.ID)
				require.True(t, slices.ContainsFunc(before, func(member database.Chat) bool {
					return member.Status == tc.active
				}), "the family must hold a %s member", tc.active)

				err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &project.ID})
				sdkErr := requireSDKError(t, err, http.StatusConflict)
				require.Equal(t, "Cannot move an active chat. Interrupt or wait for the chat to finish first.", sdkErr.Message)
				require.Equal(t, before, chatRowsOfFamily(ctx, t, api, chat.ID), "a refused move changes no row")

				// The chat has no project, so removing it from its project
				// moves nothing and is not refused.
				require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: new(uuid.Nil)}))
				require.Equal(t, before, chatRowsOfFamily(ctx, t, api, chat.ID), "a move to the current project changes no row")
			})
		}
	})

	t.Run("PublishesProjectChange", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		client, api := newChatProjectClientWithAPI(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)
		project := createChatProject(t, client, firstUser.OrganizationID, "Project")
		chat := createIdleChat(ctx, t, client, api, firstUser.OrganizationID)
		child := createSubagentChat(t, api, chat, database.ChatStatusWaiting)

		events, closer, err := client.WatchChats(ctx)
		require.NoError(t, err)
		t.Cleanup(func() { assert.NoError(t, closer.Close()) })

		require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &project.ID}))

		// Each family member gets one event, so every tab can update the
		// rows it shows.
		pending := map[uuid.UUID]*uuid.UUID{chat.ID: &project.ID, child.ID: nil}
		for len(pending) > 0 {
			event := testutil.RequireReceive(ctx, t, events)
			if event.Kind != codersdk.ChatWatchEventKindProjectChange {
				continue
			}
			want, ok := pending[event.Chat.ID]
			require.True(t, ok, "unexpected project_change event for chat %s", event.Chat.ID)
			require.Equal(t, want, event.Chat.ProjectID)
			delete(pending, event.Chat.ID)
		}
	})

	t.Run("AuditsTheMove", func(t *testing.T) {
		t.Parallel()

		mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map {
			oldChat, oldIsChat := old.(database.Chat)
			newChat, newIsChat := newVal.(database.Chat)
			if !oldIsChat || !newIsChat {
				return audit.Map{}
			}
			return audit.Map{"project_id": {Old: oldChat.ProjectID.UUID, New: newChat.ProjectID.UUID}}
		})
		ctx := testutil.Context(t, testutil.WaitLong)
		client, api := newChatProjectClientWithAPI(t, func(options *coderdtest.Options) {
			options.Auditor = mAudit
		})
		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)
		driveChatToWaiting(ctx, t, api, chat.ID)
		mAudit.ResetLogs()

		require.NoError(t, client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ProjectID: &projectB.ID}))

		logs := mAudit.AuditLogs()
		require.Len(t, logs, 1)
		require.Equal(t, database.ResourceTypeChat, logs[0].ResourceType)
		require.Equal(t, chat.ID, logs[0].ResourceID)
		var diff map[string]codersdk.AuditDiffField
		require.NoError(t, json.Unmarshal(logs[0].Diff, &diff))
		require.Equal(t, map[string]codersdk.AuditDiffField{
			"project_id": {Old: projectA.ID.String(), New: projectB.ID.String()},
		}, diff)
	})
}

// createIdleChat creates a chat outside any project and moves it to
// waiting, where a move is allowed.
func createIdleChat(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, api *coderd.API, organizationID uuid.UUID) codersdk.Chat {
	t.Helper()
	chat := createChatInProject(t, client, organizationID, nil)
	driveChatToWaiting(ctx, t, api, chat.ID)
	return chat
}

func createSubagentChat(t *testing.T, api *coderd.API, root codersdk.Chat, status database.ChatStatus) database.Chat {
	t.Helper()
	return dbgen.Chat(t, api.Database, database.Chat{
		OrganizationID:    root.OrganizationID,
		OwnerID:           root.OwnerID,
		LastModelConfigID: root.LastModelConfigID,
		Title:             "subagent",
		Status:            status,
		ParentChatID:      uuid.NullUUID{UUID: root.ID, Valid: true},
		RootChatID:        uuid.NullUUID{UUID: root.ID, Valid: true},
	})
}

func requireChatProjectID(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, chatID uuid.UUID, want *uuid.UUID) {
	t.Helper()
	chat, err := client.GetChat(ctx, chatID)
	require.NoError(t, err)
	require.Equal(t, want, chat.ProjectID)
}

func chatRowsOfFamily(ctx context.Context, t *testing.T, api *coderd.API, rootID uuid.UUID) []database.Chat {
	t.Helper()
	sysCtx := dbauthz.AsSystemRestricted(ctx) //nolint:gocritic // Test reads rows the chat owner cannot see as one family.
	ids, err := api.Database.GetChatFamilyIDsByRootID(sysCtx, rootID)
	require.NoError(t, err)
	chats := make([]database.Chat, 0, len(ids))
	for _, id := range ids {
		chat, err := api.Database.GetChatByID(sysCtx, id)
		require.NoError(t, err)
		chats = append(chats, chat)
	}
	return chats
}

func chatIDs(chats []codersdk.Chat) []uuid.UUID {
	ids := make([]uuid.UUID, 0, len(chats))
	for _, chat := range chats {
		ids = append(ids, chat.ID)
	}
	return ids
}

func newChatProjectClient(t testing.TB, overrides ...func(*coderdtest.Options)) (*codersdk.ExperimentalClient, database.Store) {
	t.Helper()
	client, api := newChatProjectClientWithAPI(t, overrides...)
	return client, api.Database
}

func newChatProjectClientWithAPI(t testing.TB, overrides ...func(*coderdtest.Options)) (*codersdk.ExperimentalClient, *coderd.API) {
	t.Helper()
	return newChatClientWithAPI(t, append([]func(*coderdtest.Options){
		func(options *coderdtest.Options) {
			options.DeploymentValues.Experiments = serpent.StringArray{
				string(codersdk.ExperimentChatProjectSharing),
			}
		},
		withChatWorkerDisabled,
	}, overrides...)...)
}

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
}