Files
coder/coderd/templatebuilder_handler_test.go

539 lines
16 KiB
Go

package coderd_test
import (
"context"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/coderd/templatebuilder"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestTemplateBuilderBases(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.TemplateBuilderBases(ctx)
require.NoError(t, err)
require.NotEmpty(t, resp.Bases)
require.Len(t, resp.Bases, len(templatebuilder.BaseTemplateIDs()))
basesByID := make(map[string]codersdk.TemplateBuilderBase, len(resp.Bases))
for _, b := range resp.Bases {
basesByID[b.ID] = b
}
type baseSpec struct {
id string
expectedOS string
expectedVars []string
hasVariables bool
}
specs := []baseSpec{
{
// Quickstart exposes no builder variables today: its base.json
// declares none. Locking that here flags drift if it changes,
// e.g. if it later exposes a container_image selector.
id: "quickstart",
expectedOS: "linux",
hasVariables: false,
},
{
id: "docker",
expectedOS: "linux",
hasVariables: true,
expectedVars: []string{"container_image"},
},
{
id: "kubernetes",
expectedOS: "linux",
hasVariables: true,
expectedVars: []string{"container_image", "namespace", "use_kubeconfig"},
},
{
id: "aws-linux",
expectedOS: "linux",
hasVariables: false,
},
{
id: "aws-windows",
expectedOS: "windows",
hasVariables: false,
},
{
id: "gcp-windows",
expectedOS: "windows",
hasVariables: true,
expectedVars: []string{"project_id"},
},
}
for _, spec := range specs {
b, ok := basesByID[spec.id]
require.True(t, ok, "base %q missing from response", spec.id)
require.NotEmpty(t, b.Name, "base %q should have a name", spec.id)
require.NotEmpty(t, b.Icon, "base %q should have an icon", spec.id)
require.Equal(t, spec.expectedOS, b.OS, "base %q OS mismatch", spec.id)
require.NotNil(t, b.Variables, "base %q should have non-nil variables slice", spec.id)
if spec.hasVariables {
require.NotEmpty(t, b.Variables, "base %q should have variables", spec.id)
varNames := make(map[string]bool, len(b.Variables))
for _, v := range b.Variables {
varNames[v.Name] = true
}
for _, expected := range spec.expectedVars {
require.True(t, varNames[expected],
"base %q should have variable %q", spec.id, expected)
}
} else {
require.Empty(t, b.Variables, "base %q should have no variables", spec.id)
}
// Every base declares at least one agent, exactly one default.
require.NotEmpty(t, b.Agents, "base %q should declare agents", spec.id)
defaults := 0
for _, a := range b.Agents {
if a.Default {
defaults++
}
}
require.Equal(t, 1, defaults, "base %q should mark exactly one default agent", spec.id)
}
// aws-linux names its agent "dev"; the rest use "main".
require.Equal(t,
[]codersdk.TemplateBuilderBaseAgent{{Name: "dev", Default: true}},
basesByID["aws-linux"].Agents)
require.Equal(t,
[]codersdk.TemplateBuilderBaseAgent{{Name: "main", Default: true}},
basesByID["docker"].Agents)
})
t.Run("Sorted", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.TemplateBuilderBases(ctx)
require.NoError(t, err)
require.NotEmpty(t, resp.Bases)
// Bases are returned sorted by display name (with ID as a deterministic
// tiebreak), so the whole list is in non-decreasing name order.
for i := 1; i < len(resp.Bases); i++ {
require.LessOrEqual(t, resp.Bases[i-1].Name, resp.Bases[i].Name,
"bases should be sorted by display name")
}
})
t.Run("DisabledReturns404", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.TemplateBuilder.Disabled = true
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: dv,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderBases(ctx)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
func TestTemplateBuilderModules(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.TemplateBuilderModules(ctx, "")
require.NoError(t, err)
require.NotEmpty(t, resp.Modules)
for _, m := range resp.Modules {
require.NotEmpty(t, m.ID)
require.NotEmpty(t, m.Version)
}
})
t.Run("FilteredByBase", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.TemplateBuilderModules(ctx, "docker")
require.NoError(t, err)
for _, m := range resp.Modules {
if len(m.CompatibleOS) > 0 {
require.Contains(t, m.CompatibleOS, "linux",
"module %q should be compatible with linux when filtered by docker base", m.ID)
}
}
})
t.Run("BaseExcludesIncludedModules", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// The quickstart base bundles the git-clone module, so the module
// list for that base must omit git-clone to avoid a collision. The
// docker base does not bundle it, so it stays available there.
quickstartResp, err := client.TemplateBuilderModules(ctx, "quickstart")
require.NoError(t, err)
require.NotEmpty(t, quickstartResp.Modules,
"quickstart should still offer modules other than the ones it bundles")
for _, m := range quickstartResp.Modules {
require.NotEqual(t, "git-clone", m.ID,
"git-clone should be excluded for the quickstart base")
}
dockerResp, err := client.TemplateBuilderModules(ctx, "docker")
require.NoError(t, err)
var dockerHasGitClone bool
for _, m := range dockerResp.Modules {
if m.ID == "git-clone" {
dockerHasGitClone = true
break
}
}
require.True(t, dockerHasGitClone,
"git-clone should remain available for bases that do not bundle it")
})
t.Run("ComputedVariablesExcluded", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.TemplateBuilderModules(ctx, "")
require.NoError(t, err)
// The embedded code-server module has agent_id with computed=true.
// It must not appear in the API response.
var found bool
for _, m := range resp.Modules {
if m.ID == "code-server" {
found = true
for _, v := range m.Variables {
require.NotEqual(t, "agent_id", v.Name,
"computed variable agent_id must not appear in API response")
}
}
}
require.True(t, found, "code-server module must be in the catalog")
})
t.Run("UnknownBaseReturns400", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderModules(ctx, "nonexistent")
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("DisabledReturns404", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.TemplateBuilder.Disabled = true
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: dv,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderModules(ctx, "")
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
func TestTemplateBuilderSession(t *testing.T) {
t.Parallel()
t.Run("WizardEntry", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{
SessionID: uuid.New(),
EventType: codersdk.TemplateBuilderSessionEventWizardEntry,
})
require.NoError(t, err)
})
t.Run("ComposeCompletion", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{
SessionID: uuid.New(),
EventType: codersdk.TemplateBuilderSessionEventComposeCompletion,
BaseTemplateID: "docker",
ModuleIDs: []string{"code-server", "git-clone"},
DurationSeconds: 42.5,
Success: true,
})
require.NoError(t, err)
})
t.Run("MissingSessionID", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{
EventType: codersdk.TemplateBuilderSessionEventWizardEntry,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("InvalidEventType", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{
EventType: "invalid_event",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("DisabledReturns404", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.TemplateBuilder.Disabled = true
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: dv,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{
EventType: codersdk.TemplateBuilderSessionEventWizardEntry,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
t.Run("MemberCannotSubmit", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
admin := coderdtest.CreateFirstUser(t, client)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
err := memberClient.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{
EventType: codersdk.TemplateBuilderSessionEventWizardEntry,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
func TestTemplateBuilderCreateTemplateFailureTelemetry(t *testing.T) {
t.Parallel()
t.Run("ComposeInvalid", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
reporter := newFakeTelemetryReporter(ctx, t, 100)
client := coderdtest.New(t, &coderdtest.Options{
TelemetryReporter: reporter,
})
user := coderdtest.CreateFirstUser(t, client)
sessionID := uuid.New()
_, err := client.TemplateBuilderCreateTemplate(ctx, codersdk.TemplateBuilderCreateTemplateRequest{
SessionID: sessionID,
BaseTemplateID: "nonexistent",
Modules: []codersdk.TemplateBuilderComposeModule{
{ID: "code-server"},
},
OrganizationID: user.OrganizationID,
Name: "compose-invalid",
})
require.Error(t, err)
event := receiveTemplateBuilderSession(ctx, t, reporter)
require.Equal(t, telemetry.TemplateBuilderSessionEventBuildFailure, event.EventType)
require.Equal(t, telemetry.TemplateBuilderFailureComposeInvalid, event.FailureReason)
// The session ID doubles as the event ID so the failure joins to the
// wizard_entry event of the same visit.
require.Equal(t, sessionID, event.ID)
require.Equal(t, user.UserID, event.UserID)
require.Equal(t, "nonexistent", event.BaseTemplateID)
require.Equal(t, []string{"code-server"}, event.ModuleIDs)
require.False(t, event.Success)
})
t.Run("MissingBaseTemplateID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
reporter := newFakeTelemetryReporter(ctx, t, 100)
client := coderdtest.New(t, &coderdtest.Options{
TelemetryReporter: reporter,
})
user := coderdtest.CreateFirstUser(t, client)
sessionID := uuid.New()
_, err := client.TemplateBuilderCreateTemplate(ctx, codersdk.TemplateBuilderCreateTemplateRequest{
SessionID: sessionID,
OrganizationID: user.OrganizationID,
Name: "missing-base",
})
require.Error(t, err)
event := receiveTemplateBuilderSession(ctx, t, reporter)
require.Equal(t, telemetry.TemplateBuilderSessionEventBuildFailure, event.EventType)
require.Equal(t, telemetry.TemplateBuilderFailureInvalidRequest, event.FailureReason)
require.Equal(t, sessionID, event.ID)
})
t.Run("NameConflict", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
reporter := newFakeTelemetryReporter(ctx, t, 100)
db, ps := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
TelemetryReporter: reporter,
})
user := coderdtest.CreateFirstUser(t, client)
existing := dbgen.Template(t, db, database.Template{
OrganizationID: user.OrganizationID,
CreatedBy: user.UserID,
Name: "taken-name",
})
_, err := client.TemplateBuilderCreateTemplate(ctx, codersdk.TemplateBuilderCreateTemplateRequest{
SessionID: uuid.New(),
BaseTemplateID: "docker",
OrganizationID: user.OrganizationID,
Name: existing.Name,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
event := receiveTemplateBuilderSession(ctx, t, reporter)
require.Equal(t, telemetry.TemplateBuilderSessionEventBuildFailure, event.EventType)
require.Equal(t, telemetry.TemplateBuilderFailureNameConflict, event.FailureReason)
require.Equal(t, "docker", event.BaseTemplateID)
})
}
// receiveTemplateBuilderSession drains snapshots until one carries a template
// builder session event. Unrelated snapshots, such as those reported while
// creating the first user, arrive on the same channel.
func receiveTemplateBuilderSession(ctx context.Context, t *testing.T, reporter *fakeTelemetryReporter) telemetry.TemplateBuilderSession {
t.Helper()
for {
snapshot := testutil.TryReceive(ctx, t, reporter.snapshots)
if len(snapshot.TemplateBuilderSessions) == 0 {
continue
}
require.Len(t, snapshot.TemplateBuilderSessions, 1)
return snapshot.TemplateBuilderSessions[0]
}
}