From 9995a0c7921e367afcb57c1a64255f2168153740 Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Mon, 3 Jun 2024 12:07:05 -0500
Subject: [PATCH 1/4] chore: add unit test to delete workspace from suspended
 user

---
 coderd/coderdtest/oidctest/idp.go |  8 +++-
 coderd/externalauth_test.go       |  8 ++--
 coderd/workspacebuilds_test.go    | 76 +++++++++++++++++++++++++++++++
 3 files changed, 86 insertions(+), 6 deletions(-)

diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go
index c0b95619d46b7..8fff75e832fff 100644
--- a/coderd/coderdtest/oidctest/idp.go
+++ b/coderd/coderdtest/oidctest/idp.go
@@ -1255,7 +1255,7 @@ type ExternalAuthConfigOptions struct {
 	// ValidatePayload is the payload that is used when the user calls the
 	// equivalent of "userinfo" for oauth2. This is not standardized, so is
 	// different for each provider type.
-	ValidatePayload func(email string) interface{}
+	ValidatePayload func(email string) (interface{}, error)
 
 	// routes is more advanced usage. This allows the caller to
 	// completely customize the response. It captures all routes under the /external-auth-validate/*
@@ -1292,7 +1292,11 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu
 		case "/user", "/", "":
 			var payload interface{} = "OK"
 			if custom.ValidatePayload != nil {
-				payload = custom.ValidatePayload(email)
+				var err error
+				payload, err = custom.ValidatePayload(email)
+				if err != nil {
+					http.Error(rw, fmt.Sprintf("failed validation via custom method: %s", err.Error()), http.StatusBadRequest)
+				}
 			}
 			_ = json.NewEncoder(rw).Encode(payload)
 		default:
diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go
index db40ccf38a554..6d990421a4f2e 100644
--- a/coderd/externalauth_test.go
+++ b/coderd/externalauth_test.go
@@ -79,11 +79,11 @@ func TestExternalAuthByID(t *testing.T) {
 		client := coderdtest.New(t, &coderdtest.Options{
 			ExternalAuthConfigs: []*externalauth.Config{
 				fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{
-					ValidatePayload: func(_ string) interface{} {
+					ValidatePayload: func(_ string) (interface{}, error) {
 						return github.User{
 							Login:     github.String("kyle"),
 							AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
-						}
+						}, nil
 					},
 				}, func(cfg *externalauth.Config) {
 					cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
@@ -108,11 +108,11 @@ func TestExternalAuthByID(t *testing.T) {
 
 		// routes includes a route for /install that returns a list of installations
 		routes := (&oidctest.ExternalAuthConfigOptions{
-			ValidatePayload: func(_ string) interface{} {
+			ValidatePayload: func(_ string) (interface{}, error) {
 				return github.User{
 					Login:     github.String("kyle"),
 					AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
-				}
+				}, nil
 			},
 		}).AddRoute("/installs", func(_ string, rw http.ResponseWriter, r *http.Request) {
 			httpapi.Write(r.Context(), rw, http.StatusOK, struct {
diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go
index f8560ff911925..79ff32e0f2ca4 100644
--- a/coderd/workspacebuilds_test.go
+++ b/coderd/workspacebuilds_test.go
@@ -20,9 +20,11 @@ import (
 	"cdr.dev/slog/sloggers/slogtest"
 	"github.com/coder/coder/v2/coderd/audit"
 	"github.com/coder/coder/v2/coderd/coderdtest"
+	"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
 	"github.com/coder/coder/v2/coderd/database"
 	"github.com/coder/coder/v2/coderd/database/dbauthz"
 	"github.com/coder/coder/v2/coderd/database/dbtime"
+	"github.com/coder/coder/v2/coderd/externalauth"
 	"github.com/coder/coder/v2/coderd/rbac"
 	"github.com/coder/coder/v2/codersdk"
 	"github.com/coder/coder/v2/provisioner/echo"
@@ -711,6 +713,80 @@ func TestWorkspaceBuildStatus(t *testing.T) {
 	require.EqualValues(t, codersdk.WorkspaceStatusDeleted, workspace.LatestBuild.Status)
 }
 
+func TestWorkspaceDeleteSuspendedUser(t *testing.T) {
+	t.Parallel()
+	const providerID = "fake-github"
+	fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
+
+	validateCalls := 0
+	userSuspended := false
+	owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
+		IncludeProvisionerDaemon: true,
+		ExternalAuthConfigs: []*externalauth.Config{
+			fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{
+				ValidatePayload: func(email string) (interface{}, error) {
+					validateCalls++
+					if userSuspended {
+						// Simulate the user being suspended from the IDP too.
+						return "", fmt.Errorf("user is suspended")
+					}
+					return "OK", nil
+				},
+			}),
+		},
+	})
+	var _ = db
+
+	first := coderdtest.CreateFirstUser(t, owner)
+
+	// New user that we will suspend when we try to delete the workspace.
+	client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleTemplateAdmin())
+	fake.ExternalLogin(t, client)
+
+	version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, &echo.Responses{
+		Parse:          echo.ParseComplete,
+		ProvisionApply: echo.ApplyComplete,
+		ProvisionPlan: []*proto.Response{{
+			Type: &proto.Response_Plan{
+				Plan: &proto.PlanComplete{
+					Error:      "",
+					Resources:  nil,
+					Parameters: nil,
+					ExternalAuthProviders: []*proto.ExternalAuthProviderResource{
+						{
+							Id:       providerID,
+							Optional: false,
+						},
+					},
+				},
+			},
+		}},
+	})
+
+	validateCalls = 0 // Reset
+	coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+	template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
+	workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
+	coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
+	require.Equal(t, 1, validateCalls) // Ensure the external link is working
+	var _, _ = workspace, user
+
+	// Suspend the user
+	ctx := testutil.Context(t, testutil.WaitLong)
+	_, err := owner.UpdateUserStatus(ctx, user.ID.String(), codersdk.UserStatusSuspended)
+	require.NoError(t, err, "suspend user")
+
+	// Now delete the workspace build
+	userSuspended = true
+	build, err := owner.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
+		Transition: codersdk.WorkspaceTransitionDelete,
+	})
+	require.NoError(t, err)
+	build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
+	require.Equal(t, 2, validateCalls)
+	require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status)
+}
+
 func TestWorkspaceBuildDebugMode(t *testing.T) {
 	t.Parallel()
 

From 36340e6fa62b33527d45ac51182399be1069b0b7 Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Mon, 3 Jun 2024 12:17:14 -0500
Subject: [PATCH 2/4] chore: account for forbidden as well as unauthorized
 response codes

---
 coderd/coderdtest/oidctest/idp.go   | 17 ++++++++++++++---
 coderd/externalauth/externalauth.go |  2 +-
 coderd/externalauth_test.go         |  8 ++++----
 coderd/workspacebuilds_test.go      |  8 ++++----
 4 files changed, 23 insertions(+), 12 deletions(-)

diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go
index 8fff75e832fff..844c4df1d2664 100644
--- a/coderd/coderdtest/oidctest/idp.go
+++ b/coderd/coderdtest/oidctest/idp.go
@@ -1255,7 +1255,9 @@ type ExternalAuthConfigOptions struct {
 	// ValidatePayload is the payload that is used when the user calls the
 	// equivalent of "userinfo" for oauth2. This is not standardized, so is
 	// different for each provider type.
-	ValidatePayload func(email string) (interface{}, error)
+	//
+	// The int,error payload can control the response if set.
+	ValidatePayload func(email string) (interface{}, int, error)
 
 	// routes is more advanced usage. This allows the caller to
 	// completely customize the response. It captures all routes under the /external-auth-validate/*
@@ -1293,10 +1295,19 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu
 			var payload interface{} = "OK"
 			if custom.ValidatePayload != nil {
 				var err error
-				payload, err = custom.ValidatePayload(email)
+				var code int
+				payload, code, err = custom.ValidatePayload(email)
+				if code == 0 && err == nil {
+					code = http.StatusOK
+				}
+				if code == 0 && err != nil {
+					code = http.StatusUnauthorized
+				}
 				if err != nil {
-					http.Error(rw, fmt.Sprintf("failed validation via custom method: %s", err.Error()), http.StatusBadRequest)
+					http.Error(rw, fmt.Sprintf("failed validation via custom method: %s", err.Error()), code)
+					return
 				}
+				rw.WriteHeader(code)
 			}
 			_ = json.NewEncoder(rw).Encode(payload)
 		default:
diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go
index 4852de3e860ce..b626a5e28fb1f 100644
--- a/coderd/externalauth/externalauth.go
+++ b/coderd/externalauth/externalauth.go
@@ -218,7 +218,7 @@ func (c *Config) ValidateToken(ctx context.Context, link *oauth2.Token) (bool, *
 		return false, nil, err
 	}
 	defer res.Body.Close()
-	if res.StatusCode == http.StatusUnauthorized {
+	if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
 		// The token is no longer valid!
 		return false, nil, nil
 	}
diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go
index 6d990421a4f2e..5cee8c3d1cb8f 100644
--- a/coderd/externalauth_test.go
+++ b/coderd/externalauth_test.go
@@ -79,11 +79,11 @@ func TestExternalAuthByID(t *testing.T) {
 		client := coderdtest.New(t, &coderdtest.Options{
 			ExternalAuthConfigs: []*externalauth.Config{
 				fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{
-					ValidatePayload: func(_ string) (interface{}, error) {
+					ValidatePayload: func(_ string) (interface{}, int, error) {
 						return github.User{
 							Login:     github.String("kyle"),
 							AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
-						}, nil
+						}, 0, nil
 					},
 				}, func(cfg *externalauth.Config) {
 					cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
@@ -108,11 +108,11 @@ func TestExternalAuthByID(t *testing.T) {
 
 		// routes includes a route for /install that returns a list of installations
 		routes := (&oidctest.ExternalAuthConfigOptions{
-			ValidatePayload: func(_ string) (interface{}, error) {
+			ValidatePayload: func(_ string) (interface{}, int, error) {
 				return github.User{
 					Login:     github.String("kyle"),
 					AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
-				}, nil
+				}, 0, nil
 			},
 		}).AddRoute("/installs", func(_ string, rw http.ResponseWriter, r *http.Request) {
 			httpapi.Write(r.Context(), rw, http.StatusOK, struct {
diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go
index 79ff32e0f2ca4..339990c7643b3 100644
--- a/coderd/workspacebuilds_test.go
+++ b/coderd/workspacebuilds_test.go
@@ -724,13 +724,13 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) {
 		IncludeProvisionerDaemon: true,
 		ExternalAuthConfigs: []*externalauth.Config{
 			fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{
-				ValidatePayload: func(email string) (interface{}, error) {
+				ValidatePayload: func(email string) (interface{}, int, error) {
 					validateCalls++
 					if userSuspended {
 						// Simulate the user being suspended from the IDP too.
-						return "", fmt.Errorf("user is suspended")
+						return "", http.StatusForbidden, fmt.Errorf("user is suspended")
 					}
-					return "OK", nil
+					return "OK", 0, nil
 				},
 			}),
 		},
@@ -782,7 +782,7 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) {
 		Transition: codersdk.WorkspaceTransitionDelete,
 	})
 	require.NoError(t, err)
-	build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
+	build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, owner, build.ID)
 	require.Equal(t, 2, validateCalls)
 	require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status)
 }

From 4a87dc0d3d38eba655555f304046d86cbe5b3484 Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Mon, 3 Jun 2024 12:22:42 -0500
Subject: [PATCH 3/4] linting

---
 coderd/workspacebuilds_test.go | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go
index 339990c7643b3..eb76239b84658 100644
--- a/coderd/workspacebuilds_test.go
+++ b/coderd/workspacebuilds_test.go
@@ -720,7 +720,7 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) {
 
 	validateCalls := 0
 	userSuspended := false
-	owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
+	owner := coderdtest.New(t, &coderdtest.Options{
 		IncludeProvisionerDaemon: true,
 		ExternalAuthConfigs: []*externalauth.Config{
 			fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{
@@ -735,7 +735,6 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) {
 			}),
 		},
 	})
-	var _ = db
 
 	first := coderdtest.CreateFirstUser(t, owner)
 
@@ -769,7 +768,6 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) {
 	workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
 	coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
 	require.Equal(t, 1, validateCalls) // Ensure the external link is working
-	var _, _ = workspace, user
 
 	// Suspend the user
 	ctx := testutil.Context(t, testutil.WaitLong)

From e31dd4543a57d9a04ad97a501d11450df9be863d Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Mon, 3 Jun 2024 12:55:08 -0500
Subject: [PATCH 4/4] fixup test status code

---
 coderd/externalauth_test.go | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go
index 5cee8c3d1cb8f..916a88460d53c 100644
--- a/coderd/externalauth_test.go
+++ b/coderd/externalauth_test.go
@@ -556,7 +556,7 @@ func TestExternalAuthCallback(t *testing.T) {
 		// If the validation URL gives a non-OK status code, this
 		// should be treated as an internal server error.
 		srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			w.WriteHeader(http.StatusForbidden)
+			w.WriteHeader(http.StatusBadRequest)
 			w.Write([]byte("Something went wrong!"))
 		})
 		_, err = agentClient.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
@@ -565,7 +565,7 @@ func TestExternalAuthCallback(t *testing.T) {
 		var apiError *codersdk.Error
 		require.ErrorAs(t, err, &apiError)
 		require.Equal(t, http.StatusInternalServerError, apiError.StatusCode())
-		require.Equal(t, "validate external auth token: status 403: body: Something went wrong!", apiError.Detail)
+		require.Equal(t, "validate external auth token: status 400: body: Something went wrong!", apiError.Detail)
 	})
 
 	t.Run("ExpiredNoRefresh", func(t *testing.T) {