Files
coder/coderd/chatd/chattool/createworkspace_test.go
T
Kyle Carberry fc9e04da67 fix(chatd): handle soft-deleted workspaces in chattool start/create (#22997)
## Problem

Both `start_workspace` and `create_workspace` chattool tools failed to
handle soft-deleted workspaces correctly.

Coder uses soft-delete for workspaces (`deleted = true` on the row).
Both tools called `GetWorkspaceByID`, which queries
`workspaces_expanded` with **no** `deleted = false` filter — so it
returns the workspace row even when soft-deleted. The only deletion
check was for `sql.ErrNoRows`, which never fires because the row still
exists.

### `start_workspace` behavior (before fix)
1. Loads the soft-deleted workspace successfully
2. Finds the latest build (a delete transition)
3. Falls through to attempt to **start** the deleted workspace
4. Produces a confusing downstream error

### `create_workspace` behavior (before fix)
1. `checkExistingWorkspace` loads the soft-deleted workspace
2. If a delete build is **in-progress**: waits for it, then falsely
reports `already_exists` — blocks new workspace creation
3. If the delete build **succeeded**: accidentally allows creation
(because no agents are found), but via fragile logic rather than an
explicit check

## Fix

Add `ws.Deleted` checks immediately after `GetWorkspaceByID` succeeds in
both tools:

- **`startworkspace.go`**: Returns `"workspace was deleted; use
create_workspace to make a new one"`
- **`createworkspace.go`** (`checkExistingWorkspace`): Returns `(nil,
false, nil)` to allow new workspace creation

## Tests

- `TestStartWorkspace/DeletedWorkspace` — verifies `start_workspace`
returns deleted error and never calls `StartFn`
- `TestCheckExistingWorkspace_DeletedWorkspace` — verifies
`checkExistingWorkspace` allows creation for soft-deleted workspaces
2026-03-12 16:09:17 +00:00

143 lines
4.1 KiB
Go

package chattool //nolint:testpackage // Uses internal symbols.
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func TestWaitForAgentReady(t *testing.T) {
t.Parallel()
t.Run("AgentConnectsAndLifecycleReady", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
agentID := uuid.New()
// Mock returns Ready lifecycle state.
db.EXPECT().
GetWorkspaceAgentLifecycleStateByID(gomock.Any(), agentID).
Return(database.GetWorkspaceAgentLifecycleStateByIDRow{
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
}, nil)
// AgentConnFn succeeds immediately.
connFn := func(ctx context.Context, id uuid.UUID) (workspacesdk.AgentConn, func(), error) {
return nil, func() {}, nil
}
result := waitForAgentReady(context.Background(), db, agentID, connFn)
require.Empty(t, result)
})
t.Run("AgentConnectTimeout", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
agentID := uuid.New()
// AgentConnFn always fails - context will timeout.
connFn := func(ctx context.Context, id uuid.UUID) (workspacesdk.AgentConn, func(), error) {
return nil, nil, context.DeadlineExceeded
}
// Use a context that's already canceled to avoid waiting.
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := waitForAgentReady(ctx, db, agentID, connFn)
require.Equal(t, "not_ready", result["agent_status"])
require.NotEmpty(t, result["agent_error"])
})
t.Run("AgentConnectsButStartupFails", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
agentID := uuid.New()
// Mock returns StartError lifecycle state.
db.EXPECT().
GetWorkspaceAgentLifecycleStateByID(gomock.Any(), agentID).
Return(database.GetWorkspaceAgentLifecycleStateByIDRow{
LifecycleState: database.WorkspaceAgentLifecycleStateStartError,
}, nil)
connFn := func(ctx context.Context, id uuid.UUID) (workspacesdk.AgentConn, func(), error) {
return nil, func() {}, nil
}
result := waitForAgentReady(context.Background(), db, agentID, connFn)
require.Equal(t, "startup_scripts_failed", result["startup_scripts"])
require.Equal(t, "start_error", result["lifecycle_state"])
})
t.Run("NilAgentConnFn", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
agentID := uuid.New()
// Mock returns Ready lifecycle state.
db.EXPECT().
GetWorkspaceAgentLifecycleStateByID(gomock.Any(), agentID).
Return(database.GetWorkspaceAgentLifecycleStateByIDRow{
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
}, nil)
result := waitForAgentReady(context.Background(), db, agentID, nil)
require.Empty(t, result)
})
t.Run("NilDB", func(t *testing.T) {
t.Parallel()
connFn := func(ctx context.Context, id uuid.UUID) (workspacesdk.AgentConn, func(), error) {
return nil, func() {}, nil
}
result := waitForAgentReady(context.Background(), nil, uuid.New(), connFn)
require.Empty(t, result)
})
}
func TestCheckExistingWorkspace_DeletedWorkspace(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
workspaceID := uuid.New()
// Mock GetChatByID returns a chat linked to a workspace.
db.EXPECT().
GetChatByID(gomock.Any(), chatID).
Return(database.Chat{
ID: chatID,
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
}, nil)
// Mock GetWorkspaceByID returns a soft-deleted workspace.
db.EXPECT().
GetWorkspaceByID(gomock.Any(), workspaceID).
Return(database.Workspace{
ID: workspaceID,
Deleted: true,
}, nil)
result, done, err := checkExistingWorkspace(
context.Background(), db, chatID, nil,
)
require.NoError(t, err)
require.False(t, done, "should allow creation for deleted workspace")
require.Nil(t, result)
}