Ctrl K
ComunidadGuías…

coderd/exp_chats_workspace_file_test.go

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

Archivo original ↗Preguntar a KiBot
package coderd_test

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/google/uuid"
	"github.com/stretchr/testify/require"
	"golang.org/x/sync/errgroup"

	"github.com/coder/coder/v2/agent"
	"github.com/coder/coder/v2/agent/agenttest"
	"github.com/coder/coder/v2/agent/usershell"
	"github.com/coder/coder/v2/coderd"
	"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/dbfake"
	"github.com/coder/coder/v2/coderd/database/dbgen"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/coderd/rbac/policy"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/testutil"
)

// uploadChatWorkspaceFile bypasses the SDK helper so tests can drive
// the endpoint with a raw (or missing) Content-Disposition header that
// the helper would otherwise set automatically.
func uploadChatWorkspaceFile(
	ctx context.Context,
	t *testing.T,
	client *codersdk.ExperimentalClient,
	chatID, contentDisposition, contentType string,
	body []byte,
) (*http.Response, error) {
	t.Helper()
	return client.Request(ctx, http.MethodPost,
		"/api/v2/chats/"+chatID+"/workspace-files",
		bytes.NewReader(body),
		func(r *http.Request) {
			if contentType != "" {
				r.Header.Set("Content-Type", contentType)
			}
			if contentDisposition != "" {
				r.Header.Set("Content-Disposition", contentDisposition)
			}
		},
	)
}

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

	chatID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
	otherChatID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
	tests := []struct {
		name     string
		chatID   uuid.UUID
		path     string
		fileName string
		want     string
	}{
		{
			name:     "LinuxPath",
			chatID:   chatID,
			path:     "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
			fileName: "data.csv",
			want:     "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
		},
		{
			name:     "WindowsPath",
			chatID:   chatID,
			path:     `C:\Users\coder\.coder\chats\00000000-0000-0000-0000-000000000001\files\data.csv`,
			fileName: "data.csv",
			want:     `C:\Users\coder\.coder\chats\00000000-0000-0000-0000-000000000001\files\data.csv`,
		},
		{
			name:     "NilChatID",
			chatID:   uuid.Nil,
			path:     "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
			fileName: "data.csv",
		},
		{
			name:     "Traversal",
			chatID:   chatID,
			path:     "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/../data.csv",
			fileName: "data.csv",
		},
		{
			name:     "MismatchedChatID",
			chatID:   otherChatID,
			path:     "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
			fileName: "data.csv",
		},
		{
			name:     "NULInPath",
			chatID:   chatID,
			path:     "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data\x00.csv",
			fileName: "data\x00.csv",
		},
		{
			name:     "NameWithSeparator",
			chatID:   chatID,
			path:     "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
			fileName: "nested/data.csv",
		},
		{
			name:     "UncleanPrefixIsCleaned",
			chatID:   chatID,
			path:     " /home/coder/./tmp/../.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv ",
			fileName: "data.csv",
			want:     "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
		},
		{
			name:     "WindowsUncleanPrefixIsCleaned",
			chatID:   chatID,
			path:     `C:\Users\coder\tmp\..\.coder\chats\00000000-0000-0000-0000-000000000001\files\data.csv`,
			fileName: "data.csv",
			want:     `C:\Users\coder\.coder\chats\00000000-0000-0000-0000-000000000001\files\data.csv`,
		},
		{
			name:     "NewlineInPrefix",
			chatID:   chatID,
			path:     "/home/co\nder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
			fileName: "data.csv",
		},
		{
			name:     "PathTooLong",
			chatID:   chatID,
			path:     "/" + strings.Repeat("a", 4096) + "/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
			fileName: "data.csv",
		},
		{
			name:     "ArbitraryAbsolutePath",
			chatID:   chatID,
			path:     "/etc/passwd",
			fileName: "passwd",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got, ok := coderd.NormalizeWorkspaceFileReference(tt.chatID, tt.path, tt.fileName)
			require.Equal(t, tt.want != "", ok)
			require.Equal(t, tt.want, got)
		})
	}
}

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

	tests := []struct {
		name   string
		part   codersdk.ChatInputPart
		detail string
	}{
		{
			name: "InitialChatCreationRejectsWorkspaceReference",
			part: codersdk.ChatInputPart{
				Type:              codersdk.ChatInputPartTypeWorkspaceFileReference,
				WorkspaceFilePath: "/home/coder/.coder/chats/00000000-0000-0000-0000-000000000001/files/data.csv",
				WorkspaceFileName: "data.csv",
				WorkspaceFileSize: 42,
			},
			detail: "content[0].workspace-file-reference requires an existing chat.",
		},
		{
			name: "MissingPath",
			part: codersdk.ChatInputPart{
				Type:              codersdk.ChatInputPartTypeWorkspaceFileReference,
				WorkspaceFileName: "data.csv",
				WorkspaceFileSize: 42,
			},
			detail: "content[0].workspace_file_path is required for workspace-file-reference.",
		},
		{
			name: "MissingName",
			part: codersdk.ChatInputPart{
				Type:              codersdk.ChatInputPartTypeWorkspaceFileReference,
				WorkspaceFilePath: "/home/coder/.coder/chats/chat-id/files/data.csv",
				WorkspaceFileSize: 42,
			},
			detail: "content[0].workspace_file_name is required for workspace-file-reference.",
		},
		{
			name: "NegativeSize",
			part: codersdk.ChatInputPart{
				Type:              codersdk.ChatInputPartTypeWorkspaceFileReference,
				WorkspaceFilePath: "/home/coder/.coder/chats/chat-id/files/data.csv",
				WorkspaceFileName: "data.csv",
				WorkspaceFileSize: -1,
			},
			detail: "content[0].workspace_file_size must be non-negative.",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			ctx := testutil.Context(t, testutil.WaitLong)
			client := newChatClient(t)
			firstUser := coderdtest.CreateFirstUser(t, client.Client)
			_ = createChatModel(t, client)

			_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
				OrganizationID: firstUser.OrganizationID,
				Content:        []codersdk.ChatInputPart{tt.part},
			})
			sdkErr := requireSDKError(t, err, http.StatusBadRequest)
			require.Equal(t, "Invalid input part.", sdkErr.Message)
			require.Equal(t, tt.detail, sdkErr.Detail)
		})
	}
}

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

	ctx := testutil.Context(t, testutil.WaitLong)
	client := newChatClient(t)
	firstUser := coderdtest.CreateFirstUser(t, client.Client)
	_ = createChatModel(t, client)

	chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
		OrganizationID: firstUser.OrganizationID,
		Content: []codersdk.ChatInputPart{
			{Type: codersdk.ChatInputPartTypeText, Text: "hello"},
		},
	})
	require.NoError(t, err)

	_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
		Content: []codersdk.ChatInputPart{
			{
				Type:                   codersdk.ChatInputPartTypeWorkspaceFileReference,
				WorkspaceFilePath:      "/etc/passwd",
				WorkspaceFileName:      "passwd",
				WorkspaceFileSize:      42,
				WorkspaceFileMediaType: "text/plain",
			},
		},
	})
	sdkErr := requireSDKError(t, err, http.StatusBadRequest)
	require.Equal(t, "Invalid input part.", sdkErr.Message)
	require.Equal(t, "content[0].workspace_file_path must reference a file uploaded to this chat.", sdkErr.Detail)
}

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

	validPart := func(chatID uuid.UUID) codersdk.ChatInputPart {
		return codersdk.ChatInputPart{
			Type:                   codersdk.ChatInputPartTypeWorkspaceFileReference,
			WorkspaceFilePath:      "/home/coder/.coder/chats/" + chatID.String() + "/files/data.csv",
			WorkspaceFileName:      "data.csv",
			WorkspaceFileSize:      42,
			WorkspaceFileMediaType: "text/csv",
		}
	}

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client := newChatClient(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "hello"},
			},
		})
		require.NoError(t, err)

		_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
			Content: []codersdk.ChatInputPart{validPart(chat.ID)},
		})
		sdkErr := requireSDKError(t, err, http.StatusBadRequest)
		require.Equal(t, "Invalid input part.", sdkErr.Message)
		require.Equal(t, "content[0].workspace_file_workspace_id is required for workspace-file-reference.", sdkErr.Detail)
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()

		chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			WorkspaceID:    &workspaceBuild.Workspace.ID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "hello"},
			},
		})
		require.NoError(t, err)

		// References uploaded to a different workspace than the chat's
		// current binding are unreadable for the bound agent.
		part := validPart(chat.ID)
		part.WorkspaceFileWorkspaceID = uuid.New()
		_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
			Content: []codersdk.ChatInputPart{part},
		})
		sdkErr := requireSDKError(t, err, http.StatusBadRequest)
		require.Equal(t, "Invalid input part.", sdkErr.Message)
		require.Equal(t, "content[0].workspace_file_workspace_id must match the chat's bound workspace. Re-upload the file to the current workspace.", sdkErr.Detail)
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client := newChatClient(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "hello"},
			},
		})
		require.NoError(t, err)

		part := validPart(chat.ID)
		part.WorkspaceFileWorkspaceID = uuid.New()
		_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
			Content: []codersdk.ChatInputPart{part},
		})
		sdkErr := requireSDKError(t, err, http.StatusBadRequest)
		require.Equal(t, "Invalid input part.", sdkErr.Message)
		require.Equal(t, "content[0].workspace_file_workspace_id must match the chat's bound workspace. Re-upload the file to the current workspace.", sdkErr.Detail)
	})

	newBoundChat := func(ctx context.Context, t *testing.T) (*codersdk.ExperimentalClient, codersdk.Chat) {
		t.Helper()
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)
		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			WorkspaceID:    &workspaceBuild.Workspace.ID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "hello"},
			},
		})
		require.NoError(t, err)
		return client, chat
	}

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, chat := newBoundChat(ctx, t)

		for _, name := range []string{" data.csv", "da\u202eta.csv", "da|ta.csv", "a$(b);c'd.txt"} {
			part := validPart(chat.ID)
			part.WorkspaceFileWorkspaceID = *chat.WorkspaceID
			part.WorkspaceFileName = name
			part.WorkspaceFilePath = "/home/coder/.coder/chats/" + chat.ID.String() + "/files/" + name
			_, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
				Content: []codersdk.ChatInputPart{part},
			})
			sdkErr := requireSDKError(t, err, http.StatusBadRequest)
			require.Equal(t, "content[0].workspace_file_name must be a name returned by the workspace file upload endpoint.", sdkErr.Detail, "name %q", name)
		}
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, chat := newBoundChat(ctx, t)

		for _, mediaType := range []string{
			"garbage",
			"text/plain\x00",
			"text/plain; charset=\"\x01\"",
			"text/plain; x=" + strings.Repeat("a", 255),
		} {
			part := validPart(chat.ID)
			part.WorkspaceFileWorkspaceID = *chat.WorkspaceID
			part.WorkspaceFileMediaType = mediaType
			_, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
				Content: []codersdk.ChatInputPart{part},
			})
			sdkErr := requireSDKError(t, err, http.StatusBadRequest)
			require.Equal(t, "content[0].workspace_file_media_type must be a valid media type of at most 255 bytes without control characters.", sdkErr.Detail, "media type %q", mediaType)
		}
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, chat := newBoundChat(ctx, t)

		part := validPart(chat.ID)
		part.WorkspaceFileWorkspaceID = *chat.WorkspaceID
		part.WorkspaceFilePath = " /home/coder/./tmp/../.coder/chats/" + chat.ID.String() + "/files/data.csv "
		part.WorkspaceFileMediaType = "Text/CSV; charset=utf-8"
		resp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
			Content: []codersdk.ChatInputPart{part},
		})
		require.NoError(t, err)
		var parts []codersdk.ChatMessagePart
		if resp.Queued {
			require.NotNil(t, resp.QueuedMessage)
			parts = resp.QueuedMessage.Content
		} else {
			require.NotNil(t, resp.Message)
			parts = resp.Message.Content
		}
		require.Contains(t, parts, codersdk.ChatMessageWorkspaceFileReference(
			*chat.WorkspaceID,
			"/home/coder/.coder/chats/"+chat.ID.String()+"/files/data.csv",
			"data.csv",
			42,
			"text/csv",
		))
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, chat := newBoundChat(ctx, t)

		messages, err := client.GetChatMessages(ctx, chat.ID, nil)
		require.NoError(t, err)
		var userMessageID int64
		for _, message := range messages.Messages {
			if message.Role == codersdk.ChatMessageRoleUser {
				userMessageID = message.ID
				break
			}
		}
		require.NotZero(t, userMessageID)

		// Edits go through the same validation as sends, so a reference
		// from another workspace is rejected.
		stale := validPart(chat.ID)
		stale.WorkspaceFileWorkspaceID = uuid.New()
		_, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
			Content: []codersdk.ChatInputPart{stale},
		})
		sdkErr := requireSDKError(t, err, http.StatusBadRequest)
		require.Equal(t, "content[0].workspace_file_workspace_id must match the chat's bound workspace. Re-upload the file to the current workspace.", sdkErr.Detail)

		current := validPart(chat.ID)
		current.WorkspaceFileWorkspaceID = *chat.WorkspaceID
		current.WorkspaceFilePath = "/home/coder/./.coder/chats/" + chat.ID.String() + "/files/data.csv"
		edited, err := client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
			Content: []codersdk.ChatInputPart{current},
		})
		require.NoError(t, err)
		require.Contains(t, edited.Message.Content, codersdk.ChatMessageWorkspaceFileReference(
			*chat.WorkspaceID,
			"/home/coder/.coder/chats/"+chat.ID.String()+"/files/data.csv",
			"data.csv",
			42,
			"text/csv",
		))
	})
}

// homeDirEnvInfo pins the agent's home directory so upload tests can run
// in parallel instead of overriding HOME for the whole process.
type homeDirEnvInfo struct {
	usershell.SystemEnvInfo
	home string
}

func (e homeDirEnvInfo) HomeDir() (string, error) { return e.home, nil }

// startChatUploadAgent connects a real agent with its own home directory
// and returns that directory.
func startChatUploadAgent(t *testing.T, client *codersdk.ExperimentalClient, workspaceBuild dbfake.WorkspaceResponse) string {
	t.Helper()
	home := t.TempDir()
	_ = agenttest.New(t, client.URL, workspaceBuild.AgentToken, func(o *agent.Options) {
		o.EnvInfo = homeDirEnvInfo{home: home}
	})
	coderdtest.NewWorkspaceAgentWaiter(t, client.Client, workspaceBuild.Workspace.ID).WaitFor(coderdtest.AgentsReady)
	return home
}

func createBoundChat(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, organizationID, workspaceID uuid.UUID) codersdk.Chat {
	t.Helper()
	chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
		OrganizationID: organizationID,
		WorkspaceID:    &workspaceID,
		Content: []codersdk.ChatInputPart{
			{Type: codersdk.ChatInputPartTypeText, Text: "upload a file"},
		},
	})
	require.NoError(t, err)
	return chat
}

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

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db, api := newChatClientWithAPIAndDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		home := startChatUploadAgent(t, client, workspaceBuild)
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		payload := bytes.Repeat([]byte{0x50, 0x4b}, 16)
		resp, err := client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "archive.zip", bytes.NewReader(payload))
		require.NoError(t, err)
		require.Equal(t, "archive.zip", resp.Name)
		require.Equal(t, int64(len(payload)), resp.Size)
		require.Equal(t, "application/zip", resp.MediaType)
		require.Equal(t, workspaceBuild.Workspace.ID, resp.WorkspaceID)
		require.True(t, strings.HasPrefix(resp.Path, home), "expected path under home, got %q (home=%q)", resp.Path, home)

		bytesOnDisk, err := os.ReadFile(resp.Path)
		require.NoError(t, err)
		require.Equal(t, payload, bytesOnDisk)

		messageResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
			Content: []codersdk.ChatInputPart{
				{
					Type:                     codersdk.ChatInputPartTypeWorkspaceFileReference,
					WorkspaceFilePath:        resp.Path,
					WorkspaceFileName:        resp.Name,
					WorkspaceFileSize:        resp.Size,
					WorkspaceFileMediaType:   resp.MediaType,
					WorkspaceFileWorkspaceID: resp.WorkspaceID,
				},
			},
		})
		require.NoError(t, err)
		var messageParts []codersdk.ChatMessagePart
		if messageResp.Queued {
			require.NotNil(t, messageResp.QueuedMessage)
			messageParts = messageResp.QueuedMessage.Content
		} else {
			require.NotNil(t, messageResp.Message)
			messageParts = messageResp.Message.Content
		}
		require.Contains(t, messageParts, codersdk.ChatMessageWorkspaceFileReference(
			resp.WorkspaceID,
			resp.Path,
			resp.Name,
			resp.Size,
			resp.MediaType,
		))

		// Archiving requires the chat generation to settle first.
		coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
		archived := true
		err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
			Archived: &archived,
		})
		require.NoError(t, err)
		_, err = os.Stat(resp.Path)
		require.NoError(t, err, "archive should not remove workspace files")
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db, api := newChatClientWithAPIAndDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		_ = startChatUploadAgent(t, client, workspaceBuild)

		// Uploads only need the chat ID and workspace binding, so a
		// chat created idle with no messages accepts them. This is
		// the sequencing used by the new-chat page: create empty,
		// upload, then send the first message with the references.
		chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			WorkspaceID:    &workspaceBuild.Workspace.ID,
		})
		require.NoError(t, err)
		require.Equal(t, codersdk.ChatStatusWaiting, chat.Status)

		payload := bytes.Repeat([]byte{0x50, 0x4b}, 16)
		resp, err := client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "archive.zip", bytes.NewReader(payload))
		require.NoError(t, err)
		require.Equal(t, workspaceBuild.Workspace.ID, resp.WorkspaceID)

		bytesOnDisk, err := os.ReadFile(resp.Path)
		require.NoError(t, err)
		require.Equal(t, payload, bytesOnDisk)

		// The first message carries text plus the uploaded reference
		// and starts generation: the idle chat inserts it directly.
		messageResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "inspect the archive"},
				{
					Type:                     codersdk.ChatInputPartTypeWorkspaceFileReference,
					WorkspaceFilePath:        resp.Path,
					WorkspaceFileName:        resp.Name,
					WorkspaceFileSize:        resp.Size,
					WorkspaceFileMediaType:   resp.MediaType,
					WorkspaceFileWorkspaceID: resp.WorkspaceID,
				},
			},
		})
		require.NoError(t, err)
		require.False(t, messageResp.Queued)
		require.NotNil(t, messageResp.Message)
		require.Contains(t, messageResp.Message.Content, codersdk.ChatMessageWorkspaceFileReference(
			resp.WorkspaceID,
			resp.Path,
			resp.Name,
			resp.Size,
			resp.MediaType,
		))

		coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		_ = startChatUploadAgent(t, client, workspaceBuild)
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		// Same-name uploads racing each other must land on distinct
		// paths instead of overwriting one another.
		payloads := [][]byte{[]byte("first"), []byte("second")}
		results := make([]codersdk.UploadChatWorkspaceFileResponse, len(payloads))
		var eg errgroup.Group
		for i, payload := range payloads {
			eg.Go(func() error {
				resp, err := client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "archive.zip", bytes.NewReader(payload))
				results[i] = resp
				return err
			})
		}
		require.NoError(t, eg.Wait())

		require.ElementsMatch(t, []string{"archive.zip", "archive_2.zip"}, []string{results[0].Name, results[1].Name})
		for i, result := range results {
			bytesOnDisk, err := os.ReadFile(result.Path)
			require.NoError(t, err)
			require.Equal(t, payloads[i], bytesOnDisk)
		}
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		_ = startChatUploadAgent(t, client, workspaceBuild)
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		res, err := uploadChatWorkspaceFile(ctx, t, client, chat.ID.String(),
			"attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf", "application/pdf", []byte("%PDF"))
		require.NoError(t, err)
		defer res.Body.Close()
		require.Equal(t, http.StatusCreated, res.StatusCode)
		var uploaded codersdk.UploadChatWorkspaceFileResponse
		require.NoError(t, json.NewDecoder(res.Body).Decode(&uploaded))
		require.Equal(t, "r\u00e9sum\u00e9.pdf", uploaded.Name)
		require.Equal(t, uploaded.Name, filepath.Base(uploaded.Path))
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		_ = startChatUploadAgent(t, client, workspaceBuild)
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		// The client sends the returned media type back as
		// workspace_file_media_type, so it must pass message validation.
		resp, err := client.UploadChatWorkspaceFile(ctx, chat.ID, "text/plain; bogus", "notes.txt", bytes.NewReader([]byte("hi")))
		require.NoError(t, err)
		require.Equal(t, "application/octet-stream", resp.MediaType)
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		for _, tc := range []struct {
			contentDisposition string
			detail             string
		}{
			{"", "Provide a filename via the Content-Disposition header."},
			{`attachment; filename="$()"`, "The provided filename has no safe characters left after sanitization or is a reserved device name."},
		} {
			res, err := uploadChatWorkspaceFile(ctx, t, client, chat.ID.String(), tc.contentDisposition, "application/zip", []byte("PK"))
			require.NoError(t, err)
			sdkErr := requireSDKError(t, codersdk.ReadBodyAsError(res), http.StatusBadRequest)
			_ = res.Body.Close()
			require.Equal(t, tc.detail, sdkErr.Detail, tc.contentDisposition)
		}
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client := newChatClient(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "no workspace"},
			},
		})
		require.NoError(t, err)

		_, err = client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		sdkErr := requireSDKError(t, err, http.StatusConflict)
		require.Contains(t, sdkErr.Message, "no workspace")
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client := newChatClient(t, func(o *coderdtest.Options) {
			o.FilesRateLimit = 1
		})
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "rate limited"},
			},
		})
		require.NoError(t, err)

		otherChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "rate limited too"},
			},
		})
		require.NoError(t, err)

		// The budget is per user, so a different chat ID does not get a
		// fresh bucket.
		_, err = client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		requireSDKError(t, err, http.StatusConflict)
		_, err = client.UploadChatWorkspaceFile(ctx, otherChat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		requireSDKError(t, err, http.StatusTooManyRequests)
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).Do()
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		_, err := client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		sdkErr := requireSDKError(t, err, http.StatusConflict)
		require.Equal(t, "Chat workspace has no agents.", sdkErr.Message)
	})

	for _, tc := range []struct {
		name    string
		build   func(dbfake.WorkspaceBuildBuilder) dbfake.WorkspaceBuildBuilder
		message string
	}{
		{
			name: "WorkspaceStopped",
			build: func(b dbfake.WorkspaceBuildBuilder) dbfake.WorkspaceBuildBuilder {
				return b.Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop})
			},
			message: "Workspace is stopped. Start the workspace before uploading files.",
		},
		{
			name: "WorkspaceStarting",
			build: func(b dbfake.WorkspaceBuildBuilder) dbfake.WorkspaceBuildBuilder {
				return b.Starting()
			},
			message: "Workspace is starting. Wait for the workspace to start before uploading files.",
		},
		{
			name: "WorkspacePending",
			build: func(b dbfake.WorkspaceBuildBuilder) dbfake.WorkspaceBuildBuilder {
				return b.Pending()
			},
			message: "Workspace is pending. Wait for the workspace to start before uploading files.",
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			ctx := testutil.Context(t, testutil.WaitLong)
			client, db := newChatClientWithDatabase(t)
			firstUser := coderdtest.CreateFirstUser(t, client.Client)
			_ = createChatModel(t, client)

			workspaceBuild := tc.build(dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
				OrganizationID: firstUser.OrganizationID,
				OwnerID:        firstUser.UserID,
			}).WithAgent()).Do()
			chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

			_, err := client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
			sdkErr := requireSDKError(t, err, http.StatusConflict)
			require.Equal(t, tc.message, sdkErr.Message)
		})
	}

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)
		err := db.UpdateWorkspaceDeletedByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceDeletedByIDParams{
			ID:      workspaceBuild.Workspace.ID,
			Deleted: true,
		})
		require.NoError(t, err)

		_, err = client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		sdkErr := requireSDKError(t, err, http.StatusConflict)
		require.Equal(t, "Chat workspace was deleted.", sdkErr.Message)
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		// dbfake.WorkspaceBuild + WithAgent records an agent in the
		// database but never connects it, so the handler should see a
		// disconnected agent.
		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		_, err := client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		sdkErr := requireSDKError(t, err, http.StatusConflict)
		require.Contains(t, sdkErr.Message, "Agent status")
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
		memberClient := codersdk.NewExperimentalClient(memberClientRaw)
		aclWith := func(actions ...policy.Action) database.WorkspaceACL {
			return database.WorkspaceACL{member.ID.String(): {Permissions: actions}}
		}

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
			UserACL:        aclWith(policy.ActionRead, policy.ActionSSH, policy.ActionApplicationConnect),
		}).WithAgent().Do()
		chat := createBoundChat(ctx, t, memberClient, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		// An SSH grant passes authorization and reaches the agent check.
		_, err := memberClient.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		sdkErr := requireSDKError(t, err, http.StatusConflict)
		require.Contains(t, sdkErr.Message, "Agent status")

		owner, err := client.User(ctx, codersdk.Me)
		require.NoError(t, err)
		err = db.UpdateWorkspaceACLByID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(owner)), database.UpdateWorkspaceACLByIDParams{
			ID:       workspaceBuild.Workspace.ID,
			UserACL:  aclWith(policy.ActionRead, policy.ActionApplicationConnect),
			GroupACL: database.WorkspaceACL{},
		})
		require.NoError(t, err)

		_, err = memberClient.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		sdkErr = requireSDKError(t, err, http.StatusForbidden)
		require.Equal(t, "Chat workspace not found.", sdkErr.Message)
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, db := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
			OrganizationID: firstUser.OrganizationID,
			OwnerID:        firstUser.UserID,
		}).WithAgent().Do()
		chat := createBoundChat(ctx, t, client, firstUser.OrganizationID, workspaceBuild.Workspace.ID)

		// chat:* cannot be requested through the token API, so insert
		// the scoped key directly.
		_, token := dbgen.APIKey(t, db, database.APIKey{
			UserID: firstUser.UserID,
			Scopes: database.APIKeyScopes{
				database.ApiKeyScopeChat,
				database.ApiKeyScopeWorkspaceRead,
				database.ApiKeyScopeWorkspaceApplicationConnect,
			},
		})
		scopedClient := codersdk.NewExperimentalClient(codersdk.New(client.URL, codersdk.WithSessionToken(token)))

		_, err := scopedClient.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		sdkErr := requireSDKError(t, err, http.StatusForbidden)
		require.Equal(t, "Chat workspace not found.", sdkErr.Message)
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		client, api := newChatClientWithAPI(t)
		firstUser := coderdtest.CreateFirstUser(t, client.Client)
		_ = createChatModel(t, client)

		chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "archived"},
			},
		})
		require.NoError(t, err)

		// Archiving requires the chat generation to settle first.
		coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
		archived := true
		err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
			Archived: &archived,
		})
		require.NoError(t, err)

		_, err = client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		sdkErr := requireSDKError(t, err, http.StatusBadRequest)
		require.Contains(t, sdkErr.Message, "archived")
	})

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

		ctx := testutil.Context(t, testutil.WaitLong)
		adminClient, _ := newChatClientWithDatabase(t)
		firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
		_ = createChatModel(t, adminClient)

		// The chat is created by the first (owner) user. The second
		// user has org-admin so they pass RBAC for ActionUpdate, but
		// the owner-only check should still reject them.
		secondClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID,
			rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID))
		secondClient := codersdk.NewExperimentalClient(secondClientRaw)

		chat, err := adminClient.CreateChat(ctx, codersdk.CreateChatRequest{
			OrganizationID: firstUser.OrganizationID,
			Content: []codersdk.ChatInputPart{
				{Type: codersdk.ChatInputPartTypeText, Text: "not owner"},
			},
		})
		require.NoError(t, err)

		_, err = secondClient.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "data.zip", bytes.NewReader([]byte("PK")))
		requireSDKError(t, err, http.StatusForbidden)
	})
}