package coderd_test import ( "context" "os" "sync" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/atomic" "golang.org/x/xerrors" "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/dbtestutil" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisioner/terraform" provProto "github.com/coder/coder/v2/provisionerd/proto" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" "github.com/coder/websocket" ) func TestDynamicParametersOwnerSSHPublicKey(t *testing.T) { t.Parallel() ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, ownerClient) templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/public_key/main.tf") require.NoError(t, err) dynamicParametersTerraformPlan, err := os.ReadFile("testdata/parameters/public_key/plan.json") require.NoError(t, err) sshKey, err := templateAdmin.GitSSHKey(t.Context(), "me") require.NoError(t, err) files := echo.WithExtraFiles(map[string][]byte{ "main.tf": dynamicParametersTerraformSource, }) files.ProvisionPlan = []*proto.Response{{ Type: &proto.Response_Plan{ Plan: &proto.PlanComplete{ Plan: dynamicParametersTerraformPlan, }, }, }} version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files) coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID) _ = coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID) ctx := testutil.Context(t, testutil.WaitShort) stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID) require.NoError(t, err) defer stream.Close(websocket.StatusGoingAway) previews := stream.Chan() // Should automatically send a form state with all defaulted/empty values preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) require.Equal(t, "public_key", preview.Parameters[0].Name) require.True(t, preview.Parameters[0].Value.Valid) require.Equal(t, sshKey.PublicKey, preview.Parameters[0].Value.Value) } // TestDynamicParametersWithTerraformValues is for testing the websocket flow of // dynamic parameters. No workspaces are created. func TestDynamicParametersWithTerraformValues(t *testing.T) { t.Parallel() t.Run("OK_Modules", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf") require.NoError(t, err) modulesArchive, skipped, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules")) require.NoError(t, err) require.Len(t, skipped, 0) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, modulesArchive: modulesArchive, plan: nil, static: nil, }) ctx := testutil.Context(t, testutil.WaitShort) stream := setup.stream previews := stream.Chan() // Should see the output of the module represented preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) require.Len(t, preview.Parameters, 2) coderdtest.AssertParameter(t, "jetbrains_ide", preview.Parameters). Exists().Value("CL") coderdtest.AssertParameter(t, "region", preview.Parameters). Exists().Value("na") }) // OldProvisioners use the static parameters in the dynamic param flow t.Run("OldProvisioner", func(t *testing.T) { t.Parallel() const defaultValue = "PS" setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: "1.4", mainTF: nil, modulesArchive: nil, plan: nil, static: []*proto.RichParameter{ { Name: "jetbrains_ide", Type: "string", DefaultValue: defaultValue, Icon: "", Options: []*proto.RichParameterOption{ { Name: "PHPStorm", Description: "", Value: defaultValue, Icon: "", }, { Name: "Golang", Description: "", Value: "GO", Icon: "", }, }, ValidationRegex: "[PG][SO]", ValidationError: "Regex check", }, }, }) ctx := testutil.Context(t, testutil.WaitShort) stream := setup.stream previews := stream.Chan() // Assert the initial state preview := testutil.RequireReceive(ctx, t, previews) diagCount := len(preview.Diagnostics) require.Equal(t, 1, diagCount) require.Contains(t, preview.Diagnostics[0].Summary, "required metadata to support dynamic parameters") require.Len(t, preview.Parameters, 1) require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name) require.True(t, preview.Parameters[0].Value.Valid) require.Equal(t, defaultValue, preview.Parameters[0].Value.Value) // Test some inputs for _, exp := range []string{defaultValue, "GO", "Invalid", defaultValue} { inputs := map[string]string{} if exp != defaultValue { // Let the default value be the default without being explicitly set inputs["jetbrains_ide"] = exp } err := stream.Send(codersdk.DynamicParametersRequest{ ID: 1, Inputs: inputs, }) require.NoError(t, err) preview := testutil.RequireReceive(ctx, t, previews) diagCount := len(preview.Diagnostics) require.Equal(t, 1, diagCount) require.Contains(t, preview.Diagnostics[0].Summary, "required metadata to support dynamic parameters") require.Len(t, preview.Parameters, 1) if exp == "Invalid" { // Try an invalid option require.Len(t, preview.Parameters[0].Diagnostics, 1) } else { require.Len(t, preview.Parameters[0].Diagnostics, 0) } require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name) require.True(t, preview.Parameters[0].Value.Valid) require.Equal(t, exp, preview.Parameters[0].Value.Value) } }) t.Run("FileError", func(t *testing.T) { // Verify files close even if the websocket terminates from an error t.Parallel() db, ps := dbtestutil.NewDB(t) dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf") require.NoError(t, err) modulesArchive, skipped, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules")) require.NoError(t, err) require.Len(t, skipped, 0) c := atomic.NewInt32(0) reject := &dbRejectGitSSHKey{Store: db, hook: func(d *dbRejectGitSSHKey) { if c.Add(1) > 1 { // Second call forward, reject d.SetReject(true) } }} setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ db: reject, ps: ps, provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, modulesArchive: modulesArchive, }) stream := setup.stream previews := stream.Chan() // Assert the failed owner ctx := testutil.Context(t, testutil.WaitShort) preview := testutil.RequireReceive(ctx, t, previews) require.Len(t, preview.Diagnostics, 1) require.Equal(t, preview.Diagnostics[0].Summary, "Failed to fetch workspace owner") }) t.Run("RebuildParameters", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf") require.NoError(t, err) modulesArchive, skipped, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules")) require.NoError(t, err) require.Len(t, skipped, 0) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, modulesArchive: modulesArchive, plan: nil, static: nil, }) ctx := testutil.Context(t, testutil.WaitMedium) stream := setup.stream previews := stream.Chan() // Should see the output of the module represented preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) require.Len(t, preview.Parameters, 2) coderdtest.AssertParameter(t, "jetbrains_ide", preview.Parameters). Exists().Value("CL") coderdtest.AssertParameter(t, "region", preview.Parameters). Exists().Value("na") _ = stream.Close(websocket.StatusGoingAway) wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) { request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ { Name: preview.Parameters[0].Name, Value: "GO", }, { Name: preview.Parameters[1].Name, Value: "eu", }, } }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID) require.NoError(t, err) require.ElementsMatch(t, []codersdk.WorkspaceBuildParameter{ {Name: "jetbrains_ide", Value: "GO"}, {Name: "region", Value: "eu"}, }, params) regionOptions := []string{"na", "af", "sa", "as"} // A helper function to assert params doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) { t.Helper() regionVal := regionOptions[0] regionOptions = regionOptions[1:] // Choose the next region on the next build bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: setup.template.ActiveVersionID, Transition: trans, RichParameterValues: []codersdk.WorkspaceBuildParameter{ {Name: "region", Value: regionVal}, }, }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, bld.ID) latestParams, err := setup.client.WorkspaceBuildParameters(ctx, bld.ID) require.NoError(t, err) require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{ {Name: "jetbrains_ide", Value: "GO"}, {Name: "region", Value: regionVal}, }) } // Restart the workspace, then delete. Asserting params on all builds. doTransition(t, codersdk.WorkspaceTransitionStop) doTransition(t, codersdk.WorkspaceTransitionStart) doTransition(t, codersdk.WorkspaceTransitionDelete) }) t.Run("BadOwner", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf") require.NoError(t, err) modulesArchive, skipped, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules")) require.NoError(t, err) require.Len(t, skipped, 0) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, modulesArchive: modulesArchive, plan: nil, static: nil, }) ctx := testutil.Context(t, testutil.WaitShort) stream := setup.stream previews := stream.Chan() // Should see the output of the module represented preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) err = stream.Send(codersdk.DynamicParametersRequest{ ID: 1, Inputs: map[string]string{ "jetbrains_ide": "GO", }, OwnerID: uuid.New(), }) require.NoError(t, err) preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 1, preview.ID) require.Len(t, preview.Diagnostics, 1) require.Equal(t, preview.Diagnostics[0].Extra.Code, "owner_not_found") }) t.Run("TemplateVariables", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/variables/main.tf") require.NoError(t, err) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, variables: []codersdk.TemplateVersionVariable{ {Name: "one", Value: "austin", DefaultValue: "alice", Type: "string"}, }, plan: nil, static: nil, }) ctx := testutil.Context(t, testutil.WaitShort) stream := setup.stream previews := stream.Chan() // Should see the output of the module represented preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) require.Len(t, preview.Parameters, 1) coderdtest.AssertParameter(t, "variable_values", preview.Parameters). Exists().Value("austin") }) t.Run("MissingSecret", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") require.NoError(t, err) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, }) ctx := testutil.Context(t, testutil.WaitShort) previews := setup.stream.Chan() preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) for _, diag := range preview.Diagnostics { require.NotEqual(t, dynamicparameters.DiagCodeMissingSecret, diag.Extra.Code) } require.Equal(t, []codersdk.SecretRequirementStatus{{ Env: "GITHUB_TOKEN", HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN", Satisfied: false, }}, preview.SecretRequirements) }) t.Run("SecretRequirementPushesOnSecretChange", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") require.NoError(t, err) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, }) ctx := testutil.Context(t, testutil.WaitMedium) previews := setup.stream.Chan() preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.False(t, preview.SecretRequirements[0].Satisfied) _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ Name: "github-token", Value: "ghp_test", EnvName: "GITHUB_TOKEN", }) require.NoError(t, err) preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 0, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.True(t, preview.SecretRequirements[0].Satisfied) err = setup.dynamicParamsClient.DeleteUserSecret(ctx, codersdk.Me, "github-token") require.NoError(t, err) preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 1, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.False(t, preview.SecretRequirements[0].Satisfied) _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ Name: "github-token", Value: "ghp_test", EnvName: "GITHUB_TOKEN", }) require.NoError(t, err) preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 2, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.True(t, preview.SecretRequirements[0].Satisfied) otherEnvName := "OTHER_GITHUB_TOKEN" _, err = setup.dynamicParamsClient.UpdateUserSecret(ctx, codersdk.Me, "github-token", codersdk.UpdateUserSecretRequest{ EnvName: &otherEnvName, }) require.NoError(t, err) preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 3, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.False(t, preview.SecretRequirements[0].Satisfied) }) t.Run("SecretRequirementPushesAfterOwnerSwitch", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") require.NoError(t, err) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ // No production role grants cross-user user_secret:read today, // so use an allow-all authorizer for lifecycle coverage. authorizer: &coderdtest.FakeAuthorizer{}, provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, }) ctx := testutil.Context(t, testutil.WaitMedium) previews := setup.stream.Chan() targetClient, target := coderdtest.CreateAnotherUser(t, setup.client, setup.template.OrganizationID) preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.False(t, preview.SecretRequirements[0].Satisfied) err = setup.stream.Send(codersdk.DynamicParametersRequest{ ID: 0, Inputs: map[string]string{}, OwnerID: target.ID, }) require.NoError(t, err) preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 0, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.False(t, preview.SecretRequirements[0].Satisfied) _, err = targetClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ Name: "github-token", Value: "ghp_target", EnvName: "GITHUB_TOKEN", }) require.NoError(t, err) preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 1, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.True(t, preview.SecretRequirements[0].Satisfied) _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ Name: "github-token", Value: "ghp_initial", EnvName: "GITHUB_TOKEN", }) require.NoError(t, err) require.Never(t, func() bool { select { case <-previews: return true default: return false } }, testutil.WaitShort/5, testutil.IntervalFast) }) t.Run("SecretRequirementDoesNotSubscribeWhenOwnerUnauthorized", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") require.NoError(t, err) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, }) ctx := testutil.Context(t, testutil.WaitMedium) previews := setup.stream.Chan() targetClient, target := coderdtest.CreateAnotherUser(t, setup.client, setup.template.OrganizationID) preview := testutil.RequireReceive(ctx, t, previews) require.Equal(t, -1, preview.ID) require.Len(t, preview.SecretRequirements, 1) require.False(t, preview.SecretRequirements[0].Satisfied) err = setup.stream.Send(codersdk.DynamicParametersRequest{ ID: 0, Inputs: map[string]string{}, OwnerID: target.ID, }) require.NoError(t, err) preview = testutil.RequireReceive(ctx, t, previews) require.Equal(t, 0, preview.ID) require.Empty(t, preview.SecretRequirements) require.Len(t, preview.Diagnostics, 1) require.Equal(t, dynamicparameters.DiagCodeSecretValidationForbidden, preview.Diagnostics[0].Extra.Code) _, err = targetClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ Name: "github-token", Value: "ghp_target", EnvName: "GITHUB_TOKEN", }) require.NoError(t, err) require.Never(t, func() bool { select { case <-previews: return true default: return false } }, testutil.WaitShort/5, testutil.IntervalFast) }) // Regression test for PLAT-100: a workspace whose template has an // unsatisfied coder_secret requirement must still be stoppable and // deletable. Start remains blocked. t.Run("SecretRequirementDoesNotBlockStopOrDelete", func(t *testing.T) { t.Parallel() dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") require.NoError(t, err) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: dynamicParametersTerraformSource, }) _ = setup.stream.Close(websocket.StatusGoingAway) ctx := testutil.Context(t, testutil.WaitLong) // Owner must satisfy the coder_secret requirement to create // the workspace; delete it later to provoke the bug scenario. _, err = setup.client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ Name: "github-token", Value: "ghp_test", EnvName: "GITHUB_TOKEN", }) require.NoError(t, err) wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) require.NoError(t, setup.client.DeleteUserSecret(ctx, codersdk.Me, "github-token")) // Start on the now-unsatisfied requirement must still fail; // otherwise we've over-filtered the diagnostic. _, err = setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, }) require.Error(t, err, "start must still reject unsatisfied secret requirement") var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Contains(t, sdkErr.Detail, "Missing required secrets") require.Contains(t, sdkErr.Detail, "env GITHUB_TOKEN") // Stop must succeed despite the unsatisfied requirement. stop, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStop, }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, stop.ID) // Delete must succeed despite the unsatisfied requirement. del, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionDelete, }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, del.ID) }) } // TestResolveAutostartPreservesParameterMismatchOnSecretEvalError exercises // the handler's default switch arm: when EvaluateSecretMismatch returns a // non-ErrTemplateVersionNotReady error, the handler must log and treat // SecretMismatch as "unknown" without dropping the already-computed // ParameterMismatch signal. func TestResolveAutostartPreservesParameterMismatchOnSecretEvalError(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) // Wrap the DB so we can fail the renderer's GetTemplateVersionTerraformValues // call. Toggle is flipped on after setup so initial template version // processing succeeds. reject := &dbRejectTemplateVersionTerraformValues{Store: db} noRequirementsTF := []byte(`terraform { required_providers { coder = { source = "coder/coder" } } } `) setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ db: reject, ps: ps, provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: noRequirementsTF, }) _ = setup.stream.Close(websocket.StatusGoingAway) wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(req *codersdk.CreateWorkspaceRequest) { req.AutomaticUpdates = codersdk.AutomaticUpdatesAlways }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) // Push a v2 that adds a required-no-default parameter so resolve-autostart // computes ParameterMismatch=true. The new version becomes active via // DynamicParameterTemplate's UpdateActiveTemplateVersion call. paramRequiredTF := []byte(`terraform { required_providers { coder = { source = "coder/coder" } } } data "coder_parameter" "required_param" { name = "required_param" type = "string" } `) // StaticParams populates the legacy template_version_parameters table via // the GraphComplete response. resolve-autostart reads from this table to // determine ParameterMismatch. _, _ = coderdtest.DynamicParameterTemplate(t, setup.dynamicParamsClient, wrk.OrganizationID, coderdtest.DynamicParameterTemplateParams{ MainTF: string(paramRequiredTF), TemplateID: setup.template.ID, StaticParams: []*proto.RichParameter{{ Name: "required_param", Type: "string", Required: true, }}, }) // Arm the rejection only for the resolve-autostart call. Setup has // already completed, so all earlier calls passed through. reject.SetReject(true) ctx := testutil.Context(t, testutil.WaitLong) resp, err := setup.client.ResolveAutostart(ctx, wrk.ID.String()) require.NoError(t, err, "resolve-autostart must not 500 when secret evaluation fails") require.True(t, resp.ParameterMismatch, "ParameterMismatch should be preserved across secret evaluation failure") require.False(t, resp.SecretMismatch, "SecretMismatch should be unknown (false) when evaluation fails") } // TestResolveAutostartSecretRequirements is the PLAT-81 backend coverage: // resolve-autostart must surface coder_secret requirements declared by the // active template version that the workspace owner's secrets do not // satisfy. The dashboard banner uses this to tell the user autostart // cannot run the auto-update build until they create the missing secrets. func TestResolveAutostartSecretRequirements(t *testing.T) { t.Parallel() noRequirementsTF := []byte(`terraform { required_providers { coder = { source = "coder/coder" } } } `) secretRequiredTF, err := os.ReadFile("testdata/parameters/secret_required/main.tf") require.NoError(t, err) // v1 has no secret requirements; we need a workspace to exist so // resolve-autostart enters its version-mismatch branch. setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ provisionerDaemonVersion: provProto.CurrentVersion.String(), mainTF: noRequirementsTF, }) _ = setup.stream.Close(websocket.StatusGoingAway) wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(req *codersdk.CreateWorkspaceRequest) { req.AutomaticUpdates = codersdk.AutomaticUpdatesAlways }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) // Push v2 with a coder_secret requirement and make it active. _, _ = coderdtest.DynamicParameterTemplate(t, setup.dynamicParamsClient, wrk.OrganizationID, coderdtest.DynamicParameterTemplateParams{ MainTF: string(secretRequiredTF), TemplateID: setup.template.ID, }) t.Run("OwnerSeesMismatchThenSatisfies", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) // Owner has no GITHUB_TOKEN secret; resolve-autostart must surface // the unsatisfied requirement. resp, err := setup.client.ResolveAutostart(ctx, wrk.ID.String()) require.NoError(t, err) require.False(t, resp.ParameterMismatch) require.True(t, resp.SecretMismatch) // Creating the matching secret must clear the entry without further // template changes. _, err = setup.client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ Name: "github-token", Value: "ghp_test", EnvName: "GITHUB_TOKEN", }) require.NoError(t, err) resp, err = setup.client.ResolveAutostart(ctx, wrk.ID.String()) require.NoError(t, err) require.False(t, resp.ParameterMismatch) require.False(t, resp.SecretMismatch) }) t.Run("ForbiddenCallerSeesNoMismatch", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) // The template-admin client can read the workspace but lacks // user_secret:read on the workspace owner. The renderer's secret // fetch produces a forbidden diagnostic, and the handler must // treat that as "unknown" and report SecretMismatch=false rather // than leaking a 500 or a stale true value. resp, err := setup.dynamicParamsClient.ResolveAutostart(ctx, wrk.ID.String()) require.NoError(t, err) require.False(t, resp.SecretMismatch) }) } type setupDynamicParamsTestParams struct { db database.Store ps pubsub.Pubsub authorizer rbac.Authorizer provisionerDaemonVersion string mainTF []byte modulesArchive []byte plan []byte static []*proto.RichParameter expectWebsocketError bool variables []codersdk.TemplateVersionVariable } type dynamicParamsTest struct { client *codersdk.Client dynamicParamsClient *codersdk.Client api *coderd.API stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest] template codersdk.Template } func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest { ownerClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: args.db, Pubsub: args.ps, Authorizer: args.authorizer, IncludeProvisionerDaemon: true, ProvisionerDaemonVersion: args.provisionerDaemonVersion, }) owner := coderdtest.CreateFirstUser(t, ownerClient) templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) tpl, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, owner.OrganizationID, coderdtest.DynamicParameterTemplateParams{ MainTF: string(args.mainTF), Plan: args.plan, ModulesArchive: args.modulesArchive, StaticParams: args.static, Variables: args.variables, }) ctx := testutil.Context(t, testutil.WaitShort) stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID) if args.expectWebsocketError { require.Errorf(t, err, "expected error forming websocket") } else { require.NoError(t, err) } t.Cleanup(func() { if stream != nil { _ = stream.Close(websocket.StatusGoingAway) } // Cache should always have 0 files when the only stream is closed require.Eventually(t, func() bool { return api.FileCache.Count() == 0 }, testutil.WaitShort/5, testutil.IntervalMedium) }) return dynamicParamsTest{ client: ownerClient, dynamicParamsClient: templateAdmin, api: api, stream: stream, template: tpl, } } // dbRejectGitSSHKey is a cheeky way to force an error to occur in a place // that is generally impossible to force an error. type dbRejectGitSSHKey struct { database.Store rejectMu sync.RWMutex reject bool hook func(d *dbRejectGitSSHKey) } // SetReject toggles whether GetGitSSHKey should return an error or passthrough to the underlying store. func (d *dbRejectGitSSHKey) SetReject(reject bool) { d.rejectMu.Lock() defer d.rejectMu.Unlock() d.reject = reject } func (d *dbRejectGitSSHKey) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { if d.hook != nil { d.hook(d) } d.rejectMu.RLock() reject := d.reject d.rejectMu.RUnlock() if reject { return database.GitSSHKey{}, xerrors.New("forcing a fake error") } return d.Store.GetGitSSHKey(ctx, userID) } // dbRejectTemplateVersionTerraformValues wraps a Store so the dynamic // parameter renderer's GetTemplateVersionTerraformValues call can be made // to fail on demand. This forces the resolve-autostart handler into the // default switch arm where EvaluateSecretMismatch returns a non- // ErrTemplateVersionNotReady error. type dbRejectTemplateVersionTerraformValues struct { database.Store rejectMu sync.RWMutex reject bool } // SetReject toggles whether GetTemplateVersionTerraformValues should // return an error or passthrough to the underlying store. func (d *dbRejectTemplateVersionTerraformValues) SetReject(reject bool) { d.rejectMu.Lock() defer d.rejectMu.Unlock() d.reject = reject } func (d *dbRejectTemplateVersionTerraformValues) GetTemplateVersionTerraformValues(ctx context.Context, templateVersionID uuid.UUID) (database.TemplateVersionTerraformValue, error) { d.rejectMu.RLock() reject := d.reject d.rejectMu.RUnlock() if reject { return database.TemplateVersionTerraformValue{}, xerrors.New("forcing a fake error") } return d.Store.GetTemplateVersionTerraformValues(ctx, templateVersionID) }