package agentapi_test import ( "cmp" "context" "database/sql" "slices" "sync/atomic" "testing" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog" "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentapi" "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/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" ) func TestSubAgentAPI(t *testing.T) { t.Parallel() newDatabaseWithOrg := func(t *testing.T) (database.Store, database.Organization) { db, _ := dbtestutil.NewDB(t) org := dbgen.Organization(t, db, database.Organization{}) return db, org } newUserWithWorkspaceAgent := func(t *testing.T, db database.Store, org database.Organization) (database.User, database.WorkspaceAgent) { user := dbgen.User(t, db, database.User{}) template := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, }) templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID}, OrganizationID: org.ID, CreatedBy: user.ID, }) workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ OrganizationID: org.ID, TemplateID: template.ID, OwnerID: user.ID, }) job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ Type: database.ProvisionerJobTypeWorkspaceBuild, OrganizationID: org.ID, }) build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ JobID: job.ID, WorkspaceID: workspace.ID, TemplateVersionID: templateVersion.ID, }) resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ JobID: build.JobID, }) agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ResourceID: resource.ID, }) return user, agent } newAgentAPI := func(t *testing.T, logger slog.Logger, db database.Store, clock quartz.Clock, user database.User, org database.Organization, agent database.WorkspaceAgent) *agentapi.SubAgentAPI { auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{} var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} accessControlStore.Store(&acs) return &agentapi.SubAgentAPI{ OwnerID: user.ID, OrganizationID: org.ID, AgentID: agent.ID, AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return agent, nil }, Clock: clock, Database: dbauthz.New(db, auth, logger, accessControlStore), } } t.Run("CreateSubAgent", func(t *testing.T) { t.Parallel() tests := []struct { name string agentName string agentDir string agentArch string agentOS string expectedError *codersdk.ValidationError }{ { name: "Ok", agentName: "some-child-agent", agentDir: "/workspaces/wibble", agentArch: "amd64", agentOS: "linux", }, { name: "NameWithUnderscore", agentName: "some_child_agent", agentDir: "/workspaces/wibble", agentArch: "amd64", agentOS: "linux", expectedError: &codersdk.ValidationError{ Field: "name", Detail: "agent name \"some_child_agent\" does not match regex \"(?i)^[a-z0-9](-?[a-z0-9])*$\"", }, }, { name: "EmptyName", agentName: "", agentDir: "/workspaces/wibble", agentArch: "amd64", agentOS: "linux", expectedError: &codersdk.ValidationError{ Field: "name", Detail: "agent name cannot be empty", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ Name: tt.agentName, Directory: tt.agentDir, Architecture: tt.agentArch, OperatingSystem: tt.agentOS, }) if tt.expectedError != nil { require.Error(t, err) var validationErr codersdk.ValidationError require.ErrorAs(t, err, &validationErr) require.Equal(t, *tt.expectedError, validationErr) } else { require.NoError(t, err) require.NotNil(t, createResp.Agent) agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) agent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. require.NoError(t, err) assert.Equal(t, tt.agentName, agent.Name) assert.Equal(t, tt.agentDir, agent.Directory) assert.Equal(t, tt.agentArch, agent.Architecture) assert.Equal(t, tt.agentOS, agent.OperatingSystem) } }) } }) type expectedAppError struct { index int32 field string error string } t.Run("CreateSubAgentWithApps", func(t *testing.T) { t.Parallel() tests := []struct { name string apps []*proto.CreateSubAgentRequest_App expectApps []database.WorkspaceApp expectedAppErrors []expectedAppError }{ { name: "OK", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "code-server", DisplayName: ptr.Ref("VS Code"), Icon: ptr.Ref("/icon/code.svg"), Url: ptr.Ref("http://localhost:13337"), Share: proto.CreateSubAgentRequest_App_OWNER.Enum(), Subdomain: ptr.Ref(false), OpenIn: proto.CreateSubAgentRequest_App_SLIM_WINDOW.Enum(), Healthcheck: &proto.CreateSubAgentRequest_App_Healthcheck{ Interval: 5, Threshold: 6, Url: "http://localhost:13337/healthz", }, }, { Slug: "vim", Command: ptr.Ref("vim"), DisplayName: ptr.Ref("Vim"), Icon: ptr.Ref("/icon/vim.svg"), }, }, expectApps: []database.WorkspaceApp{ { Slug: "fdqf0lpd-code-server", DisplayName: "VS Code", Icon: "/icon/code.svg", Command: sql.NullString{}, Url: sql.NullString{Valid: true, String: "http://localhost:13337"}, HealthcheckUrl: "http://localhost:13337/healthz", HealthcheckInterval: 5, HealthcheckThreshold: 6, Health: database.WorkspaceAppHealthInitializing, Subdomain: false, SharingLevel: database.AppSharingLevelOwner, External: false, DisplayOrder: 0, Hidden: false, OpenIn: database.WorkspaceAppOpenInSlimWindow, DisplayGroup: sql.NullString{}, }, { Slug: "547knu0f-vim", DisplayName: "Vim", Icon: "/icon/vim.svg", Command: sql.NullString{Valid: true, String: "vim"}, Health: database.WorkspaceAppHealthDisabled, SharingLevel: database.AppSharingLevelOwner, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, }, }, { name: "EmptyAppSlug", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "", DisplayName: ptr.Ref("App"), }, }, expectApps: []database.WorkspaceApp{}, expectedAppErrors: []expectedAppError{ { index: 0, field: "slug", error: "must not be empty", }, }, }, { name: "InvalidAppSlugWithUnderscores", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "invalid_slug_with_underscores", DisplayName: ptr.Ref("App"), }, }, expectApps: []database.WorkspaceApp{}, expectedAppErrors: []expectedAppError{ { index: 0, field: "slug", error: "\"invalid_slug_with_underscores\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", }, }, }, { name: "InvalidAppSlugWithUppercase", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "InvalidSlug", DisplayName: ptr.Ref("App"), }, }, expectApps: []database.WorkspaceApp{}, expectedAppErrors: []expectedAppError{ { index: 0, field: "slug", error: "\"InvalidSlug\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", }, }, }, { name: "InvalidAppSlugStartsWithHyphen", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "-invalid-app", DisplayName: ptr.Ref("App"), }, }, expectApps: []database.WorkspaceApp{}, expectedAppErrors: []expectedAppError{ { index: 0, field: "slug", error: "\"-invalid-app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", }, }, }, { name: "InvalidAppSlugEndsWithHyphen", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "invalid-app-", DisplayName: ptr.Ref("App"), }, }, expectApps: []database.WorkspaceApp{}, expectedAppErrors: []expectedAppError{ { index: 0, field: "slug", error: "\"invalid-app-\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", }, }, }, { name: "InvalidAppSlugWithDoubleHyphens", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "invalid--app", DisplayName: ptr.Ref("App"), }, }, expectApps: []database.WorkspaceApp{}, expectedAppErrors: []expectedAppError{ { index: 0, field: "slug", error: "\"invalid--app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", }, }, }, { name: "InvalidAppSlugWithSpaces", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "invalid app", DisplayName: ptr.Ref("App"), }, }, expectApps: []database.WorkspaceApp{}, expectedAppErrors: []expectedAppError{ { index: 0, field: "slug", error: "\"invalid app\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", }, }, }, { name: "MultipleAppsWithErrorInSecond", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "valid-app", DisplayName: ptr.Ref("Valid App"), }, { Slug: "Invalid_App", DisplayName: ptr.Ref("Invalid App"), }, }, expectApps: []database.WorkspaceApp{ { Slug: "511ctirn-valid-app", DisplayName: "Valid App", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, }, expectedAppErrors: []expectedAppError{ { index: 1, field: "slug", error: "\"Invalid_App\" does not match regex \"^[a-z0-9](-?[a-z0-9])*$\"", }, }, }, { name: "AppWithAllSharingLevels", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "owner-app", Share: proto.CreateSubAgentRequest_App_OWNER.Enum(), }, { Slug: "authenticated-app", Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(), }, { Slug: "public-app", Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(), }, }, expectApps: []database.WorkspaceApp{ { Slug: "atpt261l-authenticated-app", SharingLevel: database.AppSharingLevelAuthenticated, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, { Slug: "eh5gp1he-owner-app", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, { Slug: "oopjevf1-public-app", SharingLevel: database.AppSharingLevelPublic, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, }, }, { name: "AppWithDifferentOpenInOptions", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "window-app", OpenIn: proto.CreateSubAgentRequest_App_SLIM_WINDOW.Enum(), }, { Slug: "tab-app", OpenIn: proto.CreateSubAgentRequest_App_TAB.Enum(), }, }, expectApps: []database.WorkspaceApp{ { Slug: "ci9500rm-tab-app", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInTab, }, { Slug: "p17s76re-window-app", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, }, }, { name: "AppWithAllOptionalFields", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "full-app", Command: ptr.Ref("echo hello"), DisplayName: ptr.Ref("Full Featured App"), External: ptr.Ref(true), Group: ptr.Ref("Development"), Hidden: ptr.Ref(true), Icon: ptr.Ref("/icon/app.svg"), Order: ptr.Ref(int32(10)), Subdomain: ptr.Ref(true), Url: ptr.Ref("http://localhost:8080"), Healthcheck: &proto.CreateSubAgentRequest_App_Healthcheck{ Interval: 30, Threshold: 3, Url: "http://localhost:8080/health", }, }, }, expectApps: []database.WorkspaceApp{ { Slug: "0ccdbg39-full-app", Command: sql.NullString{Valid: true, String: "echo hello"}, DisplayName: "Full Featured App", External: true, DisplayGroup: sql.NullString{Valid: true, String: "Development"}, Hidden: true, Icon: "/icon/app.svg", DisplayOrder: 10, Subdomain: true, Url: sql.NullString{Valid: true, String: "http://localhost:8080"}, HealthcheckUrl: "http://localhost:8080/health", HealthcheckInterval: 30, HealthcheckThreshold: 3, Health: database.WorkspaceAppHealthInitializing, SharingLevel: database.AppSharingLevelOwner, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, }, }, { name: "AppWithoutHealthcheck", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "no-health-app", }, }, expectApps: []database.WorkspaceApp{ { Slug: "nphrhbh6-no-health-app", Health: database.WorkspaceAppHealthDisabled, SharingLevel: database.AppSharingLevelOwner, OpenIn: database.WorkspaceAppOpenInSlimWindow, HealthcheckUrl: "", HealthcheckInterval: 0, HealthcheckThreshold: 0, }, }, }, { name: "DuplicateAppSlugs", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "duplicate-app", DisplayName: ptr.Ref("First App"), }, { Slug: "duplicate-app", DisplayName: ptr.Ref("Second App"), }, }, expectApps: []database.WorkspaceApp{ { Slug: "uiklfckv-duplicate-app", DisplayName: "First App", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, }, expectedAppErrors: []expectedAppError{ { index: 1, field: "slug", error: "\"duplicate-app\" is already in use", }, }, }, { name: "MultipleDuplicateAppSlugs", apps: []*proto.CreateSubAgentRequest_App{ { Slug: "valid-app", DisplayName: ptr.Ref("Valid App"), }, { Slug: "duplicate-app", DisplayName: ptr.Ref("First Duplicate"), }, { Slug: "duplicate-app", DisplayName: ptr.Ref("Second Duplicate"), }, { Slug: "duplicate-app", DisplayName: ptr.Ref("Third Duplicate"), }, }, expectApps: []database.WorkspaceApp{ { Slug: "uiklfckv-duplicate-app", DisplayName: "First Duplicate", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, { Slug: "511ctirn-valid-app", DisplayName: "Valid App", SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, OpenIn: database.WorkspaceAppOpenInSlimWindow, }, }, expectedAppErrors: []expectedAppError{ { index: 2, field: "slug", error: "\"duplicate-app\" is already in use", }, { index: 3, field: "slug", error: "\"duplicate-app\" is already in use", }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ Name: "child-agent", Directory: "/workspaces/coder", Architecture: "amd64", OperatingSystem: "linux", Apps: tt.apps, }) require.NoError(t, err) agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. require.NoError(t, err) // Sort the apps for determinism slices.SortFunc(apps, func(a, b database.WorkspaceApp) int { return cmp.Compare(a.Slug, b.Slug) }) slices.SortFunc(tt.expectApps, func(a, b database.WorkspaceApp) int { return cmp.Compare(a.Slug, b.Slug) }) require.Len(t, apps, len(tt.expectApps)) for idx, app := range apps { assert.Equal(t, tt.expectApps[idx].Slug, app.Slug) assert.Equal(t, tt.expectApps[idx].Command, app.Command) assert.Equal(t, tt.expectApps[idx].DisplayName, app.DisplayName) assert.Equal(t, tt.expectApps[idx].External, app.External) assert.Equal(t, tt.expectApps[idx].DisplayGroup, app.DisplayGroup) assert.Equal(t, tt.expectApps[idx].HealthcheckInterval, app.HealthcheckInterval) assert.Equal(t, tt.expectApps[idx].HealthcheckThreshold, app.HealthcheckThreshold) assert.Equal(t, tt.expectApps[idx].HealthcheckUrl, app.HealthcheckUrl) assert.Equal(t, tt.expectApps[idx].Hidden, app.Hidden) assert.Equal(t, tt.expectApps[idx].Icon, app.Icon) assert.Equal(t, tt.expectApps[idx].OpenIn, app.OpenIn) assert.Equal(t, tt.expectApps[idx].DisplayOrder, app.DisplayOrder) assert.Equal(t, tt.expectApps[idx].SharingLevel, app.SharingLevel) assert.Equal(t, tt.expectApps[idx].Subdomain, app.Subdomain) assert.Equal(t, tt.expectApps[idx].Url, app.Url) } // Verify expected app creation errors require.Len(t, createResp.AppCreationErrors, len(tt.expectedAppErrors), "Number of app creation errors should match expected") // Build a map of actual errors by index for easier testing actualErrorMap := make(map[int32]*proto.CreateSubAgentResponse_AppCreationError) for _, appErr := range createResp.AppCreationErrors { actualErrorMap[appErr.Index] = appErr } // Verify each expected error for _, expectedErr := range tt.expectedAppErrors { actualErr, exists := actualErrorMap[expectedErr.index] require.True(t, exists, "Expected app creation error at index %d", expectedErr.index) require.NotNil(t, actualErr.Field, "Field should be set for validation error at index %d", expectedErr.index) require.Equal(t, expectedErr.field, *actualErr.Field, "Field name should match for error at index %d", expectedErr.index) require.Contains(t, actualErr.Error, expectedErr.error, "Error message should contain expected text for error at index %d", expectedErr.index) } }) } t.Run("ValidationErrorFieldMapping", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) // Test different types of validation errors to ensure field mapping works correctly createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ Name: "validation-test-agent", Directory: "/workspace", Architecture: "amd64", OperatingSystem: "linux", Apps: []*proto.CreateSubAgentRequest_App{ { Slug: "", // Empty slug - should error on apps[0].slug DisplayName: ptr.Ref("Empty Slug App"), }, { Slug: "Invalid_Slug_With_Underscores", // Invalid characters - should error on apps[1].slug DisplayName: ptr.Ref("Invalid Characters App"), }, { Slug: "duplicate-slug", // First occurrence - should succeed DisplayName: ptr.Ref("First Duplicate"), }, { Slug: "duplicate-slug", // Duplicate - should error on apps[3].slug DisplayName: ptr.Ref("Second Duplicate"), }, { Slug: "-invalid-start", // Invalid start character - should error on apps[4].slug DisplayName: ptr.Ref("Invalid Start App"), }, }, }) // Agent should be created successfully require.NoError(t, err) require.NotNil(t, createResp.Agent) // Should have 4 app creation errors (indices 0, 1, 3, 4) require.Len(t, createResp.AppCreationErrors, 4) errorMap := make(map[int32]*proto.CreateSubAgentResponse_AppCreationError) for _, appErr := range createResp.AppCreationErrors { errorMap[appErr.Index] = appErr } // Verify each specific validation error and its field require.Contains(t, errorMap, int32(0)) require.NotNil(t, errorMap[0].Field) require.Equal(t, "slug", *errorMap[0].Field) require.Contains(t, errorMap[0].Error, "must not be empty") require.Contains(t, errorMap, int32(1)) require.NotNil(t, errorMap[1].Field) require.Equal(t, "slug", *errorMap[1].Field) require.Contains(t, errorMap[1].Error, "Invalid_Slug_With_Underscores") require.Contains(t, errorMap, int32(3)) require.NotNil(t, errorMap[3].Field) require.Equal(t, "slug", *errorMap[3].Field) require.Contains(t, errorMap[3].Error, "duplicate-slug") require.Contains(t, errorMap, int32(4)) require.NotNil(t, errorMap[4].Field) require.Equal(t, "slug", *errorMap[4].Field) require.Contains(t, errorMap[4].Error, "-invalid-start") // Verify only the valid app (index 2) was created agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) apps, err := db.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. require.NoError(t, err) require.Len(t, apps, 1) require.Equal(t, "k5jd7a99-duplicate-slug", apps[0].Slug) require.Equal(t, "First Duplicate", apps[0].DisplayName) }) }) t.Run("DeleteSubAgent", func(t *testing.T) { t.Parallel() t.Run("WhenOnlyOne", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) // Given: A sub agent. childAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID}, ResourceID: agent.ResourceID, Name: "some-child-agent", Directory: "/workspaces/wibble", Architecture: "amd64", OperatingSystem: "linux", }) // When: We delete the sub agent. _, err := api.DeleteSubAgent(ctx, &proto.DeleteSubAgentRequest{ Id: childAgent.ID[:], }) require.NoError(t, err) // Then: It is deleted. _, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID) //nolint:gocritic // this is a test. require.ErrorIs(t, err, sql.ErrNoRows) }) t.Run("WhenOneOfMany", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) // Given: Multiple sub agents. childAgentOne := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID}, ResourceID: agent.ResourceID, Name: "child-agent-one", Directory: "/workspaces/wibble", Architecture: "amd64", OperatingSystem: "linux", }) childAgentTwo := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID}, ResourceID: agent.ResourceID, Name: "child-agent-two", Directory: "/workspaces/wobble", Architecture: "amd64", OperatingSystem: "linux", }) // When: We delete one of the sub agents. _, err := api.DeleteSubAgent(ctx, &proto.DeleteSubAgentRequest{ Id: childAgentOne.ID[:], }) require.NoError(t, err) // Then: The correct one is deleted. _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) //nolint:gocritic // this is a test. require.ErrorIs(t, err, sql.ErrNoRows) _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentTwo.ID) //nolint:gocritic // this is a test. require.NoError(t, err) }) t.Run("CannotDeleteOtherAgentsChild", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) userOne, agentOne := newUserWithWorkspaceAgent(t, db, org) _ = newAgentAPI(t, log, db, clock, userOne, org, agentOne) userTwo, agentTwo := newUserWithWorkspaceAgent(t, db, org) apiTwo := newAgentAPI(t, log, db, clock, userTwo, org, agentTwo) // Given: Both workspaces have child agents childAgentOne := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ParentID: uuid.NullUUID{Valid: true, UUID: agentOne.ID}, ResourceID: agentOne.ResourceID, Name: "child-agent-one", Directory: "/workspaces/wibble", Architecture: "amd64", OperatingSystem: "linux", }) // When: An agent API attempts to delete an agent it doesn't own _, err := apiTwo.DeleteSubAgent(ctx, &proto.DeleteSubAgentRequest{ Id: childAgentOne.ID[:], }) // Then: We expect it to fail and for the agent to still exist. var notAuthorizedError dbauthz.NotAuthorizedError require.ErrorAs(t, err, ¬AuthorizedError) _, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) //nolint:gocritic // this is a test. require.NoError(t, err) }) t.Run("DeleteRetainsWorkspaceApps", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) // Given: A sub agent with workspace apps createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ Name: "child-agent-with-apps", Directory: "/workspaces/coder", Architecture: "amd64", OperatingSystem: "linux", Apps: []*proto.CreateSubAgentRequest_App{ { Slug: "code-server", DisplayName: ptr.Ref("VS Code"), Icon: ptr.Ref("/icon/code.svg"), Url: ptr.Ref("http://localhost:13337"), }, { Slug: "vim", Command: ptr.Ref("vim"), DisplayName: ptr.Ref("Vim"), }, }, }) require.NoError(t, err) subAgentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) // Verify that the apps were created apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), subAgentID) //nolint:gocritic // this is a test. require.NoError(t, err) require.Len(t, apps, 2) // When: We delete the sub agent _, err = api.DeleteSubAgent(ctx, &proto.DeleteSubAgentRequest{ Id: createResp.Agent.Id, }) require.NoError(t, err) // Then: The agent is deleted _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), subAgentID) //nolint:gocritic // this is a test. require.ErrorIs(t, err, sql.ErrNoRows) // And: The apps are *retained* to avoid causing issues // where the resources are expected to be present. appsAfterDeletion, err := db.GetWorkspaceAppsByAgentID(ctx, subAgentID) require.NoError(t, err) require.NotEmpty(t, appsAfterDeletion) }) }) t.Run("CreateSubAgentWithDisplayApps", func(t *testing.T) { t.Parallel() tests := []struct { name string displayApps []proto.CreateSubAgentRequest_DisplayApp expectedApps []database.DisplayApp expectedError *codersdk.ValidationError }{ { name: "NoDisplayApps", displayApps: []proto.CreateSubAgentRequest_DisplayApp{}, expectedApps: []database.DisplayApp{}, }, { name: "SingleDisplayApp_VSCode", displayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_VSCODE, }, expectedApps: []database.DisplayApp{ database.DisplayAppVscode, }, }, { name: "SingleDisplayApp_VSCodeInsiders", displayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_VSCODE_INSIDERS, }, expectedApps: []database.DisplayApp{ database.DisplayAppVscodeInsiders, }, }, { name: "SingleDisplayApp_WebTerminal", displayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_WEB_TERMINAL, }, expectedApps: []database.DisplayApp{ database.DisplayAppWebTerminal, }, }, { name: "SingleDisplayApp_SSHHelper", displayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_SSH_HELPER, }, expectedApps: []database.DisplayApp{ database.DisplayAppSSHHelper, }, }, { name: "SingleDisplayApp_PortForwardingHelper", displayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, }, expectedApps: []database.DisplayApp{ database.DisplayAppPortForwardingHelper, }, }, { name: "MultipleDisplayApps", displayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_VSCODE, proto.CreateSubAgentRequest_WEB_TERMINAL, proto.CreateSubAgentRequest_SSH_HELPER, }, expectedApps: []database.DisplayApp{ database.DisplayAppVscode, database.DisplayAppWebTerminal, database.DisplayAppSSHHelper, }, }, { name: "AllDisplayApps", displayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_VSCODE, proto.CreateSubAgentRequest_VSCODE_INSIDERS, proto.CreateSubAgentRequest_WEB_TERMINAL, proto.CreateSubAgentRequest_SSH_HELPER, proto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, }, expectedApps: []database.DisplayApp{ database.DisplayAppVscode, database.DisplayAppVscodeInsiders, database.DisplayAppWebTerminal, database.DisplayAppSSHHelper, database.DisplayAppPortForwardingHelper, }, }, { name: "InvalidDisplayApp", displayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_DisplayApp(9999), // Invalid enum value }, expectedError: &codersdk.ValidationError{ Field: "display_apps[0]", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitLong) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ Name: "test-agent", Directory: "/workspaces/test", Architecture: "amd64", OperatingSystem: "linux", DisplayApps: tt.displayApps, }) if tt.expectedError != nil { require.Error(t, err) require.Nil(t, createResp) var validationErr codersdk.ValidationError require.ErrorAs(t, err, &validationErr) require.Equal(t, tt.expectedError.Field, validationErr.Field) require.Contains(t, validationErr.Detail, "is not a valid display app") } else { require.NoError(t, err) require.NotNil(t, createResp.Agent) agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. require.NoError(t, err) require.Equal(t, len(tt.expectedApps), len(subAgent.DisplayApps), "display apps count mismatch") for i, expectedApp := range tt.expectedApps { require.Equal(t, expectedApp, subAgent.DisplayApps[i], "display app at index %d doesn't match", i) } } }) } }) t.Run("CreateSubAgentWithDisplayAppsAndApps", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitLong) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) // Test that display apps and regular apps can coexist createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ Name: "test-agent", Directory: "/workspaces/test", Architecture: "amd64", OperatingSystem: "linux", DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{ proto.CreateSubAgentRequest_VSCODE, proto.CreateSubAgentRequest_WEB_TERMINAL, }, Apps: []*proto.CreateSubAgentRequest_App{ { Slug: "custom-app", DisplayName: ptr.Ref("Custom App"), Url: ptr.Ref("http://localhost:8080"), }, }, }) require.NoError(t, err) require.NotNil(t, createResp.Agent) require.Empty(t, createResp.AppCreationErrors) agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) // Verify display apps subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. require.NoError(t, err) require.Len(t, subAgent.DisplayApps, 2) require.Equal(t, database.DisplayAppVscode, subAgent.DisplayApps[0]) require.Equal(t, database.DisplayAppWebTerminal, subAgent.DisplayApps[1]) // Verify regular apps apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. require.NoError(t, err) require.Len(t, apps, 1) require.Equal(t, "v4qhkq17-custom-app", apps[0].Slug) require.Equal(t, "Custom App", apps[0].DisplayName) }) t.Run("ListSubAgents", func(t *testing.T) { t.Parallel() t.Run("Empty", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) // When: We list sub agents with no children listResp, err := api.ListSubAgents(ctx, &proto.ListSubAgentsRequest{}) require.NoError(t, err) // Then: We expect an empty list require.Empty(t, listResp.Agents) }) t.Run("Ok", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) user, agent := newUserWithWorkspaceAgent(t, db, org) api := newAgentAPI(t, log, db, clock, user, org, agent) // Given: Multiple sub agents. childAgentOne := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID}, ResourceID: agent.ResourceID, Name: "child-agent-one", Directory: "/workspaces/wibble", Architecture: "amd64", OperatingSystem: "linux", }) childAgentTwo := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID}, ResourceID: agent.ResourceID, Name: "child-agent-two", Directory: "/workspaces/wobble", Architecture: "amd64", OperatingSystem: "linux", }) childAgents := []database.WorkspaceAgent{childAgentOne, childAgentTwo} slices.SortFunc(childAgents, func(a, b database.WorkspaceAgent) int { return cmp.Compare(a.ID.String(), b.ID.String()) }) // When: We list the sub agents. listResp, err := api.ListSubAgents(ctx, &proto.ListSubAgentsRequest{}) //nolint:gocritic // this is a test. require.NoError(t, err) listedChildAgents := listResp.Agents slices.SortFunc(listedChildAgents, func(a, b *proto.SubAgent) int { return cmp.Compare(string(a.Id), string(b.Id)) }) // Then: We expect to see all the agents listed. require.Len(t, listedChildAgents, len(childAgents)) for i, listedAgent := range listedChildAgents { require.Equal(t, childAgents[i].ID[:], listedAgent.Id) require.Equal(t, childAgents[i].Name, listedAgent.Name) } }) t.Run("DoesNotListOtherAgentsChildren", func(t *testing.T) { t.Parallel() log := testutil.Logger(t) ctx := testutil.Context(t, testutil.WaitShort) clock := quartz.NewMock(t) db, org := newDatabaseWithOrg(t) // Create two users with their respective agents userOne, agentOne := newUserWithWorkspaceAgent(t, db, org) apiOne := newAgentAPI(t, log, db, clock, userOne, org, agentOne) userTwo, agentTwo := newUserWithWorkspaceAgent(t, db, org) apiTwo := newAgentAPI(t, log, db, clock, userTwo, org, agentTwo) // Given: Both parent agents have child agents childAgentOne := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ParentID: uuid.NullUUID{Valid: true, UUID: agentOne.ID}, ResourceID: agentOne.ResourceID, Name: "agent-one-child", Directory: "/workspaces/wibble", Architecture: "amd64", OperatingSystem: "linux", }) childAgentTwo := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ ParentID: uuid.NullUUID{Valid: true, UUID: agentTwo.ID}, ResourceID: agentTwo.ResourceID, Name: "agent-two-child", Directory: "/workspaces/wobble", Architecture: "amd64", OperatingSystem: "linux", }) // When: We list the sub agents for the first user listRespOne, err := apiOne.ListSubAgents(ctx, &proto.ListSubAgentsRequest{}) require.NoError(t, err) // Then: We should only see the first user's child agent require.Len(t, listRespOne.Agents, 1) require.Equal(t, childAgentOne.ID[:], listRespOne.Agents[0].Id) require.Equal(t, childAgentOne.Name, listRespOne.Agents[0].Name) // When: We list the sub agents for the second user listRespTwo, err := apiTwo.ListSubAgents(ctx, &proto.ListSubAgentsRequest{}) require.NoError(t, err) // Then: We should only see the second user's child agent require.Len(t, listRespTwo.Agents, 1) require.Equal(t, childAgentTwo.ID[:], listRespTwo.Agents[0].Id) require.Equal(t, childAgentTwo.Name, listRespTwo.Agents[0].Name) }) }) }